commit 08c2c1a5b612dbfd48485612ad217740b9277bbb Author: SA Date: Thu Jul 4 21:57:05 2024 +0200 squash diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..305b559 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +test/ +storage/ +dist/ +client/dev/ +client/node_modules/ +__pycache__ +venv +.env +.env.* +.git +.gitignore +Dockerfile +Dockerfile* +docker-compose* +.dockerignore +*.code-workspace +.vscode +.idea +node_modules +npm-debug.log +README.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5954b59 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +https://EditorConfig.org + +root = true + +[*.py] +indent_style = space +indent_size = 4 + +[*.html] +indent_style = space +indent_size = 2 + +[*.svg] +indent_style = space +indent_size = 2 + +[*.js] +indent_style = space +indent_size = 2 + +[*.scss] +indent_style = space +indent_size = 2 diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..533d94a --- /dev/null +++ b/.flake8 @@ -0,0 +1,18 @@ +[flake8] +ignore = + # Some template strings can easily go over the limit + E501 + # Doesn't work well with __init__.py files + F401 + # flake struggles with endpoints returning tuples on exceptions + E722 +exclude = + .git, + __pycache__, + venv +max-complexity = 14 + +[pycodestyle] +max_line_length = 120 +ignore = + E501 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94a0099 --- /dev/null +++ b/.gitignore @@ -0,0 +1,166 @@ +# Kemono. +.DS_Store +tusker.toml +config.json +flask.cfg +/config.py +redis_map.py + +# Dev only files +test/ +.idea +dev_* + +# Webpack dev server temp files +client/dev + +# Dev file server +storage/ + +# Javascript packages +node_modules + +# VSCode workspace +*.code-workspace + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments + +.env +.env.vpn +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +# include client `env` folder +!client/src/env + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0a2c97d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 38b88246ccc552bffaaf54259d064beeee434539 # frozen: v4.0.1 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - repo: https://github.com/pycqa/flake8 + rev: "cbeb4c9c4137cff1568659fcc48e8b85cddd0c8d" # frozen: 4.0.1 + hooks: + - id: flake8 + - repo: https://github.com/pre-commit/mirrors-autopep8 + rev: "7d14f78422aef2153a90e33373d2515bcc99038d" # frozen: v1.5.7 + hooks: + - id: autopep8 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0cb1a1a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM nikolaik/python-nodejs:python3.12-nodejs18 + +RUN apt-get update && apt-get install -y libpq-dev curl jq + +RUN curl -s -L $(curl https://api.github.com/repos/tus/tusd/releases/latest -s | jq '.assets[] | select(.name=="tusd_linux_amd64.tar.gz") | .browser_download_url' -r) | tar -xzvf - -C /usr/local/bin/ --strip-components=1 tusd_linux_amd64/tusd && chmod +x /usr/local/bin/tusd + +WORKDIR /app + +COPY requirements.txt requirements.txt +RUN pip3 install -r requirements.txt + +RUN npm install -g npm + +RUN mkdir client +COPY ./client /app/client + +RUN cd client && npm ci --also=dev && cd .. + +COPY . /app + +ENV LANG=C.UTF-8 +ARG GIT_COMMIT_HASH +ENV GIT_COMMIT_HASH=${GIT_COMMIT_HASH:-undefined} + +CMD python -m src daemon diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..29ebfa5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0760bad --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ + + +# Kemono Project + +_Frontend designed for Paysite leaking._ + +[![Button Website]][Website] + +[![Button Setup]](#setup)    +[![Button FAQ]][FAQ] + +[![Button Develop]][Develop] + + + +## Setup + +_How to use this project for yourself._ + +1. Clone the repository and switch to its folder. + + ```sh + git clone https://code.kemono.su/Kemono2 + cd Kemono2 + ``` + +[Website]: https://kemono.party/ +[Develop]: docs/Develop.md +[FAQ]: docs/FAQ.md + + + +[Button Website]: https://img.shields.io/badge/Website-e6702f?style=for-the-badge&logoColor=white&logo=FirefoxBrowser +[Button Develop]: https://img.shields.io/badge/Develop-3955A3?style=for-the-badge&logoColor=white&logo=VisualStudioCode +[Button Setup]: https://img.shields.io/badge/Setup-3EAAAF?style=for-the-badge&logoColor=white&logo=GitBook +[Button FAQ]: https://img.shields.io/badge/FAQ-569A31?style=for-the-badge&logoColor=white&logo=AskUbuntu diff --git a/client/.dockerignore b/client/.dockerignore new file mode 100644 index 0000000..f3c73a0 --- /dev/null +++ b/client/.dockerignore @@ -0,0 +1,29 @@ +# webpack output +**/dev +**/dist + +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.code-workspace +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/charts +**/docker-compose* +**/compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +README.md diff --git a/client/.vscode/extensions.json b/client/.vscode/extensions.json new file mode 100644 index 0000000..1612a87 --- /dev/null +++ b/client/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": [] +} diff --git a/client/.vscode/settings.json b/client/.vscode/settings.json new file mode 100644 index 0000000..6a2ff3b --- /dev/null +++ b/client/.vscode/settings.json @@ -0,0 +1,31 @@ +{ + "files.exclude": { + "node_modules": true + }, + // this option does work and is required for emmet in jinja to work + "files.associations": { + "*.html": "jinja-html" + }, + "emmet.includeLanguages": { + "jinja-html": "html" + }, + "search.exclude": { + "**/node_modules": true, + "**/bower_components": true, + "**/*.code-search": true, + "**/dev": true, + "**/dist": true + }, + "javascript.preferences.importModuleSpecifierEnding": "js", + "javascript.preferences.quoteStyle": "double", + "javascript.format.semicolons": "insert", + "[jinja-html]": { + "editor.tabSize": 2 + }, + "[javascript]": { + "editor.tabSize": 2 + }, + "[scss]": { + "editor.tabSize": 2 + } +} diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 0000000..f0a5c79 --- /dev/null +++ b/client/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 + +FROM node:16.14 + +ENV NODE_ENV=production + +WORKDIR /app + +COPY ["package.json", "package-lock.json", "/app/"] + +RUN npm install -g npm +RUN npm ci --also=dev + +COPY . /app + +CMD [ "npm", "run", "build" ] \ No newline at end of file diff --git a/client/Dockerfile.dev b/client/Dockerfile.dev new file mode 100644 index 0000000..d6beb4e --- /dev/null +++ b/client/Dockerfile.dev @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 + +FROM node:12.22 + +ENV NODE_ENV=development + +WORKDIR /app + +COPY ["package.json", "package-lock.json*", "./"] + +RUN npm install + +COPY . . + +CMD ["npm", "run", "dev"] diff --git a/client/configs/build-templates.js b/client/configs/build-templates.js new file mode 100644 index 0000000..49f560b --- /dev/null +++ b/client/configs/build-templates.js @@ -0,0 +1,92 @@ +const path = require("path"); +const fse = require("fs-extra"); +const HTMLWebpackPlugin = require("html-webpack-plugin"); + +/** + * @typedef BuildOptions + * @property {string} fileExtension + * @property {string} outputPrefix + * @property {HTMLWebpackPlugin.Options} pluginOptions Webpack plugin options. + */ + +/** */ +class TemplateFile { + /** + * @param {fse.Dirent} dirent + * @param {string} path Absolute path to the file. + */ + constructor(dirent, path) { + this.dirent = dirent; + this.path = path; + } +} + +/** + * Builds an array of HTML webpack plugins from the provided folder. + * @param {string} basePath Absolute path to the template folder. + * @param {BuildOptions} options Build optons. + */ +function buildHTMLWebpackPluginsRecursive(basePath, options) { + /** + * @type {HTMLWebpackPlugin[]} + */ + const plugins = []; + const files = walkFolder(basePath); + + files.forEach((file) => { + const isTemplateFile = file.dirent.isFile() && file.path.endsWith(`${options.fileExtension}`); + + if (isTemplateFile) { + const outputBase = path.relative(basePath, file.path); + const outputPath = path.join(path.basename(basePath), outputBase); + + const webpackPlugin = new HTMLWebpackPlugin({ + ...options.pluginOptions, + template: file.path, + filename: outputPath, + }); + + plugins.push(webpackPlugin); + } + }); + + return plugins; +} + +/** + * @param {string} folderPath Absolute path to the folder. + * @param {TemplateFile[]} files + */ +function walkFolder(folderPath, files = [], currentCount = 0) { + const nestedLimit = 1000; + const folderContents = fse.readdirSync(folderPath, { withFileTypes: true }); + + folderContents.forEach((entry) => { + const file = entry.isFile() && entry; + const folder = entry.isDirectory() && entry; + + if (file) { + const filePath = path.join(folderPath, file.name); + files.push(new TemplateFile(file, filePath)); + return; + } + + if (folder) { + currentCount++; + + if (currentCount > nestedLimit) { + throw new Error(`The folder at "${folderPath}" contains more than ${nestedLimit} folders.`); + } + + const newFolderPath = path.join(folderPath, folder.name); + + return walkFolder(newFolderPath, files, currentCount); + } + }); + + return files; +} + +module.exports = { + buildHTMLWebpackPluginsRecursive, +}; diff --git a/client/configs/emmet/snippets.json b/client/configs/emmet/snippets.json new file mode 100644 index 0000000..8ec5f82 --- /dev/null +++ b/client/configs/emmet/snippets.json @@ -0,0 +1,5 @@ +{ + "html": { + "snippets": {} + } +} diff --git a/client/configs/vars.js b/client/configs/vars.js new file mode 100644 index 0000000..997dea1 --- /dev/null +++ b/client/configs/vars.js @@ -0,0 +1,21 @@ +const path = require("path"); + +require("dotenv").config({ + path: path.resolve(__dirname, "..", ".."), +}); + +const kemonoSite = process.env.KEMONO_SITE || "http://localhost:5000"; +const nodeEnv = process.env.NODE_ENV || "production"; +const iconsPrepend = process.env.ICONS_PREPEND || ""; +const bannersPrepend = process.env.BANNERS_PREPEND || ""; +const thumbnailsPrepend = process.env.THUMBNAILS_PREPEND || ""; +const creatorsLocation = process.env.CREATORS_LOCATION || ""; + +module.exports = { + kemonoSite, + nodeEnv, + iconsPrepend, + bannersPrepend, + thumbnailsPrepend, + creatorsLocation, +}; diff --git a/client/jsconfig.json b/client/jsconfig.json new file mode 100644 index 0000000..5c02774 --- /dev/null +++ b/client/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "module": "commonJS", + "target": "es2015", + "moduleResolution": "node" + }, + "exclude": ["node_modules", "dist", "dev", "src"] +} diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..6492016 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,9299 @@ +{ + "name": "kemono-2-client", + "version": "0.2.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kemono-2-client", + "version": "0.2.1", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.22.10", + "@uppy/core": "^3.4.0", + "@uppy/dashboard": "^3.5.1", + "@uppy/form": "^3.0.2", + "@uppy/tus": "^3.1.3", + "diff": "^5.1.0", + "fluid-player": "^3.22.0", + "micromodal": "^0.4.10", + "purecss": "^3.0.0", + "sha256-wasm": "^2.2.2", + "whatwg-fetch": "^3.6.17" + }, + "devDependencies": { + "@babel/core": "^7.22.10", + "@babel/plugin-transform-runtime": "^7.22.10", + "@babel/preset-env": "^7.22.10", + "babel-loader": "^8.3.0", + "buffer": "^6.0.3", + "copy-webpack-plugin": "^8.1.1", + "css-loader": "^5.2.7", + "dotenv": "^8.6.0", + "fs-extra": "^10.1.0", + "html-webpack-plugin": "^5.5.3", + "mini-css-extract-plugin": "^1.6.2", + "postcss": "^8.4.28", + "postcss-loader": "^7.3.3", + "postcss-preset-env": "^9.1.1", + "rimraf": "^3.0.2", + "sass": "^1.66.0", + "sass-loader": "^11.1.1", + "stream-browserify": "^3.0.0", + "style-loader": "^2.0.0", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1", + "webpack-manifest-plugin": "^5.0.0", + "webpack-merge": "^5.9.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz", + "integrity": "sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.22.10", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.22.9", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.22.9.tgz", + "integrity": "sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.22.10.tgz", + "integrity": "sha512-fTmqbbUBAwCcre6zPzNngvsI0aNrPZe77AeqvDxWM9Nm+04RrJ3CAmGHA9f7lJQY6ZMhRztNemy4uslDxTX4Qw==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.22.10", + "@babel/generator": "^7.22.10", + "@babel/helper-compilation-targets": "^7.22.10", + "@babel/helper-module-transforms": "^7.22.9", + "@babel/helpers": "^7.22.10", + "@babel/parser": "^7.22.10", + "@babel/template": "^7.22.5", + "@babel/traverse": "^7.22.10", + "@babel/types": "^7.22.10", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.2", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz", + "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.10", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", + "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.10.tgz", + "integrity": "sha512-Av0qubwDQxC56DoUReVDeLfMEjYYSN1nZrTUrWkXd7hpU73ymRANkbuDm3yni9npkn+RXy9nNbEJZEzXr7xrfQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.10" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.10.tgz", + "integrity": "sha512-JMSwHD4J7SLod0idLq5PKgI+6g/hLD/iuWBq08ZX49xE14VpVEojJ5rHWptpirV2j020MvypRLAXAO50igCJ5Q==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.5", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.10.tgz", + "integrity": "sha512-5IBb77txKYQPpOEdUdIhBx8VrZyDCQ+H82H0+5dX1TmuscP5vJKEE3cKurjtIw/vFwzbVH48VweE78kVDBrqjA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-function-name": "^7.22.5", + "@babel/helper-member-expression-to-functions": "^7.22.5", + "@babel/helper-optimise-call-expression": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.22.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.9.tgz", + "integrity": "sha512-+svjVa/tFwsNSG4NEy1h85+HQ5imbT92Q5/bgtS7P0GTQlP8WuFdqsiABmQouhiFGyV66oGxZFpeYHza1rNsKw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "regexpu-core": "^5.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.2.tgz", + "integrity": "sha512-k0qnnOqHn5dK9pZpfD5XXZ9SojAITdCKRn2Lp6rnDGzIbaP0rHyMPk/4wsSxVBVz4RfN0q6VpXWP2pDGIoQ7hw==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.5.tgz", + "integrity": "sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.22.5.tgz", + "integrity": "sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.5", + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.5.tgz", + "integrity": "sha512-aBiH1NKMG0H2cGZqspNvsaBe6wNGjbJjuLy29aU+eDZjSbbN53BaxlpB02xm9v34pLTZ1nIQPFYn2qMZoa5BQQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz", + "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.22.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz", + "integrity": "sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", + "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.22.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.22.9.tgz", + "integrity": "sha512-8WWC4oR4Px+tr+Fp0X3RHDVfINGpF3ad1HIbrc8A77epiR6eMMc6jsgozkzT2uDiOOdoS9cLIQ+XD2XvI2WSmQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-wrap-function": "^7.22.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.22.9", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.9.tgz", + "integrity": "sha512-LJIKvvpgPOPUThdYqcX6IXRuIcTkcAub0IaDRGCZH0p5GPUp7PhRU9QVgFcDDd51BaPkk77ZjqFwh6DZTAEmGg==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-member-expression-to-functions": "^7.22.5", + "@babel/helper-optimise-call-expression": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", + "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", + "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz", + "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.22.10.tgz", + "integrity": "sha512-OnMhjWjuGYtdoO3FmsEFWvBStBAe2QOgwOLsLNDjN+aaiMD8InJk1/O3HSD8lkqTjCgg5YI34Tz15KNNA3p+nQ==", + "dev": true, + "dependencies": { + "@babel/helper-function-name": "^7.22.5", + "@babel/template": "^7.22.5", + "@babel/types": "^7.22.10" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.22.10.tgz", + "integrity": "sha512-a41J4NW8HyZa1I1vAndrraTlPZ/eZoga2ZgS7fEr0tZJGVU4xqdE80CEm0CcNjha5EZ8fTBYLKHF0kqDUuAwQw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.5", + "@babel/traverse": "^7.22.10", + "@babel/types": "^7.22.10" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.10.tgz", + "integrity": "sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.5", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.10.tgz", + "integrity": "sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.22.5.tgz", + "integrity": "sha512-NP1M5Rf+u2Gw9qfSO4ihjcTGW5zXTi36ITLd4/EoAcEhIZ0yjMqmftDNl3QC19CX7olhrjpyU454g/2W7X0jvQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.5.tgz", + "integrity": "sha512-31Bb65aZaUwqCbWMnZPduIZxCBngHFlzyN6Dq6KAJjtx+lx6ohKHubc61OomYi7XwVD4Ol0XCVz4h+pYFR048g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-transform-optional-chaining": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.22.5.tgz", + "integrity": "sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.5.tgz", + "integrity": "sha512-KwvoWDeNKPETmozyFE0P2rOLqh39EoQHNjqizrI5B8Vt0ZNS7M56s7dAiAqbYfiAYOuIzIh96z3iR2ktgu3tEg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.22.5.tgz", + "integrity": "sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.10.tgz", + "integrity": "sha512-eueE8lvKVzq5wIObKK/7dvoeKJ+xc6TvRn6aysIjS6pSCeLy7S/eVi7pEQknZqyqvzaNKdDtem8nUNTBgDVR2g==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-remap-async-to-generator": "^7.22.9", + "@babel/plugin-syntax-async-generators": "^7.8.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.22.5.tgz", + "integrity": "sha512-b1A8D8ZzE/VhNDoV1MSJTnpKkCG5bJo+19R4o4oy03zM7ws8yEMK755j61Dc3EyvdysbqH5BOOTquJ7ZX9C6vQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-remap-async-to-generator": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.22.5.tgz", + "integrity": "sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.22.10.tgz", + "integrity": "sha512-1+kVpGAOOI1Albt6Vse7c8pHzcZQdQKW+wJH+g8mCaszOdDVwRXa/slHPqIw+oJAJANTKDMuM2cBdV0Dg618Vg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.5.tgz", + "integrity": "sha512-nDkQ0NfkOhPTq8YCLiWNxp1+f9fCobEjCb0n8WdbNUBc4IB5V7P1QnX9IjpSoquKrXF5SKojHleVNs2vGeHCHQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.5.tgz", + "integrity": "sha512-SPToJ5eYZLxlnp1UzdARpOGeC2GbHvr9d/UV0EukuVx8atktg194oe+C5BqQ8jRTkgLRVOPYeXRSBg1IlMoVRA==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.22.6.tgz", + "integrity": "sha512-58EgM6nuPNG6Py4Z3zSuu0xWu2VfodiMi72Jt5Kj2FECmaYk1RrTXA45z6KBFsu9tRgwQDwIiY4FXTt+YsSFAQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-function-name": "^7.22.5", + "@babel/helper-optimise-call-expression": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.22.5.tgz", + "integrity": "sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/template": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.22.10.tgz", + "integrity": "sha512-dPJrL0VOyxqLM9sritNbMSGx/teueHF/htMKrPT7DNxccXxRDPYqlgPFFdr8u+F+qUZOkZoXue/6rL5O5GduEw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.22.5.tgz", + "integrity": "sha512-5/Yk9QxCQCl+sOIB1WelKnVRxTJDSAIxtJLL2/pqL14ZVlbH0fUQUZa/T5/UnQtBNgghR7mfB8ERBKyKPCi7Vw==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.22.5.tgz", + "integrity": "sha512-dEnYD+9BBgld5VBXHnF/DbYGp3fqGMsyxKbtD1mDyIA7AkTSpKXFhCVuj/oQVOoALfBs77DudA0BE4d5mcpmqw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.5.tgz", + "integrity": "sha512-0MC3ppTB1AMxd8fXjSrbPa7LT9hrImt+/fcj+Pg5YMD7UQyWp/02+JWpdnCymmsXwIx5Z+sYn1bwCn4ZJNvhqQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.22.5.tgz", + "integrity": "sha512-vIpJFNM/FjZ4rh1myqIya9jXwrwwgFRHPjT3DkUA9ZLHuzox8jiXkOLvwm1H+PQIP3CqfC++WPKeuDi0Sjdj1g==", + "dev": true, + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.5.tgz", + "integrity": "sha512-X4hhm7FRnPgd4nDA4b/5V280xCx6oL7Oob5+9qVS5C13Zq4bh1qq7LU0GgRU6b5dBWBvhGaXYVB4AcN6+ol6vg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.22.5.tgz", + "integrity": "sha512-3kxQjX1dU9uudwSshyLeEipvrLjBCVthCgeTp6CzE/9JYrlAIaeekVxRpCWsDDfYTfRZRoCeZatCQvwo+wvK8A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.22.5.tgz", + "integrity": "sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.5", + "@babel/helper-function-name": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.5.tgz", + "integrity": "sha512-DuCRB7fu8MyTLbEQd1ew3R85nx/88yMoqo2uPSjevMj3yoN7CDM8jkgrY0wmVxfJZyJ/B9fE1iq7EQppWQmR5A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-json-strings": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.22.5.tgz", + "integrity": "sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.5.tgz", + "integrity": "sha512-MQQOUW1KL8X0cDWfbwYP+TbVbZm16QmQXJQ+vndPtH/BoO0lOKpVoEDMI7+PskYxH+IiE0tS8xZye0qr1lGzSA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.22.5.tgz", + "integrity": "sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.22.5.tgz", + "integrity": "sha512-R+PTfLTcYEmb1+kK7FNkhQ1gP4KgjpSO6HfH9+f8/yfp2Nt3ggBjiVpRwmwTlfqZLafYKJACy36yDXlEmI9HjQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.5.tgz", + "integrity": "sha512-B4pzOXj+ONRmuaQTg05b3y/4DuFz3WcCNAXPLb2Q0GT0TrGKGxNKV4jwsXts+StaM0LQczZbOpj8o1DLPDJIiA==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-simple-access": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.5.tgz", + "integrity": "sha512-emtEpoaTMsOs6Tzz+nbmcePl6AKVtS1yC4YNAeMun9U8YCsgadPNxnOPQ8GhHFB2qdx+LZu9LgoC0Lthuu05DQ==", + "dev": true, + "dependencies": { + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-module-transforms": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.22.5.tgz", + "integrity": "sha512-+S6kzefN/E1vkSsKx8kmQuqeQsvCKCd1fraCM7zXm4SFoggI099Tr4G8U81+5gtMdUeMQ4ipdQffbKLX0/7dBQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.5.tgz", + "integrity": "sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.5.tgz", + "integrity": "sha512-AsF7K0Fx/cNKVyk3a+DW0JLo+Ua598/NxMRvxDnkpCIGFh43+h/v2xyhRUYf6oD8gE4QtL83C7zZVghMjHd+iw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.5.tgz", + "integrity": "sha512-6CF8g6z1dNYZ/VXok5uYkkBBICHZPiGEl7oDnAx2Mt1hlHVHOSIKWJaXHjQJA5VB43KZnXZDIexMchY4y2PGdA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.5.tgz", + "integrity": "sha512-NbslED1/6M+sXiwwtcAB/nieypGw02Ejf4KtDeMkCEpP6gWFMX1wI9WKYua+4oBneCCEmulOkRpwywypVZzs/g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.5.tgz", + "integrity": "sha512-Kk3lyDmEslH9DnvCDA1s1kkd3YWQITiBOHngOtDL9Pt6BZjzqb6hiOlb8VfjiiQJ2unmegBqZu0rx5RxJb5vmQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.5", + "@babel/helper-compilation-targets": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.22.5.tgz", + "integrity": "sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-replace-supers": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.5.tgz", + "integrity": "sha512-pH8orJahy+hzZje5b8e2QIlBWQvGpelS76C63Z+jhZKsmzfNaPQ+LaW6dcJ9bxTpo1mtXbgHwy765Ro3jftmUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.10.tgz", + "integrity": "sha512-MMkQqZAZ+MGj+jGTG3OTuhKeBpNcO+0oCEbrGNEaOmiEn+1MzRyQlYsruGiU8RTK3zV6XwrVJTmwiDOyYK6J9g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.5.tgz", + "integrity": "sha512-AVkFUBurORBREOmHRKo06FjHYgjrabpdqRSwq6+C7R5iTCZOsM4QbcB27St0a4U6fffyAOqh3s/qEfybAhfivg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.5.tgz", + "integrity": "sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.5.tgz", + "integrity": "sha512-/9xnaTTJcVoBtSSmrVyhtSvO3kbqS2ODoh2juEU72c3aYonNF0OMGiaz2gjukyKM2wBBYJP38S4JiE0Wfb5VMQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.22.5", + "@babel/helper-create-class-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.22.5.tgz", + "integrity": "sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.10.tgz", + "integrity": "sha512-F28b1mDt8KcT5bUyJc/U9nwzw6cV+UmTeRlXYIl2TNqMMJif0Jeey9/RQ3C4NOd2zp0/TRsDns9ttj2L523rsw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.22.5.tgz", + "integrity": "sha512-DTtGKFRQUDm8svigJzZHzb/2xatPc6TzNvAIJ5GqOKDsGFYgAskjRulbR/vGsPKq3OPqtexnz327qYpP57RFyA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.22.10.tgz", + "integrity": "sha512-RchI7HePu1eu0CYNKHHHQdfenZcM4nz8rew5B1VWqeRKdcwW5aQ5HeG9eTUbWiAS1UrmHVLmoxTWHt3iLD/NhA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5", + "babel-plugin-polyfill-corejs2": "^0.4.5", + "babel-plugin-polyfill-corejs3": "^0.8.3", + "babel-plugin-polyfill-regenerator": "^0.5.2", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.22.5.tgz", + "integrity": "sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.22.5.tgz", + "integrity": "sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.22.5.tgz", + "integrity": "sha512-zf7LuNpHG0iEeiyCNwX4j3gDg1jgt1k3ZdXBKbZSoA3BbGQGvMiSvfbZRR3Dr3aeJe3ooWFZxOOG3IRStYp2Bw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.22.5.tgz", + "integrity": "sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.22.5.tgz", + "integrity": "sha512-bYkI5lMzL4kPii4HHEEChkD0rkc+nvnlR6+o/qdqR6zrm0Sv/nodmyLhlq2DO0YKLUNd2VePmPRjJXSBh9OIdA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.10.tgz", + "integrity": "sha512-lRfaRKGZCBqDlRU3UIFovdp9c9mEvlylmpod0/OatICsSfuQ9YFthRo1tpTkGsklEefZdqlEFdY4A2dwTb6ohg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.5.tgz", + "integrity": "sha512-HCCIb+CbJIAE6sXn5CjFQXMwkCClcOfPCzTlilJ8cUatfzwHlWQkbtV0zD338u9dZskwvuOYTuuaMaA8J5EI5A==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.22.5.tgz", + "integrity": "sha512-028laaOKptN5vHJf9/Arr/HiJekMd41hOEZYvNsrsXqJ7YPYuX2bQxh31fkZzGmq3YqHRJzYFFAVYvKfMPKqyg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.5.tgz", + "integrity": "sha512-lhMfi4FC15j13eKrh3DnYHjpGj6UKQHtNKTbtc1igvAhRy4+kLhV07OpLcsN0VgDEw/MjAvJO4BdMJsHwMhzCg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.22.5", + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.22.10.tgz", + "integrity": "sha512-riHpLb1drNkpLlocmSyEg4oYJIQFeXAK/d7rI6mbD0XsvoTOOweXDmQPG/ErxsEhWk3rl3Q/3F6RFQlVFS8m0A==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-compilation-targets": "^7.22.10", + "@babel/helper-plugin-utils": "^7.22.5", + "@babel/helper-validator-option": "^7.22.5", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.22.5", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.22.5", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.22.5", + "@babel/plugin-syntax-import-attributes": "^7.22.5", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.22.5", + "@babel/plugin-transform-async-generator-functions": "^7.22.10", + "@babel/plugin-transform-async-to-generator": "^7.22.5", + "@babel/plugin-transform-block-scoped-functions": "^7.22.5", + "@babel/plugin-transform-block-scoping": "^7.22.10", + "@babel/plugin-transform-class-properties": "^7.22.5", + "@babel/plugin-transform-class-static-block": "^7.22.5", + "@babel/plugin-transform-classes": "^7.22.6", + "@babel/plugin-transform-computed-properties": "^7.22.5", + "@babel/plugin-transform-destructuring": "^7.22.10", + "@babel/plugin-transform-dotall-regex": "^7.22.5", + "@babel/plugin-transform-duplicate-keys": "^7.22.5", + "@babel/plugin-transform-dynamic-import": "^7.22.5", + "@babel/plugin-transform-exponentiation-operator": "^7.22.5", + "@babel/plugin-transform-export-namespace-from": "^7.22.5", + "@babel/plugin-transform-for-of": "^7.22.5", + "@babel/plugin-transform-function-name": "^7.22.5", + "@babel/plugin-transform-json-strings": "^7.22.5", + "@babel/plugin-transform-literals": "^7.22.5", + "@babel/plugin-transform-logical-assignment-operators": "^7.22.5", + "@babel/plugin-transform-member-expression-literals": "^7.22.5", + "@babel/plugin-transform-modules-amd": "^7.22.5", + "@babel/plugin-transform-modules-commonjs": "^7.22.5", + "@babel/plugin-transform-modules-systemjs": "^7.22.5", + "@babel/plugin-transform-modules-umd": "^7.22.5", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.22.5", + "@babel/plugin-transform-new-target": "^7.22.5", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.22.5", + "@babel/plugin-transform-numeric-separator": "^7.22.5", + "@babel/plugin-transform-object-rest-spread": "^7.22.5", + "@babel/plugin-transform-object-super": "^7.22.5", + "@babel/plugin-transform-optional-catch-binding": "^7.22.5", + "@babel/plugin-transform-optional-chaining": "^7.22.10", + "@babel/plugin-transform-parameters": "^7.22.5", + "@babel/plugin-transform-private-methods": "^7.22.5", + "@babel/plugin-transform-private-property-in-object": "^7.22.5", + "@babel/plugin-transform-property-literals": "^7.22.5", + "@babel/plugin-transform-regenerator": "^7.22.10", + "@babel/plugin-transform-reserved-words": "^7.22.5", + "@babel/plugin-transform-shorthand-properties": "^7.22.5", + "@babel/plugin-transform-spread": "^7.22.5", + "@babel/plugin-transform-sticky-regex": "^7.22.5", + "@babel/plugin-transform-template-literals": "^7.22.5", + "@babel/plugin-transform-typeof-symbol": "^7.22.5", + "@babel/plugin-transform-unicode-escapes": "^7.22.10", + "@babel/plugin-transform-unicode-property-regex": "^7.22.5", + "@babel/plugin-transform-unicode-regex": "^7.22.5", + "@babel/plugin-transform-unicode-sets-regex": "^7.22.5", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "@babel/types": "^7.22.10", + "babel-plugin-polyfill-corejs2": "^0.4.5", + "babel-plugin-polyfill-corejs3": "^0.8.3", + "babel-plugin-polyfill-regenerator": "^0.5.2", + "core-js-compat": "^3.31.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", + "dev": true + }, + "node_modules/@babel/runtime": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.10.tgz", + "integrity": "sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", + "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.5", + "@babel/parser": "^7.22.5", + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.10.tgz", + "integrity": "sha512-Q/urqV4pRByiNNpb/f5OSv28ZlGJiFiiTh+GAHktbIrkPhPbl90+uW6SmpoLyZqutrg9AEaEf3Q/ZBRHBXgxig==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.10", + "@babel/generator": "^7.22.10", + "@babel/helper-environment-visitor": "^7.22.5", + "@babel/helper-function-name": "^7.22.5", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.22.10", + "@babel/types": "^7.22.10", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.22.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.10.tgz", + "integrity": "sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.5", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/cascade-layer-name-parser": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.4.tgz", + "integrity": "sha512-zXMGsJetbLoXe+gjEES07MEGjL0Uy3hMxmnGtVBrRpVKr5KV9OgCB09zr/vLrsEtoVQTgJFewxaU8IYSAE4tjg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-3.0.0.tgz", + "integrity": "sha512-rBODd1rY01QcenD34QxbQxLc1g+Uh7z1X/uzTHNQzJUnFCT9/EZYI7KWq+j0YfWMXJsRJ8lVkqBcB0R/qLr+yg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-1.1.3.tgz", + "integrity": "sha512-7mJZ8gGRtSQfQKBQFi5N0Z+jzNC0q8bIkwojP1W0w+APzEqHu5wJoGVsvKxVnVklu9F8tW1PikbBRseYnAdv+g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-1.2.3.tgz", + "integrity": "sha512-YaEnCoPTdhE4lPQFH3dU4IEk8S+yCnxS88wMv45JzlnMfZp57hpqA6qf2gX8uv7IJTJ/43u6pTQmhy7hCjlz7g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^3.0.0", + "@csstools/css-calc": "^1.1.3" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.3.1.tgz", + "integrity": "sha512-xrvsmVUtefWMWQsGgFffqWSK03pZ1vfDki4IVIIUxxDKnGBzqNgv0A7SB1oXtVNEkcVO8xi1ZrTL29HhSu5kGA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^2.2.0" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.2.0.tgz", + "integrity": "sha512-wErmsWCbsmig8sQKkM6pFhr/oPha1bHfvxsUY5CYSQxwyhA9Ulrs8EqCgClhg4Tgg2XapVstGqSVcz0xOYizZA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.4.tgz", + "integrity": "sha512-V/OUXYX91tAC1CDsiY+HotIcJR+vPtzrX8pCplCpT++i8ThZZsq5F5dzZh/bDM3WUOjrvC1ljed1oSJxMfjqhw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0" + } + }, + "node_modules/@csstools/postcss-cascade-layers": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-4.0.0.tgz", + "integrity": "sha512-dVPVVqQG0FixjM9CG/+8eHTsCAxRKqmNh6H69IpruolPlnEF1611f2AoLK8TijTSAsqBSclKd4WHs1KUb/LdJw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/selector-specificity": "^3.0.0", + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-function": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-function/-/postcss-color-function-3.0.1.tgz", + "integrity": "sha512-+vrvCQeUifpMeyd42VQ3JPWGQ8cO19+TnGbtfq1SDSgZzRapCQO4aK9h/jhMOKPnxGzbA57oS0aHgP/12N9gSQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-color-parser": "^1.2.2", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/postcss-progressive-custom-properties": "^3.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-mix-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-2.0.1.tgz", + "integrity": "sha512-Z5cXkLiccKIVcUTe+fAfjUD7ZUv0j8rq3dSoBpM6I49dcw+50318eYrwUZa3nyb4xNx7ntNNUPmesAc87kPE2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-color-parser": "^1.2.2", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/postcss-progressive-custom-properties": "^3.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-exponential-functions": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-1.0.0.tgz", + "integrity": "sha512-FPndJ/7oGlML7/4EhLi902wGOukO0Nn37PjwOQGc0BhhjQPy3np3By4d3M8s9Cfmp9EHEKgUHRN2DQ5HLT/hTw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-calc": "^1.1.3", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-font-format-keywords": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-3.0.0.tgz", + "integrity": "sha512-ntkGj+1uDa/u6lpjPxnkPcjJn7ChO/Kcy08YxctOZI7vwtrdYvFhmE476dq8bj1yna306+jQ9gzXIG/SWfOaRg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-gradients-interpolation-method": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-4.0.1.tgz", + "integrity": "sha512-IHeFIcksjI8xKX7PWLzAyigM3UvJdZ4btejeNa7y/wXxqD5dyPPZuY55y8HGTrS6ETVTRqfIznoCPtTzIX7ygQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-color-parser": "^1.2.2", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/postcss-progressive-custom-properties": "^3.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-hwb-function": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-hwb-function/-/postcss-hwb-function-3.0.1.tgz", + "integrity": "sha512-FYe2K8EOYlL1BUm2HTXVBo6bWAj0xl4khOk6EFhQHy/C5p3rlr8OcetzQuwMeNQ3v25nB06QTgqUHoOUwoEqhA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-color-parser": "^1.2.2", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-ic-unit": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-ic-unit/-/postcss-ic-unit-3.0.0.tgz", + "integrity": "sha512-FH3+zfOfsgtX332IIkRDxiYLmgwyNk49tfltpC6dsZaO4RV2zWY6x9VMIC5cjvmjlDO7DIThpzqaqw2icT8RbQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^3.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-4.0.0.tgz", + "integrity": "sha512-0I6siRcDymG3RrkNTSvHDMxTQ6mDyYE8awkcaHNgtYacd43msl+4ZWDfQ1yZQ/viczVWjqJkLmPiRHSgxn5nZA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/selector-specificity": "^3.0.0", + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-float-and-clear": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-float-and-clear/-/postcss-logical-float-and-clear-2.0.0.tgz", + "integrity": "sha512-Wki4vxsF6icRvRz8eF9bPpAvwaAt0RHwhVOyzfoFg52XiIMjb6jcbHkGxwpJXP4DVrnFEwpwmrz5aTRqOW82kg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-resize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-resize/-/postcss-logical-resize-2.0.0.tgz", + "integrity": "sha512-lCQ1aX8c5+WI4t5EoYf3alTzJNNocMqTb+u1J9CINdDhFh1fjovqK+0aHalUHsNstZmzFPNzIkU4Mb3eM9U8SA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-viewport-units": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-2.0.1.tgz", + "integrity": "sha512-R5s19SscS7CHoxvdYNMu2Y3WDwG4JjdhsejqjunDB1GqfzhtHSvL7b5XxCkUWqm2KRl35hI6kJ4HEaCDd/3BXg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-tokenizer": "^2.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-media-minmax": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-minmax/-/postcss-media-minmax-1.0.7.tgz", + "integrity": "sha512-5LGLdu8cJgRPmvkjUNqOPKIKeHbyQmoGKooB5Rh0mp5mLaNI9bl+IjFZ2keY0cztZYsriJsGf6Lu8R5XetuwoQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-calc": "^1.1.3", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/media-query-list-parser": "^2.1.4" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-media-queries-aspect-ratio-number-values": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-2.0.2.tgz", + "integrity": "sha512-kQJR6NvTRidsaRjCdHGjra2+fLoFiDQOm5B2aZrhmXqng/hweXjruboKzB326rxQO2L0m0T+gCKbZgyuncyhLg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/media-query-list-parser": "^2.1.4" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-nested-calc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-nested-calc/-/postcss-nested-calc-3.0.0.tgz", + "integrity": "sha512-HsB66aDWAouOwD/GcfDTS0a7wCuVWaTpXcjl5VKP0XvFxDiU+r0T8FG7xgb6ovZNZ+qzvGIwRM+CLHhDgXrYgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-normalize-display-values": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-3.0.0.tgz", + "integrity": "sha512-6Nw55PRXEKEVqn3bzA8gRRPYxr5tf5PssvcE5DRA/nAxKgKtgNZMCHCSd1uxTCWeyLnkf6h5tYRSB0P1Vh/K/A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-oklab-function": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-oklab-function/-/postcss-oklab-function-3.0.1.tgz", + "integrity": "sha512-3TIz+dCPlQPzz4yAEYXchUpfuU2gRYK4u1J+1xatNX85Isg4V+IbLyppblWLV4Vb6npFF8qsHN17rNuxOIy/6w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-color-parser": "^1.2.2", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/postcss-progressive-custom-properties": "^3.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-progressive-custom-properties": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-3.0.0.tgz", + "integrity": "sha512-2/D3CCL9DN2xhuUTP8OKvKnaqJ1j4yZUxuGLsCUOQ16wnDAuMLKLkflOmZF5tsPh/02VPeXRmqIN+U595WAulw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-relative-color-syntax": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-2.0.1.tgz", + "integrity": "sha512-9B8br/7q0bjD1fV3yE22izjc7Oy5hDbDgwdFEz207cdJHYC9yQneJzP3H+/w3RgC7uyfEVhyyhkGRx5YAfJtmg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-color-parser": "^1.2.2", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/postcss-progressive-custom-properties": "^3.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-scope-pseudo-class": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-scope-pseudo-class/-/postcss-scope-pseudo-class-3.0.0.tgz", + "integrity": "sha512-GFNVsD97OuEcfHmcT0/DAZWAvTM/FFBDQndIOLawNc1Wq8YqpZwBdHa063Lq+Irk7azygTT+Iinyg3Lt76p7rg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-stepped-value-functions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-3.0.1.tgz", + "integrity": "sha512-y1sykToXorFE+5cjtp//xAMWEAEple0kcZn2QhzEFIZDDNvGOCp5JvvmmPGsC3eDlj6yQp70l9uXZNLnimEYfA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-calc": "^1.1.3", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-text-decoration-shorthand": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-3.0.0.tgz", + "integrity": "sha512-BAa1MIMJmEZlJ+UkPrkyoz3DC7kLlIl2oDya5yXgvUrelpwxddgz8iMp69qBStdXwuMyfPx46oZcSNx8Z0T2eA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^3.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-trigonometric-functions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-3.0.1.tgz", + "integrity": "sha512-hW+JPv0MPQfWC1KARgvJI6bisEUFAZWSvUNq/khGCupYV/h6Z9R2ZFz0Xc633LXBst0ezbXpy7NpnPurSx5Klw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-calc": "^1.1.3", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-unset-value": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-3.0.0.tgz", + "integrity": "sha512-P0JD1WHh3avVyKKRKjd0dZIjCEeaBer8t1BbwGMUDtSZaLhXlLNBqZ8KkqHzYWXOJgHleXAny2/sx8LYl6qhEA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.0.0.tgz", + "integrity": "sha512-hBI9tfBtuPIi885ZsZ32IMEU/5nlZH/KOVYJCOh7gyMxaVLGmLedYqFN6Ui1LXkI8JlC8IsuC0rF0btcRZKd5g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.13" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.19", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", + "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz", + "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", + "dev": true + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@transloadit/prettier-bytes": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.9.tgz", + "integrity": "sha512-pCvdmea/F3Tn4hAtHqNXmjcixSaroJJ+L3STXlYJdir1g1m2mRQpWbN8a4SvgQtaw2930Ckhdx8qXdXBFMKbAA==" + }, + "node_modules/@types/body-parser": { + "version": "1.19.2", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", + "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.10.tgz", + "integrity": "sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.35", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", + "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.0.tgz", + "integrity": "sha512-4x5FkPpLipqwthjPsF7ZRbOv3uoLUFkTA9G9v583qi4pACvq0uTELrB8OLUzPWUI4IJIyvM85vzkV1nyiI2Lig==", + "dev": true, + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "8.44.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.2.tgz", + "integrity": "sha512-sdPRb9K6iL5XZOmBubg8yiFp5yS/JdUDQsq5e6h95km91MCYMuvp7mh1fjPEYUhvHepKpZOjnEaMBR4PxjWDzg==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", + "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", + "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "dev": true + }, + "node_modules/@types/express": { + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", + "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.17.35", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz", + "integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "dev": true + }, + "node_modules/@types/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==", + "dev": true + }, + "node_modules/@types/http-proxy": { + "version": "1.17.11", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.11.tgz", + "integrity": "sha512-HC8G7c1WmaF2ekqpnFq626xd3Zz0uvaqFmBJNRZCGEZCXkvSdJoNFn/8Ygbd9fKNQj8UzLdCETaI0UWPAjK7IA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", + "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", + "dev": true + }, + "node_modules/@types/mime": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", + "dev": true + }, + "node_modules/@types/node": { + "version": "20.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.5.1.tgz", + "integrity": "sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==", + "dev": true + }, + "node_modules/@types/qs": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", + "dev": true + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true + }, + "node_modules/@types/send": { + "version": "0.17.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.1.tgz", + "integrity": "sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.2.tgz", + "integrity": "sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/mime": "*", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.33", + "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", + "integrity": "sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.5.5", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.5.tgz", + "integrity": "sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@uppy/companion-client": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-3.3.0.tgz", + "integrity": "sha512-ogU0QieutbM0A6/yxK91w1Ge4KTC+eAGQMk6JKZu58b435dLScRTCsWGFSSIvt1U8RDY7YDCyl51zawh+A+5CQ==", + "dependencies": { + "@uppy/utils": "^5.4.3", + "namespace-emitter": "^2.0.1" + } + }, + "node_modules/@uppy/core": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@uppy/core/-/core-3.4.0.tgz", + "integrity": "sha512-95NNyXZfuNfB6sgna41fNNPRuTqjrHdlVzkXaJlZzghAckIxNz2CoeMYA1rtgn9o8ykKa2Zdz4kk2MEq8Qy4fw==", + "dependencies": { + "@transloadit/prettier-bytes": "0.0.9", + "@uppy/store-default": "^3.0.3", + "@uppy/utils": "^5.4.3", + "lodash": "^4.17.21", + "mime-match": "^1.0.2", + "namespace-emitter": "^2.0.1", + "nanoid": "^4.0.0", + "preact": "^10.5.13" + } + }, + "node_modules/@uppy/dashboard": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@uppy/dashboard/-/dashboard-3.5.1.tgz", + "integrity": "sha512-Fb3FFg4n3QuoLsFb2Cp2wnlEXJ9bYq/uy4d68USqrkAAHiFiT+/y07Lvrf2BN4H5UFCzWEMhgaBrwo792DwxjQ==", + "dependencies": { + "@transloadit/prettier-bytes": "0.0.7", + "@uppy/informer": "^3.0.3", + "@uppy/provider-views": "^3.5.0", + "@uppy/status-bar": "^3.2.4", + "@uppy/thumbnail-generator": "^3.0.4", + "@uppy/utils": "^5.4.3", + "classnames": "^2.2.6", + "is-shallow-equal": "^1.0.1", + "lodash": "^4.17.21", + "memoize-one": "^6.0.0", + "nanoid": "^4.0.0", + "preact": "^10.5.13" + }, + "peerDependencies": { + "@uppy/core": "^3.4.0" + } + }, + "node_modules/@uppy/dashboard/node_modules/@transloadit/prettier-bytes": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz", + "integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==" + }, + "node_modules/@uppy/form": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@uppy/form/-/form-3.0.2.tgz", + "integrity": "sha512-o1wQy23Yh8q8oh+ZHxwx6RAJFWcoRL9p42l6W1X+9y9MyduXYyHPIRvib6QOp9MHJiqITDpAQQyoHPHSkdYi8Q==", + "dependencies": { + "@uppy/utils": "^5.3.0", + "get-form-data": "^3.0.0" + }, + "peerDependencies": { + "@uppy/core": "^3.2.0" + } + }, + "node_modules/@uppy/informer": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@uppy/informer/-/informer-3.0.3.tgz", + "integrity": "sha512-jMMlZ0bCJ2ruJJ0LMl7pJrM/b0e9vjVEHvYYdQghnRSRDSMONcTJXEqNZ0Lu4x7OZR1SGvqqchFk7n3vAsuERw==", + "dependencies": { + "@uppy/utils": "^5.4.3", + "preact": "^10.5.13" + }, + "peerDependencies": { + "@uppy/core": "^3.4.0" + } + }, + "node_modules/@uppy/provider-views": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@uppy/provider-views/-/provider-views-3.5.0.tgz", + "integrity": "sha512-xSp5xQ6NsPLS2XJdsdBQCLgQELEd0BvVM2R34/XFyGTSqeA4NJKHfM6kSKwjW/jkj26CyFN5nth6CGeNaaKQ+w==", + "dependencies": { + "@uppy/utils": "^5.4.3", + "classnames": "^2.2.6", + "nanoid": "^4.0.0", + "p-queue": "^7.3.4", + "preact": "^10.5.13" + }, + "peerDependencies": { + "@uppy/core": "^3.4.0" + } + }, + "node_modules/@uppy/status-bar": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@uppy/status-bar/-/status-bar-3.2.4.tgz", + "integrity": "sha512-WuK0LRmz7H7iBDV0VO+iUNoXmhbyeCEAWzslX0nqhkGuMchIQprVwd80ZegACySajqcpV1RDNxdhmgtCbRn8wA==", + "dependencies": { + "@transloadit/prettier-bytes": "0.0.9", + "@uppy/utils": "^5.4.3", + "classnames": "^2.2.6", + "preact": "^10.5.13" + }, + "peerDependencies": { + "@uppy/core": "^3.4.0" + } + }, + "node_modules/@uppy/store-default": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-3.0.3.tgz", + "integrity": "sha512-/zlvQNj4HjkthI+7dNdj/8mOlTg1Zb1gJ/ZsOxof0g3xXD+OAwm7asRnOwpfj2dos+lExdW/zMn8XsRGsuvb6Q==" + }, + "node_modules/@uppy/thumbnail-generator": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@uppy/thumbnail-generator/-/thumbnail-generator-3.0.4.tgz", + "integrity": "sha512-f7E+4F6UWunX3jnV3wfL+k5zQaukKmD1z2qYbmRg5OuE9CxDJrNdAVk14KDAi79seejPJa6VVfCgGjTlIGLaRA==", + "dependencies": { + "@uppy/utils": "^5.4.3", + "exifr": "^7.0.0" + }, + "peerDependencies": { + "@uppy/core": "^3.4.0" + } + }, + "node_modules/@uppy/tus": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@uppy/tus/-/tus-3.1.3.tgz", + "integrity": "sha512-AY4tXHfeM+btnG9uKWc2ZiPhnB29xEFTudVbVmC/vEN6oBeKuJVF9NF7z9s34cRxptvvrZsv8pnRkvPJkTdfyQ==", + "dependencies": { + "@uppy/companion-client": "^3.3.0", + "@uppy/utils": "^5.4.3", + "tus-js-client": "^3.0.0" + }, + "peerDependencies": { + "@uppy/core": "^3.4.0" + } + }, + "node_modules/@uppy/utils": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-5.4.3.tgz", + "integrity": "sha512-ewQTWQ5Wu1/ocz/lLCkhoXQwHLRktFK4CxrOsZmeCLK9LxjD1GOwSFjOuL199WDQKXiCle6SVlAJGQ3SDlXVkg==", + "dependencies": { + "lodash": "^4.17.21", + "preact": "^10.5.13" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "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==", + "dev": true, + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ajv/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "dev": true, + "engines": [ + "node >= 0.8.0" + ], + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/array-flatten": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", + "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", + "dev": true + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.15", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz", + "integrity": "sha512-KCuPB8ZCIqFdA4HwKXsvz7j6gvSDNhDP7WnUjBleRkKjPdvCmHFuQ77ocavI8FT6NdvlBnE2UFr2H4Mycn8Vew==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "browserslist": "^4.21.10", + "caniuse-lite": "^1.0.30001520", + "fraction.js": "^4.2.0", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.0", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/b4a": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", + "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" + }, + "node_modules/babel-loader": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-8.3.0.tgz", + "integrity": "sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==", + "dev": true, + "dependencies": { + "find-cache-dir": "^3.3.1", + "loader-utils": "^2.0.0", + "make-dir": "^3.1.0", + "schema-utils": "^2.6.5" + }, + "engines": { + "node": ">= 8.9" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "webpack": ">=2" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.5.tgz", + "integrity": "sha512-19hwUH5FKl49JEsvyTcoHakh6BE0wgXLLptIyKZ3PijHc/Ci521wygORCUCCred+E/twuqRyAkE02BAWPmsHOg==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.4.2", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.3.tgz", + "integrity": "sha512-z41XaniZL26WLrvjy7soabMXrfPWARN25PZoriDEiLMxAp50AUW3t35BGQUMg5xK3UrpVTtagIDklxYa+MhiNA==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.4.2", + "core-js-compat": "^3.31.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.2.tgz", + "integrity": "sha512-tAlOptU0Xj34V1Y2PNTL4Y0FOJMDB6bZmoW39FeCQIhigGLkqu3Fj6uiXpxIf6Ij274ENdYx64y6Au+ZKlb1IA==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.4.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "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 + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/batch": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "dev": true + }, + "node_modules/bcp-47": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-1.0.8.tgz", + "integrity": "sha512-Y9y1QNBBtYtv7hcmoX0tR+tUNSFZGZ6OL6vKPObq8BbOhkCoyayF6ogfLTgAli/KuAEbsYHYUNq2AQuY6IuLag==", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-1.0.3.tgz", + "integrity": "sha512-LggQ4YTdjWQSKELZF5JwchnBa1u0pIQSZf5lSdOHEdbVP55h0qICA/FUp3+W99q0xqxYa1ZQizTUH87gecII5w==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-normalize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bcp-47-normalize/-/bcp-47-normalize-1.1.1.tgz", + "integrity": "sha512-jWZ1Jdu3cs0EZdfCkS0UE9Gg01PtxnChjEBySeB+Zo6nkqtFfnvtoQQgP1qU1Oo4qgJgxhTI6Sf9y/pZIhPs0A==", + "dependencies": { + "bcp-47": "^1.0.0", + "bcp-47-match": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "dev": true, + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "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.11.0", + "raw-body": "2.5.1", + "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/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "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==", + "dev": true, + "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==", + "dev": true + }, + "node_modules/bonjour-service": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.1.1.tgz", + "integrity": "sha512-Z/5lQRMOG9k7W+FkeGTNjh7htqn/2LMnfOvBZ8pynNZCM9MwkQkI3zeI4oz09uWdcgmgHugVvBqxGg4VQJ5PCg==", + "dev": true, + "dependencies": { + "array-flatten": "^2.1.2", + "dns-equal": "^1.0.0", + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/bonjour-service/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.21.10", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.10.tgz", + "integrity": "sha512-bipEBdZfVH5/pwrvqc+Ub0kUPVfGUhlKxbvfD+z1BDnPEO/X98ruXGA1WP5ASpAFKan7Qr6j736IacbZQuAlKQ==", + "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" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001517", + "electron-to-chromium": "^1.4.477", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.11" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "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==" + }, + "node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "dev": true, + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001521", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001521.tgz", + "integrity": "sha512-fnx1grfpEOvDGH+V17eccmNjucGUnCbP6KL+l5KqBIerp26WK/+RQ7CIDE37KGJjaPyqWXXlFUyKiWmvdNNKmQ==", + "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" + } + ] + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, + "node_modules/clean-css": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.2.tgz", + "integrity": "sha512-JVJbM+f3d3Q704rF4bqQ5UUyTtuJ0JRKNbTKVEeujCCBoMdkEi+V+e8oktO9qGQNSvHrFTM6JZRXrUvGR1czww==", + "dev": true, + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/codem-isoboxer": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/codem-isoboxer/-/codem-isoboxer-0.3.9.tgz", + "integrity": "sha512-4XOTqEzBWrGOZaMd+sTED2hLpzfBbiQCf1W6OBGkIHqk1D8uwy8WFLazVbdQwfDpQ+vf39lqTGPa9IhWW0roTA==" + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/combine-errors": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/combine-errors/-/combine-errors-3.0.3.tgz", + "integrity": "sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==", + "dependencies": { + "custom-error-instance": "2.1.1", + "lodash.uniqby": "4.5.0" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "dev": true, + "dependencies": { + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "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==", + "dev": true, + "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==", + "dev": true + }, + "node_modules/compression/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==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "dev": true, + "engines": { + "node": ">=0.8" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true, + "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==", + "dev": true + }, + "node_modules/copy-webpack-plugin": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-8.1.1.tgz", + "integrity": "sha512-rYM2uzRxrLRpcyPqGceRBDpxxUV8vcDqIKxAUKfcnFpcrPxT5+XvhTxv7XLjo5AvEJFPdAE3zCogG2JVahqgSQ==", + "dev": true, + "dependencies": { + "fast-glob": "^3.2.5", + "glob-parent": "^5.1.1", + "globby": "^11.0.3", + "normalize-path": "^3.0.0", + "p-limit": "^3.1.0", + "schema-utils": "^3.0.0", + "serialize-javascript": "^5.0.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/core-js-compat": { + "version": "3.32.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.32.1.tgz", + "integrity": "sha512-GSvKDv4wE0bPnQtjklV101juQ85g6H3rm5PDP20mqlS5j0kXF3pP97YvAu5hl+uFHqMictp3b2VxOHljWMAtuA==", + "dev": true, + "dependencies": { + "browserslist": "^4.21.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true + }, + "node_modules/cosmiconfig": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.2.0.tgz", + "integrity": "sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==", + "dev": true, + "dependencies": { + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-blank-pseudo": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-6.0.0.tgz", + "integrity": "sha512-VbfLlOWO7sBHBTn6pwDQzc07Z0SDydgDBfNfCE0nvrehdBNv9RKsuupIRa/qal0+fBZhAALyQDPMKz5lnvcchw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-has-pseudo": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/css-has-pseudo/-/css-has-pseudo-6.0.0.tgz", + "integrity": "sha512-X+r+JBuoO37FBOWVNhVJhxtSBUFHgHbrcc0CjFT28JEdOw1qaDwABv/uunyodUuSy2hMPe9j/HjssxSlvUmKjg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/selector-specificity": "^3.0.0", + "postcss-selector-parser": "^6.0.13", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "dev": true, + "dependencies": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.27.0 || ^5.0.0" + } + }, + "node_modules/css-loader/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/css-loader/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/css-prefers-color-scheme": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-9.0.0.tgz", + "integrity": "sha512-03QGAk/FXIRseDdLb7XAiu6gidQ0Nd8945xuM7VFVPpc6goJsG9uIO8xQjTxwbPdPIIV4o4AJoOJyt8gwDl67g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssdb": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.7.0.tgz", + "integrity": "sha512-1hN+I3r4VqSNQ+OmMXxYexnumbOONkSil0TWMebVXHtzYW4tRRPovUNHPHj2d4nrgOuYJ8Vs3XwvywsuwwXNNA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + } + ] + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/custom-error-instance": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz", + "integrity": "sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==" + }, + "node_modules/dashjs": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/dashjs/-/dashjs-4.7.1.tgz", + "integrity": "sha512-RPUqJGjR4lXrApHfNOd9G6885q8GpQ4rWecYBMdJjXCtnM8sNg9bhqic3Jl0bTgR0Xzl7Jd86qRc1YZbq1wjPw==", + "dependencies": { + "bcp-47-match": "^1.0.3", + "bcp-47-normalize": "^1.1.1", + "codem-isoboxer": "0.3.9", + "es6-promise": "^4.2.8", + "fast-deep-equal": "2.0.1", + "html-entities": "^1.2.1", + "imsc": "^1.1.3", + "localforage": "^1.7.1", + "path-browserify": "^1.0.1", + "ua-parser-js": "^1.0.2" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "dev": true + }, + "node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dns-equal": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dns-equal/-/dns-equal-1.0.0.tgz", + "integrity": "sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg==", + "dev": true + }, + "node_modules/dns-packet": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.0.tgz", + "integrity": "sha512-rza3UH1LwdHh9qyPXp8lkwpjSNk/AMD3dPytUoRoqnypDUhY0xvbdmVhWOfxO68frEfV9BU8V12Ez7ZsHGZpCQ==", + "dev": true, + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "dev": true, + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dotenv": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", + "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "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==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.4.496", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.496.tgz", + "integrity": "sha512-qeXC3Zbykq44RCrBa4kr8v/dWzYJA8rAwpyh9Qd+NKWoJfjG5vvJqy9XOJ9H4P/lqulZBCgUWAYi+FeK5AuJ8g==", + "dev": true + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.10.0.tgz", + "integrity": "sha512-ZtUjZO6l5mwTHvc1L9+1q5p/R3wTopcfqMW8r5t8SJSKqeVI/LtajORwRFEKpEFuekjD0VBjwu1HMxL4UalIRw==", + "dev": true, + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.3.0.tgz", + "integrity": "sha512-vZK7T0N2CBmBOixhmjdqx2gWVbFZ4DXZ/NyRMZVlJXPa7CyFS+/a4QQsDGDQy9ZfEzxFuNEsMLeQJnKP2p5/JA==", + "dev": true + }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "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==", + "dev": true + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==" + }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "dev": true, + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "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" + } + }, + "node_modules/express/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==", + "dev": true + }, + "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==", + "dev": true, + "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==", + "dev": true + }, + "node_modules/fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "dev": true, + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "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==", + "dev": true, + "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==", + "dev": true + }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fluid-player": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/fluid-player/-/fluid-player-3.22.0.tgz", + "integrity": "sha512-SyR7hBjB1+sHAApE4Emo5xcIXVz72c68BhLwaGb8EjwEJ9NnWOtMHZGDZeOg4sg2DN5OWo95XPzUhNSHxOmtLw==", + "dependencies": { + "dashjs": "^4.5.2", + "es6-promise": "^4.2.8", + "hls.js": "^1.3.2", + "panolens": "^0.12.1", + "videojs-vtt.js": "^0.15.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz", + "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==", + "dev": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://www.patreon.com/infusion" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "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, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.4.tgz", + "integrity": "sha512-INM/fWAxMICjttnD0DX1rBvinKskj5G1w+oy/pnm9u/tSlnBrzFonJMcalKJ30P8RRsPzKcCG7Q8l0jx5Fh9YQ==", + "dev": true + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "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, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-form-data": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-form-data/-/get-form-data-3.0.0.tgz", + "integrity": "sha512-1d53Kn08wlPuLu31/boF1tW2WRYKw3xAWae3mqcjqpDjoqVBtXolbQnudbbEFyFWL7+2SLGRAFdotxNY06V7MA==" + }, + "node_modules/get-intrinsic": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/global": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, + "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, + "engines": { + "node": ">=4" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==" + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "dev": true + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/hls.js": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.4.10.tgz", + "integrity": "sha512-wAVSj4Fm2MqOHy5+BlYnlKxXvJlv5IuZHjlzHu18QmjRzSDFQiUDWdHs5+NsFMQrgKEBwuWDcyvaMC9dUzJ5Uw==" + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/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==", + "dev": true + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-entities": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", + "integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==" + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "dev": true, + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.3.tgz", + "integrity": "sha512-6YrDKTuqaP/TquFH7h4srYWsZx+x6k6+FbsTm0ziCwGHDP78Unr1r9F/H4+sGmMbX08GQcJ+K64x55b+7VM/jg==", + "dev": true, + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "webpack": "^5.20.0" + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "dev": true + }, + "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==", + "dev": true, + "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/http-parser-js": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.8.tgz", + "integrity": "sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==", + "dev": true + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", + "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "dev": true, + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "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==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", + "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, + "node_modules/immutable": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.2.tgz", + "integrity": "sha512-oGXzbEDem9OOpDWZu88jGiYCvIsLHMvGw+8OXlpsvTFvIQplQbjg1B1cvKg8f7Hoch6+NGjpPsH1Fr+Mc2D1aA==", + "dev": true + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imsc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/imsc/-/imsc-1.1.3.tgz", + "integrity": "sha512-IY0hMkVTNoqoYwKEp5UvNNKp/A5jeJUOrIO7judgOyhHT+xC6PA4VBOMAOhdtAYbMRHx9DTgI8p6Z6jhYQPFDA==", + "dependencies": { + "sax": "1.2.1" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", + "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", + "dev": true, + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-shallow-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-shallow-equal/-/is-shallow-equal-1.0.1.tgz", + "integrity": "sha512-lq5RvK+85Hs5J3p4oA4256M1FEffzmI533ikeDHvJd42nouRRx5wBzt36JuviiGe5dIPyHON/d0/Up+PBo6XkQ==" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.19.3", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.19.3.tgz", + "integrity": "sha512-5eEbBDQT/jF1xg6l36P+mWGGoH9Spuy0PCdSr2dtWRDGC6ph/w9ZCL4lmESW8f8F7MwT3XKescfP0wnZWAKL9w==", + "dev": true, + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-base64": { + "version": "3.7.5", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.5.tgz", + "integrity": "sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==" + }, + "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 + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "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, + "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, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/launch-editor": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.6.0.tgz", + "integrity": "sha512-JpDCcQnyAAzZZaZ7vEiSqL690w7dAEyLao+KC96zBplnYbJS7TYNjvM3M7y3dGz+v7aIsJk3hllWuc0kWAjyRQ==", + "dev": true, + "dependencies": { + "picocolors": "^1.0.0", + "shell-quote": "^1.7.3" + } + }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "dependencies": { + "lie": "3.1.1" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash._baseiteratee": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz", + "integrity": "sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==", + "dependencies": { + "lodash._stringtopath": "~4.8.0" + } + }, + "node_modules/lodash._basetostring": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-4.12.0.tgz", + "integrity": "sha512-SwcRIbyxnN6CFEEK4K1y+zuApvWdpQdBHM/swxP962s8HIxPO3alBH5t3m/dl+f4CMUug6sJb7Pww8d13/9WSw==" + }, + "node_modules/lodash._baseuniq": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz", + "integrity": "sha512-Ja1YevpHZctlI5beLA7oc5KNDhGcPixFhcqSiORHNsp/1QTv7amAXzw+gu4YOvErqVlMVyIJGgtzeepCnnur0A==", + "dependencies": { + "lodash._createset": "~4.0.0", + "lodash._root": "~3.0.0" + } + }, + "node_modules/lodash._createset": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/lodash._createset/-/lodash._createset-4.0.3.tgz", + "integrity": "sha512-GTkC6YMprrJZCYU3zcqZj+jkXkrXzq3IPBcF/fIPpNEAB4hZEtXU8zp/RwKOvZl43NUmwDbyRk3+ZTbeRdEBXA==" + }, + "node_modules/lodash._root": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz", + "integrity": "sha512-O0pWuFSK6x4EXhM1dhZ8gchNtG7JMqBtrHdoUFUWXD7dJnNSUze1GuyQr5sOs0aCvgGeI3o/OJW8f4ca7FDxmQ==" + }, + "node_modules/lodash._stringtopath": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/lodash._stringtopath/-/lodash._stringtopath-4.8.0.tgz", + "integrity": "sha512-SXL66C731p0xPDC5LZg4wI5H+dJo/EO4KTqOMwLYCH3+FmmfAKJEZCm6ohGpI+T1xwsDsJCfL4OnhorllvlTPQ==", + "dependencies": { + "lodash._basetostring": "~4.12.0" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==" + }, + "node_modules/lodash.uniqby": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.5.0.tgz", + "integrity": "sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==", + "dependencies": { + "lodash._baseiteratee": "~4.7.0", + "lodash._baseuniq": "~4.6.0" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "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, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, + "node_modules/merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", + "dev": true + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromodal": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/micromodal/-/micromodal-0.4.10.tgz", + "integrity": "sha512-BUrEnzMPFBwK8nOE4xUDYHLrlGlLULQVjpja99tpJQPSUEWgw3kTLp1n1qv0HmKU29AiHE7Y7sMLiRziDK4ghQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz", + "integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==", + "dependencies": { + "wildcard": "^1.1.0" + } + }, + "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==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/min-document": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", + "integrity": "sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==", + "dependencies": { + "dom-walk": "^0.1.0" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.6.2.tgz", + "integrity": "sha512-WhDvO3SjGm40oV5y26GjMJYjd2UMqrLAGKy5YS2/3QKJy2F7jgynuHTir/tgUUOiNQu5saXHdc8reo7YuhhT4Q==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0", + "webpack-sources": "^1.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.4.0 || ^5.0.0" + } + }, + "node_modules/mini-css-extract-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "dev": true, + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/namespace-emitter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz", + "integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==" + }, + "node_modules/nanoassert": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-2.0.0.tgz", + "integrity": "sha512-7vO7n28+aYO4J+8w96AzhmU8G+Y/xpPDJz/se19ICsqj/momRbb9mh9ZUtkoJ5X3nTnPdhEJyc0qnM6yAsHBaA==" + }, + "node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "dev": true, + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", + "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dev": true + }, + "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==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-7.3.4.tgz", + "integrity": "sha512-esox8CWt0j9EZECFvkFl2WNPat8LN4t7WWeXq73D9ha0V96qPRufApZi4ZhPwXAln1uVVal429HVVKPa2X0yQg==", + "dependencies": { + "eventemitter3": "^4.0.7", + "p-timeout": "^5.0.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-retry/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/p-timeout": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-5.1.0.tgz", + "integrity": "sha512-auFDyzzzGZZZdHz3BtET9VEz0SE/uMEAx7uWfGPucfzEwwe/xH0iVeZibQmANYE/hp9T2+UUZT5m+BKyrDp3Ew==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/panolens": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/panolens/-/panolens-0.12.1.tgz", + "integrity": "sha512-2hpjm+rRnDdaLD5Bak49K0Y9/X6vOr1OcyJx5piSA6sCOs1tsgchMgKIwpSGCMpBMHWZ10E/Cz4BIwyXYebt5g==", + "dependencies": { + "three": "^0.105.2" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "dev": true, + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", + "dev": true + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss": { + "version": "8.4.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.28.tgz", + "integrity": "sha512-Z7V5j0cq8oEKyejIKfpD8b4eBy9cwW2JWPk0+fB1HOAMsfHbnAXLLS+PfVWlzMSLQaWttKDt607I0XHmpE67Vw==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-attribute-case-insensitive": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-6.0.2.tgz", + "integrity": "sha512-IRuCwwAAQbgaLhxQdQcIIK0dCVXg3XDUnzgKD8iwdiYdwU4rMWRWyl/W9/0nA4ihVpq5pyALiHB2veBJ0292pw==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-clamp": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-clamp/-/postcss-clamp-4.1.0.tgz", + "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=7.6.0" + }, + "peerDependencies": { + "postcss": "^8.4.6" + } + }, + "node_modules/postcss-color-functional-notation": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-color-functional-notation/-/postcss-color-functional-notation-6.0.0.tgz", + "integrity": "sha512-kaWTgnhRKFtfMF8H0+NQBFxgr5CGg05WGe07Mc1ld6XHwwRWlqSbHOW0zwf+BtkBQpsdVUu7+gl9dtdvhWMedw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^3.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-color-hex-alpha": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/postcss-color-hex-alpha/-/postcss-color-hex-alpha-9.0.2.tgz", + "integrity": "sha512-SfPjgr//VQ/DOCf80STIAsdAs7sbIbxATvVmd+Ec7JvR8onz9pjawhq3BJM3Pie40EE3TyB0P6hft16D33Nlyg==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-color-rebeccapurple": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-9.0.0.tgz", + "integrity": "sha512-RmUFL+foS05AKglkEoqfx+KFdKRVmqUAxlHNz4jLqIi7046drIPyerdl4B6j/RA2BSP8FI8gJcHmLRrwJOMnHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-custom-media": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-10.0.0.tgz", + "integrity": "sha512-NxDn7C6GJ7X8TsWOa8MbCdq9rLERRLcPfQSp856k1jzMreL8X9M6iWk35JjPRIb9IfRnVohmxAylDRx7n4Rv4g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/cascade-layer-name-parser": "^1.0.3", + "@csstools/css-parser-algorithms": "^2.3.0", + "@csstools/css-tokenizer": "^2.1.1", + "@csstools/media-query-list-parser": "^2.1.2" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-custom-properties": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/postcss-custom-properties/-/postcss-custom-properties-13.3.0.tgz", + "integrity": "sha512-q4VgtIKSy5+KcUvQ0WxTjDy9DZjQ5VCXAZ9+tT9+aPMbA0z6s2t1nMw0QHszru1ib5ElkXl9JUpYYU37VVUs7g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/cascade-layer-name-parser": "^1.0.4", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-custom-selectors": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-custom-selectors/-/postcss-custom-selectors-7.1.4.tgz", + "integrity": "sha512-TU2xyUUBTlpiLnwyE2ZYMUIYB41MKMkBZ8X8ntkqRDQ8sdBLhFFsPgNcOliBd5+/zcK51C9hRnSE7hKUJMxQSw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/cascade-layer-name-parser": "^1.0.3", + "@csstools/css-parser-algorithms": "^2.3.0", + "@csstools/css-tokenizer": "^2.1.1", + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-dir-pseudo-class": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-8.0.0.tgz", + "integrity": "sha512-Oy5BBi0dWPwij/IA+yDYj+/OBMQ9EPqAzTHeSNUYrUWdll/PRJmcbiUj0MNcsBi681I1gcSTLvMERPaXzdbvJg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-double-position-gradients": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-double-position-gradients/-/postcss-double-position-gradients-5.0.0.tgz", + "integrity": "sha512-wR8npIkrIVUTicUpCWSSo1f/g7gAEIH70FMqCugY4m4j6TX4E0T2Q5rhfO0gqv00biBZdLyb+HkW8x6as+iJNQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^3.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-visible": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/postcss-focus-visible/-/postcss-focus-visible-9.0.0.tgz", + "integrity": "sha512-zA4TbVaIaT8npZBEROhZmlc+GBKE8AELPHXE7i4TmIUEQhw/P/mSJfY9t6tBzpQ1rABeGtEOHYrW4SboQeONMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-within": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/postcss-focus-within/-/postcss-focus-within-8.0.0.tgz", + "integrity": "sha512-E7+J9nuQzZaA37D/MUZMX1K817RZGDab8qw6pFwzAkDd/QtlWJ9/WTKmzewNiuxzeq6WWY7ATiRePVoDKp+DnA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-font-variant": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", + "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", + "dev": true, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-gap-properties": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-5.0.0.tgz", + "integrity": "sha512-YjsEEL6890P7MCv6fch6Am1yq0EhQCJMXyT4LBohiu87+4/WqR7y5W3RIv53WdA901hhytgRvjlrAhibhW4qsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-image-set-function": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-image-set-function/-/postcss-image-set-function-6.0.0.tgz", + "integrity": "sha512-bg58QnJexFpPBU4IGPAugAPKV0FuFtX5rHYNSKVaV91TpHN7iwyEzz1bkIPCiSU5+BUN00e+3fV5KFrwIgRocw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-initial": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", + "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", + "dev": true, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-lab-function": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-lab-function/-/postcss-lab-function-6.0.1.tgz", + "integrity": "sha512-/Xl6JitDh7jWkcOLxrHcAlEaqkxyaG3g4iDMy5RyhNaiQPJ9Egf2+Mxp1W2qnH5jB2bj59f3RbdKmC6qx1IcXA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/css-color-parser": "^1.2.2", + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/postcss-progressive-custom-properties": "^3.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-loader": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.3.tgz", + "integrity": "sha512-YgO/yhtevGO/vJePCQmTxiaEwER94LABZN0ZMT4A0vsak9TpO+RvKRs7EmJ8peIlB9xfXCsS7M8LjqncsUZ5HA==", + "dev": true, + "dependencies": { + "cosmiconfig": "^8.2.0", + "jiti": "^1.18.2", + "semver": "^7.3.8" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/postcss-loader/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/postcss-loader/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/postcss-loader/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/postcss-logical": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-7.0.0.tgz", + "integrity": "sha512-zYf3vHkoW82f5UZTEXChTJvH49Yl9X37axTZsJGxrCG2kOUwtaAoz9E7tqYg0lsIoJLybaL8fk/2mOi81zVIUw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz", + "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.3.tgz", + "integrity": "sha512-2/u2zraspoACtrbFRnTijMiQtb4GW4BvatjaG/bCjYQo8kLTdevCUlwuBHx2sCnSyrI3x3qj4ZK1j5LQBgzmwA==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz", + "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nesting": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/postcss-nesting/-/postcss-nesting-12.0.1.tgz", + "integrity": "sha512-6LCqCWP9pqwXw/njMvNK0hGY44Fxc4B2EsGbn6xDcxbNRzP8GYoxT7yabVVMLrX3quqOJ9hg2jYMsnkedOf8pA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/selector-specificity": "^3.0.0", + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-opacity-percentage": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postcss-opacity-percentage/-/postcss-opacity-percentage-2.0.0.tgz", + "integrity": "sha512-lyDrCOtntq5Y1JZpBFzIWm2wG9kbEdujpNt4NLannF+J9c8CgFIzPa80YQfdza+Y+yFfzbYj/rfoOsYsooUWTQ==", + "dev": true, + "funding": [ + { + "type": "kofi", + "url": "https://ko-fi.com/mrcgrtz" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/mrcgrtz" + } + ], + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.2" + } + }, + "node_modules/postcss-overflow-shorthand": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-5.0.0.tgz", + "integrity": "sha512-2rlxDyeSics/hC2FuMdPnWiP9WUPZ5x7FTuArXLFVpaSQ2woPSfZS4RD59HuEokbZhs/wPUQJ1E3MT6zVv94MQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-page-break": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", + "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", + "dev": true, + "peerDependencies": { + "postcss": "^8" + } + }, + "node_modules/postcss-place": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/postcss-place/-/postcss-place-9.0.0.tgz", + "integrity": "sha512-qLEPD9VPH5opDVemwmRaujODF9nExn24VOC3ghgVLEvfYN7VZLwJHes0q/C9YR5hI2UC3VgBE8Wkdp1TxCXhtg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-preset-env": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/postcss-preset-env/-/postcss-preset-env-9.1.1.tgz", + "integrity": "sha512-rMPEqyTLm8JLbvaHnDAdQg6SN4Z/NDOsm+CRefg4HmSOiNpTcBXaw4RAaQbfTNe8BB75l4NpoQ6sMdrutdEpdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/postcss-cascade-layers": "^4.0.0", + "@csstools/postcss-color-function": "^3.0.1", + "@csstools/postcss-color-mix-function": "^2.0.1", + "@csstools/postcss-exponential-functions": "^1.0.0", + "@csstools/postcss-font-format-keywords": "^3.0.0", + "@csstools/postcss-gradients-interpolation-method": "^4.0.1", + "@csstools/postcss-hwb-function": "^3.0.1", + "@csstools/postcss-ic-unit": "^3.0.0", + "@csstools/postcss-is-pseudo-class": "^4.0.0", + "@csstools/postcss-logical-float-and-clear": "^2.0.0", + "@csstools/postcss-logical-resize": "^2.0.0", + "@csstools/postcss-logical-viewport-units": "^2.0.1", + "@csstools/postcss-media-minmax": "^1.0.7", + "@csstools/postcss-media-queries-aspect-ratio-number-values": "^2.0.2", + "@csstools/postcss-nested-calc": "^3.0.0", + "@csstools/postcss-normalize-display-values": "^3.0.0", + "@csstools/postcss-oklab-function": "^3.0.1", + "@csstools/postcss-progressive-custom-properties": "^3.0.0", + "@csstools/postcss-relative-color-syntax": "^2.0.1", + "@csstools/postcss-scope-pseudo-class": "^3.0.0", + "@csstools/postcss-stepped-value-functions": "^3.0.1", + "@csstools/postcss-text-decoration-shorthand": "^3.0.0", + "@csstools/postcss-trigonometric-functions": "^3.0.1", + "@csstools/postcss-unset-value": "^3.0.0", + "autoprefixer": "^10.4.14", + "browserslist": "^4.21.10", + "css-blank-pseudo": "^6.0.0", + "css-has-pseudo": "^6.0.0", + "css-prefers-color-scheme": "^9.0.0", + "cssdb": "^7.7.0", + "postcss-attribute-case-insensitive": "^6.0.2", + "postcss-clamp": "^4.1.0", + "postcss-color-functional-notation": "^6.0.0", + "postcss-color-hex-alpha": "^9.0.2", + "postcss-color-rebeccapurple": "^9.0.0", + "postcss-custom-media": "^10.0.0", + "postcss-custom-properties": "^13.3.0", + "postcss-custom-selectors": "^7.1.4", + "postcss-dir-pseudo-class": "^8.0.0", + "postcss-double-position-gradients": "^5.0.0", + "postcss-focus-visible": "^9.0.0", + "postcss-focus-within": "^8.0.0", + "postcss-font-variant": "^5.0.0", + "postcss-gap-properties": "^5.0.0", + "postcss-image-set-function": "^6.0.0", + "postcss-initial": "^4.0.1", + "postcss-lab-function": "^6.0.1", + "postcss-logical": "^7.0.0", + "postcss-nesting": "^12.0.1", + "postcss-opacity-percentage": "^2.0.0", + "postcss-overflow-shorthand": "^5.0.0", + "postcss-page-break": "^3.0.4", + "postcss-place": "^9.0.0", + "postcss-pseudo-class-any-link": "^9.0.0", + "postcss-replace-overflow-wrap": "^4.0.0", + "postcss-selector-not": "^7.0.1", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-pseudo-class-any-link": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-9.0.0.tgz", + "integrity": "sha512-QNCYIL98VKFKY6HGDEJpF6+K/sg9bxcUYnOmNHJxZS5wsFDFaVoPeG68WAuhsqwbIBSo/b9fjEnTwY2mTSD+uA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "postcss-selector-parser": "^6.0.13" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-replace-overflow-wrap": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", + "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", + "dev": true, + "peerDependencies": { + "postcss": "^8.0.3" + } + }, + "node_modules/postcss-selector-not": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-selector-not/-/postcss-selector-not-7.0.1.tgz", + "integrity": "sha512-1zT5C27b/zeJhchN7fP0kBr16Cc61mu7Si9uWWLoA3Px/D9tIJPKchJCkUH3tPO5D0pCFmGeApAv8XpXBQJ8SQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.10" + }, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/preact": { + "version": "10.17.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.17.1.tgz", + "integrity": "sha512-X9BODrvQ4Ekwv9GURm9AKAGaomqXmip7NQTZgY7gcNmr7XE83adOMJvd3N42id1tMFU7ojiynRsYnY6/BRFxLA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "dev": true, + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "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==", + "dev": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/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==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/purecss": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/purecss/-/purecss-3.0.0.tgz", + "integrity": "sha512-IdYbGwbmuA7Hy9ACIO1q7ks4xGLcJSVHxJT2BXIz2c4Ve1aSrNU5bAzA1ILT4Gmdy5K59ruWoRPf9WvJZU5fbA==" + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "dev": true, + "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/raw-body/node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", + "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regexpu-core": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", + "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "dev": true, + "dependencies": { + "@babel/regjsgen": "^0.8.0", + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.1.0", + "regjsparser": "^0.9.1", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsparser": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dev": true, + "dependencies": { + "jsesc": "~0.5.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "dev": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "dev": true, + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/resolve": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz", + "integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.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==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/sass": { + "version": "1.66.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.66.1.tgz", + "integrity": "sha512-50c+zTsZOJVgFfTgwwEzkjA3/QACgdNsKueWPyAR0mRINIvLAStVQBbPg14iuqEQ74NPDbXzJARJ/O4SI1zftA==", + "dev": true, + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-loader": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-11.1.1.tgz", + "integrity": "sha512-fOCp/zLmj1V1WHDZbUbPgrZhA7HKXHEqkslzB+05U5K9SbSbcmH91C7QLW31AsXikxUMaxXRhhcqWZAxUMLDyA==", + "dev": true, + "dependencies": { + "klona": "^2.0.4", + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0", + "sass": "^1.3.0", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" + }, + "node_modules/schema-utils": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", + "integrity": "sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.5", + "ajv": "^6.12.4", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "dev": true + }, + "node_modules/selfsigned": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz", + "integrity": "sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==", + "dev": true, + "dependencies": { + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "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, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true + }, + "node_modules/send/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==", + "dev": true + }, + "node_modules/serialize-javascript": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", + "integrity": "sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-index": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", + "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", + "dev": true, + "dependencies": { + "accepts": "~1.3.4", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.6.2", + "mime-types": "~2.1.17", + "parseurl": "~1.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", + "dev": true, + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "dev": true + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/serve-index/node_modules/setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", + "dev": true + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "dev": true, + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true + }, + "node_modules/sha256-wasm": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/sha256-wasm/-/sha256-wasm-2.2.2.tgz", + "integrity": "sha512-qKSGARvao+JQlFiA+sjJZhJ/61gmW/3aNLblB2rsgIxDlDxsJPHo8a1seXj12oKtuHVgJSJJ7QEGBUYQN741lQ==", + "dependencies": { + "b4a": "^1.0.1", + "nanoassert": "^2.0.0" + } + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "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, + "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, + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.1.tgz", + "integrity": "sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "dev": true, + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/source-list-map": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", + "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==", + "dev": true + }, + "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==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "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==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "dev": true, + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "dev": true, + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "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, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/style-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", + "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "dev": true, + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/style-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.19.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.2.tgz", + "integrity": "sha512-qC5+dmecKJA4cpYxRa5aVkKehYsQKc+AHeKl0Oe62aYjBL8ZA33tTljktDHJSaxxMnbI5ZYw+o/S2DxxLu8OfA==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.9", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz", + "integrity": "sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.17", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.16.8" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser-webpack-plugin/node_modules/serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/three": { + "version": "0.105.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.105.2.tgz", + "integrity": "sha512-L3Al37k4g3hVbgFFS251UVtIc25chhyN0/RvXzR0C+uIBToV6EKDG+MZzEXm9L2miGUVMK27W46/VkP6WUZXMg==" + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, + "node_modules/tus-js-client": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-3.1.1.tgz", + "integrity": "sha512-SZzWP62jEFLmROSRZx+uoGLKqsYWMGK/m+PiNehPVWbCm7/S9zRIMaDxiaOcKdMnFno4luaqP5E+Y1iXXPjP0A==", + "dependencies": { + "buffer-from": "^1.1.2", + "combine-errors": "^3.0.3", + "is-stream": "^2.0.0", + "js-base64": "^3.7.2", + "lodash.throttle": "^4.1.1", + "proper-lockfile": "^4.1.2", + "url-parse": "^1.5.7" + } + }, + "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==", + "dev": true, + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ua-parser-js": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", + "integrity": "sha512-fKnGuqmTBnIE+/KXSzCn4db8RTigUzw1AN0DmdU6hJovUTbYJKyqj+8Mt1c4VfRDnOVJnENmfYkIPZ946UrSAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "engines": { + "node": "*" + } + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", + "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", + "integrity": "sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==", + "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" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, + "node_modules/utila": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "dev": true + }, + "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==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/videojs-vtt.js": { + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/videojs-vtt.js/-/videojs-vtt.js-0.15.5.tgz", + "integrity": "sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==", + "dependencies": { + "global": "^4.3.1" + } + }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "dev": true, + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/webpack": { + "version": "5.88.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", + "integrity": "sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.0", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.15.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.7", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", + "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "dev": true, + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^3.4.3", + "mime-types": "^2.1.31", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-middleware/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/webpack-dev-middleware/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-dev-server": { + "version": "4.15.1", + "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", + "integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==", + "dev": true, + "dependencies": { + "@types/bonjour": "^3.5.9", + "@types/connect-history-api-fallback": "^1.3.5", + "@types/express": "^4.17.13", + "@types/serve-index": "^1.9.1", + "@types/serve-static": "^1.13.10", + "@types/sockjs": "^0.3.33", + "@types/ws": "^8.5.5", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.0.11", + "chokidar": "^3.5.3", + "colorette": "^2.0.10", + "compression": "^1.7.4", + "connect-history-api-fallback": "^2.0.0", + "default-gateway": "^6.0.3", + "express": "^4.17.3", + "graceful-fs": "^4.2.6", + "html-entities": "^2.3.2", + "http-proxy-middleware": "^2.0.3", + "ipaddr.js": "^2.0.1", + "launch-editor": "^2.6.0", + "open": "^8.0.9", + "p-retry": "^4.5.0", + "rimraf": "^3.0.2", + "schema-utils": "^4.0.0", + "selfsigned": "^2.1.1", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^5.3.1", + "ws": "^8.13.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.37.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack-dev-server/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack-dev-server/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/webpack-dev-server/node_modules/html-entities": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", + "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ] + }, + "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/webpack-dev-server/node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack-manifest-plugin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webpack-manifest-plugin/-/webpack-manifest-plugin-5.0.0.tgz", + "integrity": "sha512-8RQfMAdc5Uw3QbCQ/CBV/AXqOR8mt03B6GJmRbhWopE8GzRfEpn+k0ZuWywxW+5QZsffhmFDY1J6ohqJo+eMuw==", + "dev": true, + "dependencies": { + "tapable": "^2.0.0", + "webpack-sources": "^2.2.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "webpack": "^5.47.0" + } + }, + "node_modules/webpack-manifest-plugin/node_modules/webpack-sources": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-2.3.1.tgz", + "integrity": "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA==", + "dev": true, + "dependencies": { + "source-list-map": "^2.0.1", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-merge": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.9.0.tgz", + "integrity": "sha512-6NbRQw4+Sy50vYNTw7EyOn41OZItPiXB8GNv3INSoe3PSFaHJEz3SHTrYVaRm2LilNGnFUzh0FAwqPEmU/CwDg==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-merge/node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, + "node_modules/webpack-sources": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz", + "integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==", + "dev": true, + "dependencies": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/webpack/node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "dev": true, + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.17", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.17.tgz", + "integrity": "sha512-c4ghIvG6th0eudYwKZY5keb81wtFz9/WeAHAoy8+r18kcWlitUIrmGFQ2rWEl4UCKUilD3zCLHOIPheHx5ypRQ==" + }, + "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, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz", + "integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..14cb424 --- /dev/null +++ b/client/package.json @@ -0,0 +1,52 @@ +{ + "name": "kemono-2-client", + "version": "0.2.1", + "description": "frontend for kemono 2", + "private": true, + "scripts": { + "dev": "webpack serve --config webpack.dev.js", + "build": "webpack --config webpack.prod.js" + }, + "keywords": [], + "author": "BassOfBass", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.22.10", + "@uppy/core": "^3.4.0", + "@uppy/dashboard": "^3.5.1", + "@uppy/form": "^3.0.2", + "@uppy/tus": "^3.1.3", + "diff": "^5.1.0", + "fluid-player": "^3.22.0", + "micromodal": "^0.4.10", + "purecss": "^3.0.0", + "sha256-wasm": "^2.2.2", + "whatwg-fetch": "^3.6.17" + }, + "devDependencies": { + "@babel/core": "^7.22.10", + "@babel/plugin-transform-runtime": "^7.22.10", + "@babel/preset-env": "^7.22.10", + "babel-loader": "^8.3.0", + "buffer": "^6.0.3", + "copy-webpack-plugin": "^8.1.1", + "css-loader": "^5.2.7", + "dotenv": "^8.6.0", + "fs-extra": "^10.1.0", + "html-webpack-plugin": "^5.5.3", + "mini-css-extract-plugin": "^1.6.2", + "postcss": "^8.4.28", + "postcss-loader": "^7.3.3", + "postcss-preset-env": "^9.1.1", + "rimraf": "^3.0.2", + "sass": "^1.66.0", + "sass-loader": "^11.1.1", + "stream-browserify": "^3.0.0", + "style-loader": "^2.0.0", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1", + "webpack-manifest-plugin": "^5.0.0", + "webpack-merge": "^5.9.0" + } +} diff --git a/client/src/api/_index.js b/client/src/api/_index.js new file mode 100644 index 0000000..6990896 --- /dev/null +++ b/client/src/api/_index.js @@ -0,0 +1,2 @@ +export { kemonoAPI } from "./kemono/_index"; +export { paysitesAPI } from "./paysites/_index"; diff --git a/client/src/api/kemono/_index.js b/client/src/api/kemono/_index.js new file mode 100644 index 0000000..a41d235 --- /dev/null +++ b/client/src/api/kemono/_index.js @@ -0,0 +1,14 @@ +import { favorites } from "./favorites"; +import { posts } from "./posts"; +import { api } from "./api"; +import { dms } from "./dms"; + +/** + * @type {KemonoAPI} + */ +export const kemonoAPI = { + favorites, + posts, + api, + dms, +}; diff --git a/client/src/api/kemono/api.js b/client/src/api/kemono/api.js new file mode 100644 index 0000000..7031be8 --- /dev/null +++ b/client/src/api/kemono/api.js @@ -0,0 +1,100 @@ +import { KemonoError } from "@wp/utils"; +import { kemonoFetch } from "./kemono-fetch"; +import { CREATORS_LOCATION } from "@wp/env/env-vars"; + +export const api = { + bans, + bannedArtist, + creators, + logs, +}; + +async function bans() { + try { + const response = await kemonoFetch("/api/v1/creators/bans", { method: "GET" }); + + if (!response || !response.ok) { + alert(new KemonoError(6)); + return null; + } + + /** + * @type {KemonoAPI.API.BanItem[]} + */ + const banItems = await response.json(); + + return banItems; + } catch (error) { + console.error(error); + } +} + +/** + * @param {string} id + * @param {string} service + */ +async function bannedArtist(id, service) { + const params = new URLSearchParams([["service", service]]).toString(); + + try { + const response = await kemonoFetch(`/api/v1/lookup/cache/${id}?${params}`); + + if (!response || !response.ok) { + alert(new KemonoError(7)); + return null; + } + + /** + * @type {KemonoAPI.API.BannedArtist} + */ + const artist = await response.json(); + + return artist; + } catch (error) { + console.error(error); + } +} + +async function creators() { + try { + const response = await kemonoFetch(CREATORS_LOCATION || "/api/v1/creators", { + method: "GET", + }); + + if (!response || !response.ok) { + alert(new KemonoError(8)); + return null; + } + + /** + * @type {KemonoAPI.User[]} + */ + const artists = await response.json(); + + return artists; + } catch (error) { + console.error(error); + } +} + +async function logs(importID) { + try { + const response = await kemonoFetch(`/api/v1/importer/logs/${importID}`, { + method: "GET", + }); + + if (!response || !response.ok) { + alert(new KemonoError(9)); + return null; + } + + /** + * @type {KemonoAPI.API.LogItem[]} + */ + const logs = await response.json(); + + return logs; + } catch (error) { + console.error(error); + } +} diff --git a/client/src/api/kemono/dms.js b/client/src/api/kemono/dms.js new file mode 100644 index 0000000..6f4dd78 --- /dev/null +++ b/client/src/api/kemono/dms.js @@ -0,0 +1,22 @@ +import { KemonoError } from "@wp/utils"; +import { kemonoFetch } from "./kemono-fetch"; + +/** + * @type {KemonoAPI.DMs} + */ +export const dms = { + retrieveHasPendingDMs, +}; + +async function retrieveHasPendingDMs() { + try { + const response = await kemonoFetch(`/api/v1/has_pending_dms`); + + if (!response || !response.ok) { + throw new Error(`Error ${response.status}: ${response.statusText}`); + } + return await response.json(); + } catch (error) { + console.error(error); + } +} diff --git a/client/src/api/kemono/favorites.js b/client/src/api/kemono/favorites.js new file mode 100644 index 0000000..6b87f84 --- /dev/null +++ b/client/src/api/kemono/favorites.js @@ -0,0 +1,142 @@ +import { KemonoError } from "@wp/utils"; +import { kemonoFetch } from "./kemono-fetch"; + +/** + * @type {KemonoAPI.Favorites} + */ +export const favorites = { + retrieveFavoriteArtists, + favoriteArtist, + unfavoriteArtist, + retrieveFavoritePosts, + favoritePost, + unfavoritePost, +}; + +async function retrieveFavoriteArtists() { + const params = new URLSearchParams([["type", "artist"]]).toString(); + + try { + const response = await kemonoFetch(`/api/v1/account/favorites?${params}`); + + if (!response || !response.ok) { + throw new Error(`Error ${response.status}: ${response.statusText}`); + } + /** + * @type {string} + */ + const favs = await response.text(); + return favs; + } catch (error) { + console.error(error); + } +} + +/** + * @param {string} service + * @param {string} userID + */ +async function favoriteArtist(service, userID) { + try { + const response = await kemonoFetch(`/api/v1/favorites/creator/${service}/${userID}`, { method: "POST" }); + + if (!response || !response.ok) { + alert(new KemonoError(3)); + return false; + } + + return true; + } catch (error) { + console.error(error); + } +} + +/** + * @param {string} service + * @param {string} userID + */ +async function unfavoriteArtist(service, userID) { + try { + const response = await kemonoFetch(`/api/v1/favorites/creator/${service}/${userID}`, { method: "DELETE" }); + + if (!response || !response.ok) { + alert(new KemonoError(4)); + return false; + } + + return true; + } catch (error) { + console.error(error); + } +} + +async function retrieveFavoritePosts() { + const params = new URLSearchParams([["type", "post"]]).toString(); + + try { + const response = await kemonoFetch(`/api/v1/account/favorites?${params}`); + + if (!response || !response.ok) { + throw new Error(`Error ${response.status}: ${response.statusText}`); + } + + /** + * @type {KemonoAPI.Post[]} + */ + const favs = await response.json(); + /** + * @type {KemonoAPI.Favorites.Post[]} + */ + const transformedFavs = favs.map((post) => { + return { + id: post.id, + service: post.service, + user: post.user, + }; + }); + + return JSON.stringify(transformedFavs); + } catch (error) { + console.error(error); + } +} + +/** + * @param {string} service + * @param {string} user + * @param {string} post_id + */ +async function favoritePost(service, user, post_id) { + try { + const response = await kemonoFetch(`/api/v1/favorites/post/${service}/${user}/${post_id}`, { method: "POST" }); + + if (!response || !response.ok) { + alert(new KemonoError(1)); + return false; + } + + return true; + } catch (error) { + console.error(error); + } +} + +/** + * @param {string} service + * @param {string} user + * @param {string} post_id + */ +async function unfavoritePost(service, user, post_id) { + try { + const response = await kemonoFetch(`/api/v1/favorites/post/${service}/${user}/${post_id}`, { method: "DELETE" }); + + if (!response || !response.ok) { + alert(new KemonoError(2)); + return false; + } + + return true; + } catch (error) { + console.error(error); + } +} diff --git a/client/src/api/kemono/kemono-fetch.js b/client/src/api/kemono/kemono-fetch.js new file mode 100644 index 0000000..539609a --- /dev/null +++ b/client/src/api/kemono/kemono-fetch.js @@ -0,0 +1,46 @@ +import { isLoggedIn } from "@wp/js/account"; + +/** + * Generic request for Kemono API. + * @param {RequestInfo} endpoint + * @param {RequestInit} options + * @returns {Promise} + */ +export async function kemonoFetch(endpoint, options) { + try { + const response = await fetch(endpoint, options); + + // doing this because the server returns `401` before redirecting + // in case of favs + if (response.status === 401) { + // server logged the account out + if (isLoggedIn) { + localStorage.removeItem("logged_in"); + localStorage.removeItem("role"); + localStorage.removeItem("favs"); + localStorage.removeItem("post_favs"); + location.href = "/account/logout"; + return; + } + const loginURL = new URL("/account/login", location.origin).toString(); + location = addURLParam(loginURL, "location", location.pathname); + return; + } + + return response; + } catch (error) { + console.error(`Kemono request error: ${error}`); + } +} + +/** + * @param {string} url + * @param {string} paramName + * @param {string} paramValue + * @returns {string} + */ +function addURLParam(url, paramName, paramValue) { + var newURL = new URL(url); + newURL.searchParams.set(paramName, paramValue); + return newURL.toString(); +} diff --git a/client/src/api/kemono/posts.js b/client/src/api/kemono/posts.js new file mode 100644 index 0000000..b9f97bb --- /dev/null +++ b/client/src/api/kemono/posts.js @@ -0,0 +1,26 @@ +import { kemonoFetch } from "./kemono-fetch"; +import { KemonoError } from "@wp/utils"; + +export const posts = { + attemptFlag, +}; + +/** + * @param {string} service + * @param {string} user + * @param {string} post_id + */ +async function attemptFlag(service, user, post_id) { + try { + const response = await kemonoFetch(`/api/v1/${service}/user/${user}/post/${post_id}/flag`, { method: "POST" }); + + if (!response || !response.ok) { + alert(new KemonoError(5)); + return false; + } + + return true; + } catch (error) { + console.error(error); + } +} diff --git a/client/src/api/paysites/_index.js b/client/src/api/paysites/_index.js new file mode 100644 index 0000000..0e1948b --- /dev/null +++ b/client/src/api/paysites/_index.js @@ -0,0 +1 @@ +export const paysitesAPI = {}; diff --git a/client/src/assets/loading.gif b/client/src/assets/loading.gif new file mode 100644 index 0000000..a5a3046 Binary files /dev/null and b/client/src/assets/loading.gif differ diff --git a/client/src/css/_index.scss b/client/src/css/_index.scss new file mode 100644 index 0000000..e049833 --- /dev/null +++ b/client/src/css/_index.scss @@ -0,0 +1,7 @@ +@use "config/variables"; +@use "animations"; +@use "sass-mixins"; +@use "base"; +@use "attributes"; +@use "blocks"; +@use "legacy"; diff --git a/client/src/css/animations.scss b/client/src/css/animations.scss new file mode 100644 index 0000000..a7996a8 --- /dev/null +++ b/client/src/css/animations.scss @@ -0,0 +1,8 @@ +@keyframes fadeInOpacity { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} diff --git a/client/src/css/attributes.scss b/client/src/css/attributes.scss new file mode 100644 index 0000000..59ec40f --- /dev/null +++ b/client/src/css/attributes.scss @@ -0,0 +1,73 @@ +@use "config/variables" as *; +/* Attributes */ +// only selectors by attributes +// and their pseudo-classes/elements go there +// base tags are for easier grouping/folding + +a { + // internal links + // &[href^=#{$kemono-site}], + &[href^="/"], + &[href^="./"], + &[href^="../"] { + --local-colour1-primary: var(--anchour-internal-colour1-primary); + --local-colour1-secondary: var(--anchour-internal-colour1-secondary); + --local-colour2-primary: var(--anchour-internal-colour2-primary); + --local-colour2-secondary: var(--anchour-internal-colour2-secondary); + } + + // local links + &[href^="#"] { + --local-colour1-primary: var(--anchour-local-colour1-primary); + // the same color because visited state is irrelevant + --local-colour1-secondary: var(--anchour-local-colour1-primary); + --local-colour2-primary: var(--anchour-local-colour2-primary); + --local-colour2-secondary: var(--anchour-local-colour2-secondary); + } + + // email links + &[href^="mailto:"] { + // &::before { + // content: "\1F4E7"; // email icon + // padding-right: $size-little; + // } + + //&::after { + // content: " (\01f4e8\02197\01f441)"; // mail sent, NE arrow, eye + //} + } + + // telephone links + &[href^="tel:"] { + &::before { + content: "\260e"; // phone icon + padding-right: $size-little; + } + } +} + +input { + &[type="submit"] { + min-height: 24px; + text-align: center; + color: var(--colour0-primary); + background-image: linear-gradient(hsl(220, 7%, 17%), hsl(0, 0%, 7%)); + border-radius: 5px; + border: 0; + } + + &[type="text"], + &[type="password"], + &[type="number"] { + border-radius: 5px; + box-shadow: inset 0 1px 3px hsl(228, 7%, 13%); + background: hsl(220, 7%, 25%); + color: var(--colour0-primary); + border: 0; + } + + &[type="checkbox"], + &[type="radio"] { + cursor: pointer; + } +} diff --git a/client/src/css/base.scss b/client/src/css/base.scss new file mode 100644 index 0000000..c75f178 --- /dev/null +++ b/client/src/css/base.scss @@ -0,0 +1,188 @@ +@use "config/variables" as *; + +html { + box-sizing: border-box; + height: 100%; + width: 100%; + font-size: 100%; + font-family: Helvetica, sans-serif; + padding: 0; + margin: 0; + overflow-wrap: break-word; + overflow: auto; +} + +*, +*::before, +*::after { + box-sizing: inherit; + margin: 0; +} + +body { + display: flex; + position: relative; + height: 100%; + width: 100%; + color: var(--colour0-primary); + background-color: var(--colour1-primary); + padding: 0; + margin: 0; +} + +main { +} + +h1 { + text-transform: capitalize; + font-size: 1.7rem; + font-weight: normal; + margin: 0; +} + +h2 { + font-size: 1.6rem; + font-weight: normal; + line-height: 1.35; + margin: 0; +} + +h3 { + font-size: 1.5rem; + font-weight: normal; + margin: 0; +} + +h4 { + font-size: 1.4rem; + font-weight: normal; + margin: 0; +} + +h5 { + font-size: 1.3rem; + font-weight: normal; + margin: 0; +} + +h6 { + font-size: 1.2rem; + font-weight: normal; + margin: 0; +} + +p, +li, +dd, +dt { + line-height: 1.5; +} + +p, +ul, +ol { + margin: $size-small 0; +} + +ul, +ol { + padding-left: $size-normal; +} + +a { + --local-colour1-primary: var(--anchour-colour1-primary); + --local-colour1-secondary: var(--anchour-colour1-secondary); + --local-colour2-primary: var(--anchour-colour2-primary); + --local-colour2-secondary: var(--anchour-colour2-secondary); + + outline: none; + text-decoration: none; + border-bottom: $size-nano solid transparent; + padding: 2px; + transition-property: color, border-color, background-color; + transition-duration: var(--duration-global); + + &:link { + color: var(--local-colour1-primary); + } + + &:visited { + color: var(--local-colour1-secondary); + } + + &:focus { + background-color: var(--local-colour2-primary); + border-bottom-color: var(--local-colour1-primary); + } + + &:hover { + background-color: var(--local-colour2-secondary); + border-bottom-color: var(--local-colour1-primary); + } + + &:active { + background-color: var(--local-colour1-primary); + color: var(--local-colour2-primary); + border-bottom-color: var(--local-colour2-primary); + } +} + +img { + max-width: 100%; + height: auto; +} + +button, +input, +select, +textarea, +.pure-g [class*="pure-u"] { + font-family: Helvetica, sans-serif; +} + +label { + cursor: pointer; +} + +textarea { + width: 100%; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + min-height: 3rem; + box-shadow: inset 0 1px 3px #1f2024; + background: #3a3d43; + color: var(--colour0-primary); + border: none; +} + +pre { + white-space: pre-wrap; /* Since CSS 2.1 */ + word-wrap: break-word; /* Internet Explorer 5.5+ */ +} + +select { + color: var(--colour0-primary); + background-image: linear-gradient(#282a2e, #111111); + border-radius: 5px; + border-color: #111; + text-align-last: center; + cursor: pointer; +} + +select * { + color: #000; +} + +select::-ms-expand { + color: #000; +} + +button { + cursor: pointer; + + // prevents onclick events from firind on children + & > * { + pointer-events: none; + } +} diff --git a/client/src/css/blocks/_index.scss b/client/src/css/blocks/_index.scss new file mode 100644 index 0000000..b0e499d --- /dev/null +++ b/client/src/css/blocks/_index.scss @@ -0,0 +1 @@ +@use "form"; diff --git a/client/src/css/blocks/form.scss b/client/src/css/blocks/form.scss new file mode 100644 index 0000000..df242de --- /dev/null +++ b/client/src/css/blocks/form.scss @@ -0,0 +1,145 @@ +@use "../config/variables.scss" as *; + +.form { + max-width: $width-mobile; + padding: $size-normal; + margin: 0 auto; + + &--bigger { + max-width: $width-phone; + } + + &--wide { + max-width: $width-tablet; + } + + &--controller { + display: none; + } + + &__section, + &__fieldset { + padding-bottom: $size-normal; + transition-property: opacity, visibility; + transition-duration: var(--duration-global); + + &:last-child { + padding-bottom: 0; + } + + &--hidden { + max-height: 0; + opacity: 0; + visibility: hidden; + padding: 0; + margin: 0; + } + } + + &__section { + &--buttons { + text-align: center; + } + &--checkbox { + position: relative; + display: flex; + flex-flow: row wrap; + justify-content: space-around; + align-items: center; + gap: $size-normal; + + & .form__input { + flex: 0 1; + -webkit-appearance: none; + appearance: none; + padding: 0; + margin: 0; + + &:before { + position: absolute; + top: 40%; + left: 0.7em; + content: "X"; + font-size: 2em; + color: hsl(0, 100%, 60%); + transform: translate(-50%, -50%); + } + + &:checked:before { + content: "\2713"; /* checkmark */ + color: hsl(120, 100%, 50%); + } + + &:checked + .form__label { + opacity: 1; + } + } + + & .form__label { + flex: 1 1; + opacity: 0.5; + transition-property: opacity; + transition-duration: 250ms; + } + } + } + + &__input, + &__button, + &__select { + box-sizing: border-box; + min-height: $button-min-width; + min-width: $button-min-height; + width: 100%; + font-family: inherit; + font-size: 18px; + border-radius: 10px; + padding: $size-small; + } + + &__subtitle { + display: block; + line-height: normal; + color: hsl(0, 0%, 45%); + } + + &__input { + background: hsl(224, 7%, 32%); + color: hsl(0, 0%, 100%); + border: 0; + } + + &__option { + color: hsl(0, 0%, 100%); + background-color: hsl(224, 7%, 32%); + } + + /* quick hack to overwrite attribute rules */ + &__input.form__input--text, + &__input.form__input--password { + text-align: left; + } + + &__button { + cursor: pointer; + + &--submit { + max-width: $width-tablet; + text-align: center; + color: var(--colour0-primary); + background-image: linear-gradient(#32373e, #262a31); + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.2), + 0 4px 6px -4px rgb(0 0 0 / 0.2); + border: 0; + transition: none; + transition-property: box-shadow; + transition-duration: var(--duration-global); + + &:hover, + &:focus-within { + box-shadow: none; + } + } + } +} diff --git a/client/src/css/config/variables.scss b/client/src/css/config/variables.scss new file mode 100644 index 0000000..14dea8e --- /dev/null +++ b/client/src/css/config/variables.scss @@ -0,0 +1,97 @@ +/* SASS variables */ +// screen widths +$width-feature: 240px; +$width-mobile: 360px; +$width-phone: 480px; +$width-tablet: 720px; +$width-laptop: 1280px; +$width-desktop: 1920px; + +// sizes for borders/padding/margins/gaps +$size-nano: 0.0625em; +$size-thin: 0.125em; +$size-little: 0.25em; +$size-small: 0.5em; +$size-normal: 1em; +$size-big: 2em; +$size-large: 3em; + +// buttons +$button-min-width: 44px; +$button-min-height: 44px; + +// min sidebar width do not touch +$sidebar-min-width: 1020px; + +/* CSS variables */ +:root { + // base colours + --colour0-primary: hsl(0, 0%, 95%); + --colour0-secondary: hsl(0, 0%, 70%); + --colour0-tertirary: hsl(0, 0%, 45%); + // colors + --colour1-primary: hsl(210, 6%, 12%); + --colour1-primary-transparent: hsla(210, 6%, 12%, 0.75); + --colour1-secondary: hsl(220, 7%, 25%); + --colour1-secondary-transparent: hsla(220, 7%, 25%, 0.5); + // kemono colors + //--colour1-primary: hsl(210, 6%, 12%); + //--colour1-primary-transparent: hsla(210, 6%, 12%, 0.75); + //--colour1-secondary: hsl(220, 7%, 25%); + //--colour1-secondary-transparent: hsla(220, 7%, 25%, 0.5); + // coomer colors + //--colour1-primary: hsl(200, 25%, 5%); + //--colour1-primary-transparent: hsla(200, 25%, 5%, 0.75); + //--colour1-secondary: hsl(208, 22%, 12%); + //--colour1-secondary-transparent: hsla(208, 22%, 12%, 0.5); + --colour1-tertiary: hsl(210, 15%, 5%); + + /* Buttons */ + --submit-colour1-primary: hsl(200, 100%, 70%); + --submit-colour1-secondary: hsl(240, 100%, 50%); + --submit-colour2-primary: hsl(240, 100%, 50%); + --submit-colour2-secondary: hsl(240, 100%, 50%); + --positive-colour1-primary: hsl(120, 100%, 45%); + --positive-colour1-secondary: hsl(120, 100%, 30%); + --negative-colour1-primary: hsl(0, 100%, 60%); + --favourite-colour1-primary: hsl(51, 100%, 50%); + --favourite-colour2-primary: hsl(60, 100%, 30%); + /* END Buttons */ + + /* Links */ + // external + --anchour-colour1-primary: hsl(200, 100%, 80%); + --anchour-colour1-secondary: hsla(210, 70%, 70%); + --anchour-colour2-primary: hsl(240, 100%, 40%); + --anchour-colour2-secondary: hsla(230, 70%, 40%); + // internal + --anchour-internal-colour1-primary: hsl(20, 100%, 80%); + --anchour-internal-colour1-secondary: hsla(20, 70%, 70%); + --anchour-internal-colour2-primary: hsl(30, 100%, 20%); + --anchour-internal-colour2-secondary: hsla(30, 80%, 20%); + // kemono colors + //--anchour-internal-colour1-primary: hsl(20, 100%, 80%); + //--anchour-internal-colour1-secondary: hsla(20, 70%, 70%); + //--anchour-internal-colour2-primary: hsl(30, 100%, 20%); + //--anchour-internal-colour2-secondary: hsla(30, 80%, 20%); + // coomer colors + //--anchour-internal-colour1-primary: hsl(200, 100%, 80%); + //--anchour-internal-colour1-secondary: hsla(210, 70%, 70%); + //--anchour-internal-colour2-primary: hsl(240, 100%, 40%); + //--anchour-internal-colour2-secondary: hsla(230, 70%, 40%); + // local + --anchour-local-colour1-primary: hsl(260, 100%, 80%); + --anchour-local-colour1-secondary: hsla(260, 70%, 70%); + --anchour-local-colour2-primary: hsl(280, 100%, 30%); + --anchour-local-colour2-secondary: hsla(280, 70%, 30%); + // email + // --anchour-email-colour1-primary: hsl(260, 100%, 80%); + // --anchour-email-colour1-secondary: hsla(260, 70%, 70%); + // --anchour-email-colour2-primary: hsl(280, 100%, 30%); + // --anchour-email-colour2-secondary: hsla(280, 70%, 30%); + /* END Links */ + + // durations + --duration-fast: 250ms; + --duration-global: var(--duration-fast); +} diff --git a/client/src/css/legacy.scss b/client/src/css/legacy.scss new file mode 100644 index 0000000..b39cba3 --- /dev/null +++ b/client/src/css/legacy.scss @@ -0,0 +1,457 @@ +/* + TODO: Spread the styles around page/component/block files. +*/ + +.flash_messages { + background-color: #3a3d43; + padding: 10px; + text-align: center; +} + +.subtitle { + color: #737373; +} + +.no-posts { + text-align: center; +} + +.upload-button { + padding: 30px; + margin-top: 5px; + border-radius: 0.25rem; + background: #3a3d43; + font-size: 24px; + text-align: center; +} + +.activity-view { + padding: 5px; + margin: 0.5rem; + border-radius: 0.25rem; + background: #3a3d43; + display: flex; +} + +.activity-view-avatar { + border-radius: 0.25rem; + background-size: cover; + background-position: center; + width: 50px; + height: 50px; +} + +.activity-view-p { + margin-left: 5px; +} + +.activity-view-name { + font-weight: bold; + font-size: 20px; +} + +.activity-view-update { + display: block; +} + +.jumbo { + padding: 10px; + margin: 0.5rem; + border-radius: 0.25rem; + background: hsl(220, 7%, 25%); + + & p, + & h2 { + margin: 0; + } +} + +.jumbo-user { + text-align: center; +} + +.jumbo-user-avatar { + margin: 0 auto; + border-radius: 0.25rem; + background-size: cover; + background-position: center; + width: 50px; + height: 50px; +} + +.favorites-opts select { + margin-left: 0.5rem; +} + +.opts { + float: right; + position: relative; +} + +.opts select { + position: absolute; + top: 0.5rem; + right: 0.5rem; +} + +.visually-hidden { + position: absolute; + left: -100vw; +} + +.hidden { + display: none !important; +} + +.text-card { + height: 500px; + background: #3a3d43; + border-radius: 2px; + margin: 0.5rem; + box-shadow: + 0 2px 2px 0 rgba(0, 0, 0, 0.14), + 0 1px 5px 0 rgba(0, 0, 0, 0.12), + 0 3px 1px -2px rgba(0, 0, 0, 0.2); + position: relative; + overflow-y: auto; +} + +.card-image { + overflow: hidden; + position: relative; + max-height: 310px; +} + +.card-reveal { + border-radius: 2px; + position: absolute; + background-color: #3a3d43; + width: 100%; + overflow-y: auto; + top: 0; + height: 100%; + z-index: 1; + display: none; +} + +.card-reveal-content { + padding: 20px; +} + +.card-reveal-content img { + max-width: 100%; +} + +.card-content { + overflow: hidden; + padding: 20px; + border-radius: 0 0 2px 2px; + height: 170px; +} + +.card-title { + font-size: 24px; + font-weight: 300; +} + +.card-action { + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-top: 1px solid rgba(160, 160, 160, 0.2); + padding: 20px; +} + +.card-action a { + margin-right: 10px; +} + +.card-image img { + border-radius: 2px 2px 0 0; + position: relative; + left: 0; + right: 0; + top: 0; + bottom: 0; + width: 100%; +} + +/* thumbnails */ +.thumb-standard { + border: 1px solid #000; +} +.thumb-child { + border: 1px solid #cc0; +} +.thumb-parent { + border: 1px solid #0f0; +} +.thumb-shared { + border: 1px solid #ff7f00; +} + +.thumb-link { + margin: 2px; +} + +.thumb { + width: 200px; + height: 200px; +} + +.thumb:hover .thumb-with-image-overlay { + display: block; +} + +.thumb-with-image { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-image: url("@wp/assets/loading.gif"); + background-position: center; + background-repeat: no-repeat; + position: relative; +} + +.thumb-image { + width: 100%; + height: 100%; + object-fit: contain; +} + +.thumb-with-image-overlay { + display: none; + z-index: 9; + width: 100%; + height: 100%; + position: absolute; + top: 0; + text-align: center; + background-image: linear-gradient(to bottom, rgba(0, 0, 0, 1), rgba(0, 0, 0, 0)); +} + +.thumb-with-text { + overflow-y: auto; + text-align: center; +} + +.thumb-with-text * { + padding: 2px; +} + +.thumb * { + margin: auto; +} + +.thumb h3, +.thumb p { + color: #fff; +} + +/* sidebar */ + +.sidebar { + float: left; + margin-right: 5px; + margin-left: 5px; + width: 195px; + flex-shrink: 0; + display: flex; + flex-direction: column; + word-break: break-word; +} + +.search-input { + text-align: center; +} + +/* search */ + +.results li { + display: block; + list-style-type: none; + line-height: 1.8em; +} + +.page img { + max-width: 100%; +} + +/* pagination */ +.paginator { + text-align: center; +} + +.paginator menu { + padding: 0; + margin: 5px auto; + display: table; + + & > li, + & > a { + border: 1px solid var(--colour0-tertirary); + display: table-cell; + line-height: 33px; + color: var(--colour0-secondary); + user-select: none; + cursor: pointer; + padding: 0; + min-width: 35px; + transition-property: color background-color; + + @media (min-width: #{600px + 1}) { + &.pagination-mobile:not(:last-child) { + display: none; + } + + &.pagination-mobile:last-child { + min-width: unset; + border-right: none; + border-top: none; + border-bottom: none; + & > * { + display: none; + } + } + } + + @media (max-width: 600px) { + &.pagination-button-optional { + display: none; + } + &.pagination-desktop { + display: none; + } + } + + &.pagination-button-disabled { + color: var(--colour0-tertirary); + background-color: unset; + cursor: default; + } + &.pagination-button-current { + background-color: var(--anchour-internal-colour2-primary); + color: var(--anchour-internal-colour1-secondary); + border-color: var(--anchour-internal-colour1-primary); + } + &.pagination-button-after-current { + border-left: 1px solid var(--anchour-internal-colour1-primary); + } + &:not(.pagination-button-disabled):hover, + &:not(.pagination-button-disabled):focus, + &:not(.pagination-button-disabled):active { + background-color: var(--colour0-tertirary); + color: var(--colour0-primary); + } + & > b { + padding: 0 9px; + } + &:not(:last-child) { + border-right: none; + } + } +} + +menu li { + display: inline; + list-style-type: none; + margin: 0; + padding: 0 0.2em; +} + +/* posts */ + +.embed-view { + border: 1px solid #111; + padding: 2px; +} + +/* media queries */ +@media only screen and (max-width: 568px) { + .thumb { + width: 160px; + height: 160px; + } + .sidebar { + width: auto; + } + #paginator-bottom { + margin-bottom: env(safe-area-inset-bottom); + } +} + +/* search forms */ + +.search-form { + display: table; + padding: 0.5rem; + margin-left: 5px; + background-color: #282a2e; + margin: 0px auto 8px auto; + + &-hidden { + display: none; + } + + & > div { + display: table-row; + line-height: 1.5em; + margin-bottom: 2em; + } + + & small { + display: block; + line-height: normal; + } + + & label, + & input { + display: table-cell; + padding-right: 1em; + white-space: nowrap; + text-align: left; + } + + & label { + text-align: right; + font-weight: 700; + } +} + +/* search results */ +.search-results tbody td { + height: 2.25em; + padding-right: 10px; +} + +thead th { + font-weight: 700; + text-align: left; + padding-right: 8px; +} + +.user-icon { + display: inline-block; + width: 40px; + height: 40px; + border-radius: 0.25rem; + overflow: hidden; + + // quick hack to apply styles + + & img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.ad-container { + text-align: center; +} + +.ad-container * { + max-width: 100%; +} diff --git a/client/src/css/sass-mixins.scss b/client/src/css/sass-mixins.scss new file mode 100644 index 0000000..992441a --- /dev/null +++ b/client/src/css/sass-mixins.scss @@ -0,0 +1,15 @@ +/* SASS mixins go there */ +@use "config/variables" as *; + +@mixin article_card() { + display: flex; + flex-flow: column nowrap; + border-radius: 10px; + background-color: var(--colour1-tertiary); + padding: 0; + + & > * { + flex: 0 0 auto; + padding: $size-small; + } +} diff --git a/client/src/development/entry.js b/client/src/development/entry.js new file mode 100644 index 0000000..3c7c98b --- /dev/null +++ b/client/src/development/entry.js @@ -0,0 +1 @@ +import "./entry.scss"; diff --git a/client/src/development/entry.scss b/client/src/development/entry.scss new file mode 100644 index 0000000..7b6d73f --- /dev/null +++ b/client/src/development/entry.scss @@ -0,0 +1,3 @@ +@use "../css"; +@use "../pages"; +@use "../pages/development"; diff --git a/client/src/env/derived-vars.js b/client/src/env/derived-vars.js new file mode 100644 index 0000000..674271a --- /dev/null +++ b/client/src/env/derived-vars.js @@ -0,0 +1,4 @@ +import { KEMONO_SITE, NODE_ENV } from "./env-vars.js"; + +export const IS_DEVELOPMENT = NODE_ENV === "development"; +export const SITE_HOSTNAME = new URL(KEMONO_SITE).hostname; diff --git a/client/src/env/env-vars.js b/client/src/env/env-vars.js new file mode 100644 index 0000000..50a3f5d --- /dev/null +++ b/client/src/env/env-vars.js @@ -0,0 +1,31 @@ +/* + https://webpack.js.org/plugins/define-plugin/ +*/ + +/** + * @type {string} + */ +export const KEMONO_SITE = BUNDLER_ENV_KEMONO_SITE; +/** + * @type {string} + */ +export const NODE_ENV = BUNDLER_ENV_NODE_ENV; +/** + * @type {string} + */ +export const ICONS_PREPEND = BUNDLER_ENV_ICONS_PREPEND; +/** + * @type {string} + */ +export const BANNERS_PREPEND = BUNDLER_ENV_BANNERS_PREPEND; +/** + * @type {string} + */ +export const THUMBNAILS_PREPEND = BUNDLER_ENV_THUMBNAILS_PREPEND; +/** + * @type {string} + */ +export const CREATORS_LOCATION = BUNDLER_ENV_CREATORS_LOCATION; +/** + * @type {string} + */ diff --git a/client/src/js/account.js b/client/src/js/account.js new file mode 100644 index 0000000..9701d8f --- /dev/null +++ b/client/src/js/account.js @@ -0,0 +1 @@ +export const isLoggedIn = localStorage.getItem("logged_in") === "yes"; diff --git a/client/src/js/admin.js b/client/src/js/admin.js new file mode 100644 index 0000000..99fd033 --- /dev/null +++ b/client/src/js/admin.js @@ -0,0 +1,7 @@ +import "./admin.scss"; +import { fixImageLinks } from "@wp/utils"; +import { initSections } from "./page-loader"; +import { adminPageScripts } from "@wp/pages"; + +fixImageLinks(document.images); +initSections(adminPageScripts); diff --git a/client/src/js/admin.scss b/client/src/js/admin.scss new file mode 100644 index 0000000..9a46059 --- /dev/null +++ b/client/src/js/admin.scss @@ -0,0 +1,3 @@ +@use "../css"; +@use "../pages/components"; +@use "../pages/account/administrator"; diff --git a/client/src/js/component-factory.js b/client/src/js/component-factory.js new file mode 100644 index 0000000..8dfaabb --- /dev/null +++ b/client/src/js/component-factory.js @@ -0,0 +1,35 @@ +/** + * @type {Map} + */ +const components = new Map(); + +/** + * @param {HTMLElement} footer + */ +export function initComponentFactory(footer) { + const container = footer.querySelector(".component-container"); + /** + * @type {NodeListOf *`); + + componentElements.forEach((component) => { + components.set(component.className.trim(), component); + }); + container.remove(); +} + +/** + * @param {string} className + */ +export function createComponent(className) { + const componentSkeleton = components.get(className); + + if (!componentSkeleton) { + return console.error(`Component "${className}" doesn't exist.`); + } + + const newInstance = componentSkeleton.cloneNode(true); + + return newInstance; +} diff --git a/client/src/js/favorites.js b/client/src/js/favorites.js new file mode 100644 index 0000000..e678435 --- /dev/null +++ b/client/src/js/favorites.js @@ -0,0 +1,207 @@ +import { kemonoAPI } from "@wp/api"; + +export async function initFavorites() { + let artistFavs = localStorage.getItem("favs"); + let postFavs = localStorage.getItem("post_favs"); + + if (!artistFavs || artistFavs === "undefined") { + /** + * @type {string} + */ + const favs = await kemonoAPI.favorites.retrieveFavoriteArtists(); + + if (favs) { + localStorage.setItem("favs", favs); + } + } + + if (!postFavs || postFavs === "undefined") { + /** + * @type {string} + */ + const favs = await kemonoAPI.favorites.retrieveFavoritePosts(); + + if (favs) { + localStorage.setItem("post_favs", favs); + } + } +} + +async function saveFavouriteArtists() { + try { + const favs = await kemonoAPI.favorites.retrieveFavoriteArtists(); + + if (!favs) { + alert("Could not retrieve favorite artists"); + return false; + } + + localStorage.setItem("favs", favs); + return true; + } catch (error) { + console.error(error); + } +} + +async function saveFavouritePosts() { + try { + const favs = await kemonoAPI.favorites.retrieveFavoritePosts(); + + if (!favs) { + alert("Could not retrieve favorite posts"); + return false; + } + + localStorage.setItem("post_favs", favs); + return true; + } catch (error) { + console.error(error); + } +} + +/** + * @param {string} id + * @param {string} service + * @returns {Promise | undefined} + */ +export async function findFavouriteArtist(id, service) { + /** + * @type {KemonoAPI.Favorites.User[]} + */ + let favList; + + try { + favList = JSON.parse(localStorage.getItem("favs")); + } catch (error) { + // corrupted entry + if (error instanceof SyntaxError) { + const isSaved = await saveFavouriteArtists(); + + if (!isSaved) { + return undefined; + } + + return await findFavouriteArtist(id, service); + } + } + + if (!favList) { + return undefined; + } + + const favArtist = favList.find((favItem) => { + return favItem.id === id && favItem.service === service; + }); + + return favArtist; +} + +/** + * @param {string} service + * @param {string} user + * @param {string} postID + * @returns {Promise | undefined} + */ +export async function findFavouritePost(service, user, postID) { + /** + * @type {KemonoAPI.Favorites.Post[]} + */ + let favList; + + try { + favList = JSON.parse(localStorage.getItem("post_favs")); + + if (!favList) { + return undefined; + } + + const favPost = favList.find((favItem) => { + const isMatch = favItem.id === postID && favItem.service === service && favItem.user === user; + return isMatch; + }); + + return favPost; + } catch (error) { + // corrupted entry + if (error instanceof SyntaxError) { + const isSaved = await saveFavouritePosts(); + + if (!isSaved) { + return undefined; + } + + return await findFavouritePost(service, user, postID); + } + } +} + +/** + * @param {string} id + * @param {string} service + */ +export async function addFavouriteArtist(id, service) { + const isFavorited = await kemonoAPI.favorites.favoriteArtist(service, id); + + if (!isFavorited) { + return false; + } + + const newFavs = await kemonoAPI.favorites.retrieveFavoriteArtists(); + localStorage.setItem("favs", newFavs); + + return true; +} + +/** + * @param {string} id + * @param {string} service + */ +export async function removeFavouriteArtist(id, service) { + const isUnfavorited = await kemonoAPI.favorites.unfavoriteArtist(service, id); + + if (!isUnfavorited) { + return false; + } + + const favItems = await kemonoAPI.favorites.retrieveFavoriteArtists(); + localStorage.setItem("favs", favItems); + + return true; +} + +/** + * @param {string} service + * @param {string} user + * @param {string} postID + */ +export async function addFavouritePost(service, user, postID) { + const isFavorited = await kemonoAPI.favorites.favoritePost(service, user, postID); + + if (!isFavorited) { + return false; + } + + const newFavs = await kemonoAPI.favorites.retrieveFavoritePosts(); + localStorage.setItem("post_favs", newFavs); + + return true; +} + +/** + * @param {string} service + * @param {string} user + * @param {string} postID + * @returns + */ +export async function removeFavouritePost(service, user, postID) { + const isUnfavorited = await kemonoAPI.favorites.unfavoritePost(service, user, postID); + + if (!isUnfavorited) { + return false; + } + + const favItems = await kemonoAPI.favorites.retrieveFavoritePosts(); + localStorage.setItem("post_favs", favItems); + + return true; +} diff --git a/client/src/js/feature-detect.js b/client/src/js/feature-detect.js new file mode 100644 index 0000000..0286d95 --- /dev/null +++ b/client/src/js/feature-detect.js @@ -0,0 +1,13 @@ +export const features = { + localStorage: isLocalStorageAvailable(), +}; + +function isLocalStorageAvailable() { + try { + localStorage.setItem("__storage_test__", "__storage_test__"); + localStorage.removeItem("__storage_test__"); + return true; + } catch (error) { + return false; + } +} diff --git a/client/src/js/global.js b/client/src/js/global.js new file mode 100644 index 0000000..333a110 --- /dev/null +++ b/client/src/js/global.js @@ -0,0 +1,18 @@ +import "./global.scss"; +import "purecss/build/base-min.css"; +import "purecss/build/grids-min.css"; +import "purecss/build/grids-responsive-min.css"; +import 'whatwg-fetch'; /* fetch polyfill */ +import { isLoggedIn } from "@wp/js/account"; +import { initFavorites } from "@wp/js/favorites"; +import { fixImageLinks } from "@wp/utils"; +import { globalPageScripts } from "@wp/pages"; +import { initSections } from "./page-loader"; +import { initPendingReviewDms } from "@wp/js/pending-review-dms"; + +if (isLoggedIn) { + initFavorites(); + initPendingReviewDms(); +} +fixImageLinks(document.images); +initSections(globalPageScripts); diff --git a/client/src/js/global.scss b/client/src/js/global.scss new file mode 100644 index 0000000..a6e8a86 --- /dev/null +++ b/client/src/js/global.scss @@ -0,0 +1,2 @@ +@use "../css"; +@use "../pages"; diff --git a/client/src/js/moderator.js b/client/src/js/moderator.js new file mode 100644 index 0000000..ed160e6 --- /dev/null +++ b/client/src/js/moderator.js @@ -0,0 +1,7 @@ +import "./moderator.scss"; +import { fixImageLinks } from "@wp/utils"; +import { initSections } from "./page-loader"; +import { moderatorPageScripts } from "@wp/pages"; + +fixImageLinks(document.images); +initSections(moderatorPageScripts); diff --git a/client/src/js/moderator.scss b/client/src/js/moderator.scss new file mode 100644 index 0000000..ab8a9c1 --- /dev/null +++ b/client/src/js/moderator.scss @@ -0,0 +1 @@ +@use "../pages/moderator"; diff --git a/client/src/js/page-loader.js b/client/src/js/page-loader.js new file mode 100644 index 0000000..46c7d8c --- /dev/null +++ b/client/src/js/page-loader.js @@ -0,0 +1,37 @@ +import { initShell } from "@wp/components"; +import { initComponentFactory } from "./component-factory"; + +/** + * Initialises the scripts on the page. + * @param {Map void>} pages The map of page names and their callbacks. + */ +export function initSections(pages) { + const sidebar = document.querySelector(".global-sidebar"); + const main = document.querySelector("main"); + /** + * @type {HTMLElement} + */ + const footer = document.querySelector(".global-footer"); + /** + * @type {NodeListOf} + */ + const sections = main.querySelectorAll("main > .site-section"); + + initComponentFactory(footer); + initShell(sidebar); + sections.forEach((section) => { + const sectionNames = section.className.match(/site-section--([a-z\-]+)/ig); + + if (sectionNames && sectionNames.length > 0) { + sectionNames.forEach((sectionName) => { + sectionName = sectionName.replace('site-section--', ''); + if (pages.has(sectionName)) { + const sectionCallbacks = pages.get(sectionName); + sectionCallbacks.forEach((sectionCallback) => { + sectionCallback(section); + }); + } + }); + } + }); +} diff --git a/client/src/js/pending-review-dms.js b/client/src/js/pending-review-dms.js new file mode 100644 index 0000000..ff04258 --- /dev/null +++ b/client/src/js/pending-review-dms.js @@ -0,0 +1,19 @@ +import { kemonoAPI } from "@wp/api"; + +export async function initPendingReviewDms(forceReload= false, minutesForRecheck = 30) { + let HasPendingReviewDms = localStorage.getItem("has_pending_review_dms") === "true"; + let LastCheckedHasPendingReviewDms = parseInt(localStorage.getItem("last_checked_has_pending_review_dms")); + + if (forceReload || !LastCheckedHasPendingReviewDms || (LastCheckedHasPendingReviewDms < Date.now() - minutesForRecheck * 60 * 1000)) { + /** + * @type {string} + */ + HasPendingReviewDms = await kemonoAPI.dms.retrieveHasPendingDMs(); + localStorage.setItem("has_pending_review_dms", HasPendingReviewDms.toString()); + localStorage.setItem("last_checked_has_pending_review_dms", Date.now().toString()); + } + if (HasPendingReviewDms) + document.querySelector(".review_dms > img").src = "/static/menu/red_dm.svg"; + else + document.querySelector(".review_dms > img").src = "/static/menu/dm.svg"; +} diff --git a/client/src/js/resumable.js b/client/src/js/resumable.js new file mode 100644 index 0000000..bf05040 --- /dev/null +++ b/client/src/js/resumable.js @@ -0,0 +1,1242 @@ +/* + * MIT Licensed + * http://www.23developer.com/opensource + * http://github.com/23/resumable.js + * Steffen Tiedemann Christensen, steffen@23company.com + */ + +(function () { + "use strict"; + + var Resumable = function (opts) { + if (!(this instanceof Resumable)) { + return new Resumable(opts); + } + this.version = 1.0; + // SUPPORTED BY BROWSER? + // Check if these features are support by the browser: + // - File object type + // - Blob object type + // - FileList object type + // - slicing files + this.support = + typeof File !== "undefined" && + typeof Blob !== "undefined" && + typeof FileList !== "undefined" && + (!!Blob.prototype.webkitSlice || !!Blob.prototype.mozSlice || !!Blob.prototype.slice || false); + if (!this.support) return false; + + // PROPERTIES + var $ = this; + $.files = []; + $.defaults = { + chunkSize: 1 * 1024 * 1024, + forceChunkSize: false, + simultaneousUploads: 3, + fileParameterName: "file", + chunkNumberParameterName: "resumableChunkNumber", + chunkSizeParameterName: "resumableChunkSize", + currentChunkSizeParameterName: "resumableCurrentChunkSize", + totalSizeParameterName: "resumableTotalSize", + typeParameterName: "resumableType", + identifierParameterName: "resumableIdentifier", + fileNameParameterName: "resumableFilename", + relativePathParameterName: "resumableRelativePath", + totalChunksParameterName: "resumableTotalChunks", + dragOverClass: "dragover", + throttleProgressCallbacks: 0.5, + query: {}, + headers: {}, + preprocess: null, + preprocessFile: null, + method: "multipart", + uploadMethod: "POST", + testMethod: "GET", + prioritizeFirstAndLastChunk: false, + target: "/", + testTarget: null, + parameterNamespace: "", + testChunks: true, + generateUniqueIdentifier: null, + getTarget: null, + maxChunkRetries: 100, + chunkRetryInterval: undefined, + permanentErrors: [400, 401, 403, 404, 409, 415, 500, 501], + maxFiles: undefined, + withCredentials: false, + xhrTimeout: 0, + clearInput: true, + chunkFormat: "blob", + setChunkTypeFromFile: false, + maxFilesErrorCallback: function (files, errorCount) { + var maxFiles = $.getOpt("maxFiles"); + alert("Please upload no more than " + maxFiles + " file" + (maxFiles === 1 ? "" : "s") + " at a time."); + }, + minFileSize: 1, + minFileSizeErrorCallback: function (file, errorCount) { + alert( + file.fileName || + file.name + + " is too small, please upload files larger than " + + $h.formatSize($.getOpt("minFileSize")) + + ".", + ); + }, + maxFileSize: undefined, + maxFileSizeErrorCallback: function (file, errorCount) { + alert( + file.fileName || + file.name + " is too large, please upload files less than " + $h.formatSize($.getOpt("maxFileSize")) + ".", + ); + }, + fileType: [], + fileTypeErrorCallback: function (file, errorCount) { + alert( + file.fileName || + file.name + " has type not allowed, please upload files of type " + $.getOpt("fileType") + ".", + ); + }, + }; + $.opts = opts || {}; + $.getOpt = function (o) { + var $opt = this; + // Get multiple option if passed an array + if (o instanceof Array) { + var options = {}; + $h.each(o, function (option) { + options[option] = $opt.getOpt(option); + }); + return options; + } + // Otherwise, just return a simple option + if ($opt instanceof ResumableChunk) { + if (typeof $opt.opts[o] !== "undefined") { + return $opt.opts[o]; + } else { + $opt = $opt.fileObj; + } + } + if ($opt instanceof ResumableFile) { + if (typeof $opt.opts[o] !== "undefined") { + return $opt.opts[o]; + } else { + $opt = $opt.resumableObj; + } + } + if ($opt instanceof Resumable) { + if (typeof $opt.opts[o] !== "undefined") { + return $opt.opts[o]; + } else { + return $opt.defaults[o]; + } + } + }; + $.indexOf = function (array, obj) { + if (array.indexOf) { + return array.indexOf(obj); + } + for (var i = 0; i < array.length; i++) { + if (array[i] === obj) { + return i; + } + } + return -1; + }; + + // EVENTS + // catchAll(event, ...) + // fileSuccess(file), fileProgress(file), fileAdded(file, event), filesAdded(files, filesSkipped), fileRetry(file), + // fileError(file, message), complete(), progress(), error(message, file), pause() + $.events = []; + $.on = function (event, callback) { + $.events.push(event.toLowerCase(), callback); + }; + $.fire = function () { + // `arguments` is an object, not array, in FF, so: + var args = []; + for (var i = 0; i < arguments.length; i++) args.push(arguments[i]); + // Find event listeners, and support pseudo-event `catchAll` + var event = args[0].toLowerCase(); + for (var i = 0; i <= $.events.length; i += 2) { + if ($.events[i] == event) $.events[i + 1].apply($, args.slice(1)); + if ($.events[i] == "catchall") $.events[i + 1].apply(null, args); + } + if (event == "fileerror") $.fire("error", args[2], args[1]); + if (event == "fileprogress") $.fire("progress"); + }; + + // INTERNAL HELPER METHODS (handy, but ultimately not part of uploading) + var $h = { + stopEvent: function (e) { + e.stopPropagation(); + e.preventDefault(); + }, + each: function (o, callback) { + if (typeof o.length !== "undefined") { + for (var i = 0; i < o.length; i++) { + // Array or FileList + if (callback(o[i]) === false) return; + } + } else { + for (i in o) { + // Object + if (callback(i, o[i]) === false) return; + } + } + }, + generateUniqueIdentifier: function (file, event) { + var custom = $.getOpt("generateUniqueIdentifier"); + if (typeof custom === "function") { + return custom(file, event); + } + var relativePath = file.webkitRelativePath || file.relativePath || file.fileName || file.name; // Some confusion in different versions of Firefox + var size = file.size; + return size + "-" + relativePath.replace(/[^0-9a-zA-Z_-]/gim, ""); + }, + contains: function (array, test) { + var result = false; + + $h.each(array, function (value) { + if (value == test) { + result = true; + return false; + } + return true; + }); + + return result; + }, + formatSize: function (size) { + if (size < 1024) { + return size + " bytes"; + } else if (size < 1024 * 1024) { + return (size / 1024.0).toFixed(0) + " KB"; + } else if (size < 1024 * 1024 * 1024) { + return (size / 1024.0 / 1024.0).toFixed(1) + " MB"; + } else { + return (size / 1024.0 / 1024.0 / 1024.0).toFixed(1) + " GB"; + } + }, + getTarget: function (request, params) { + var target = $.getOpt("target"); + + if (request === "test" && $.getOpt("testTarget")) { + target = $.getOpt("testTarget") === "/" ? $.getOpt("target") : $.getOpt("testTarget"); + } + + if (typeof target === "function") { + return target(params); + } + + var separator = target.indexOf("?") < 0 ? "?" : "&"; + var joinedParams = params.join("&"); + + if (joinedParams) target = target + separator + joinedParams; + + return target; + }, + }; + + var onDrop = function (e) { + e.currentTarget.classList.remove($.getOpt("dragOverClass")); + $h.stopEvent(e); + + //handle dropped things as items if we can (this lets us deal with folders nicer in some cases) + if (e.dataTransfer && e.dataTransfer.items) { + loadFiles(e.dataTransfer.items, e); + } + //else handle them as files + else if (e.dataTransfer && e.dataTransfer.files) { + loadFiles(e.dataTransfer.files, e); + } + }; + var onDragLeave = function (e) { + e.currentTarget.classList.remove($.getOpt("dragOverClass")); + }; + var onDragOverEnter = function (e) { + e.preventDefault(); + var dt = e.dataTransfer; + if ($.indexOf(dt.types, "Files") >= 0) { + // only for file drop + e.stopPropagation(); + dt.dropEffect = "copy"; + dt.effectAllowed = "copy"; + e.currentTarget.classList.add($.getOpt("dragOverClass")); + } else { + // not work on IE/Edge.... + dt.dropEffect = "none"; + dt.effectAllowed = "none"; + } + }; + + /** + * processes a single upload item (file or directory) + * @param {Object} item item to upload, may be file or directory entry + * @param {string} path current file path + * @param {File[]} items list of files to append new items to + * @param {Function} cb callback invoked when item is processed + */ + function processItem(item, path, items, cb) { + var entry; + if (item.isFile) { + // file provided + return item.file(function (file) { + file.relativePath = path + file.name; + items.push(file); + cb(); + }); + } else if (item.isDirectory) { + // item is already a directory entry, just assign + entry = item; + } else if (item instanceof File) { + items.push(item); + } + if ("function" === typeof item.webkitGetAsEntry) { + // get entry from file object + entry = item.webkitGetAsEntry(); + } + if (entry && entry.isDirectory) { + // directory provided, process it + return processDirectory(entry, path + entry.name + "/", items, cb); + } + if ("function" === typeof item.getAsFile) { + // item represents a File object, convert it + item = item.getAsFile(); + if (item instanceof File) { + item.relativePath = path + item.name; + items.push(item); + } + } + cb(); // indicate processing is done + } + + /** + * cps-style list iteration. + * invokes all functions in list and waits for their callback to be + * triggered. + * @param {Function[]} items list of functions expecting callback parameter + * @param {Function} cb callback to trigger after the last callback has been invoked + */ + function processCallbacks(items, cb) { + if (!items || items.length === 0) { + // empty or no list, invoke callback + return cb(); + } + // invoke current function, pass the next part as continuation + items[0](function () { + processCallbacks(items.slice(1), cb); + }); + } + + /** + * recursively traverse directory and collect files to upload + * @param {Object} directory directory to process + * @param {string} path current path + * @param {File[]} items target list of items + * @param {Function} cb callback invoked after traversing directory + */ + function processDirectory(directory, path, items, cb) { + var dirReader = directory.createReader(); + var allEntries = []; + + function readEntries() { + dirReader.readEntries(function (entries) { + if (entries.length) { + allEntries = allEntries.concat(entries); + return readEntries(); + } + + // process all conversion callbacks, finally invoke own one + processCallbacks( + allEntries.map(function (entry) { + // bind all properties except for callback + return processItem.bind(null, entry, path, items); + }), + cb, + ); + }); + } + + readEntries(); + } + + /** + * process items to extract files to be uploaded + * @param {File[]} items items to process + * @param {Event} event event that led to upload + */ + function loadFiles(items, event) { + if (!items.length) { + return; // nothing to do + } + $.fire("beforeAdd"); + var files = []; + processCallbacks( + Array.prototype.map.call(items, function (item) { + // bind all properties except for callback + var entry = item; + if ("function" === typeof item.webkitGetAsEntry) { + entry = item.webkitGetAsEntry(); + } + return processItem.bind(null, entry, "", files); + }), + function () { + if (files.length) { + // at least one file found + appendFilesFromFileList(files, event); + } + }, + ); + } + + var appendFilesFromFileList = function (fileList, event) { + // check for uploading too many files + var errorCount = 0; + var o = $.getOpt([ + "maxFiles", + "minFileSize", + "maxFileSize", + "maxFilesErrorCallback", + "minFileSizeErrorCallback", + "maxFileSizeErrorCallback", + "fileType", + "fileTypeErrorCallback", + ]); + if (typeof o.maxFiles !== "undefined" && o.maxFiles < fileList.length + $.files.length) { + // if single-file upload, file is already added, and trying to add 1 new file, simply replace the already-added file + if (o.maxFiles === 1 && $.files.length === 1 && fileList.length === 1) { + $.removeFile($.files[0]); + } else { + o.maxFilesErrorCallback(fileList, errorCount++); + return false; + } + } + var files = [], + filesSkipped = [], + remaining = fileList.length; + var decreaseReamining = function () { + if (!--remaining) { + // all files processed, trigger event + if (!files.length && !filesSkipped.length) { + // no succeeded files, just skip + return; + } + window.setTimeout(function () { + $.fire("filesAdded", files, filesSkipped); + }, 0); + } + }; + $h.each(fileList, function (file) { + var fileName = file.name; + var fileType = file.type; // e.g video/mp4 + if (o.fileType.length > 0) { + var fileTypeFound = false; + for (var index in o.fileType) { + // For good behaviour we do some inital sanitizing. Remove spaces and lowercase all + o.fileType[index] = o.fileType[index].replace(/\s/g, "").toLowerCase(); + + // Allowing for both [extension, .extension, mime/type, mime/*] + var extension = (o.fileType[index].match(/^[^.][^/]+$/) ? "." : "") + o.fileType[index]; + + if ( + fileName.substr(-1 * extension.length).toLowerCase() === extension || + //If MIME type, check for wildcard or if extension matches the files tiletype + (extension.indexOf("/") !== -1 && + ((extension.indexOf("*") !== -1 && + fileType.substr(0, extension.indexOf("*")) === extension.substr(0, extension.indexOf("*"))) || + fileType === extension)) + ) { + fileTypeFound = true; + break; + } + } + if (!fileTypeFound) { + o.fileTypeErrorCallback(file, errorCount++); + return true; + } + } + + if (typeof o.minFileSize !== "undefined" && file.size < o.minFileSize) { + o.minFileSizeErrorCallback(file, errorCount++); + return true; + } + if (typeof o.maxFileSize !== "undefined" && file.size > o.maxFileSize) { + o.maxFileSizeErrorCallback(file, errorCount++); + return true; + } + + function addFile(uniqueIdentifier) { + if (!$.getFromUniqueIdentifier(uniqueIdentifier)) { + (function () { + file.uniqueIdentifier = uniqueIdentifier; + var f = new ResumableFile($, file, uniqueIdentifier); + $.files.push(f); + files.push(f); + f.container = typeof event != "undefined" ? event.srcElement : null; + window.setTimeout(function () { + $.fire("fileAdded", f, event); + }, 0); + })(); + } else { + filesSkipped.push(file); + } + decreaseReamining(); + } + // directories have size == 0 + var uniqueIdentifier = $h.generateUniqueIdentifier(file, event); + if (uniqueIdentifier && typeof uniqueIdentifier.then === "function") { + // Promise or Promise-like object provided as unique identifier + uniqueIdentifier.then( + function (uniqueIdentifier) { + // unique identifier generation succeeded + addFile(uniqueIdentifier); + }, + function () { + // unique identifier generation failed + // skip further processing, only decrease file count + decreaseReamining(); + }, + ); + } else { + // non-Promise provided as unique identifier, process synchronously + addFile(uniqueIdentifier); + } + }); + }; + + // INTERNAL OBJECT TYPES + function ResumableFile(resumableObj, file, uniqueIdentifier) { + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $._prevProgress = 0; + $.resumableObj = resumableObj; + $.file = file; + $.fileName = file.fileName || file.name; // Some confusion in different versions of Firefox + $.size = file.size; + $.relativePath = file.relativePath || file.webkitRelativePath || $.fileName; + $.uniqueIdentifier = uniqueIdentifier; + $._pause = false; + $.container = ""; + $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished + var _error = uniqueIdentifier !== undefined; + + // Callback when something happens within the chunk + var chunkEvent = function (event, message) { + // event can be 'progress', 'success', 'error' or 'retry' + switch (event) { + case "progress": + $.resumableObj.fire("fileProgress", $, message); + break; + case "error": + $.abort(); + _error = true; + $.chunks = []; + $.resumableObj.fire("fileError", $, message); + break; + case "success": + if (_error) return; + $.resumableObj.fire("fileProgress", $, message); // it's at least progress + if ($.isComplete()) { + $.resumableObj.fire("fileSuccess", $, message); + } + break; + case "retry": + $.resumableObj.fire("fileRetry", $); + break; + } + }; + + // Main code to set up a file object with chunks, + // packaged to be able to handle retries if needed. + $.chunks = []; + $.abort = function () { + // Stop current uploads + var abortCount = 0; + $h.each($.chunks, function (c) { + if (c.status() == "uploading") { + c.abort(); + abortCount++; + } + }); + if (abortCount > 0) $.resumableObj.fire("fileProgress", $); + }; + $.cancel = function () { + // Reset this file to be void + var _chunks = $.chunks; + $.chunks = []; + // Stop current uploads + $h.each(_chunks, function (c) { + if (c.status() == "uploading") { + c.abort(); + $.resumableObj.uploadNextChunk(); + } + }); + $.resumableObj.removeFile($); + $.resumableObj.fire("fileProgress", $); + }; + $.retry = function () { + $.bootstrap(); + var firedRetry = false; + $.resumableObj.on("chunkingComplete", function () { + if (!firedRetry) $.resumableObj.upload(); + firedRetry = true; + }); + }; + $.bootstrap = function () { + $.abort(); + _error = false; + // Rebuild stack of chunks from file + $.chunks = []; + $._prevProgress = 0; + var round = $.getOpt("forceChunkSize") ? Math.ceil : Math.floor; + var maxOffset = Math.max(round($.file.size / $.getOpt("chunkSize")), 1); + for (var offset = 0; offset < maxOffset; offset++) { + (function (offset) { + $.chunks.push(new ResumableChunk($.resumableObj, $, offset, chunkEvent)); + $.resumableObj.fire("chunkingProgress", $, offset / maxOffset); + })(offset); + } + window.setTimeout(function () { + $.resumableObj.fire("chunkingComplete", $); + }, 0); + }; + $.progress = function () { + if (_error) return 1; + // Sum up progress across everything + var ret = 0; + var error = false; + $h.each($.chunks, function (c) { + if (c.status() == "error") error = true; + ret += c.progress(true); // get chunk progress relative to entire file + }); + ret = error ? 1 : ret > 0.99999 ? 1 : ret; + ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused + $._prevProgress = ret; + return ret; + }; + $.isUploading = function () { + var uploading = false; + $h.each($.chunks, function (chunk) { + if (chunk.status() == "uploading") { + uploading = true; + return false; + } + }); + return uploading; + }; + $.isComplete = function () { + var outstanding = false; + if ($.preprocessState === 1) { + return false; + } + $h.each($.chunks, function (chunk) { + var status = chunk.status(); + if (status == "pending" || status == "uploading" || chunk.preprocessState === 1) { + outstanding = true; + return false; + } + }); + return !outstanding; + }; + $.pause = function (pause) { + if (typeof pause === "undefined") { + $._pause = $._pause ? false : true; + } else { + $._pause = pause; + } + }; + $.isPaused = function () { + return $._pause; + }; + $.preprocessFinished = function () { + $.preprocessState = 2; + $.upload(); + }; + $.upload = function () { + var found = false; + if ($.isPaused() === false) { + var preprocess = $.getOpt("preprocessFile"); + if (typeof preprocess === "function") { + switch ($.preprocessState) { + case 0: + $.preprocessState = 1; + preprocess($); + return true; + case 1: + return true; + case 2: + break; + } + } + $h.each($.chunks, function (chunk) { + if (chunk.status() == "pending" && chunk.preprocessState !== 1) { + chunk.send(); + found = true; + return false; + } + }); + } + return found; + }; + $.markChunksCompleted = function (chunkNumber) { + if (!$.chunks || $.chunks.length <= chunkNumber) { + return; + } + for (var num = 0; num < chunkNumber; num++) { + $.chunks[num].markComplete = true; + } + }; + + // Bootstrap and return + $.resumableObj.fire("chunkingStart", $); + $.bootstrap(); + return this; + } + + function ResumableChunk(resumableObj, fileObj, offset, callback) { + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $.resumableObj = resumableObj; + $.fileObj = fileObj; + $.fileObjSize = fileObj.size; + $.fileObjType = fileObj.file.type; + $.offset = offset; + $.callback = callback; + $.lastProgressCallback = new Date(); + $.tested = false; + $.retries = 0; + $.pendingRetry = false; + $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished + $.markComplete = false; + + // Computed properties + var chunkSize = $.getOpt("chunkSize"); + $.loaded = 0; + $.startByte = $.offset * chunkSize; + $.endByte = Math.min($.fileObjSize, ($.offset + 1) * chunkSize); + if ($.fileObjSize - $.endByte < chunkSize && !$.getOpt("forceChunkSize")) { + // The last chunk will be bigger than the chunk size, but less than 2*chunkSize + $.endByte = $.fileObjSize; + } + $.xhr = null; + + // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session + $.test = function () { + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + var testHandler = function (e) { + $.tested = true; + var status = $.status(); + if (status == "success") { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.send(); + } + }; + $.xhr.addEventListener("load", testHandler, false); + $.xhr.addEventListener("error", testHandler, false); + $.xhr.addEventListener("timeout", testHandler, false); + + // Add data from the query options + var params = []; + var parameterNamespace = $.getOpt("parameterNamespace"); + var customQuery = $.getOpt("query"); + if (typeof customQuery == "function") customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function (k, v) { + params.push([encodeURIComponent(parameterNamespace + k), encodeURIComponent(v)].join("=")); + }); + // Add extra data to identify chunk + params = params.concat( + [ + // define key/value pairs for additional parameters + ["chunkNumberParameterName", $.offset + 1], + ["chunkSizeParameterName", $.getOpt("chunkSize")], + ["currentChunkSizeParameterName", $.endByte - $.startByte], + ["totalSizeParameterName", $.fileObjSize], + ["typeParameterName", $.fileObjType], + ["identifierParameterName", $.fileObj.uniqueIdentifier], + ["fileNameParameterName", $.fileObj.fileName], + ["relativePathParameterName", $.fileObj.relativePath], + ["totalChunksParameterName", $.fileObj.chunks.length], + ] + .filter(function (pair) { + // include items that resolve to truthy values + // i.e. exclude false, null, undefined and empty strings + return $.getOpt(pair[0]); + }) + .map(function (pair) { + // map each key/value pair to its final form + return [parameterNamespace + $.getOpt(pair[0]), encodeURIComponent(pair[1])].join("="); + }), + ); + // Append the relevant chunk and send it + $.xhr.open($.getOpt("testMethod"), $h.getTarget("test", params)); + $.xhr.timeout = $.getOpt("xhrTimeout"); + $.xhr.withCredentials = $.getOpt("withCredentials"); + // Add data from header options + var customHeaders = $.getOpt("headers"); + if (typeof customHeaders === "function") { + customHeaders = customHeaders($.fileObj, $); + } + $h.each(customHeaders, function (k, v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(null); + }; + + $.preprocessFinished = function () { + $.preprocessState = 2; + $.send(); + }; + + // send() uploads the actual data in a POST call + $.send = function () { + var preprocess = $.getOpt("preprocess"); + if (typeof preprocess === "function") { + switch ($.preprocessState) { + case 0: + $.preprocessState = 1; + preprocess($); + return; + case 1: + return; + case 2: + break; + } + } + if ($.getOpt("testChunks") && !$.tested) { + $.test(); + return; + } + + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + // Progress + $.xhr.upload.addEventListener( + "progress", + function (e) { + if (new Date() - $.lastProgressCallback > $.getOpt("throttleProgressCallbacks") * 1000) { + $.callback("progress"); + $.lastProgressCallback = new Date(); + } + $.loaded = e.loaded || 0; + }, + false, + ); + $.loaded = 0; + $.pendingRetry = false; + $.callback("progress"); + + // Done (either done, failed or retry) + var doneHandler = function (e) { + var status = $.status(); + if (status == "success" || status == "error") { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.callback("retry", $.message()); + $.abort(); + $.retries++; + var retryInterval = $.getOpt("chunkRetryInterval"); + if (retryInterval !== undefined) { + $.pendingRetry = true; + setTimeout($.send, retryInterval); + } else { + $.send(); + } + } + }; + $.xhr.addEventListener("load", doneHandler, false); + $.xhr.addEventListener("error", doneHandler, false); + $.xhr.addEventListener("timeout", doneHandler, false); + + // Set up the basic query data from Resumable + var query = [ + ["chunkNumberParameterName", $.offset + 1], + ["chunkSizeParameterName", $.getOpt("chunkSize")], + ["currentChunkSizeParameterName", $.endByte - $.startByte], + ["totalSizeParameterName", $.fileObjSize], + ["typeParameterName", $.fileObjType], + ["identifierParameterName", $.fileObj.uniqueIdentifier], + ["fileNameParameterName", $.fileObj.fileName], + ["relativePathParameterName", $.fileObj.relativePath], + ["totalChunksParameterName", $.fileObj.chunks.length], + ] + .filter(function (pair) { + // include items that resolve to truthy values + // i.e. exclude false, null, undefined and empty strings + return $.getOpt(pair[0]); + }) + .reduce(function (query, pair) { + // assign query key/value + query[$.getOpt(pair[0])] = pair[1]; + return query; + }, {}); + // Mix in custom data + var customQuery = $.getOpt("query"); + if (typeof customQuery == "function") customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function (k, v) { + query[k] = v; + }); + + var func = $.fileObj.file.slice + ? "slice" + : $.fileObj.file.mozSlice + ? "mozSlice" + : $.fileObj.file.webkitSlice + ? "webkitSlice" + : "slice"; + var bytes = $.fileObj.file[func]( + $.startByte, + $.endByte, + $.getOpt("setChunkTypeFromFile") ? $.fileObj.file.type : "", + ); + var data = null; + var params = []; + + var parameterNamespace = $.getOpt("parameterNamespace"); + if ($.getOpt("method") === "octet") { + // Add data from the query options + data = bytes; + $h.each(query, function (k, v) { + params.push([encodeURIComponent(parameterNamespace + k), encodeURIComponent(v)].join("=")); + }); + } else { + // Add data from the query options + data = new FormData(); + $h.each(query, function (k, v) { + data.append(parameterNamespace + k, v); + params.push([encodeURIComponent(parameterNamespace + k), encodeURIComponent(v)].join("=")); + }); + if ($.getOpt("chunkFormat") == "blob") { + data.append(parameterNamespace + $.getOpt("fileParameterName"), bytes, $.fileObj.fileName); + } else if ($.getOpt("chunkFormat") == "base64") { + var fr = new FileReader(); + fr.onload = function (e) { + data.append(parameterNamespace + $.getOpt("fileParameterName"), fr.result); + $.xhr.send(data); + }; + fr.readAsDataURL(bytes); + } + } + + var target = $h.getTarget("upload", params); + var method = $.getOpt("uploadMethod"); + + $.xhr.open(method, target); + if ($.getOpt("method") === "octet") { + $.xhr.setRequestHeader("Content-Type", "application/octet-stream"); + } + $.xhr.timeout = $.getOpt("xhrTimeout"); + $.xhr.withCredentials = $.getOpt("withCredentials"); + // Add data from header options + var customHeaders = $.getOpt("headers"); + if (typeof customHeaders === "function") { + customHeaders = customHeaders($.fileObj, $); + } + + $h.each(customHeaders, function (k, v) { + $.xhr.setRequestHeader(k, v); + }); + + if ($.getOpt("chunkFormat") == "blob") { + $.xhr.send(data); + } + }; + $.abort = function () { + // Abort and reset + if ($.xhr) $.xhr.abort(); + $.xhr = null; + }; + $.status = function () { + // Returns: 'pending', 'uploading', 'success', 'error' + if ($.pendingRetry) { + // if pending retry then that's effectively the same as actively uploading, + // there might just be a slight delay before the retry starts + return "uploading"; + } else if ($.markComplete) { + return "success"; + } else if (!$.xhr) { + return "pending"; + } else if ($.xhr.readyState < 4) { + // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening + return "uploading"; + } else { + if ($.xhr.status == 200 || $.xhr.status == 201) { + // HTTP 200, 201 (created) + return "success"; + } else if ( + $h.contains($.getOpt("permanentErrors"), $.xhr.status) || + $.retries >= $.getOpt("maxChunkRetries") + ) { + // HTTP 400, 404, 409, 415, 500, 501 (permanent error) + return "error"; + } else { + // this should never happen, but we'll reset and queue a retry + // a likely case for this would be 503 service unavailable + $.abort(); + return "pending"; + } + } + }; + $.message = function () { + return $.xhr ? $.xhr.responseText : ""; + }; + $.progress = function (relative) { + if (typeof relative === "undefined") relative = false; + var factor = relative ? ($.endByte - $.startByte) / $.fileObjSize : 1; + if ($.pendingRetry) return 0; + if ((!$.xhr || !$.xhr.status) && !$.markComplete) factor *= 0.95; + var s = $.status(); + switch (s) { + case "success": + case "error": + return 1 * factor; + case "pending": + return 0 * factor; + default: + return ($.loaded / ($.endByte - $.startByte)) * factor; + } + }; + return this; + } + + // QUEUE + $.uploadNextChunk = function () { + var found = false; + + // In some cases (such as videos) it's really handy to upload the first + // and last chunk of a file quickly; this let's the server check the file's + // metadata and determine if there's even a point in continuing. + if ($.getOpt("prioritizeFirstAndLastChunk")) { + $h.each($.files, function (file) { + if (file.chunks.length && file.chunks[0].status() == "pending" && file.chunks[0].preprocessState === 0) { + file.chunks[0].send(); + found = true; + return false; + } + if ( + file.chunks.length > 1 && + file.chunks[file.chunks.length - 1].status() == "pending" && + file.chunks[file.chunks.length - 1].preprocessState === 0 + ) { + file.chunks[file.chunks.length - 1].send(); + found = true; + return false; + } + }); + if (found) return true; + } + + // Now, simply look for the next, best thing to upload + $h.each($.files, function (file) { + found = file.upload(); + if (found) return false; + }); + if (found) return true; + + // The are no more outstanding chunks to upload, check is everything is done + var outstanding = false; + $h.each($.files, function (file) { + if (!file.isComplete()) { + outstanding = true; + return false; + } + }); + if (!outstanding) { + // All chunks have been uploaded, complete + $.fire("complete"); + } + return false; + }; + + // PUBLIC METHODS FOR RESUMABLE.JS + $.assignBrowse = function (domNodes, isDirectory) { + if (typeof domNodes.length == "undefined") domNodes = [domNodes]; + $h.each(domNodes, function (domNode) { + var input; + if (domNode.tagName === "INPUT" && domNode.type === "file") { + input = domNode; + } else { + input = document.createElement("input"); + input.setAttribute("type", "file"); + input.style.display = "none"; + domNode.addEventListener( + "click", + function () { + input.style.opacity = 0; + input.style.display = "block"; + input.focus(); + input.click(); + input.style.display = "none"; + }, + false, + ); + domNode.appendChild(input); + } + var maxFiles = $.getOpt("maxFiles"); + if (typeof maxFiles === "undefined" || maxFiles != 1) { + input.setAttribute("multiple", "multiple"); + } else { + input.removeAttribute("multiple"); + } + if (isDirectory) { + input.setAttribute("webkitdirectory", "webkitdirectory"); + } else { + input.removeAttribute("webkitdirectory"); + } + var fileTypes = $.getOpt("fileType"); + if (typeof fileTypes !== "undefined" && fileTypes.length >= 1) { + input.setAttribute( + "accept", + fileTypes + .map(function (e) { + e = e.replace(/\s/g, "").toLowerCase(); + if (e.match(/^[^.][^/]+$/)) { + e = "." + e; + } + return e; + }) + .join(","), + ); + } else { + input.removeAttribute("accept"); + } + // When new files are added, simply append them to the overall list + input.addEventListener( + "change", + function (e) { + appendFilesFromFileList(e.target.files, e); + var clearInput = $.getOpt("clearInput"); + if (clearInput) { + e.target.value = ""; + } + }, + false, + ); + }); + }; + $.assignDrop = function (domNodes) { + if (typeof domNodes.length == "undefined") domNodes = [domNodes]; + + $h.each(domNodes, function (domNode) { + domNode.addEventListener("dragover", onDragOverEnter, false); + domNode.addEventListener("dragenter", onDragOverEnter, false); + domNode.addEventListener("dragleave", onDragLeave, false); + domNode.addEventListener("drop", onDrop, false); + }); + }; + $.unAssignDrop = function (domNodes) { + if (typeof domNodes.length == "undefined") domNodes = [domNodes]; + + $h.each(domNodes, function (domNode) { + domNode.removeEventListener("dragover", onDragOverEnter); + domNode.removeEventListener("dragenter", onDragOverEnter); + domNode.removeEventListener("dragleave", onDragLeave); + domNode.removeEventListener("drop", onDrop); + }); + }; + $.isUploading = function () { + var uploading = false; + $h.each($.files, function (file) { + if (file.isUploading()) { + uploading = true; + return false; + } + }); + return uploading; + }; + $.upload = function () { + // Make sure we don't start too many uploads at once + if ($.isUploading()) return; + // Kick off the queue + $.fire("uploadStart"); + for (var num = 1; num <= $.getOpt("simultaneousUploads"); num++) { + $.uploadNextChunk(); + } + }; + $.pause = function () { + // Resume all chunks currently being uploaded + $h.each($.files, function (file) { + file.abort(); + }); + $.fire("pause"); + }; + $.cancel = function () { + $.fire("beforeCancel"); + for (var i = $.files.length - 1; i >= 0; i--) { + $.files[i].cancel(); + } + $.fire("cancel"); + }; + $.progress = function () { + var totalDone = 0; + var totalSize = 0; + // Resume all chunks currently being uploaded + $h.each($.files, function (file) { + totalDone += file.progress() * file.size; + totalSize += file.size; + }); + return totalSize > 0 ? totalDone / totalSize : 0; + }; + $.addFile = function (file, event) { + appendFilesFromFileList([file], event); + }; + $.addFiles = function (files, event) { + appendFilesFromFileList(files, event); + }; + $.removeFile = function (file) { + for (var i = $.files.length - 1; i >= 0; i--) { + if ($.files[i] === file) { + $.files.splice(i, 1); + } + } + }; + $.getFromUniqueIdentifier = function (uniqueIdentifier) { + var ret = false; + $h.each($.files, function (f) { + if (f.uniqueIdentifier == uniqueIdentifier) ret = f; + }); + return ret; + }; + $.getSize = function () { + var totalSize = 0; + $h.each($.files, function (file) { + totalSize += file.size; + }); + return totalSize; + }; + $.handleDropEvent = function (e) { + onDrop(e); + }; + $.handleChangeEvent = function (e) { + appendFilesFromFileList(e.target.files, e); + e.target.value = ""; + }; + $.updateQuery = function (query) { + $.opts.query = query; + }; + + return this; + }; + + // Node.js-style export for Node and Component + if (typeof module != "undefined") { + // left here for backwards compatibility + module.exports = Resumable; + module.exports.Resumable = Resumable; + } else if (typeof define === "function" && define.amd) { + // AMD/requirejs: Define the module + define(function () { + return Resumable; + }); + } else { + // Browser: Expose to window + window.Resumable = Resumable; + } +})(); diff --git a/client/src/jsconfig.json b/client/src/jsconfig.json new file mode 100644 index 0000000..2f547e4 --- /dev/null +++ b/client/src/jsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@wp/pages": ["./pages/_index.js"], + "@wp/components": ["./pages/components/_index.js"], + "@wp/env/*": ["./env/*"], + "@wp/lib/*": ["./lib/*"], + "@wp/js/*": ["./js/*"], + "@wp/css/*": ["./css/*"], + "@wp/assets/*": ["./assets/*"], + "@wp/api": ["./api/_index.js"], + "@wp/utils": ["./utils/_index.js"] + }, + "target": "es6", + "module": "es6", + "lib": ["dom", "dom.iterable"], + "moduleResolution": "node", + "allowSyntheticDefaultImports": true + } +} diff --git a/client/src/lib/_index.js b/client/src/lib/_index.js new file mode 100644 index 0000000..4e67407 --- /dev/null +++ b/client/src/lib/_index.js @@ -0,0 +1 @@ +export { validateImportKey } from "./imports/_index.js"; diff --git a/client/src/lib/imports/lib.js b/client/src/lib/imports/lib.js new file mode 100644 index 0000000..e2be172 --- /dev/null +++ b/client/src/lib/imports/lib.js @@ -0,0 +1,148 @@ +import { isLowerCase } from "@wp/utils"; + +/** + * @typedef ValidationResult + * @property {boolean} isValid + * @property {string[]} [errors] + * @property {any} [result] A modified result, if any. + */ + +/** + * @callback KeyValidator + * @param {string} key + * @param {string[]} errors + * @returns {string[]} An array of error messages, if any. + */ + +const maxLength = 1024; + +/** + * @type {Record} + */ +const serviceConstraints = { + patreon: patreonKey, + fanbox: fanboxKey, + gumroad: gumroadKey, + subscribestar: subscribestarKey, + dlsite: dlsiteKey, + discord: discordKey, + fantia: fantiaKey, +}; + +/** + * Validates the key according to these rules: + * - Trim spaces from both sides. + * @param {string} key + * @param {string} service + * @returns {ValidationResult} + */ +export function validateImportKey(key, service) { + const formattedKey = key.trim(); + const errors = serviceConstraints[service](key, []); + + return { + isValid: !errors.length, + errors, + result: formattedKey, + }; +} + +/** + * @type KeyValidator + */ +function patreonKey(key, errors) { + const reqLength = 43; + if (key.length !== reqLength) { + errors.push(`The key length of "${key.length}" is not a valid Patreon key. Required length: "${reqLength}".`); + } + + return errors; +} + +/** + * @type KeyValidator + */ +function fanboxKey(key, errors) { + const pattern = /^\d+_\w+$/i; + + if (key.length > maxLength) { + errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`); + } + + if (!key.match(pattern)) { + errors.push(`The key doesn't match the required pattern of "${String(pattern)}"`); + } + + return errors; +} + +/** + * @type KeyValidator + */ +function fantiaKey(key, errors) { + const reqLengths = [32, 64]; + + if (reqLengths.map((reqLength) => key.length !== reqLength).every((v) => v === false)) { + errors.push( + `The key length of "${key.length}" is not a valid Fantia key. ` + `Accepted lengths: ${reqLengths.join(", ")}.`, + ); + } + + if (!isLowerCase(key)) { + errors.push(`The key is not in lower case.`); + } + + return errors; +} + +/** + * @type KeyValidator + */ +function gumroadKey(key, errors) { + const minLength = 200; + + if (key.length < minLength) { + errors.push(`The key length of "${key.length}" is less than minimum required "${minLength}".`); + } + + if (key.length > maxLength) { + errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`); + } + + return errors; +} + +/** + * @type KeyValidator + */ +function subscribestarKey(key, errors) { + if (key.length > maxLength) { + errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`); + } + + return errors; +} + +/** + * @type KeyValidator + */ +function dlsiteKey(key, errors) { + if (key.length > maxLength) { + errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`); + } + + return errors; +} + +/** + * @type KeyValidator + */ +function discordKey(key, errors) { + const pattern = /(mfa.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}.[a-z0-9_-]{6,7}.[a-z0-9_-]{27})/i; + + if (!key.match(pattern)) { + errors.push(`The key doesn't match the required pattern of "${String(pattern)}".`); + } + + return errors; +} diff --git a/client/src/pages/_index.js b/client/src/pages/_index.js new file mode 100644 index 0000000..76948bd --- /dev/null +++ b/client/src/pages/_index.js @@ -0,0 +1,43 @@ +import { userPage } from "./user"; +import { viewLinkedAccountsPage } from "./artist/linked_accounts.js"; +import { newLinkedAccountPage } from "./artist/new_linked_account.js"; +import { changePasswordPage, registerPage } from "./account/_index.js"; +import { postPage } from "./post"; +import { importerPage } from "./importer_list"; +import { importerStatusPage } from "./importer_status"; +import { postsPage } from "./posts"; +import { artistsPage } from "./artists"; +import { updatedPage } from "./updated"; +import { uploadPage } from "./upload"; +import { searchHashPage } from "./search_hash"; +import { registerPaginatorKeybinds } from "@wp/components"; +import { reviewDMsPage } from "./review_dms/dms"; +import { creatorLinksPage } from "./account/moderator/creator_links"; + +export { adminPageScripts } from "./account/administrator/_index.js"; +export { moderatorPageScripts } from "./account/moderator/_index.js"; +/** + * The map of page names and their callbacks. + */ +export const globalPageScripts = new Map([ + ["user", [userPage]], + ["register", [registerPage]], + ["change-password", [changePasswordPage]], + ["linked-account", [viewLinkedAccountsPage]], + ["new-linked-account", [newLinkedAccountPage]], + ["post", [postPage]], + ["importer", [importerPage]], + ["importer-status", [importerStatusPage]], + ["posts", [postsPage]], + ["popular-posts", [postsPage]], + ["artists", [artistsPage]], + ["updated", [updatedPage]], + ["upload", [uploadPage]], + ["all-dms", [registerPaginatorKeybinds]], + ["favorites", [registerPaginatorKeybinds]], + ["file-hash-search", [searchHashPage]], + ["review-dms", [reviewDMsPage]], + // trying to load moderator scripts by initSections(moderatorPageScripts) breaks this pile of junk + // so it's going here instead + ["moderator-creator-links", [creatorLinksPage]], +]); diff --git a/client/src/pages/_index.scss b/client/src/pages/_index.scss new file mode 100644 index 0000000..76d2a0e --- /dev/null +++ b/client/src/pages/_index.scss @@ -0,0 +1,12 @@ +@use "components"; +@use "home"; +@use "post"; +@use "artist"; +@use "user"; +@use "review_dms"; +@use "importer_status"; +@use "posts"; +@use "favorites"; +@use "account"; +@use "upload"; +@use "tags"; diff --git a/client/src/pages/account/_index.js b/client/src/pages/account/_index.js new file mode 100644 index 0000000..43d0ac4 --- /dev/null +++ b/client/src/pages/account/_index.js @@ -0,0 +1,2 @@ +export { registerPage } from "./register.js"; +export { changePasswordPage } from "./change_password.js"; diff --git a/client/src/pages/account/_index.scss b/client/src/pages/account/_index.scss new file mode 100644 index 0000000..78ca863 --- /dev/null +++ b/client/src/pages/account/_index.scss @@ -0,0 +1,5 @@ +@use "home"; +@use "components"; +@use "notifications"; +@use "keys"; +@use "moderator"; diff --git a/client/src/pages/account/administrator/_index.js b/client/src/pages/account/administrator/_index.js new file mode 100644 index 0000000..b892de3 --- /dev/null +++ b/client/src/pages/account/administrator/_index.js @@ -0,0 +1,4 @@ +/** + * @type {Map void>} + */ +export const adminPageScripts = new Map(); diff --git a/client/src/pages/account/administrator/_index.scss b/client/src/pages/account/administrator/_index.scss new file mode 100644 index 0000000..08e6287 --- /dev/null +++ b/client/src/pages/account/administrator/_index.scss @@ -0,0 +1,2 @@ +@use "accounts"; +@use "shell"; diff --git a/client/src/pages/account/administrator/account_files.html b/client/src/pages/account/administrator/account_files.html new file mode 100644 index 0000000..f3ec702 --- /dev/null +++ b/client/src/pages/account/administrator/account_files.html @@ -0,0 +1,20 @@ +{% extends 'account/administrator/shell.html' %} + +{% block content %} + +{% endblock content %} diff --git a/client/src/pages/account/administrator/account_info.html b/client/src/pages/account/administrator/account_info.html new file mode 100644 index 0000000..3fccee2 --- /dev/null +++ b/client/src/pages/account/administrator/account_info.html @@ -0,0 +1,21 @@ +{% extends 'account/administrator/shell.html' %} + +{% from 'components/timestamp.html' import timestamp %} + +{% block content %} + +{% endblock content %} diff --git a/client/src/pages/account/administrator/accounts.html b/client/src/pages/account/administrator/accounts.html new file mode 100644 index 0000000..4043707 --- /dev/null +++ b/client/src/pages/account/administrator/accounts.html @@ -0,0 +1,141 @@ +{% extends 'account/administrator/shell.html' %} + +{% from 'components/card_list.html' import card_list %} +{% from 'components/cards/account.html' import account_card %} +{% from 'components/paginator_new.html' import paginator, paginator_controller %} + +{% block content %} +
+
+

+ Accounts +

+
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+ {{ paginator('account-pages', request, props.pagination) }} + {% call card_list('legacy') %} + {% for account in props.accounts %} + {% if account.role == 'moderator' %} + + {% elif account.role == 'consumer'%} + + {% else %} + {% endif %} + {% else %} +

No accounts found.

+ {% endfor %} + {% endcall %} + {# {{ paginator('account-pages', request, props.pagination) }} #} + {% if props.accounts | length %} +
+ +
+ {% endif %} +
+ {{ paginator_controller( + 'account-pages', + request, + props.pagination + ) }} +
+{% endblock content %} diff --git a/client/src/pages/account/administrator/accounts.scss b/client/src/pages/account/administrator/accounts.scss new file mode 100644 index 0000000..4fe69ef --- /dev/null +++ b/client/src/pages/account/administrator/accounts.scss @@ -0,0 +1,76 @@ +@use "../../../css/config/variables" as *; + +.site-section--admin-accounts { + .account { + &__view { + position: relative; + padding: 0; + margin-bottom: 3em; + + &--promote { + & .account__info { + border-radius: 10px 10px 0 10px; + } + + & .account__label { + right: 0; + } + } + + &--demote { + & .account__info { + border-radius: 10px 10px 10px 0; + } + + & .account__label { + left: 0; + } + } + } + + &__role-check { + position: absolute; + visibility: hidden; + opacity: 0; + max-width: 1px; + + &:checked + .account__info { + --local-colour1: var(--colour1-tertiary); + --local-background-colour1: var(--submit-colour1-primary); + --local-border-colour1: var(--submit-colour1-primary); + } + } + + &__info { + --local-colour1: var(--colour0-primary); + --local-background-colour1: var(--colour1-tertiary); + --local-border-colour1: var(--colour1-tertiary); + + max-width: var(--card-size); + height: 100%; + border-radius: 10px; + border: $size-thin solid var(--local-border-colour1); + overflow: hidden; + transition-duration: var(--duration-global); + transition-property: border-color; + + & .account-card { + height: 100%; + border-radius: 0; + } + } + + &__label { + position: absolute; + top: 100%; + color: var(--local-colour1); + background-color: var(--local-background-colour1); + border-radius: 0 0 10px 10px; + border: $size-thin solid var(--local-border-colour1); + border-top: none; + padding: $size-small; + transition-duration: var(--duration-global); + transition-property: color, background-color, border-color; + } + } +} diff --git a/client/src/pages/account/administrator/dashboard.html b/client/src/pages/account/administrator/dashboard.html new file mode 100644 index 0000000..7ba801e --- /dev/null +++ b/client/src/pages/account/administrator/dashboard.html @@ -0,0 +1,18 @@ +{% extends 'account/administrator/shell.html' %} + +{% block content %} +
+
+

+ Admin dashboard +

+
+ +
+{% endblock content %} diff --git a/client/src/pages/account/administrator/mods_actions.html b/client/src/pages/account/administrator/mods_actions.html new file mode 100644 index 0000000..44a8f89 --- /dev/null +++ b/client/src/pages/account/administrator/mods_actions.html @@ -0,0 +1,18 @@ +{% extends 'account/administrator/shell.html' %} + +{% block content %} +
+
+

+ Moderator actions +

+
+
    + {% for action in props.actions %} +
  • action
  • + {% else %} +
  • No actions found
  • + {% endfor %} +
+
+{% endblock content %} diff --git a/client/src/pages/account/administrator/shell.html b/client/src/pages/account/administrator/shell.html new file mode 100644 index 0000000..8a8724f --- /dev/null +++ b/client/src/pages/account/administrator/shell.html @@ -0,0 +1,18 @@ +{% extends 'components/shell.html' %} + +{# TODO: filter only admin entry #} +{% block bundler_output %} + <% for (const css in htmlWebpackPlugin.files.css) { %> + <% if (htmlWebpackPlugin.files.css[css].startsWith("/static/bundle/css/admin")) { %> + + <% } %> + <% } %> + <% for (const chunk in htmlWebpackPlugin.files.chunks) { %> + + <% } %> + <% for (const scriptPath in htmlWebpackPlugin.files.js) { %> + <% if (htmlWebpackPlugin.files.js[scriptPath].startsWith("/static/bundle/js/admin") || htmlWebpackPlugin.files.js[scriptPath].startsWith("/static/bundle/js/runtime") || htmlWebpackPlugin.files.js[scriptPath].startsWith("/static/bundle/js/vendors")) { %> + + <% } %> + <% } %> +{% endblock bundler_output %} diff --git a/client/src/pages/account/administrator/shell.scss b/client/src/pages/account/administrator/shell.scss new file mode 100644 index 0000000..0dc0330 --- /dev/null +++ b/client/src/pages/account/administrator/shell.scss @@ -0,0 +1 @@ +@use "../../components/shell"; diff --git a/client/src/pages/account/change_password.html b/client/src/pages/account/change_password.html new file mode 100644 index 0000000..5064837 --- /dev/null +++ b/client/src/pages/account/change_password.html @@ -0,0 +1,44 @@ +{% extends "components/shell.html" %} + +{% block content %} + +{% endblock %} diff --git a/client/src/pages/account/change_password.js b/client/src/pages/account/change_password.js new file mode 100644 index 0000000..5119ac6 --- /dev/null +++ b/client/src/pages/account/change_password.js @@ -0,0 +1,45 @@ +export function changePasswordPage() { + let passwordInput = () => document.getElementById("current-password"); + let newPasswordInput = () => document.getElementById("new-password"); + let newPasswordConfirmationInput = () => document.getElementById("new-password-confirmation"); + let submitButton = () => document.getElementById("submit"); + + let passCharCount = () => document.getElementById("password-char-count"); + let newPassCharCount = () => document.getElementById("new-password-char-count"); + let passMatches = () => document.getElementById("password-confirm-matches"); + + function doValidate(e) { + let password = passwordInput().value; + let newPassword = newPasswordInput().value; + let newPasswordConfirmation = newPasswordConfirmationInput().value; + let errors = false; + + if (password.length < 5) { + errors = true; + passCharCount().classList.add("invalid"); + } else { + passCharCount().classList.remove("invalid"); + } + + if (newPassword.length < 5) { + errors = true; + newPassCharCount().classList.add("invalid"); + } else { + newPassCharCount().classList.remove("invalid"); + } + + if (newPassword != newPasswordConfirmation || newPassword.length < 5) { + errors = true; + passMatches().classList.add("invalid"); + } else { + passMatches().classList.remove("invalid"); + } + + submitButton().disabled = errors; + } + + doValidate(); + passwordInput().addEventListener("input", doValidate); + newPasswordInput().addEventListener("input", doValidate); + newPasswordConfirmationInput().addEventListener("input", doValidate); +} diff --git a/client/src/pages/account/components/_index.scss b/client/src/pages/account/components/_index.scss new file mode 100644 index 0000000..cfb37e0 --- /dev/null +++ b/client/src/pages/account/components/_index.scss @@ -0,0 +1,2 @@ +@use "notification"; +@use "service_key"; diff --git a/client/src/pages/account/components/notification.html b/client/src/pages/account/components/notification.html new file mode 100644 index 0000000..81aaf48 --- /dev/null +++ b/client/src/pages/account/components/notification.html @@ -0,0 +1,20 @@ +{% from 'components/timestamp.html' import timestamp %} + +{% macro ACCOUNT_ROLE_CHANGE(extra_info) %} + Your role was changed from {{ extra_info.old_role }} to {{ extra_info.new_role }}. +{% endmacro %} + +{% set notification_types = { + 1: ACCOUNT_ROLE_CHANGE +} %} + +{% macro notification_item(notification) %} +
  • + + {{ timestamp(notification.created_at) }} + + + {{ notification_types[notification.type](notification.extra_info) }} + +
  • +{% endmacro %} diff --git a/client/src/pages/account/components/notification.scss b/client/src/pages/account/components/notification.scss new file mode 100644 index 0000000..545d1dd --- /dev/null +++ b/client/src/pages/account/components/notification.scss @@ -0,0 +1,7 @@ +.notifications { + &__item { + &--seen { + opacity: 0.5; + } + } +} diff --git a/client/src/pages/account/components/service_key.html b/client/src/pages/account/components/service_key.html new file mode 100644 index 0000000..0c0d04a --- /dev/null +++ b/client/src/pages/account/components/service_key.html @@ -0,0 +1,50 @@ +{% from 'components/cards/base.html' import card, card_header, card_body, card_footer %} +{% from 'components/timestamp.html' import timestamp %} + +{% macro service_key_card(service_key, import_ids, class_name= none) %} + {% set paysite = g.paysites[service_key.service] %} + + {% call card(class_name= class_name) %} + {% call card_header() %} +

    + {{ paysite.title }} +

    + {% endcall %} + + {% call card_body() %} +
    +
    +
    Status:
    + {% if not service_key.dead %} +
    + Alive +
    + {% else %} +
    + Dead +
    + {% endif %} +
    +
    + {% if import_ids %} +
    Logs
    + + {% endif %} +
    +
    + {% endcall %} + + {% call card_footer() %} +
    +
    +
    Added:
    +
    {{ timestamp(service_key.added) }}
    +
    +
    + {% endcall %} + {% endcall %} +{% endmacro %} diff --git a/client/src/pages/account/components/service_key.scss b/client/src/pages/account/components/service_key.scss new file mode 100644 index 0000000..91d8e1d --- /dev/null +++ b/client/src/pages/account/components/service_key.scss @@ -0,0 +1,18 @@ +.service-key { + &__stats { + } + + &__stat { + & > * { + display: inline; + } + } + + &__status { + color: var(--positive-colour1-primary); + + &--dead { + color: var(--negative-colour1-primary); + } + } +} diff --git a/client/src/pages/account/home.html b/client/src/pages/account/home.html new file mode 100644 index 0000000..ab65f36 --- /dev/null +++ b/client/src/pages/account/home.html @@ -0,0 +1,80 @@ +{% extends 'components/shell.html' %} + +{% from 'components/image_link.html' import image_link %} + +{% from 'components/timestamp.html' import timestamp %} +{% from 'components/links.html' import kemono_link %} + +{% set role_links = { + "consumer": "/account", + "moderator": "/account/moderator", + "administrator": "/account/administrator" +} %} + +{% block title %} + {{ props.title }} +{% endblock title %} + +{% block content %} + +{% endblock content %} diff --git a/client/src/pages/account/home.scss b/client/src/pages/account/home.scss new file mode 100644 index 0000000..627badb --- /dev/null +++ b/client/src/pages/account/home.scss @@ -0,0 +1,35 @@ +.site-section--account { + max-width: 480px; + background-color: #282a2e; + box-shadow: 1px 2px 5px 4px rgb(0 0 0 / 0.2); + border-radius: 4px; + // border: 0.5px solid rgba(17, 17, 17, 1); + & .account-view { + margin: 0 auto; + padding: 8px; + + &__header { + text-align: center; + } + + &__greeting { + color: hsl(0, 0%, 45%); + font-weight: 300; + font-size: 28px; + } + + &__identity { + color: #fff; + font-weight: normal; + } + + &__info { + font-size: 12px; + color: hsl(0, 0%, 45%); + } + + &__role { + text-transform: capitalize; + } + } +} diff --git a/client/src/pages/account/keys.html b/client/src/pages/account/keys.html new file mode 100644 index 0000000..8879a90 --- /dev/null +++ b/client/src/pages/account/keys.html @@ -0,0 +1,53 @@ +{% extends 'components/shell.html' %} + +{% import 'components/site.html' as site %} +{% from 'components/forms/base.html' import form %} +{% from 'components/forms/submit_button.html' import submit_button %} +{% from 'components/card_list.html' import card_list %} +{% from 'components/cards/no_results.html' import no_results %} +{% from 'account/components/service_key.html' import service_key_card %} + +{% set page_title = 'Manage saved keys | ' ~ g.site_name %} + +{% block title %} + + {{ page_title }} + +{% endblock title %} + +{% block content %} +{% call site.section('account-keys', 'Stored service keys') %} + {% call card_list() %} + {% for key in props.service_keys %} +
    + +
    + {{ service_key_card(key, import_ids[loop.index0], class_name="key__card") }} + +
    +
    + {% else %} + {{ no_results() }} + {% endfor %} + {% endcall %} + {% if props.service_keys %} + {% call form( + id= 'revoke-service-keys', + action = '/account/keys', + method= 'POST' + ) %} + {{ submit_button('Revoke selected keys') }} + {% endcall %} + {% endif %} +{% endcall %} +{% endblock content %} diff --git a/client/src/pages/account/keys.scss b/client/src/pages/account/keys.scss new file mode 100644 index 0000000..a6c4813 --- /dev/null +++ b/client/src/pages/account/keys.scss @@ -0,0 +1,58 @@ +@use "../../css/config/variables" as *; + +.site-section--account-keys { + .key { + &__view { + position: relative; + padding: 0; + margin-bottom: 3em; + } + + &__revoke-check { + position: absolute; + visibility: hidden; + opacity: 0; + max-width: 1px; + + &:checked + .key__info { + --local-colour1: var(--colour1-tertiary); + --local-background-colour1: var(--negative-colour1-primary); + --local-border-colour1: var(--negative-colour1-primary); + } + } + + &__info { + --local-colour1: var(--colour0-primary); + --local-background-colour1: var(--colour1-tertiary); + --local-border-colour1: var(--colour1-tertiary); + + max-width: var(--card-size); + height: 100%; + border-radius: 10px 10px 0 10px; + border: $size-thin solid var(--local-border-colour1); + overflow: hidden; + transition-duration: var(--duration-global); + transition-property: border-color; + + & .key__card { + height: 100%; + border-radius: 0; + } + } + + // &__card {} + &__label { + position: absolute; + top: 100%; + right: 0; + color: var(--local-colour1); + background-color: var(--local-background-colour1); + border-radius: 0 0 10px 10px; + border: $size-thin solid var(--local-border-colour1); + border-top: none; + padding: $size-small; + transition-duration: var(--duration-global); + transition-property: color, background-color, border-color; + } + } +} diff --git a/client/src/pages/account/login.html b/client/src/pages/account/login.html new file mode 100644 index 0000000..7208ba0 --- /dev/null +++ b/client/src/pages/account/login.html @@ -0,0 +1,50 @@ +{% extends 'components/shell.html' %} +{% block content %} + +{% endblock %} diff --git a/client/src/pages/account/moderator/_index.js b/client/src/pages/account/moderator/_index.js new file mode 100644 index 0000000..110ce34 --- /dev/null +++ b/client/src/pages/account/moderator/_index.js @@ -0,0 +1,4 @@ +/** + * @type {Map void} + */ +export const moderatorPageScripts = new Map(); diff --git a/client/src/pages/account/moderator/_index.scss b/client/src/pages/account/moderator/_index.scss new file mode 100644 index 0000000..bf6869d --- /dev/null +++ b/client/src/pages/account/moderator/_index.scss @@ -0,0 +1,48 @@ +@use "../../../css/config/variables" as *; + +.site-section--moderator-dashboard { + margin-left: auto; + margin-right: auto; + max-width: 960px; +} + +section#card-list { + max-width: 960px; + margin-left: auto; + margin-right: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); + gap: 10px; + + article.link-request-card { + width: 310px; + background-color: #282a2e; + padding: 0.5em; + border-radius: 5px; + + h6 { + color: var(--colour0-secondary); + } + + .control { + text-align: center; + padding-top: 0.5em; + + button { + border: none; + background-color: var(--colour1-primary); + font-size: 150%; + width: calc((310px - 2em) / 2); + height: 36px; + + &.approve { + color: green; + } + + &.reject { + color: red; + } + } + } + } +} diff --git a/client/src/pages/account/moderator/creator_links.html b/client/src/pages/account/moderator/creator_links.html new file mode 100644 index 0000000..bba7bb0 --- /dev/null +++ b/client/src/pages/account/moderator/creator_links.html @@ -0,0 +1,55 @@ +{% extends "components/shell.html" %} + +{% block content %} + +{% endblock content %} diff --git a/client/src/pages/account/moderator/creator_links.js b/client/src/pages/account/moderator/creator_links.js new file mode 100644 index 0000000..9523cc3 --- /dev/null +++ b/client/src/pages/account/moderator/creator_links.js @@ -0,0 +1,22 @@ +export function creatorLinksPage() { + Array.from(document.querySelectorAll(".link-request-card")).forEach(card => { + card.querySelector(".control > .approve").addEventListener("click", async (_e) => { + await approveLinkRequest(card.dataset["id"]); + card.remove(); + }); + card.querySelector(".control > .reject").addEventListener("click", async (_e) => { + await rejectLinkRequest(card.dataset["id"]); + card.remove(); + }); + }) +} + +async function approveLinkRequest(requestId) { + let resp = await fetch(`/creator_link_requests/${requestId}/approve`, { method: "POST" }); + let json = await resp.json(); +} + +async function rejectLinkRequest(requestId) { + let resp = await fetch(`/creator_link_requests/${requestId}/reject`, { method: "POST" }); + let json = await resp.json(); +} diff --git a/client/src/pages/account/moderator/dashboard.html b/client/src/pages/account/moderator/dashboard.html new file mode 100644 index 0000000..36a7af4 --- /dev/null +++ b/client/src/pages/account/moderator/dashboard.html @@ -0,0 +1,18 @@ +{% extends 'components/shell.html' %} + +{% block content %} +
    +
    +

    + Moderator room +

    +
    + +
    +{% endblock content %} diff --git a/client/src/pages/account/moderator/files.html b/client/src/pages/account/moderator/files.html new file mode 100644 index 0000000..a9baeea --- /dev/null +++ b/client/src/pages/account/moderator/files.html @@ -0,0 +1,11 @@ +{% extends 'components/shell.html' %} + +{% block content %} +
    +
    +

    + Files for review +

    +
    +
    +{% endblock content %} diff --git a/client/src/pages/account/notifications.html b/client/src/pages/account/notifications.html new file mode 100644 index 0000000..bdab237 --- /dev/null +++ b/client/src/pages/account/notifications.html @@ -0,0 +1,27 @@ +{% extends 'components/shell.html' %} + +{% from 'components/timestamp.html' import timestamp %} +{% from 'account/components/notification.html' import notification_item %} + +{% block title %} + Account notificatons +{% endblock title %} + +{% block content %} + +{% endblock content %} diff --git a/client/src/pages/account/notifications.scss b/client/src/pages/account/notifications.scss new file mode 100644 index 0000000..6207a3f --- /dev/null +++ b/client/src/pages/account/notifications.scss @@ -0,0 +1,19 @@ +@use "../../css/config/variables" as *; + +.site-section--account-notifications { + .notifications { + &__list { + list-style: none; + padding: 0; + } + + &__item { + } + + &__date { + } + + &__message { + } + } +} diff --git a/client/src/pages/account/register.html b/client/src/pages/account/register.html new file mode 100644 index 0000000..6b276ad --- /dev/null +++ b/client/src/pages/account/register.html @@ -0,0 +1,73 @@ +{% extends 'components/shell.html' %} +{% block content %} + + +{% endblock %} diff --git a/client/src/pages/account/register.js b/client/src/pages/account/register.js new file mode 100644 index 0000000..0ee8c7c --- /dev/null +++ b/client/src/pages/account/register.js @@ -0,0 +1,96 @@ +import "./register.scss"; + +/** + * @param {HTMLElement} section + */ +export function registerPage(section) { + populate_favorites(); + input_validation(); +} + +function populate_favorites() { + var input = document.getElementById("serialized-favorites"); + var favorites = localStorage.getItem("favorites"); + var to_serialize = []; + if (input && favorites) { + var artists = favorites.split(","); + artists.forEach(function (artist) { + var split = artist.split(":"); + if (split.length != 2) { + return; + } + var elem = { + service: split[0], + artist_id: split[1], + }; + to_serialize.push(elem); + }); + var serialized = JSON.stringify(to_serialize); + input.value = serialized; + } +} + + +function input_validation() { + const USERNAME_INPUT = () => document.getElementById("new-username"); + const PASSWORD_INPUT = () => document.getElementById("new-password"); + const PASSWORD_CONFIRM_INPUT = () => document.getElementById("password-confirm"); + const SUBMIT_BUTTON = () => document.getElementById("submit"); + + const USER_CHAR_COUNT = () => document.getElementById("username-char-count"); + const ALLOWED_CHARS = () => document.getElementById("username-allowed-characters"); + const PASS_CHAR_COUNT = () => document.getElementById("password-char-count"); + const PASSWORD_CONFIRM = () => document.getElementById("password-confirm-matches"); + + const USERNAME_PAT = new RegExp("^" + document.getElementById("register_form").dataset["pattern"] + "$"); + const NOT_ALLOWED_CHARS_PAT = new RegExp(`[^a-z0-9_@+.\-]`, 'g'); + + function validateInputs(e) { + let errors = false; + let username = USERNAME_INPUT().value; + let password = PASSWORD_INPUT().value; + let passwordConfirmation = PASSWORD_CONFIRM_INPUT().value; + + if (username.length < 3 || username.length > 15) { + errors = true; + USER_CHAR_COUNT().classList.add("invalid"); + } else { + USER_CHAR_COUNT().classList.remove("invalid"); + } + + if (!username.match(USERNAME_PAT)) { + errors = true; + ALLOWED_CHARS().classList.add("invalid"); + } else { + ALLOWED_CHARS().classList.remove("invalid"); + } + + if (password.length < 5) { + errors = true; + PASS_CHAR_COUNT().classList.add("invalid"); + } else { + PASS_CHAR_COUNT().classList.remove("invalid"); + } + + if (passwordConfirmation !== password || passwordConfirmation === "") { + errors = true; + PASSWORD_CONFIRM().classList.add("invalid"); + } else { + PASSWORD_CONFIRM().classList.remove("invalid"); + } + + SUBMIT_BUTTON().disabled = errors; + } + + window.addEventListener("load", (_event) => { + USERNAME_INPUT().textContent = USERNAME_INPUT().textContent.toLowerCase().replace(NOT_ALLOWED_CHARS_PAT, ""); + validateInputs(); + USERNAME_INPUT().addEventListener("input", validateInputs); + USERNAME_INPUT().addEventListener("input", (input) => { + input.target.value = input.target.value.toLowerCase().replace(NOT_ALLOWED_CHARS_PAT, ""); + }); + PASSWORD_INPUT().addEventListener("input", validateInputs); + PASSWORD_CONFIRM_INPUT().addEventListener("input", validateInputs); + }); + +} diff --git a/client/src/pages/account/register.scss b/client/src/pages/account/register.scss new file mode 100644 index 0000000..915b014 --- /dev/null +++ b/client/src/pages/account/register.scss @@ -0,0 +1,13 @@ +.form__hint.invalid { + color: red; +} + +.form__hint { + color: green; +} + +button:disabled{ + color: hsl(0deg 0% 40%); + background-image: linear-gradient(#4a5059, #4a5059); + cursor: not-allowed; +} \ No newline at end of file diff --git a/client/src/pages/all_dms.html b/client/src/pages/all_dms.html new file mode 100644 index 0000000..a5ca44b --- /dev/null +++ b/client/src/pages/all_dms.html @@ -0,0 +1,50 @@ +{% extends 'components/shell.html' %} + +{% import 'components/site.html' as site %} +{% from 'components/card_list.html' import card_list %} +{% from 'components/cards/dm.html' import dm_card %} +{% from 'components/ads.html' import slider_ad, header_ad %} + +{% block content %} +{% call site.section("all-dms", title="DMs") %} + {{ slider_ad() }} + {{ header_ad() }} +
    + {% include 'components/paginator.html' %} +
    + + +
    +
    + + {% call card_list("phone") %} + {% for dm in props.dms %} + {{ dm_card(dm, artist=dm|attr("artist") or {}, is_global=True) }} + {% else %} +
    +

    Nobody here but us chickens!

    +

    + There are no DMs. +

    +
    + {% endfor %} + {% endcall %} + +
    + {% include 'components/paginator.html' %} +
    +{% endcall %} +{% endblock %} diff --git a/client/src/pages/all_dms.scss b/client/src/pages/all_dms.scss new file mode 100644 index 0000000..2ef8f97 --- /dev/null +++ b/client/src/pages/all_dms.scss @@ -0,0 +1,9 @@ +@use "../css/config/variables" as *; + +.site-section--all-dms { + .no-results { + --card-size: $width-phone; + padding: $size-small 0; + margin: 0 auto; + } +} diff --git a/client/src/pages/artist/_index.scss b/client/src/pages/artist/_index.scss new file mode 100644 index 0000000..7227f44 --- /dev/null +++ b/client/src/pages/artist/_index.scss @@ -0,0 +1,3 @@ +@use "dms"; +@use "fancards"; +@use "linked_accounts"; diff --git a/client/src/pages/artist/announcements.html b/client/src/pages/artist/announcements.html new file mode 100644 index 0000000..29a976f --- /dev/null +++ b/client/src/pages/artist/announcements.html @@ -0,0 +1,77 @@ +{% extends "components/shell.html" %} + +{% from "components/headers.html" import user_header %} +{% from "components/card_list.html" import card_list %} +{% from "components/cards/dm.html" import dm_card %} + +{% set paysite = g.paysites[props.service] %} +{% set page_title = "Announcements of " ~ props.artist.name ~ " from " ~ paysite.title ~ " | " ~ g.site_name %} + +{% block title %} + {{ page_title }} +{% endblock %} + +{% block meta %} + + + + +{% endblock meta %} + +{% block opengraph %} + + + + + +{% endblock opengraph %} + +{% block content %} +
    + {{ user_header(request, props) }} +
    + {% include "components/tabs.html" %} +
    +{#
    #} +{# {% include "components/tabs.html" %}#} +{# {% include "components/paginator.html" %}#} +{##} +{#
    #} +{# #} +{# #} +{#
    #} + + {% call card_list("phone") %} + {% for announcement in props.announcements %} + {{ dm_card(announcement) }} + {% else %} +
    +

    Nobody here but us chickens!

    +

    + There are no Announcements for your query. +

    +
    + {% endfor %} + {% endcall %} + +{#
    #} +{# {% include "components/paginator.html" %}#} +{#
    #} +
    +{% endblock content %} + +{% block components %} + + {{ loading_icon() }} +{% endblock components %} \ No newline at end of file diff --git a/client/src/pages/artist/dms.html b/client/src/pages/artist/dms.html new file mode 100644 index 0000000..f247812 --- /dev/null +++ b/client/src/pages/artist/dms.html @@ -0,0 +1,56 @@ +{% extends 'components/shell.html' %} + +{% from 'components/cards/dm.html' import dm_card %} +{% from 'components/headers.html' import user_header %} +{% from 'components/card_list.html' import card_list %} + +{% set paysite = g.paysites[props.service] %} +{% set page_title = 'DMs of ' ~ props.artist.name ~ ' from ' ~ paysite.title ~ ' | ' ~ g.site_name %} + +{% block title %} + {{ page_title }} +{% endblock title %} + +{% block meta %} + + + + +{% endblock meta %} + +{% block opengraph %} + + + + + +{% endblock opengraph %} + +{% block content %} +
    + {{ user_header(request, props) }} +
    + {% include "components/tabs.html" %} +
    + {% call card_list("phone") %} + {% for dm in props.dms %} + {{ dm_card(dm, artist=dm|attr("artist") or {}) }} + {% else %} +
    +

    Nobody here but us chickens!

    +

    + There are no DMs for your query. +

    +
    + {% endfor %} + {% endcall %} +
    +{% endblock content %} + +{% block components %} + + {{ loading_icon() }} +{% endblock components %} diff --git a/client/src/pages/artist/dms.scss b/client/src/pages/artist/dms.scss new file mode 100644 index 0000000..3a755a4 --- /dev/null +++ b/client/src/pages/artist/dms.scss @@ -0,0 +1,9 @@ +@use "../../css/config/variables" as *; + +.site-section--dms { + .no-results { + --card-size: $width-phone; + padding: $size-small 0; + margin: 0 auto; + } +} diff --git a/client/src/pages/artist/fancards.html b/client/src/pages/artist/fancards.html new file mode 100644 index 0000000..cd5f5fe --- /dev/null +++ b/client/src/pages/artist/fancards.html @@ -0,0 +1,64 @@ +{% extends 'components/shell.html' %} + +{% from 'components/headers.html' import user_header %} + +{% set page_title = 'Fancards of ' ~ artist.name ~ ' | ' ~ g.site_name %} + +{% block title %} + {{ page_title }} +{% endblock title %} + +{% block meta %} + + + + +{% endblock meta %} + +{% block opengraph %} + + + + + +{% endblock opengraph %} + +{% block content %} +
    + {{ user_header(request, props) }} +
    + {% include 'components/tabs.html' %} +
    +
    + {% for fancard in fancards %} +
    + Added {{ (fancard.added|simple_date)[:7] }} + + + +
    + {% else %} +
    +

    Nobody here but us chickens!

    +

    + There are no uploads for your query. +

    +
    + {% endfor %} +
    +
    +{% endblock content %} + +{% block components %} + + {{ loading_icon() }} +{% endblock components %} \ No newline at end of file diff --git a/client/src/pages/artist/fancards.scss b/client/src/pages/artist/fancards.scss new file mode 100644 index 0000000..d634373 --- /dev/null +++ b/client/src/pages/artist/fancards.scss @@ -0,0 +1,26 @@ +div#fancard-container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); + margin-top: 0.5em; + gap: 0.5em 0.25em; + + article.fancard__file { + width: 400px; + height: 293px; + display: inline-grid; + background-color: var(--colour1-secondary); + + span { + padding-top: 0.5em; + padding-left: 1em; + display: inline-block; + filter: unset; + } + + img { + padding: 1em; + filter: unset; + border-radius: 25px; + } + } +} diff --git a/client/src/pages/artist/linked_accounts.html b/client/src/pages/artist/linked_accounts.html new file mode 100644 index 0000000..08e5aae --- /dev/null +++ b/client/src/pages/artist/linked_accounts.html @@ -0,0 +1,58 @@ +{% extends "components/shell.html" %} + +{% from "components/card_list.html" import card_list %} +{% from "components/headers.html" import user_header %} +{% from "components/cards/user.html" import user_card %} + +{% set paysite = g.paysites[props.service] %} +{% set page_title = "Linked accounts for " ~ props.artist.name ~ " on " ~ paysite.title ~ " | " ~ g.site_name %} + +{% block title %} + {{ page_title }} +{% endblock %} + +{% block meta %} + + + + +{% endblock meta %} + +{% block opengraph %} + + + + + +{% endblock opengraph %} + +{% block content %} + +{% endblock %} + +{% block components %} + + {{ loading_icon() }} +{% endblock components %} \ No newline at end of file diff --git a/client/src/pages/artist/linked_accounts.js b/client/src/pages/artist/linked_accounts.js new file mode 100644 index 0000000..7cc1f31 --- /dev/null +++ b/client/src/pages/artist/linked_accounts.js @@ -0,0 +1,32 @@ +export function viewLinkedAccountsPage() { + if (localStorage.getItem("role") !== "administrator") return; + + window.addEventListener("DOMContentLoaded", (_e) => { + Array.from(document.querySelectorAll(".user-card")).forEach(card => { + let btn = document.createElement("button"); + btn.textContent = "✗"; + btn.classList = "remove-link"; + btn.addEventListener("click", async (e) => { + // apparently it only actually stops if you use both: + e.preventDefault(); + e.stopPropagation(); + + let id = card.dataset["id"]; + let service = card.dataset["service"]; + + if (confirm(`Delete the connection for user #${id} on ${service}?`)) { + if (await deleteLinkedAccount(service, id)) { + card.remove(); + } else { + alert("Error"); + } + } + }); + card.appendChild(btn); + }); + }); +} + +async function deleteLinkedAccount(service, id) { + return (await fetch(`/${service}/user/${id}/links`, { method: "DELETE" })).status == 204; +} diff --git a/client/src/pages/artist/linked_accounts.scss b/client/src/pages/artist/linked_accounts.scss new file mode 100644 index 0000000..e1fc852 --- /dev/null +++ b/client/src/pages/artist/linked_accounts.scss @@ -0,0 +1,69 @@ +div#add-new-link { + text-align: center; +} + +section.site-section--new-linked-account { + p.link-notice { + text-align: center; + } + + .card-list .user-card.selected { + border: 2px solid green; + } + + .user-card__service { + width: fit-content; + } +} + +#new_link_form { + div#artist-section { + margin-left: auto; + margin-right: auto; + display: flex; + flex-direction: row; + + span { + display: flex; + flex-direction: column; + + &:nth-child(1) { + margin-left: auto; + } + + &:nth-child(2) { + margin-left: 1em; + margin-right: auto; + } + } + } + + div#reason-section { + max-width: 360px; + margin-left: auto; + margin-right: auto; + + #reason { + width: 100%; + + &.invalid { + border: 2px solid red; + } + } + } + + #button-section { + text-align: center; + + button { + max-width: 360px; + } + } +} + +button.remove-link { + color: red; + position: absolute; + right: 1em; + bottom: 1em; +} diff --git a/client/src/pages/artist/new_linked_account.html b/client/src/pages/artist/new_linked_account.html new file mode 100644 index 0000000..6b1dfa0 --- /dev/null +++ b/client/src/pages/artist/new_linked_account.html @@ -0,0 +1,84 @@ +{% extends "components/shell.html" %} + +{% from "components/headers.html" import user_header %} + +{% set paysite = g.paysites[props.service] %} +{% set page_title = "Link a new account to " ~ props.artist.name ~ " on " ~ paysite.title ~ " | " ~ g.site_name %} + +{% block title %} + {{ page_title }} +{% endblock %} + +{% block meta %} + + + + +{% endblock meta %} + +{% block opengraph %} + + + + + +{% endblock opengraph %} + +{% block content %} + +{% endblock %} + +{% block components %} + + {{ loading_icon() }} +{% endblock components %} diff --git a/client/src/pages/artist/new_linked_account.js b/client/src/pages/artist/new_linked_account.js new file mode 100644 index 0000000..e7b1830 --- /dev/null +++ b/client/src/pages/artist/new_linked_account.js @@ -0,0 +1,111 @@ +import { debounce } from "../artists.js"; +import { kemonoAPI } from "@wp/api"; +import { freesites, paysites } from "@wp/utils"; +import { BANNERS_PREPEND, ICONS_PREPEND } from "@wp/env/env-vars"; + +/** + * @type {Array} + */ +let ALL_CREATORS; + +export function newLinkedAccountPage() { + document.getElementById("service").addEventListener("input", debounce(testUser, 300)) + document.getElementById("creator_name").addEventListener("input", debounce(testUser, 300)) + document.getElementById("reason").addEventListener("input", debounce(validateReason, 300)) + testUser(); +} + +function validateReason() { + let reason = document.getElementById("reason"); + + if (reason.value.length > 140) { + reason.title = "Too long (140 max)"; + reason.classList.add("invalid"); + } else { + reason.title = ""; + reason.classList.remove("invalid"); + } +} + +async function testUser() { + if (ALL_CREATORS === undefined) { + ALL_CREATORS = await kemonoAPI.api.creators(); + } + + const CURRENT_ARTIST_ID = document.querySelector("meta[name='id']").content; + const CURRENT_ARTIST_SERVICE = document.querySelector("meta[name='service']").content; + let service = document.getElementById("service").value; + let creatorName = document.getElementById("creator_name").value.toLowerCase(); + let button = document.getElementById("submit"); + let resultBox = document.getElementById("lookup-result"); + + button.disabled = true; + + let items = ALL_CREATORS.filter(item => { + console.log(item); + return !(item.service === CURRENT_ARTIST_SERVICE && item.id === CURRENT_ARTIST_ID) + && (creatorName? item.name.toLowerCase().includes(creatorName) : true) + && (service !== "all"? item.service === service : true); + }).slice(0, 20).map(createCard); + console.log(items); + resultBox.replaceChildren.apply(resultBox, items); +} + +function createCard({id, name, service, indexed, updated, favorited}) { + // would like to use initUserCardFromScratch here, but it doesn't work. + // why would components be reuseable, after all? + + // so I'll just recreate the entire function here! + let updatedDate; + + if (updated || indexed) { + updatedDate = new Date((updated || indexed) * 1000).toISOString(); + } + + let profileIcon = freesites.kemono.user.icon(service, id); + let profileBanner = freesites.kemono.user.banner(service, id); + + profileIcon = ICONS_PREPEND + profileIcon; + profileBanner = BANNERS_PREPEND + profileBanner; + + let div = document.createElement("div"); + div.innerHTML = ` + +
    +
    + + + + + +
    +
    + +
    +
    + ${paysites[service].title} +
    + +
    + ${name} +
    + +
    + +
    +
    +
    + `; + div.addEventListener("click", (e) => { + e.preventDefault(); + + Array.from(document.querySelectorAll(".user-card.selected")).forEach(item => item.classList.remove("selected")); + + div.children[0].classList.add("selected"); + document.querySelector("#new_link_form #creator").value = `${service}/${id}`; + document.querySelector("#new_link_form #submit").disabled = false; + }); + return div; +} diff --git a/client/src/pages/artist/shares.html b/client/src/pages/artist/shares.html new file mode 100644 index 0000000..8079e93 --- /dev/null +++ b/client/src/pages/artist/shares.html @@ -0,0 +1,56 @@ +{% extends 'components/shell.html' %} + +{% from 'components/cards/share.html' import share_card %} +{% from 'components/headers.html' import user_header %} +{% from 'components/card_list.html' import card_list %} + +{% set paysite = g.paysites[props.service] %} +{% set page_title = 'DMs of ' ~ props.artist.name ~ ' from ' ~ paysite.title ~ ' | ' ~ g.site_name %} + +{% block title %} + {{ page_title }} +{% endblock title %} + +{% block meta %} + + + + +{% endblock meta %} + +{% block opengraph %} + + + + + +{% endblock opengraph %} + +{% block content %} +
    + {{ user_header(request, props) }} +
    + {% include 'components/tabs.html' %} +
    + {% call card_list() %} + {% for dm in results %} + {{ share_card(dm) }} + {% else %} +
    +

    Nobody here but us chickens!

    +

    + There are no uploads for your query. +

    +
    + {% endfor %} + {% endcall %} +
    +{% endblock content %} + +{% block components %} + + {{ loading_icon() }} +{% endblock components %} diff --git a/client/src/pages/artist/tags.html b/client/src/pages/artist/tags.html new file mode 100644 index 0000000..0244815 --- /dev/null +++ b/client/src/pages/artist/tags.html @@ -0,0 +1,66 @@ +{% extends "components/shell.html" %} + +{% from "components/headers.html" import user_header %} +{% from "components/card_list.html" import card_list %} +{% from "components/cards/dm.html" import dm_card %} + +{% set paysite = g.paysites[props.service] %} +{% set page_title = "Announcements of " ~ props.artist.name ~ " from " ~ paysite.title ~ " | " ~ g.site_name %} + +{% block title %} + {{ page_title }} +{% endblock %} + +{% block meta %} + + + + +{% endblock meta %} + +{% block opengraph %} + + + + + +{% endblock opengraph %} + +{% block content %} +
    + {{ user_header(request, props) }} +
    + {% include "components/tabs.html" %} +
    +
    + {% for tag in tags %} + + {% else %} +
    +

    Nobody here but us chickens!

    +

    + There are no Announcements for your query. +

    +
    + {% endfor %} +
    +
    +{% endblock %} + + +{% block components %} + + {{ loading_icon() }} +{% endblock components %} diff --git a/client/src/pages/artists.html b/client/src/pages/artists.html new file mode 100644 index 0000000..bac121f --- /dev/null +++ b/client/src/pages/artists.html @@ -0,0 +1,101 @@ +{% extends 'components/shell.html' %} + +{% from 'components/image_link.html' import image_link %} +{% from 'components/fancy_image.html' import fancy_image %} +{% from 'components/card_list.html' import card_list %} +{% from 'components/cards/user.html' import user_card, user_card_header, user_card_skeleton %} +{% from 'components/ads.html' import slider_ad, header_ad, footer_ad %} + +{% block content %} + {{ slider_ad() }} +
    +
    + + Loading creators... please wait! + +
    +
    +
    + + + Leave blank to list all +
    +
    + + +
    +
    + + + +
    +
    + {% if props.display %} +
    +

    + Displaying {{ props.display }} +

    +
    + {% endif %} +
    + {% include 'components/paginator.html' %} +
    + {{ header_ad() }} + {% call card_list('phone') %} + {% for user in results %} + {{ user_card( + user, + is_updated=base.get('sort_by') == 'updated', + is_indexed=base.get('sort_by') == 'indexed', + is_count=base.get('sort_by') == 'favorited', + single_of='favorite', + plural_of='favorites' + ) }} + {% else %} +

    + No {{ g.artists_or_creators|lower }} found for your query. +

    + {% endfor %} + {% endcall %} +
    + {% include 'components/paginator.html' %} +
    + {{ footer_ad() }} +
    +{% endblock %} + +{% block components %} + {{ image_link("") }} + {{ fancy_image("") }} + {{ user_card_skeleton() }} +{% endblock components %} diff --git a/client/src/pages/artists.js b/client/src/pages/artists.js new file mode 100644 index 0000000..c5c4a5e --- /dev/null +++ b/client/src/pages/artists.js @@ -0,0 +1,400 @@ +import { kemonoAPI } from "@wp/api"; +import { CardList, registerPaginatorKeybinds, UserCard } from "@wp/components"; +import { isLoggedIn } from "@wp/js/account"; +import { findFavouriteArtist } from "@wp/js/favorites"; + +/** + * @type {KemonoAPI.User[]} + */ +let creators; +/** + * @type {KemonoAPI.User[]} + */ +let filteredCreators; +let skip = + parseInt( + window.location.hash + .substring(1) + .split("&") + .find((e) => e.split("=")[0] === "o") + ?.split("=")[1], + ) || 0; +let limit = 50; +const TOTAL_BUTTONS = 5; +const OPTIONAL_BUTTONS = TOTAL_BUTTONS - 2; +const MANDATORY_BUTTONS = TOTAL_BUTTONS - OPTIONAL_BUTTONS; + +// generic debounce function, idk jsdoc, figure it out :) +export function debounce(func, timeout = 300) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => { + func.apply(this, args); + }, timeout); + }; +} + +/** + * @param {HTMLElement} section + */ +export async function artistsPage(section) { + /** + * @type {HTMLHeadingElement} + */ + const displayStatus = document.getElementById("display-status"); + /** + * @type {HTMLDivElement} + */ + const loadingStatus = document.getElementById("loading"); + /** + * @type {HTMLFormElement} + */ + const searchForm = document.forms["search-form"]; + /** + * @type {HTMLSelectElement} + */ + const orderSelect = searchForm.elements["order"]; + /** + * @type {HTMLSelectElement} + */ + const serviceSelect = searchForm.elements["service"]; + /** + * @type {HTMLSelectElement} + */ + const sortSelect = searchForm.elements["sort_by"]; + /** + * @type {HTMLInputElement} + */ + const queryInput = searchForm.elements["q"]; + /** + * @type {HTMLDivElement} + */ + const cardListElement = section.querySelector(".card-list"); + const { cardList, cardContainer } = CardList(cardListElement); + const pagination = { + top: document.getElementById("paginator-top"), + bottom: document.getElementById("paginator-bottom"), + }; + + Array.from(cardContainer.children).forEach(async (userCard) => { + const { id, service } = userCard.dataset; + const isFav = isLoggedIn && (await findFavouriteArtist(id, service)); + + if (isFav) { + userCard.classList.add("user-card--fav"); + } + }); + section.addEventListener("click", async (event) => { + /** + * @type {HTMLAnchorElement} + */ + const button = event.target; + const isB = button.parentElement.classList.contains("paginator-button-ident"); + if ( + (button.classList.contains("paginator-button-ident") && button.dataset && button.dataset.value) || + (isB && button.parentElement.dataset && button.parentElement.dataset.value) + ) { + event.preventDefault(); + skip = Number(isB ? button.parentElement.dataset.value : button.dataset.value); + window.location.hash = "o=" + skip; + filterCards(orderSelect.value, serviceSelect.value, sortSelect.value, queryInput.value); + await loadCards(displayStatus, cardContainer, pagination, sortSelect.value); + } + }); + + searchForm.addEventListener("submit", (event) => event.preventDefault()); + queryInput.addEventListener( + "change", + handleSearch(orderSelect, serviceSelect, sortSelect, queryInput, displayStatus, cardContainer, pagination), + ); + // 300 ms delay between each keystroke, trigger a new search on each new letter added or removed + // debounce lets you do this by waiting for the user to stop typing first + queryInput.addEventListener( + "keydown", + debounce( + handleSearch(orderSelect, serviceSelect, sortSelect, queryInput, displayStatus, cardContainer, pagination), + 300, + ), + ); + serviceSelect.addEventListener( + "change", + handleSearch(orderSelect, serviceSelect, sortSelect, queryInput, displayStatus, cardContainer, pagination), + ); + sortSelect.addEventListener( + "change", + handleSearch(orderSelect, serviceSelect, sortSelect, queryInput, displayStatus, cardContainer, pagination), + ); + orderSelect.addEventListener( + "change", + handleSearch(orderSelect, serviceSelect, sortSelect, queryInput, displayStatus, cardContainer, pagination), + ); + + await retrieveArtists(loadingStatus); + handleSearch(orderSelect, serviceSelect, sortSelect, queryInput, displayStatus, cardContainer, pagination)(null); + registerPaginatorKeybinds(); +} + +/** + * @param {HTMLSelectElement} orderSelect + * @param {HTMLSelectElement} serviceSelect + * @param {HTMLSelectElement} sortSelect + * @param {HTMLInputElement} queryInput + * @param {HTMLDivElement} displayStatus + * @param {HTMLDivElement} cardContainer + * @param {{ top: HTMLElement, bottom: HTMLElement }} pagination + * @return {(event: Event) => void} + */ +function handleSearch(orderSelect, serviceSelect, sortSelect, queryInput, displayStatus, cardContainer, pagination) { + return async (event) => { + filterCards(orderSelect.value, serviceSelect.value, sortSelect.value, queryInput.value); + await loadCards(displayStatus, cardContainer, pagination, sortSelect.value); + }; +} + +// localeCompare isn't slow itself, but this is still faster and we're processing a LOT of data here! +// better get any speed gains we can +function fastCompare(a, b) { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * @param {string} order + * @param {string} service + * @param {string} sortBy + * @param {string} query + */ +function filterCards(order, service, sortBy, query) { + // fixme: creators is null/undefined sometimes + filteredCreators = creators.slice(0); + + if (order === "desc") { + filteredCreators.reverse(); + } + + filteredCreators = filteredCreators + .filter((creator) => creator.service === (service || creator.service)) + .sort((a, b) => { + if (order === "asc") { + return sortBy === "indexed" + ? a.parsedIndexed - b.parsedIndexed + : sortBy === "updated" + ? a.parsedUpdated - b.parsedUpdated + : fastCompare(a[sortBy], b[sortBy]); + } else { + return sortBy === "indexed" + ? b.parsedIndexed - a.parsedIndexed + : sortBy === "updated" + ? b.parsedUpdated - a.parsedUpdated + : fastCompare(b[sortBy], a[sortBy]); + } + }) + .filter((creator) => { + return creator.name.match(new RegExp(query.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"), "i")); + }); +} + +function _paginatorButton(content, skip, className = "") { + if (typeof skip === "string") { + className = skip; + skip = null; + } + if (typeof skip === "number") + return `${content}`; + return `
  • ${content}
  • `; +} + +function createPaginator() { + const count = filteredCreators.length; + + const currentCeilingOfRange = skip + limit < count ? skip + limit : count; + + const currPageNum = Math.ceil((skip + limit) / limit); + const totalPages = Math.ceil(count / limit); + const numBeforeCurrPage = + totalPages < TOTAL_BUTTONS || currPageNum < TOTAL_BUTTONS + ? currPageNum - 1 + : totalPages - currPageNum < TOTAL_BUTTONS + ? TOTAL_BUTTONS - 1 + (TOTAL_BUTTONS - (totalPages - currPageNum)) + : TOTAL_BUTTONS - 1; + const basePageNum = Math.max(currPageNum - numBeforeCurrPage - 1, 1); + const showFirstPostsButton = basePageNum > 1; + const showLastPostsButton = + totalPages - currPageNum > + TOTAL_BUTTONS + (currPageNum - basePageNum < TOTAL_BUTTONS ? TOTAL_BUTTONS - (currPageNum - basePageNum) : 0); + const optionalBeforeButtons = + currPageNum - + MANDATORY_BUTTONS - + (totalPages - currPageNum < MANDATORY_BUTTONS ? MANDATORY_BUTTONS - (totalPages - currPageNum) : 0); + const optionalAfterButtons = + currPageNum + + MANDATORY_BUTTONS + + (currPageNum - basePageNum < MANDATORY_BUTTONS ? MANDATORY_BUTTONS - (currPageNum - basePageNum) : 0); + + const range = createRange(0, TOTAL_BUTTONS * 2 + 1); + + const paginator = + count > limit + ? ` + Showing ${skip + 1} - ${currentCeilingOfRange} of ${count} + + + ${ + showFirstPostsButton || showLastPostsButton + ? showFirstPostsButton + ? _paginatorButton("<<", 0) + : _paginatorButton( + "<<", + `pagination-button-disabled${currPageNum - MANDATORY_BUTTONS - 1 ? " pagination-desktop" : ""}`, + ) + : `` + } + ${ + showFirstPostsButton + ? "" + : currPageNum - MANDATORY_BUTTONS - 1 + ? _paginatorButton("<<", 0, "pagination-mobile") + : totalPages - currPageNum > MANDATORY_BUTTONS && !showLastPostsButton + ? _paginatorButton("<<", "pagination-button-disabled pagination-mobile") + : "" + } + ${ + currPageNum > 1 + ? _paginatorButton("<", (currPageNum - 2) * limit, "prev") + : _paginatorButton("<", "pagination-button-disabled") + } + ${range + .map((page) => + page + basePageNum && page + basePageNum <= totalPages + ? _paginatorButton( + page + basePageNum, + page + basePageNum != currPageNum ? (page + basePageNum - 1) * limit : null, + (page + basePageNum < optionalBeforeButtons || page + basePageNum > optionalAfterButtons) && + page + basePageNum != currPageNum + ? "pagination-button-optional" + : page + basePageNum == currPageNum + ? "pagination-button-disabled pagination-button-current" + : page + basePageNum == currPageNum + 1 + ? "pagination-button-after-current" + : "", + ) + : "", + ) + .join("\n")} + ${ + currPageNum < totalPages + ? _paginatorButton(">", currPageNum * limit, "next") + : _paginatorButton(">", `pagination-button-disabled${totalPages ? " pagination-button-after-current" : ""}`) + } + ${ + showFirstPostsButton || showLastPostsButton + ? showLastPostsButton + ? _paginatorButton(">>", (totalPages - 1) * limit) + : _paginatorButton( + ">>", + `pagination-button-disabled${totalPages - currPageNum > MANDATORY_BUTTONS ? " pagination-desktop" : ""}`, + ) + : "" + } + ${ + showLastPostsButton + ? "" + : totalPages - currPageNum > MANDATORY_BUTTONS + ? _paginatorButton(">>", (totalPages - 1) * limit, "pagination-mobile") + : currPageNum > OPTIONAL_BUTTONS && !showFirstPostsButton + ? _paginatorButton(">>", "pagination-button-disabled pagination-mobile") + : "" + } + + ` + : ""; + + return paginator; +} + +/** + * @param {HTMLDivElement} displayStatus + * @param {HTMLDivElement} cardContainer + * @param {{ top: HTMLElement, bottom: HTMLElement }} pagination + * @param {String} sortBy + */ +async function loadCards(displayStatus, cardContainer, pagination, sortBy) { + displayStatus.textContent = "Displaying search results"; + pagination.top.innerHTML = createPaginator(); + pagination.bottom.innerHTML = createPaginator(); + /** + * @type {[ HTMLDivElement, HTMLElement ]} + */ + const [...cards] = cardContainer.children; + cards.forEach((card) => { + card.remove(); + }); + + if (filteredCreators.length === 0) { + const paragraph = document.createElement("p"); + + paragraph.classList.add("subtitle", "card-list__item--no-results"); + paragraph.textContent = "No artists found for your query."; + cardContainer.appendChild(paragraph); + return; + } else { + const fragment = document.createDocumentFragment(); + + for await (const user of filteredCreators.slice(skip, skip + limit)) { + const userIsCount = sortBy === "favorited"; + const userIsIndexed = sortBy === "indexed"; + const userIsUpdated = sortBy === "updated"; + const userCard = UserCard(null, user, userIsCount, userIsUpdated, userIsIndexed); + const isFaved = isLoggedIn && (await findFavouriteArtist(user.id, user.service)); + + if (isFaved) { + userCard.classList.add("user-card--fav"); + } + + fragment.appendChild(userCard); + } + + cardContainer.appendChild(fragment); + } +} + +/** + * @param {HTMLDivElement} loadingStatus + */ +async function retrieveArtists(loadingStatus) { + try { + const artists = await kemonoAPI.api.creators(); + + if (!artists) { + return null; + } + + for (const artist of artists) { + // preemptively do it here, it's taxing to parse a date string then convert it to a unix timestamp in milliseconds + // this way we only have to do it once after fetching and none for sorting + artist.parsedIndexed = artist.indexed * 1000; + artist.parsedUpdated = artist.updated * 1000; + artist.indexed = new Date(artist.parsedIndexed).toISOString(); + artist.updated = new Date(artist.parsedUpdated).toISOString(); + } + + loadingStatus.innerHTML = ""; + creators = artists; + filteredCreators = artists; + } catch (error) { + console.error(error); + } +} + +/** + * @param {number} start + * @param {number} end + */ +function createRange(start, end) { + const length = end - start; + const range = Array.from({ length }, (_, index) => start + index); + + return range; +} diff --git a/client/src/pages/components/_index.js b/client/src/pages/components/_index.js new file mode 100644 index 0000000..5414576 --- /dev/null +++ b/client/src/pages/components/_index.js @@ -0,0 +1,10 @@ +export { LoadingIcon } from "./loading_icon"; +export { CardList } from "./card_list"; +export { PostCard, UserCard } from "./cards/_index.js"; +export { FancyImage } from "./fancy_image"; +export { FancyLink } from "./links"; +export { ImageLink } from "./image_link"; +export { showTooltip, registerMessage } from "./tooltip"; +export { initShell } from "./shell"; +export { Timestamp } from "./timestamp"; +export { registerPaginatorKeybinds } from "./paginator"; diff --git a/client/src/pages/components/_index.scss b/client/src/pages/components/_index.scss new file mode 100644 index 0000000..67a5821 --- /dev/null +++ b/client/src/pages/components/_index.scss @@ -0,0 +1,16 @@ +@use "site"; +@use "fancy_image"; +@use "links"; +@use "timestamp"; +@use "card_list"; +@use "cards"; +@use "loading_icon"; +@use "buttons"; +@use "image_link"; +@use "shell"; +@use "tooltip"; +@use "paginator_new"; +@use "navigation"; +@use "lists"; +@use "importer_states"; +@use "file_hash_search"; diff --git a/client/src/pages/components/ads.html b/client/src/pages/components/ads.html new file mode 100644 index 0000000..50bfd5a --- /dev/null +++ b/client/src/pages/components/ads.html @@ -0,0 +1,29 @@ +{% macro header_ad() %} + {% if g.header_ad %} +
    + {{ g.header_ad|safe }} +
    + {% endif %} +{% endmacro %} + +{% macro middle_ad() %} + {% if g.middle_ad %} +
    + {{ g.middle_ad|safe }} +
    + {% endif %} +{% endmacro %} + +{% macro footer_ad() %} + {% if g.footer_ad %} +
    + {{ g.footer_ad|safe }} +
    + {% endif %} +{% endmacro %} + +{% macro slider_ad() %} + {% if g.slider_ad %} + {{ g.slider_ad|safe }} + {% endif %} +{% endmacro %} diff --git a/client/src/pages/components/buttons.html b/client/src/pages/components/buttons.html new file mode 100644 index 0000000..38b758c --- /dev/null +++ b/client/src/pages/components/buttons.html @@ -0,0 +1,7 @@ +{%- macro button(text, class_name=none, is_focusable=true) -%} + +{%- endmacro -%} diff --git a/client/src/pages/components/buttons.scss b/client/src/pages/components/buttons.scss new file mode 100644 index 0000000..22bbab8 --- /dev/null +++ b/client/src/pages/components/buttons.scss @@ -0,0 +1,14 @@ +.button { + --local-colour1: var(--colour0-primary); + + box-sizing: border-box; + min-height: 44px; + min-width: 44px; + font-family: inherit; + font-size: 100%; + color: var(--local-colour1); + background-image: linear-gradient(hsl(0, 0%, 7%), hsl(220, 7%, 17%)); + border-radius: 5px; + border: none; + padding: 0.5em; +} diff --git a/client/src/pages/components/card_list.html b/client/src/pages/components/card_list.html new file mode 100644 index 0000000..1bc991c --- /dev/null +++ b/client/src/pages/components/card_list.html @@ -0,0 +1,11 @@ +{% macro card_list(layout='legacy', class_name=none) %} +
    +
    +
    +
    + {{ caller() }} +
    +
    +{% endmacro %} diff --git a/client/src/pages/components/card_list.js b/client/src/pages/components/card_list.js new file mode 100644 index 0000000..10c2adb --- /dev/null +++ b/client/src/pages/components/card_list.js @@ -0,0 +1,116 @@ +import { createComponent } from "@wp/js/component-factory"; + +/** + * TODO: layout switch button. + * @param {HTMLElement} element + * @param {string} layout + */ +export function CardList(element = null, layout = "feature") { + const cardList = element ? initFromElement(element) : initFromScratch(); + let currentLayout = layout; + + + let thumbSizeSetting = undefined; + try { + let cookies = getCookies(); + thumbSizeSetting = parseInt(cookies?.thumbSize); + thumbSizeSetting = isNaN(thumbSizeSetting) ? undefined : thumbSizeSetting; + } catch (e) { + return cardList; + } + if (!thumbSizeSetting){ + addCookie("thumbSize","180", 399) + } + + let defaultThumbSize = 180; + let thumbSize = parseInt(thumbSizeSetting) === parseInt(defaultThumbSize) ? undefined: thumbSizeSetting; + + let cardListEl = document.querySelector('.card-list__items'); + + if (cardListEl.parentNode.classList.contains("card-list--phone")){ + return cardList; + } + + window.addEventListener('resize', () => updateThumbsizes(cardListEl, defaultThumbSize, thumbSize)); + updateThumbsizes(cardListEl, defaultThumbSize, thumbSize) + + return cardList; +} + +/** + * @param {HTMLElement} element + */ +function initFromElement(element) { + /** + * @type {HTMLDivElement} + */ + const cardContainer = element.querySelector(".card-list__items"); + /** + * @type {NodeListOf} + */ + const itemListElements = element.querySelectorAll(".card-list__items > *"); + + return { + cardList: element, + cardContainer, + cardItems: Array.from(itemListElements), + }; +} + +function initFromScratch() { + /** + * @type {HTMLElement} + */ + const cardList = createComponent("card-list"); + /** + * @type {HTMLDivElement} + */ + const cardContainer = cardList?.querySelector(".card-list__items"); + /** + * @type {HTMLElement[]} + */ + const cardItems = []; + + return { + cardList, + cardContainer, + cardItems, + }; +} + + +function getCookies(){ + return document.cookie.split(';').reduce((cookies, cookie) => (cookies[cookie.split('=')[0].trim()] = decodeURIComponent(cookie.split('=')[1]), cookies), {}); +} + +function setCookie(name, value, daysToExpire) { + const date = new Date(); + date.setTime(date.getTime() + (daysToExpire * 24 * 60 * 60 * 1000)); + const expires = "expires=" + date.toUTCString(); + document.cookie = name + "=" + value + "; " + expires + ";path=/"; +} +function addCookie(name, newValue, daysToExpire) { + const existingCookie = document.cookie + .split(';') + .find(cookie => cookie.trim().startsWith(name + '=')); + + if (!existingCookie) { + setCookie(name, newValue, daysToExpire); + } +} +function updateThumbsizes(element, defaultSize, thumbSizeSetting){ + let thumbSize = thumbSizeSetting? thumbSizeSetting : defaultSize; + if (!thumbSizeSetting){ + let viewportWidth = window.innerWidth; + let offset = 24; + let viewportWidthExcludingMargin = viewportWidth - offset; + let howManyFit = viewportWidthExcludingMargin/thumbSize; + + if ( howManyFit < 2.0 && 1.5 < howManyFit) { + thumbSize = viewportWidthExcludingMargin / 2; + } else if( howManyFit > 12 ){ + thumbSize = defaultSize*1.5; + } + } + element.style.setProperty('--card-size', `${thumbSize}px`); +} \ No newline at end of file diff --git a/client/src/pages/components/card_list.scss b/client/src/pages/components/card_list.scss new file mode 100644 index 0000000..4b13e69 --- /dev/null +++ b/client/src/pages/components/card_list.scss @@ -0,0 +1,55 @@ +@use "../../css/config/variables" as *; + +.card-list { + --local-flex-flow: row wrap; + --local-justify: center; + --local-align: stretch; + padding: $size-small 0; + + &--table { + --local-flex-flow: column nowrap; + --card-size: 100%; + } + + &--legacy { + --card-size: 180px; + } + + &--feature { + --card-size: #{$width-feature}; + } + + &--mobile { + --card-size: #{$width-mobile}; + } + + &--phone { + --card-size: #{$width-phone}; + } + + &--tablet { + --card-size: #{$width-tablet}; + } + + &__items { + display: flex; + flex-flow: var(--local-flex-flow); + justify-content: var(--local-justify); + align-items: var(--local-align); + gap: 0.5em; + + & > * { + flex: 0 1 var(--card-size); + } + } + + &__item { + &--no-results { + --card-size: $width-phone; + + text-align: center; + padding: $size-small 0; + margin: 0 auto; + } + } +} diff --git a/client/src/pages/components/cards/_index.js b/client/src/pages/components/cards/_index.js new file mode 100644 index 0000000..49f2aa1 --- /dev/null +++ b/client/src/pages/components/cards/_index.js @@ -0,0 +1,151 @@ +import { createComponent } from "@wp/js/component-factory"; +import { FancyImage, Timestamp } from "@wp/components"; +import { freesites, paysites } from "@wp/utils"; +import { BANNERS_PREPEND, ICONS_PREPEND } from "@wp/env/env-vars"; + +/** + * @param {HTMLElement} element + * @param {KemonoAPI.Post} post + */ +export function PostCard(element = null, post = {}) { + const postCard = element ? initFromElement(element) : initFromScratch(post); + + const view = postCard.postCardElement.querySelector(".post-card__view"); + + if (view) { + /** + * @type {HTMLButtonElement} + */ + const button = view.querySelector(".post-card__button"); + /** + * @type {HTMLAnchorElement} + */ + const link = postCard.postCardElement.querySelector(".post-card__link"); + + button.addEventListener("click", handlePostView(link)); + } + + return postCard; +} + +/** + * @param {HTMLElement} element + */ +function initFromElement(element) { + const { id, service, user } = element.dataset; + return { + postCardElement: element, + postID: id, + service, + userID: user, + }; +} + +/** + * @param {KemonoAPI.Post} post + */ +function initFromScratch(post) { + /** + * @type {HTMLElement} + */ + const postCardElement = createComponent("post-card"); + + return { + postCardElement, + postID: post.id, + service: post.service, + userID: post.user, + }; +} + +/** + * @param {HTMLAnchorElement} link + * @returns {(event: MouseEvent) => void} + */ +function handlePostView(link) { + return (event) => { + link.focus(); + }; +} + +/** + * @param {HTMLElement} element + * @param {KemonoAPI.User} user + * @param {boolean} isCount + * @param {boolean} isDate + * @param {string} className + */ +export function UserCard(element, user = {}, isCount = false, isUpdated = false, isIndexed = false, className = null) { + const userCard = element + ? initUserCardFromElement(element) + : initUserCardFromScratch(user, isCount, isUpdated, isIndexed, className); + + return userCard; +} + +/** + * @param {HTMLElement} element + */ +function initUserCardFromElement(element) { + const userCard = element; + + return userCard; +} + +/** + * @param {KemonoAPI.User} user + * @param {boolean} isCount + * @param {boolean} isDate + * @param {string} className + */ +function initUserCardFromScratch(user, isCount, isUpdated, isIndexed, className) { + let profileIcon = freesites.kemono.user.icon(user.service, user.id); + let profileBanner = freesites.kemono.user.banner(user.service, user.id); + const profileLink = freesites.kemono.user.profile(user.service, user.id); + /** + * @type {HTMLElement} + */ + + profileIcon = ICONS_PREPEND + profileIcon; + profileBanner = BANNERS_PREPEND + profileBanner; + + const userCard = createComponent("user-card"); + userCard.href = profileLink; + userCard.style.backgroundImage = `linear-gradient(rgb(0 0 0 / 50%), rgb(0 0 0 / 80%)), url(${profileBanner})`; + + const imageLink = FancyImage(null, profileIcon, profileIcon, true, "", "user-card__user-icon"); + + const userIcon = userCard.querySelector(".user-card__icon"); + const userName = userCard.querySelector(".user-card__name"); + const userService = userCard.querySelector(".user-card__service"); + const userCount = userCard.querySelector(".user-card__count"); + const userUpdated = userCard.querySelector(".user-card__updated"); + + userIcon.appendChild(imageLink); + userName.textContent = user.name; + if (user.name.length >= 24) { + userName.title = user.name; + } + + userService.textContent = paysites[user.service].title; + userService.style.backgroundColor = paysites[user.service].color; + + if (className) { + userCard.classList.add(className); + } + + if (isCount) { + userCount.innerHTML = `${user.favorited} favorites`; + } else { + userCount.remove(); + } + + if (isUpdated || isIndexed) { + const timestamp = Timestamp(null, isUpdated ? user.updated : user.indexed); + userUpdated.appendChild(timestamp); + } else { + userUpdated.remove(); + } + + return userCard; +} diff --git a/client/src/pages/components/cards/_index.scss b/client/src/pages/components/cards/_index.scss new file mode 100644 index 0000000..545d0b3 --- /dev/null +++ b/client/src/pages/components/cards/_index.scss @@ -0,0 +1,6 @@ +@use "base"; +@use "account"; +@use "post"; +@use "user"; +@use "dm"; +@use "no_results"; diff --git a/client/src/pages/components/cards/account.html b/client/src/pages/components/cards/account.html new file mode 100644 index 0000000..ec3b891 --- /dev/null +++ b/client/src/pages/components/cards/account.html @@ -0,0 +1,29 @@ +{% from 'components/timestamp.html' import timestamp %} +{% from 'components/links.html' import kemono_link %} + +{% macro account_card(account) %} + {% set account_url = 'account/administrator/accounts/' ~ account.id %} + + +{% endmacro %} diff --git a/client/src/pages/components/cards/account.scss b/client/src/pages/components/cards/account.scss new file mode 100644 index 0000000..7c003a1 --- /dev/null +++ b/client/src/pages/components/cards/account.scss @@ -0,0 +1,9 @@ +@use "../../../css/sass-mixins" as mixins; + +.account-card { + @include mixins.article-card(); + + &__body { + flex: 1 1 auto; + } +} diff --git a/client/src/pages/components/cards/base.html b/client/src/pages/components/cards/base.html new file mode 100644 index 0000000..ef39246 --- /dev/null +++ b/client/src/pages/components/cards/base.html @@ -0,0 +1,26 @@ +{# base parts of the card #} +{# these macros can only be called #} + +{% macro card(class_name=none) %} +
    + {{ caller() }} +
    +{% endmacro %} + +{% macro card_header(class_name=none) %} +
    + {{ caller() }} +
    +{% endmacro %} + +{% macro card_body(class_name=none) %} +
    + {{ caller() }} +
    +{% endmacro %} + +{% macro card_footer(class_name=none) %} +
    + {{ caller() }} +
    +{% endmacro %} diff --git a/client/src/pages/components/cards/base.scss b/client/src/pages/components/cards/base.scss new file mode 100644 index 0000000..08990e1 --- /dev/null +++ b/client/src/pages/components/cards/base.scss @@ -0,0 +1,41 @@ +@use "../../../css/config/variables" as *; + +.card { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr auto; + grid-template-areas: + "header" + "body" + "footer"; + background-color: var(--colour1-tertiary); + border-radius: 10px; + border: $size-thin solid transparent; + overflow: hidden; + transition-duration: var(--duration-global); + transition-property: border-color, box-shadow; + + &:hover, + &:focus-within { + box-shadow: 0px 0px 3px 5px var(--positive-colour1-secondary); + } + + & > * { + padding: $size-small; + } + + &__header { + grid-area: header; + align-self: flex-start; + } + + &__body { + grid-area: body; + align-self: center; + } + + &__footer { + grid-area: footer; + align-self: flex-end; + } +} diff --git a/client/src/pages/components/cards/dm.html b/client/src/pages/components/cards/dm.html new file mode 100644 index 0000000..276fe65 --- /dev/null +++ b/client/src/pages/components/cards/dm.html @@ -0,0 +1,70 @@ +{% from 'components/timestamp.html' import timestamp %} +{% from 'components/links.html' import fancy_link %} +{% from 'components/fancy_image.html' import fancy_image, background_image %} + +{% macro dm_card( + dm, + is_private=false, + is_global=false, + artist={}, + class_name=none +) %} + {% set service = g.paysites[dm.service] %} + {% set creator_page_url = '/' ~ dm.service ~ '/user/' ~ dm.user %} + {% set remote_creator_page_url = service.user.profile(artist or { "id" : dm.user}) %} + +
    + {% if is_global %} +
    +{# {% call fancy_link(url=creator_page_url, class_name="dm-card__icon") %}#} +{# {{ fancy_image( g.icons_prepend ~ '/icons/' ~ artist.service ~ '/' ~ artist.id) }}#} +{# {% endcall %}#} + + {% call fancy_link(url=creator_page_url, class_name='dms__user-link') %} + {{ artist.name or dm.user }} + {% endcall %} + {% call fancy_link(url=remote_creator_page_url, class_name='dms__remote-user-link') %} + ({{ service.title }}) + {% endcall %} +
    + {% endif %} + + {% if is_private %} +
    + {% call fancy_link(url=creator_page_url, class_name='dms__user-link') %} + {{ artist.name or dm.user }} + {% endcall %} + + {% call fancy_link(url=remote_creator_page_url, class_name='dms__remote-user-link') %} + ({{ service.title }}) + {% endcall %} +
    + {% endif %} + +
    + {# writing it like this so there wouldn't be whitespaces/newlines in the output #} +
    {{ dm.content|sanitize_html|safe }}
    +
    + +
    + {% if dm.published %} +
    + Published: {{ ( dm.published|simple_datetime|string)[:7] }} +
    + {% elif dm.user_id %} {# this is to detect if its not DM#} +
    + Added: {{ ( dm.added|simple_datetime|string)[:7] }} +
    + {% else %} +
    + Added: {{ dm.added|simple_datetime }} +
    + {% endif %} +
    +
    +{% endmacro %} diff --git a/client/src/pages/components/cards/dm.scss b/client/src/pages/components/cards/dm.scss new file mode 100644 index 0000000..2f57bb7 --- /dev/null +++ b/client/src/pages/components/cards/dm.scss @@ -0,0 +1,64 @@ +@use "../../../css/config/variables" as *; + +.dm-card { + position: relative; + display: flex; + flex-flow: column nowrap; + align-items: stretch; + background-color: var(--colour1-tertiary); + border-radius: 10px; + border: $size-thin solid transparent; + overflow: hidden; + transition-duration: var(--duration-global); + transition-property: border-color, box-shadow; + + & > * { + flex: 0 1 auto; + padding: $size-small; + } + + &:hover, + &:focus-within { + box-shadow: 0px 0px 3px 5px var(--positive-colour1-secondary); + } + + &__icon { + display: inline-block; + border-radius: 5px; + overflow: hidden; + width: 1em; + height: 1em; + } + + &__header { + } + + &__user { + } + + &__service { + } + + &__body { + flex: 1 1 auto; + } + + &__content { + line-height: 1.5; + } + + &__files { + } + + &__embeds { + } + + &__footer { + } + + &__published { + } + + &__added { + } +} diff --git a/client/src/pages/components/cards/no_results.html b/client/src/pages/components/cards/no_results.html new file mode 100644 index 0000000..cf871c7 --- /dev/null +++ b/client/src/pages/components/cards/no_results.html @@ -0,0 +1,18 @@ +{% from 'components/cards/base.html' import card, card_header, card_body %} + +{% macro no_results( + title = 'Nobody here but us chickens!', + message = 'There are no items found.' +) %} + {% call card(class_name='card--no-results') %} + {% call card_header() %} +

    + {{ title }} +

    + {% endcall %} + + {% call card_body() %} + {{ message }} + {% endcall %} + {% endcall %} +{% endmacro %} diff --git a/client/src/pages/components/cards/no_results.scss b/client/src/pages/components/cards/no_results.scss new file mode 100644 index 0000000..9b12b98 --- /dev/null +++ b/client/src/pages/components/cards/no_results.scss @@ -0,0 +1,9 @@ +@use "../../../css/config/variables" as *; + +.card--no-results { + flex: 0 1 $width-phone; + + &:hover { + box-shadow: none; + } +} diff --git a/client/src/pages/components/cards/post.html b/client/src/pages/components/cards/post.html new file mode 100644 index 0000000..ad05d01 --- /dev/null +++ b/client/src/pages/components/cards/post.html @@ -0,0 +1,117 @@ +{% from 'components/timestamp.html' import timestamp %} +{% from 'components/buttons.html' import button %} +{% from 'components/links.html' import fancy_link %} + +{% macro post_card(post) %} + {% set src_ns = namespace(found=false) %} + {% set src_ns.src = post.file.path if post.file.path and post.file.path|lower|regex_match("\.(gif|jpe?g|jpe|png|webp)$") %} + {% if post.service == "fansly" or post.service == "candfans" or post.service == "boosty" or post.service == "gumroad"%} + {% for file in post.attachments %} + {% if not src_ns.src and file.path and file.path|lower|regex_match("\.(gif|jpe?g|jpe|png|webp)$") %} + {% set src_ns.src = file.path %} + {% endif %} + {% endfor %} + {% endif %} + {% set post_link = g.freesites.kemono.post.link(post.service, post.user, post.id) %} +
    + +
    + {% if post.title and post.title != "DM" %} + {{ post.title }} + {% elif post.content|length < 50 %} + {{ post.content }} + {% else %} + {{ post.content[:50] + "..." }} + {% endif %} +
    + {% if src_ns.src %} +
    + +
    + {% endif %} +
    +
    +
    + {% if post.published %} + {{ timestamp(post.published) }} + {% endif %} +
    + {% if post.attachments|length %} + {{ post.attachments|length }} {{ 'attachment' if post.attachments|length == 1 else 'attachments' }} + {% else %} + No attachments + {% endif %} +
    +
    + +
    +
    +
    +
    +{% endmacro %} + +{% macro post_fav_card(post) %} + {% set src_ns = namespace(found=false) %} + {% set src_ns.src = post.file.path if post.file.path and post.file.path|lower|regex_match("\.(gif|jpe?g|jpe|png|webp)$") %} + {% if post.service == "fansly" or post.service == "candfans" or post.service == "boosty" or post.service == "gumroad"%} + {% for file in post.attachments %} + {% if not src_ns.src and file.path and file.path|lower|regex_match("\.(gif|jpe?g|jpe|png|webp)$") %} + {% set src_ns.src = file.path %} + {% endif %} + {% endfor %} + {% endif %} + {% set post_link = g.freesites.kemono.post.link(post.service, post.user, post.id) %} +
    + +
    + {% if post.title and post.title != "DM" %} + {{ post.title }} + {% elif post.content|length < 50 %} + {{ post.content }} + {% else %} + {{ post.content[:50] + "..." }} + {% endif %} +
    + {% if src_ns.src %} +
    + +
    + {% endif %} +
    +
    +
    + {% if post.published %} + {{ timestamp(post.published) }} + {% endif %} +
    + {% if post.attachments|length %} + {{ post.attachments|length }} {{ 'attachment' if post.attachments|length == 1 else 'attachments' }} + {% else %} + No attachments + {% endif %} +
    + {{ post.fav_count| int }} {{ "favorites" if post.fav_count > 1 else "favorite" }} +
    +
    + +
    +
    +
    +
    +{% endmacro %} diff --git a/client/src/pages/components/cards/post.scss b/client/src/pages/components/cards/post.scss new file mode 100644 index 0000000..79c0459 --- /dev/null +++ b/client/src/pages/components/cards/post.scss @@ -0,0 +1,103 @@ +@use "../../../css/config/variables" as *; + +.post-card { + width: var(--card-size); + height: var(--card-size); + text-shadow: + -1px -1px 0 #000, + 1px -1px 0 #000, + -1px 1px 0 #000, + 1px 1px 0 #000; + color: white; + font-size: 80%; + + &--fav { + border-color: var(--favourite-colour2-primary); + border-style: solid; + border-width: 2px; + } + + &:hover { + & > a { + top: -5px; + } + } + + & > a:active, + & > a:focus { + top: -5px; + } + + &--preview { + .post-card__header { + background: rgb(0 0 0 / 50%); + &--fav { + background: hsla(60, 100%, 30%, 0.5); + } + } + + .post-card__footer { + background: rgb(0 0 0 / 50%); + &--fav { + background: hsla(60, 100%, 30%, 0.5); + } + } + } + + & > a { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + padding: 0; + position: relative; + top: 0; + transition: + top ease 0.1s, + background ease 0.1s, + border-bottom-color ease 0.1s; + + &:not(:hover):not(:active):not(:focus) { + background: black; + } + } + + &__header { + padding: 5px; + z-index: 1; + color: white; + } + + &__image-container { + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + display: flex; + } + + &__image { + object-fit: cover; + width: 100%; + height: 100%; + } + + &__footer { + padding: 5px; + z-index: 1; + + & > div > img { + height: 20px; + margin-left: auto; + } + & > div > div > time { + color: var(--colour0-primary); + } + & > div { + color: white; + display: flex; + align-items: center; + } + } +} diff --git a/client/src/pages/components/cards/share.html b/client/src/pages/components/cards/share.html new file mode 100644 index 0000000..d9939a3 --- /dev/null +++ b/client/src/pages/components/cards/share.html @@ -0,0 +1,66 @@ +{% from 'components/fancy_image.html' import fancy_image, background_image %} +{% from 'components/timestamp.html' import timestamp %} +{% from 'components/links.html' import fancy_link %} +{% from 'components/buttons.html' import button %} + +{% macro share_card(share) %} + +{% endmacro %} + diff --git a/client/src/pages/components/cards/user.html b/client/src/pages/components/cards/user.html new file mode 100644 index 0000000..2ca7eb8 --- /dev/null +++ b/client/src/pages/components/cards/user.html @@ -0,0 +1,93 @@ +{% from 'components/timestamp.html' import timestamp %} +{% from 'components/image_link.html' import image_link %} +{% from 'components/fancy_image.html' import fancy_image %} +{% from 'components/links.html' import fancy_link %} + +{% macro user_card_header(is_count=false, is_date=false) %} +
    +
    Icon
    +
    Name
    +
    Service
    + {% if is_count %} +
    Times favorited
    + {% endif %} + {% if is_date %} +
    Updated
    + {% else %} + {% endif %} +
    +{% endmacro %} + +{% macro user_card( + user, + is_updated=false, + is_indexed=false, + is_count=false, + single_of='', + plural_of='', + is_date=false, + class_name=none +) %} + {% set user_link = g.freesites.kemono.user.profile(user.service, user.id) %} + {% set user_icon = g.freesites.kemono.user.icon(user.service, user.id) %} + {% set user_banner = g.freesites.kemono.user.banner(user.service, user.id) %} + + + {# Icon. #} +
    +
    + {{ fancy_image(src=user_icon) }} +
    +
    + + {# Secondary identifiers and elements. #} +
    + + {{ g.paysites[user.service].title }} + + +
    {{ user.name }}
    + + {% if is_updated %} +
    + {{ timestamp(user.updated) }} +
    + {% endif %} + {% if is_indexed %} +
    + {{ timestamp(user.indexed) }} +
    + {% endif %} + {% if is_count %} +
    + {% if user.count %} + {{ user.count }} {{ plural_of if user.count > 1 else single_of }} + {% else %} + {{ 'No ' ~ plural_of if plural_of else 'None' }} + {% endif %} +
    + {% endif %} +
    +
    +{% endmacro %} + +{% macro user_card_skeleton() %} + +
    +
    + +
    +
    +
    +
    +
    +{% endmacro %} diff --git a/client/src/pages/components/cards/user.scss b/client/src/pages/components/cards/user.scss new file mode 100644 index 0000000..10602a1 --- /dev/null +++ b/client/src/pages/components/cards/user.scss @@ -0,0 +1,81 @@ +@use "../../../css/config/variables" as *; + +.user-card { + position: relative; + display: flex; + align-items: center; + padding: 1.25rem; + box-shadow: + 0 10px 15px -3px rgb(0 0 0 / 0.1), + 0 4px 6px -4px rgb(0 0 0 / 0.1); + grid-gap: 1rem; + gap: 1rem; + border-radius: 10px; + background-color: #282a2e; + background-size: cover; + transition: top ease 0.1s; + top: 0; + border-bottom: none; + --local-colour1-primary: none; + --local-colour1-secondary: none; + --local-colour2-primary: none; + --local-colour2-secondary: none; + + &--fav { + border-color: var(--favourite-colour2-primary); + border-style: solid; + border-width: 2px; + } + + &:hover, + &:focus-within { + top: -3px; + } + + &__info { + padding: 5px; + } + + &__service { + color: #fff; + font-weight: 700; + padding-top: 0.25rem; + padding-bottom: 0.25rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + border-radius: 0.25rem; + font-size: 14px; + } + + &__icon { + width: 80px; + height: 80px; + align-self: center; + overflow: hidden; + border-radius: 10px; + + background-image: url("/static/loading.gif"); + background-position: center; + background-repeat: no-repeat; + + & img { + object-fit: cover; + } + } + + &__name { + font-weight: 300; + font-size: 28px; + padding-top: 5px; + padding-bottom: 5px; + color: #fff; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__count { + color: #fff; + } +} diff --git a/client/src/pages/components/fancy_image.html b/client/src/pages/components/fancy_image.html new file mode 100644 index 0000000..324a12b --- /dev/null +++ b/client/src/pages/components/fancy_image.html @@ -0,0 +1,23 @@ +{% macro fancy_image(src, srcset=src, is_lazy=true, alt="", class_name=none) %} + + {{ base_image(src, srcset, is_lazy, alt) }} + +{% endmacro %} + +{% macro background_image(src, srcset=src, is_lazy=true, class_name=none) %} +
    + {{ base_image(src, srcset, is_lazy, alt="") }} +
    +{% endmacro %} + +{% macro base_image(src, srcset=src, is_lazy=true, alt="") %} + + {{ alt }} + +{% endmacro %} diff --git a/client/src/pages/components/fancy_image.js b/client/src/pages/components/fancy_image.js new file mode 100644 index 0000000..cb9373b --- /dev/null +++ b/client/src/pages/components/fancy_image.js @@ -0,0 +1,59 @@ +import { createComponent } from "@wp/js/component-factory"; + +/** + * @param {HTMLSpanElement} element + * @param {string} src + * @param {string} srcset + * @param {boolean} isLazy + * @param {string} alt + * @param {string} className + */ +export function FancyImage(element = null, src, srcset = src, isLazy = true, alt = "", className = null) { + /** + * @type {HTMLSpanElement} + */ + const fancyImage = element ? initFromElement(element) : initFromScratch(src, srcset, isLazy, alt, className); + + return fancyImage; +} + +/** + * @param {HTMLSpanElement} element + */ +function initFromElement(element) { + return element; +} + +/** + * @param {string} src + * @param {string} srcset + * @param {boolean} isLazy + * @param {string} alt + * @param {string} className + */ +function initFromScratch(src, srcset, isLazy, alt, className) { + /** + * @type {HTMLSpanElement} + */ + const fancyImage = createComponent("fancy-image"); + /** + * @type {HTMLImageElement} + */ + const img = fancyImage.querySelector(".fancy-image__image"); + + img.src = src; + img.srcset = srcset; + img.alt = alt; + + if (className) { + fancyImage.classList.add(className); + } + + if (isLazy) { + img.loading = "lazy"; + } else { + img.loading = "eager"; + } + + return fancyImage; +} diff --git a/client/src/pages/components/fancy_image.scss b/client/src/pages/components/fancy_image.scss new file mode 100644 index 0000000..7a90124 --- /dev/null +++ b/client/src/pages/components/fancy_image.scss @@ -0,0 +1,24 @@ +.fancy-image { + &--background { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + z-index: -1; + + & .fancy-image__image { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + // &____picture {} + + &__image { + max-width: 100%; + height: auto; + object-fit: cover; + } +} diff --git a/client/src/pages/components/file_hash_search.html b/client/src/pages/components/file_hash_search.html new file mode 100644 index 0000000..84cbcc4 --- /dev/null +++ b/client/src/pages/components/file_hash_search.html @@ -0,0 +1,20 @@ +{% macro search_form() %} + +{% endmacro %} diff --git a/client/src/pages/components/file_hash_search.scss b/client/src/pages/components/file_hash_search.scss new file mode 100644 index 0000000..1af7d1e --- /dev/null +++ b/client/src/pages/components/file_hash_search.scss @@ -0,0 +1,5 @@ +#file-hash-search-form { + text-align: center; + padding-top: 1em; + padding-bottom: 0.5em; +} diff --git a/client/src/pages/components/flash_messages.html b/client/src/pages/components/flash_messages.html new file mode 100644 index 0000000..e1edc3d --- /dev/null +++ b/client/src/pages/components/flash_messages.html @@ -0,0 +1,9 @@ +{% with messages = get_flashed_messages() %} + {% if messages %} +
    + {% for message in messages %} + {{ message }}
    + {% endfor %} +
    + {% endif %} +{% endwith %} diff --git a/client/src/pages/components/footer.html b/client/src/pages/components/footer.html new file mode 100644 index 0000000..1d26570 --- /dev/null +++ b/client/src/pages/components/footer.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/client/src/pages/components/forms/base.html b/client/src/pages/components/forms/base.html new file mode 100644 index 0000000..2086c8b --- /dev/null +++ b/client/src/pages/components/forms/base.html @@ -0,0 +1,8 @@ +{% macro form() %} +
    {{ caller() if caller }}
    +{% endmacro %} diff --git a/client/src/pages/components/forms/submit_button.html b/client/src/pages/components/forms/submit_button.html new file mode 100644 index 0000000..ff68fe8 --- /dev/null +++ b/client/src/pages/components/forms/submit_button.html @@ -0,0 +1,9 @@ +{%- macro submit_button(text) -%} + +{%- endmacro -%} diff --git a/client/src/pages/components/headers.html b/client/src/pages/components/headers.html new file mode 100644 index 0000000..da744be --- /dev/null +++ b/client/src/pages/components/headers.html @@ -0,0 +1,76 @@ +{% from 'components/links.html' import fancy_link %} +{% from 'components/fancy_image.html' import background_image %} +{% from 'components/image_link.html' import image_link %} + +{% macro user_header(request, props) %} + {% set artist_icon = g.freesites.kemono.user.icon(props.service, props.id) %} + {% set artist_banner = g.freesites.kemono.user.banner(props.service, props.id) %} + {% set paysite_icons = { + 'patreon': '/static/patreon.svg', + 'fanbox': '/static/fanbox.svg', + 'gumroad': '/static/gumroad.svg', + 'subscribestar': '/static/subscribestar.png', + 'dlsite': '/static/dlsite.png', + 'fantia': '/static/fantia.png', + 'onlyfans': '/static/onlyfans.svg', + 'fansly': '/static/fansly.svg', + 'candfans': '/static/candfans.png', + } %} + +
    + {{ background_image( + artist_banner, + is_lazy=false, + class_name='user-header__background' + ) }} + + {{ image_link( + url=request.path, + src=artist_icon, + is_lazy=false, + is_noop=false, + class_name='user-header__avatar' + ) }} + + +
    +{% endmacro %} diff --git a/client/src/pages/components/image_link.html b/client/src/pages/components/image_link.html new file mode 100644 index 0000000..07ea5fa --- /dev/null +++ b/client/src/pages/components/image_link.html @@ -0,0 +1,25 @@ +{% from 'components/fancy_image.html' import base_image %} +{% from 'components/links.html' import fancy_link %} + +{% macro image_link( + url, + src=url, + alt="", + srcset=src, + is_lazy=true, + is_noop=true, + class_name=none +) %} + {% call fancy_link( + url, + '', + is_noop, + 'image-link ' ~ (class_name if class_name) + ) %} + {% if not caller %} + {{ base_image(src, srcset, is_lazy, alt) }} + {% else %} + {{ caller() }} + {% endif %} + {% endcall %} +{% endmacro %} diff --git a/client/src/pages/components/image_link.js b/client/src/pages/components/image_link.js new file mode 100644 index 0000000..0f65902 --- /dev/null +++ b/client/src/pages/components/image_link.js @@ -0,0 +1,78 @@ +import { createComponent } from "@wp/js/component-factory"; + +/** + * TODO: Restructure arguments. + * @param {HTMLAnchorElement} element + * @param {string} url + * @param {string} src + * @param {string} alt + * @param {string} srcset + * @param {boolean} isLazy + * @param {boolean} isNoop + * @param {string} className + */ +export function ImageLink( + element = null, + url, + src = url, + alt = "", + srcset = src, + isLazy = true, + isNoop = true, + className = null, +) { + const imageLink = element + ? initFromElement(element) + : initFromScratch(url, src, alt, srcset, isLazy, isNoop, className); + + return imageLink; +} + +/** + * @param {HTMLAnchorElement} element + */ +function initFromElement(element) { + return element; +} + +/** + * @param {string} url + * @param {string} src + * @param {string} alt + * @param {string} srcset + * @param {boolean} isLazy + * @param {boolean} isNoop + * @param {string} className + */ +function initFromScratch(url, src, alt, srcset, isLazy, isNoop, className) { + /** + * @type {HTMLAnchorElement} + */ + const imageLink = createComponent("fancy-link image-link"); + /** + * @type {HTMLImageElement} + */ + const image = imageLink.querySelector(".fancy-image__image"); + + imageLink.href = url; + image.src = src; + image.srcset = srcset; + image.alt = alt; + + if (isNoop) { + imageLink.target = "_blank"; + imageLink.rel = "noopener noreferrer"; + } + + if (isLazy) { + image.loading = "lazy"; + } else { + image.loading = "eager"; + } + + if (className) { + imageLink.classList.add(className); + } + + return imageLink; +} diff --git a/client/src/pages/components/image_link.scss b/client/src/pages/components/image_link.scss new file mode 100644 index 0000000..85fcb6c --- /dev/null +++ b/client/src/pages/components/image_link.scss @@ -0,0 +1,38 @@ +a.image-link { + --local-colour1-primary: transparent; + --local-colour1-secondary: transparent; + --local-colour2-primary: transparent; + --local-colour2-secondary: transparent; + + background-color: transparent; + border: none; + padding: 0; + overflow: hidden; + + &:link { + color: var(--local-colour1-primary); + } + + &:visited { + color: var(--local-colour1-secondary); + } + + &:focus { + background: var(--local-colour2-primary); + } + + &:hover { + background: var(--local-colour2-secondary); + } + + &:active { + background: var(--local-colour1-primary); + color: var(--local-colour2-primary); + } + + & .fancy-image__image { + width: 100%; + height: 100%; + object-fit: cover; + } +} diff --git a/client/src/pages/components/import_sidebar.html b/client/src/pages/components/import_sidebar.html new file mode 100644 index 0000000..712c852 --- /dev/null +++ b/client/src/pages/components/import_sidebar.html @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/client/src/pages/components/importer_states.html b/client/src/pages/components/importer_states.html new file mode 100644 index 0000000..659318d --- /dev/null +++ b/client/src/pages/components/importer_states.html @@ -0,0 +1,4 @@ +{#
    + + +
    #} \ No newline at end of file diff --git a/client/src/pages/components/importer_states.scss b/client/src/pages/components/importer_states.scss new file mode 100644 index 0000000..51892a7 --- /dev/null +++ b/client/src/pages/components/importer_states.scss @@ -0,0 +1,10 @@ +.importer-state-wrapper { + display: flex; + align-items: center; + justify-content: center; + padding: 1em 0; + + & > :last-child { + margin-left: 1em; + } +} diff --git a/client/src/pages/components/links.html b/client/src/pages/components/links.html new file mode 100644 index 0000000..87c05d6 --- /dev/null +++ b/client/src/pages/components/links.html @@ -0,0 +1,56 @@ +{# not splitting on several lines because it adds whitespaces in the output #} +{% macro fancy_link(url, text=url, is_noop=true, class_name=none ) %} + {{ text if not caller else caller() }} +{%- endmacro -%} + +{% macro download_link(url, text=url, file_name=text, class_name=none) %} + {{ text if not caller else caller() }} +{%- endmacro -%} + +{% macro kemono_link(url, text=url, is_noop=true,class_name=none) %} + {{ text if not caller else caller() }} +{%- endmacro -%} + +{% macro local_link(id, text=id, class_name=none) %} + {{ text if not caller else caller() }} +{%- endmacro -%} + +{% macro email_link(email, text=email, class_name=none) %} + +{%- endmacro -%} + +{% macro link_button(url, text=url, is_noop=true, class_name=none) %} + {{ text if not caller else caller() }} +{% endmacro %} diff --git a/client/src/pages/components/links.js b/client/src/pages/components/links.js new file mode 100644 index 0000000..6ab26bd --- /dev/null +++ b/client/src/pages/components/links.js @@ -0,0 +1,53 @@ +import { createComponent } from "@wp/js/component-factory"; + +/** + * @param {HTMLElement} element + * @param {string} url + * @param {string} text + * @param {boolean} isNoop + * @param {string} className + * @returns + */ +export function FancyLink(element = null, url, text = url, isNoop = true, className = undefined) { + /** + * @type {HTMLAnchorElement} + */ + const fancyLink = element ? initFromElement(element) : initFromScratch(url, text, isNoop, className); + + return fancyLink; +} + +/** + * @param {HTMLAnchorElement} + */ +function initFromElement(element) { + return element; +} + +/** + * @param {string} url + * @param {string} text + * @param {boolean} isNoop + * @param {string} className + * @returns + */ +function initFromScratch(url, text, isNoop, className) { + /** + * @type {HTMLAnchorElement} + */ + const fancyLink = createComponent("fancy-link"); + + fancyLink.href = url; + fancyLink.textContent = text; + + if (className) { + fancyLink.classList.add(className); + } + + if (isNoop) { + fancyLink.target = "_blank"; + fancyLink.rel = "noopener noreferrer"; + } + + return fancyLink; +} diff --git a/client/src/pages/components/links.scss b/client/src/pages/components/links.scss new file mode 100644 index 0000000..80074e9 --- /dev/null +++ b/client/src/pages/components/links.scss @@ -0,0 +1,63 @@ +@use "../../css/config/variables.scss" as *; + +.fancy-link { + &--download { + } + &--kemono { + } + &--local { + } + + &--button { + display: grid; + align-items: center; + min-height: $button-min-height; + min-width: $button-min-width; + background-color: var(--local-colour2-secondary); + border-radius: 5px; + border: $size-nano solid var(--local-colour1-primary); + padding: $size-small; + + & .fancy-link__text { + background-color: transparent; + border-bottom: $size-nano solid transparent; + + transition-property: color, border-color, background-color; + transition-duration: var(--duration-global); + } + + &:link { + color: var(--local-colour1-primary); + } + + &:visited { + color: var(--local-colour1-primary); + } + + &:focus { + background-color: var(--local-colour2-primary); + } + + &:hover { + background-color: var(--local-colour2-primary); + border-color: var(--local-colour1-primary); + + & .fancy-link__text { + border-color: var(--local-colour1-primary); + } + } + + &:active { + background-color: var(--local-colour1-primary); + color: var(--local-colour2-primary); + border-color: var(--local-colour2-primary); + + & .fancy-link__text { + border-color: var(--local-colour2-primary); + } + } + } + + &__text { + } +} diff --git a/client/src/pages/components/lists/_index.scss b/client/src/pages/components/lists/_index.scss new file mode 100644 index 0000000..914c413 --- /dev/null +++ b/client/src/pages/components/lists/_index.scss @@ -0,0 +1,2 @@ +@use "base"; +@use "faq"; diff --git a/client/src/pages/components/lists/base.html b/client/src/pages/components/lists/base.html new file mode 100644 index 0000000..52b498b --- /dev/null +++ b/client/src/pages/components/lists/base.html @@ -0,0 +1,18 @@ +{% from 'components/meta/attributes.html' import attributes %} + +{# Call-only macros #} +{% macro desc_list() %} +
    {{ caller() }}
    +{% endmacro %} + +{% macro desc_section() %} +
    {{ caller() }}
    +{% endmacro %} + +{% macro desc_term() %} +
    {{ caller() }}
    +{% endmacro %} + +{% macro desc_details() %} +
    {{ caller() }}
    +{% endmacro %} diff --git a/client/src/pages/components/lists/base.scss b/client/src/pages/components/lists/base.scss new file mode 100644 index 0000000..ef27f9d --- /dev/null +++ b/client/src/pages/components/lists/base.scss @@ -0,0 +1,23 @@ +@use "../../../css/config/variables" as *; + +.desc-list { + background-color: var(--colour1-tertiary); + border-radius: 10px; + + &__section { + display: inline-block; + border-radius: 10px; + padding: $size-small; + + &:target { + outline-color: var(--anchour-local-colour1-primary); + outline-width: $size-thin; + outline-style: dashed; + } + } + &__term { + font-weight: bold; + } + &__details { + } +} diff --git a/client/src/pages/components/lists/faq.html b/client/src/pages/components/lists/faq.html new file mode 100644 index 0000000..80ebe71 --- /dev/null +++ b/client/src/pages/components/lists/faq.html @@ -0,0 +1,18 @@ +{% from 'components/meta/attributes.html' import attributes %} + +{# Call-only macros #} +{% macro faq_list() %} +
    {{ caller() }}
    +{% endmacro %} + +{% macro faq_section() %} +
    {{ caller() }}
    +{% endmacro %} + +{% macro faq_question() %} +
    {{ caller() }}
    +{% endmacro %} + +{% macro faq_answer() %} +
    {{ caller() }}
    +{% endmacro %} diff --git a/client/src/pages/components/lists/faq.scss b/client/src/pages/components/lists/faq.scss new file mode 100644 index 0000000..6d4f63b --- /dev/null +++ b/client/src/pages/components/lists/faq.scss @@ -0,0 +1,10 @@ +.desc-list { + &--faq { + } + &__section--faq { + } + &__term--question { + } + &__details--answer { + } +} diff --git a/client/src/pages/components/loading_icon.html b/client/src/pages/components/loading_icon.html new file mode 100644 index 0000000..0bb1a2a --- /dev/null +++ b/client/src/pages/components/loading_icon.html @@ -0,0 +1,9 @@ +{% from "components/fancy_image.html" import fancy_image %} + +{% set url = url_for('static', filename='loading.gif') %} + +{% macro loading_icon() -%} + + {{ fancy_image(url, alt="loading progress spinner") }} + +{%- endmacro %} diff --git a/client/src/pages/components/loading_icon.js b/client/src/pages/components/loading_icon.js new file mode 100644 index 0000000..f86fc89 --- /dev/null +++ b/client/src/pages/components/loading_icon.js @@ -0,0 +1,9 @@ +import { createComponent } from "@wp/js/component-factory"; + +export function LoadingIcon() { + /** + * @type {HTMLSpanElement} + */ + const icon = createComponent("loading-icon"); + return icon; +} diff --git a/client/src/pages/components/loading_icon.scss b/client/src/pages/components/loading_icon.scss new file mode 100644 index 0000000..d9b6df9 --- /dev/null +++ b/client/src/pages/components/loading_icon.scss @@ -0,0 +1,3 @@ +.loading-icon { + display: inline-block; +} diff --git a/client/src/pages/components/meta/attributes.html b/client/src/pages/components/meta/attributes.html new file mode 100644 index 0000000..e08080e --- /dev/null +++ b/client/src/pages/components/meta/attributes.html @@ -0,0 +1,7 @@ +{# Put html attributes into kwargs argument #} +{% macro attributes(class_name) %} + class="{{ class_name ~ ' ' ~ kwargs.pop('class') if kwargs.class else class_name }}" + {% for attribute in kwargs %} + {{ attribute }}="{{ kwargs[attribute] }}" + {% endfor %} +{% endmacro %} diff --git a/client/src/pages/components/navigation/_index.scss b/client/src/pages/components/navigation/_index.scss new file mode 100644 index 0000000..31e0e9a --- /dev/null +++ b/client/src/pages/components/navigation/_index.scss @@ -0,0 +1,5 @@ +@use "base"; +@use "global"; +@use "local"; +@use "account"; +@use "sidebar"; diff --git a/client/src/pages/components/navigation/account.html b/client/src/pages/components/navigation/account.html new file mode 100644 index 0000000..e69de29 diff --git a/client/src/pages/components/navigation/account.scss b/client/src/pages/components/navigation/account.scss new file mode 100644 index 0000000..e7c51f6 --- /dev/null +++ b/client/src/pages/components/navigation/account.scss @@ -0,0 +1 @@ +@use "../../../css/config/variables" as *; diff --git a/client/src/pages/components/navigation/base.html b/client/src/pages/components/navigation/base.html new file mode 100644 index 0000000..61f14d1 --- /dev/null +++ b/client/src/pages/components/navigation/base.html @@ -0,0 +1,23 @@ +{# Call-only macros #} +{% macro navigation(id=none, class_name=none) %} + +{% endmacro %} + +{% macro nav_list(class_name=none) %} + +{% endmacro %} + +{% macro nav_item(class_name=none) %} + +{% endmacro %} diff --git a/client/src/pages/components/navigation/base.scss b/client/src/pages/components/navigation/base.scss new file mode 100644 index 0000000..8e830c1 --- /dev/null +++ b/client/src/pages/components/navigation/base.scss @@ -0,0 +1,24 @@ +@use "../../../css/config/variables" as *; + +.navigation { + &__list { + display: flex; + flex-flow: column nowrap; + align-items: flex-start; + gap: $size-small; + list-style: none; + padding: $size-small; + margin: 0; + + &--ordered { + list-style-type: decimal-leading-zero; + padding-left: $size-normal; + } + } + + &__item { + } + + &__link { + } +} diff --git a/client/src/pages/components/navigation/global.html b/client/src/pages/components/navigation/global.html new file mode 100644 index 0000000..9a1dbc4 --- /dev/null +++ b/client/src/pages/components/navigation/global.html @@ -0,0 +1,35 @@ +{% from 'components/navigation/base.html' import navigation, nav_list, nav_item %} +{% from 'components/links.html' import fancy_link, kemono_link %} +{% from 'components/buttons.html' import button as base_button %} + +{% macro nav(id) %} + {% call navigation(id, class_name='global-nav') %} + {{ caller }} + {% endcall %} +{% endmacro %} + +{% macro list() %} + {% call nav_list(class_name='global-nav__list') %} + {{ caller }} + {% endcall %} +{% endmacro %} + +{% macro item() %} + {% call nav_item(class_name='global-nav__item') %} + {{ caller }} + {% endcall %} +{% endmacro %} + +{% macro button() %} + {% call base_button() %} + {{ caller }} + {% endcall %} +{% endmacro %} + +{% macro link(url, text) %} + {{ kemono_link(url, text, class_name='global-nav__link') }} +{% endmacro %} + +{% macro link_external(url, text) %} + {{ fancy_link(url, text, class_name='global-nav__link') }} +{% endmacro %} diff --git a/client/src/pages/components/navigation/global.scss b/client/src/pages/components/navigation/global.scss new file mode 100644 index 0000000..08865b4 --- /dev/null +++ b/client/src/pages/components/navigation/global.scss @@ -0,0 +1,110 @@ +@use "../../../css/config/variables" as *; + +.global-nav { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: center; + gap: $size-normal; + + &__list { + display: flex; + flex-flow: column nowrap; + + transition-property: visibility, opacity; + transition-duration: var(--duration-global); + } + + &__item { + position: relative; + + & > .global-nav__list { + position: absolute; + top: 100%; + z-index: 1; + visibility: hidden; + opacity: 0; + display: flex; + flex-flow: column nowrap; + align-items: flex-start; + min-width: 150px; + background-color: var(--colour1-tertiary); + border-radius: 10px; + padding: $size-small; + } + + &--open { + & > .global-nav__button { + background-color: var(--local-colour2-secondary); + border-radius: 5px 5px 0 0; + border-bottom-color: transparent; + } + + & > .global-nav__list { + visibility: visible; + opacity: 1; + border-radius: 0 10px 10px 10px; + box-shadow: 0 0 5px var(--colour1-primary-transparent); + } + } + + &--account { + margin-left: auto; + + & > .global-nav__list { + right: 0; + border-radius: 10px 0 10px 10px; + } + } + + // quick hack until I figure out anchour selector specificities + & .global-nav__link { + --local-colour1-primary: var(--colour0-primary); + --local-colour1-secondary: var(--colour0-primary); + --local-colour2-primary: var(--colour1-tertiary); + --local-colour2-secondary: var(--colour1-secondary); + } + } + + &__button { + --local-colour1-primary: var(--colour0-primary); + --local-colour1-secondary: var(--colour0-tertirary); + --local-colour2-primary: var(--colour1-primary); + --local-colour2-secondary: var(--colour1-tertiary); + + // temp until header rework + min-height: 34px; + color: var(--local-colour1-primary); + background-image: none; + background-color: var(--local-colour2-primary); + border: $size-nano solid var(--local-colour1-secondary); + box-shadow: + inset 2px 2px 3px hsla(0, 0%, 40%, 0.5), + inset -2px -2px 3px hsla(0, 0%, 0%, 0.5); + + transition-property: color, background-color, shadow, outline; + transition-duration: var(--duration-global); + + &:focus { + background-color: var(--local-colour2-secondary); + // outline-offset: 3px; + // outline-width: $size-thin; + // outline-style: dashed; + // outline-color: var(--colour0-secondary); + } + + &:hover { + background-color: var(--local-colour2-secondary); + } + + &:active { + box-shadow: + inset -2px -2px 3px hsla(0, 0%, 40%, 0.5), + inset 2px 2px 3px hsla(0, 0%, 0%, 0.5); + } + + &--notifs { + --local-colour1-primary: var(--submit-colour1-primary); + } + } +} diff --git a/client/src/pages/components/navigation/local.html b/client/src/pages/components/navigation/local.html new file mode 100644 index 0000000..9fb70f5 --- /dev/null +++ b/client/src/pages/components/navigation/local.html @@ -0,0 +1,18 @@ +{% from 'components/meta/attributes.html' import attributes %} +{% from 'components/links.html' import local_link %} + +{% macro local_nav() %} + +{% endmacro %} + +{% macro local_list() %} +
      {{ caller() }}
    +{% endmacro %} + +{% macro local_list_ordered() %} +
      {{ caller() }}
    +{% endmacro %} + +{% macro local_item(id, text) %} +
  • {{ local_link(id, text) }}
  • +{% endmacro %} diff --git a/client/src/pages/components/navigation/local.scss b/client/src/pages/components/navigation/local.scss new file mode 100644 index 0000000..a5bf4c8 --- /dev/null +++ b/client/src/pages/components/navigation/local.scss @@ -0,0 +1,16 @@ +@use "../../../css/config/variables" as *; + +.navigation { + &--local { + display: inline-block; + } + + &__list--local { + list-style-type: disc; + background-color: var(--colour1-tertiary); + border-radius: 10px; + padding-left: calc(#{$size-normal} + #{$size-small}); + } + &__item--local { + } +} diff --git a/client/src/pages/components/navigation/sidebar.html b/client/src/pages/components/navigation/sidebar.html new file mode 100644 index 0000000..9096558 --- /dev/null +++ b/client/src/pages/components/navigation/sidebar.html @@ -0,0 +1,75 @@ +{% macro nav_list(items, class_name=none) %} + {% for item in items %} + {{ nav_entry(item, class_name) }} + {% endfor %} +{% endmacro %} + +{% macro nav_entry(items, class_name=none) %} +
    + {% if not caller %} + {% for item in items %} + {% if not item.disable %} + {% if item.header %} + {% if item.link %} + {{ nav_item( + item.link, + item.text, + "clickable-header " ~ (item.class_name if item.class_name), + icon=item.icon + ) + }} + {% else %} + {{ nav_header(item.text, item.class_name, item.icon) }} + {% endif %} + {% else %} + {{ nav_item( + item.link, + item.text, + item.class_name, + item.is_external, + item.color, + item.icon + ) + }} + {% endif %} + {% endif %} + {% endfor %} + {% else %} + {{ caller() }} + {% endif %} +
    +{% endmacro %} + +{% macro nav_header(text, class_name=none, icon=none) %} +
    + {% if icon %} + + {% endif %} + {% if not caller %} + {{ text }} + {% else %} + {{ caller() }} + {% endif %} +
    +{% endmacro %} + +{% macro nav_item(link, text=link, class_name=none, is_external=false, color=none, icon=none) %} + {% if icon %} + + {% endif %} + {% if not caller %} + {{ text }} + {% else %} + {{ caller() }} + {% endif %} + +{% endmacro %} diff --git a/client/src/pages/components/navigation/sidebar.scss b/client/src/pages/components/navigation/sidebar.scss new file mode 100644 index 0000000..7b31f8e --- /dev/null +++ b/client/src/pages/components/navigation/sidebar.scss @@ -0,0 +1,138 @@ +@use "../../../css/config/variables" as *; + +.global-sidebar { + position: fixed; + height: 100%; + display: flex; + width: 12rem; + min-width: 160px; + flex-direction: column; + background: rgb(40 42 46); + padding: 0.5em 0; + transition: margin-left 250ms ease-in-out 0s; + overflow-y: auto; + + @media (max-width: $sidebar-min-width) { + position: absolute; + height: auto; + min-height: 100%; + width: 15rem; + margin-left: -15rem; + z-index: 3; + + &.expanded { + margin-left: 0; + } + } + + @media (min-width: #{$sidebar-min-width + 1}) { + &.retracted { + margin-left: -12rem; + } + } + + &-entry { + display: flex; + flex-direction: column; + margin-left: 1rem; + margin-right: 1rem; + margin-top: 0.5rem; + white-space: nowrap; + + a { + color: unset; + border: 0; + transition-property: unset; + transition-duration: unset; + } + + &.stuck-bottom { + margin-top: auto; + } + + &.clickable-header-entry { + display: flex; + justify-content: space-between; + flex-direction: row; + align-items: center; + } + + .close-sidebar { + cursor: pointer; + height: 28px; + margin-left: 10px; + + @media (max-width: $sidebar-min-width) { + height: 34px; + margin-left: 20px; + } + + & > img { + height: 100%; + } + &:hover, + &:focus { + background: rgb(255 255 255 / 10%); + } + } + + &-item { + display: flex; + align-items: center; + padding-left: 0.5rem; + height: 28px; + + @media (max-width: $sidebar-min-width) { + height: 34px; + } + + &-icon { + width: 20px; + height: 20px; + margin-right: 7px; + } + + &.clickable-header { + cursor: pointer; + width: 100%; + } + + &.header, + &.clickable-header { + font-weight: bold; + } + + &.home-button { + flex: 1; + } + + &:not(.header):not(&.clickable-header) { + padding-left: 1.5rem; + cursor: pointer; + } + + &:not(.header):hover, + &.clickable-header:hover, + &:not(.header):focus, + &.clickable-header:focus { + background: rgb(255 255 255 / 10%); + } + + &.donate { + color: hsl(3, 100%, 70%); + } + + &.chan { + color: hsl(224, 27%, 49%); + } + + &.tg { + color: hsl(200, 100%, 50%); + } + + &.tpd { + color: hsl(35, 100%, 50%); + } + } + } +} diff --git a/client/src/pages/components/paginator.html b/client/src/pages/components/paginator.html new file mode 100644 index 0000000..d925709 --- /dev/null +++ b/client/src/pages/components/paginator.html @@ -0,0 +1,72 @@ +{% set skip = request.args.get('o')|parse_int if request.args.get('o') else 0 %} +{% set currentCeilingOfRange = skip + props.limit if (skip + props.limit) < props.count else props.count %} + +{% set TOTAL_BUTTONS = 5 %} +{% set OPTIONAL_BUTTONS = TOTAL_BUTTONS - 2 %} +{% set MANDATORY_BUTTONS = TOTAL_BUTTONS - OPTIONAL_BUTTONS %} +{% set currPageNum = ((skip + props.limit) / props.limit)|round(0, 'ceil')|int %} +{% set totalPages = (props.count / props.limit)|round(0, 'ceil')|int %} +{% set numBeforeCurrPage = currPageNum - 1 if ((totalPages < TOTAL_BUTTONS) or (currPageNum < TOTAL_BUTTONS)) else ((TOTAL_BUTTONS - 1) + ((TOTAL_BUTTONS) - (totalPages - currPageNum)) if (totalPages - currPageNum) < TOTAL_BUTTONS else (TOTAL_BUTTONS - 1)) %} +{% set basePageNum = [currPageNum - numBeforeCurrPage - 1, 1]|max %} +{% set showFirstPostsButton = basePageNum > 1 %} +{% set showLastPostsButton = totalPages - currPageNum > (TOTAL_BUTTONS + ((TOTAL_BUTTONS - (currPageNum - basePageNum)) if currPageNum - basePageNum < TOTAL_BUTTONS else 0)) %} +{% set optionalBeforeButtons = currPageNum - MANDATORY_BUTTONS - ((MANDATORY_BUTTONS - (totalPages - currPageNum)) if totalPages - currPageNum < MANDATORY_BUTTONS else 0) %} +{% set optionalAfterButtons = currPageNum + MANDATORY_BUTTONS + ((MANDATORY_BUTTONS - (currPageNum - basePageNum)) if currPageNum - basePageNum < MANDATORY_BUTTONS else 0) %} + +{% macro paginator_button(content, href=none, class_name=none) %} + {%if href %} + {{ content }} + {%else%} +
  • {{ content }}
  • + {%endif%} +{% endmacro %} + +{% if props.count > props.limit %} + + Showing {{ skip + 1 }} - {{ currentCeilingOfRange }} of {{ props.true_count or props.count }} + + {% set rng = range(0, (TOTAL_BUTTONS * 2) + 1) %} + + {%if showFirstPostsButton or showLastPostsButton %} + {%if showFirstPostsButton %} + {{ paginator_button('<<', href=url_for(request.endpoint, o = 0, **base)) }} + {%else%} + {{ paginator_button('<<', class_name='pagination-button-disabled' ~ (' pagination-desktop' if currPageNum - MANDATORY_BUTTONS - 1 else '')) }} + {%endif%} + {%endif%} + {%if not showFirstPostsButton %} + {%if currPageNum - MANDATORY_BUTTONS - 1 %} + {{ paginator_button('<<', href=url_for(request.endpoint, o = 0, **base), class_name='pagination-mobile') }} + {%elif (totalPages - currPageNum > MANDATORY_BUTTONS) and not showLastPostsButton %} + {{ paginator_button('<<', class_name='pagination-button-disabled pagination-mobile') }} + {%endif%} + {%endif%} + {%if currPageNum > 1 %} + {{ paginator_button('<', href=url_for(request.endpoint, o = (currPageNum - 2) * props.limit, **base), class_name='prev') }} + {%else%} + {{ paginator_button('<', class_name='pagination-button-disabled')}} + {%endif%} + {% for page in rng if (page + basePageNum) and ((page + basePageNum) <= totalPages) %} + {{ paginator_button((page + basePageNum), href=url_for(request.endpoint, o =((page + basePageNum - 1) * props.limit) if not (page + basePageNum) == 1 else none, **base) if (page + basePageNum) != currPageNum else none, class_name='pagination-button-optional' if ((page + basePageNum) < optionalBeforeButtons or (page + basePageNum) > optionalAfterButtons) and (page + basePageNum) != currPageNum else ('pagination-button-disabled pagination-button-current' if (page + basePageNum) == currPageNum else ('pagination-button-after-current' if (page + basePageNum) == (currPageNum + 1) else ''))) }} + {% endfor %} + {%if currPageNum < totalPages %} + {{ paginator_button('>', href=url_for(request.endpoint, o = currPageNum * props.limit, **base), class_name='next') }} + {%else%} + {{ paginator_button('>', class_name='pagination-button-disabled' ~(' pagination-button-after-current' if totalPages else '')) }} + {%endif%} + {%if showFirstPostsButton or showLastPostsButton %} + {%if showLastPostsButton %} + {{ paginator_button('>>', href=url_for(request.endpoint, o = (totalPages - 1) * props.limit, **base)) }} + {%else%} + {{ paginator_button('>>', class_name='pagination-button-disabled' ~ (' pagination-desktop' if totalPages - currPageNum > MANDATORY_BUTTONS else '')) }} + {%endif%} + {%endif%} + {%if not showLastPostsButton %} + {%if totalPages - currPageNum > MANDATORY_BUTTONS%} + {{ paginator_button('>>', href=url_for(request.endpoint, o = (totalPages - 1) * props.limit, **base), class_name='pagination-mobile') }} + {%elif (currPageNum > OPTIONAL_BUTTONS) and not showFirstPostsButton %} + {{ paginator_button('>>', class_name='pagination-button-disabled pagination-mobile') }} + {%endif%} + {%endif%} + +{% endif %} diff --git a/client/src/pages/components/paginator.js b/client/src/pages/components/paginator.js new file mode 100644 index 0000000..76e4451 --- /dev/null +++ b/client/src/pages/components/paginator.js @@ -0,0 +1,12 @@ +export function registerPaginatorKeybinds() { + document.addEventListener("keydown", (e) => { + switch (e.key) { + case "ArrowLeft": + document.querySelector(".paginator .prev")?.click(); + break; + case "ArrowRight": + document.querySelector(".paginator .next")?.click(); + break; + } + }); +} diff --git a/client/src/pages/components/paginator_new.html b/client/src/pages/components/paginator_new.html new file mode 100644 index 0000000..64b05a3 --- /dev/null +++ b/client/src/pages/components/paginator_new.html @@ -0,0 +1,116 @@ +{% from 'components/links.html' import link_button %} + +{# `id` is the id of related `form_controller()` #} +{% macro paginator(id, request, pagination, class_name= none) %} + {% set current_page = pagination.current_page %} + {% set total_pages = pagination.total_pages %} + {% set base_url = pagination.base_url %} + +
    + + Showing {{ pagination.offset + 1 }} - {{ pagination.current_count }} of {{ pagination.count }} + +
      +
    • + {% if current_page != 1 %} + {{ link_button( + pagination.create_paged_url(request, 1), + 1, + is_noop=false, + class_name= 'paginator__link' + ) }} + {% else %} + + ... + + {% endif %} + +
    • +
    • + {% if current_page > 2 %} + {{ link_button( + pagination.create_paged_url(request, current_page - 1), + current_page - 1, + is_noop=false, + class_name= 'paginator__link' + ) }} + {% else %} + + ... + + {% endif %} +
    • + +
    • + + +
    • + +
    • + {% if current_page < total_pages - 1 %} + {{ link_button( + pagination.create_paged_url(request, current_page + 1), + current_page + 1, + is_noop=false, + class_name= 'paginator__link' + ) }} + {% else %} + + ... + + {% endif %} +
    • + +
    • + {% if current_page != total_pages %} + {{ link_button( + pagination.create_paged_url(request, total_pages), + total_pages, + is_noop=false, + class_name= 'paginator__link' + ) }} + {% else %} + + ... + + {% endif %} +
    • +
    +
    +{% endmacro %} + +{# `**kwargs` is `
    ` attributes #} +{% macro paginator_controller(id, request, pagination) %} + + {% for param in pagination.base %} + + {% endfor %} +
    +{% endmacro %} diff --git a/client/src/pages/components/paginator_new.scss b/client/src/pages/components/paginator_new.scss new file mode 100644 index 0000000..2153f99 --- /dev/null +++ b/client/src/pages/components/paginator_new.scss @@ -0,0 +1,55 @@ +@use "../../css/config/variables.scss" as *; + +.paginator { + &__count { + } + + &__pages { + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + gap: 1em; + list-style: none; + } + + &__page { + display: grid; + min-width: 44px; + min-height: 44px; + + &--current { + position: relative; + flex: 0 1 var(--local-width); + + &:focus-within .paginator__submit { + visibility: visible; + opacity: 1; + } + } + } + + &__link { + padding: $size-small; + } + + &__input { + text-align: center; + } + + &__submit { + position: absolute; + top: calc(100% + 0.5em); + left: 0; + z-index: 1; + visibility: hidden; + opacity: 0; + + transition-property: visibility, opacity; + transition-duration: var(--duration-global); + } + + &__controller { + display: none; + } +} diff --git a/client/src/pages/components/shell.html b/client/src/pages/components/shell.html new file mode 100644 index 0000000..88e4c33 --- /dev/null +++ b/client/src/pages/components/shell.html @@ -0,0 +1,221 @@ +{# TODO: figure out nested macro calls #} +{% import 'components/navigation/global.html' as global %} +{% from 'components/navigation/sidebar.html' import nav_list, nav_item, nav_header, nav_entry %} +{% from 'components/loading_icon.html' import loading_icon %} +{% from 'components/timestamp.html' import timestamp %} +{% from 'components/tooltip.html' import tooltip %} +{% from 'components/links.html' import fancy_link, kemono_link, link_button %} +{% from 'components/tooltip.html' import register_message %} +{% from 'components/buttons.html' import button %} + +{% macro header_link(url, text, class_name=none) %} + + {{ text }} + +{% endmacro %} + + + + + + + {% if g.matomo_enabled and g.matomo_plain_code %} + {{ g.matomo_plain_code|safe }} + {% elif g.matomo_enabled %} + + + {% endif %} + + + + {% block title %} + + {{ (props.name ~ " | " ~ g.site_name) if props.name else g.site_name }} + + {% endblock title %} + + + {% block meta %} + {% if props.service %} + + {% endif %} + {% if props.id %} + + {% endif %} + {% if props.importId %} + + {% endif %} + {% if props.count %} + + {% endif %} + {% if props.posts|length %} + {% if props.posts[0].published %} + + {% endif %} + + + {% endif %} + {% endblock meta %} + + {% block opengraph %} + + + + + + + + {% endblock opengraph %} + + {% block styles %} + {% endblock styles %} + + {% block scripts %} + {% if request.args.logged_in %} + + {% endif %} + {% if request.args.role %} + + {% endif %} + {# TODO remove this shit #} + {% endblock scripts %} + + {% block bundler_output %} + {# quick hack until writing proper loader #} + <% for (const css in htmlWebpackPlugin.files.css) { %> + <% if (htmlWebpackPlugin.files.css[css].startsWith("/static/bundle/css/global")) { %> + + <% } %> + <% } %> + <% for (const chunk in htmlWebpackPlugin.files.chunks) { %> + + <% } %> + <% for (const scriptPath in htmlWebpackPlugin.files.js) { %> + <% if (htmlWebpackPlugin.files.js[scriptPath].startsWith("/static/bundle/js/global") || htmlWebpackPlugin.files.js[scriptPath].startsWith("/static/bundle/js/runtime") || htmlWebpackPlugin.files.js[scriptPath].startsWith("/static/bundle/js/vendors")) { %> + + <% } %> + <% } %> + {% endblock bundler_output %} + + {% block scripts_extra %} + {% endblock scripts_extra %} + + + + +
    +
    +
    +
    +
    + +
    + {{ header_link('/', 'Home', 'home') }} + {{ header_link('/artists', g.artists_or_creators) }} + {{ header_link('/posts', 'Posts') }} + {{ header_link('/importer', 'Import', 'import') }} + {{ header_link('/account/register?location=' + request.args.get("location", request.path), 'Register', 'register') }} + {{ header_link('/account/login?location=' + request.args.get("location", request.path), 'Login', 'login') }} +
    + {% include 'components/flash_messages.html' %} + {% if g.banner_global %} + {{ g.banner_global|safe }} + {% endif %} + +
    + {% block content %} + {% endblock content %} +
    + +
    + {% include 'components/footer.html' %} + +
    +
    + {#
    +
    #} + {% call tooltip() %} +

    +

    + {% endcall %} + + diff --git a/client/src/pages/components/shell.js b/client/src/pages/components/shell.js new file mode 100644 index 0000000..0db0df7 --- /dev/null +++ b/client/src/pages/components/shell.js @@ -0,0 +1,106 @@ +import { isLoggedIn } from "@wp/js/account"; + +window.addEventListener("load", () => { + document.body.classList.remove("transition-preload"); +}); + +/** + * @param {HTMLElement} sidebar + */ +export function initShell(sidebar) { + const burgor = document.getElementById("burgor"); + const header = burgor.parentElement; + const backdrop = document.querySelector(".backdrop"); + const contentWrapper = document.querySelector(".content-wrapper"); + const closeButton = sidebar.querySelector(".close-sidebar"); + const closeSidebar = (_, setState = true) => { + sidebar.classList.toggle("expanded"); + sidebar.classList.toggle("retracted"); + backdrop.classList.toggle("backdrop-hidden"); + contentWrapper.classList.toggle("shifted"); + const retracted = header.classList.toggle("sidebar-retracted"); + if (setState && window.innerWidth > 1020) localStorage.setItem("sidebar_state", retracted); + }; + if (typeof localStorage.getItem("sidebar_state") === "string") { + const sidebarState = localStorage.getItem("sidebar_state") === "true"; + if (window.innerWidth > 1020 && sidebarState) closeSidebar(); + } + window.addEventListener("resize", () => { + if (typeof localStorage.getItem("sidebar_state") !== "string") return; + const sidebarState = localStorage.getItem("sidebar_state") === "true"; + const realState = header.classList.contains("sidebar-retracted"); + const killAnimations = () => { + document.body.classList.add("transition-preload"); + requestAnimationFrame(() => setInterval(() => document.body.classList.remove("transition-preload"))); + }; + if (window.innerWidth <= 1020) { + if (sidebarState && realState) { + killAnimations(); + closeSidebar(null, false); + } + } else if (sidebarState && !realState) { + killAnimations(); + closeSidebar(); + } + }); + burgor.addEventListener("click", closeSidebar); + backdrop.addEventListener("click", closeSidebar); + closeButton.addEventListener("click", closeSidebar); + if (isLoggedIn) { + const accountList = sidebar.querySelector(".account"); + const login = accountList.querySelector(".login"); + const loginHeader = header.querySelector(".login"); + const register = accountList.querySelector(".register"); + const registerHeader = header.querySelector(".register"); + const favorites = accountList.querySelector(".favorites"); + const reviewDms = accountList.querySelector(".review_dms"); + login.classList.remove("login"); + loginHeader.classList.remove("login"); + loginHeader.classList.add("logout"); + register.classList.remove("register"); + registerHeader.classList.remove("register"); + favorites.classList.remove("hidden"); + reviewDms.classList.remove("hidden"); + login.lastChild.textContent = "Logout"; + login.firstElementChild.src = "/static/menu/logout.svg"; + login.href = "/account/logout"; + loginHeader.innerText = "Logout"; + loginHeader.href = "/account/logout"; + register.lastChild.textContent = "Keys"; + register.firstElementChild.src = "/static/menu/keys.svg"; + register.href = "/account/keys"; + registerHeader.innerText = "Favorites"; + registerHeader.href = "/favorites"; + const onLogout = (e) => { + e.preventDefault(); + localStorage.removeItem("logged_in"); + localStorage.removeItem("role"); + localStorage.removeItem("favs"); + localStorage.removeItem("post_favs"); + location.href = "/account/logout"; + }; + login.addEventListener("click", onLogout); + loginHeader.addEventListener("click", onLogout); + } else { + const accountHeader = sidebar.querySelector(".account-header"); + const newHeader = document.createElement("div"); + newHeader.className = "global-sidebar-entry-item header"; + newHeader.innerText = "Account"; + newHeader.prepend(accountHeader.firstElementChild); + accountHeader.parentElement.replaceChild(newHeader, accountHeader); + } + // questionable? close sidebar on tap of an item, + // delay loading of page until animation is done + // uncomment to close on tap + // uncomment the items commented with // to add a delay so it finishes animating + /* sidebar.querySelectorAll('.global-sidebar-entry-item').forEach(e => { + e.addEventListener('click', ev => { + //ev.preventDefault(); + sidebar.classList.remove('expanded'); + backdrop.classList.add('backdrop-hidden'); + // setTimeout(() => { + // location.href = e.href; + // }, 250); + }) + }) */ +} diff --git a/client/src/pages/components/shell.scss b/client/src/pages/components/shell.scss new file mode 100644 index 0000000..c6bd3e3 --- /dev/null +++ b/client/src/pages/components/shell.scss @@ -0,0 +1,244 @@ +@use "../../css/config/variables" as *; + +.global-footer { + padding: 5px 20px; + margin: 0; + font-weight: normal; + font-size: 14px; + text-align: center; + + .footer { + list-style-type: none; + font-size: 14px; + text-align: center; + padding: 0; + + & li { + color: var(--colour0-primary); + display: inline; + margin: 0 0.5em 0 0; + + & a { + --local-colour1-primary: var(--colour0-primary); + --local-colour1-secondary: var(--colour0-primary); + --local-colour2-primary: var(--colour1-tertiary); + --local-colour2-secondary: var(--colour1-secondary); + } + } + } +} + +// TODO: split logic properly +.global-header { + // border-bottom: $size-thin solid var(--colour0-secondary); + + & > * { + max-width: 1280px; + margin: 0 auto; + } + + .header { + list-style-type: none; + font-weight: 700; + font-size: 14px; + text-align: center; + padding: 0; + + & a { + --local-colour1-primary: var(--colour0-primary); + --local-colour1-secondary: var(--colour0-primary); + --local-colour2-primary: var(--colour1-tertiary); + --local-colour2-secondary: var(--colour1-secondary); + + &.aux-link { + font-weight: bold; + + &--support { + --local-colour1-primary: hsl(3, 100%, 70%); + --local-colour1-secondary: hsl(3, 70%, 65%); + --local-colour2-primary: hsl(3, 100%, 20%); + --local-colour2-secondary: hsl(3, 70%, 20%); + } + + &--board { + --local-colour1-primary: hsl(224, 27%, 49%); + --local-colour1-secondary: hsl(224, 27%, 49%); + --local-colour2-primary: hsl(224, 27%, 20%); + --local-colour2-secondary: hsl(224, 15%, 20%); + } + + &--telegram { + --local-colour1-primary: hsl(200, 100%, 50%); + --local-colour1-secondary: hsl(200, 70%, 50%); + --local-colour2-primary: hsl(210, 100%, 20%); + --local-colour2-secondary: hsl(210, 70%, 20%); + } + + &--tpd { + --local-colour1-primary: hsl(35, 100%, 50%); + --local-colour1-secondary: hsl(35, 100%, 50%); + --local-colour2-primary: hsl(35, 100%, 20%); + --local-colour2-secondary: hsl(35, 70%, 20%); + } + } + } + & li { + color: var(--colour0-primary); + display: inline; + margin: 0 0.5em 0 0; + + & a { + --local-colour1-primary: var(--colour0-primary); + --local-colour1-secondary: var(--colour0-primary); + --local-colour2-primary: var(--colour1-tertiary); + --local-colour2-secondary: var(--colour1-secondary); + } + } + } + + .subheader { + padding: 5px 20px; + font-size: 14px; + text-align: center; + + &:empty { + display: none; + } + + & li { + color: var(--colour0-primary); + display: inline; + list-style-type: none; + margin: 0 0.5em 0 0; + + & a { + --local-colour1-primary: var(--colour0-primary); + --local-colour1-secondary: var(--colour0-primary); + --local-colour2-primary: var(--colour1-tertiary); + --local-colour2-secondary: var(--colour1-secondary); + } + } + } +} + +.current-page { + background-image: linear-gradient(hsl(216, 4%, 28%), hsl(220, 7%, 17%)); + padding: 6px 10px 6px; + font-weight: 700; +} + +// .global-overlay {} + +.content-wrapper { + width: 100%; + transition: margin-left 250ms ease-in-out 0s; + + @media (max-width: $sidebar-min-width) { + position: fixed; + + &.shifted { + position: static; + } + } + + @media (min-width: #{$sidebar-min-width + 1}) { + &.shifted { + margin-left: 12rem; + } + } + + & > .main, + & > .global-footer { + padding: 1em; + + @media (max-width: $sidebar-min-width) { + padding: 1em 0; + } + } + + .header { + width: 100%; + height: 2.5rem; + background: hsl(220deg 7% 17%); + display: flex; + align-items: center; + padding: 0 10px 0 5px; + margin-top: calc(-1.2rem - 20px); + transition: margin-top 250ms ease-in-out 0s; + overflow-x: hidden; + + @media (max-width: $sidebar-min-width) { + margin-top: 0; + } + + @media (min-width: #{$sidebar-min-width + 1}) { + &.sidebar-retracted { + margin-top: 0; + } + } + + #burgor { + cursor: pointer; + height: 100%; + padding: 5px; + min-width: 48px; + + & > img { + height: 100%; + } + } + + &-link { + height: 100%; + display: flex; + align-items: center; + padding: 5px; + + &:first-of-type { + margin-left: 10px; + } + + @media (max-width: 412px) { + &.import { + display: none; + } + } + + @media (max-width: 358px) { + &.home { + display: none; + } + } + + @media (max-width: 294px) { + &.login, + &.logout { + display: none; + } + } + } + } + + .backdrop { + position: absolute; + background: rgb(0 0 0 / 40%); + width: 100%; + height: 100%; + opacity: 1; + transition: opacity 250ms ease-in-out 0s; + z-index: 2; + + &.backdrop-hidden { + opacity: 0; + pointer-events: none; + } + + @media (min-width: #{$sidebar-min-width + 1}) { + display: none; + } + } +} + +.transition-preload * { + transition: none !important; +} diff --git a/client/src/pages/components/site.html b/client/src/pages/components/site.html new file mode 100644 index 0000000..6811eb8 --- /dev/null +++ b/client/src/pages/components/site.html @@ -0,0 +1,27 @@ +{# call-only #} +{% macro section(name, title=none, class_name=none) %} +
    + {% if title %} + {% call header() %} + {{ heading(title) }} + {% endcall %} + {% endif %} + {{ caller() }} +
    +{% endmacro %} + +{% macro header(class_name=none) %} +
    + {{ caller() }} +
    +{% endmacro %} + +{% macro heading(title, class_name=none) %} +

    + {% if not caller %} + {{ title }} + {% else %} + {{ caller() }} + {% endif %} +

    +{% endmacro %} diff --git a/client/src/pages/components/site.scss b/client/src/pages/components/site.scss new file mode 100644 index 0000000..cebc4a9 --- /dev/null +++ b/client/src/pages/components/site.scss @@ -0,0 +1,23 @@ +@use "../../css/config/variables" as *; + +.site-section { + margin: 0 auto; + + &__header { + padding: 0 0 $size-little; + } + + &__heading { + font-weight: 300; + text-align: center; + margin: 0; + } + + &__subheading { + margin: 0.5em 0; + } + + &__register-cta { + text-align: center; + } +} diff --git a/client/src/pages/components/site_section.html b/client/src/pages/components/site_section.html new file mode 100644 index 0000000..de100d0 --- /dev/null +++ b/client/src/pages/components/site_section.html @@ -0,0 +1,13 @@ +{% macro site_section(name) %} +
    + {{ caller() }} +
    +{% endmacro %} + +{% macro site_section_header(heading) %} +
    +

    + {{ heading }} +

    +
    +{% endmacro %} diff --git a/client/src/pages/components/support_sidebar.html b/client/src/pages/components/support_sidebar.html new file mode 100644 index 0000000..701ac56 --- /dev/null +++ b/client/src/pages/components/support_sidebar.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/client/src/pages/components/tabs.html b/client/src/pages/components/tabs.html new file mode 100644 index 0000000..ab51ab9 --- /dev/null +++ b/client/src/pages/components/tabs.html @@ -0,0 +1,68 @@ + diff --git a/client/src/pages/components/timestamp.html b/client/src/pages/components/timestamp.html new file mode 100644 index 0000000..79457be --- /dev/null +++ b/client/src/pages/components/timestamp.html @@ -0,0 +1,14 @@ +{% macro timestamp(time, is_relative=false, class_name=none) %} + {# `datetime` value should be an ISO string #} + +{% endmacro %} diff --git a/client/src/pages/components/timestamp.js b/client/src/pages/components/timestamp.js new file mode 100644 index 0000000..85f6825 --- /dev/null +++ b/client/src/pages/components/timestamp.js @@ -0,0 +1,39 @@ +import { createComponent } from "@wp/js/component-factory"; + +/** + * TODO: make it work with `Date` objects. + * @param {HTMLTimeElement} element + * @param {string} date + * @param {string} className + */ +export function Timestamp(element, date, isRelative = false, className = null) { + const timestamp = element ? element : initFromScratch(date, isRelative, className); + + return timestamp; +} + +/** + * @param {string} date + * @param {boolean} isRelative + * @param {string} className + */ +function initFromScratch(date, isRelative, className) { + /** + * @type {HTMLTimeElement} + */ + const timestamp = createComponent("timestamp"); + + timestamp.dateTime = date; + + if (className) { + timestamp.classList.add(className); + } + + if (isRelative) { + timestamp.textContent = date; + } else { + timestamp.textContent = date; + } + + return timestamp; +} diff --git a/client/src/pages/components/timestamp.scss b/client/src/pages/components/timestamp.scss new file mode 100644 index 0000000..dd77823 --- /dev/null +++ b/client/src/pages/components/timestamp.scss @@ -0,0 +1,3 @@ +.timestamp { + color: var(--colour0-secondary); +} diff --git a/client/src/pages/components/tooltip.html b/client/src/pages/components/tooltip.html new file mode 100644 index 0000000..9bcc918 --- /dev/null +++ b/client/src/pages/components/tooltip.html @@ -0,0 +1,22 @@ +{% from 'components/buttons.html' import button %} +{% from 'components/links.html' import kemono_link %} + +{% macro tooltip() %} +
    + {{ button('Close', 'tooltip__close') }} + {{ caller() }} +
    +{% endmacro %} + +{% macro register_message(action_name) %} +

    + {{ action_name }} is only available to registered users. +
    + Visit the {{ kemono_link('/account/login?location=' + request.path, 'login page', is_noop=false) }} if you have an account. +
    + Otherwise visit the {{ kemono_link('/account/register?location=' + request.path, 'registration page', is_noop=false) }} to create one. +

    +{% endmacro %} diff --git a/client/src/pages/components/tooltip.js b/client/src/pages/components/tooltip.js new file mode 100644 index 0000000..dd2eec2 --- /dev/null +++ b/client/src/pages/components/tooltip.js @@ -0,0 +1,65 @@ +import { createComponent } from "@wp/js/component-factory"; + +/** + * @type {HTMLDivElement} + */ +const tooltip = document.getElementById("flying-tooltip"); +/** + * @type {[HTMLButtonElement, HTMLSpanElement]} + */ +const [closeButton, messageContainer] = tooltip.children; + +closeButton.addEventListener("click", (event) => { + tooltip.classList.remove("tooltip--shown"); +}); + +/** + * @param {HTMLElement} element + * @param {HTMLParagraphElement} messageElement + */ +export function showTooltip(element, messageElement) { + const { left, bottom } = element.getBoundingClientRect(); + + tooltip.classList.remove("tooltip--shown"); + messageContainer.replaceWith(messageElement); + tooltip.style.setProperty("--local-x", `${left}px`); + tooltip.style.setProperty("--local-y", `${bottom}px`); + tooltip.classList.add("tooltip--shown"); +} + +/** + * TODO: init from `action_name` + * @param {HTMLElement} element + * @param {string} actionName + */ +export function registerMessage(element, actionName = "") { + /** + * @type {HTMLParagraphElement} + */ + const messageElement = element ? element : initFromScratch(actionName); + + return messageElement; +} + +/** + * @param {HTMLElement} element + */ +function initFromElement(element) {} + +/** + * @param {string} actionName + */ +function initFromScratch(actionName) { + /** + * @type {HTMLParagraphElement} + */ + const message = createComponent("tooltip__message tooltip__message--register"); + /** + * @type {HTMLSpanElement} + */ + const action = message.querySelector(".tooltip__action"); + + action.textContent = actionName; + + return message; +} diff --git a/client/src/pages/components/tooltip.scss b/client/src/pages/components/tooltip.scss new file mode 100644 index 0000000..4577de3 --- /dev/null +++ b/client/src/pages/components/tooltip.scss @@ -0,0 +1,34 @@ +@use "../../css/config/variables" as *; + +.tooltip { + --local-x: 0; + --local-y: 0; + + position: absolute; + left: var(--local-x); + top: var(--local-y); + z-index: 1; + max-width: $width-feature; + color: var(--colour0-primary); + background-color: var(--colour1-tertiary); + border-radius: 10px; + border: $size-thin solid var(--negative-colour1-primary); + padding: $size-small; + visibility: hidden; + opacity: 0; + transition-duration: var(--duration-global); + transition-property: opacity, visibility; + + &--shown { + visibility: visible; + opacity: 1; + } + + &__close { + float: right; + margin-left: $size-small; + } + &__message { + line-height: 1.5; + } +} diff --git a/client/src/pages/development/_index.scss b/client/src/pages/development/_index.scss new file mode 100644 index 0000000..a3235dd --- /dev/null +++ b/client/src/pages/development/_index.scss @@ -0,0 +1,2 @@ +@use "components"; +@use "design"; diff --git a/client/src/pages/development/closure.html b/client/src/pages/development/closure.html new file mode 100644 index 0000000..cf7b73e --- /dev/null +++ b/client/src/pages/development/closure.html @@ -0,0 +1,26 @@ +{# taken from https://gist.github.com/dah33/e18e71a81d1a0aaf59658269ada963b3 #} + +{% macro enclose(fn, env) %} + {% set closure = namespace(fn=fn, env=env) %} + {% do return(closure) %} +{% endmacro %} + +{% macro call1(closure, x1) %} + {% do return(closure.fn(x1, closure.env)) %} +{% endmacro %} + +{% macro call2(closure, x1, x2) %} + {% do return(closure.fn(x1, x2, closure.env)) %} +{% endmacro %} + +{# Example: #} + +{% macro power(x, kwargs) %} + {% do return(x**kwargs.exponent) %} +{% endmacro %} + +{# +{% set square = enclose(power, dict(exponent=2)) %} + +{{ call1(square, 8) }}{# = 8**2 = 64 #} +#} diff --git a/client/src/pages/development/components/_index.scss b/client/src/pages/development/components/_index.scss new file mode 100644 index 0000000..af1b252 --- /dev/null +++ b/client/src/pages/development/components/_index.scss @@ -0,0 +1,2 @@ +@use "forms"; +@use "inputs"; diff --git a/client/src/pages/development/components/forms.html b/client/src/pages/development/components/forms.html new file mode 100644 index 0000000..bd34b3f --- /dev/null +++ b/client/src/pages/development/components/forms.html @@ -0,0 +1,17 @@ +{% from 'components/meta/attributes.html' import attributes %} + +{% macro form() %} +
    {{ caller() if caller }}
    +{% endmacro %} + +{% macro section() %} +
    {{ caller() if caller }}
    +{% endmacro %} + +{% macro label(text=none) %} + +{% endmacro %} + +{% macro input() %} + +{% endmacro %} diff --git a/client/src/pages/development/components/forms.scss b/client/src/pages/development/components/forms.scss new file mode 100644 index 0000000..501af59 --- /dev/null +++ b/client/src/pages/development/components/forms.scss @@ -0,0 +1,31 @@ +@use "../../../css/config/variables" as *; + +.dev-form { + display: grid; + grid-template-columns: 1fr; + grid-auto-rows: auto; + gap: $size-normal; + max-width: $width-mobile; + margin: 0 auto; + + &__section { + border: none; + padding: 0; + margin: 0; + + &--submit { + text-align: center; + } + } + + &__label { + display: inline-block; + } + + &__input { + min-width: 44px; + min-height: 44px; + width: 100%; + padding: $size-small; + } +} diff --git a/client/src/pages/development/components/inputs.html b/client/src/pages/development/components/inputs.html new file mode 100644 index 0000000..9935918 --- /dev/null +++ b/client/src/pages/development/components/inputs.html @@ -0,0 +1,25 @@ +{% from 'components/meta/attributes.html' import attributes %} + +{% import 'development/components/forms.html' as forms %} + +{% macro text(id, text) %} + {% call forms.section() %} + {{ forms.label(text, for=id) }} + {{ forms.input(id=id, + type="text", + **kwargs + ) }} + {% endcall %} +{% endmacro %} + +{% macro submit_button(text=none) %} + {% call forms.section(class='dev-form__section--submit') %} + + {% endcall %} +{% endmacro %} diff --git a/client/src/pages/development/components/inputs.scss b/client/src/pages/development/components/inputs.scss new file mode 100644 index 0000000..24d2370 --- /dev/null +++ b/client/src/pages/development/components/inputs.scss @@ -0,0 +1,11 @@ +@use "../../../css/config/variables" as *; + +.dev-form__input { +} + +.dev-form__submit { + min-width: 44px; + min-height: 44px; + width: auto; + padding: $size-small; +} diff --git a/client/src/pages/development/components/nav.html b/client/src/pages/development/components/nav.html new file mode 100644 index 0000000..524c5cd --- /dev/null +++ b/client/src/pages/development/components/nav.html @@ -0,0 +1,15 @@ +{% from 'components/navigation/base.html' import navigation, nav_list, nav_item %} +{% from 'components/links.html' import kemono_link %} + +{# `nav_items` is a list of tuples `(url, title)` #} +{% macro dev_nav(nav_items, id=none) %} + {% call navigation(id) %} + {% call nav_list() %} + {% for url, title in nav_items %} + {% call nav_item() %} + {{ kemono_link(url, title, is_noop= false) }} + {% endcall %} + {% endfor %} + {% endcall %} + {% endcall %} +{% endmacro %} diff --git a/client/src/pages/development/config.html b/client/src/pages/development/config.html new file mode 100644 index 0000000..803abe3 --- /dev/null +++ b/client/src/pages/development/config.html @@ -0,0 +1,45 @@ +{% extends 'development/shell.html' %} + +{% import 'components/site.html' as site %} + +{% block content %} +{% call site.section('test-entries', 'Test Database') %} +
    +
    +

    Press "Activate" to:

    +
      +
    • Add test service keys
    • +
    +
    +
    + +
    +
    + +
    +
    +

    Press "Activate" to:

    +
      +
    • Add test accounts
    • +
    +
    +
    + +
    +
    +{% endcall %} +{% endblock content %} diff --git a/client/src/pages/development/design/_index.scss b/client/src/pages/development/design/_index.scss new file mode 100644 index 0000000..a232bd3 --- /dev/null +++ b/client/src/pages/development/design/_index.scss @@ -0,0 +1 @@ +@use "wip"; diff --git a/client/src/pages/development/design/current/home.html b/client/src/pages/development/design/current/home.html new file mode 100644 index 0000000..b24447b --- /dev/null +++ b/client/src/pages/development/design/current/home.html @@ -0,0 +1,14 @@ +{% extends 'development/shell.html' %} + +{% import 'components/site.html' as site %} +{% from 'development/components/nav.html' import dev_nav %} + +{% set nav_items = [ + ('/development/design', 'Home'), +] %} + +{% block content %} +{% call site.section('design-current', 'Current') %} + {{ dev_nav(nav_items) }} +{% endcall %} +{% endblock content %} diff --git a/client/src/pages/development/design/home.html b/client/src/pages/development/design/home.html new file mode 100644 index 0000000..774c544 --- /dev/null +++ b/client/src/pages/development/design/home.html @@ -0,0 +1,16 @@ +{% extends 'development/shell.html' %} + +{% import 'components/site.html' as site %} +{% from 'development/components/nav.html' import dev_nav %} + +{% set nav_items = [ + ('/development/design/current', 'Current'), + ('/development/design/upcoming', 'Upcoming'), + ('/development/design/wip', 'Work In Progress'), +] %} + +{% block content %} +{% call site.section('design', 'Design Overview') %} + {{ dev_nav(nav_items) }} +{% endcall %} +{% endblock content %} diff --git a/client/src/pages/development/design/upcoming/home.html b/client/src/pages/development/design/upcoming/home.html new file mode 100644 index 0000000..f71614c --- /dev/null +++ b/client/src/pages/development/design/upcoming/home.html @@ -0,0 +1,14 @@ +{% extends 'development/shell.html' %} + +{% import 'components/site.html' as site %} +{% from 'development/components/nav.html' import dev_nav %} + +{% set nav_items = [ + ('/development/design', 'Home'), +] %} + +{% block content %} +{% call site.section('design-upcoming', 'Upcoming') %} + {{ dev_nav(nav_items) }} +{% endcall %} +{% endblock content %} diff --git a/client/src/pages/development/design/wip/_index.scss b/client/src/pages/development/design/wip/_index.scss new file mode 100644 index 0000000..495fed2 --- /dev/null +++ b/client/src/pages/development/design/wip/_index.scss @@ -0,0 +1 @@ +@use "home"; diff --git a/client/src/pages/development/design/wip/home.html b/client/src/pages/development/design/wip/home.html new file mode 100644 index 0000000..fbd9e59 --- /dev/null +++ b/client/src/pages/development/design/wip/home.html @@ -0,0 +1,34 @@ +{% extends 'development/shell.html' %} + +{% import 'components/site.html' as site %} +{% import 'development/components/forms.html' as forms %} +{% import 'development/components/inputs.html' as inputs %} +{% from 'components/navigation/local.html' import local_nav %} +{% from 'development/components/nav.html' import dev_nav %} + +{% set page_title = 'Work In Progress Designs | ' ~ g.site_name %} + +{% block title %} + + {{ page_title }} + +{% endblock title %} + +{% block content %} +{% call site.section('development-design-wip', 'Work In Progress Designs') %} + {{ dev_nav([ + ('/development/design', 'Home'), + ]) }} + {{ local_nav([ + ('forms', 'Forms') + ]) }} + {% call site.article(id='forms') %} +

    Forms

    + {% call forms.form() %} +

    Form example

    + {{ inputs.text('form-text', 'Text Input:') }} + {{ inputs.submit_button('Submit Button') }} + {% endcall %} + {% endcall %} +{% endcall %} +{% endblock content %} diff --git a/client/src/pages/development/design/wip/home.scss b/client/src/pages/development/design/wip/home.scss new file mode 100644 index 0000000..5f8e844 --- /dev/null +++ b/client/src/pages/development/design/wip/home.scss @@ -0,0 +1,2 @@ +.site-section--development-design-wip { +} diff --git a/client/src/pages/development/home.html b/client/src/pages/development/home.html new file mode 100644 index 0000000..15de9fd --- /dev/null +++ b/client/src/pages/development/home.html @@ -0,0 +1,52 @@ +{% extends 'development/shell.html' %} + +{% import 'components/site.html' as site %} +{% from 'development/components/nav.html' import dev_nav %} + +{# ('/development/test-entries', 'Test entries'), #} +{% set nav_items = [ + ('/development/design', 'Design'), +] %} + +{% block content %} +{% call site.section('dev-only', g.site_name ~' dev') %} + {{ dev_nav(nav_items) }} +
    +
    +

    + Press "Activate" to create a seeded database. +

    +
    +
    + +
    +
    + +
    +
    +

    + Press "Activate" to create a random database. +

    +
    +
    + +
    +
    +{% endcall %} +{% endblock content %} diff --git a/client/src/pages/development/shell.html b/client/src/pages/development/shell.html new file mode 100644 index 0000000..b00fc94 --- /dev/null +++ b/client/src/pages/development/shell.html @@ -0,0 +1,18 @@ +{% extends 'components/shell.html' %} + + {% block bundler_output %} + {# quick hack until writing proper loader #} + <% for (const css in htmlWebpackPlugin.files.css) { %> + <% if (htmlWebpackPlugin.files.css[css].startsWith("/static/bundle/css/development")) { %> + + <% } %> + <% } %> + <% for (const chunk in htmlWebpackPlugin.files.chunks) { %> + + <% } %> + <% for (const scriptPath in htmlWebpackPlugin.files.js) { %> + <% if (htmlWebpackPlugin.files.js[scriptPath].startsWith("/static/bundle/js/development") | htmlWebpackPlugin.files.js[scriptPath].startsWith("/static/bundle/js/runtime")) { %> + + <% } %> + <% } %> + {% endblock bundler_output %} diff --git a/client/src/pages/development/test_entries.html b/client/src/pages/development/test_entries.html new file mode 100644 index 0000000..73910b5 --- /dev/null +++ b/client/src/pages/development/test_entries.html @@ -0,0 +1,51 @@ +{% extends 'development/shell.html' %} + +{% import 'components/site.html' as site %} +{% from 'development/components/nav.html' import dev_nav %} + +{% set nav_items = [ + ('/development', 'Home') +] %} + +{% block content %} +{% call site.section('test-entries', 'Test entries') %} + {{ dev_nav(nav_items) }} +
    +
    +

    + Press "Activate" to create a seeded database. +

    +
    +
    + +
    +
    + +
    +
    +

    + Press "Activate" to create a random database. +

    +
    +
    + +
    +
    +{% endcall %} +{% endblock content %} diff --git a/client/src/pages/discord.html b/client/src/pages/discord.html new file mode 100644 index 0000000..328a52e --- /dev/null +++ b/client/src/pages/discord.html @@ -0,0 +1,27 @@ + + + + + + {{ g.site_name }} + + + + + + + +
    +
    +
    + +
    +
    + + + + \ No newline at end of file diff --git a/client/src/pages/error.html b/client/src/pages/error.html new file mode 100644 index 0000000..d6a82e7 --- /dev/null +++ b/client/src/pages/error.html @@ -0,0 +1,9 @@ +{% extends 'components/shell.html' %} +{% block content %} +

    Error

    +

    {{ props.get('message') }}

    + {% if props.get('redirect') %} + +

    Redirecting you back...

    + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/client/src/pages/favorites.html b/client/src/pages/favorites.html new file mode 100644 index 0000000..f76a296 --- /dev/null +++ b/client/src/pages/favorites.html @@ -0,0 +1,111 @@ +{% extends 'components/shell.html' %} + +{% from 'components/card_list.html' import card_list %} +{% from 'components/cards/user.html' import user_card, user_card_header %} +{% from 'components/cards/post.html' import post_card %} +{% from 'components/ads.html' import slider_ad, header_ad, footer_ad %} + +{% block scripts_extra %} + +{% endblock scripts_extra %} + +{% block content %} +{{ slider_ad() }} +
    + {{ header_ad() }} +
    +

    Favorite {{ g.artists_or_creators if props.fave_type == 'artist' else 'Posts' }}

    +
    + + {% if source == 'session' %} +
    + + + + + + + + + + + + + + +
    NameService
    This feature requires Javascript.
    + +
    + {% else %} + + {% if props.fave_type == "artist" %} +
    + {% include 'components/paginator.html' %} +
    + + {% call card_list('phone') %} + {% for user in results %} + {{ user_card(user, is_updated=true) }} + {% else %} +

    Nobody here but us chickens!

    +

    + There are no {{ g.artists_or_creators|lower }}. +

    + {% endfor %} + {% endcall %} + +
    + {% include 'components/paginator.html' %} +
    + {% else %} +
    + {% include 'components/paginator.html' %} +
    + + {% call card_list() %} + {% for post in results %} + {{ post_card(post) }} + {% else %} +

    Nobody here but us chickens!

    +

    + There are no more posts. +

    + {% endfor %} + {% endcall %} + +
    + {% include 'components/paginator.html' %} +
    + {% endif %} + {% endif %} + {{ footer_ad() }} +
    +{% endblock content %} diff --git a/client/src/pages/favorites.scss b/client/src/pages/favorites.scss new file mode 100644 index 0000000..8b279b6 --- /dev/null +++ b/client/src/pages/favorites.scss @@ -0,0 +1,16 @@ +.site-section--favorites { + div.dropdowns { + display: grid; + grid-template-columns: max-content max-content; + grid-gap: 5px; + justify-content: center; + } + + div.dropdowns > label { + text-align: right; + } + + div.dropdowns > label:after { + content: ":"; + } +} diff --git a/client/src/pages/help/faq.html b/client/src/pages/help/faq.html new file mode 100644 index 0000000..c7788c1 --- /dev/null +++ b/client/src/pages/help/faq.html @@ -0,0 +1,48 @@ +{% extends 'components/shell.html' %} + +{% import 'components/site.html' as site %} +{% from 'components/navigation/local.html' import local_nav, local_list, local_item %} +{% from 'components/lists/faq.html' import faq_list, faq_section, faq_question, faq_answer %} + +{% set page_title = 'Frequently Asked Questions | ' ~ g.site_name %} + +{% block title %} + + {{ page_title }} + +{% endblock title %} + +{% block content %} +{% call site.section('help-faq', 'Frequently Asked Questions') %} + {% call local_nav(id="faq-nav") %} +

    Table of contents

    + {% call local_list() %} + {{ local_item("id1", "question1") }} + {{ local_item("id2", "question2") }} + {% endcall %} + {% endcall %} + +

    FAQ

    + {% call faq_list() %} + {% call faq_section(id="id1") %} + {% call faq_question() %} + question1 + {% endcall %} + + {% call faq_answer() %} + answer1 + {% endcall %} + {% endcall %} + + {% call faq_section(id="id2") %} + {% call faq_question() %} + question2 + {% endcall %} + + {% call faq_answer() %} + answer2 + {% endcall %} + {% endcall %} + {% endcall %} +{% endcall %} +{% endblock content %} diff --git a/client/src/pages/help/license.html b/client/src/pages/help/license.html new file mode 100644 index 0000000..dff0ee5 --- /dev/null +++ b/client/src/pages/help/license.html @@ -0,0 +1,26 @@ +{% extends 'components/shell.html' %} + +{% block content %} +
    +
    +

    Open Source

    +

    + This website is running Kemono 2, which is provided for free under the BSD-3 License.
    +

    +          
    +Copyright 2020 kemono.party
    +
    +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
    +
    +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
    +
    +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
    +
    +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
    +
    +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE
    +          
    +      
    +
    +
    +{% endblock %} diff --git a/client/src/pages/help/posts.html b/client/src/pages/help/posts.html new file mode 100644 index 0000000..87a825d --- /dev/null +++ b/client/src/pages/help/posts.html @@ -0,0 +1,25 @@ +{% extends 'components/shell.html' %} + +{% block content %} +
    +
    +

    Posts

    +

    + A green border means the post is the parent of one or more "child" images.
    + A yellow border means the image has a parent.
    + An orange border means the post is user-shared.
    +
    + Multiple edits of the same post can appear on one page. +

    +

    Searching Posts

    +

    + Searching for posts is straightforward. Enter the terms you want to search for, and both titles and descriptions will be scanned for your query. For example, searching for mio yuuko will return every post that has both mio and yuuko in it. You can also exclude a term by putting a hyphen (-) in front of it, and search for a phrase by putting quotation marks around it. They work about how you would expect.
    + Please note that {{ g.site_name }} has limited support for non-English search terms due to database limitations. Most notably, Japanese characters cannot be searched. +

    +

    Flagging

    +

    + If there's something wrong with a post (like damaged/corrupted files) you can click Flag for reimport to have it purged and redownloaded the next time the importer encounters its ID. After that, simply import as usual. +

    +
    +
    +{% endblock %} diff --git a/client/src/pages/home.html b/client/src/pages/home.html new file mode 100644 index 0000000..396510c --- /dev/null +++ b/client/src/pages/home.html @@ -0,0 +1,58 @@ +{% extends 'components/shell.html' %} + +{% import 'components/site.html' as site %} +{% from "components/links.html" import kemono_link %} + +{% block content %} + {% call site.section("home") %} + {% if g.banner_welcome %} + {{ g.banner_welcome|safe }} + {% endif %} +
    +
    + {% if g.mascot_path %} +
    +
    + +
    +
    + {% endif %} +
    + {% if g.logo_path %} +
    + +
    + {% endif %} +

    + {{ g.site_name }} is a public archiver for: +

    +
      + {% for paysite in g.paysite_list %} +
    • + {{ g.paysites[paysite].title }} +
    • + {% endfor %} +
    +

    + Contributors here upload content and share it here for easy searching and organization. To get started viewing content, either search for creators on the {{ kemono_link("/artists", g.artists_or_creators|lower ~ " page")}}, or search for content on the {{ kemono_link("/posts", "posts page") }}. If you want to contribute content, head over to the {{ kemono_link("/importer", "import page") }}. +

    + {% if g.welcome_credits %} +
    + {{ g.welcome_credits|safe }} +
    + {% endif %} +
    +
    + {% for announcement in g.announcements %} +
    +
    +

    {{ announcement.title }}

    +
    {{ announcement.date }}
    +
    +

    + {{ announcement.content|safe }} +

    +
    + {% endfor %} + {% endcall %} +{% endblock %} diff --git a/client/src/pages/home.scss b/client/src/pages/home.scss new file mode 100644 index 0000000..448eb1c --- /dev/null +++ b/client/src/pages/home.scss @@ -0,0 +1,90 @@ +@use "../css/config/variables" as *; + +.site-section--home { +} +// minheights + +.jumbo-welcome { + overflow-y: hidden; + position: relative; + display: flex; + flex-direction: row; + box-shadow: 0 1px 3px rgb(0 0 0 / 25%); + align-items: center; + justify-content: flex-end; + min-height: 450px; + background-color: rgba(0, 0, 0, 0.7); + margin: 0.5rem; + @media (max-width: $width-tablet) { + background-color: #3b3e44; + } +} + +.jumbo-welcome-mascot { + transform: translateZ(0); + display: flex; + max-height: 450px; + width: 100%; + height: 100%; + @media (max-width: $width-tablet) { + display: none; + } +} + +.jumbo-welcome-description { + flex-direction: column; + width: 100%; + height: 100%; + padding: 0.5em 2em; +} + +.jumbo-welcome-description-header { + text-align: center; +} + +.jumbo-welcome-credits { + overflow: hidden; + display: block; + float: right; + text-align: right; +} + +.jumbo-welcome-background { + z-index: -1; + background-position: center; + position: absolute; + left: 0; + top: 0; + bottom: 0; + height: 100%; + width: 100%; + background-size: cover; +} + +.date { + //position: absolute; /* Add this line */ + //top: 10px; /* Adjust the top position as needed */ + //right: 10px; /* Adjust the right position as needed */ + color: #d5d5d5; +} + +.announcement-title-container { + display: flex; + align-items: baseline; + gap: 15px; +} +.announcement-text { + color: #929292; +} + +.jumbo-announcement { + padding: 10px; + margin: 0.5rem; + border-radius: 0.25rem; + background: hsl(220, 7%, 25%); + + & p, + & h2 { + margin: 0; + } +} diff --git a/client/src/pages/importer_list.html b/client/src/pages/importer_list.html new file mode 100644 index 0000000..d934496 --- /dev/null +++ b/client/src/pages/importer_list.html @@ -0,0 +1,269 @@ +{% extends 'components/shell.html' %} + +{% from 'components/links.html' import email_link %} +{% from 'components/tooltip.html' import register_message %} + +{% block title %} + Import paywall posts/comments/DMs to {{ g.site_name }}. +{% endblock title %} + +{% block content %} +
    +

    Import from paysite

    + {% include "components/importer_states.html" %} +
    +
    + + +
    + +
    + + + + + Learn how to get your session key. + + + + + +
    +
    +
    + + Your user ID. Can be found in Cookies -> auth_id. +
    +
    + + + + BC token. Can be found in Local Storage -> bcTokenSha, or the headers of an XHR request -> x-bc.
    + Paste this on the console localStorage.bcTokenSha +
    + +
    + +
    + + + + This needs to be set to the User-Agent + of the last device that logged into your OnlyFans account; leave it as the default value if you are on it + right now. +
    + +
    + +
    + + + + comma separated, no spaces + +
    + + + + + + + + + +
    + +
    + +
    +

    Important information

    +

    + Your session key is used to scrape paid posts from your feed. After downloading missing posts, the key is + immediately discarded and never stored without permission. +

    + {% if "fantia" in g.paysite_list %} +

    Fantia

    +
      +
    • At least one paid content must be unlocked for the post to be imported. Free posts cannot be archived at + this time.
    • +
    • In order to download post contents accurately, the importer will automatically enable adult-viewing mode for + duration of the import if you have it turned off. Do not change back to general-viewing during imports. +
    • +
    + {% endif %} +

    Auto-import

    +

    + The auto-import feature allows users to give {{ g.site_name }} permission to automatically detect and retrieve new + posts and creators by storing session keys long-term, without need for manual key submission. All keys are + encrypted using a strong RSA 4096 key. When the administrators start a new autoimport round, a computer outside of + {{ g.site_name }}'s infrastucture sends the private key to the backend, allowing it to decrypt all working keys + and start import tasks. Even if {{ g.site_name }}'s private database were to somehow be compromised, your tokens + would remain anonymous and secure.
    + If you are logged into {{ g.site_name }}, any key you submit with autoimport enabled can be managed under the Keys + section of your [Account] tab in the header. There, you will be able to view import logs or revoke access. + Please note that anonymously-submitted keys cannot be managed. +

    +
    +{% endblock content %} + +{% block components %} + {{ register_message("DM import") }} +{% endblock components %} diff --git a/client/src/pages/importer_list.js b/client/src/pages/importer_list.js new file mode 100644 index 0000000..1d7f68f --- /dev/null +++ b/client/src/pages/importer_list.js @@ -0,0 +1,174 @@ +import { registerMessage, showTooltip } from "@wp/components"; + +/** + * @param {HTMLElement} section + */ +export function importerPage(section) { + const isLoggedIn = localStorage.getItem("logged_in") === "yes"; + /** + * @type {HTMLFormElement} + */ + const form = document.forms["import-list"]; + const currentService = form.querySelector("#service").value; + + /** + * @type {Record} + */ + const noteLookup = { + fansly: form.querySelector(".fansly__notes"), + onlyfans: form.querySelector(".onlyfans__notes"), + fanbox: form.querySelector(".fanbox__notes"), + candfans: form.querySelector(".candfans__notes"), + other: form.querySelector(".other__notes"), + }; + switchKeyNotesToggle(currentService, noteLookup); + form.addEventListener( + "change", + processChangeForService((service) => switchKeyNotesToggle(service, noteLookup)), + ); + + /** + * @type {Record} + */ + const sectionLookup = { + discord: form.querySelector("#discord-section"), + onlyfans: form.querySelector("#onlyfans-section"), + }; + displayOnlyActiveInputSectionsFieldsRequired(currentService, sectionLookup); + form.addEventListener( + "change", + processChangeForService((service) => displayOnlyActiveInputSectionsFieldsRequired(service, sectionLookup)), + ); + + /** + * @type {Record} + */ + const DMLookup = { + patreon: true, + fansly: true, + }; + ActivateDMSection(currentService, DMLookup, form.querySelector("#dm-consent")); + form.addEventListener( + "change", + processChangeForService((service) => ActivateDMSection(service, DMLookup, form.querySelector("#dm-consent"))), + ); + ActivateFanboxTestConsentSection(currentService, form.querySelector("#fanbox-test-consent")); + form.addEventListener( + "change", + processChangeForService((service) => ActivateFanboxTestConsentSection(service, form.querySelector("#fanbox-test-consent"))), + ); + + form.addEventListener("submit", handleSubmit(isLoggedIn)); + document.getElementById("user-agent").value = navigator.userAgent; +} + +/** + * @param {function} procesingFunction + * @returns {(event: Event) => void} + */ +function processChangeForService(procesingFunction) { + return (event) => { + if (event.target.id === "service") { + event.stopPropagation(); + /** + * @type {String} + */ + const selectValue = event.target.value; + procesingFunction(selectValue); + } + }; +} + +/** + * @param {String} selectService + * @param {Record} sectionLookup + * @returns {void} + */ +function displayOnlyActiveInputSectionsFieldsRequired(selectService, sectionLookup) { + let activeSection = sectionLookup[selectService]; + Object.values(sectionLookup).forEach((section) => section.classList.add("form__section--hidden")); + Object.values(sectionLookup).forEach((section) => + section.querySelectorAll("input").forEach((input) => (input.required = false)), + ); + if (activeSection) { + activeSection.classList.remove("form__section--hidden"); + activeSection.querySelectorAll("input").forEach((input) => (input.required = true)); + } +} + +/** + * @param {String} selectService + * @param {Record} noteLookup + * @returns {void} + */ +function switchKeyNotesToggle(selectService, noteLookup) { + Object.values(noteLookup).forEach((notes) => (notes.hidden = true)); + if (noteLookup[selectService]) { + noteLookup[selectService].hidden = false; + } else { + noteLookup["other"].hidden = false; + } +} + +/** + * @param {String} selectService + * @param {Record} DMLookup + * @param {HTMLElement} dmSection + * @returns {void} + */ +function ActivateDMSection(selectService, DMLookup, dmSection) { + let isActive = DMLookup[selectService]; + if (isActive) { + dmSection.classList.remove("form__section--hidden"); + dmSection.querySelector("input").checked = true; + } else { + dmSection.classList.add("form__section--hidden"); + dmSection.querySelector("input").checked = false; + } +} + +/** + * @param {String} selectService + * @param {HTMLElement} fanboxTestConsentSection + * @returns {void} + */ +function ActivateFanboxTestConsentSection(selectService, fanboxTestConsentSection) { + let isActive = selectService === "fanbox"; + if (isActive) { + fanboxTestConsentSection.classList.remove("form__section--hidden"); + fanboxTestConsentSection.querySelector("input").checked = false; + } else { + fanboxTestConsentSection.classList.add("form__section--hidden"); + fanboxTestConsentSection.querySelector("input").checked = false; + } +} + +/** + * @param {boolean} isLoggedIn + * @returns {(event: Event) => void} + */ +function handleSubmit(isLoggedIn) { + return (event) => { + /** + * @type {HTMLFormElement} + */ + const form = event.target; + /** + * @type {HTMLInputElement} + */ + const dmConsent = form.elements["save-dms"]; + const fanboxTestConsent = form.elements["fanbox-test-consent"]; + const service = form.elements["service"]; + + if (service.value === "patreon" && dmConsent.checked && !isLoggedIn) { + event.preventDefault(); + showTooltip(dmConsent, registerMessage(null)); + } + console.log(!fanboxTestConsent.checked); + console.log(service.value); + if (service.value === "fanbox" && !fanboxTestConsent.checked) { + event.preventDefault(); + showTooltip(fanboxTestConsent, registerMessage("You need to agree.")); + } + }; +} diff --git a/client/src/pages/importer_ok.html b/client/src/pages/importer_ok.html new file mode 100644 index 0000000..9453195 --- /dev/null +++ b/client/src/pages/importer_ok.html @@ -0,0 +1,11 @@ +{% extends 'components/shell.html' %} +{% block content %} +
    + {% include "components/importer_states.html" %} +

    Success

    +

    + Your session key has been submitted to the server. Posts will be added soon. Thank you for contributing!
    + If you're having trouble with the importer, contact admin. +

    +
    +{% endblock %} \ No newline at end of file diff --git a/client/src/pages/importer_status.html b/client/src/pages/importer_status.html new file mode 100644 index 0000000..91adb87 --- /dev/null +++ b/client/src/pages/importer_status.html @@ -0,0 +1,60 @@ +{% extends 'components/shell.html' %} + +{% from 'components/loading_icon.html' import loading_icon %} +{% from 'components/buttons.html' import button %} + +{% block title %} + Import {{ props.import_id }} +{% endblock title %} + +{% block meta %} + +{% endblock meta %} + +{% block content %} +
    + {% include "components/importer_states.html" %} +
    +

    Importer logs for {{ props.import_id }}

    +
    + {% if props.is_dms %} +
    + Hey! +

    + You gave the importer permission to access your messages. To protect your anonymity, you must manually approve each one. Wait until after the importer says Done importing DMs, then go here to choose which ones you wish to import. +

    +
    + {% endif %} +
    +
    +
    +
    + Status: + Fetching +
    +
    + Total: + +
    +
    +
    + {{ button('Reverse order', class_name='import__reverse') }} +
    +
    + +

    + {{ loading_icon() }} Wait until logs load... +

    +
      +
    +
    +
    +{% endblock content %} + +{% block components %} + {{ log_item() }} +{% endblock components %} + +{% macro log_item() %} +
  • +{% endmacro %} diff --git a/client/src/pages/importer_status.js b/client/src/pages/importer_status.js new file mode 100644 index 0000000..8fa917e --- /dev/null +++ b/client/src/pages/importer_status.js @@ -0,0 +1,164 @@ +import { kemonoAPI } from "@wp/api"; +import { createComponent } from "@wp/js/component-factory"; +import { waitAsync } from "@wp/utils"; +import { initPendingReviewDms } from "@wp/js/pending-review-dms"; + +/** + * @typedef Stats + * @property {string} importID + * @property {HTMLSpanElement} status + * @property {HTMLSpanElement} count + * @property {number} cooldown + * @property {number} retries + */ + +/** + * TODOs: + * - service heuristics + * - error handling + * @param {HTMLElement} section + */ +export async function importerStatusPage(section) { + /** + * @type {HTMLDivElement} + */ + const importInfo = section.querySelector(".import__info"); + /** + * @type {[HTMLDivElement, HTMLDivElement]} + */ + const [importStats, buttonPanel] = importInfo.children; + const [status, count] = importStats.children; + /** + * @type {Stats} + */ + const stats = { + importID: document.head.querySelector("meta[name='import_id']").content, + status: status.children[1], + count: count.children[1], + cooldown: 5000, + retries: 0, + }; + /** + * @type {HTMLParagraphElement} + */ + const loadingPlaceholder = section.querySelector(".loading-placeholder"); + /** + * @type {HTMLOListElement} + */ + const logList = section.querySelector(".log-list"); + + initButtons(buttonPanel, logList); + const logs = await kemonoAPI.api.logs(stats.importID); + + if (logs) { + populateLogList(logs, logList, loadingPlaceholder); + stats.status.textContent = "In Progress"; + stats.count.textContent = logs.length; + count.classList.remove("import__count--invisible"); + + initPendingReviewDms(true).then(() => {}) + await waitAsync(stats.cooldown); + await updateLogList(logs, logList, stats); + } else { + loadingPlaceholder.classList.add("loading-placeholder--complete"); + alert("Failed to fetch the logs, try reloading the page."); + } +} + +/** + * @param {HTMLDivElement} buttonPanel + * @param {HTMLOListElement} logList + */ +function initButtons(buttonPanel, logList) { + /** + * @type {HTMLButtonElement[]} + */ + const [reverseButton] = buttonPanel.children; + + reverseButton.addEventListener("click", reverseList(logList)); +} + +/** + * @param {HTMLOListElement} logList + * @returns {(event: MouseEvent) => void} + */ +function reverseList(logList) { + return (event) => { + logList.classList.toggle("log-list--reversed"); + }; +} + +/** + * @param {string[]} logs + * @param {HTMLOListElement} logList + * @param {HTMLParagraphElement} loadingItem + */ +function populateLogList(logs, logList, loadingItem) { + const fragment = document.createDocumentFragment(); + + logs.forEach((log) => { + const logItem = LogItem(log); + fragment.appendChild(logItem); + }); + + loadingItem.classList.add("loading-placeholder--complete"); + logList.appendChild(fragment); + logList.classList.add("log-list--loaded"); +} + +/** + * TODO: finishing condition. + * @param {string[]} logs + * @param {HTMLOListElement} logList + * @param {Stats} stats + */ +async function updateLogList(logs, logList, stats) { + let newLogs = await kemonoAPI.api.logs(stats.importID); + + if (!newLogs) { + if (stats.retries === 5) { + stats.status.textContent = "Fatal Error"; + return; + } + + await waitAsync(stats.cooldown); + stats.retries++; + return await updateLogList(logs, logList, stats); + } + + const diff = newLogs.length - logs.length; + + if (diff === 0) { + stats.cooldown = stats.cooldown * 2; + await waitAsync(stats.cooldown); + initPendingReviewDms(false, 1).then(() => {}) + return await updateLogList(logs, logList, stats); + } + + const diffLogs = newLogs.slice(newLogs.length - diff); + const fragment = document.createDocumentFragment(); + diffLogs.forEach((log) => { + const logItem = LogItem(log); + fragment.appendChild(logItem); + }); + logs.push(...diffLogs); + logList.appendChild(fragment); + stats.count.textContent = logs.length; + + await waitAsync(stats.cooldown); + return await updateLogList(logs, logList, stats); +} + +/** + * @param {string} message + */ +function LogItem(message) { + /** + * @type {HTMLLIElement} + */ + const item = createComponent("log-list__item"); + + item.textContent = message; + + return item; +} diff --git a/client/src/pages/importer_status.scss b/client/src/pages/importer_status.scss new file mode 100644 index 0000000..e016f62 --- /dev/null +++ b/client/src/pages/importer_status.scss @@ -0,0 +1,98 @@ +@use "../css/config/variables" as *; + +.site-section--importer-status { + .import { + &__info { + position: sticky; + top: 2em; + right: 2em; + left: 100%; + display: inline-block; + background-color: var(--colour1-primary-transparent); + border-radius: 10px; + padding: $size-normal; + + & > * { + padding-bottom: $size-normal; + + &:last-child { + padding-bottom: 0; + } + } + } + + &__stats { + display: flex; + flex-flow: row nowrap; + justify-content: flex-end; + align-items: center; + gap: $size-normal; + } + + &__buttons { + display: flex; + flex-flow: row nowrap; + justify-content: flex-end; + align-items: center; + gap: $size-normal; + } + + &__status { + } + &__count { + transition-duration: var(--duration-global); + transition-property: visibility, opacity; + + &--invisible { + visibility: hidden; + opacity: 0; + } + } + } + + .loading-placeholder { + transition-duration: var(--duration-global); + transition-property: visibility, opacity; + + &--complete { + position: absolute; + visibility: hidden; + opacity: 0; + } + } + + // .import-id { + // visibility: hidden; + // transition-duration: var(--duration-global); + // transition-property: visibility, opacity; + // } + + .jumbo.no-posts { + background-color: hsl(211, 100%, 49%); + margin: 0; + } + + .log-list { + padding: 1em 0 1em 3em; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + opacity: 0; + visibility: hidden; + transition-duration: var(--duration-global); + transition-property: visibility, opacity; + + &--reversed { + flex-direction: column-reverse; + } + + &--loaded { + opacity: 1; + visibility: visible; + } + + &__item { + line-height: 1.5; + } + } +} diff --git a/client/src/pages/importer_tutorial.html b/client/src/pages/importer_tutorial.html new file mode 100644 index 0000000..f3873ee --- /dev/null +++ b/client/src/pages/importer_tutorial.html @@ -0,0 +1,76 @@ +{% extends 'components/shell.html' %} +{% block content %} +{% include "components/importer_states.html" %} +
    +

    How to get your session key

    +

    Patreon, Fanbox, SubscribeStar, Gumroad, Fantia, Boosty, Afdian

    +

    {{ g.site_name }} needs your session key in order to access posts from the {{ g.artists_or_creators|lower }} you are subscribed to.

    +

    Below are the respective cookies for the supported paysites.

    +
      +
    • For Patreon, your session key is under session_id.
    • +
    • For Fanbox, your session key is under FANBOXSESSID.
    • +
    • For Gumroad, your session key is under _gumroad_app_session.
    • +
    • For SubscribeStar, your session key is under _personalization_id.
    • +
    • For Fantia, your session key is under _session_id.
    • +
    • For Boosty, your session key is under auth.
    • +
    • For Afdian, your session key is under auth_token.
    • +
    +

    After going to the paysite you want to import and signing in, ( + Patreon + / + Fanbox + / + Gumroad + / + SubscribeStar + / + Fantia + / + Boosty + / + Afdian + ) you need to find where cookies are located in your browser.

    +

    Chrome

    +
      +
    • Press F12 to open Developer tools. If it didn't work for you try Ctrl+Shift+I or right click inspect element.
    • +
    • In the menu at the top, navigate to "Application" tab, if this isn't visible at a first glance simply press >> for more tabs.
    • + Select Application in Developer tools. +
    • In the "Application" tab, go to "Cookies".
    • +
    • Within the "Cookies" dropdown, select "patreon.com".
    • +
    • Now in list of cookies find session_id and select it, copy the contents and that will be the value you will use.
    • + Copy cookie in the correct menu +
    • Paste the content of the cookie you copied and submit in the {{ g.site_name }} import page
    • +
    +

    Safari

    +
      +
    • Ensure "Show Develop Menu" is enabled in Preferences (⌘,)
    • +
    • Open Web Inspector (⌘⌥I)
    • +
    • Go to Storage > Cookies
    • +
    • Right-click the cookie for your service and click "Copy"
    • +
    +

    Firefox

    +
      +
    • Open DevTools by pressing F12 and open the Storage tab
    • +
    • Go to Cookies > [site]
    • +
    • Go to Storage > Cookies
    • +
    • Right-click the cookie for your service and click "Copy"
    • +
    +

    For other browsers, please consult browser documentation on how to access stored cookies.

    +

    Discord

    +

    Getting your token

    +
      +
    • Open Discord in browser of your choice
    • +
    • Open DevTools (F12, Safari see above)
    • +
    • Go to Console Tab
    • +
    • Paste and execute the following snippet: (webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
    • +
    • A "slightly.long.string" will be returned at the bottom of the console. Copy the contents between "". This is your self token.
    • +
    +

    The above should work with most browsers and the official Discord App, although you open the DevTools via the following combination in the App Ctrl + Shift + I

    +

    + Instructions on how to get the channel IDs can be found + + here. + +

    +
    +{% endblock %} \ No newline at end of file diff --git a/client/src/pages/importer_tutorial_fanbox.html b/client/src/pages/importer_tutorial_fanbox.html new file mode 100644 index 0000000..817756c --- /dev/null +++ b/client/src/pages/importer_tutorial_fanbox.html @@ -0,0 +1,38 @@ +{% extends 'components/shell.html' %} +{% block content %} +{% include "components/importer_states.html" %} +
    +

    How to get your Fanbox session key

    +

    {{ g.site_name }} needs your session key in order to access posts from the {{ g.artists_or_creators|lower }} you are subscribed to.

    + +

    After going to the paysite you want to import and signing in, ( + Fanbox + ) you need to find where cookies are located in your browser, follow the instructions bellow.

    +

    Chrome

    +
      +
    • Press F12 to open Developer tools. If it didn't work for you try Ctrl+Shift+I or right click inspect element.
    • +
    • In the menu at the top, navigate to "Application" tab, if this isn't visible at a first glance simply press >> for more tabs.
    • + Select Application in Developer tools. +
    • In the "Application" tab, go to "Cookies".
    • +
    • Within the "Cookies" dropdown, select "fanbox.cc".
    • +
    • Now in list of cookies find FANBOXSESSID and select it, copy the contents and that will be the value you will use.
    • + Copy cookie in the correct menu +
    • Paste the content of the cookie you copied and submit in the {{ g.site_name }} import page
    • +
    +

    Safari

    +
      +
    • Ensure "Show Develop Menu" is enabled in Preferences (⌘,)
    • +
    • Open Web Inspector (⌘⌥I)
    • +
    • Go to Storage > Cookies
    • +
    • Right-click the cookie for your service and click "Copy"
    • +
    +

    Firefox

    +
      +
    • Open DevTools by pressing F12 and open the Storage tab
    • +
    • Go to Cookies > [site]
    • +
    • Go to Storage > Cookies
    • +
    • Right-click the cookie for your service and click "Copy"
    • +
    +

    For other browsers, please consult browser documentation on how to access stored cookies.

    +
    +{% endblock %} \ No newline at end of file diff --git a/client/src/pages/post.html b/client/src/pages/post.html new file mode 100644 index 0000000..4918c42 --- /dev/null +++ b/client/src/pages/post.html @@ -0,0 +1,420 @@ +{% extends 'components/shell.html' %} + +{% from 'components/timestamp.html' import timestamp %} +{% from 'components/links.html' import kemono_link, local_link %} +{% from 'components/fancy_image.html' import fancy_image, background_image %} +{% from 'components/image_link.html' import image_link %} +{% from 'components/ads.html' import middle_ad, slider_ad %} + +{% set paysite = g.paysites[props.service] %} +{% set post_title = post.title if post.title else 'Untitled' %} +{% set artist_name = props.artist.name if props.artist.name else props.user %} +{% set page_title = "\"" ~ post_title ~ "\" by " ~ props.artist.name ~ " from " ~ paysite.title ~ " | " ~ g.site_name if props.artist else post_title ~ " | " ~ g.site_name %} +{% set user_link = g.freesites.kemono.user.profile(post.service, post.user) %} +{% set user_icon = g.freesites.kemono.user.icon(post.service, post.user) %} +{% set user_banner = g.freesites.kemono.user.banner(post.service, post.user) %} +{% set post_link = g.freesites.kemono.post.link(post.service, post.user, post.id) %} + +{% block title %} + + {{ page_title }} + +{% endblock title %} + +{% block meta %} + + + + {% if post.published %} + + {% endif %} + +{% endblock meta %} + +{% block opengraph %} + + + + + +{% endblock opengraph %} + +{% block content %} +{{ slider_ad() }} +
    + {% if post %} + + +
    +
    + {{ background_image(user_banner) }} + {{ image_link( + url=user_link, + src=user_icon, + is_lazy=false, + class_name='post__user-profile' + ) }} + +
    + +
    + +
    + {{ middle_ad() }} + {{ post_view(post, result_attachments, result_previews) }} +
    + +
    +

    Comments

    + {# TODO: comment filters #} +
    + {% for comment in comments %} + {% set is_user = comment.commenter == post.user %} +
    +
    + {% if is_user %} + + {% call kemono_link(comment.id, class_name="comment__icon") %} + {{ fancy_image( g.icons_prepend ~ '/icons/' ~ post.service ~ '/' ~ post.user) }} + {% endcall %} + + {% call local_link(comment.id, class_name="comment__name") %} + {{ props.artist.name if props.artist else g.artists_or_creators[:-1] }} + {% endcall %} + + {% else %} + {{ local_link(comment.id, comment.commenter_name or 'Anonymous' , "comment__name") }} + {% endif %} + + {% if comment.revisions %} + (edited) +
    +
    +
    +
    +
    +

    Comment edits

    + + +
    + +
    + {% for revision in comment.revisions + [comment] %} +
    + {{(revision.published or revision.added)|simple_datetime}} + {{revision.content}} +
    + {% endfor %} +
    +
    +
    +
    +
    + {% endif %} +
    +
    + {% if comment.parent_id %} + + {% endif %} +

    + {{ comment.content }} +

    +
    +
    + {{ timestamp(comment.published) }} +
    +
    + {% else %} +

    No comments found for this post.

    + {% endfor %} +
    + +
    + + {% else %} +

    Nobody here but us chickens!

    + {% endif %} +
    +{% endblock content %} + +{% block components %} + + + + Flagged + + + Revisions +{% endblock components %} + +{% macro post_view(post, attachments, previews) %} + {% if post.service == 'dlsite' and post.attachments|length > 1 %} +

    + This DLsite post was received as a split set of multiple files due to file size. Download all the files, then open the .exe file to compile them into a single one. +

    + {% endif %} + + {% if videos %} +

    Videos

    + +
      + {% for video in videos %} +
    • + {{ video.name }} + {% if video.caption %} + {{video.caption}} + {% endif %} + +
    • + {% endfor %} +
    + {% endif %} + + {% if attachments %} +

    Downloads

    +
      + {% for attachment in attachments %} +
    • + + Download {{ attachment.name }} + + {% if archives_enabled and ( attachment.extension in [".zip", ".rar", ".7z"] or attachment.name_extension in [".zip", ".rar", ".7z"] )%} + (browse ») + {% endif %} +
    • + {% endfor %} +
    + {% endif %} + + {% if post.incomplete_rewards %} +
    +
    {{ post.incomplete_rewards|safe }}
    +
    + {% endif %} + + {% if post.poll %} +

    Poll

    + +
    +
    +

    {{post.poll.title}}

    + {% if post.poll.description %} +
    {{post.poll.description}}
    + {% endif %} +
    +
      + {% for choice in post.poll.choices %} + {% set pct = choice.votes / (post.poll.total_votes or 1) * 100 %} +
    • + {{choice.text}} + {{choice.votes}} + +
    • + {% endfor %} +
    +
    +
      +
    • {{post.poll.created_at|simple_date}}
    • + {% if post.poll.closes_at %} +
    • —{{post.poll.closes_at|simple_date}}
    • + {% endif %} + {% if post.poll.allow_multiple %} +
    • multiple choice
    • + {% endif %} +
    • {{post.poll.total_votes}} votes
    • +
    +
    + {{post.poll}} + {% endif %} + + {% if post.content %} +

    Content

    +
    + {% if props.service == "subscribestar" -%} +
    + {%- endif %} + {% if props.service == 'fantia' or props.service == 'onlyfans' or props.service == 'fansly' or props.service == 'candfans' -%} +
    {{ post.content|safe }}
    + {% else -%} + {{ post.content|safe }} + {%- endif %} +
    + {% endif %} + + + {% if previews %} +

    Files

    +
    + {% for preview in previews %} + {% if preview.type == 'thumbnail' %} +
    +
    + + {# TODO: move backup image logic to the script #} + + + {% if preview.caption %} +
    {{preview.caption}}
    + {% endif %} +
    +
    + {% elif preview.type == 'embed' %} + +
    +

    + {{ preview.subject if preview.subject else '(No title)' }} +

    + {% if preview.description %} +

    + {{ preview.description }} +

    + {% endif %} +
    +
    + {% endif %} + {% endfor %} +
    + {% endif %} + +{% endmacro %} diff --git a/client/src/pages/post.js b/client/src/pages/post.js new file mode 100644 index 0000000..ca08966 --- /dev/null +++ b/client/src/pages/post.js @@ -0,0 +1,375 @@ +import { kemonoAPI } from "@wp/api"; +import { addFavouritePost, findFavouritePost, removeFavouritePost } from "@wp/js/favorites"; +import { LoadingIcon, registerMessage, showTooltip } from "@wp/components"; +import { createComponent } from "@wp/js/component-factory"; +import { isLoggedIn } from "@wp/js/account"; +import MicroModal from "micromodal"; +import { diffChars } from "diff"; + +import "fluid-player/src/css/fluidplayer.css"; +import fluidPlayer from "fluid-player"; + +const meta = { + service: null, + user: null, + postID: null, +}; + +/** + * @param {HTMLElement} section + */ +export async function postPage(section) { + /** + * @type {HTMLElement} + */ + const buttonPanel = section.querySelector(".post__actions"); + + meta.service = document.head.querySelector("[name='service']").content; + meta.user = document.head.querySelector("[name='user']").content; + meta.postID = document.head.querySelector("[name='id']").content; + const postBody = section.querySelector(".post__body"); + + section.addEventListener("click", Expander); + + cleanupBody(postBody); + await initButtons(buttonPanel); + addRevisionHandler(); + + document.addEventListener("DOMContentLoaded", (d, ev) => {addShowTagsButton()}); + window.addEventListener("resize", (d, ev) => {addShowTagsButton()}); + + MicroModal.init(); + // diffComments(); + + Array.from(document.getElementsByTagName("video")).forEach((_, i) => { + fluidPlayer(`kemono-player${i}`, { + layoutControls: { + fillToContainer: false, + preload: "none", + }, + vastOptions: { + adList: window.videoAds, + adTextPosition: "top left", + maxAllowedVastTagRedirects: 2, + vastAdvanced: { + vastLoadedCallback: function () {}, + noVastVideoCallback: function () {}, + vastVideoSkippedCallback: function () {}, + vastVideoEndedCallback: function () {}, + }, + }, + }); + }); +} + +/** + * Apply some fixes to the content of the post. + * @param {HTMLElement} postBody + */ +function cleanupBody(postBody) { + const postContent = postBody.querySelector(".post__content"); + const isNoPostContent = !postContent || (!postContent.childElementCount && !postContent.childNodes.length); + + // content is empty + if (isNoPostContent) { + return; + } + + // pixiv post + if (meta.service === "fanbox") { + // its contents is a text node + if (!postContent.childElementCount && postContent.childNodes.length === 1) { + // wrap the text node into `
    `
    +      const [textNode] = Array.from(postContent.childNodes);
    +      const pre = document.createElement("pre");
    +      textNode.after(pre);
    +      pre.appendChild(textNode);
    +    }
    +
    +    // remove paragraphs with only `
    ` in them + const paragraphs = postContent.querySelectorAll("p"); + paragraphs.forEach((para) => { + if (para.childElementCount === 1 && para.firstElementChild.tagName === "BR") { + para.remove(); + } + }); + } + + Array.from(document.links).forEach((anchour) => { + // remove links to fanbox from the post + const hostname = anchour.hostname; + if (hostname.includes("downloads.fanbox.cc")) { + if (anchour.classList.contains("image-link")) { + anchour.remove(); + } else { + let el = document.createElement("span"); + el.textContent = anchour.textContent; + anchour.replaceWith(el); + } + } + else if (hostname.includes("fanbox.cc")){ + anchour.href = anchour.href.replace(/https?:\/\/(?:[a-zA-Z0-9-]*.)?fanbox\.cc\/(?:(?:manage\/)|(?:@[a-zA-Z\d]+\/)|)posts\/(\d+)/g, '/fanbox/post/$1'); + } + else if (hostname.includes("patreon.com")){ + anchour.href = anchour.href.replace( /https?:\/\/(?:[\w-]*.)?patreon\.com\/posts\/.*\b(\d+)\b(?:\?.*)?/g, '/patreon/post/$1'); + } + }); + + // Remove needless spaces and empty paragraphs. + /** + * @type {NodeListOf { + if (paragraph.nextElementSibling && paragraph.nextElementSibling.tagName === "BR") { + paragraph.nextElementSibling.remove(); + paragraph.remove(); + } else { + paragraph.remove(); + } + }); +} + +/** + * @param {HTMLElement} buttonPanel + */ +async function initButtons(buttonPanel) { + /** + * @type {HTMLButtonElement} + */ + const flagButton = buttonPanel.querySelector(".post__flag"); + /** + * @type {HTMLButtonElement} + */ + const favButton = createComponent("post__fav"); + const isFavorited = isLoggedIn && (await findFavouritePost(meta.service, meta.user, meta.postID)); + + if (isFavorited) { + const [icon, text] = favButton.children; + favButton.classList.add("post__fav--unfav"); + icon.textContent = "★"; + text.textContent = "Unfavorite"; + } + + if (!flagButton.classList.contains("post__flag--flagged")) { + flagButton.addEventListener("click", handleFlagging(meta.service, meta.user, meta.postID)); + } + + favButton.addEventListener("click", handleFavouriting(meta.service, meta.user, meta.postID)); + + buttonPanel.appendChild(favButton); + + document.addEventListener("keydown", (e) => { + switch (e.key) { + case "ArrowLeft": + document.querySelector(".post__nav-link.prev")?.click(); + break; + case "ArrowRight": + document.querySelector(".post__nav-link.next")?.click(); + break; + } + }); +} + +function addRevisionHandler() { + let selector = document.getElementById("post-revision-selection"); + if (selector) { + selector.addEventListener("change", (ev) => { + let revision = ev.target.selectedOptions[0].value; + if (revision) + location.pathname = `/${meta.service}/user/${meta.user}/post/${meta.postID}/revision/${revision}`; + else + location.pathname = `/${meta.service}/user/${meta.user}/post/${meta.postID}`; + }); + } +} + +/** + * @param {string} service + * @param {string} user + * @param {string} postID + * @returns {(event: MouseEvent) => Promise} + */ +function handleFlagging(service, user, postID) { + return async (event) => { + /** + * @type {HTMLButtonElement} + */ + const button = event.target; + const [icon, text] = button.children; + const loadingIcon = LoadingIcon(); + const isConfirmed = confirm( + "Are you sure you want to flag this post for reimport? Only do this if data in the post is broken/corrupted/incomplete.\nThis is not a deletion button.", + ); + + button.classList.add("post__flag--loading"); + button.disabled = true; + button.insertBefore(loadingIcon, text); + + try { + if (isConfirmed) { + const isFlagged = await kemonoAPI.posts.attemptFlag(service, user, postID); + + if (isFlagged) { + const parent = button.parentElement; + const newButton = createComponent("post__flag post__flag--flagged"); + + parent.insertBefore(newButton, button); + button.remove(); + } + } + } catch (error) { + console.error(error); + } finally { + loadingIcon.remove(); + button.disabled = false; + button.classList.remove("post__flag--loading"); + } + }; +} + +/** + * @param {string} service + * @param {string} user + * @param {string} postID + * @returns {(event: MouseEvent) => Promise} + */ +function handleFavouriting(service, user, postID) { + return async (event) => { + /** + * @type {HTMLButtonElement} + */ + const button = event.currentTarget; + const isLoggedIn = localStorage.getItem("logged_in") === "yes"; + + if (!isLoggedIn) { + showTooltip(button, registerMessage(null, "Favoriting")); + return; + } + + const [icon, text] = button.children; + const loadingIcon = LoadingIcon(); + + button.disabled = true; + button.classList.add("post__fav--loading"); + button.insertBefore(loadingIcon, text); + + try { + if (button.classList.contains("post__fav--unfav")) { + const isUnfavorited = await removeFavouritePost(service, user, postID); + + if (isUnfavorited) { + button.classList.remove("post__fav--unfav"); + icon.textContent = "☆"; + text.textContent = "Favorite"; + } + } else { + const isFavorited = await addFavouritePost(service, user, postID); + + if (isFavorited) { + button.classList.add("post__fav--unfav"); + icon.textContent = "★"; + text.textContent = "Unfavorite"; + } + } + } catch (error) { + console.error(error); + } finally { + loadingIcon.remove(); + button.disabled = false; + button.classList.remove("post__fav--loading"); + } + }; +} + +// expander.js +function Expand(c, t) { + if (!c.naturalWidth) { + return setTimeout(Expand, 10, c, t); + } + c.style.maxWidth = "100%"; + c.style.display = ""; + t.style.display = "none"; + t.style.opacity = ""; +} + +/** + * @param {MouseEvent} e + */ +function Expander(e) { + /** + * @type {HTMLElement} + */ + var t = e.target; + if (t.parentNode.classList.contains("fileThumb")) { + e.preventDefault(); + if (t.hasAttribute("data-src")) { + var c = document.createElement("img"); + c.setAttribute("src", t.parentNode.getAttribute("href")); + c.style.display = "none"; + t.parentNode.insertBefore(c, t.nextElementSibling); + t.style.opacity = "0.75"; + setTimeout(Expand, 10, c, t); + } else { + var a = t.parentNode; + a.firstChild.style.display = ""; + a.removeChild(t); + a.offsetTop < window.pageYOffset && a.scrollIntoView({ top: 0, behavior: "smooth" }); + } + } +} + +function addShowTagsButton() { + let div = document.querySelector("#post-tags > div"); + if (document.getElementById("show-tag-overflow-button")){ + document.getElementById("show-tag-overflow-button").remove(); + } + if (div && div.offsetWidth < div.scrollWidth) { + // tags overflow + let button = document.createElement("a"); + button.href = "javascript:void 0"; + button.id = "show-tag-overflow-button"; + button.textContent = "Show all »"; + button.onclick = (e) => { + if (div.classList.contains("show-overflow")) { + div.classList.remove("show-overflow"); + button.textContent = "Show all»"; + } else { + div.classList.add("show-overflow"); + button.textContent = "« Hide"; + } + } + div.parentElement.appendChild(button); + } +} + +function* pairwise(iterable) { + const iterator = iterable[Symbol.iterator](); + let a = iterator.next(); + if (a.done) return; + let b = iterator.next(); + while (!b.done) { + yield [a.value, b.value]; + a = b; + b = iterator.next(); + } +} + +function diffComments() { + let comments = Array.from(document.querySelectorAll(".comment-revisions-dialog .prose")); + let pairs = pairwise(comments); + for (let [old, new_] of pairs) { + let newSpan = document.createElement("span"); + newSpan.classList.add("prose"); + diffChars(old.textContent, new_.textContent) + .forEach(c => { + let span = document.createElement("span"); + if (c.added) { span.classList.add("added"); } + else if (c.removed) { span.classList.add("removed"); } + span.appendChild(document.createTextNode(c.value)); + newSpan.appendChild(span); + }); + + old.replaceWith(newSpan); + } +} diff --git a/client/src/pages/post.scss b/client/src/pages/post.scss new file mode 100644 index 0000000..aa5d66e --- /dev/null +++ b/client/src/pages/post.scss @@ -0,0 +1,516 @@ +@use "../css/config/variables" as *; + +.post { + &__nav-list { + display: flex; + height: 2rem; + align-items: center; + justify-content: space-between; + list-style: none; + padding: 0; + margin: 0.5rem; + } + + &__header { + display: flex; + flex-flow: row nowrap; + justify-content: flex-start; + align-items: stretch; + border: solid hsla(0, 0%, 50%, 0.7) $size-thin; + border-radius: 10px 10px 0 0; + overflow: hidden; + } + + &__user { + position: relative; + flex: 0 0 220px; + text-align: center; + background-color: hsla(0, 0%, 0%, 0.7); + border-right: solid hsla(0, 0%, 50%, 0.7) $size-thin; + padding: 0.5em; + } + + &__user-profile { + display: inline-block; + width: 160px; + height: 160px; + border-radius: 10px; + margin: 0 auto; + } + + &__user-name { + font-size: 1.25em; + display: inline-block; + } + + &__info { + flex: 1 1 auto; + display: flex; + flex-flow: column nowrap; + justify-content: center; + background-color: var(--colour1-secondary); + padding: 0.5em; + padding-left: 2em; + + & > * { + margin: 0.75rem 0; + } + } + + &__title { + } + + &__published { + color: var(--colour0-secondary); + } + + &__added { + color: var(--colour0-secondary); + } + + &__edited { + color: var(--colour0-secondary); + } + + &__actions { + font-size: 1.5em; + + & > * { + margin-right: 1em; + user-select: none; + + &:last-child { + margin-right: 0; + } + } + } + + &__flag { + display: inline-block; + color: hsl(3, 100%, 69%); + font-weight: bold; + text-shadow: + hsl(0, 0%, 0%) 0px 0px 3px, + hsl(0, 0%, 0%) -1px -1px 0px, + hsl(0, 0%, 0%) 1px 1px 0px; + background-color: transparent; + border: transparent; + + // hack to overwrite * selector color + & span { + color: hsl(3, 100%, 69%); + } + + &--flagged { + color: hsl(0, 0%, 45%); + + // hack to overwrite * selector color + & span { + color: hsl(0, 0%, 45%); + } + } + + &--loading { + cursor: progress; + + & .post__flag-icon { + display: none; + } + } + } + + &__fav { + color: hsl(0, 0%, 100%); + font-weight: bold; + text-shadow: + hsl(0, 0%, 0%) 0px 0px 3px, + hsl(0, 0%, 0%) -1px -1px 0px, + hsl(0, 0%, 0%) 1px 1px 0px; + background-color: transparent; + border: transparent; + + &--unfav { + color: var(--favourite-colour1-primary); + + // hack to overwrite * selector color + & span { + color: var(--favourite-colour1-primary); + } + } + + &--loading { + cursor: progress; + + & .post__fav-icon { + display: none; + } + } + } + + &__body { + border-left: solid hsl(0, 0%, 50%) $size-thin; + border-right: solid hsl(0, 0%, 50%) $size-thin; + padding: $size-small; + } + + &__attachments { + list-style: none; + padding: $size-small; + margin: 0; + } + + &__attachment { + padding: $size-little 0; + } + + &__content { + line-height: 1.5; + + & p { + padding: $size-small 0; + } + } + + &__files { + display: flex; + flex-flow: column nowrap; + align-items: center; + } + + &__thumbnail { + margin: 0.5em 0; + + figcaption { + text-align: center; + background-color: var(--colour1-secondary); + margin-top: -4px; + } + } + + &__video { + width: 60%; + height: 60%; + } + + &__footer { + border: solid hsl(0, 0%, 50%) $size-thin; + border-radius: 0 0 10px 10px; + padding: $size-small; + } + + &__comments { + &--no-comments { + text-align: center; + } + + & > * { + margin-bottom: $size-normal; + + &:last-child { + margin-bottom: 0; + } + } + } + + @media (max-width: $width-phone) { + &__header { + flex-flow: column nowrap; + align-items: stretch; + } + + &__info { + padding-left: 0.5em; + } + + &__user { + border-right: none; + border-bottom: solid hsla(0, 0%, 50%, 0.7) $size-thin; + } + &__video { + width: 100%; + height: 100%; + } + } +} + +article#poll { + border-radius: 10px; + border: $size-thin solid var(--colour1-secondary); + max-width: $width-phone; + margin-bottom: $size-normal; + + @media only screen and (orientation: portrait) { + margin-left: auto; + margin-right: auto; + } + + & > div#poll-summary { + border-bottom: $size-thin solid var(--colour1-secondary); + + > h4, + > h6 { + padding: $size-small; + } + } + + & > ul { + padding-left: unset; + cursor: default; + + > li { + list-style-type: none; + border: $size-thin solid var(--colour1-secondary); + margin: $size-normal; + position: relative; + display: grid; + grid-template-areas: "a b"; + grid-template-columns: auto 5ch; + + .choice-text { + padding-left: $size-small; + overflow: hidden; + grid-area: a; + } + + .choice-votes { + padding-right: $size-small; + float: right; + grid-area: b; + text-align: right; + } + + .choice-fill { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 100%; + background: var(--colour1-secondary); + z-index: -2; + } + } + } + + & > footer { + padding: $size-nano; + border-top: $size-thin solid var(--colour1-secondary); + cursor: default; + + li { + display: inline; + color: var(--colour0-secondary); + + &#end { + margin-left: -4px; + } + } + + span.sep::after { + color: var(--colour0-secondary); + content: " | "; + } + } +} + +section#post-tags { + display: flex; + margin: 2px 0; + + span#label { + width: 89px; + display: inline-block; + color: var(--colour0-secondary); + } + + div { + display: flex; + max-width: 720px; + overflow: hidden; + } + + div.show-overflow { + overflow: unset; + flex-wrap: wrap; + } + + > div > a { + color: unset; + background-color: var(--colour1-primary); + border-radius: 5px; + padding: 3px 5px; + margin: $size-thin; + white-space: nowrap; + font-size: 10px; + } + + #show-tag-overflow-button { + max-height: 21px; + --local-colour1-primary: var(--anchour-internal-colour1-primary); + --local-colour1-secondary: var(--anchour-internal-colour1-secondary); + --local-colour2-primary: var(--anchour-internal-colour2-primary); + --local-colour2-secondary: var(--anchour-internal-colour2-secondary); + font-size: 12px; + vertical-align: middle; + padding-top: 4px; + } +} + +.comment { + border-radius: 10px; + border: $size-thin solid var(--colour1-secondary); + max-width: $width-phone; + + &:target { + outline-color: var(--anchour-local-colour1-primary); + outline-width: $size-thin; + outline-style: dashed; + } + + & > * { + padding: $size-small; + } + + &--user { + background-color: var(--colour1-secondary); + border: none; + } + + &__header { + border-bottom: $size-nano solid var(--colour1-secondary); + + .comment-revisions-dialog { + display: none; + + > div { + // overlay + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + + > div { + // container + background-color: var(--colour1-primary); + // padding: 30px; + max-width: 720px; + max-height: 100vh; + border-radius: 4px; + overflow-y: auto; + box-sizing: border-box; + + section { + padding: 30px; + width: 100%; + height: 100%; + } + + header { + display: flex; + justify-content: space-between; + align-items: center; + + > h2 { + margin-top: 0; + margin-bottom: 0; + font-weight: 600; + font-size: 1.25rem; + line-height: 1.25; + box-sizing: border-box; + } + + > button { + font-size: 0.875rem; + padding: 0.5rem 1rem; + background-color: var(--colour1-primary); + color: var(--colour0-primary); + border-radius: 0.25rem; + border-style: none; + border-width: 0; + cursor: pointer; + text-transform: none; + overflow: visible; + line-height: 1.15; + margin: 0; + will-change: transform; + backface-visibility: hidden; + transform: translateZ(0); + transition: transform 0.25s ease-out; + + &:focus { + outline: none; + } + + &::before { + content: "\2715"; // ✕ + } + } + } + + main { + margin-top: 2rem; + margin-bottom: 2rem; + line-height: 1.5; + color: white; + + article { + + span.timestamp { + padding-right: 0.5em; + } + + span.prose { + span.removed { + text-decoration: line-through; + color: #f77b7b; + } + + span.added { + color: #8be78b; + } + } + } + } + } + } + } + + .comment-revisions-dialog.is-open { + display: block; + } + } + + &__reply { + // padding: $size-little 0; + } + + &__message { + line-height: 1.5; + margin: 0; + } + + &__profile { + } + + &__icon { + display: inline-block; + border-radius: 5px; + overflow: hidden; + width: 1em; + height: 1em; + } + + &__name { + } + + &__body { + } + + &__footer { + border-top: $size-nano solid var(--colour1-secondary); + } +} diff --git a/client/src/pages/posts.html b/client/src/pages/posts.html new file mode 100644 index 0000000..ae6d223 --- /dev/null +++ b/client/src/pages/posts.html @@ -0,0 +1,57 @@ +{% extends 'components/shell.html' %} + +{% from 'components/card_list.html' import card_list %} +{% from 'components/cards/post.html' import post_card %} +{% from 'components/ads.html' import slider_ad, header_ad, footer_ad %} + +{% block content %} + {{ slider_ad() }} +
    +
    +

    Posts

    +
    +
    + {% include 'components/paginator.html' %} +
    + + +
    +
    + + {{ header_ad() }} + + {% call card_list() %} + {% for post in results %} + {{ post_card(post) }} + {% else %} +
    +

    Nobody here but us chickens!

    +

    + There are no posts for your query. +

    +
    + {% endfor %} + {% endcall %} + + {{ footer_ad() }} + +
    + {% include 'components/paginator.html' %} +
    +
    +{% endblock %} + + diff --git a/client/src/pages/posts.js b/client/src/pages/posts.js new file mode 100644 index 0000000..7c10cd4 --- /dev/null +++ b/client/src/pages/posts.js @@ -0,0 +1,35 @@ +import { CardList, PostCard, registerPaginatorKeybinds } from "@wp/components"; +import { isLoggedIn } from "@wp/js/account"; +import { findFavouriteArtist, findFavouritePost } from "@wp/js/favorites"; + +/** + * @param {HTMLElement} section + */ +export function postsPage(section) { + const cardListElement = section.querySelector(".card-list"); + if (!cardListElement){ + return; + } + const { cardList, cardItems } = CardList(cardListElement); + + cardItems.forEach(async (card) => { + registerPaginatorKeybinds(); + + const { postID, userID, service } = PostCard(card); + const favPost = isLoggedIn && (await findFavouritePost(service, userID, postID)); + const favUser = isLoggedIn && (await findFavouriteArtist(userID, service)); + + if (favPost) { + card.classList.add("post-card--fav"); + } + + if (favUser) { + const postHeader = card.querySelector(".post-card__header"); + const postFooter = card.querySelector(".post-card__footer"); + + postHeader.classList.add("post-card__header--fav"); + postFooter.classList.add("post-card__footer--fav"); + /* userName.textContent = favUser.name; this is doing nothing does it */ + } + }); +} diff --git a/client/src/pages/posts/_index.scss b/client/src/pages/posts/_index.scss new file mode 100644 index 0000000..d2ef914 --- /dev/null +++ b/client/src/pages/posts/_index.scss @@ -0,0 +1 @@ +@use "popular"; diff --git a/client/src/pages/posts/archive.html b/client/src/pages/posts/archive.html new file mode 100644 index 0000000..1edf998 --- /dev/null +++ b/client/src/pages/posts/archive.html @@ -0,0 +1,53 @@ +{% extends "components/shell.html" %} + +{% block title %} + Archived Files | {{ g.site_name }} +{% endblock %} + +{% block content %} +
    +
    +

    Archive Files

    +
    +
    + +
    +

    + {% if archive.password %} + Archive password: {{archive.password}} + {% elif archive.password == "" %} + Archive needs password, but none was provided. Click to input + {% endif %} +

    +
    + + {% for file_name in archive.file_list %} + {% if file_serving_enabled and archive.password %} + {{file_name}}
    + {% elif file_serving_enabled and archive.password == None %} + {{file_name}}
    + {% else %} + {{file_name}}
    + {% endif %} + {% else %} + {% if archive %} + Archive is empty or missing password. + {% else %} + File does not exist or is not an archive. + {% endif %} + {% endfor %} + + +{% endblock %} diff --git a/client/src/pages/posts/popular.html b/client/src/pages/posts/popular.html new file mode 100644 index 0000000..3c4e918 --- /dev/null +++ b/client/src/pages/posts/popular.html @@ -0,0 +1,114 @@ +{% extends "components/shell.html" %} + +{% from "components/card_list.html" import card_list %} +{% from "components/cards/post.html" import post_fav_card %} +{% from "components/ads.html" import slider_ad, header_ad, footer_ad %} + +{% block title %} + Popular Posts | {{ g.site_name }} +{% endblock %} + +{% block content %} + {{ slider_ad() }} + + + +{% endblock %} diff --git a/client/src/pages/posts/popular.scss b/client/src/pages/posts/popular.scss new file mode 100644 index 0000000..adfbd45 --- /dev/null +++ b/client/src/pages/posts/popular.scss @@ -0,0 +1,10 @@ +#popular-posts-paginator { + display: grid; + max-width: 650px; + margin-left: auto; + margin-right: auto; + + #daily, #weekly, #monthly { + grid-row: 1; + } +} diff --git a/client/src/pages/review_dms/_index.scss b/client/src/pages/review_dms/_index.scss new file mode 100644 index 0000000..a3d8603 --- /dev/null +++ b/client/src/pages/review_dms/_index.scss @@ -0,0 +1 @@ +@use "dms"; diff --git a/client/src/pages/review_dms/dms.js b/client/src/pages/review_dms/dms.js new file mode 100644 index 0000000..eb87036 --- /dev/null +++ b/client/src/pages/review_dms/dms.js @@ -0,0 +1,14 @@ +import { initPendingReviewDms } from "@wp/js/pending-review-dms"; + +export async function reviewDMsPage() { + + const status_selector = document.getElementById("status"); + status_selector.addEventListener("change", async function (e) { + e.preventDefault(); + const currentUrl = new URL(window.location.href); + const urlParams = currentUrl.searchParams; + urlParams.set('status', status_selector.value); + window.location.href = currentUrl.toString(); + }); + await initPendingReviewDms(true); +} diff --git a/client/src/pages/review_dms/dms.scss b/client/src/pages/review_dms/dms.scss new file mode 100644 index 0000000..3309d35 --- /dev/null +++ b/client/src/pages/review_dms/dms.scss @@ -0,0 +1,68 @@ +@use "../../css/config/variables" as *; + +.site-section--review-dms { + .dms { + padding: 0; + + &__dm { + position: relative; + margin-bottom: $size-large; + padding: 0; + } + + &__check { + position: absolute; + visibility: hidden; + opacity: 0; + + &:checked + .dms__content { + --local-border-colour1: var(--positive-colour1-secondary); + --local-opacity: 1; + } + } + + &__content { + --local-border-colour1: var(--colour1-secondary); + --local-opacity: 0.5; + + border-radius: 10px 10px 0 10px; + border: $size-thin solid var(--local-border-colour1); + transition-duration: var(--duration-global); + transition-property: border-color; + } + + &__card { + & > * { + padding: $size-small; + transition-duration: var(--duration-global); + transition-property: border-color; + + &:first-child { + border-bottom: $size-thin solid var(--local-border-colour1); + } + + &:last-child { + border-top: $size-thin solid var(--local-border-colour1); + } + } + } + + &__approve { + position: absolute; + right: 0; + top: 100%; + background-color: var(--local-border-colour1); + opacity: var(--local-opacity); + border-radius: 0 0 5px 5px; + border: $size-thin solid var(--local-border-colour1); + border-top: none; + padding: $size-small; + transition-duration: var(--duration-global); + transition-property: color, opacity; + } + } + + .no-dms { + text-align: center; + } +} diff --git a/client/src/pages/review_dms/review_dms.html b/client/src/pages/review_dms/review_dms.html new file mode 100644 index 0000000..7c13def --- /dev/null +++ b/client/src/pages/review_dms/review_dms.html @@ -0,0 +1,81 @@ +{% extends 'components/shell.html' %} + +{% from 'components/cards/dm.html' import dm_card %} + +{% block title %} + Approve DMs for import to {{ g.site_name }}. +{% endblock title %} + +{% block meta %} + +{% endblock meta %} + +{% block content %} +
    +
    +

    + DM Review for {{ props.import_id }} +

    +
    +
    + + +
    +
    +
    + {% if props.dms %} +
    + {% for dm in props.dms %} +
    + +
    + {{ dm_card(dm, class_name='dms__card', is_private=true, artist=(dm|attr("artist") or {}) ) }} + +
    +
    + {% endfor %} + {% if props.status == "ignored" %} + + {% endif %} +
    + +
    +
    + {% else %} +

    + There are no DMs waiting for approval. +

    + {% endif %} +
    +{% endblock %} diff --git a/client/src/pages/schema.html b/client/src/pages/schema.html new file mode 100644 index 0000000..f7829da --- /dev/null +++ b/client/src/pages/schema.html @@ -0,0 +1,7 @@ +{% extends 'components/shell.html' %} +{% block content %} + + +{% endblock %} \ No newline at end of file diff --git a/client/src/pages/search_hash.html b/client/src/pages/search_hash.html new file mode 100644 index 0000000..5b92c83 --- /dev/null +++ b/client/src/pages/search_hash.html @@ -0,0 +1,12 @@ +{% extends "components/shell.html" %} +{% from "components/file_hash_search.html" import search_form %} + +{% block title %} + Search files | {{ g.site_name }} +{% endblock title %} + +{% block content %} + +{% endblock content %} diff --git a/client/src/pages/search_hash.js b/client/src/pages/search_hash.js new file mode 100644 index 0000000..404875d --- /dev/null +++ b/client/src/pages/search_hash.js @@ -0,0 +1,43 @@ +import sha256 from "sha256-wasm"; + +export function searchHashPage() { + const FORM = document.getElementById("file-search"); + const FILE = document.getElementById("file"); + const HASH = document.getElementById("hash"); + + FORM.addEventListener("submit", async function (e) { + + e.preventDefault(); + let hash = undefined; + if (FILE.value !== "") { + hash = await getFileHash(FILE.files[0]); + } else if (HASH.value !== "") { + if (HASH.value.match(/[A-Fa-f0-9]{64}/)) { + hash = HASH.value; + } else { + alert("Invalid SHA256 hash"); + } + } else { + alert("Neither file or hash provided"); + } + + if (hash) { + window.location.search = "?hash=" + hash; + } + }); +} + +async function getFileHash(file) { + const fileSize = file.size; + const chunkSize = 1024 * 1024; // 1Mi + let offset = 0; + let hash = new sha256(); + + while (offset < fileSize) { + const arr = new Uint8Array(await file.slice(offset, chunkSize + offset).arrayBuffer()); + hash.update(arr); + offset += chunkSize; + } + + return hash.digest("hex"); +} diff --git a/client/src/pages/search_results.html b/client/src/pages/search_results.html new file mode 100644 index 0000000..b2195bd --- /dev/null +++ b/client/src/pages/search_results.html @@ -0,0 +1,41 @@ +{% extends "components/shell.html" %} + +{% from "components/card_list.html" import card_list %} +{% from "components/cards/post.html" import post_card %} +{% from "components/file_hash_search.html" import search_form %} + +{% block title %} + File search results | {{ g.site_name }} +{% endblock title %} + +{% block meta %} + +{% endblock meta %} + +{% block content %} + +{% endblock content %} diff --git a/client/src/pages/search_results.js b/client/src/pages/search_results.js new file mode 100644 index 0000000..e69de29 diff --git a/client/src/pages/share.html b/client/src/pages/share.html new file mode 100644 index 0000000..7515bb0 --- /dev/null +++ b/client/src/pages/share.html @@ -0,0 +1,31 @@ +{% extends 'components/shell.html' %} + +{% from 'components/site_section.html' import site_section, site_section_header %} + +{% block scripts_extra %} + + +{% endblock scripts_extra %} + +{% block content %} +{% call site_section('upload') %} +
    + {{ site_section_header(share.name) }} +
    {{ share.description }}
    + {% for file in share_files %} +
  • + + Download {{ file['filename'] }} + +
  • + {% endfor %} +
    +{% endcall %} +{% endblock %} diff --git a/client/src/pages/shares.html b/client/src/pages/shares.html new file mode 100644 index 0000000..d46faec --- /dev/null +++ b/client/src/pages/shares.html @@ -0,0 +1,49 @@ +{% extends 'components/shell.html' %} + +{% import 'components/site.html' as site %} +{% from 'components/card_list.html' import card_list %} +{% from 'components/cards/share.html' import share_card %} + +{% block content %} +{% call site.section("all-dms", title="Filehaus") %} +
    + {% include 'components/paginator.html' %} + {# + + #} +
    + + {% call card_list("phone") %} + {% for dm in props.shares %} + {{ share_card(dm) }} + {% else %} +
    +

    Nobody here but us chickens!

    +

    + There are no uploads. +

    +
    + {% endfor %} + {% endcall %} + +
    + {% include 'components/paginator.html' %} +
    +{% endcall %} +{% endblock %} diff --git a/client/src/pages/success.html b/client/src/pages/success.html new file mode 100644 index 0000000..eb5aa8a --- /dev/null +++ b/client/src/pages/success.html @@ -0,0 +1,8 @@ +{% extends 'components/shell.html' %} + +{% block content %} +

    {{ props.message or 'Success!' }}

    + {% if props.redirect %} + + {% endif %} +{% endblock %} diff --git a/client/src/pages/swagger_schema.html b/client/src/pages/swagger_schema.html new file mode 100644 index 0000000..aa38187 --- /dev/null +++ b/client/src/pages/swagger_schema.html @@ -0,0 +1,32 @@ + + + + + + + SwaggerUI + + + +
    + + + + + \ No newline at end of file diff --git a/client/src/pages/tags.html b/client/src/pages/tags.html new file mode 100644 index 0000000..decf5b2 --- /dev/null +++ b/client/src/pages/tags.html @@ -0,0 +1,31 @@ +{% extends "components/shell.html" %} + +{% from "components/headers.html" import user_header %} + +{% block title %} + Tags | {{g.site_name}} +{% endblock %} + +{% block content %} + +{% endblock %} + +{% block components %} + + {{ loading_icon() }} +{% endblock components %} diff --git a/client/src/pages/tags.scss b/client/src/pages/tags.scss new file mode 100644 index 0000000..73e6282 --- /dev/null +++ b/client/src/pages/tags.scss @@ -0,0 +1,44 @@ +@use "../css/config/variables" as *; + +h2#all-tags-header { + margin-left: auto; + margin-right: auto; + width: fit-content; +} + +section#tag-container { + width: 80vw; + margin-top: $size-normal; + margin-left: auto; + margin-right: auto; + display: flex; + flex-wrap: wrap; + justify-content: center; + + article { + border-radius: 10px; + background-color: var(--colour1-secondary); + width: fit-content; + display: inline-flex; + margin: $size-thin; + + span:first-child { + padding-right: $size-nano; + } + + span:nth-child(2) { + color: aqua; + padding-left: $size-thin; + } + + span { + height: 100%; + padding: $size-small; + } + + a { + color: unset; + border-radius: 10px; + } + } +} diff --git a/client/src/pages/updated.html b/client/src/pages/updated.html new file mode 100644 index 0000000..09520f1 --- /dev/null +++ b/client/src/pages/updated.html @@ -0,0 +1,28 @@ +{% extends 'components/shell.html' %} + +{% from 'components/card_list.html' import card_list %} +{% from 'components/cards/user.html' import user_card, user_card_header %} + +{% block content %} +
    + {% if results|length %} +
    + {% include 'components/paginator.html' %} +
    + {% endif %} + {% call card_list('phone') %} + {% for user in results %} + {{ user_card(user, is_date=true) }} + {% else %} +

    + No {{ g.artists_or_creators|lower }} found. +

    + {% endfor %} + {% endcall %} + {% if results|length %} +
    + {% include 'components/paginator.html' %} +
    + {% endif %} +
    +{% endblock %} diff --git a/client/src/pages/updated.js b/client/src/pages/updated.js new file mode 100644 index 0000000..4ae8559 --- /dev/null +++ b/client/src/pages/updated.js @@ -0,0 +1,23 @@ +import { CardList, registerPaginatorKeybinds } from "@wp/components"; +import { isLoggedIn } from "@wp/js/account"; +import { findFavouriteArtist } from "@wp/js/favorites"; + +/** + * @param {HTMLElement} section + */ +export async function updatedPage(section) { + registerPaginatorKeybinds(); + + const cardListElement = section.querySelector(".card-list"); + const { cardContainer } = CardList(cardListElement); + + for await (const userCard of cardContainer.children) { + const { id, service } = userCard.dataset; + + const isFaved = isLoggedIn && (await findFavouriteArtist(id, service)); + + if (isFaved) { + userCard.classList.add("user-card--fav"); + } + } +} diff --git a/client/src/pages/upload.html b/client/src/pages/upload.html new file mode 100644 index 0000000..ea74e65 --- /dev/null +++ b/client/src/pages/upload.html @@ -0,0 +1,87 @@ +{% extends 'components/shell.html' %} + +{% from 'components/site_section.html' import site_section, site_section_header %} + +{% block scripts_extra %} + + +{% endblock scripts_extra %} + +{% block content %} +{% call site_section('upload') %} +
    + {{ site_section_header('Upload file') }} +
    +
    + {% if request.args.get('service') and request.args.get('user') %} + + + {% else %} + {# #} + {% endif %} +
    +
    + + + + example, "February 2020 Rewards" + +
    +
    + + + + Specify what the file/archive is, where the original data can be found, include relevant keys/passwords, etc. + +
    +
      +
    +
    + Add files +
    +
    + +
    +
    +
    +
    +
    +{% endcall %} +{% endblock %} diff --git a/client/src/pages/upload.js b/client/src/pages/upload.js new file mode 100644 index 0000000..a8a6975 --- /dev/null +++ b/client/src/pages/upload.js @@ -0,0 +1,57 @@ +import Dashboard from "@uppy/dashboard"; +import Form from "@uppy/form"; +import Uppy from "@uppy/core"; +import Tus from "@uppy/tus"; + +import "@uppy/dashboard/dist/style.min.css"; +import "@uppy/core/dist/style.min.css"; + +// import "@wp/js/resumable"; + +/** + * @param {HTMLElement} section + */ +export async function uploadPage(section) { + Array.from(document.getElementsByTagName("textarea")).forEach((tx) => { + function onTextareaInput() { + this.style.height = "auto"; + this.style.height = this.scrollHeight + "px"; + } + tx.setAttribute("style", "height:" + tx.scrollHeight + "px;overflow-y:hidden;"); + tx.addEventListener("input", onTextareaInput, false); + }); + + const uppy = new Uppy({ + restrictions: { + maxTotalFileSize: 2 * 1024 * 1024 * 1024, + maxNumberOfFiles: 10, + minNumberOfFiles: 1, + }, + }) + .use(Dashboard, { + note: "Up to 10 files permitted.", + fileManagerSelectionType: "both", + target: "#upload", + // inline: true, + inline: false, + trigger: "#upload-button", + theme: "dark", + }) + .use(Tus, { + // endpoint: 'https://tusd.tusdemo.net/files/', + endpoint: "http://localhost:1080/files/", + retryDelays: [0, 1000, 3000, 5000], + }) + .use(Form, { + resultName: "uppyResult", + target: "#upload-form", + submitOnSuccess: false, + }); + + uppy.on("complete", ({ successful }) => { + successful.forEach((file) => { + const fileList = document.getElementById("file-list"); + fileList.innerHTML += `
  • ${file.meta.name}
  • `; + }); + }); +} diff --git a/client/src/pages/upload.scss b/client/src/pages/upload.scss new file mode 100644 index 0000000..dbaf989 --- /dev/null +++ b/client/src/pages/upload.scss @@ -0,0 +1,35 @@ +.site-section--upload { + max-width: 480px; + background-color: #282a2e; + box-shadow: 1px 2px 5px 4px rgb(0 0 0 / 0.2); + border-radius: 4px; + // border: 0.5px solid rgba(17, 17, 17, 1); + & .upload-view { + margin: 0 auto; + padding: 8px; + + &__header { + text-align: center; + } + + &__greeting { + color: hsl(0, 0%, 45%); + font-weight: 300; + font-size: 28px; + } + + &__identity { + color: #fff; + font-weight: normal; + } + + &__info { + font-size: 12px; + color: hsl(0, 0%, 45%); + } + + &__role { + text-transform: capitalize; + } + } +} diff --git a/client/src/pages/user.html b/client/src/pages/user.html new file mode 100644 index 0000000..be968d5 --- /dev/null +++ b/client/src/pages/user.html @@ -0,0 +1,91 @@ +{% extends 'components/shell.html' %} + +{% from 'components/ads.html' import slider_ad, header_ad, footer_ad %} +{% from 'components/headers.html' import user_header %} +{% from "components/loading_icon.html" import loading_icon %} +{% from 'components/card_list.html' import card_list %} +{% from 'components/cards/post.html' import post_card %} + +{% set paysite = g.paysites[props.service] %} +{% set page_title = 'Posts of ' ~ props.name ~ ' from ' ~ paysite.title ~ ' | ' ~ g.site_name %} + +{% block title %} + {{ page_title }} +{% endblock title %} + +{% block meta %} + + + + +{% endblock meta %} + +{% block opengraph %} + + + + + +{% endblock opengraph %} + +{% block content %} +{{ slider_ad() }} +
    + {{ user_header(request, props) }} +
    + {% include 'components/tabs.html' %} + {% if results or request.args.get('q') %} + {% include 'components/paginator.html' %} +
    + + +
    + {% endif %} +
    + {% if results or request.args.get('q') %} + {{ header_ad() }} + + {% call card_list() %} + {% for post in results %} + {{ post_card(post) }} + {% endfor %} + {% endcall %} + + {{ footer_ad() }} + +
    + {% include 'components/paginator.html' %} +
    + {% endif %} + {% if not results %} +
    +

    Nobody here but us chickens!

    +

    + There are no posts for your query. +

    +
    + {% endif %} + +
    +{% endblock content %} + +{% block components %} + + {{ loading_icon() }} +{% endblock components %} diff --git a/client/src/pages/user.js b/client/src/pages/user.js new file mode 100644 index 0000000..b27401e --- /dev/null +++ b/client/src/pages/user.js @@ -0,0 +1,122 @@ +import { addFavouriteArtist, findFavouriteArtist, findFavouritePost, removeFavouriteArtist } from "@wp/js/favorites"; +import { CardList, PostCard, registerMessage, registerPaginatorKeybinds, showTooltip } from "@wp/components"; +import { createComponent } from "@wp/js/component-factory"; +import { isLoggedIn } from "@wp/js/account"; + +/** + * @param {HTMLElement} section + */ +export async function userPage(section) { + registerPaginatorKeybinds(); + + const artistID = document.head.querySelector("[name='id']")?.content; + const artistService = document.head.querySelector("[name='service']")?.content; + /** + * @type {HTMLElement} + */ + const buttonsPanel = section.querySelector(".user-header__actions"); + const cardListElement = section.querySelector(".card-list"); + + document.styleSheets[0].insertRule(".post-card__footer > div > img { display: none; }", 0); + await initButtons(buttonsPanel, artistID, artistService); + const urlPath = document.location.pathname; + if (urlPath.includes("fancards") ||urlPath.includes("tags")) return; + if (cardListElement) { + await initCardList(cardListElement); + } +} + +/** + * @param {HTMLElement} panelElement + * @param {string} artistID + * @param {string} artistService + */ +async function initButtons(panelElement, artistID, artistService) { + /** + * @type {HTMLButtonElement} + */ + const favButton = createComponent("user-header__favourite"); + if (!favButton) return; + const favItem = await findFavouriteArtist(artistID, artistService); + if (localStorage.getItem("logged_in") && favItem) { + favButton.classList.add("user-header__favourite--unfav"); + const [icon, text] = favButton.children; + icon.textContent = "★"; + text.textContent = "Unfavorite"; + } + + favButton.addEventListener("click", handleFavouriting(artistID, artistService)); + + panelElement?.appendChild(favButton); +} + +/** + * @param {HTMLElement} cardListElement + */ +async function initCardList(cardListElement) { + const { cardItems } = CardList(cardListElement); + + cardItems.forEach(async (card) => { + const { postID, userID, service } = PostCard(card); + const favPost = isLoggedIn && (await findFavouritePost(service, userID, postID)); + + if (favPost) { + card.classList.add("post-card--fav"); + } + }); +} + +/** + * @param {string} id + * @param {string} service + * @returns {(event: MouseEvent) => Promise} + */ +function handleFavouriting(id, service) { + return async (event) => { + /** + * @type {HTMLButtonElement} + */ + const button = event.target; + + if (!isLoggedIn) { + showTooltip(button, registerMessage(null, "Favoriting")); + return; + } + + const [icon, text] = button.children; + /** + * @type {HTMLElement} + */ + const loadingIcon = createComponent("loading-icon"); + + button.disabled = true; + button.classList.add("user-header__favourite--loading"); + button.insertBefore(loadingIcon, text); + + try { + if (button.classList.contains("user-header__favourite--unfav")) { + const isRemoved = await removeFavouriteArtist(id, service); + + if (isRemoved) { + button.classList.remove("user-header__favourite--unfav"); + icon.textContent = "☆"; + text.textContent = "Favorite"; + } + } else { + const isAdded = await addFavouriteArtist(id, service); + + if (isAdded) { + button.classList.add("user-header__favourite--unfav"); + icon.textContent = "★"; + text.textContent = "Unfavorite"; + } + } + } catch (error) { + console.error(error); + } finally { + loadingIcon.remove(); + button.disabled = false; + button.classList.remove("user-header__favourite--loading"); + } + }; +} diff --git a/client/src/pages/user.scss b/client/src/pages/user.scss new file mode 100644 index 0000000..3febb0b --- /dev/null +++ b/client/src/pages/user.scss @@ -0,0 +1,176 @@ +@use "../css/config/variables" as *; + +.tabs { + margin: 0 auto; + padding-bottom: 0.25em; + padding-left: 0; + list-style-type: none; + + & .tab { + display: inline-block; + line-height: 48px; + height: 48px; + padding: 0 0.75rem; + + & a { + display: inline-block; + height: 100%; + + &.active { + border-bottom-color: var(--local-colour1-primary); + border-bottom-width: 2px; + } + } + } +} + +.site-section--user { + .no-results { + --card-size: #{$width-phone}; + width: var(--card-size); + padding: $size-small 0; + margin: 0 auto; + } +} + +.user-header { + position: relative; + display: flex; + flex-flow: row nowrap; + justify-content: flex-start; + align-items: stretch; + max-width: 720px; + background-color: hsla(0, 0%, 0%, 0.7); + border-radius: 10px; + margin: 0 auto; + overflow: hidden; + + &__background { + position: absolute; + width: 100%; + height: 100%; + z-index: -1; + + & img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &__avatar { + flex: 0 0 10em; + height: 10em; + + & img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &__info { + flex: 1 1 auto; + display: flex; + flex-flow: column nowrap; + justify-content: center; + color: hsl(0, 0%, 100%); + padding: 1em; + padding-left: 2em; + } + + &__name { + margin: 0; + margin-bottom: 0.5em; + } + + &__profile { + display: flex; + flex-flow: row wrap; + align-items: center; + + & span { + font-weight: 700; + } + + &-image { + flex: 0 0 1.75em; + padding-right: 0.5em; + + & img { + } + } + } + + &__actions { + font-size: 1.5em; + font-weight: bold; + text-shadow: + hsl(0, 0%, 0%) 0px 0px 3px, + hsl(0, 0%, 0%) -1px -1px 0px, + hsl(0, 0%, 0%) 1px 1px 0px; + color: hsl(0, 0%, 100%); + border: transparent; + + & > * { + margin-right: 1em; + + &:last-child { + margin-right: 0; + } + } + } + + &__favourite { + box-sizing: border-box; + font-weight: bold; + color: hsl(0, 0%, 100%); + text-shadow: + hsl(0, 0%, 0%) 0px 0px 3px, + hsl(0, 0%, 0%) -1px -1px 0px, + hsl(0, 0%, 0%) 1px 1px 0px; + background-color: transparent; + border: transparent; + user-select: none; + + &--unfav { + color: hsl(51, 100%, 50%); + + // hack to overwrite * selector color + & span { + color: hsl(51, 100%, 50%); + } + } + + &--loading { + cursor: progress; + + & .user-header__fav-icon { + display: none; + } + } + } + + @media (max-width: $width-phone) { + flex-flow: column nowrap; + align-items: center; + + &__info { + padding-left: 1em; + } + } +} + +// TODO: check how user blocking works +// .user-header-blocked { +// box-sizing: border-box; +// font-size: 32px; +// font-weight: bold; +// color: hsl(0, 0%, 100%); +// text-shadow: +// rgb(0, 0, 0) 0px 0px 3px, +// rgb(0, 0, 0) -1px -1px 0px, +// rgb(0, 0, 0) 1px 1px 0px; +// cursor: pointer; +// user-select: none; +// } diff --git a/client/src/templates/page.html b/client/src/templates/page.html new file mode 100644 index 0000000..66555e3 --- /dev/null +++ b/client/src/templates/page.html @@ -0,0 +1,17 @@ +{% extends 'components/shell.html' %} + +{% import 'components/site.html' as site %} + +{% set page_title = 'Heading | ' ~ g.site_name %} + +{% block title %} + + {{ page_title }} + +{% endblock title %} + +{% block content %} +{% call site.section('modifier', 'Heading') %} + +{% endcall %} +{% endblock content %} diff --git a/client/src/types/global.d.ts b/client/src/types/global.d.ts new file mode 100644 index 0000000..b50a491 --- /dev/null +++ b/client/src/types/global.d.ts @@ -0,0 +1,93 @@ +interface KemonoAPI { + favorites: KemonoAPI.Favorites; + posts: KemonoAPI.Posts; + api: KemonoAPI.API; + dms: KemonoAPI.dms; +} + +namespace KemonoAPI { + interface Post { + id: string; + service: string; + title: string; + user: string; + added: string; + published: string; + attachments: string[]; + content: string; + edited: null; + embed: {}; + file: {}; + shared_file: boolean; + faved_seq?: number; + } + + interface User { + id: string; + name: string; + service: string; + indexed: string; + updated: string; + faved_seq?: number; + } + + interface Favorites { + retrieveFavoriteArtists: () => Promise; + favoriteArtist: (service: string, id: string) => Promise; + unfavoriteArtist: (service: string, id: string) => Promise; + retrieveFavoritePosts: () => Promise; + favoritePost: (service: string, user: string, post_id: string) => Promise; + unfavoritePost: (service: string, user: string, post_id: string) => Promise; + } + + namespace Favorites { + interface User extends KemonoAPI.User {} + + interface Post { + id: string; + service: string; + user: string; + } + } + + interface dms { + retrieveHasPendingDMs: () => Promise; + } + interface Posts { + attemptFlag: (service: string, user: string, post_id: string) => Promise; + } + + interface API { + bans: () => Promise; + bannedArtist: (id: string, service: string) => Promise; + creators: () => Promise; + logs: (importID: string) => Promise; + } + + namespace API { + interface BanItem { + id: string; + service: string; + } + + interface BannedArtist { + name: string; + } + + interface LogItem {} + } +} + +namespace Events { + interface Click { + (event: MouseEvent): void; + } + + interface NavClick { + (event: NavClickEvent): void; + } + + interface NavClickEvent extends MouseEvent { + target: HTMLButtonElement; + } +} diff --git a/client/src/utils/_index.js b/client/src/utils/_index.js new file mode 100644 index 0000000..903d953 --- /dev/null +++ b/client/src/utils/_index.js @@ -0,0 +1,217 @@ +export { KemonoError } from "./kemono-error"; + +const defaultDelay = parseInt(document.documentElement.style.getPropertyValue("--duration-global")); + +/** + * @param {string} name + * @param {string} url + * @returns + */ +function getParameterByName(name, url) { + if (!url) url = window.location.href; + name = name.replace(/[[]]/g, "\\$&"); + var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"); + var results = regex.exec(url); + if (!results) return null; + if (!results[2]) return ""; + return decodeURIComponent(results[2].replace(/\+/g, " ")); +} + +/** + * @param {() => void} func + * @param {number} wait + * @param {boolean} immediate + * @returns {void} + */ +function debounce(func, wait, immediate) { + let timeout; + return function () { + var context = this; + var args = arguments; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + + function later() { + timeout = null; + if (!immediate) func.apply(context, args); + } + }; +} + +/** + * @param {number} time + * @returns + */ +export function setTimeoutAsync(time = defaultDelay) { + const timeOut = new Promise((resolve) => { + setTimeout(resolve, time); + }); + return timeOut; +} + +/** + * Iterate over the list of images + * and add `image_link` class + * if they are a descendant of an `a` element + * and don't have that class already. + * @param {HTMLImageElement[] | HTMLCollectionOf} imageElements + */ +export function fixImageLinks(imageElements) { + const images = Array.from(imageElements); + + images.forEach((image) => { + const link = image.closest("a"); + + if ( + link && + // && !image.nextSibling + // && !image.previousSibling + // TODO: fix this later + !link.classList.contains("user-header__profile") && + !link.classList.contains("user-card") && + !link.classList.contains("image-link") && + !link.classList.contains("global-sidebar-entry-item") + ) { + link.classList.add("image-link"); + } + }); +} + +/** + * @type {{[paysite:string]: {title: string, color: string, user: { profile: (userID: string) => string }, post: {}}}} + */ +export const paysites = { + patreon: { + title: "Patreon", + color: "#fa5742", + user: { + profile: (userID) => `https://www.patreon.com/user?u=${userID}`, + }, + post: {}, + }, + fanbox: { + title: "Pixiv Fanbox", + color: "#2c333c", + user: { + profile: (userID) => `https://www.pixiv.net/fanbox/creator/${userID}`, + }, + post: {}, + }, + subscribestar: { + title: "SubscribeStar", + color: "#009688", + user: { + profile: (userID) => `https://subscribestar.adult/${userID}`, + }, + post: {}, + }, + gumroad: { + title: "Gumroad", + color: "#2b9fa4", + user: { + profile: (userID) => `https://gumroad.com/${userID}`, + }, + post: {}, + }, + discord: { + title: "Discord", + color: "#5165f6", + user: { + profile: (userID) => ``, + }, + post: {}, + }, + dlsite: { + title: "DLsite", + color: "#052a83", + user: { + profile: (userID) => `https://www.dlsite.com/eng/circle/profile/=/maker_id/${userID}`, + }, + post: {}, + }, + fantia: { + title: "Fantia", + color: "#e1097f", + user: { + profile: (userID) => `user_id: f"https://fantia.jp/fanclubs/${userID}`, + }, + post: {}, + }, + boosty: { + title: "Boosty", + color: "#fd6035", + user: { + profile: (userID) => `https://boosty.to/${userID}`, + }, + post: {}, + }, + afdian: { + title: "Afdian", + color: "#9169df", + user: { + profile: (userID) => ``, + }, + post: {}, + }, + fansly: { + title: "Fansly", + color: "#2399f7", + user: { + profile: (userID) => `https://fansly.com/${userID}`, + }, + post: {}, + }, + onlyfans: { + title: "OnlyFans", + color: "#008ccf", + user: { + profile: (userID) => `https://onlyfans.com/${userID}`, + }, + post: {}, + }, + candfans: { + title: "CandFans", + color: "#e8486c", + user: { + profile: (userID) => `https://candfans.jp/${userID}`, + }, + post: {}, + }, +}; + +export const freesites = { + kemono: { + title: "Kemono", + user: { + /** + * @param {string} service + * @param {string} artistID + */ + profile: (service, artistID) => `/${service}/${service === "discord" ? "server" : "user"}/${artistID}`, + /** + * @param {string} service + * @param {string} artistID + */ + icon: (service, artistID) => `/icons/${service}/${artistID}`, + banner: (service, artistID) => `/banners/${service}/${artistID}`, + }, + post: { + /** + * @param {string} service + * @param {string} userID + * @param {string} postID + * @returns + */ + link: (service, userID, postID) => `/${service}/user/${userID}/post/${postID}`, + }, + }, +}; + +/** + * @param {number} time + */ +export function waitAsync(time) { + return new Promise((resolve) => setTimeout(resolve, time)); +} diff --git a/client/src/utils/kemono-error.js b/client/src/utils/kemono-error.js new file mode 100644 index 0000000..a54c25b --- /dev/null +++ b/client/src/utils/kemono-error.js @@ -0,0 +1,23 @@ +const errorList = { + 0: "Could not connect to server.", + 1: "Could not favorite post.", + 2: "Could not unfavorite post.", + 3: "Could not favorite artist.", + 4: "Could not unfavorite artist.", + 5: "There might already be a flag here.", + 6: "Could not retrieve the list of bans.", + 7: "Could not retrieve banned artist.", + 8: "Could not retrieve artists.", + 9: "Could not retrieve import logs.", +}; + +export class KemonoError extends Error { + /** + * @param {number} code + */ + constructor(code) { + super(); + this.code = String(code).padStart(3, "0"); + this.message = `${this.code}: ${errorList[code]}`; + } +} diff --git a/client/static/amouranth.png b/client/static/amouranth.png new file mode 100755 index 0000000..d54de01 Binary files /dev/null and b/client/static/amouranth.png differ diff --git a/client/static/amouranth_wall.jpg b/client/static/amouranth_wall.jpg new file mode 100755 index 0000000..4cbece5 Binary files /dev/null and b/client/static/amouranth_wall.jpg differ diff --git a/client/static/candfans.png b/client/static/candfans.png new file mode 100644 index 0000000..d5b9bee Binary files /dev/null and b/client/static/candfans.png differ diff --git a/client/static/close.svg b/client/static/close.svg new file mode 100644 index 0000000..17202da --- /dev/null +++ b/client/static/close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/static/copy_cookie_fanbox.png b/client/static/copy_cookie_fanbox.png new file mode 100644 index 0000000..3283b56 Binary files /dev/null and b/client/static/copy_cookie_fanbox.png differ diff --git a/client/static/copy_cookie_patreon.png b/client/static/copy_cookie_patreon.png new file mode 100644 index 0000000..fa898ba Binary files /dev/null and b/client/static/copy_cookie_patreon.png differ diff --git a/client/static/css/compatibility.css b/client/static/css/compatibility.css new file mode 100644 index 0000000..cbaea1b --- /dev/null +++ b/client/static/css/compatibility.css @@ -0,0 +1,287 @@ +html { + background: #1d1f21; +} + +noscript { + text-align: center; +} + +h1, +h2, +h3, +p { + font-family: Helvetica; + line-height: 14px; +} + +.user-post-view img { + max-width: 100%; + border-radius: 10px; +} + +.warning { + color: rgb(255, 255, 102); +} + +.user-post-view h1, +.user-post-view h2, +.user-post-view h3, +.user-post-view p { + line-height: normal; +} + +.main { + justify-content: center; +} + +.link-reset { + text-decoration: none; + color: #fff; +} + +.tiny-link-reset { + text-decoration: underline; + color: #fff; +} + +.tiny-link-reset-warning { + text-decoration: underline; + color: rgb(255, 255, 102); +} + +.header { + display: flex; + flex-direction: row; + justify-content: center; + font-size: 14px; + min-height: 19px; + font-family: Helvetica; + font-weight: bold; +} + +.header-item { + padding-left: 5px; + padding-right: 5px; +} + +.importer-form { + width: 100%; + display: flex; + flex-direction: column; +} + +.search-input { + margin-bottom: 5px; +} + +.importer-select { + -webkit-appearance: none; + -moz-appearance: none; + background-color: rgba(0, 0, 0, 0); + color: white; + border-color: #111; + border-radius: 5px; + font-family: Helvetica; + min-height: 32px; + text-align-last: center; +} + +.importer-select:focus { + outline: none; +} + +.importer-input { + border-radius: 5px; + min-height: 32px; + background-color: #282a2e; + text-align: center; + color: #fff; + font-family: Helvetica; + font-size: 24px; + border: 0; +} + +.importer-input:focus { + outline: none; +} + +.footer { + font-size: 10px; + margin: 0 auto; + text-align: center; + font-family: Helvetica; + font-weight: bold; + color: #fff; +} + +.footer p { + margin: 0; +} + +.discord-banner { + border-radius: 5px; + min-height: 32px; + background-color: #7289da; +} + +.discord-logo { + min-height: 32px; + background-size: contain; + background-position: center; + background-image: url("https://discordapp.com/assets/e7a3b51fdac2aa5ec71975d257d5c405.png"); + background-repeat: no-repeat; +} + +.embed-view { + border-radius: 10px; + border: 1px solid #111; + margin-top: 5px; + padding: 5px; +} + +.martha-view { + max-width: 590px; + display: flex; + flex-direction: column; + margin: 0 auto; +} + +.user-header-view { + border-radius: 10px; + padding: 10px; + display: flex; +} + +.user-header-avatar { + border-radius: 10px; + background-size: cover; + background-position: center; + height: 100px; + width: 100px; +} + +.user-header-info { + margin-left: 10px; + border-radius: 10px; + padding-left: 10px; + padding-right: 10px; + background-color: rgba(0, 0, 0, 0.7); + color: #fff; +} + +.user-header-info-top { + display: flex; +} + +.user-header-info-patreon { + background-image: url(/static/patreon.svg); + margin-top: 15px; + margin-left: 10px; + width: 25px; + height: 25px; + background-size: 25px; +} + +.user-header-info-fanbox { + background-image: url(/static/fanbox.svg); + margin-top: 15px; + margin-left: 10px; + width: 50px; + height: 35px; + background-size: 50px; + background-repeat: no-repeat; +} + +.user-header-info-gumroad { + background-image: url(/static/gumroad.svg); + margin-top: 15px; + margin-left: 10px; + width: 25px; + height: 26px; + background-size: 25px; +} + +.user-header-info-subscribestar { + background-image: url(/static/subscribestar.png); + margin-top: 15px; + margin-left: 10px; + width: 30px; + height: 30px; + background-size: 30px; +} + +.user-post-view { + margin-top: 5px; + min-height: 0; + padding: 5px; + background-color: #282a2e; + border: 1px solid #111; + border-radius: 10px; + justify-content: center; + color: #fff; + font-family: Helvetica; + word-break: break-word; +} + +.load-more-button { + padding: 10px; + margin-top: 3px; + background-color: #282a2e; + border: 1px solid #111; + border-radius: 10px; + color: #fff; + font-family: Helvetica; +} + +.user-post-view p { + line-height: 20px; + word-break: break-word; +} + +.user-post-view a { + text-decoration: underline; + color: #81a2be; +} + +.user-post-image { + max-width: 100%; + border-radius: 10px; +} + +.recent-view { + min-height: 0; + padding-left: 0; + padding-right: 0; + background-color: #282a2e; + border: 1px solid #111; + justify-content: center; + color: #fff; +} + +.recent-row-container { + display: flex; +} + +.recent-row p { + margin: 0; + margin-top: 2.5px; + margin-left: 6px; + font-size: 16px; +} + +.recent-row { + align-items: center; + border-bottom: 1px solid #111; + padding-bottom: 5px; + padding-left: 5px; + padding-top: 5px; + min-height: 0.5px; +} + +.avatar { + border-radius: 4px; + background-size: cover; + background-position: center; + height: 32px; + width: 32px; +} diff --git a/client/static/css/discord.css b/client/static/css/discord.css new file mode 100644 index 0000000..6290a03 --- /dev/null +++ b/client/static/css/discord.css @@ -0,0 +1,105 @@ +/* emulate normalize.css */ +body { + overflow: hidden; + margin: 0; + padding: 0; +} + +.channel > p { + margin: 0; +} + +.discord-main { + display: flex; + flex-direction: row; + overflow: hidden; +} + +.channels { + width: 15vw; + height: 100vh; + min-width: 215px; + max-width: 300px; + overflow-y: auto; + background-color: #282a2e; +} + +.messages { + flex: 1 1 0; + height: 100vh; + overflow-y: auto; +} + +.messages > div, +.messages noscript div { + margin: 0 0 0 24px; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + color: white; + display: flex; + flex-direction: row; +} + +.messages p { + line-height: normal; + margin: auto; +} + +.message-container { + display: flex; +} + +.message-header { + display: flex; +} + +.message-header p { + margin: 0; + margin-right: 8px; +} + +.channels > div { + cursor: pointer; + padding: 10px 12px; + color: #eee; + font-size: 15px; + border-bottom: 1px solid #1d1f21; +} + +.avatar { + margin-right: 8px; + min-width: 32px; + max-height: 32px; +} + +.message a { + text-decoration: underline; + color: #81a2be; + font-family: Helvetica; +} + +.load-more-button { + cursor: pointer; +} + +.message__body { + white-space: pre-line; + margin: 0.5em 0; +} + +.user-post-image { + border-radius: 4px; +} + +.embed-view { + border-radius: 4px; +} + +.emoji { + max-width: 20px; + max-height: 20px; +} + +.channel-active { + background-color: #1d1f21; +} diff --git a/client/static/css/plyr.css b/client/static/css/plyr.css new file mode 100644 index 0000000..89f4d4e --- /dev/null +++ b/client/static/css/plyr.css @@ -0,0 +1,1331 @@ +@charset "UTF-8"; +@keyframes plyr-progress { + to { + background-position: 25px 0; + background-position: var(--plyr-progress-loading-size, 25px) 0; + } +} +@keyframes plyr-popup { + 0% { + opacity: 0.5; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +@keyframes plyr-fade-in { + 0% { + opacity: 0; + } + to { + opacity: 1; + } +} +.plyr { + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + align-items: center; + direction: ltr; + display: flex; + flex-direction: column; + font-family: inherit; + font-family: var(--plyr-font-family, inherit); + font-variant-numeric: tabular-nums; + font-weight: 400; + font-weight: var(--plyr-font-weight-regular, 400); + line-height: 1.7; + line-height: var(--plyr-line-height, 1.7); + max-width: 100%; + min-width: 200px; + position: relative; + text-shadow: none; + transition: box-shadow 0.3s ease; + z-index: 0; +} +.plyr audio, +.plyr iframe, +.plyr video { + display: block; + height: 100%; + width: 100%; +} +.plyr button { + font: inherit; + line-height: inherit; + width: auto; +} +.plyr:focus { + outline: 0; +} +.plyr--full-ui { + box-sizing: border-box; +} +.plyr--full-ui *, +.plyr--full-ui :after, +.plyr--full-ui :before { + box-sizing: inherit; +} +.plyr--full-ui a, +.plyr--full-ui button, +.plyr--full-ui input, +.plyr--full-ui label { + touch-action: manipulation; +} +.plyr__badge { + background: #4a5464; + background: var(--plyr-badge-background, #4a5464); + border-radius: 2px; + border-radius: var(--plyr-badge-border-radius, 2px); + color: #fff; + color: var(--plyr-badge-text-color, #fff); + font-size: 9px; + font-size: var(--plyr-font-size-badge, 9px); + line-height: 1; + padding: 3px 4px; +} +.plyr--full-ui ::-webkit-media-text-track-container { + display: none; +} +.plyr__captions { + animation: plyr-fade-in 0.3s ease; + bottom: 0; + display: none; + font-size: 13px; + font-size: var(--plyr-font-size-small, 13px); + left: 0; + padding: 10px; + padding: var(--plyr-control-spacing, 10px); + position: absolute; + text-align: center; + transition: transform 0.4s ease-in-out; + width: 100%; +} +.plyr__captions span:empty { + display: none; +} +@media (min-width: 480px) { + .plyr__captions { + font-size: 15px; + font-size: var(--plyr-font-size-base, 15px); + padding: 20px; + padding: calc(var(--plyr-control-spacing, 10px) * 2); + } +} +@media (min-width: 768px) { + .plyr__captions { + font-size: 18px; + font-size: var(--plyr-font-size-large, 18px); + } +} +.plyr--captions-active .plyr__captions { + display: block; +} +.plyr:not(.plyr--hide-controls) .plyr__controls:not(:empty) ~ .plyr__captions { + transform: translateY(-40px); + transform: translateY(calc(var(--plyr-control-spacing, 10px) * -4)); +} +.plyr__caption { + background: rgba(0, 0, 0, 0.8); + background: var(--plyr-captions-background, rgba(0, 0, 0, 0.8)); + border-radius: 2px; + -webkit-box-decoration-break: clone; + box-decoration-break: clone; + color: #fff; + color: var(--plyr-captions-text-color, #fff); + line-height: 185%; + padding: 0.2em 0.5em; + white-space: pre-wrap; +} +.plyr__caption div { + display: inline; +} +.plyr__control { + background: 0 0; + border: 0; + border-radius: 3px; + border-radius: var(--plyr-control-radius, 3px); + color: inherit; + cursor: pointer; + flex-shrink: 0; + overflow: visible; + padding: 7px; + padding: calc(var(--plyr-control-spacing, 10px) * 0.7); + position: relative; + transition: all 0.3s ease; +} +.plyr__control svg { + fill: currentColor; + display: block; + height: 18px; + height: var(--plyr-control-icon-size, 18px); + pointer-events: none; + width: 18px; + width: var(--plyr-control-icon-size, 18px); +} +.plyr__control:focus { + outline: 0; +} +.plyr__control.plyr__tab-focus { + outline: 3px dotted #00b2ff; + outline: var(--plyr-tab-focus-color, var(--plyr-color-main, var(--plyr-color-main, #00b2ff))) dotted 3px; + outline-offset: 2px; +} +a.plyr__control { + text-decoration: none; +} +.plyr__control.plyr__control--pressed .icon--not-pressed, +.plyr__control.plyr__control--pressed .label--not-pressed, +.plyr__control:not(.plyr__control--pressed) .icon--pressed, +.plyr__control:not(.plyr__control--pressed) .label--pressed, +a.plyr__control:after, +a.plyr__control:before { + display: none; +} +.plyr--full-ui ::-webkit-media-controls { + display: none; +} +.plyr__controls { + align-items: center; + display: flex; + justify-content: flex-end; + text-align: center; +} +.plyr__controls .plyr__progress__container { + flex: 1; + min-width: 0; +} +.plyr__controls .plyr__controls__item { + margin-left: 2.5px; + margin-left: calc(var(--plyr-control-spacing, 10px) / 4); +} +.plyr__controls .plyr__controls__item:first-child { + margin-left: 0; + margin-right: auto; +} +.plyr__controls .plyr__controls__item.plyr__progress__container { + padding-left: 2.5px; + padding-left: calc(var(--plyr-control-spacing, 10px) / 4); +} +.plyr__controls .plyr__controls__item.plyr__time { + padding: 0 5px; + padding: 0 calc(var(--plyr-control-spacing, 10px) / 2); +} +.plyr__controls .plyr__controls__item.plyr__progress__container:first-child, +.plyr__controls .plyr__controls__item.plyr__time + .plyr__time, +.plyr__controls .plyr__controls__item.plyr__time:first-child { + padding-left: 0; +} +.plyr [data-plyr="airplay"], +.plyr [data-plyr="captions"], +.plyr [data-plyr="fullscreen"], +.plyr [data-plyr="pip"], +.plyr__controls:empty { + display: none; +} +.plyr--airplay-supported [data-plyr="airplay"], +.plyr--captions-enabled [data-plyr="captions"], +.plyr--fullscreen-enabled [data-plyr="fullscreen"], +.plyr--pip-supported [data-plyr="pip"] { + display: inline-block; +} +.plyr__menu { + display: flex; + position: relative; +} +.plyr__menu .plyr__control svg { + transition: transform 0.3s ease; +} +.plyr__menu .plyr__control[aria-expanded="true"] svg { + transform: rotate(90deg); +} +.plyr__menu .plyr__control[aria-expanded="true"] .plyr__tooltip { + display: none; +} +.plyr__menu__container { + animation: plyr-popup 0.2s ease; + background: hsla(0, 0%, 100%, 0.9); + background: var(--plyr-menu-background, hsla(0, 0%, 100%, 0.9)); + border-radius: 4px; + bottom: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + box-shadow: var(--plyr-menu-shadow, 0 1px 2px rgba(0, 0, 0, 0.15)); + color: #4a5464; + color: var(--plyr-menu-color, #4a5464); + font-size: 15px; + font-size: var(--plyr-font-size-base, 15px); + margin-bottom: 10px; + position: absolute; + right: -3px; + text-align: left; + white-space: nowrap; + z-index: 3; +} +.plyr__menu__container > div { + overflow: hidden; + transition: + height 0.35s cubic-bezier(0.4, 0, 0.2, 1), + width 0.35s cubic-bezier(0.4, 0, 0.2, 1); +} +.plyr__menu__container:after { + border: 4px solid transparent; + border-top-color: hsla(0, 0%, 100%, 0.9); + border: var(--plyr-menu-arrow-size, 4px) solid transparent; + border-top-color: var(--plyr-menu-background, hsla(0, 0%, 100%, 0.9)); + content: ""; + height: 0; + position: absolute; + right: 14px; + right: calc( + var(--plyr-control-icon-size, 18px) / 2 + var(--plyr-control-spacing, 10px) * 0.7 - var(--plyr-menu-arrow-size, 4px) / + 2 + ); + top: 100%; + width: 0; +} +.plyr__menu__container [role="menu"] { + padding: 7px; + padding: calc(var(--plyr-control-spacing, 10px) * 0.7); +} +.plyr__menu__container [role="menuitem"], +.plyr__menu__container [role="menuitemradio"] { + margin-top: 2px; +} +.plyr__menu__container [role="menuitem"]:first-child, +.plyr__menu__container [role="menuitemradio"]:first-child { + margin-top: 0; +} +.plyr__menu__container .plyr__control { + align-items: center; + color: #4a5464; + color: var(--plyr-menu-color, #4a5464); + display: flex; + font-size: 13px; + font-size: var(--plyr-font-size-menu, var(--plyr-font-size-small, 13px)); + padding: 4.66667px 10.5px; + padding: calc(var(--plyr-control-spacing, 10px) * 0.7 / 1.5) calc(var(--plyr-control-spacing, 10px) * 0.7 * 1.5); + -webkit-user-select: none; + user-select: none; + width: 100%; +} +.plyr__menu__container .plyr__control > span { + align-items: inherit; + display: flex; + width: 100%; +} +.plyr__menu__container .plyr__control:after { + border: 4px solid transparent; + border: var(--plyr-menu-item-arrow-size, 4px) solid transparent; + content: ""; + position: absolute; + top: 50%; + transform: translateY(-50%); +} +.plyr__menu__container .plyr__control--forward { + padding-right: 28px; + padding-right: calc(var(--plyr-control-spacing, 10px) * 0.7 * 4); +} +.plyr__menu__container .plyr__control--forward:after { + border-left-color: #728197; + border-left-color: var(--plyr-menu-arrow-color, #728197); + right: 6.5px; + right: calc(var(--plyr-control-spacing, 10px) * 0.7 * 1.5 - var(--plyr-menu-item-arrow-size, 4px)); +} +.plyr__menu__container .plyr__control--forward.plyr__tab-focus:after, +.plyr__menu__container .plyr__control--forward:hover:after { + border-left-color: currentColor; +} +.plyr__menu__container .plyr__control--back { + font-weight: 400; + font-weight: var(--plyr-font-weight-regular, 400); + margin: 7px; + margin: calc(var(--plyr-control-spacing, 10px) * 0.7); + margin-bottom: 3.5px; + margin-bottom: calc(var(--plyr-control-spacing, 10px) * 0.7 / 2); + padding-left: 28px; + padding-left: calc(var(--plyr-control-spacing, 10px) * 0.7 * 4); + position: relative; + width: calc(100% - 14px); + width: calc(100% - var(--plyr-control-spacing, 10px) * 0.7 * 2); +} +.plyr__menu__container .plyr__control--back:after { + border-right-color: #728197; + border-right-color: var(--plyr-menu-arrow-color, #728197); + left: 6.5px; + left: calc(var(--plyr-control-spacing, 10px) * 0.7 * 1.5 - var(--plyr-menu-item-arrow-size, 4px)); +} +.plyr__menu__container .plyr__control--back:before { + background: #dcdfe5; + background: var(--plyr-menu-back-border-color, #dcdfe5); + box-shadow: 0 1px 0 #fff; + box-shadow: 0 1px 0 var(--plyr-menu-back-border-shadow-color, #fff); + content: ""; + height: 1px; + left: 0; + margin-top: 3.5px; + margin-top: calc(var(--plyr-control-spacing, 10px) * 0.7 / 2); + overflow: hidden; + position: absolute; + right: 0; + top: 100%; +} +.plyr__menu__container .plyr__control--back.plyr__tab-focus:after, +.plyr__menu__container .plyr__control--back:hover:after { + border-right-color: currentColor; +} +.plyr__menu__container .plyr__control[role="menuitemradio"] { + padding-left: 7px; + padding-left: calc(var(--plyr-control-spacing, 10px) * 0.7); +} +.plyr__menu__container .plyr__control[role="menuitemradio"]:after, +.plyr__menu__container .plyr__control[role="menuitemradio"]:before { + border-radius: 100%; +} +.plyr__menu__container .plyr__control[role="menuitemradio"]:before { + background: rgba(0, 0, 0, 0.1); + content: ""; + display: block; + flex-shrink: 0; + height: 16px; + margin-right: 10px; + margin-right: var(--plyr-control-spacing, 10px); + transition: all 0.3s ease; + width: 16px; +} +.plyr__menu__container .plyr__control[role="menuitemradio"]:after { + background: #fff; + border: 0; + height: 6px; + left: 12px; + opacity: 0; + top: 50%; + transform: translateY(-50%) scale(0); + transition: + transform 0.3s ease, + opacity 0.3s ease; + width: 6px; +} +.plyr__menu__container .plyr__control[role="menuitemradio"][aria-checked="true"]:before { + background: #00b2ff; + background: var(--plyr-control-toggle-checked-background, var(--plyr-color-main, var(--plyr-color-main, #00b2ff))); +} +.plyr__menu__container .plyr__control[role="menuitemradio"][aria-checked="true"]:after { + opacity: 1; + transform: translateY(-50%) scale(1); +} +.plyr__menu__container .plyr__control[role="menuitemradio"].plyr__tab-focus:before, +.plyr__menu__container .plyr__control[role="menuitemradio"]:hover:before { + background: rgba(35, 40, 47, 0.1); +} +.plyr__menu__container .plyr__menu__value { + align-items: center; + display: flex; + margin-left: auto; + margin-right: calc(-7px - -2); + margin-right: calc(var(--plyr-control-spacing, 10px) * 0.7 * -1 - -2); + overflow: hidden; + padding-left: 24.5px; + padding-left: calc(var(--plyr-control-spacing, 10px) * 0.7 * 3.5); + pointer-events: none; +} +.plyr--full-ui input[type="range"] { + -webkit-appearance: none; + appearance: none; + background: 0 0; + border: 0; + border-radius: 26px; + border-radius: calc(var(--plyr-range-thumb-height, 13px) * 2); + color: #00b2ff; + color: var(--plyr-range-fill-background, var(--plyr-color-main, var(--plyr-color-main, #00b2ff))); + display: block; + height: 19px; + height: calc(var(--plyr-range-thumb-active-shadow-width, 3px) * 2 + var(--plyr-range-thumb-height, 13px)); + margin: 0; + min-width: 0; + padding: 0; + transition: box-shadow 0.3s ease; + width: 100%; +} +.plyr--full-ui input[type="range"]::-webkit-slider-runnable-track { + background: 0 0; + background-image: linear-gradient(90deg, currentColor 0, transparent 0); + background-image: linear-gradient(to right, currentColor var(--value, 0), transparent var(--value, 0)); + border: 0; + border-radius: 2.5px; + border-radius: calc(var(--plyr-range-track-height, 5px) / 2); + height: 5px; + height: var(--plyr-range-track-height, 5px); + -webkit-transition: box-shadow 0.3s ease; + transition: box-shadow 0.3s ease; + -webkit-user-select: none; + user-select: none; +} +.plyr--full-ui input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background: #fff; + background: var(--plyr-range-thumb-background, #fff); + border: 0; + border-radius: 100%; + box-shadow: + 0 1px 1px rgba(35, 40, 47, 0.15), + 0 0 0 1px rgba(35, 40, 47, 0.2); + box-shadow: var(--plyr-range-thumb-shadow, 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2)); + height: 13px; + height: var(--plyr-range-thumb-height, 13px); + margin-top: -4px; + margin-top: calc((var(--plyr-range-thumb-height, 13px) - var(--plyr-range-track-height, 5px)) / 2 * -1); + position: relative; + -webkit-transition: all 0.2s ease; + transition: all 0.2s ease; + width: 13px; + width: var(--plyr-range-thumb-height, 13px); +} +.plyr--full-ui input[type="range"]::-moz-range-track { + background: 0 0; + border: 0; + border-radius: 2.5px; + border-radius: calc(var(--plyr-range-track-height, 5px) / 2); + height: 5px; + height: var(--plyr-range-track-height, 5px); + -moz-transition: box-shadow 0.3s ease; + transition: box-shadow 0.3s ease; + user-select: none; +} +.plyr--full-ui input[type="range"]::-moz-range-thumb { + background: #fff; + background: var(--plyr-range-thumb-background, #fff); + border: 0; + border-radius: 100%; + box-shadow: + 0 1px 1px rgba(35, 40, 47, 0.15), + 0 0 0 1px rgba(35, 40, 47, 0.2); + box-shadow: var(--plyr-range-thumb-shadow, 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2)); + height: 13px; + height: var(--plyr-range-thumb-height, 13px); + position: relative; + -moz-transition: all 0.2s ease; + transition: all 0.2s ease; + width: 13px; + width: var(--plyr-range-thumb-height, 13px); +} +.plyr--full-ui input[type="range"]::-moz-range-progress { + background: currentColor; + border-radius: 2.5px; + border-radius: calc(var(--plyr-range-track-height, 5px) / 2); + height: 5px; + height: var(--plyr-range-track-height, 5px); +} +.plyr--full-ui input[type="range"]::-ms-track { + color: transparent; +} +.plyr--full-ui input[type="range"]::-ms-fill-upper, +.plyr--full-ui input[type="range"]::-ms-track { + background: 0 0; + border: 0; + border-radius: 2.5px; + border-radius: calc(var(--plyr-range-track-height, 5px) / 2); + height: 5px; + height: var(--plyr-range-track-height, 5px); + -ms-transition: box-shadow 0.3s ease; + transition: box-shadow 0.3s ease; + user-select: none; +} +.plyr--full-ui input[type="range"]::-ms-fill-lower { + background: 0 0; + background: currentColor; + border: 0; + border-radius: 2.5px; + border-radius: calc(var(--plyr-range-track-height, 5px) / 2); + height: 5px; + height: var(--plyr-range-track-height, 5px); + -ms-transition: box-shadow 0.3s ease; + transition: box-shadow 0.3s ease; + user-select: none; +} +.plyr--full-ui input[type="range"]::-ms-thumb { + background: #fff; + background: var(--plyr-range-thumb-background, #fff); + border: 0; + border-radius: 100%; + box-shadow: + 0 1px 1px rgba(35, 40, 47, 0.15), + 0 0 0 1px rgba(35, 40, 47, 0.2); + box-shadow: var(--plyr-range-thumb-shadow, 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2)); + height: 13px; + height: var(--plyr-range-thumb-height, 13px); + margin-top: 0; + position: relative; + -ms-transition: all 0.2s ease; + transition: all 0.2s ease; + width: 13px; + width: var(--plyr-range-thumb-height, 13px); +} +.plyr--full-ui input[type="range"]::-ms-tooltip { + display: none; +} +.plyr--full-ui input[type="range"]::-moz-focus-outer { + border: 0; +} +.plyr--full-ui input[type="range"]:focus { + outline: 0; +} +.plyr--full-ui input[type="range"].plyr__tab-focus::-webkit-slider-runnable-track { + outline: 3px dotted #00b2ff; + outline: var(--plyr-tab-focus-color, var(--plyr-color-main, var(--plyr-color-main, #00b2ff))) dotted 3px; + outline-offset: 2px; +} +.plyr--full-ui input[type="range"].plyr__tab-focus::-moz-range-track { + outline: 3px dotted #00b2ff; + outline: var(--plyr-tab-focus-color, var(--plyr-color-main, var(--plyr-color-main, #00b2ff))) dotted 3px; + outline-offset: 2px; +} +.plyr--full-ui input[type="range"].plyr__tab-focus::-ms-track { + outline: 3px dotted #00b2ff; + outline: var(--plyr-tab-focus-color, var(--plyr-color-main, var(--plyr-color-main, #00b2ff))) dotted 3px; + outline-offset: 2px; +} +.plyr__poster { + background-color: #000; + background-color: var(--plyr-video-background, var(--plyr-video-background, #000)); + background-position: 50% 50%; + background-repeat: no-repeat; + background-size: contain; + height: 100%; + left: 0; + opacity: 0; + position: absolute; + top: 0; + transition: opacity 0.2s ease; + width: 100%; + z-index: 1; +} +.plyr--stopped.plyr__poster-enabled .plyr__poster { + opacity: 1; +} +.plyr--youtube.plyr--paused.plyr__poster-enabled:not(.plyr--stopped) .plyr__poster { + display: none; +} +.plyr__time { + font-size: 13px; + font-size: var(--plyr-font-size-time, var(--plyr-font-size-small, 13px)); +} +.plyr__time + .plyr__time:before { + content: "⁄"; + margin-right: 10px; + margin-right: var(--plyr-control-spacing, 10px); +} +@media (max-width: 767px) { + .plyr__time + .plyr__time { + display: none; + } +} +.plyr__tooltip { + background: hsla(0, 0%, 100%, 0.9); + background: var(--plyr-tooltip-background, hsla(0, 0%, 100%, 0.9)); + border-radius: 5px; + border-radius: var(--plyr-tooltip-radius, 5px); + bottom: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + box-shadow: var(--plyr-tooltip-shadow, 0 1px 2px rgba(0, 0, 0, 0.15)); + color: #4a5464; + color: var(--plyr-tooltip-color, #4a5464); + font-size: 13px; + font-size: var(--plyr-font-size-small, 13px); + font-weight: 400; + font-weight: var(--plyr-font-weight-regular, 400); + left: 50%; + line-height: 1.3; + margin-bottom: 10px; + margin-bottom: calc(var(--plyr-control-spacing, 10px) / 2 * 2); + opacity: 0; + padding: 5px 7.5px; + padding: calc(var(--plyr-control-spacing, 10px) / 2) calc(var(--plyr-control-spacing, 10px) / 2 * 1.5); + pointer-events: none; + position: absolute; + transform: translate(-50%, 10px) scale(0.8); + transform-origin: 50% 100%; + transition: + transform 0.2s ease 0.1s, + opacity 0.2s ease 0.1s; + white-space: nowrap; + z-index: 2; +} +.plyr__tooltip:before { + border-left: 4px solid transparent; + border-left: var(--plyr-tooltip-arrow-size, 4px) solid transparent; + border-right: 4px solid transparent; + border-right: var(--plyr-tooltip-arrow-size, 4px) solid transparent; + border-top: 4px solid hsla(0, 0%, 100%, 0.9); + border-top: var(--plyr-tooltip-arrow-size, 4px) solid var(--plyr-tooltip-background, hsla(0, 0%, 100%, 0.9)); + bottom: -4px; + bottom: calc(var(--plyr-tooltip-arrow-size, 4px) * -1); + content: ""; + height: 0; + left: 50%; + position: absolute; + transform: translateX(-50%); + width: 0; + z-index: 2; +} +.plyr .plyr__control.plyr__tab-focus .plyr__tooltip, +.plyr .plyr__control:hover .plyr__tooltip, +.plyr__tooltip--visible { + opacity: 1; + transform: translate(-50%) scale(1); +} +.plyr .plyr__control:hover .plyr__tooltip { + z-index: 3; +} +.plyr__controls > .plyr__control:first-child .plyr__tooltip, +.plyr__controls > .plyr__control:first-child + .plyr__control .plyr__tooltip { + left: 0; + transform: translateY(10px) scale(0.8); + transform-origin: 0 100%; +} +.plyr__controls > .plyr__control:first-child .plyr__tooltip:before, +.plyr__controls > .plyr__control:first-child + .plyr__control .plyr__tooltip:before { + left: 16px; + left: calc(var(--plyr-control-icon-size, 18px) / 2 + var(--plyr-control-spacing, 10px) * 0.7); +} +.plyr__controls > .plyr__control:last-child .plyr__tooltip { + left: auto; + right: 0; + transform: translateY(10px) scale(0.8); + transform-origin: 100% 100%; +} +.plyr__controls > .plyr__control:last-child .plyr__tooltip:before { + left: auto; + right: 16px; + right: calc(var(--plyr-control-icon-size, 18px) / 2 + var(--plyr-control-spacing, 10px) * 0.7); + transform: translateX(50%); +} +.plyr__controls > .plyr__control:first-child .plyr__tooltip--visible, +.plyr__controls > .plyr__control:first-child + .plyr__control .plyr__tooltip--visible, +.plyr__controls > .plyr__control:first-child + .plyr__control.plyr__tab-focus .plyr__tooltip, +.plyr__controls > .plyr__control:first-child + .plyr__control:hover .plyr__tooltip, +.plyr__controls > .plyr__control:first-child.plyr__tab-focus .plyr__tooltip, +.plyr__controls > .plyr__control:first-child:hover .plyr__tooltip, +.plyr__controls > .plyr__control:last-child .plyr__tooltip--visible, +.plyr__controls > .plyr__control:last-child.plyr__tab-focus .plyr__tooltip, +.plyr__controls > .plyr__control:last-child:hover .plyr__tooltip { + transform: translate(0) scale(1); +} +.plyr__progress { + left: 6.5px; + left: calc(var(--plyr-range-thumb-height, 13px) * 0.5); + margin-right: 13px; + margin-right: var(--plyr-range-thumb-height, 13px); + position: relative; +} +.plyr__progress input[type="range"], +.plyr__progress__buffer { + margin-left: -6.5px; + margin-left: calc(var(--plyr-range-thumb-height, 13px) * -0.5); + margin-right: -6.5px; + margin-right: calc(var(--plyr-range-thumb-height, 13px) * -0.5); + width: calc(100% + 13px); + width: calc(100% + var(--plyr-range-thumb-height, 13px)); +} +.plyr__progress input[type="range"] { + position: relative; + z-index: 2; +} +.plyr__progress .plyr__tooltip { + left: 0; + max-width: 120px; + overflow-wrap: break-word; + white-space: normal; +} +.plyr__progress__buffer { + -webkit-appearance: none; + background: 0 0; + border: 0; + border-radius: 100px; + height: 5px; + height: var(--plyr-range-track-height, 5px); + left: 0; + margin-top: -2.5px; + margin-top: calc((var(--plyr-range-track-height, 5px) / 2) * -1); + padding: 0; + position: absolute; + top: 50%; +} +.plyr__progress__buffer::-webkit-progress-bar { + background: 0 0; +} +.plyr__progress__buffer::-webkit-progress-value { + background: currentColor; + border-radius: 100px; + min-width: 5px; + min-width: var(--plyr-range-track-height, 5px); + -webkit-transition: width 0.2s ease; + transition: width 0.2s ease; +} +.plyr__progress__buffer::-moz-progress-bar { + background: currentColor; + border-radius: 100px; + min-width: 5px; + min-width: var(--plyr-range-track-height, 5px); + -moz-transition: width 0.2s ease; + transition: width 0.2s ease; +} +.plyr__progress__buffer::-ms-fill { + border-radius: 100px; + -ms-transition: width 0.2s ease; + transition: width 0.2s ease; +} +.plyr--loading .plyr__progress__buffer { + animation: plyr-progress 1s linear infinite; + background-image: linear-gradient( + -45deg, + rgba(35, 40, 47, 0.6) 25%, + transparent 0, + transparent 50%, + rgba(35, 40, 47, 0.6) 0, + rgba(35, 40, 47, 0.6) 75%, + transparent 0, + transparent + ); + background-image: linear-gradient( + -45deg, + var(--plyr-progress-loading-background, rgba(35, 40, 47, 0.6)) 25%, + transparent 25%, + transparent 50%, + var(--plyr-progress-loading-background, rgba(35, 40, 47, 0.6)) 50%, + var(--plyr-progress-loading-background, rgba(35, 40, 47, 0.6)) 75%, + transparent 75%, + transparent + ); + background-repeat: repeat-x; + background-size: 25px 25px; + background-size: var(--plyr-progress-loading-size, 25px) var(--plyr-progress-loading-size, 25px); + color: transparent; +} +.plyr--video.plyr--loading .plyr__progress__buffer { + background-color: hsla(0, 0%, 100%, 0.25); + background-color: var(--plyr-video-progress-buffered-background, hsla(0, 0%, 100%, 0.25)); +} +.plyr--audio.plyr--loading .plyr__progress__buffer { + background-color: rgba(193, 200, 209, 0.6); + background-color: var(--plyr-audio-progress-buffered-background, rgba(193, 200, 209, 0.6)); +} +.plyr__progress__marker { + background-color: #fff; + background-color: var(--plyr-progress-marker-background, #fff); + border-radius: 1px; + height: 5px; + height: var(--plyr-range-track-height, 5px); + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + width: 3px; + width: var(--plyr-progress-marker-width, 3px); + z-index: 3; +} +.plyr__volume { + align-items: center; + display: flex; + max-width: 110px; + min-width: 80px; + position: relative; + width: 20%; +} +.plyr__volume input[type="range"] { + margin-left: 5px; + margin-left: calc(var(--plyr-control-spacing, 10px) / 2); + margin-right: 5px; + margin-right: calc(var(--plyr-control-spacing, 10px) / 2); + position: relative; + z-index: 2; +} +.plyr--is-ios .plyr__volume { + min-width: 0; + width: auto; +} +.plyr--audio { + display: block; +} +.plyr--audio .plyr__controls { + background: #fff; + background: var(--plyr-audio-controls-background, #fff); + border-radius: inherit; + color: #4a5464; + color: var(--plyr-audio-control-color, #4a5464); + padding: 10px; + padding: var(--plyr-control-spacing, 10px); +} +.plyr--audio .plyr__control.plyr__tab-focus, +.plyr--audio .plyr__control:hover, +.plyr--audio .plyr__control[aria-expanded="true"] { + background: #00b2ff; + background: var(--plyr-audio-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #00b2ff))); + color: #fff; + color: var(--plyr-audio-control-color-hover, #fff); +} +.plyr--full-ui.plyr--audio input[type="range"]::-webkit-slider-runnable-track { + background-color: rgba(193, 200, 209, 0.6); + background-color: var( + --plyr-audio-range-track-background, + var(--plyr-audio-progress-buffered-background, rgba(193, 200, 209, 0.6)) + ); +} +.plyr--full-ui.plyr--audio input[type="range"]::-moz-range-track { + background-color: rgba(193, 200, 209, 0.6); + background-color: var( + --plyr-audio-range-track-background, + var(--plyr-audio-progress-buffered-background, rgba(193, 200, 209, 0.6)) + ); +} +.plyr--full-ui.plyr--audio input[type="range"]::-ms-track { + background-color: rgba(193, 200, 209, 0.6); + background-color: var( + --plyr-audio-range-track-background, + var(--plyr-audio-progress-buffered-background, rgba(193, 200, 209, 0.6)) + ); +} +.plyr--full-ui.plyr--audio input[type="range"]:active::-webkit-slider-thumb { + box-shadow: + 0 1px 1px rgba(35, 40, 47, 0.15), + 0 0 0 1px rgba(35, 40, 47, 0.2), + 0 0 0 3px rgba(35, 40, 47, 0.1); + box-shadow: + var(--plyr-range-thumb-shadow, 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2)), + 0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px) + var(--plyr-audio-range-thumb-active-shadow-color, rgba(35, 40, 47, 0.1)); +} +.plyr--full-ui.plyr--audio input[type="range"]:active::-moz-range-thumb { + box-shadow: + 0 1px 1px rgba(35, 40, 47, 0.15), + 0 0 0 1px rgba(35, 40, 47, 0.2), + 0 0 0 3px rgba(35, 40, 47, 0.1); + box-shadow: + var(--plyr-range-thumb-shadow, 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2)), + 0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px) + var(--plyr-audio-range-thumb-active-shadow-color, rgba(35, 40, 47, 0.1)); +} +.plyr--full-ui.plyr--audio input[type="range"]:active::-ms-thumb { + box-shadow: + 0 1px 1px rgba(35, 40, 47, 0.15), + 0 0 0 1px rgba(35, 40, 47, 0.2), + 0 0 0 3px rgba(35, 40, 47, 0.1); + box-shadow: + var(--plyr-range-thumb-shadow, 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2)), + 0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px) + var(--plyr-audio-range-thumb-active-shadow-color, rgba(35, 40, 47, 0.1)); +} +.plyr--audio .plyr__progress__buffer { + color: rgba(193, 200, 209, 0.6); + color: var(--plyr-audio-progress-buffered-background, rgba(193, 200, 209, 0.6)); +} +.plyr--video { + background: #000; + background: var(--plyr-video-background, var(--plyr-video-background, #000)); + overflow: hidden; +} +.plyr--video.plyr--menu-open { + overflow: visible; +} +.plyr__video-wrapper { + background: #000; + background: var(--plyr-video-background, var(--plyr-video-background, #000)); + height: 100%; + margin: auto; + overflow: hidden; + position: relative; + width: 100%; +} +.plyr__video-embed, +.plyr__video-wrapper--fixed-ratio { + aspect-ratio: 16/9; +} +@supports not (aspect-ratio: 16/9) { + .plyr__video-embed, + .plyr__video-wrapper--fixed-ratio { + height: 0; + padding-bottom: 56.25%; + position: relative; + } +} +.plyr__video-embed iframe, +.plyr__video-wrapper--fixed-ratio video { + border: 0; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.plyr--full-ui .plyr__video-embed > .plyr__video-embed__container { + padding-bottom: 240%; + position: relative; + transform: translateY(-38.28125%); +} +.plyr--video .plyr__controls { + background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); + background: var(--plyr-video-controls-background, linear-gradient(transparent, rgba(0, 0, 0, 0.75))); + border-bottom-left-radius: inherit; + border-bottom-right-radius: inherit; + bottom: 0; + color: #fff; + color: var(--plyr-video-control-color, #fff); + left: 0; + padding: 5px; + padding: calc(var(--plyr-control-spacing, 10px) / 2); + padding-top: 20px; + padding-top: calc(var(--plyr-control-spacing, 10px) * 2); + position: absolute; + right: 0; + transition: + opacity 0.4s ease-in-out, + transform 0.4s ease-in-out; + z-index: 3; +} +@media (min-width: 480px) { + .plyr--video .plyr__controls { + padding: 10px; + padding: var(--plyr-control-spacing, 10px); + padding-top: 35px; + padding-top: calc(var(--plyr-control-spacing, 10px) * 3.5); + } +} +.plyr--video.plyr--hide-controls .plyr__controls { + opacity: 0; + pointer-events: none; + transform: translateY(100%); +} +.plyr--video .plyr__control.plyr__tab-focus, +.plyr--video .plyr__control:hover, +.plyr--video .plyr__control[aria-expanded="true"] { + background: #00b2ff; + background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #00b2ff))); + color: #fff; + color: var(--plyr-video-control-color-hover, #fff); +} +.plyr__control--overlaid { + background: #00b2ff; + background: var(--plyr-video-control-background-hover, var(--plyr-color-main, var(--plyr-color-main, #00b2ff))); + border: 0; + border-radius: 100%; + color: #fff; + color: var(--plyr-video-control-color, #fff); + display: none; + left: 50%; + opacity: 0.9; + padding: 15px; + padding: calc(var(--plyr-control-spacing, 10px) * 1.5); + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + transition: 0.3s; + z-index: 2; +} +.plyr__control--overlaid svg { + left: 2px; + position: relative; +} +.plyr__control--overlaid:focus, +.plyr__control--overlaid:hover { + opacity: 1; +} +.plyr--playing .plyr__control--overlaid { + opacity: 0; + visibility: hidden; +} +.plyr--full-ui.plyr--video .plyr__control--overlaid { + display: block; +} +.plyr--full-ui.plyr--video input[type="range"]::-webkit-slider-runnable-track { + background-color: hsla(0, 0%, 100%, 0.25); + background-color: var( + --plyr-video-range-track-background, + var(--plyr-video-progress-buffered-background, hsla(0, 0%, 100%, 0.25)) + ); +} +.plyr--full-ui.plyr--video input[type="range"]::-moz-range-track { + background-color: hsla(0, 0%, 100%, 0.25); + background-color: var( + --plyr-video-range-track-background, + var(--plyr-video-progress-buffered-background, hsla(0, 0%, 100%, 0.25)) + ); +} +.plyr--full-ui.plyr--video input[type="range"]::-ms-track { + background-color: hsla(0, 0%, 100%, 0.25); + background-color: var( + --plyr-video-range-track-background, + var(--plyr-video-progress-buffered-background, hsla(0, 0%, 100%, 0.25)) + ); +} +.plyr--full-ui.plyr--video input[type="range"]:active::-webkit-slider-thumb { + box-shadow: + 0 1px 1px rgba(35, 40, 47, 0.15), + 0 0 0 1px rgba(35, 40, 47, 0.2), + 0 0 0 3px hsla(0, 0%, 100%, 0.5); + box-shadow: + var(--plyr-range-thumb-shadow, 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2)), + 0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px) + var(--plyr-audio-range-thumb-active-shadow-color, hsla(0, 0%, 100%, 0.5)); +} +.plyr--full-ui.plyr--video input[type="range"]:active::-moz-range-thumb { + box-shadow: + 0 1px 1px rgba(35, 40, 47, 0.15), + 0 0 0 1px rgba(35, 40, 47, 0.2), + 0 0 0 3px hsla(0, 0%, 100%, 0.5); + box-shadow: + var(--plyr-range-thumb-shadow, 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2)), + 0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px) + var(--plyr-audio-range-thumb-active-shadow-color, hsla(0, 0%, 100%, 0.5)); +} +.plyr--full-ui.plyr--video input[type="range"]:active::-ms-thumb { + box-shadow: + 0 1px 1px rgba(35, 40, 47, 0.15), + 0 0 0 1px rgba(35, 40, 47, 0.2), + 0 0 0 3px hsla(0, 0%, 100%, 0.5); + box-shadow: + var(--plyr-range-thumb-shadow, 0 1px 1px rgba(35, 40, 47, 0.15), 0 0 0 1px rgba(35, 40, 47, 0.2)), + 0 0 0 var(--plyr-range-thumb-active-shadow-width, 3px) + var(--plyr-audio-range-thumb-active-shadow-color, hsla(0, 0%, 100%, 0.5)); +} +.plyr--video .plyr__progress__buffer { + color: hsla(0, 0%, 100%, 0.25); + color: var(--plyr-video-progress-buffered-background, hsla(0, 0%, 100%, 0.25)); +} +.plyr:fullscreen { + background: #000; + border-radius: 0 !important; + height: 100%; + margin: 0; + width: 100%; +} +.plyr:fullscreen video { + height: 100%; +} +.plyr:fullscreen .plyr__control .icon--exit-fullscreen { + display: block; +} +.plyr:fullscreen .plyr__control .icon--exit-fullscreen + svg { + display: none; +} +.plyr:fullscreen.plyr--hide-controls { + cursor: none; +} +@media (min-width: 1024px) { + .plyr:fullscreen .plyr__captions { + font-size: 21px; + font-size: var(--plyr-font-size-xlarge, 21px); + } +} +.plyr--fullscreen-fallback { + background: #000; + border-radius: 0 !important; + bottom: 0; + display: block; + height: 100%; + left: 0; + margin: 0; + position: fixed; + right: 0; + top: 0; + width: 100%; + z-index: 10000000; +} +.plyr--fullscreen-fallback video { + height: 100%; +} +.plyr--fullscreen-fallback .plyr__control .icon--exit-fullscreen { + display: block; +} +.plyr--fullscreen-fallback .plyr__control .icon--exit-fullscreen + svg { + display: none; +} +.plyr--fullscreen-fallback.plyr--hide-controls { + cursor: none; +} +@media (min-width: 1024px) { + .plyr--fullscreen-fallback .plyr__captions { + font-size: 21px; + font-size: var(--plyr-font-size-xlarge, 21px); + } +} +.plyr__ads { + border-radius: inherit; + bottom: 0; + cursor: pointer; + left: 0; + overflow: hidden; + position: absolute; + right: 0; + top: 0; + z-index: -1; +} +.plyr__ads > div, +.plyr__ads > div iframe { + height: 100%; + position: absolute; + width: 100%; +} +.plyr__ads:after { + background: #23282f; + border-radius: 2px; + bottom: 10px; + bottom: var(--plyr-control-spacing, 10px); + color: #fff; + content: attr(data-badge-text); + font-size: 11px; + padding: 2px 6px; + pointer-events: none; + position: absolute; + right: 10px; + right: var(--plyr-control-spacing, 10px); + z-index: 3; +} +.plyr__ads:empty:after { + display: none; +} +.plyr__cues { + background: currentColor; + display: block; + height: 5px; + height: var(--plyr-range-track-height, 5px); + left: 0; + opacity: 0.8; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 3px; + z-index: 3; +} +.plyr__preview-thumb { + background-color: hsla(0, 0%, 100%, 0.9); + background-color: var(--plyr-tooltip-background, hsla(0, 0%, 100%, 0.9)); + border-radius: 5px; + border-radius: var(--plyr-tooltip-radius, 5px); + bottom: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + box-shadow: var(--plyr-tooltip-shadow, 0 1px 2px rgba(0, 0, 0, 0.15)); + margin-bottom: 10px; + margin-bottom: calc(var(--plyr-control-spacing, 10px) / 2 * 2); + opacity: 0; + padding: 3px; + pointer-events: none; + position: absolute; + transform: translateY(10px) scale(0.8); + transform-origin: 50% 100%; + transition: + transform 0.2s ease 0.1s, + opacity 0.2s ease 0.1s; + z-index: 2; +} +.plyr__preview-thumb--is-shown { + opacity: 1; + transform: translate(0) scale(1); +} +.plyr__preview-thumb:before { + border-left: 4px solid transparent; + border-left: var(--plyr-tooltip-arrow-size, 4px) solid transparent; + border-right: 4px solid transparent; + border-right: var(--plyr-tooltip-arrow-size, 4px) solid transparent; + border-top: 4px solid hsla(0, 0%, 100%, 0.9); + border-top: var(--plyr-tooltip-arrow-size, 4px) solid var(--plyr-tooltip-background, hsla(0, 0%, 100%, 0.9)); + bottom: -4px; + bottom: calc(var(--plyr-tooltip-arrow-size, 4px) * -1); + content: ""; + height: 0; + left: calc(50% + var(--preview-arrow-offset)); + position: absolute; + transform: translateX(-50%); + width: 0; + z-index: 2; +} +.plyr__preview-thumb__image-container { + background: #c1c8d1; + border-radius: 4px; + border-radius: calc(var(--plyr-tooltip-radius, 5px) - 1px); + overflow: hidden; + position: relative; + z-index: 0; +} +.plyr__preview-thumb__image-container img, +.plyr__preview-thumb__image-container:after { + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +} +.plyr__preview-thumb__image-container:after { + border-radius: inherit; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15); + content: ""; + pointer-events: none; +} +.plyr__preview-thumb__image-container img { + max-height: none; + max-width: none; +} +.plyr__preview-thumb__time-container { + background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); + background: var(--plyr-video-controls-background, linear-gradient(transparent, rgba(0, 0, 0, 0.75))); + border-bottom-left-radius: 4px; + border-bottom-left-radius: calc(var(--plyr-tooltip-radius, 5px) - 1px); + border-bottom-right-radius: 4px; + border-bottom-right-radius: calc(var(--plyr-tooltip-radius, 5px) - 1px); + bottom: 0; + left: 0; + line-height: 1.1; + padding: 20px 6px 6px; + position: absolute; + right: 0; + z-index: 3; +} +.plyr__preview-thumb__time-container span { + color: #fff; + font-size: 13px; + font-size: var(--plyr-font-size-time, var(--plyr-font-size-small, 13px)); +} +.plyr__preview-scrubbing { + bottom: 0; + filter: blur(1px); + height: 100%; + left: 0; + margin: auto; + opacity: 0; + overflow: hidden; + pointer-events: none; + position: absolute; + right: 0; + top: 0; + transition: opacity 0.3s ease; + width: 100%; + z-index: 1; +} +.plyr__preview-scrubbing--is-shown { + opacity: 1; +} +.plyr__preview-scrubbing img { + height: 100%; + left: 0; + max-height: none; + max-width: none; + -o-object-fit: contain; + object-fit: contain; + position: absolute; + top: 0; + width: 100%; +} +.plyr--no-transition { + transition: none !important; +} +.plyr__sr-only { + clip: rect(1px, 1px, 1px, 1px); + border: 0 !important; + height: 1px !important; + overflow: hidden; + padding: 0 !important; + position: absolute !important; + width: 1px !important; +} +.plyr [hidden] { + display: none !important; +} diff --git a/client/static/devtools_pick_application.png b/client/static/devtools_pick_application.png new file mode 100644 index 0000000..0b4be65 Binary files /dev/null and b/client/static/devtools_pick_application.png differ diff --git a/client/static/dicksword.png b/client/static/dicksword.png new file mode 100644 index 0000000..ee7a52a Binary files /dev/null and b/client/static/dicksword.png differ diff --git a/client/static/discordgrey.png b/client/static/discordgrey.png new file mode 100644 index 0000000..3bde60d Binary files /dev/null and b/client/static/discordgrey.png differ diff --git a/client/static/dlsite.png b/client/static/dlsite.png new file mode 100644 index 0000000..5552a8d Binary files /dev/null and b/client/static/dlsite.png differ diff --git a/client/static/fanbox.svg b/client/static/fanbox.svg new file mode 100644 index 0000000..e57ab6e --- /dev/null +++ b/client/static/fanbox.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/static/fansly.svg b/client/static/fansly.svg new file mode 100644 index 0000000..06cc18b --- /dev/null +++ b/client/static/fansly.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/static/fantia.png b/client/static/fantia.png new file mode 100644 index 0000000..730aecf Binary files /dev/null and b/client/static/fantia.png differ diff --git a/client/static/favicon.ico b/client/static/favicon.ico new file mode 100644 index 0000000..44a8f13 Binary files /dev/null and b/client/static/favicon.ico differ diff --git a/client/static/gumroad.svg b/client/static/gumroad.svg new file mode 100644 index 0000000..2057d62 --- /dev/null +++ b/client/static/gumroad.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/static/js/discord.js b/client/static/js/discord.js new file mode 100644 index 0000000..56e998b --- /dev/null +++ b/client/static/js/discord.js @@ -0,0 +1,158 @@ +// number of messages to load at once when clicking "load more" +const MESSAGE_LOAD_LIMIT = 150; +const BASE_TITLE = document.title; + +let currentChannel; +//image file formats which can be rendered in browser +let imageFormats = [ + "bmp", + "gif", + "ico", + "jpeg", + "jpe", + "jpg", + "jfif", + "apng", + "png", + "tga", + "tiff", + "tif", + "svg", + "webp", +]; + +const loadMessages = async (channelId, skip = 0, initial = false) => { + let channelEl = document.getElementById(`channel-${channelId}`); + // hack to avoid loading the same messages repeatedly if clicking the channel repeatedly without switching + if (channelEl.classList.contains("channel-active") && skip == 0) return; + Array.from(document.querySelectorAll(".channel-active")).forEach(ch => { + ch.classList.remove("channel-active"); + }); + channelEl.classList.add("channel-active"); + window.location.hash = channelId; + + document.title = `${channelEl.children[0].textContent} | ${BASE_TITLE}`; + + const messages = document.getElementById("messages"); + const prevHeight = Array.from(messages.children).reduce((accumulator, element) => accumulator + element.offsetHeight, 0); + const loadButton = document.getElementById("load-more-button"); + if (loadButton) { + loadButton.outerHTML = ""; + } + if (currentChannel !== channelId) messages.innerHTML = ""; + currentChannel = channelId; + const channelData = await fetch( + `/api/v1/discord/channel/${channelId}?o=${skip}` + ).then(resp => resp.json()); + channelData.map((msg) => { + let dls = ""; + let avatarurl = ""; + let embeds = ""; + if (msg.content) { + msg.content = msg.content + .replace(/&/g, "&") + .replace(//g, ""); + + // XXX scanning for < here because < was encoded—not really ideal + // the a? is because animated emojis are + msg.content = msg.content.replace(/<a?:(.+?):(\d+)>/g, ''); + } + msg.attachments.map((dl) => { + if (imageFormats.includes(dl.name.split(".").pop())) { + dls += `
    `; + } else { + dls += `Download ${dl.name + .replace(/&/g, "&") + .replace(/
    `; + } + }); + msg.embeds.map((embed) => { + // XXX this is almost definitely wrong + embeds += ` +
    +
    +

    ${(embed.description || embed.title || "") + .replace(/&/g, "&") + .replace(/ +

    +
    + `; + }); + if (msg.author.avatar) { + avatarurl = `https://cdn.discordapp.com/avatars/${msg.author.id}/${msg.author.avatar}`; + } else { + avatarurl = "/static/discordgrey.png"; + } + + messages.innerHTML = ` +
    +
    + +
    +
    +
    +

    ${msg.author.username.replace(/&/g, "&").replace(/ +

    ${new Date(msg.published)}

    +
    +

    ${msg.content}

    + ${dls} + ${embeds} +
    +
    + ` + messages.innerHTML; + }); + + // avoid adding the button if there are no more messages + if (channelData.length == MESSAGE_LOAD_LIMIT) { + messages.innerHTML = ` +
    + +
    + ` + messages.innerHTML; + } else { + messages.innerHTML = ` +
    +

    End of channel history

    +
    + ` + messages.innerHTML; + } + + messages.scrollTop = messages.scrollHeight - prevHeight; +}; + +const load = async () => { + const pathname = window.location.pathname.split("/"); + const serverData = await fetch(`/api/v1/discord/channel/lookup/${pathname[3]}`); + const server = await serverData.json(); + const channels = document.getElementById("channels"); + server.forEach((ch) => { + const channel = document.getElementById(`channel-${ch.id}`); + if (!channel) { + channels.innerHTML += ` +
    +

    #${ch.name.replace(/&/g, "&").replace(/ +

    + `; + } + }); + + const serverID = window.location.href.match(/\/discord\/server\/(\d+)/)[1]; + + channels.innerHTML += ` + + `; + + if (window.location.hash !== "") { + loadMessages(window.location.hash.slice(1).split("-")[0]); + } +}; + +load(); diff --git a/client/static/js/favorites.js b/client/static/js/favorites.js new file mode 100644 index 0000000..1858632 --- /dev/null +++ b/client/static/js/favorites.js @@ -0,0 +1,18 @@ +function on_change_favorite_type(target) { + if (target) { + var new_type = target.value; + if (new_type) { + window.location = "/favorites?type=" + new_type; + } + } +} + +function on_change_filters(field, target) { + if (target) { + var value = target.value; + var query_string = window.location.search; + var url_params = new URLSearchParams(query_string); + url_params.set(field, value); + window.location.search = url_params.toString(); + } +} diff --git a/client/static/js/lazy-styles.js b/client/static/js/lazy-styles.js new file mode 100644 index 0000000..316fd4a --- /dev/null +++ b/client/static/js/lazy-styles.js @@ -0,0 +1,22 @@ +document.addEventListener("DOMContentLoaded", function () { + const elements = [].slice.call(document.querySelectorAll("[data-style]")); + if ("IntersectionObserver" in window && elements.length) { + let lazyObserver = new IntersectionObserver(function (entries, _) { + entries.forEach(function (entry) { + if (entry.isIntersecting) { + let lazyElement = entry.target; + lazyElement.style.cssText = lazyElement.dataset.style; + lazyObserver.unobserve(lazyElement); + } + }); + }); + + elements.forEach(function (element) { + lazyObserver.observe(element); + }); + } else if (elements.length) { + elements.forEach(function (element) { + element.style.cssText = element.dataset.style; + }); + } +}); diff --git a/client/static/js/request.js b/client/static/js/request.js new file mode 100644 index 0000000..ffa5081 --- /dev/null +++ b/client/static/js/request.js @@ -0,0 +1,11 @@ +document.getElementById("specific_id").style.display = "none"; + +/* eslint-disable no-unused-vars */ +function handleClick(radio) { + if (radio.value === "specific") { + document.getElementById("specific_id").style.display = "block"; + } else { + document.getElementById("specific_id").style.display = "none"; + } +} +/* eslint-enable no-unused-vars */ diff --git a/client/static/kemono-logo.svg b/client/static/kemono-logo.svg new file mode 100644 index 0000000..dd644e9 --- /dev/null +++ b/client/static/kemono-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/client/static/klogo.png b/client/static/klogo.png new file mode 100644 index 0000000..2ca0f60 Binary files /dev/null and b/client/static/klogo.png differ diff --git a/client/static/krokobyaka.cod.jpg b/client/static/krokobyaka.cod.jpg new file mode 100644 index 0000000..b104a22 Binary files /dev/null and b/client/static/krokobyaka.cod.jpg differ diff --git a/client/static/krokobyaka.jpg b/client/static/krokobyaka.jpg new file mode 100644 index 0000000..142d795 Binary files /dev/null and b/client/static/krokobyaka.jpg differ diff --git a/client/static/ktan.png b/client/static/ktan.png new file mode 100644 index 0000000..28d435e Binary files /dev/null and b/client/static/ktan.png differ diff --git a/client/static/loading.gif b/client/static/loading.gif new file mode 100644 index 0000000..a5a3046 Binary files /dev/null and b/client/static/loading.gif differ diff --git a/client/static/menu.svg b/client/static/menu.svg new file mode 100644 index 0000000..f78c497 --- /dev/null +++ b/client/static/menu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/static/menu/account.svg b/client/static/menu/account.svg new file mode 100644 index 0000000..1468b9a --- /dev/null +++ b/client/static/menu/account.svg @@ -0,0 +1,55 @@ + + + + diff --git a/client/static/menu/artists.svg b/client/static/menu/artists.svg new file mode 100644 index 0000000..18f9c4e --- /dev/null +++ b/client/static/menu/artists.svg @@ -0,0 +1,63 @@ + + + + diff --git a/client/static/menu/dm.svg b/client/static/menu/dm.svg new file mode 100644 index 0000000..e39deef --- /dev/null +++ b/client/static/menu/dm.svg @@ -0,0 +1,34 @@ + + + + diff --git a/client/static/menu/faq.svg b/client/static/menu/faq.svg new file mode 100644 index 0000000..8e850f9 --- /dev/null +++ b/client/static/menu/faq.svg @@ -0,0 +1,39 @@ + + + + diff --git a/client/static/menu/favorites.svg b/client/static/menu/favorites.svg new file mode 100644 index 0000000..47f640c --- /dev/null +++ b/client/static/menu/favorites.svg @@ -0,0 +1,52 @@ + + + + diff --git a/client/static/menu/heart.png b/client/static/menu/heart.png new file mode 100644 index 0000000..76d169f Binary files /dev/null and b/client/static/menu/heart.png differ diff --git a/client/static/menu/home.svg b/client/static/menu/home.svg new file mode 100644 index 0000000..c361a92 --- /dev/null +++ b/client/static/menu/home.svg @@ -0,0 +1,40 @@ + + + + diff --git a/client/static/menu/import.svg b/client/static/menu/import.svg new file mode 100644 index 0000000..9520c10 --- /dev/null +++ b/client/static/menu/import.svg @@ -0,0 +1,35 @@ + + + + diff --git a/client/static/menu/importer.svg b/client/static/menu/importer.svg new file mode 100644 index 0000000..72c8ed6 --- /dev/null +++ b/client/static/menu/importer.svg @@ -0,0 +1,65 @@ + + + + diff --git a/client/static/menu/index.svg b/client/static/menu/index.svg new file mode 100644 index 0000000..affd767 --- /dev/null +++ b/client/static/menu/index.svg @@ -0,0 +1,40 @@ + + + + diff --git a/client/static/menu/keys.svg b/client/static/menu/keys.svg new file mode 100644 index 0000000..9c4c809 --- /dev/null +++ b/client/static/menu/keys.svg @@ -0,0 +1,55 @@ + + + + diff --git a/client/static/menu/login.svg b/client/static/menu/login.svg new file mode 100644 index 0000000..6918995 --- /dev/null +++ b/client/static/menu/login.svg @@ -0,0 +1,40 @@ + + + + diff --git a/client/static/menu/logout.svg b/client/static/menu/logout.svg new file mode 100644 index 0000000..a881855 --- /dev/null +++ b/client/static/menu/logout.svg @@ -0,0 +1,45 @@ + + + + diff --git a/client/static/menu/posts.svg b/client/static/menu/posts.svg new file mode 100644 index 0000000..d5a38aa --- /dev/null +++ b/client/static/menu/posts.svg @@ -0,0 +1,56 @@ + + + + diff --git a/client/static/menu/random1.svg b/client/static/menu/random1.svg new file mode 100644 index 0000000..d367bc0 --- /dev/null +++ b/client/static/menu/random1.svg @@ -0,0 +1,53 @@ + + + + diff --git a/client/static/menu/random2.svg b/client/static/menu/random2.svg new file mode 100644 index 0000000..81dcda2 --- /dev/null +++ b/client/static/menu/random2.svg @@ -0,0 +1,34 @@ + + + + diff --git a/client/static/menu/recent.svg b/client/static/menu/recent.svg new file mode 100644 index 0000000..02db6b1 --- /dev/null +++ b/client/static/menu/recent.svg @@ -0,0 +1,41 @@ + + + + diff --git a/client/static/menu/red_dm.svg b/client/static/menu/red_dm.svg new file mode 100644 index 0000000..7ebfd3d --- /dev/null +++ b/client/static/menu/red_dm.svg @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/client/static/menu/register.svg b/client/static/menu/register.svg new file mode 100644 index 0000000..c874c3d --- /dev/null +++ b/client/static/menu/register.svg @@ -0,0 +1,56 @@ + + + + diff --git a/client/static/menu/search.svg b/client/static/menu/search.svg new file mode 100644 index 0000000..e532224 --- /dev/null +++ b/client/static/menu/search.svg @@ -0,0 +1,43 @@ + + + + diff --git a/client/static/menu/tag.svg b/client/static/menu/tag.svg new file mode 100644 index 0000000..3c2059f --- /dev/null +++ b/client/static/menu/tag.svg @@ -0,0 +1,3 @@ + + # + \ No newline at end of file diff --git a/client/static/nuns.cod.jpg b/client/static/nuns.cod.jpg new file mode 100644 index 0000000..afb085a Binary files /dev/null and b/client/static/nuns.cod.jpg differ diff --git a/client/static/onlyfans.svg b/client/static/onlyfans.svg new file mode 100644 index 0000000..74235ff --- /dev/null +++ b/client/static/onlyfans.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/static/padlock.png b/client/static/padlock.png new file mode 100644 index 0000000..39575fc Binary files /dev/null and b/client/static/padlock.png differ diff --git a/client/static/paroro.cod.jpg b/client/static/paroro.cod.jpg new file mode 100644 index 0000000..9b810b3 Binary files /dev/null and b/client/static/paroro.cod.jpg differ diff --git a/client/static/patreon.svg b/client/static/patreon.svg new file mode 100644 index 0000000..1ed2c6e --- /dev/null +++ b/client/static/patreon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/static/small_icons/canfans.png b/client/static/small_icons/canfans.png new file mode 100644 index 0000000..75e5224 Binary files /dev/null and b/client/static/small_icons/canfans.png differ diff --git a/client/static/small_icons/dlsite.png b/client/static/small_icons/dlsite.png new file mode 100644 index 0000000..1f588fd Binary files /dev/null and b/client/static/small_icons/dlsite.png differ diff --git a/client/static/small_icons/fanbox.png b/client/static/small_icons/fanbox.png new file mode 100644 index 0000000..2c3b6bf Binary files /dev/null and b/client/static/small_icons/fanbox.png differ diff --git a/client/static/small_icons/fansly.png b/client/static/small_icons/fansly.png new file mode 100644 index 0000000..ac99257 Binary files /dev/null and b/client/static/small_icons/fansly.png differ diff --git a/client/static/small_icons/fantia.png b/client/static/small_icons/fantia.png new file mode 100644 index 0000000..4934698 Binary files /dev/null and b/client/static/small_icons/fantia.png differ diff --git a/client/static/small_icons/gumroad.png b/client/static/small_icons/gumroad.png new file mode 100644 index 0000000..fe7288a Binary files /dev/null and b/client/static/small_icons/gumroad.png differ diff --git a/client/static/small_icons/onlyfans.png b/client/static/small_icons/onlyfans.png new file mode 100644 index 0000000..b5736e4 Binary files /dev/null and b/client/static/small_icons/onlyfans.png differ diff --git a/client/static/small_icons/patreon.png b/client/static/small_icons/patreon.png new file mode 100644 index 0000000..5b766de Binary files /dev/null and b/client/static/small_icons/patreon.png differ diff --git a/client/static/small_icons/subscribestar.png b/client/static/small_icons/subscribestar.png new file mode 100644 index 0000000..e41ce6e Binary files /dev/null and b/client/static/small_icons/subscribestar.png differ diff --git a/client/static/subscribestar.png b/client/static/subscribestar.png new file mode 100644 index 0000000..2df8981 Binary files /dev/null and b/client/static/subscribestar.png differ diff --git a/client/static/upload.svg b/client/static/upload.svg new file mode 100644 index 0000000..c68058b --- /dev/null +++ b/client/static/upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/static/user-placeholder.jpg b/client/static/user-placeholder.jpg new file mode 100644 index 0000000..e7bdbef Binary files /dev/null and b/client/static/user-placeholder.jpg differ diff --git a/client/webpack.config.js b/client/webpack.config.js new file mode 100644 index 0000000..e5c0ee1 --- /dev/null +++ b/client/webpack.config.js @@ -0,0 +1,73 @@ +const path = require("path"); +const { DefinePlugin } = require("webpack"); +const CopyWebpackPlugin = require("copy-webpack-plugin"); +const { buildHTMLWebpackPluginsRecursive } = require("./configs/build-templates"); +const { + kemonoSite, + nodeEnv, + iconsPrepend, + bannersPrepend, + thumbnailsPrepend, + creatorsLocation, +} = require("./configs/vars"); + +const projectPath = path.resolve(__dirname, "src"); +const pagesPath = path.join(projectPath, "pages"); +const pagePlugins = buildHTMLWebpackPluginsRecursive(pagesPath, { + fileExtension: "html", + pluginOptions: { + inject: false, + minify: false, + }, +}); + +/** + * TODO: make separate entries for `admin` and `moderator` + * @type import("webpack").Configuration + */ +const webpackConfig = { + entry: { + global: path.join(projectPath, "js", "global.js"), + admin: path.join(projectPath, "js", "admin.js"), + // moderator: path.join(projectPath, "js", "moderator.js"), + }, + plugins: [ + ...pagePlugins, + // https://webpack.js.org/plugins/define-plugin/ + new DefinePlugin({ + BUNDLER_ENV_KEMONO_SITE: JSON.stringify(kemonoSite), + BUNDLER_ENV_NODE_ENV: JSON.stringify(nodeEnv), + BUNDLER_ENV_ICONS_PREPEND: JSON.stringify(iconsPrepend), + BUNDLER_ENV_BANNERS_PREPEND: JSON.stringify(bannersPrepend), + BUNDLER_ENV_THUMBNAILS_PREPEND: JSON.stringify(thumbnailsPrepend), + BUNDLER_ENV_CREATORS_LOCATION: JSON.stringify(creatorsLocation), + }), + new CopyWebpackPlugin({ + patterns: [ + { + from: "static", + to: "static", + }, + ], + }), + ], + resolve: { + extensions: [".js"], + alias: { + ["@wp/pages"]: path.join(projectPath, "pages", "_index.js"), + ["@wp/components"]: path.join(projectPath, "pages", "components", "_index.js"), + ["@wp/env"]: path.join(projectPath, "env"), + ["@wp/lib"]: path.join(projectPath, "lib"), + ["@wp/js"]: path.join(projectPath, "js"), + ["@wp/css"]: path.join(projectPath, "css"), + ["@wp/assets"]: path.join(projectPath, "assets"), + ["@wp/api"]: path.join(projectPath, "api", "_index.js"), + ["@wp/utils"]: path.join(projectPath, "utils", "_index.js"), + }, + fallback: { + stream: false, + }, + }, +}; + +module.exports = webpackConfig; diff --git a/client/webpack.dev.js b/client/webpack.dev.js new file mode 100644 index 0000000..ab8884e --- /dev/null +++ b/client/webpack.dev.js @@ -0,0 +1,103 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const path = require("path"); +const { merge } = require("webpack-merge"); + +const baseConfig = require("./webpack.config"); +const { kemonoSite } = require("./configs/vars"); + +const projectPath = path.resolve(__dirname, "src"); + +/** + * @type {import("webpack-dev-server").Configuration} + */ +const devServer = { + host: "0.0.0.0", + port: 3450, + devMiddleware: { + writeToDisk: true, + }, + watchFiles: { + options: { + poll: 500, + aggregateTimeout: 500, + }, + }, + static: { + directory: path.resolve(__dirname, "static"), + watch: true, + }, + hot: false, + liveReload: true, + client: { + overlay: true, + progress: true, + }, +}; + +/** + * @type import("webpack").Configuration + */ +const webpackConfigDev = { + mode: "development", + devtool: "eval-source-map", + devServer: devServer, + entry: { + development: path.join(projectPath, "development", "entry.js"), + }, + plugins: [ + new MiniCssExtractPlugin({ + filename: "static/bundle/css/[name].css", + chunkFilename: "static/bundle/css/[id].chunk.css", + }), + ], + module: { + rules: [ + { + test: /\.s[ac]ss$/i, + exclude: /\.module.s[ac]ss$/i, + use: [ + MiniCssExtractPlugin.loader, + { + loader: "css-loader", + options: { + sourceMap: true, + }, + }, + { + loader: "sass-loader", + options: { + sourceMap: true, + // TODO: find how to prepend data properly + additionalData: `$kemono-site: '${kemonoSite}';`, + }, + }, + ], + }, + { + test: /\.(png|jpg|jpeg|gif|webp)$/i, + type: "asset/resource", + }, + { + test: /\.(woff|woff2|eot|ttf|otf)$/i, + type: "asset/resource", + }, + { + test: /\.svg$/i, + type: "asset/resource", + }, + { + test: /\.css$/, + use: ["style-loader", "css-loader"], + }, + ], + }, + output: { + path: path.resolve(__dirname, "dev"), + filename: "static/bundle/js/[name].bundle.js", + assetModuleFilename: "static/bundle/assets/[name][ext][query]", + publicPath: "/", + clean: true, + }, +}; + +module.exports = merge(baseConfig, webpackConfigDev); diff --git a/client/webpack.prod.js b/client/webpack.prod.js new file mode 100644 index 0000000..ff02923 --- /dev/null +++ b/client/webpack.prod.js @@ -0,0 +1,113 @@ +const path = require("path"); + +const MiniCSSExtractPlugin = require("mini-css-extract-plugin"); +const { merge } = require("webpack-merge"); + +const baseConfig = require("./webpack.config"); +const { kemonoSite } = require("./configs/vars"); +/** + * @type import("webpack").Configuration + */ +const webpackConfigProd = { + mode: "production", + // devtool: "source-map", + plugins: [ + new MiniCSSExtractPlugin({ + filename: "static/bundle/css/[name]-[contenthash].css", + chunkFilename: "static/bundle/css/[id]-[contenthash].chunk.css", + }), + ], + module: { + rules: [ + { + test: /\.m?js$/i, + exclude: /node_modules/, + use: { + loader: "babel-loader", + options: { + presets: [["@babel/preset-env", { targets: "defaults" }]], + plugins: ["@babel/plugin-transform-runtime"], + }, + }, + }, + { + test: /\.s[ac]ss$/i, + exclude: /\.module\.s[ac]ss$/i, + use: [ + MiniCSSExtractPlugin.loader, + { + loader: "css-loader", + // options: { + // sourceMap: true, + // } + }, + + { + loader: "postcss-loader", + options: { + postcssOptions: { + plugins: [["postcss-preset-env"]], + }, + }, + }, + { + loader: "sass-loader", + options: { + // sourceMap: true, + additionalData: `$kemono-site: '${kemonoSite}';`, + }, + }, + ], + }, + + { + test: /\.(png|jpg|jpeg|gif|webp)$/i, + type: "asset/resource", + generator: { + filename: "static/bundle/assets/[name]-[contenthash][ext][query]", + }, + }, + { + test: /\.(woff|woff2|eot|ttf|otf)$/i, + type: "asset/resource", + generator: { + filename: "static/bundle/fonts/[name]-[contenthash][ext][query]", + }, + }, + { + test: /\.svg$/i, + type: "asset/resource", + generator: { + filename: "static/bundle/svg/[name]-[contenthash][ext][query]", + }, + }, + { + test: /\.css$/, + use: ["style-loader", "css-loader"], + }, + ], + }, + output: { + path: path.resolve(__dirname, "dist"), + filename: "static/bundle/js/[name]-[contenthash].bundle.js", + assetModuleFilename: "static/bundle/assets/[name]-[contenthash][ext][query]", + // sourceMapFilename: "source-maps/[file].map[query]", + publicPath: "/", + clean: true, + }, + optimization: { + moduleIds: "deterministic", + runtimeChunk: "single", + splitChunks: { + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name: "vendors", + chunks: "all", + }, + }, + }, + }, +}; + +module.exports = merge(baseConfig, webpackConfigProd); diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..f2a558c --- /dev/null +++ b/config.example.json @@ -0,0 +1,30 @@ +{ + "site": "http://localhost:5000", + "development_mode": true, + "automatic_migrations": true, + "webserver": { + "secret": "To SECRET name.", + "port": 80, + "ui": { + "home": { + "site_name": "Kemono" + }, + "config": { + "paysite_list": ["patreon", "fanbox", "afdian"] + } + } + }, + "database": { + "host": "postgres", + "user": "kemono", + "password": "kemono", + "database": "kemono" + }, + "redis": { + "defaults": { + "host": "redis", + "port": 6379, + "db": 0 + } + } +} diff --git a/db/migrations/20210118_01_1Jlkq-add-unique-constraint-to-service-and-post-fields.py b/db/migrations/20210118_01_1Jlkq-add-unique-constraint-to-service-and-post-fields.py new file mode 100644 index 0000000..7f5927f --- /dev/null +++ b/db/migrations/20210118_01_1Jlkq-add-unique-constraint-to-service-and-post-fields.py @@ -0,0 +1,53 @@ +""" +Add unique constraint to service and post fields +""" + +from yoyo import step + +__depends__ = {"initial"} + +steps = [ + step( + """ + CREATE TABLE posts ( + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "title" text NOT NULL DEFAULT '', + "content" text NOT NULL DEFAULT '', + "embed" jsonb NOT NULL DEFAULT '{}', + "shared_file" boolean NOT NULL DEFAULT '0', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "edited" timestamp, + "file" jsonb NOT NULL, + "attachments" jsonb[] NOT NULL, + PRIMARY KEY (id, service) + ); + """, + "DROP TABLE posts", + ), + step( + "INSERT INTO posts SELECT * FROM booru_posts ON CONFLICT DO NOTHING", + "INSERT INTO booru_posts SELECT * FROM posts", + ), + step( + "DROP TABLE booru_posts", + """ + CREATE TABLE booru_posts ( + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "title" text NOT NULL DEFAULT '', + "content" text NOT NULL DEFAULT '', + "embed" jsonb NOT NULL DEFAULT '{}', + "shared_file" boolean NOT NULL DEFAULT '0', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "edited" timestamp, + "file" jsonb NOT NULL, + "attachments" jsonb[] NOT NULL + ); + """, + ), +] diff --git a/db/migrations/20210118_02_JxKbt-add-indexes-to-posts-table.py b/db/migrations/20210118_02_JxKbt-add-indexes-to-posts-table.py new file mode 100644 index 0000000..f7a77f2 --- /dev/null +++ b/db/migrations/20210118_02_JxKbt-add-indexes-to-posts-table.py @@ -0,0 +1,16 @@ +""" +Add indexes to posts table +""" + +from yoyo import step + +__depends__ = {"20210118_01_1Jlkq-add-unique-constraint-to-service-and-post-fields"} + +steps = [ + step('CREATE INDEX id_idx ON posts USING hash ("id")', "DROP INDEX id_idx"), + step('CREATE INDEX service_idx ON posts USING btree ("service")', "DROP INDEX service_idx"), + step('CREATE INDEX added_idx ON posts USING btree ("added")', "DROP INDEX added_idx"), + step('CREATE INDEX published_idx ON posts USING btree ("published")', "DROP INDEX published_idx"), + step('CREATE INDEX user_idx ON posts USING btree ("user")', "DROP INDEX user_idx"), + step('CREATE INDEX updated_idx ON posts USING btree ("user", "service", "added")', "DROP INDEX updated_idx"), +] diff --git a/db/migrations/20210120_01_PZzeb-apply-primary-key-constraint-to-lookup-table.py b/db/migrations/20210120_01_PZzeb-apply-primary-key-constraint-to-lookup-table.py new file mode 100644 index 0000000..0fe7a95 --- /dev/null +++ b/db/migrations/20210120_01_PZzeb-apply-primary-key-constraint-to-lookup-table.py @@ -0,0 +1,41 @@ +""" +Apply primary key constraint to lookup table +""" + +from yoyo import step + +__depends__ = {"20210118_02_JxKbt-add-indexes-to-posts-table"} + +steps = [ + step( + "ALTER TABLE lookup RENAME TO old_lookup", + "ALTER TABLE old_lookup RENAME TO lookup", + ), + step( + """ + CREATE TABLE lookup ( + "id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "indexed" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, service) + ); + """, + "DROP TABLE lookup", + ), + step( + "INSERT INTO lookup SELECT * FROM old_lookup ON CONFLICT DO NOTHING", + "INSERT INTO old_lookup SELECT * FROM lookup", + ), + step( + "DROP TABLE old_lookup", + """ + CREATE TABLE old_lookup ( + "id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "indexed" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """, + ), +] diff --git a/db/migrations/20210214_01_IljvB-index-posts-for-search.py b/db/migrations/20210214_01_IljvB-index-posts-for-search.py new file mode 100644 index 0000000..dcadb1f --- /dev/null +++ b/db/migrations/20210214_01_IljvB-index-posts-for-search.py @@ -0,0 +1,14 @@ +""" +Index posts for search +""" + +from yoyo import step + +__depends__ = {"20210120_01_PZzeb-apply-primary-key-constraint-to-lookup-table"} + +steps = [ + step( + "CREATE INDEX IF NOT EXISTS search_idx ON posts USING GIN (to_tsvector('english', content || ' ' || title))", + "DROP INDEX search_idx", + ) +] diff --git a/db/migrations/20210321_01_m7Fuq-add-account-tables.py b/db/migrations/20210321_01_m7Fuq-add-account-tables.py new file mode 100644 index 0000000..c4cf7b7 --- /dev/null +++ b/db/migrations/20210321_01_m7Fuq-add-account-tables.py @@ -0,0 +1,43 @@ +""" +Add account tables +""" + +from yoyo import step + +__depends__ = {"20210214_01_IljvB-index-posts-for-search"} + +steps = [ + step( + """ + CREATE TABLE account ( + id serial primary key, + username varchar not null, + password_hash varchar not null, + UNIQUE(username) + ); + """ + ), + step( + """ + CREATE TABLE account_post_favorite ( + id serial primary key, + account_id int not null REFERENCES account(id), + service varchar(20) not null, + artist_id varchar(255) not null, + post_id varchar(255) not null, + UNIQUE(account_id, service, artist_id, post_id) + ); + """ + ), + step( + """ + CREATE TABLE account_artist_favorite ( + id serial primary key, + account_id int not null REFERENCES account(id), + service varchar(20) not null, + artist_id varchar(255) not null, + UNIQUE(account_id, service, artist_id) + ); + """ + ), +] diff --git a/db/migrations/20210322_01_In37S-add-account-field.py b/db/migrations/20210322_01_In37S-add-account-field.py new file mode 100644 index 0000000..5e31323 --- /dev/null +++ b/db/migrations/20210322_01_In37S-add-account-field.py @@ -0,0 +1,16 @@ +""" +Add account field +""" + +from yoyo import step + +__depends__ = {"20210321_01_m7Fuq-add-account-tables"} + +steps = [ + step( + """ + ALTER TABLE account + ADD COLUMN created_at timestamp without time zone not null default CURRENT_TIMESTAMP + """ + ) +] diff --git a/db/migrations/20210328_01_8tlz4-add-indexes-to-favorites-tables.py b/db/migrations/20210328_01_8tlz4-add-indexes-to-favorites-tables.py new file mode 100644 index 0000000..1860654 --- /dev/null +++ b/db/migrations/20210328_01_8tlz4-add-indexes-to-favorites-tables.py @@ -0,0 +1,20 @@ +""" +Add indexes to favorites tables +""" + +from yoyo import step + +__depends__ = {"20210322_01_In37S-add-account-field"} + +steps = [ + step( + """ + CREATE INDEX ON account_artist_favorite (service, artist_id) + """ + ), + step( + """ + CREATE INDEX ON account_post_favorite (service, artist_id, post_id) + """ + ), +] diff --git a/db/migrations/20210529_01_LO1OU-add-primary-keys-to-discord-posts.py b/db/migrations/20210529_01_LO1OU-add-primary-keys-to-discord-posts.py new file mode 100644 index 0000000..599b3cb --- /dev/null +++ b/db/migrations/20210529_01_LO1OU-add-primary-keys-to-discord-posts.py @@ -0,0 +1,21 @@ +""" +Add primary keys to discord_posts +""" + +from yoyo import step + +__depends__ = {"20210328_01_8tlz4-add-indexes-to-favorites-tables"} + +steps = [ + step( + """ + DELETE FROM discord_posts a + USING discord_posts b + WHERE a.ctid < b.ctid + AND a.id = b.id + AND a.server = b.server + AND a.channel = b.channel; + """ + ), + step("ALTER TABLE discord_posts ADD PRIMARY KEY (id, server, channel); "), +] diff --git a/db/migrations/20210610_01_w7oGH-add-updated-field-to-lookup-table.py b/db/migrations/20210610_01_w7oGH-add-updated-field-to-lookup-table.py new file mode 100644 index 0000000..4f90775 --- /dev/null +++ b/db/migrations/20210610_01_w7oGH-add-updated-field-to-lookup-table.py @@ -0,0 +1,14 @@ +""" +Add updated field to lookup table +""" + +from yoyo import step + +__depends__ = {"20210529_01_LO1OU-add-primary-keys-to-discord-posts"} + +steps = [ + step( + "ALTER TABLE lookup ADD COLUMN updated timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP", + "ALTER TABLE lookup DROP COLUMN updated", + ) +] diff --git a/db/migrations/20210611_01_jpGYN-add-update-index.py b/db/migrations/20210611_01_jpGYN-add-update-index.py new file mode 100644 index 0000000..06fb06d --- /dev/null +++ b/db/migrations/20210611_01_jpGYN-add-update-index.py @@ -0,0 +1,12 @@ +""" +Add update index +""" + +from yoyo import step + +__depends__ = {"20210610_01_w7oGH-add-updated-field-to-lookup-table"} + +steps = [ + step("DROP INDEX updated_idx", 'CREATE INDEX updated_idx ON posts USING btree ("user", "service", "added")'), + step('CREATE INDEX updated_idx ON lookup USING btree ("updated")', "DROP INDEX updated_idx"), +] diff --git a/db/migrations/20210614_01_YW8Os-add-comment-tables.py b/db/migrations/20210614_01_YW8Os-add-comment-tables.py new file mode 100644 index 0000000..47fc8fd --- /dev/null +++ b/db/migrations/20210614_01_YW8Os-add-comment-tables.py @@ -0,0 +1,25 @@ +""" +Add comment tables +""" + +from yoyo import step + +__depends__ = {"20210611_01_jpGYN-add-update-index"} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS comments ( + "id" varchar(255) NOT NULL, + "post_id" varchar(255) NOT NULL, + "parent_id" varchar(255), + "commenter" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "content" text NOT NULL DEFAULT '', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + PRIMARY KEY (id, service) + ); + """ + ) +] diff --git a/db/migrations/20210707_01_favHK-add-comment-indexes.py b/db/migrations/20210707_01_favHK-add-comment-indexes.py new file mode 100644 index 0000000..a4c9d57 --- /dev/null +++ b/db/migrations/20210707_01_favHK-add-comment-indexes.py @@ -0,0 +1,14 @@ +""" +Add comment indexes +""" + +from yoyo import step + +__depends__ = {"20210614_01_YW8Os-add-comment-tables"} + +steps = [ + step( + 'CREATE INDEX comment_idx ON comments USING btree ("post_id")', + "DROP INDEX comment_idx", + ) +] diff --git a/db/migrations/20210707_02_flWka-add-dm-tables.py b/db/migrations/20210707_02_flWka-add-dm-tables.py new file mode 100644 index 0000000..004966d --- /dev/null +++ b/db/migrations/20210707_02_flWka-add-dm-tables.py @@ -0,0 +1,26 @@ +""" +Add DM tables +""" + +from yoyo import step + +__depends__ = {"20210707_01_favHK-add-comment-indexes"} + +steps = [ + step( + """ + CREATE TABLE dms ( + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "content" text NOT NULL DEFAULT '', + "embed" jsonb NOT NULL DEFAULT '{}', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "file" jsonb NOT NULL, + PRIMARY KEY (id, service) + ); + """, + "DROP TABLE dms", + ), +] diff --git a/db/migrations/20210707_03_m9nL9-add-temp-dm-tables.py b/db/migrations/20210707_03_m9nL9-add-temp-dm-tables.py new file mode 100644 index 0000000..5c24615 --- /dev/null +++ b/db/migrations/20210707_03_m9nL9-add-temp-dm-tables.py @@ -0,0 +1,27 @@ +""" +Add temp DM tables +""" + +from yoyo import step + +__depends__ = {"20210707_02_flWka-add-dm-tables"} + +steps = [ + step( + """ + CREATE TABLE unapproved_dms ( + "import_id" varchar(255) NOT NULL, + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "content" text NOT NULL DEFAULT '', + "embed" jsonb NOT NULL DEFAULT '{}', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "file" jsonb NOT NULL, + PRIMARY KEY (id, service) + ); + """, + "DROP TABLE unapproved_dms", + ), +] diff --git a/db/migrations/20210707_04_l1vte-add-dm-indexes.py b/db/migrations/20210707_04_l1vte-add-dm-indexes.py new file mode 100644 index 0000000..aaa86cc --- /dev/null +++ b/db/migrations/20210707_04_l1vte-add-dm-indexes.py @@ -0,0 +1,18 @@ +""" +Add DM indexes +""" + +from yoyo import step + +__depends__ = {"20210707_03_m9nL9-add-temp-dm-tables"} + +steps = [ + step( + 'CREATE INDEX unapproved_dm_idx ON unapproved_dms USING btree ("import_id")', + "DROP INDEX unapproved_dm_idx", + ), + step( + 'CREATE INDEX dm_idx ON dms USING btree ("user")', + "DROP INDEX dm_idx", + ), +] diff --git a/db/migrations/20210729_01_jopn6-add-contributor-id-column-to-dm-table.py b/db/migrations/20210729_01_jopn6-add-contributor-id-column-to-dm-table.py new file mode 100644 index 0000000..d696ae7 --- /dev/null +++ b/db/migrations/20210729_01_jopn6-add-contributor-id-column-to-dm-table.py @@ -0,0 +1,14 @@ +""" +Add contributor ID column to DM table +""" + +from yoyo import step + +__depends__ = {"20210707_04_l1vte-add-dm-indexes"} + +steps = [ + step( + "ALTER TABLE unapproved_dms ADD COLUMN contributor_id varchar(255)", + "ALTER TABLE unapproved_dms DROP COLUMN contributor_id", + ) +] diff --git a/db/migrations/20210805_01_riCyY-index-dms-for-search.py b/db/migrations/20210805_01_riCyY-index-dms-for-search.py new file mode 100644 index 0000000..cc27a51 --- /dev/null +++ b/db/migrations/20210805_01_riCyY-index-dms-for-search.py @@ -0,0 +1,14 @@ +""" +Index DMs for search +""" + +from yoyo import step + +__depends__ = {"20210729_01_jopn6-add-contributor-id-column-to-dm-table"} + +steps = [ + step( + "CREATE INDEX IF NOT EXISTS dm_search_idx ON dms USING GIN (to_tsvector('english', content))", + "DROP INDEX dm_search_idx", + ) +] diff --git a/db/migrations/20210926_01_5n9U4-create-file-tracking-tables.py b/db/migrations/20210926_01_5n9U4-create-file-tracking-tables.py new file mode 100644 index 0000000..8fb5a98 --- /dev/null +++ b/db/migrations/20210926_01_5n9U4-create-file-tracking-tables.py @@ -0,0 +1,102 @@ +""" +Create file tracking tables +""" + +from yoyo import step + +__depends__ = {"20210805_01_riCyY-index-dms-for-search"} + +steps = [ + step( + """ + CREATE TABLE files ( + id serial primary key, + hash varchar not null, + mtime timestamp not null, + ctime timestamp not null, + mime varchar, + ext varchar, + added timestamp not null DEFAULT CURRENT_TIMESTAMP, + UNIQUE(hash) + ); + """, + "DROP TABLE files;", + ), + step( + """ + CREATE TABLE file_post_relationships ( + file_id int not null REFERENCES files(id), + filename varchar not null, + service varchar not null, + "user" varchar not null, + post varchar not null, + contributor_id int REFERENCES account(id), + inline boolean not null DEFAULT FALSE, + PRIMARY KEY (file_id, service, "user", post) + ); + """, + "DROP TABLE file_post_relationships;", + ), + step( + """ + CREATE TABLE file_discord_message_relationships ( + file_id int not null REFERENCES files(id), + filename varchar not null, + server varchar not null, + channel varchar not null, + id varchar not null, + contributor_id int REFERENCES account(id), + PRIMARY KEY (file_id, server, channel, id) + ); + """, + "DROP TABLE file_discord_message_relationships;", + ), + step( + """ + CREATE TABLE file_server_relationships ( + file_id int not null REFERENCES files(id), + remote_path varchar not null + ); + """, + "DROP TABLE file_server_relationships;", + ), + step( + 'CREATE INDEX file_id_idx ON file_post_relationships USING btree ("file_id")', + "DROP INDEX file_id_idx", + ), + step( + 'CREATE INDEX file_post_service_idx ON file_post_relationships USING btree ("service")', + "DROP INDEX file_post_service_idx", + ), + step( + 'CREATE INDEX file_post_user_idx ON file_post_relationships USING btree ("user")', + "DROP INDEX file_post_user_idx", + ), + step( + 'CREATE INDEX file_post_id_idx ON file_post_relationships USING btree ("post")', "DROP INDEX file_post_id_idx" + ), + step( + 'CREATE INDEX file_post_contributor_id_idx ON file_post_relationships USING btree ("contributor_id")', + "DROP INDEX file_post_contributor_id_idx", + ), + step( + 'CREATE INDEX file_discord_id_idx ON file_discord_message_relationships USING btree ("file_id")', + "DROP INDEX file_discord_id_idx", + ), + step( + 'CREATE INDEX file_discord_message_server_idx ON file_discord_message_relationships USING btree ("server")', + "DROP INDEX file_discord_message_server_idx", + ), + step( + 'CREATE INDEX file_discord_message_channel_idx ON file_discord_message_relationships USING btree ("channel")', + "DROP INDEX file_discord_message_channel_idx", + ), + step( + 'CREATE INDEX file_discord_message_id_idx ON file_discord_message_relationships USING btree ("id")', + "DROP INDEX file_discord_message_id_idx", + ), + step( + 'CREATE INDEX file_discord_message_contributor_id_idx ON file_discord_message_relationships USING btree ("contributor_id")', + "DROP INDEX file_discord_message_contributor_id_idx", + ), +] diff --git a/db/migrations/20210927_01_administrator-groundwork.py b/db/migrations/20210927_01_administrator-groundwork.py new file mode 100644 index 0000000..8c21afb --- /dev/null +++ b/db/migrations/20210927_01_administrator-groundwork.py @@ -0,0 +1,34 @@ +""" +Add role column to accounts table, make index account_idx on accounts table, add notifications table with indexes +""" +from yoyo import step + +__depends__ = {"20211003_01_vHxE2-create-auto-import-tables"} + +steps = [ + step("ALTER TABLE account ADD COLUMN role varchar DEFAULT 'consumer';", "ALTER TABLE account DROP COLUMN role;"), + step( + "CREATE INDEX IF NOT EXISTS account_idx ON account USING BTREE (username, created_at, role);", + "DROP INDEX IF EXISTS account_idx;", + ), + step( + """ + CREATE TABLE IF NOT EXISTS notifications ( + id BIGSERIAL PRIMARY KEY, + account_id INT NOT NULL, + type SMALLINT NOT NULL, + extra_info jsonb, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_seen BOOLEAN NOT NULL DEFAULT FALSE, + FOREIGN KEY (account_id) REFERENCES account(id) + ); + + CREATE INDEX IF NOT EXISTS notifications_account_id_idx ON notifications USING BTREE ("account_id"); + CREATE INDEX IF NOT EXISTS notifications_created_at_idx ON notifications USING BTREE ("created_at"); + CREATE INDEX IF NOT EXISTS notifications_type_idx ON notifications USING BTREE ("type"); + """, + """ + DROP TABLE IF EXISTS notifications; + """, + ), +] diff --git a/db/migrations/20211003_01_vHxE2-create-auto-import-tables.py b/db/migrations/20211003_01_vHxE2-create-auto-import-tables.py new file mode 100644 index 0000000..8b02bfd --- /dev/null +++ b/db/migrations/20211003_01_vHxE2-create-auto-import-tables.py @@ -0,0 +1,43 @@ +""" +Create auto-import tables +""" + +from yoyo import step + +__depends__ = {"20210926_01_5n9U4-create-file-tracking-tables"} + +steps = [ + step( + """ + CREATE TABLE saved_session_keys ( + id serial primary key, + service varchar not null, + discord_channel_ids varchar, + encrypted_key varchar not null, + added timestamp not null DEFAULT CURRENT_TIMESTAMP, + dead boolean not null DEFAULT FALSE, + contributor_id int REFERENCES account(id), + UNIQUE (service, encrypted_key) + ); + """, + "DROP TABLE saved_session_keys", + ), + step( + """ + CREATE TABLE saved_session_key_import_ids ( + key_id int not null REFERENCES saved_session_keys(id), + import_id varchar not null, + UNIQUE (key_id, import_id) + ); + """, + "DROP TABLE saved_session_key_import_ids", + ), + step( + 'CREATE INDEX saved_session_keys_contributor_idx ON saved_session_keys USING btree ("contributor_id")', + "DROP INDEX saved_session_keys_contributor_idx", + ), + step( + 'CREATE INDEX saved_session_keys_dead_idx ON saved_session_keys USING btree ("dead")', + "DROP INDEX saved_session_keys_contributor_idx", + ), +] diff --git a/db/migrations/20211014_01_1hR6J-add-sha256hash-column-to-saved-key-table.py b/db/migrations/20211014_01_1hR6J-add-sha256hash-column-to-saved-key-table.py new file mode 100644 index 0000000..5910d2d --- /dev/null +++ b/db/migrations/20211014_01_1hR6J-add-sha256hash-column-to-saved-key-table.py @@ -0,0 +1,35 @@ +""" +Add sha256hash column to saved key table +""" + +from yoyo import step + +__depends__ = {"20210927_01_administrator-groundwork"} + +steps = [ + step( + """ + CREATE TABLE saved_session_keys_with_hashes ( + id serial primary key, + service varchar not null, + discord_channel_ids varchar, + encrypted_key varchar not null, + hash varchar not null, + added timestamp not null DEFAULT CURRENT_TIMESTAMP, + dead boolean not null DEFAULT FALSE, + contributor_id int REFERENCES account(id), + UNIQUE (service, hash) + ); + """, + "DROP TABLE saved_session_keys_with_hashes", + ), + step( + "ALTER TABLE saved_session_key_import_ids DROP CONSTRAINT saved_session_key_import_ids_key_id_fkey;", + "ALTER TABLE saved_session_key_import_ids ADD CONSTRAINT saved_session_key_import_ids_key_id_fkey FOREIGN KEY (key_id) REFERENCES saved_session_keys(id);", + ) + # will add another constraint later for the new table in a separate commit, otherwise it'll complain that things are missing + # step( + # "ALTER TABLE saved_session_key_import_ids ADD CONSTRAINT saved_session_key_import_ids_key_id_fkey FOREIGN KEY (key_id) REFERENCES saved_session_keys(id);", + # "ALTER TABLE saved_session_key_import_ids DROP CONSTRAINT saved_session_key_import_ids_key_id_fkey;" + # ), +] diff --git a/db/migrations/20211014_02_J099n-add-indexes-to-new-saved-key-table.py b/db/migrations/20211014_02_J099n-add-indexes-to-new-saved-key-table.py new file mode 100644 index 0000000..0268d22 --- /dev/null +++ b/db/migrations/20211014_02_J099n-add-indexes-to-new-saved-key-table.py @@ -0,0 +1,18 @@ +""" +Add indexes to new saved key table +""" + +from yoyo import step + +__depends__ = {"20211014_01_1hR6J-add-sha256hash-column-to-saved-key-table"} + +steps = [ + step( + 'CREATE INDEX saved_session_keys_with_hashes_contributor_idx ON saved_session_keys_with_hashes USING btree ("contributor_id")', + "DROP INDEX saved_session_keys_with_hashes_contributor_idx", + ), + step( + 'CREATE INDEX saved_session_keys_with_hashes_dead_idx ON saved_session_keys_with_hashes USING btree ("dead")', + "DROP INDEX saved_session_keys_with_hashes_dead_idx", + ), +] diff --git a/db/migrations/20211028_01_k4D9Q-add-indexes-to-flag-table.py b/db/migrations/20211028_01_k4D9Q-add-indexes-to-flag-table.py new file mode 100644 index 0000000..b1daacc --- /dev/null +++ b/db/migrations/20211028_01_k4D9Q-add-indexes-to-flag-table.py @@ -0,0 +1,13 @@ +""" +Add indexes to flag table +""" + +from yoyo import step + +__depends__ = {"20211014_02_J099n-add-indexes-to-new-saved-key-table"} + +steps = [ + step('CREATE INDEX flag_id_idx ON booru_flags USING btree ("id")', "DROP INDEX flag_id_idx"), + step('CREATE INDEX flag_user_idx ON booru_flags USING btree ("user")', "DROP INDEX flag_user_idx"), + step('CREATE INDEX flag_service_idx ON booru_flags USING btree ("service")', "DROP INDEX flag_service_idx"), +] diff --git a/db/migrations/20211124_01_O8GOk-add-revisions-table.py b/db/migrations/20211124_01_O8GOk-add-revisions-table.py new file mode 100644 index 0000000..da20873 --- /dev/null +++ b/db/migrations/20211124_01_O8GOk-add-revisions-table.py @@ -0,0 +1,30 @@ +""" +Add revisions table +""" + +from yoyo import step + +__depends__ = {"20211028_01_k4D9Q-add-indexes-to-flag-table"} + +steps = [ + step( + """ + CREATE TABLE revisions ( + "revision_id" SERIAL PRIMARY KEY, + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "title" text NOT NULL DEFAULT '', + "content" text NOT NULL DEFAULT '', + "embed" jsonb NOT NULL DEFAULT '{}', + "shared_file" boolean NOT NULL DEFAULT '0', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "edited" timestamp, + "file" jsonb NOT NULL, + "attachments" jsonb[] NOT NULL + ); + """, + "DROP TABLE revisions", + ) +] diff --git a/db/migrations/20211212_01_K1PlV-add-size-and-image-hash-columns-to-file-table.py b/db/migrations/20211212_01_K1PlV-add-size-and-image-hash-columns-to-file-table.py new file mode 100644 index 0000000..bb6e3a1 --- /dev/null +++ b/db/migrations/20211212_01_K1PlV-add-size-and-image-hash-columns-to-file-table.py @@ -0,0 +1,18 @@ +""" +Add size and image hash columns to file table +""" + +from yoyo import step + +__depends__ = {"20211124_01_O8GOk-add-revisions-table"} + +steps = [ + step( + "ALTER TABLE files ADD COLUMN size int", + "ALTER TABLE files DROP COLUMN size", + ), + step( + "ALTER TABLE files ADD COLUMN ihash varchar", + "ALTER TABLE files DROP COLUMN ihash", + ), +] diff --git a/db/migrations/20211212_02_LdfLH-change-type-of-size-column-in-file-table.py b/db/migrations/20211212_02_LdfLH-change-type-of-size-column-in-file-table.py new file mode 100644 index 0000000..0f1706a --- /dev/null +++ b/db/migrations/20211212_02_LdfLH-change-type-of-size-column-in-file-table.py @@ -0,0 +1,14 @@ +""" +Change type of size column in file table +""" + +from yoyo import step + +__depends__ = {"20211212_01_K1PlV-add-size-and-image-hash-columns-to-file-table"} + +steps = [ + step( + "ALTER TABLE files ALTER COLUMN size TYPE bigint", + "ALTER TABLE files ALTER COLUMN size TYPE int", + ) +] diff --git a/db/migrations/20220709_01_vMJ3S-add-newsletter-table.py b/db/migrations/20220709_01_vMJ3S-add-newsletter-table.py new file mode 100644 index 0000000..7fdf108 --- /dev/null +++ b/db/migrations/20220709_01_vMJ3S-add-newsletter-table.py @@ -0,0 +1,35 @@ +""" +add newsletter table +""" + +from yoyo import step + +__depends__ = {"20211212_02_LdfLH-change-type-of-size-column-in-file-table"} + +steps = [ + step( + """ + CREATE TABLE fantia_newsletters ( + id varchar not null, + user_id varchar not null, + content text not null, + added timestamp not null default CURRENT_TIMESTAMP, + published timestamp, + PRIMARY KEY (id) + ); + """, + "DROP TABLE fantia_newsletters;", + ), + step( + "CREATE INDEX fantia_newsletters_user_id_idx ON fantia_newsletters USING btree (user_id)", + "DROP INDEX fantia_newsletters_user_id_idx", + ), + step( + "CREATE INDEX fantia_newsletters_added_idx ON fantia_newsletters USING btree (added)", + "DROP INDEX fantia_newsletters_added_idx", + ), + step( + "CREATE INDEX fantia_newsletters_published_idx ON fantia_newsletters USING btree (published)", + "DROP INDEX fantia_newsletters_published_idx", + ), +] diff --git a/db/migrations/20220709_02_tUc0A-rename-newsletter-tables.py b/db/migrations/20220709_02_tUc0A-rename-newsletter-tables.py new file mode 100644 index 0000000..380587b --- /dev/null +++ b/db/migrations/20220709_02_tUc0A-rename-newsletter-tables.py @@ -0,0 +1,26 @@ +""" +rename newsletter tables +""" + +from yoyo import step + +__depends__ = {"20220709_01_vMJ3S-add-newsletter-table"} + +steps = [ + step( + "ALTER TABLE fantia_newsletters RENAME TO fanbox_newsletters", + "ALTER TABLE fanbox_newsletters RENAME TO fantia_newsletters", + ), + step( + "ALTER INDEX fantia_newsletters_user_id_idx RENAME TO fanbox_newsletters_user_id_idx", + "ALTER INDEX fanbox_newsletters_user_id_idx RENAME TO fantia_newsletters_user_id_idx", + ), + step( + "ALTER INDEX fantia_newsletters_added_idx RENAME TO fanbox_newsletters_added_idx", + "ALTER INDEX fanbox_newsletters_added_idx RENAME TO fantia_newsletters_added_idx", + ), + step( + "ALTER INDEX fantia_newsletters_published_idx RENAME TO fanbox_newsletters_published_idx", + "ALTER INDEX fanbox_newsletters_published_idx RENAME TO fantia_newsletters_published_idx", + ), +] diff --git a/db/migrations/20221109_01_l9fHN-add-fanbox-embeds-table.py b/db/migrations/20221109_01_l9fHN-add-fanbox-embeds-table.py new file mode 100644 index 0000000..35fe4ca --- /dev/null +++ b/db/migrations/20221109_01_l9fHN-add-fanbox-embeds-table.py @@ -0,0 +1,41 @@ +""" +Add fanbox_embeds table +""" + +from yoyo import step + +__depends__ = {"20220709_02_tUc0A-rename-newsletter-tables"} + +steps = [ + step( + """ + CREATE TABLE fanbox_embeds ( + id varchar not null, + user_id varchar not null, + post_id varchar not null, + type varchar not null, + json varchar not null, + added timestamp not null default CURRENT_TIMESTAMP, + processed boolean not null default false, + PRIMARY KEY (id) + ); + """, + "DROP TABLE fanbox_embeds;", + ), + step( + "CREATE INDEX fanbox_embeds_user_id_idx ON fanbox_embeds USING btree (user_id)", + "DROP INDEX fanbox_embeds_user_id_idx", + ), + step( + "CREATE INDEX fanbox_embeds_post_id_idx ON fanbox_embeds USING btree (post_id)", + "DROP INDEX fanbox_embeds_post_id_idx", + ), + step( + "CREATE INDEX fanbox_embeds_added_idx ON fanbox_embeds USING btree (added)", + "DROP INDEX fanbox_embeds_added_idx", + ), + step( + "CREATE INDEX fanbox_embeds_type_idx ON fanbox_embeds USING btree (type)", + "DROP INDEX fanbox_embeds_type_idx", + ), +] diff --git a/db/migrations/20221111_01_jQjnV-add-processed-json-column-to-fanbox-embeds.py b/db/migrations/20221111_01_jQjnV-add-processed-json-column-to-fanbox-embeds.py new file mode 100644 index 0000000..14aa610 --- /dev/null +++ b/db/migrations/20221111_01_jQjnV-add-processed-json-column-to-fanbox-embeds.py @@ -0,0 +1,18 @@ +""" +Fix fanbox_embeds processed column +""" + +from yoyo import step + +__depends__ = {"20221109_01_l9fHN-add-fanbox-embeds-table"} + +steps = [ + step( + "ALTER TABLE fanbox_embeds DROP COLUMN processed", + "ALTER TABLE fanbox_embeds ADD COLUMN processed boolean not null default false", + ), + step( + "ALTER TABLE fanbox_embeds ADD COLUMN processed varchar", + "ALTER TABLE fanbox_embeds DROP COLUMN processed", + ), +] diff --git a/db/migrations/20221113_01_24prS-add-fancards-table.py b/db/migrations/20221113_01_24prS-add-fancards-table.py new file mode 100644 index 0000000..a794fe4 --- /dev/null +++ b/db/migrations/20221113_01_24prS-add-fancards-table.py @@ -0,0 +1,24 @@ +""" +Add fancards table +""" + +from yoyo import step + +__depends__ = {"20221111_01_jQjnV-add-processed-json-column-to-fanbox-embeds"} + +steps = [ + step( + """ + CREATE TABLE fanbox_fancards ( + id serial primary key, + user_id varchar not null, + file_id int not null references files(id), + UNIQUE (user_id, file_id) + ); + """ + ), + step( + "CREATE INDEX fanbox_fancards_user_id_idx ON fanbox_fancards USING btree (user_id)", + "DROP INDEX fanbox_fancards_user_id_idx", + ), +] diff --git a/db/migrations/20230124_01_aT7eI-add-welcome-message-table.py b/db/migrations/20230124_01_aT7eI-add-welcome-message-table.py new file mode 100644 index 0000000..e285ee2 --- /dev/null +++ b/db/migrations/20230124_01_aT7eI-add-welcome-message-table.py @@ -0,0 +1,22 @@ +""" +Add welcome message table +""" + +from yoyo import step + +__depends__ = {"20211212_02_LdfLH-change-type-of-size-column-in-file-table"} + +steps = [ + step( + """ + CREATE TABLE introductory_messages ( + service varchar not null, + user_id varchar not null, + hash varchar not null, + content varchar not null, + added timestamp not null DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (service, user_id, hash) + ); + """ + ) +] diff --git a/db/migrations/20230408_00_FLSD-add-post-incomplete-rewards.py b/db/migrations/20230408_00_FLSD-add-post-incomplete-rewards.py new file mode 100644 index 0000000..71a6e74 --- /dev/null +++ b/db/migrations/20230408_00_FLSD-add-post-incomplete-rewards.py @@ -0,0 +1,21 @@ +""" +Add table for incomplete posts +""" + +from yoyo import step + +__depends__ = {"20230124_01_aT7eI-add-welcome-message-table"} + +steps = [ + step( + """ + CREATE TABLE posts_incomplete_rewards ( + id varchar(255) NOT NULL, + service varchar(20) NOT NULL, + last_checked_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + incomplete_attachments_info jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT posts_incomplete_rewards_pkey PRIMARY KEY (id, service) + ); + """ + ) +] diff --git a/db/migrations/20230507_00_d10V9-add-service-to-post-incomplete-rewards.py b/db/migrations/20230507_00_d10V9-add-service-to-post-incomplete-rewards.py new file mode 100644 index 0000000..df7d684 --- /dev/null +++ b/db/migrations/20230507_00_d10V9-add-service-to-post-incomplete-rewards.py @@ -0,0 +1,28 @@ +""" +Add service in table for incomplete posts +""" + +from yoyo import step + +__depends__ = {"20230408_00_FLSD-add-post-incomplete-rewards"} + +steps = [ + step( + """ + ALTER TABLE posts_incomplete_rewards ADD COLUMN "user" varchar(255) NULL; + """ + ), + step( + """ + UPDATE posts_incomplete_rewards pir + SET "user" = p."user" + FROM posts p + WHERE pir.id = p.id AND pir.service = p.service; + """ + ), + step( + """ + CREATE INDEX posts_incomplete_rewards_service_user_idx ON posts_incomplete_rewards USING btree (service, "user"); + """ + ), +] diff --git a/db/migrations/20230517_00_Nt351-add-complete-imports-table.py b/db/migrations/20230517_00_Nt351-add-complete-imports-table.py new file mode 100644 index 0000000..d51f6f2 --- /dev/null +++ b/db/migrations/20230517_00_Nt351-add-complete-imports-table.py @@ -0,0 +1,22 @@ +""" +Add table complete_imports +""" + +from yoyo import step + +__depends__ = {"20230507_00_d10V9-add-service-to-post-incomplete-rewards"} + +steps = [ + step( + """ + CREATE TABLE complete_imports ( + user_id varchar(255) NOT NULL, + service varchar(20) NOT NULL, + subscription varchar(255) NOT NULL, + last_successful_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + info jsonb NOT NULL DEFAULT '{}'::jsonb, + CONSTRAINT complete_imports_pkey PRIMARY KEY (user_id, service, subscription) + ); + """ + ), +] diff --git a/db/migrations/20230616_00_TALu5-add_iframely-data-to-embed.py b/db/migrations/20230616_00_TALu5-add_iframely-data-to-embed.py new file mode 100644 index 0000000..31ce50a --- /dev/null +++ b/db/migrations/20230616_00_TALu5-add_iframely-data-to-embed.py @@ -0,0 +1,17 @@ +""" +Add table complete_imports +""" + +from yoyo import step + +__depends__ = {"20230517_00_Nt351-add-complete-imports-table"} + +steps = [ + step( + """ + ALTER TABLE fanbox_embeds ADD COLUMN "iframely_key" varchar(255) NULL; + ALTER TABLE fanbox_embeds ADD COLUMN "iframely_data" jsonb NULL; + ALTER TABLE fanbox_embeds ADD COLUMN "iframely_url" text NULL; + """ + ), +] diff --git a/db/migrations/20230827_00_USDf5-add-index-to-revisions.py b/db/migrations/20230827_00_USDf5-add-index-to-revisions.py new file mode 100644 index 0000000..3659d38 --- /dev/null +++ b/db/migrations/20230827_00_USDf5-add-index-to-revisions.py @@ -0,0 +1,15 @@ +""" +Add table complete_imports +""" + +from yoyo import step + +__depends__ = {"20230616_00_TALu5-add_iframely-data-to-embed"} + +steps = [ + step( + """ + CREATE INDEX revisions_id_idx ON revisions USING hash (id); + """ + ), +] diff --git a/db/migrations/20230827_18_JPYk7T-change-discord-channel-idx.py b/db/migrations/20230827_18_JPYk7T-change-discord-channel-idx.py new file mode 100644 index 0000000..1615086 --- /dev/null +++ b/db/migrations/20230827_18_JPYk7T-change-discord-channel-idx.py @@ -0,0 +1,20 @@ +""" +Add table complete_imports +""" + +from yoyo import step + +__depends__ = {"20230827_00_USDf5-add-index-to-revisions"} + +steps = [ + step( + """ + CREATE INDEX discord_posts_channel_published_idx ON discord_posts USING btree (channel, published); + """ + ), + step( + """ + DROP INDEX IF EXISTS channel_idx; + """ + ), +] diff --git a/db/migrations/20230901_12_KR36h-index-announcements-idx.py b/db/migrations/20230901_12_KR36h-index-announcements-idx.py new file mode 100644 index 0000000..57dbab9 --- /dev/null +++ b/db/migrations/20230901_12_KR36h-index-announcements-idx.py @@ -0,0 +1,15 @@ +""" +Add table complete_imports +""" + +from yoyo import step + +__depends__ = {"20230827_00_USDf5-add-index-to-revisions"} + +steps = [ + step( + """ + CREATE INDEX introductory_messages_user_id_added_idx ON public.introductory_messages USING btree (user_id , added); + """ + ), +] diff --git a/db/migrations/20230903_00_CHN7so-remake_fanbox_newsletter.py b/db/migrations/20230903_00_CHN7so-remake_fanbox_newsletter.py new file mode 100644 index 0000000..a939d08 --- /dev/null +++ b/db/migrations/20230903_00_CHN7so-remake_fanbox_newsletter.py @@ -0,0 +1,41 @@ +""" +Add table complete_imports +""" + +from yoyo import step + +__depends__ = {"20230827_00_USDf5-add-index-to-revisions"} + +steps = [ + step( + """ + CREATE TABLE public.fanbox_newsletters_temp_new ( + user_id varchar NOT NULL, + hash varchar NOT NULL, + "content" varchar NOT NULL, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + published timestamp NOT NULL, + CONSTRAINT fanbox_newsletters_pkey PRIMARY KEY (user_id, hash) + ); + + CREATE EXTENSION IF NOT EXISTS pgcrypto; + + INSERT INTO public.fanbox_newsletters_temp_new (user_id, "hash", "content", added, published) + SELECT + user_id, + ENCODE(DIGEST(content, 'sha256'), 'hex'), + content, + MIN(added) AS min_added, + MIN(published) AS min_published + FROM public.fanbox_newsletters + GROUP BY user_id, ENCODE(DIGEST(content, 'sha256'), 'hex'), content + ON CONFLICT (user_id, "hash") DO NOTHING; + + CREATE INDEX fanbox_newsletters_user_id_published_idx ON public.fanbox_newsletters_temp_new USING btree (user_id , published); + + ALTER TABLE fanbox_newsletters RENAME TO fanbox_newsletters_temp_old; + + ALTER TABLE fanbox_newsletters_temp_new RENAME TO fanbox_newsletters; + """ + ), +] diff --git a/db/migrations/20230909_00_AUS5i6-new-index-for-post-get-prev-next.py b/db/migrations/20230909_00_AUS5i6-new-index-for-post-get-prev-next.py new file mode 100644 index 0000000..039318e --- /dev/null +++ b/db/migrations/20230909_00_AUS5i6-new-index-for-post-get-prev-next.py @@ -0,0 +1,11 @@ +""" +Add table complete_imports +""" + +from yoyo import step + +__depends__ = {"20230903_00_CHN7so-remake_fanbox_newsletter"} + +steps = [ + step("""CREATE INDEX IF NOT EXISTS posts_user_published_id_idx ON posts USING btree ("user", published, id);"""), +] diff --git a/db/migrations/20230930_00_TNd34a-add-created-timestamp-to-favs.py b/db/migrations/20230930_00_TNd34a-add-created-timestamp-to-favs.py new file mode 100644 index 0000000..f2d06b7 --- /dev/null +++ b/db/migrations/20230930_00_TNd34a-add-created-timestamp-to-favs.py @@ -0,0 +1,109 @@ +""" +Add timestamp to faves +""" + +from yoyo import step + +__depends__ = {"20230909_00_AUS5i6-new-index-for-post-get-prev-next"} + +steps = [ + step("""ALTER TABLE public.account_post_favorite ADD COLUMN created_at timestamp null;"""), + step("""ALTER TABLE public.account_post_favorite ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP;"""), + step("""ALTER TABLE public.account_artist_favorite ADD COLUMN created_at timestamp null;"""), + step("""ALTER TABLE public.account_artist_favorite ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP;"""), +] + + +""" +run these by hand optionally + +WITH RollingMaxDates AS ( + SELECT + apf.id, + MAX(p.added) OVER (ORDER BY apf.id ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rolling_max_date + FROM public.account_post_favorite apf + LEFT JOIN public.posts p ON apf.service = p.service AND apf.post_id = p.id +), + +RollingMaxDatesMinMaxIds AS ( + SELECT + MIN(RollingMaxDates.id) AS min_id, + MAX(RollingMaxDates.id) AS max_id, + rolling_max_date + FROM RollingMaxDates + GROUP BY rolling_max_date + ORDER BY min_id ASC +), + +DurationPerIdPerRange AS ( + SELECT + a.min_id AS first_id_for_date_range, + a.max_id AS last_id_for_date_range, + a.rolling_max_date AS first_rolling_max_date, + MIN(b.rolling_max_date) AS last_rolling_max_date, + (MIN(b.rolling_max_date) - a.rolling_max_date) / GREATEST(a.max_id - a.min_id, 1) AS duration_per_id + FROM RollingMaxDatesMinMaxIds a + LEFT JOIN RollingMaxDatesMinMaxIds b ON a.max_id < b.min_id + GROUP BY a.min_id, a.max_id, a.rolling_max_date + ORDER BY a.min_id +) + +UPDATE public.account_post_favorite apf +SET created_at = data_to_insert.created_at +FROM ( + SELECT + apf.id, + dpipr.first_rolling_max_date + dpipr.duration_per_id * (apf.id - dpipr.first_id_for_date_range) AS created_at + FROM public.account_post_favorite apf + LEFT JOIN DurationPerIdPerRange dpipr ON apf.id >= dpipr.first_id_for_date_range AND apf.id <= dpipr.last_id_for_date_range +) as data_to_insert +WHERE apf.id = data_to_insert.id; + + + +WITH RollingMaxDates AS ( + SELECT + aaf.id, + MAX(l.indexed) OVER (ORDER BY aaf.id ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rolling_max_date + FROM public.account_artist_favorite aaf + LEFT JOIN public.lookup l ON aaf.service = l.service AND aaf.artist_id = l.id +), + +RollingMaxDatesMinMaxIds AS ( + SELECT + MIN(RollingMaxDates.id) AS min_id, + MAX(RollingMaxDates.id) AS max_id, + rolling_max_date + FROM RollingMaxDates + GROUP BY rolling_max_date + ORDER BY min_id ASC +), + +DurationPerIdPerRange AS ( + SELECT + a.min_id AS first_id_for_date_range, + a.max_id AS last_id_for_date_range, + a.rolling_max_date AS first_rolling_max_date, + MIN(b.rolling_max_date) AS last_rolling_max_date, + (MIN(b.rolling_max_date) - a.rolling_max_date) / GREATEST(a.max_id - a.min_id, 1) AS duration_per_id + FROM RollingMaxDatesMinMaxIds a + LEFT JOIN RollingMaxDatesMinMaxIds b ON a.max_id < b.min_id + GROUP BY a.min_id, a.max_id, a.rolling_max_date + ORDER BY a.min_id +) + + +UPDATE public.account_artist_favorite aaf +SET created_at = data_to_insert.created_at +FROM ( + SELECT + aaf.id, + dpipr.first_rolling_max_date + dpipr.duration_per_id * (aaf.id - dpipr.first_id_for_date_range) AS created_at + FROM public.account_artist_favorite aaf + LEFT JOIN DurationPerIdPerRange dpipr ON aaf.id >= dpipr.first_id_for_date_range AND aaf.id <= dpipr.last_id_for_date_range +) as data_to_insert +WHERE aaf.id = data_to_insert.id; + + + +""" diff --git a/db/migrations/20231014_00_MU90C3-match-things-to-prod.py b/db/migrations/20231014_00_MU90C3-match-things-to-prod.py new file mode 100644 index 0000000..b216824 --- /dev/null +++ b/db/migrations/20231014_00_MU90C3-match-things-to-prod.py @@ -0,0 +1,116 @@ +""" +Mock this migration of changes that are already in production +""" + +from yoyo import step + +__depends__ = {"20230930_00_TNd34a-add-created-timestamp-to-favs"} + +steps = [ + step( + """ + DROP INDEX IF EXISTS public.dm_search_idx; + DROP INDEX IF EXISTS public.search_idx; + """ + ), + step( + """ + ALTER TABLE public.account_artist_favorite DROP CONSTRAINT IF EXISTS account_artist_favorite_pkey; + ALTER TABLE public.account_artist_favorite ADD CONSTRAINT account_artist_favorite_pkey PRIMARY KEY (service, id); + """ + ), + step( + """ + ALTER TABLE public.account_post_favorite DROP CONSTRAINT IF EXISTS account_post_favorite_pkey; + ALTER TABLE public.account_post_favorite ADD CONSTRAINT account_post_favorite_pkey PRIMARY KEY (service, id); + """ + ), +] + + +""" +/* In production we have these partitioned like this */ +BEGIN; + +ALTER SEQUENCE IF EXISTS account_artist_favorite_id_seq RENAME TO TBD_account_artist_favorite_id_seq; +ALTER SEQUENCE IF EXISTS account_post_favorite_id_seq RENAME TO TBD_account_post_favorite_id; + +ALTER TABLE account_artist_favorite RENAME TO TBD_account_artist_favorite; +ALTER TABLE account_post_favorite RENAME TO TBD_account_post_favorite; + +ALTER TABLE TBD_account_artist_favorite RENAME CONSTRAINT account_artist_favorite_account_id_fkey TO TBD_account_artist_favorite_account_id_fkey; +ALTER TABLE TBD_account_post_favorite RENAME CONSTRAINT account_post_favorite_account_id_fkey TO TBD_account_post_favorite_account_id_fkey; + +ALTER INDEX IF EXISTS account_post_favorite_pkey RENAME TO TBD_account_post_favorite_pkey; +ALTER INDEX IF EXISTS account_post_favorite_account_id_service_artist_id_post_id_key RENAME TO TBD_account_post_favorite_account_id_service_artist_id_post_id_key; +ALTER INDEX IF EXISTS account_post_favorite_service_artist_id_post_id_idx RENAME TO TBD_account_post_favorite_service_artist_id_post_id_idx; +ALTER INDEX IF EXISTS account_artist_favorite_pkey RENAME TO TBD_account_artist_favorite_pkey; +ALTER INDEX IF EXISTS account_artist_favorite_account_id_service_artist_id_key RENAME TO TBD_account_artist_favorite_account_id_service_artist_id_key; +ALTER INDEX IF EXISTS account_artist_favorite_service_artist_id_idx RENAME TO TBD_account_artist_favorite_service_artist_id_idx; + + +create table account_artist_favorite ( + id serial, + account_id int not null references account(id), + service varchar not null, + artist_id varchar not null, + unique (account_id, service, artist_id), + PRIMARY KEY(service, id) +) PARTITION BY LIST (service); + +create table account_post_favorite ( + id serial, + account_id int not null references account(id), + service varchar not null, + artist_id varchar not null, + post_id varchar not null, + unique (account_id, service, artist_id, post_id), + PRIMARY KEY(service, id) +) PARTITION BY LIST (service); + + +CREATE INDEX account_artist_favorite_service_artist_id_idx ON account_artist_favorite USING btree (service, artist_id); +CREATE INDEX account_post_favorite_service_artist_id_post_id_idx ON account_post_favorite USING btree (service, artist_id, post_id); + +CREATE TABLE account_artist_favorite_DEFAULT PARTITION OF account_artist_favorite DEFAULT; +CREATE TABLE account_artist_favorite_patreon PARTITION OF account_artist_favorite FOR VALUES IN ('patreon'); +CREATE TABLE account_artist_favorite_fantia PARTITION OF account_artist_favorite FOR VALUES IN ('fantia'); +CREATE TABLE account_artist_favorite_fanbox PARTITION OF account_artist_favorite FOR VALUES IN ('fanbox'); +CREATE TABLE account_artist_favorite_gumroad PARTITION OF account_artist_favorite FOR VALUES IN ('gumroad'); +CREATE TABLE account_artist_favorite_dlsite PARTITION OF account_artist_favorite FOR VALUES IN ('dlsite'); +CREATE TABLE account_artist_favorite_discord PARTITION OF account_artist_favorite FOR VALUES IN ('discord'); +CREATE TABLE account_artist_favorite_afdian PARTITION OF account_artist_favorite FOR VALUES IN ('afdian'); +CREATE TABLE account_artist_favorite_boosty PARTITION OF account_artist_favorite FOR VALUES IN ('boosty'); +CREATE TABLE account_artist_favorite_subscribestar PARTITION OF account_artist_favorite FOR VALUES IN ('subscribestar'); + +CREATE TABLE account_post_favorite_DEFAULT PARTITION OF account_post_favorite DEFAULT; +CREATE TABLE account_post_favorite_patreon PARTITION OF account_post_favorite FOR VALUES IN ('patreon'); +CREATE TABLE account_post_favorite_fantia PARTITION OF account_post_favorite FOR VALUES IN ('fantia'); +CREATE TABLE account_post_favorite_fanbox PARTITION OF account_post_favorite FOR VALUES IN ('fanbox'); +CREATE TABLE account_post_favorite_gumroad PARTITION OF account_post_favorite FOR VALUES IN ('gumroad'); +CREATE TABLE account_post_favorite_dlsite PARTITION OF account_post_favorite FOR VALUES IN ('dlsite'); +CREATE TABLE account_post_favorite_discord PARTITION OF account_post_favorite FOR VALUES IN ('discord'); +CREATE TABLE account_post_favorite_afdian PARTITION OF account_post_favorite FOR VALUES IN ('afdian'); +CREATE TABLE account_post_favorite_boosty PARTITION OF account_post_favorite FOR VALUES IN ('boosty'); +CREATE TABLE account_post_favorite_subscribestar PARTITION OF account_post_favorite FOR VALUES IN ('subscribestar'); + + + +INSERT INTO account_artist_favorite (account_id, service, artist_id) SELECT account_id, service, artist_id FROM TBD_account_artist_favorite; +INSERT INTO account_post_favorite (account_id, service, artist_id, post_id) SELECT account_id, service, artist_id, post_id FROM TBD_account_post_favorite; + +DROP TABLE IF EXISTS TBD_account_artist_favorite; +DROP TABLE IF EXISTS TBD_account_post_favorite; + +COMMIT; + + +ANALYZE account_artist_favorite_DEFAULT, account_artist_favorite_patreon, account_artist_favorite_fantia, account_artist_favorite_fanbox, account_artist_favorite_gumroad, account_artist_favorite_gumroad, account_artist_favorite_dlsite, account_artist_favorite_discord, account_artist_favorite_afdian, account_artist_favorite_boosty, account_artist_favorite_subscribestar; +ANALYZE account_post_favorite_DEFAULT, account_post_favorite_patreon, account_post_favorite_fantia, account_post_favorite_fanbox, account_post_favorite_gumroad, account_post_favorite_gumroad, account_post_favorite_dlsite, account_post_favorite_discord, account_post_favorite_afdian, account_post_favorite_boosty, account_post_favorite_subscribestar; + +COPY distributors FROM 'input_file'; +SELECT setval('serial', max(id)) FROM distributors; + + +INSERT INTO account_artist_favorite (account_id, service, artist_id) SELECT account_id, service, artist_id FROM account_artist_favorite_orig; +""" diff --git a/db/migrations/20231014_01_D23j23-discord-server-index.py b/db/migrations/20231014_01_D23j23-discord-server-index.py new file mode 100644 index 0000000..2e2a3b8 --- /dev/null +++ b/db/migrations/20231014_01_D23j23-discord-server-index.py @@ -0,0 +1,13 @@ +""" +Some index that was added by hand before +""" + +from yoyo import step + +__depends__ = {"20230930_00_TNd34a-add-created-timestamp-to-favs"} + +steps = [ + step( + "CREATE INDEX IF NOT EXISTS discord_posts_server_channel_idx ON public.discord_posts USING btree (server, channel);" + ), +] diff --git a/db/migrations/20231014_01_KV93UG-shares.py b/db/migrations/20231014_01_KV93UG-shares.py new file mode 100644 index 0000000..5558a63 --- /dev/null +++ b/db/migrations/20231014_01_KV93UG-shares.py @@ -0,0 +1,60 @@ +""" +Shares wasnt in yoyo migrations +""" + +from yoyo import step + +__depends__ = {"20230930_00_TNd34a-add-created-timestamp-to-favs"} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS public.shares ( + id serial4 NOT NULL, + "name" varchar NOT NULL, + description varchar NOT NULL, + uploader int4 NULL, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT shares_pkey PRIMARY KEY (id), + CONSTRAINT shares_uploader_fkey FOREIGN KEY (uploader) REFERENCES public.account(id) + ); + CREATE INDEX IF NOT EXISTS shares_added_idx ON public.shares USING btree (added); + CREATE INDEX IF NOT EXISTS shares_uploader_idx ON public.shares USING btree (uploader); + """ + ), + step( + """ + CREATE TABLE IF NOT EXISTS public.lookup_share_relationships ( + share_id int4 NOT NULL, + service varchar NOT NULL, + user_id varchar NOT NULL, + CONSTRAINT lookup_share_relationships_pkey PRIMARY KEY (share_id, service, user_id), + CONSTRAINT lookup_share_relationships_service_user_id_fkey FOREIGN KEY (service,user_id) REFERENCES public.lookup(service, id), + CONSTRAINT lookup_share_relationships_share_id_fkey FOREIGN KEY (share_id) REFERENCES public.shares(id) + ); + """ + ), + step( + """ + CREATE TABLE IF NOT EXISTS public.file_share_relationships ( + share_id int4 NOT NULL, + upload_url varchar NOT NULL, + upload_id varchar NOT NULL, + file_id int4 NULL, + filename varchar NOT NULL, + CONSTRAINT file_share_relationships_pkey PRIMARY KEY (share_id, upload_id), + CONSTRAINT file_share_relationships_file_id_fkey FOREIGN KEY (file_id) REFERENCES public.files(id), + CONSTRAINT file_share_relationships_share_id_fkey FOREIGN KEY (share_id) REFERENCES public.shares(id) + ); + """ + ), + step("""DROP INDEX IF EXISTS file_share_id_idx;"""), +] + +""" +Use this to remove duplicates from file_server_relationships + +DELETE FROM public.file_server_relationships T1 +using public.file_server_relationships T2 +WHERE T1.ctid > T2.ctid AND T1.file_id = T2.file_id AND T1.remote_path = T2.remote_path +""" diff --git a/db/migrations/20231014_01_fod03f-dnp-import-field.py b/db/migrations/20231014_01_fod03f-dnp-import-field.py new file mode 100644 index 0000000..230ac5a --- /dev/null +++ b/db/migrations/20231014_01_fod03f-dnp-import-field.py @@ -0,0 +1,12 @@ +""" +DNP was not in yoyo migrations before... +""" + +from yoyo import step + +__depends__ = {"20230930_00_TNd34a-add-created-timestamp-to-favs"} + +steps = [ + step("""ALTER TABLE "public"."dnp" ADD COLUMN IF NOT EXISTS "import" boolean NOT NULL DEFAULT FALSE;"""), + step("""ALTER TABLE "public"."dnp" ALTER COLUMN "import" SET DEFAULT TRUE;"""), +] diff --git a/db/migrations/20231014_02_2a1d34-post-added-max.py b/db/migrations/20231014_02_2a1d34-post-added-max.py new file mode 100644 index 0000000..af85562 --- /dev/null +++ b/db/migrations/20231014_02_2a1d34-post-added-max.py @@ -0,0 +1,42 @@ +""" +This didnt exist in yoyo migrations but we had it in production, mock this migration in production +""" + +from yoyo import step + +__depends__ = {"20210118_01_1Jlkq-add-unique-constraint-to-service-and-post-fields"} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS public.posts_added_max ( + "user" varchar NOT NULL, + service varchar NOT NULL, + added timestamp NOT NULL, + CONSTRAINT posts_added_max_pkey PRIMARY KEY ("user", service) + ); + """ + ), + step( + """ +CREATE OR REPLACE FUNCTION public.posts_added_max() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + INSERT INTO posts_added_max AS pam ("user", service, added) + SELECT "user", service, max(added) AS added FROM posts + WHERE posts.service = NEW.service + AND posts."user" = NEW."user" + GROUP BY "user", service + ON CONFLICT (service, "user") + DO UPDATE SET added = EXCLUDED.added + WHERE EXCLUDED.added > pam.added; + RETURN NULL; +END; +$$; + """ + ), + step( + "CREATE TRIGGER posts_added_max AFTER INSERT OR UPDATE ON public.posts FOR EACH ROW EXECUTE FUNCTION public.posts_added_max();" + ), +] diff --git a/db/migrations/20231014_02_s43efs-drop-logs.py b/db/migrations/20231014_02_s43efs-drop-logs.py new file mode 100644 index 0000000..9ef2506 --- /dev/null +++ b/db/migrations/20231014_02_s43efs-drop-logs.py @@ -0,0 +1,13 @@ +""" +Drop unused logs table +""" + +from yoyo import step + +__depends__ = {"20210118_01_1Jlkq-add-unique-constraint-to-service-and-post-fields"} + +steps = [ + step( + "DROP TABLE IF EXISTS logs;", + ) +] diff --git a/db/migrations/20231015_20_2f3f4i-remove-duplicate-flags.py b/db/migrations/20231015_20_2f3f4i-remove-duplicate-flags.py new file mode 100644 index 0000000..c94387c --- /dev/null +++ b/db/migrations/20231015_20_2f3f4i-remove-duplicate-flags.py @@ -0,0 +1,21 @@ +""" +Remove duplicate flags since there is no restriction +""" + +from yoyo import step + +__depends__ = {"20210118_01_1Jlkq-add-unique-constraint-to-service-and-post-fields"} + +steps = [ + step( + """ + DELETE FROM public.booru_flags T1 + using public.booru_flags T2 + WHERE T1.ctid > T2.ctid AND T1.id = T2.id AND T1."user" = T2."user" AND T1.service = T2.service; + + DROP INDEX IF EXISTS flag_id_idx, flag_service_idx, flag_user_idx; + + ALTER TABLE booru_flags ADD PRIMARY KEY (id, "user", service); + """ + ), +] diff --git a/db/migrations/20231031_00_UAEfd3-add-user-id-hash-to-keys.py b/db/migrations/20231031_00_UAEfd3-add-user-id-hash-to-keys.py new file mode 100644 index 0000000..8182536 --- /dev/null +++ b/db/migrations/20231031_00_UAEfd3-add-user-id-hash-to-keys.py @@ -0,0 +1,11 @@ +""" +Add user id hash to saved session keys +""" + +from yoyo import step + +__depends__ = {"20230909_00_AUS5i6-new-index-for-post-get-prev-next"} + +steps = [ + step("""ALTER TABLE public.saved_session_keys_with_hashes ADD remote_user_id_hash varchar NULL;"""), +] diff --git a/db/migrations/20231101_00_AMD325-add-key-dead-timestamp.py b/db/migrations/20231101_00_AMD325-add-key-dead-timestamp.py new file mode 100644 index 0000000..0839517 --- /dev/null +++ b/db/migrations/20231101_00_AMD325-add-key-dead-timestamp.py @@ -0,0 +1,14 @@ +""" +Add date key is made dead +""" + +from yoyo import step + +__depends__ = {"20230909_00_AUS5i6-new-index-for-post-get-prev-next"} + +steps = [ + step( + """ALTER TABLE public.saved_session_keys_with_hashes ADD dead_at timestamp NULL;""" + """UPDATE public.saved_session_keys_with_hashes SET dead_at = CURRENT_TIMESTAMP WHERE dead = true;""" + ), +] diff --git a/db/migrations/20231110_00_BENL23-add-poll-to-post-table.py b/db/migrations/20231110_00_BENL23-add-poll-to-post-table.py new file mode 100644 index 0000000..503dfb7 --- /dev/null +++ b/db/migrations/20231110_00_BENL23-add-poll-to-post-table.py @@ -0,0 +1,9 @@ +""" +Add poll to posts +""" + +from yoyo import step + +__depends__ = {"20230124_01_aT7eI-add-welcome-message-table"} + +steps = [step("""ALTER TABLE posts ADD COLUMN poll jsonb NULL;""")] diff --git a/db/migrations/20231110_00_MAC34-add-poll-to-revisions-table.py b/db/migrations/20231110_00_MAC34-add-poll-to-revisions-table.py new file mode 100644 index 0000000..a7d6dbb --- /dev/null +++ b/db/migrations/20231110_00_MAC34-add-poll-to-revisions-table.py @@ -0,0 +1,9 @@ +""" +Add poll to posts +""" + +from yoyo import step + +__depends__ = {"20231110_00_BENL23-add-poll-to-post-table"} + +steps = [step("""ALTER TABLE revisions ADD COLUMN poll jsonb NULL;""")] diff --git a/db/migrations/20231111_00_BQL75-add-captions-to-posts.py b/db/migrations/20231111_00_BQL75-add-captions-to-posts.py new file mode 100644 index 0000000..fad1c48 --- /dev/null +++ b/db/migrations/20231111_00_BQL75-add-captions-to-posts.py @@ -0,0 +1,12 @@ +""" +Add poll to posts +""" + +from yoyo import step + +__depends__ = {"20231110_00_BENL23-add-poll-to-post-table"} + +steps = [ + step("""ALTER TABLE posts ADD COLUMN captions jsonb NULL;"""), + step("""ALTER TABLE revisions ADD COLUMN captions jsonb NULL;"""), +] diff --git a/db/migrations/20231111_00_DEL88D-add-tags-to-posts.py b/db/migrations/20231111_00_DEL88D-add-tags-to-posts.py new file mode 100644 index 0000000..9ac34dd --- /dev/null +++ b/db/migrations/20231111_00_DEL88D-add-tags-to-posts.py @@ -0,0 +1,12 @@ +""" +Add poll to posts +""" + +from yoyo import step + +__depends__ = {"20231110_00_BENL23-add-poll-to-post-table"} + +steps = [ + step("""ALTER TABLE posts ADD COLUMN tags _text NULL;"""), + step("""ALTER TABLE revisions ADD COLUMN tags _text NULL;"""), +] diff --git a/db/migrations/20231114_00_FOA436-add-comment-revisions-table.py b/db/migrations/20231114_00_FOA436-add-comment-revisions-table.py new file mode 100644 index 0000000..7e15ddc --- /dev/null +++ b/db/migrations/20231114_00_FOA436-add-comment-revisions-table.py @@ -0,0 +1,28 @@ +""" +Add comments revisions +""" + +from yoyo import step + +__depends__ = {"20231111_00_DEL88D-add-tags-to-posts"} + +steps = [ + step( + """ + CREATE TABLE public.comments_revisions ( + revision_id serial4 NOT NULL, + id varchar(255) NOT NULL, + post_id varchar(255) NOT NULL, + parent_id varchar(255) NULL, + commenter varchar(255) NOT NULL, + service varchar(20) NOT NULL, + "content" text NOT NULL DEFAULT ''::text, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + published timestamp NULL, + CONSTRAINT comments_revisions_pkey PRIMARY KEY (revision_id) + ); + CREATE INDEX comments_revisions_post_id_idx ON public.comments_revisions USING btree (post_id); + CREATE INDEX comments_revisions_id_idx ON public.comments_revisions USING btree (id); + """ + ), +] diff --git a/db/migrations/20231115_00_TAI3e31-add-deleted_at-to-comments.py b/db/migrations/20231115_00_TAI3e31-add-deleted_at-to-comments.py new file mode 100644 index 0000000..fbe5497 --- /dev/null +++ b/db/migrations/20231115_00_TAI3e31-add-deleted_at-to-comments.py @@ -0,0 +1,13 @@ +""" +Add deleted_at to comments +""" + +from yoyo import step + +__depends__ = {"20231114_00_FOA436-add-comment-revisions-table"} + + +steps = [ + step("""ALTER TABLE comments ADD COLUMN deleted_at timestamp NULL;"""), + step("""ALTER TABLE comments_revisions ADD COLUMN deleted_at timestamp NULL;"""), +] diff --git a/db/migrations/20231118_00_SGATs3-change-tags-to-add-index-and-citext.py b/db/migrations/20231118_00_SGATs3-change-tags-to-add-index-and-citext.py new file mode 100644 index 0000000..6b43d7e --- /dev/null +++ b/db/migrations/20231118_00_SGATs3-change-tags-to-add-index-and-citext.py @@ -0,0 +1,13 @@ +""" +Add index to tags and change to citext +""" + +from yoyo import step + +__depends__ = {"20231115_00_TAI3e31-add-deleted_at-to-comments"} + +steps = [ + step("""CREATE EXTENSION IF NOT EXISTS citext;"""), + step("""ALTER TABLE public.posts ALTER COLUMN tags TYPE _CITEXT;"""), + step("""CREATE INDEX IF NOT EXISTS posts_tags_idx ON public.posts USING gin(tags);"""), +] diff --git a/db/migrations/20231119_00_ASHrR6-fix-revisions-table-to-match-tags-type.py b/db/migrations/20231119_00_ASHrR6-fix-revisions-table-to-match-tags-type.py new file mode 100644 index 0000000..b4bc05d --- /dev/null +++ b/db/migrations/20231119_00_ASHrR6-fix-revisions-table-to-match-tags-type.py @@ -0,0 +1,11 @@ +""" +Change revisions tags to citext +""" + +from yoyo import step + +__depends__ = {"20231118_00_SGATs3-change-tags-to-add-index-and-citext"} + +steps = [ + step("""ALTER TABLE public.revisions ALTER COLUMN tags TYPE _CITEXT;"""), +] diff --git a/db/migrations/20231123_00_OFL42-unapproved-dms-and-dms-remade.py b/db/migrations/20231123_00_OFL42-unapproved-dms-and-dms-remade.py new file mode 100644 index 0000000..d86edc9 --- /dev/null +++ b/db/migrations/20231123_00_OFL42-unapproved-dms-and-dms-remade.py @@ -0,0 +1,82 @@ +""" +Change completely the structure of DMs +""" + +from yoyo import step + +__depends__ = {'20231119_00_ASHrR6-fix-revisions-table-to-match-tags-type'} + +steps = [ + step(""" +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE public.dms_temp_new ( + "hash" varchar NOT NULL, + "user" varchar(255) NOT NULL, + service varchar(20) NOT NULL, + "content" text NOT NULL DEFAULT ''::text, + embed jsonb NOT NULL DEFAULT '{}'::jsonb, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + published timestamp NULL, + file jsonb NOT null, + CONSTRAINT dms_temp_new_pkey PRIMARY KEY ("hash","user", service) +); + +INSERT INTO public.dms_temp_new ("hash", "user", service, "content", embed, added, published, file) +select ENCODE(DIGEST("content", 'sha256'), 'hex'), "user", service, "content", embed, MIN(added), MIN(published), file +FROM public.dms +GROUP BY "content", "user", service, embed, file; + +ALTER TABLE dms RENAME TO dms_temp_old; +ALTER TABLE dms_temp_new RENAME TO dms; + +ALTER TABLE dms_temp_old RENAME CONSTRAINT dms_pkey TO dms_temp_old_pkey; +ALTER TABLE dms RENAME CONSTRAINT dms_temp_new_pkey TO dms_pkey; + +DO $$ +BEGIN + IF EXISTS(SELECT 1 FROM pg_indexes WHERE indexname = 'pgroonga_dms_idx') THEN + ALTER INDEX pgroonga_dms_idx RENAME TO pgroonga_dms_temp_old_idx; + END IF; +END $$; + + +CREATE INDEX dms_user_idx ON public.dms ("user"); + + + +CREATE TABLE public.unapproved_dms_temp_new ( + "hash" varchar NOT NULL, + "user" varchar(255) NOT NULL, + service varchar(20) NOT NULL, + contributor_id varchar(255) NOT NULL, + "content" text NOT NULL DEFAULT ''::text, + embed jsonb NOT NULL DEFAULT '{}'::jsonb, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + published timestamp NULL, + file jsonb NOT NULL, + import_id varchar(255) NOT NULL, + remote_user_id_hash varchar NULL, + deleted_at timestamp NULL, + CONSTRAINT unapproved_dms_temp_new_pkey PRIMARY KEY ("hash", "user", service, contributor_id) +); +INSERT INTO public.unapproved_dms_temp_new ("hash", "user", service, contributor_id, "content", embed, added, published, file, import_id) +select ENCODE(DIGEST("content", 'sha256'), 'hex'), "user", service, COALESCE( contributor_id, '0' ), "content", embed, MIN(added), MIN(published), file, MIN(import_id) +FROM public.unapproved_dms +GROUP BY "user", service, "content", embed, file, contributor_id; + +ALTER TABLE unapproved_dms RENAME TO unapproved_dms_temp_old; +ALTER TABLE unapproved_dms_temp_new RENAME TO unapproved_dms; + +ALTER TABLE unapproved_dms_temp_old RENAME CONSTRAINT unapproved_dms_pkey TO unapproved_dms_temp_old_pkey; +ALTER TABLE unapproved_dms RENAME CONSTRAINT unapproved_dms_temp_new_pkey TO unapproved_dms_pkey; + + +CREATE INDEX unapproved_dms_contributor_id_user_idx ON public.unapproved_dms (contributor_id,"user"); + + +DELETE FROM public.unapproved_dms +USING public.dms +WHERE public.unapproved_dms.hash = public.dms.hash; +"""), +] diff --git a/db/migrations/20231205_00_TOS34-add-public-tables.py b/db/migrations/20231205_00_TOS34-add-public-tables.py new file mode 100644 index 0000000..c8d27e3 --- /dev/null +++ b/db/migrations/20231205_00_TOS34-add-public-tables.py @@ -0,0 +1,89 @@ +""" +Change completely the structure of DMs +""" + +from yoyo import step + +__depends__ = {'20231119_00_ASHrR6-fix-revisions-table-to-match-tags-type'} + +steps = [ + step(""" + +CREATE TABLE public.creators ( + creator_id text NOT NULL, + service text NOT NULL, + creator_name text NOT NULL, + creator_slug text, + creator_internal_id text, + short_description text NOT NULL, + description text NOT NULL, + icon text, + banner text, + is_nsfw boolean, + deleted_at timestamp without time zone, + stopped_at timestamp without time zone, + paused_at timestamp without time zone, + post_count integer, + media_count integer, + tiers jsonb[], + access_groups jsonb[], + published_at timestamp without time zone, + added_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone, + public_posts_refreshed_at timestamp without time zone, + public_posts_full_refreshed_at timestamp without time zone +); +ALTER TABLE ONLY public.creators ADD CONSTRAINT creators_pkey PRIMARY KEY (creator_id, service); + + +CREATE TABLE public.creators_revisions ( + revision_id serial NOT NULL PRIMARY KEY, + creator_id text NOT NULL, + service text NOT NULL, + creator_name text NOT NULL, + creator_slug text, + creator_internal_id text, + short_description text NOT NULL, + description text NOT NULL, + icon text, + banner text, + is_nsfw boolean, + deleted_at timestamp without time zone, + stopped_at timestamp without time zone, + paused_at timestamp without time zone, + post_count integer, + media_count integer, + tiers jsonb[], + access_groups jsonb[], + published_at timestamp without time zone, + added_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone, + public_posts_refreshed_at timestamp without time zone, + public_posts_full_refreshed_at timestamp without time zone +); +CREATE INDEX creators_revisions_creator_id_service_idx ON public.creators_revisions USING btree (creator_id, service); + + +CREATE TABLE public.public_posts ( + post_id text NOT NULL, + creator_id text NOT NULL, + service text NOT NULL, + title text NOT NULL, + body text NOT NULL, + tier_price_required text, + tier_required text[], + published_at timestamp without time zone, + edited_at timestamp without time zone, + deleted_at timestamp without time zone, + tags text[], + like_count integer, + comment_count integer, + is_public boolean, + is_nsfw boolean, + refreshed_at timestamp without time zone +); + +ALTER TABLE ONLY public.public_posts ADD CONSTRAINT public_posts_pkey PRIMARY KEY (post_id, service); +CREATE INDEX public_posts_creator_id_service_idx ON public.public_posts USING btree (service, creator_id); +"""), +] diff --git a/db/migrations/20240105_00_IRP42-add-jobs-table.py b/db/migrations/20240105_00_IRP42-add-jobs-table.py new file mode 100644 index 0000000..7ad0e86 --- /dev/null +++ b/db/migrations/20240105_00_IRP42-add-jobs-table.py @@ -0,0 +1,29 @@ +""" +Table for import jobs +""" + +from yoyo import step + +__depends__ = {'20231119_00_ASHrR6-fix-revisions-table-to-match-tags-type'} + +steps = [ + step(""" +CREATE TABLE jobs ( + job_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + queue_name TEXT NOT NULL, + priority INTEGER NOT NULL, + consumer_id TEXT, + pids INTEGER[], + started_at TIMESTAMP, + last_heartbeat_at TIMESTAMP, + job_input JSONB not NULL, + job_status JSONB DEFAULT '{}'::jsonb, + finished_at TIMESTAMP, + error TEXT +); + +CREATE INDEX jobs_finished_at_queue_name_job_input_key_idx ON public.jobs (finished_at, queue_name, (job_input->>'key')); + +"""), +] diff --git a/db/migrations/20240120_00_TID85-password-field-for-files.py b/db/migrations/20240120_00_TID85-password-field-for-files.py new file mode 100644 index 0000000..9119f94 --- /dev/null +++ b/db/migrations/20240120_00_TID85-password-field-for-files.py @@ -0,0 +1,17 @@ +""" +Add table to keep track of files passwords +""" + +from yoyo import step + +__depends__ = {'20231123_00_OFL42-unapproved-dms-and-dms-remade'} + +steps = [ + step(""" +CREATE TABLE archive_files ( + file_id int NOT NULL REFERENCES files (id), + files TEXT[] NOT NULL, + password TEXT +); +"""), +] diff --git a/db/migrations/20240121_00_TOD44-password-table-pk.py b/db/migrations/20240121_00_TOD44-password-table-pk.py new file mode 100644 index 0000000..3c7a7cd --- /dev/null +++ b/db/migrations/20240121_00_TOD44-password-table-pk.py @@ -0,0 +1,13 @@ +""" +Add table to keep track of files passwords +""" + +from yoyo import step + +__depends__ = {'20240120_00_TID85-password-field-for-files'} + +steps = [ + step(""" +ALTER TABLE public.archive_files ADD CONSTRAINT archive_files_pk PRIMARY KEY (file_id); +"""), +] diff --git a/db/migrations/20240128_00_ANI72-add-resuming_at-to-jobs.py b/db/migrations/20240128_00_ANI72-add-resuming_at-to-jobs.py new file mode 100644 index 0000000..4a9e987 --- /dev/null +++ b/db/migrations/20240128_00_ANI72-add-resuming_at-to-jobs.py @@ -0,0 +1,14 @@ +""" +Table for import jobs +""" + +from yoyo import step + +__depends__ = {'20231119_00_ASHrR6-fix-revisions-table-to-match-tags-type'} + +steps = [ + step(""" +ALTER TABLE jobs +ADD COLUMN resuming_at TIMESTAMP; +"""), +] diff --git a/db/migrations/20240131_00_SHI55-extend-lookup.py b/db/migrations/20240131_00_SHI55-extend-lookup.py new file mode 100644 index 0000000..74c2876 --- /dev/null +++ b/db/migrations/20240131_00_SHI55-extend-lookup.py @@ -0,0 +1,16 @@ +""" +Add fields to lookup +""" + +from yoyo import step + +__depends__ = {'20231119_00_ASHrR6-fix-revisions-table-to-match-tags-type'} + +steps = [ + step(""" +ALTER TABLE public.lookup ADD COLUMN public_id TEXT; +ALTER TABLE public.lookup ADD COLUMN relation_id INTEGER; +CREATE SEQUENCE lookup_relation_id_seq; +CREATE INDEX lookup_relation_id_index ON public.lookup USING btree (relation_id); +"""), +] diff --git a/db/migrations/20240202_00_JGL52-add-buy-price-to-public-posts.py b/db/migrations/20240202_00_JGL52-add-buy-price-to-public-posts.py new file mode 100644 index 0000000..d17c090 --- /dev/null +++ b/db/migrations/20240202_00_JGL52-add-buy-price-to-public-posts.py @@ -0,0 +1,13 @@ +""" +Add buy_price to public posts +""" + +from yoyo import step + +__depends__ = {'20231205_00_TOS34-add-public-tables'} + +steps = [ + step(""" + ALTER TABLE public_posts ADD COLUMN buy_price text; +"""), +] diff --git a/db/migrations/20240203_00_CRD96-alter-fancards-table.py b/db/migrations/20240203_00_CRD96-alter-fancards-table.py new file mode 100644 index 0000000..a5a2cf6 --- /dev/null +++ b/db/migrations/20240203_00_CRD96-alter-fancards-table.py @@ -0,0 +1,49 @@ +""" +alter fancards table +""" + +from yoyo import step + +__depends__ = {'20240128_00_ANI72-add-resuming_at-to-jobs'} + +steps = [ + step(""" +CREATE TEMPORARY TABLE temp_unique_fancards AS +SELECT DISTINCT ON (user_id, file_id) + id, + user_id, + file_id +FROM fanbox_fancards +ORDER BY user_id, file_id, id DESC; + + +TRUNCATE TABLE fanbox_fancards; + +INSERT INTO fanbox_fancards (id, user_id, file_id) +SELECT id, user_id, file_id +FROM temp_unique_fancards; + +DROP TABLE temp_unique_fancards; +"""), + step(""" +ALTER TABLE public.fanbox_fancards ADD COLUMN last_checked_at timestamp DEFAULT CURRENT_TIMESTAMP; +"""), + step(""" +ALTER TABLE public.fanbox_fancards ADD COLUMN price text NOT NULL DEFAULT '' ; +"""), + step(""" +ALTER TABLE public.fanbox_fancards ALTER COLUMN file_id DROP NOT NULL; +"""), + step(""" +ALTER TABLE fanbox_fancards DROP CONSTRAINT IF EXISTS fanbox_fancards_user_id_file_id_key; +ALTER TABLE fanbox_fancards ADD CONSTRAINT fanbox_fancards_user_id_file_id_price_unique_idx UNIQUE (user_id, file_id, price); +CREATE UNIQUE INDEX fanbox_fancards_null_file_id_user_id_price_unique_idx ON fanbox_fancards (user_id, price) WHERE file_id IS NULL; + + """), + step(""" +UPDATE public.fanbox_fancards +SET last_checked_at = f.added +FROM public.files f +WHERE fanbox_fancards.file_id = f.id; +"""), +] diff --git a/db/migrations/20240203_00_er342-add-commenter-name.py b/db/migrations/20240203_00_er342-add-commenter-name.py new file mode 100644 index 0000000..ec2c77c --- /dev/null +++ b/db/migrations/20240203_00_er342-add-commenter-name.py @@ -0,0 +1,16 @@ +""" +add commenter name to +""" + +from yoyo import step + +__depends__ = {'20240128_00_ANI72-add-resuming_at-to-jobs'} + +steps = [ + step(""" +ALTER TABLE public."comments" ADD COLUMN commenter_name text; +"""), + step(""" +ALTER TABLE public."comments_revisions" ADD COLUMN commenter_name text; +"""), +] diff --git a/db/migrations/20240210_00_gto81-add-table-for-tracking-forced-reimports.py b/db/migrations/20240210_00_gto81-add-table-for-tracking-forced-reimports.py new file mode 100644 index 0000000..32ef29e --- /dev/null +++ b/db/migrations/20240210_00_gto81-add-table-for-tracking-forced-reimports.py @@ -0,0 +1,20 @@ +""" +table to track forced reimports already done +""" + +from yoyo import step + +__depends__ = {'20240128_00_ANI72-add-resuming_at-to-jobs'} + +steps = [ + step(""" +CREATE TABLE public.posts_forced_reimports ( + creator_id TEXT NOT NULL, + service TEXT NOT NULL, + post_id TEXT NOT NULL, + reason TEXT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT posts_forced_reimports_pkey PRIMARY KEY (creator_id, service, post_id) +); +"""), +] diff --git a/db/migrations/20240211_00_asb39-index-for-remote-in-file_server_relationships.py b/db/migrations/20240211_00_asb39-index-for-remote-in-file_server_relationships.py new file mode 100644 index 0000000..4d28bf9 --- /dev/null +++ b/db/migrations/20240211_00_asb39-index-for-remote-in-file_server_relationships.py @@ -0,0 +1,9 @@ +from yoyo import step + +__depends__ = {'20240210_00_gto81-add-table-for-tracking-forced-reimports'} + +steps = [ + step(""" +CREATE INDEX IF NOT EXISTS file_server_relationships_remote_path_idx ON public.file_server_relationships USING btree (remote_path); +"""), +] diff --git a/db/migrations/20240211_00_yto88-discord-channel-table-and-discord-posts-revisions.py b/db/migrations/20240211_00_yto88-discord-channel-table-and-discord-posts-revisions.py new file mode 100644 index 0000000..8bf5234 --- /dev/null +++ b/db/migrations/20240211_00_yto88-discord-channel-table-and-discord-posts-revisions.py @@ -0,0 +1,72 @@ +from yoyo import step + +__depends__ = {'20240210_00_gto81-add-table-for-tracking-forced-reimports'} + +steps = [ + step(""" +CREATE TABLE public.discord_channels ( + channel_id text NOT NULL, + server_id text NOT NULL, + name text NOT NULL, + parent_channel_id text NULL, + topic text NULL, + theme_color text NULL, + is_nsfw bool NOT NULL, + position int NOT NULL DEFAULT 0, + icon_emoji text null, + type int NOT NULL DEFAULT 0, + CONSTRAINT discord_channels_pkey PRIMARY KEY (channel_id) +); +CREATE INDEX discord_channels_server_id_idx ON public.discord_channels USING btree (server_id); +CREATE INDEX discord_channels_parent_channel_id_idx ON public.discord_channels USING btree (parent_channel_id); +"""), + step(""" +CREATE TABLE public.discord_posts_revisions ( + revision_id serial4 NOT NULL, + id varchar(255) NOT NULL, + author jsonb NOT NULL, + "server" varchar(255) NOT NULL, + channel varchar(255) NOT NULL, + "content" text NOT NULL DEFAULT ''::text, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + published timestamp NULL, + edited timestamp NULL, + embeds _jsonb NOT NULL, + mentions _jsonb NOT NULL, + attachments _jsonb NOT NULL, + CONSTRAINT discord_posts_revisions_pkey PRIMARY KEY (revision_id) +); +CREATE INDEX discord_posts_revisions_id_idx ON public.discord_posts_revisions USING btree (id); +"""), + step(""" +INSERT INTO public.discord_channels (channel_id, server_id, "name", is_nsfw, "position", "type") +SELECT DISTINCT + channel AS channel_id, + "server", + channel AS "name", + false AS is_nsfw, + 0 AS "position", + 0 AS "type" +FROM + public.discord_posts +ON CONFLICT DO NOTHING; + +UPDATE public.discord_channels AS dc +SET "name" = l."name" +FROM public.lookup AS l +WHERE l.id = dc.channel_id +AND l.service = 'discord-channel'; + +DELETE FROM public.lookup +WHERE service = 'discord-channel'; + +UPDATE public.lookup AS l +SET updated = COALESCE(( + SELECT MAX(added) + FROM public.discord_posts AS d + WHERE d."server" = l.id +), updated) +WHERE l.service = 'discord'; + +"""), +] diff --git a/db/migrations/20240221_03_Cgdrp-create-unapproved-link-requests.py b/db/migrations/20240221_03_Cgdrp-create-unapproved-link-requests.py new file mode 100644 index 0000000..bd463cd --- /dev/null +++ b/db/migrations/20240221_03_Cgdrp-create-unapproved-link-requests.py @@ -0,0 +1,30 @@ +from yoyo import step + +__depends__ = {"20240211_00_yto88-discord-channel-table-and-discord-posts-revisions"} + +steps = [ + step(""" + CREATE TYPE unapproved_link_status AS ENUM ('pending', 'approved', 'rejected'); + CREATE TABLE unapproved_link_requests ( + id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + from_service text NOT NULL, + from_id text NOT NULL, + to_service text NOT NULL, + to_id text NOT NULL, + reason text, + requester_id int NOT NULL REFERENCES account (id), + status unapproved_link_status NOT NULL DEFAULT 'pending', + + FOREIGN KEY (from_service, from_id) REFERENCES lookup (service, id), + FOREIGN KEY (to_service, to_id) REFERENCES lookup (service, id), + UNIQUE (from_service, from_id, to_service, to_id) + ); + + CREATE INDEX unapproved_link_requests_status_id_idx ON unapproved_link_requests (status, id); + """), + step(""" + UPDATE lookup SET relation_id = nextval('lookup_relation_id_seq'); + CREATE INDEX lookup_public_id_idx ON lookup (public_id); + CREATE INDEX lookup_relation_id_idx ON lookup (relation_id); + """) +] diff --git a/db/migrations/20240223_00_ASDAS-reset_relation_id_seq.py b/db/migrations/20240223_00_ASDAS-reset_relation_id_seq.py new file mode 100644 index 0000000..5f4fda7 --- /dev/null +++ b/db/migrations/20240223_00_ASDAS-reset_relation_id_seq.py @@ -0,0 +1,10 @@ +from yoyo import step + +__depends__ = {"20240221_03_Cgdrp-create-unapproved-link-requests"} + +steps = [ + step(""" + UPDATE lookup SET relation_id = NULL; + ALTER SEQUENCE lookup_relation_id_seq RESTART WITH 1; + """) +] diff --git a/db/migrations/initial.sql b/db/migrations/initial.sql new file mode 100644 index 0000000..d5cc812 --- /dev/null +++ b/db/migrations/initial.sql @@ -0,0 +1,117 @@ +-- TODO: Implement tagging/ratings/revisions +-- Goal for now is just to get Kemono working in SQL. + +-- Posts +CREATE TABLE IF NOT EXISTS booru_posts ( + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "title" text NOT NULL DEFAULT '', + "content" text NOT NULL DEFAULT '', + "embed" jsonb NOT NULL DEFAULT '{}', + "shared_file" boolean NOT NULL DEFAULT '0', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "edited" timestamp, + "file" jsonb NOT NULL, + "attachments" jsonb[] NOT NULL +); +CREATE INDEX IF NOT EXISTS id_idx ON booru_posts USING hash ("id"); +CREATE INDEX IF NOT EXISTS user_idx ON booru_posts USING btree ("user"); +CREATE INDEX IF NOT EXISTS service_idx ON booru_posts USING btree ("service"); +CREATE INDEX IF NOT EXISTS added_idx ON booru_posts USING btree ("added"); +CREATE INDEX IF NOT EXISTS published_idx ON booru_posts USING btree ("published"); +CREATE INDEX IF NOT EXISTS updated_idx ON booru_posts USING btree ("user", "service", "added"); + +-- Booru bans +CREATE TABLE IF NOT EXISTS dnp ( + "id" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL +); + +-- Posts (Discord) +CREATE TABLE IF NOT EXISTS discord_posts ( + "id" varchar(255) NOT NULL, + "author" jsonb NOT NULL, + "server" varchar(255) NOT NULL, + "channel" varchar(255) NOT NULL, + "content" text NOT NULL DEFAULT '', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "edited" timestamp, + "embeds" jsonb[] NOT NULL, + "mentions" jsonb[] NOT NULL, + "attachments" jsonb[] NOT NULL +); +CREATE INDEX IF NOT EXISTS discord_id_idx ON discord_posts USING hash ("id"); +CREATE INDEX IF NOT EXISTS server_idx ON discord_posts USING hash ("server"); +CREATE INDEX IF NOT EXISTS channel_idx ON discord_posts USING hash ("channel"); + +-- Flags +CREATE TABLE IF NOT EXISTS booru_flags ( + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL +); + +-- Lookup +CREATE TABLE IF NOT EXISTS lookup ( + "id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "indexed" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS name_idx ON lookup USING btree ("name"); +CREATE INDEX IF NOT EXISTS lookup_id_idx ON lookup USING btree ("id"); +CREATE INDEX IF NOT EXISTS lookup_service_idx ON lookup USING btree ("service"); +CREATE INDEX IF NOT EXISTS lookup_indexed_idx ON lookup USING btree ("indexed"); + +-- Board +CREATE TABLE IF NOT EXISTS board_replies ( + "reply" integer NOT NULL, + "in" integer NOT NULL +); + +-- Requests +DO $$ BEGIN + CREATE TYPE request_status AS ENUM ('open', 'fulfilled', 'closed'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +CREATE TABLE IF NOT EXISTS requests ( + "id" SERIAL PRIMARY KEY, + "service" varchar(20) NOT NULL, + "user" varchar(255) NOT NULL, + "post_id" varchar(255), + "title" text NOT NULL, + "description" text NOT NULL DEFAULT '', + "created" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "image" text, + "price" numeric NOT NULL, + "votes" integer NOT NULL DEFAULT 1, + "ips" text[] NOT NULL, + "status" request_status NOT NULL DEFAULT 'open' +); +CREATE INDEX IF NOT EXISTS request_title_idx ON requests USING btree ("title"); +CREATE INDEX IF NOT EXISTS request_service_idx ON requests USING btree ("service"); +CREATE INDEX IF NOT EXISTS request_votes_idx ON requests USING btree ("votes"); +CREATE INDEX IF NOT EXISTS request_created_idx ON requests USING btree ("created"); +CREATE INDEX IF NOT EXISTS request_price_idx ON requests USING btree ("price"); +CREATE INDEX IF NOT EXISTS request_status_idx ON requests USING btree ("status"); + +-- Request Subscriptions +CREATE TABLE IF NOT EXISTS request_subscriptions ( + "request_id" numeric NOT NULL, + "endpoint" text NOT NULL, + "expirationTime" numeric, + "keys" jsonb NOT NULL +); +CREATE INDEX IF NOT EXISTS request_id_idx ON request_subscriptions USING btree ("request_id"); + +-- Logs +CREATE TABLE IF NOT EXISTS logs ( + "log0" text NOT NULL, + "log" text[] NOT NULL, + "created" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS log_idx ON logs USING GIN (to_tsvector('english', log0)); \ No newline at end of file diff --git a/db/seed.sql b/db/seed.sql new file mode 100644 index 0000000..9700eeb --- /dev/null +++ b/db/seed.sql @@ -0,0 +1,7 @@ +INSERT INTO public.posts (id, "user", service, title, "content", embed, shared_file, added, published, edited, file, attachments) VALUES + ('84262657', '4262497', 'patreon', 'Silver Wolf The Hacker!', '

    ????

    ', '{}', false, '2023-06-08 16:21:09.939', '2023-06-08 13:26:03.000', NULL, '{"name": "IMG_5494.png", "path": "/33/61/3361c9111b263a11313cc33aa4415444976f96db93041c2190ab05b31d1fbbd1.png"}','{"{\"name\": \"IMG_5494.png\", \"path\": \"/33/61/3361c9111b263a11313cc33aa4415444976f96db93041c2190ab05b31d1fbbd1.png\"}"}') +ON CONFLICT (id, service) DO NOTHING; + +INSERT INTO public.lookup (id, "name", service, indexed, updated) VALUES + ('4262497', 'PrincessHinghoi', 'patreon', '2020-09-08 20:08:29.062', '2023-10-06 03:32:33.091') +ON CONFLICT (id, service) DO NOTHING; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cdbca08 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +version: '3' +services: + postgres: + image: groonga/pgroonga:3.1.6-alpine-16-slim + restart: unless-stopped + environment: + - POSTGRES_DB=kemono + - POSTGRES_USER=kemono + - POSTGRES_PASSWORD=kemono + volumes: + - ./storage/postgres:/var/lib/postgresql/data + command: ["postgres", "-c", "log_statement=all"] + ports: + - '15432:5432' + + redis: + image: redis:7-alpine + restart: always + ports: + - '16379:6379' + + web: + build: + context: . + args: + GIT_COMMIT_HASH: "custom" + restart: unless-stopped + depends_on: + - postgres + - redis + environment: + - FLASK_ENV=development + - KEMONO_SITE=http://localhost:5000 + - UPLOAD_LIMIT=2000000000 + - ARCHIVERHOST=kemono-archiver + - ARCHIVERPORT=80 + - PYTHONUNBUFFERED=1 + - KEMONO_CONFIG=config.example.json + - PYTHONPATH=/app + volumes: + - ./:/app + - ./storage/files:/storage + sysctls: + net.core.somaxconn: 2000 + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:80" ] + interval: 60s + timeout: 2m + retries: 3 + start_period: 30s + + command: + [ "python", "-m", "src", "run" ] + + nginx: + image: nginx + depends_on: + - web + ports: + - '5000:80' + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + - ./storage/files:/storage + - ./:/app + + webpack: + build: + context: . + restart: unless-stopped + environment: + - FLASK_ENV=development + - KEMONO_SITE=http://localhost:5000 + - KEMONO_CONFIG=config.example.json + - PYTHONPATH=/app + volumes: + - .:/app + command: + [ "python", "-m", "src", "webpack" ] diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..109074b --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,162 @@ +# Frequently Asked Questions + +
    + +### My dump doesn't migrate. + +_This assumes a running setup._ + +
    + +1. Enter into database container: + + ```sh + docker exec \ + --interactive \ + --username=nano \ + --tty kemono-db psql \ + kemonodb + ``` + +
    + +2. Check the contents of the  `posts`  table. + + ```sql + SELECT * FROM posts; + ``` + + _Most likely it has  `0`  rows._ + +
    + +3. Move contents of  `booru_posts`  ➞  `posts` + + ```sql + INSERT INTO posts SELECT * FROM booru_posts ON CONFLICT DO NOTHING; + ``` + +
    + +4. Restart the archiver. + + ```sh + docker restart kemono-archiver + ``` + + If you see a bunch of log entries from  `kemono-db` ,
    + then this indicates that the archiver is doing it's job. + +
    + +5. In case the frontend still doesn't show
    + the artists / posts, clear the redis cache. + + ```sh + docker exec \ + kemono-redis \ + redis-cli \ + FLUSHALL + ``` + +
    +
    + +### How do I git modules? + +_This assumes you haven't cloned the repository recursively._ + +
    + +1. Initiate the submodules + + ```sh + git submodule init + git submodule update \ + --recursive \ + --init + ``` + +
    + +2. Switch to the archiver folder and
    + add your fork to the remotes list. + + ```sh + cd archiver + git remote add + ``` + +
    + +3. Now you can interact with Kitsune repo the same
    + way you do as if it was outside of project folder. + +
    +
    + +### How do I import from db dump? + +
    + +1. Retrieve a database dump. + +
    + +2. Run the following in the folder of said dump. + + ```sh + cat db-filename.dump \ + | gunzip \ + | docker exec \ + --interactive kemono-db psql \ + --username=nano kemonodb + ``` + +
    + +3. Restart the archiver to trigger migrations. + + ```sh + docker restart kemono-archiver + ``` + +
    + + If that didn't start the migrations, refer
    + to  [`My Dump Doesn't Migrate`]  section. + +
    +
    + +### How do I put files into nginx container? + +
    + +1. Retrieve the files in required folder structure. + +
    + +2. Copy them into nginx image. + + ```sh + docker \ + cp ./ kemono-nginx:/storage + ``` + +
    + +3. Add required permissions to that folder. + + ```sh + docker \ + exec kemono-nginx \ + chown --recursive \ + nginx /storage + ``` + +
    + + + +[`My Dump Doesn't Migrate`]: #my-dump-doesnt-migrate diff --git a/docs/develop.md b/docs/develop.md new file mode 100644 index 0000000..18f547c --- /dev/null +++ b/docs/develop.md @@ -0,0 +1,121 @@ +# Develop + +For now Docker is a primary way of working on the repo. + +## Installation + +1. Install `virtualenv` package if it's not installed. + + ```sh + pip install --user virtualenv + ``` + +2. Create a virtual environment: + + ```sh + virtualenv venv + ``` + +2. Activate the virtual environment. + + ```sh + # Windows ➞ venv\Scripts\activate + source venv/bin/activate + ``` + +3. Install python packages. + + ```sh + pip install --requirement requirements.txt + ``` + +4. Install  `pre-commit`  hooks. + + ```sh + pre-commit install --install-hooks + ``` + +### Database + +1. Register an account. + +2. Visit  [`http://localhost:5000/development`] + +3. Click either seeded or random generation. + + _This will start a mock import process,_
    + _which will also populate the database._ + +### Build + +```sh +docker-compose build +docker-compose up --detach +``` + +In a browser, visit  [`http://localhost:8000/`] + +## Manual + +> **TODO** : Write installation and setup instructions + +This assumes you have  `Python 3.8+`  &  `Node 12+`  installed
    +as well as a running **PostgreSQL** server with **Pgroonga**. + +```sh +# Make sure your database is initialized +# cd to kemono directory + +pip install virtualenv +virtualenv venv + +# Windows ➞ venv\Scripts\activate +source venv/bin/activate + +pip install \ + --requirement requirements.txt + +cd client \ + && npm install \ + && npm run build \ + && cd .. +``` + +## Git + +Configure `git` to store credentials: + +```sh +git config credential.helper store +``` + +After the next time creds are accepted, they will be saved on hard drive +as per rules listed in `man git-credential-store`and won't be asked again. + +Alternatively they can be stored temporarily in memory: + +```sh +git config credential.helper cache +``` + +The creds are stored as per rules in `man git-credential-cache`. + +## IDE + +_IDE specific instructions._ + +### VSCode + +1. Copy  `.code-workspace`  file. + + ```sh + cp \ + configs/workspace.code-workspace.example \ + kemono-2.code-workspace + ``` + +2. Install the recommended extensions. + +[`http://localhost:5000/development`]: http://localhost:5000/development +[`http://localhost:5000/`]: http://localhost:5000/ +[`http://localhost:8000/`]: http://localhost:8000/ diff --git a/docs/features/_index.md b/docs/features/_index.md new file mode 100644 index 0000000..917c6b9 --- /dev/null +++ b/docs/features/_index.md @@ -0,0 +1,7 @@ +# Features of Kemono Project + +- [Accounts](./accounts.md) +- [Notifications](./notifications.md) +- [Development mode](./development-mode.md) +- [Keys Page](./keys-page.md) +- [DM Import](./dm-import.md) diff --git a/docs/features/accounts.md b/docs/features/accounts.md new file mode 100644 index 0000000..8d0d4fc --- /dev/null +++ b/docs/features/accounts.md @@ -0,0 +1,30 @@ +# Accounts + +## Table of contents + +- [General Description](#general-description) +- [Issues](#issues) + +## General Description + +### Creation + +A visitor can create an account, which allows him to favourite posts and artists, get notifications and get assigned a role. + +### Roles + +#### Consumer + +The base role for any fresh account. + +#### Administrator + +The first registered account of the instance gets this role. Administrator can change the role of other accounts, of which they get notified. + +### Features: + +#### Session Key Management + +Accounts can access the page which lists all keys set up for autoimport and revoke any of them. + +## Issues diff --git a/docs/features/development-mode.md b/docs/features/development-mode.md new file mode 100644 index 0000000..a6f9d00 --- /dev/null +++ b/docs/features/development-mode.md @@ -0,0 +1,32 @@ +# Development Mode + +## Table of contents + +- [General Description](#general-description) +- [Issues](#issues) + +## General Description + +Development mode provides a way to create a dev environment from scratch for ease of development and testing. + +### Location + +Development-only files live in their `development` folder, located in the root of a project, which exposes its own exported modules in its index file. Only exports declared within in are allowed to be imported outside and only do it conditionally by checking for the presence of development environment variable beforehand. +The folder structure follows the same logic as `src` folder, i.e. it can have its own `lib`, `endpoints`, `types` and even `internals` folders. +The server also provides `/development` endpoint, which allows to access various features. + +### Features + +#### Test entries + +Generates test entries in the database. There are two mechanisms for generation: + +- Seeded +- Random + +Seeded generation outputs the same result each use and therefore should error out on the second use. The main usecase is to populate the initial test database. +Random generation allows to create new random entries regardless of circumstances. The main usecase is to add random entries of any table during development process. + +## Issues + +- Due to some underlying changes of importing process the test import doesn't work diff --git a/docs/features/dm-import.md b/docs/features/dm-import.md new file mode 100644 index 0000000..d4693c0 --- /dev/null +++ b/docs/features/dm-import.md @@ -0,0 +1,12 @@ +# Direct Messages + +## Table of contents + +- [General Description](#general-description) +- [Issues](#issues) + +## General Description + +DM import allows for registered account to import DMs. Upon the start of an import the account can visit the separate page to approve/discard DMs fetched during import. + +## Issues diff --git a/docs/features/keys-page.md b/docs/features/keys-page.md new file mode 100644 index 0000000..1f88e86 --- /dev/null +++ b/docs/features/keys-page.md @@ -0,0 +1,12 @@ +# Keys Page + +## Table of contents + +- [General Description](#general-description) +- [Issues](#issues) + +## General Description + +The key management panel simply needs to show the user information on the keys they have saved (service + added time,) a list of import logs for those keys, an indication if that key is "dead"as well, as a button to "revoke" permissions, which deletes the key from the database. + +## Issues diff --git a/docs/features/notifications.md b/docs/features/notifications.md new file mode 100644 index 0000000..7f785ac --- /dev/null +++ b/docs/features/notifications.md @@ -0,0 +1,12 @@ +# Notifications + +## Table of contents + +- [General Description](#general-description) +- [Issues](#issues) + +## General Description + +Notifications allow to send various information to users. + +## Issues diff --git a/docs/features/redis_sharding.md b/docs/features/redis_sharding.md new file mode 100644 index 0000000..4c8264b --- /dev/null +++ b/docs/features/redis_sharding.md @@ -0,0 +1,14 @@ +# Redis Sharding + +## Table of contents + +- [General Description](#general-description) +- [Issues](#issues) + +## General Description + +Redis' synchronous nature combined with large, hulking datasets that need to be scanned repeatedly and often make for poor scalability. Let's mitigate those bottlenecks by splitting up the data Redis has to scan across databases, and entire threads of execution across instances. + +## Issues + +- [`rb`](https://github.com/getsentry/rb) package [has a `redis` requirement < 3.5](https://github.com/getsentry/rb/blob/6f96a68dca2d77e9ac1d8bdd7a21e2575af65a20/setup.py#L15) diff --git a/docs/projects/_index.md b/docs/projects/_index.md new file mode 100644 index 0000000..d9efcad --- /dev/null +++ b/docs/projects/_index.md @@ -0,0 +1,3 @@ +# Projects + +- [Moderation system](./moderation-system.md) diff --git a/docs/projects/favorites1dot5.md b/docs/projects/favorites1dot5.md new file mode 100644 index 0000000..afea33d --- /dev/null +++ b/docs/projects/favorites1dot5.md @@ -0,0 +1,18 @@ +# Favorites V1.5 + +## Table of contents + +- [General Description](#general-description) +- [Interfaces](#interfaces) +- [Technical Description](#technical-description) +- [Issues](#issues) + +## General Description + +## Interfaces + +### SQL + +## Technical Description + +## Issues diff --git a/docs/projects/file-upload.md b/docs/projects/file-upload.md new file mode 100644 index 0000000..40119b9 --- /dev/null +++ b/docs/projects/file-upload.md @@ -0,0 +1,30 @@ +# File Upload + +## Table of contents + +- [Interfaces](#interfaces) +- [Core information](#core-information) +- [Process](#process) +- [Issues](#issues) + +## Interfaces + +```typescript +interface ManualUpload {} +``` + +## Core information + +## Process + +1. The account goes to `/posts/upload`. +2. Uploads the file. +3. The file gets processed. +4. The file gets sent for review to a moderator. +5. The moderator then decides to discard the upload or approve for public view. +6. ... + +## Issues + +- What happens when one mod approves a file while the other one discards it? +- Unlike DMs the file verification can take very variable amount of time, so there should be separate states for a given upload, like `"pending"`,`"approved"`,`"rejected"`. diff --git a/docs/projects/moderation-system.md b/docs/projects/moderation-system.md new file mode 100644 index 0000000..7a23482 --- /dev/null +++ b/docs/projects/moderation-system.md @@ -0,0 +1,42 @@ +# Moderation System + +## Table of contents + +- [General Description](#general-description) +- [Interfaces](#interfaces) +- [Technical Description](#technical-description) +- [Process](#process) +- [Issues](#issues) + +## General Description + +The moderation system allows certain users ("moderators") chosen by the administrator user to perform various tasks. + +## Interfaces + +```typescript +interface Action { + id: string; + account_id: string; + type: string; + categories: string[]; + /** + * A list of resource `id`s affected by the action. + */ + entity_ids: string[]; + status: "completed" | "failed" | "reverted"; + created_at: Date; +} +``` + +## Technical Description + +## Process + +### Moderator + +1. When the role of an account changes to `moderator`, the account gets notified of this. +1. The account then can access `/mod` endpoint, which leads to the moderator dashboard. On this page the mod can see various stats, among them is the list of various `tasks`. +1. Each performed `task` results in an `action`. + +## Issues diff --git a/docs/resources/Preview.png b/docs/resources/Preview.png new file mode 100644 index 0000000..2d41635 Binary files /dev/null and b/docs/resources/Preview.png differ diff --git a/docs/templates/feature-template.md b/docs/templates/feature-template.md new file mode 100644 index 0000000..7ff85ef --- /dev/null +++ b/docs/templates/feature-template.md @@ -0,0 +1,10 @@ +# Title + +## Table of contents + +- [General Description](#general-description) +- [Issues](#issues) + +## General Description + +## Issues diff --git a/docs/templates/project-template.md b/docs/templates/project-template.md new file mode 100644 index 0000000..8cba19d --- /dev/null +++ b/docs/templates/project-template.md @@ -0,0 +1,16 @@ +# Title + +## Table of contents + +- [General Description](#general-description) +- [Interfaces](#interfaces) +- [Technical Description](#technical-description) +- [Issues](#issues) + +## General Description + +## Interfaces + +## Technical Description + +## Issues diff --git a/docs/todos.md b/docs/todos.md new file mode 100644 index 0000000..972d0ca --- /dev/null +++ b/docs/todos.md @@ -0,0 +1,31 @@ +# TODOs + +## Server + +## Archiver + +- Make dev file import work + +## Client + +### Webpack + +- SASS uses its own module name resolution mechanism which differs from the current webpack setup. Specifically `config.resolve.alias` rules will not apply to filenames in `@use "";` expression. +- Figure out how to pass env variables to stylesheets. +- Figure out how to set up source maps for production. + +### HTML/Templates + +- Find a way to nest macro calls. + +#### `user.html` + +- AJAX search. + +#### `import` pages + +- consolidate them into a single page, since most of them are just placeholders for AJAX scripts. + +### CSS + +### JS diff --git a/docs/workspace.code-workspace.example b/docs/workspace.code-workspace.example new file mode 100644 index 0000000..0379219 --- /dev/null +++ b/docs/workspace.code-workspace.example @@ -0,0 +1,47 @@ +{ + "folders": [ + { + "name": "server", + "path": "." + }, + { + "name": "client", + "path": "client" + }, + { + "name": "archiver", + "path": "archiver" + }, + { + "name": "docs", + "path": "docs" + } + ], + "settings": { + "files.exclude": { + "venv/": true, + "dist/": true, + "client/": true, + "archiver/": true, + "docs/": true, + }, + "editor.wordWrap": "bounded", + "editor.wordWrapColumn": 120, + "editor.rulers": [ + { + "column": 65, + "color": "orange" + }, + { + "column": 120, + "color": "red" + }, + ], + "files.associations": { + "*.html": "jinja-html" + }, + "emmet.includeLanguages": { + "jinja-html": "html" + }, + } +} diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..2e58f2c --- /dev/null +++ b/nginx.conf @@ -0,0 +1,49 @@ +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + resolver 127.0.0.11; + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log off; + + sendfile on; + #tcp_nopush on; + + keepalive_timeout 65; + + #gzip on; + + server { + root /storage; + client_max_body_size 100m; + + location ~ "^/archive_files/([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{60})\.(.*)" { + proxy_pass http://foobar:5000/extract/data/$1/$2/$1$2$3.$4$is_args$args; + } + + location / { + try_files $uri @kemono; + if ($arg_f) { + add_header Content-Disposition "inline; filename=$arg_f"; + } + } + location @kemono { + proxy_pass http://web; + } + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1d9ae3c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[tool.black] +line-length = 120 +target-version = ["py312"] + +[tool.isort] +line_length = 120 + +[tool.mypy] +exclude = "storage" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..03f3786 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,36 @@ +bcrypt==4.0.1 +beautifulsoup4==4.12.2 +nh3==0.2.15 +cloudscraper==1.2.71 +dill==0.3.7 +Flask==2.3.2 +humanize==4.8.0 +murmurhash2==0.2.10 +pre-commit==3.7.0 +psycopg[binary,pool]>=3.1.10 +python-dateutil==2.8.2 +python-redis-lock==4.0.0 +rb==1.10.0 +redis>=4.6.0,<5.0.0 +requests==2.31.0 +httpx[http2,socks]==0.25.2 +retry==0.9.2 +sentry-sdk[flask]==1.29.2 +orjson==3.9.9 +#uWSGI==2.0.22 +git+https://github.com/unbit/uwsgi.git@0486062811be6f4bbed28e61bcb0d33dfeb2045c#uWSGI +pyyaml==6.0.1 +yoyo-migrations==8.2.0 +zstandard==0.21.0 +lz4==4.3.2 + + +opentelemetry-api +opentelemetry-sdk +opentelemetry-exporter-otlp +opentelemetry-instrumentation-wsgi +opentelemetry-instrumentation-flask +opentelemetry-instrumentation-redis +opentelemetry-instrumentation-dbapi +opentelemetry-instrumentation-psycopg2 +opentelemetry-instrumentation-requests diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..365cc7a --- /dev/null +++ b/setup.py @@ -0,0 +1,6 @@ +from setuptools import find_packages, setup + +setup( + name="Kemono", + packages=find_packages(), +) diff --git a/src/__main__.py b/src/__main__.py new file mode 100644 index 0000000..5746012 --- /dev/null +++ b/src/__main__.py @@ -0,0 +1,6 @@ +import sys + +from src import cmd + +if __name__ == "__main__": + cmd.main(sys.argv[1:]) \ No newline at end of file diff --git a/src/cmd/__init__.py b/src/cmd/__init__.py new file mode 100644 index 0000000..871fc1c --- /dev/null +++ b/src/cmd/__init__.py @@ -0,0 +1,41 @@ +from src.cmd.daemon import run_daemon +from src.cmd.web import run_web +from src.cmd.webpack import run_webpack + +from src.config import Configuration +from src.internals.tracing.custom_psycopg_instrumentor import instrument_psycopg + + +def __try(f: callable): + import sys + try: + f() + except KeyboardInterrupt: + sys.exit() + + +def main(args: list[str]): + match args: + case ["run"]: + if Configuration().open_telemetry_endpoint: + instrument_psycopg() + if Configuration().development_mode: + __try(run_web()) + else: + __try(run_daemon()) + + case ["web"]: + if Configuration().open_telemetry_endpoint: + instrument_psycopg() + __try(run_web()) + + case ["webpack"]: + run_webpack() + + case ["daemon"]: + if Configuration().open_telemetry_endpoint: + instrument_psycopg() + __try(run_daemon()) + + case _: + print(f"usage: python -m kemono [web|webpack|daemon]") diff --git a/src/cmd/daemon.py b/src/cmd/daemon.py new file mode 100644 index 0000000..5404eb9 --- /dev/null +++ b/src/cmd/daemon.py @@ -0,0 +1,147 @@ +import logging +import os +import subprocess + +from yoyo import get_backend, read_migrations + +from src.config import Configuration +from src.internals.database import database + + +def run_daemon(): + """Bugs to fix at a later time:""" + """ - Pages can get stuck with an older version of their """ + """ HTML, even after disabling anything and everything """ + """ related to cache. The only resolution as of now is """ + """ a restart of the entire webserver. """ + + environment_vars = { + **os.environ.copy(), + "FLASK_DEBUG": "development" if Configuration().development_mode else "false", + "NODE_ENV": "development" if Configuration().development_mode else "production", + "KEMONO_SITE": Configuration().webserver["site"], + "ICONS_PREPEND": Configuration().webserver["ui"]["files_url_prepend"]["icons_base_url"], + "BANNERS_PREPEND": Configuration().webserver["ui"]["files_url_prepend"]["banners_base_url"], + "THUMBNAILS_PREPEND": Configuration().webserver["ui"]["files_url_prepend"]["thumbnails_base_url"], + "CREATORS_LOCATION": Configuration().webserver["api"]["creators_location"], + } + + if Configuration().sentry_dsn: + import sentry_sdk + from sentry_sdk.integrations.flask import FlaskIntegration + from sentry_sdk.integrations.redis import RedisIntegration + + sentry_sdk.utils.MAX_STRING_LENGTH = 2048 + sentry_sdk.serializer.MAX_EVENT_BYTES = 10 ** 7 + sentry_sdk.serializer.MAX_DATABAG_DEPTH = 8 + sentry_sdk.serializer.MAX_DATABAG_BREADTH = 20 + sentry_sdk.init( + integrations=[FlaskIntegration(), RedisIntegration()], + dsn=Configuration().sentry_dsn, + release=os.getenv("GIT_COMMIT_HASH") or "NOT_FOUND", + max_request_body_size = "always", + max_value_length = 1024 * 4, + attach_stacktrace = True, + max_breadcrumbs = 1000, + send_default_pii = True, + ) + + """ Install client dependencies. """ + if not os.path.isdir("./client/node_modules"): + subprocess.run( + ["npm", "ci", "--also=dev", "--legacy-peer-deps"], + check=True, + cwd="client", + env=environment_vars, + ) + + """ Build or run client development server depending on config. """ + if Configuration().development_mode: + subprocess.Popen( + ["npm", "run", "dev"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd="client", + env=environment_vars, + ) + else: + subprocess.run(["npm", "run", "build"], check=True, cwd="client", env=environment_vars) + + """ Run `tusd`. """ + if Configuration().filehaus["tus"]["manage"]: + subprocess.Popen( + [ + "tusd", + "-upload-dir=./storage/uploads", + "--hooks-enabled-events", + "post-create", + "-hooks-http", + "http://127.0.0.1:3343" + # 'http://127.0.0.1:6942/shares/tus' + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=environment_vars, + ) + + database.init() + try: + if Configuration().automatic_migrations: + """Run migrations.""" + backend = get_backend( + f"postgresql+psycopg://{Configuration().database["user"]}" + f":{Configuration().database["password"]}" + f"@{Configuration().database["host"]}:" + f"{Configuration().database["port"]}" + f"/{Configuration().database["database"]}" + ) + migrations = read_migrations("./db/migrations") + with backend.lock(): + backend.apply_migrations(backend.to_apply(migrations)) + """ Initialize Pgroonga if needed. """ + with database.pool.getconn() as conn: + try: + with conn.cursor() as db: + db.execute("CREATE EXTENSION IF NOT EXISTS pgroonga") + db.execute( + "CREATE INDEX IF NOT EXISTS pgroonga_posts_concat_idx ON posts USING pgroonga ( (title || ' ' || content) );" + ) + db.execute("CREATE INDEX IF NOT EXISTS pgroonga_dms_idx ON dms USING pgroonga (content)") + conn.commit() + except Exception: + logging.exception("Failed to do pgronga migrations") + database.pool.putconn(conn) + finally: + """ "Close" the database pool.""" + database.close_pool() + + args = [ + "uwsgi", + "--ini", + "./uwsgi.ini", + "--processes", + str(Configuration().webserver["workers"]), + "--http", + f"0.0.0.0:{Configuration().webserver['port']}", + ] + + if (threads := Configuration().webserver["threads"]) > 1: + args += ["--threads", str(threads)] + + if Configuration().development_mode: + args += ["--py-autoreload", "1"] + + for k, v in Configuration().webserver["uwsgi_options"].items(): + args.append(f"--{k}") + if k in ("disable-logging", ): + continue + args.append(str(v).lower() if isinstance(v, bool) else str(v)) + + logging.info(f"Starting UWSGI server with cmdline: {' '.join(args)}") + + subprocess.run( + args, + check=True, + close_fds=True, + env=environment_vars, + ) diff --git a/src/cmd/web.py b/src/cmd/web.py new file mode 100644 index 0000000..f36e6ef --- /dev/null +++ b/src/cmd/web.py @@ -0,0 +1,61 @@ +import os +import sys + +from yoyo import get_backend, read_migrations + +from src.config import Configuration +from src.internals.database import database + + +def run_web(): + environment_vars = { + **os.environ.copy(), + "FLASK_DEBUG": "development" if Configuration().development_mode else "false", + "NODE_ENV": "development" if Configuration().development_mode else "production", + "KEMONO_SITE": Configuration().webserver["site"], + "ICONS_PREPEND": Configuration().webserver["ui"]["files_url_prepend"]["icons_base_url"], + "BANNERS_PREPEND": Configuration().webserver["ui"]["files_url_prepend"]["banners_base_url"], + "THUMBNAILS_PREPEND": Configuration().webserver["ui"]["files_url_prepend"]["thumbnails_base_url"], + "CREATORS_LOCATION": Configuration().webserver["api"]["creators_location"], + } + + for k, v in environment_vars.items(): + os.environ[k] = v + + database.init() + + try: + if Configuration().automatic_migrations: + """Run migrations.""" + backend = get_backend( + f"postgresql+psycopg://{Configuration().database["user"]}" + f":{Configuration().database["password"]}" + f"@{Configuration().database["host"]}:" + f"{Configuration().database["port"]}" + f"/{Configuration().database["database"]}" + ) + migrations = read_migrations("./db/migrations") + with backend.lock(): + backend.apply_migrations(backend.to_apply(migrations)) + """ Initialize Pgroonga if needed. """ + with database.pool.getconn() as conn: + try: + with conn.cursor() as db: + db.execute("CREATE EXTENSION IF NOT EXISTS pgroonga") + db.execute( + "CREATE INDEX IF NOT EXISTS pgroonga_posts_concat_idx ON posts USING pgroonga ( (title || ' ' || content) );" + ) + db.execute("CREATE INDEX IF NOT EXISTS pgroonga_dms_idx ON dms USING pgroonga (content)") + conn.commit() + except Exception: + pass + if Configuration().development_mode: + with conn.cursor() as db: + db.execute(open("db/seed.sql", "r").read()) + database.pool.putconn(conn) + finally: + """ "Close" the database pool.""" + database.close_pool() + + from src import server + server.app.run("0.0.0.0", port=80) diff --git a/src/cmd/webpack.py b/src/cmd/webpack.py new file mode 100644 index 0000000..c84c805 --- /dev/null +++ b/src/cmd/webpack.py @@ -0,0 +1,27 @@ +import os +import subprocess + +from src.config import Configuration + + +def run_webpack(): + environment_vars = { + **os.environ.copy(), + "FLASK_DEBUG": "development" if Configuration().development_mode else "false", + "NODE_ENV": "development" if Configuration().development_mode else "production", + "KEMONO_SITE": Configuration().webserver["site"], + "ICONS_PREPEND": Configuration().webserver["ui"]["files_url_prepend"]["icons_base_url"], + "BANNERS_PREPEND": Configuration().webserver["ui"]["files_url_prepend"]["banners_base_url"], + "THUMBNAILS_PREPEND": Configuration().webserver["ui"]["files_url_prepend"]["thumbnails_base_url"], + "CREATORS_LOCATION": Configuration().webserver["api"]["creators_location"], + } + + for k, v in environment_vars.items(): + os.environ[k] = v + + if not os.path.isdir("./client/node_modules"): + subprocess.run( + ["npm", "ci", "--also=dev", "--legacy-peer-deps"], check=True, cwd="client", env=environment_vars + ) + os.chdir("client") + os.execv("/usr/bin/sh", ["sh", "-c", "npm run dev"]) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..2ad457f --- /dev/null +++ b/src/config.py @@ -0,0 +1,328 @@ +import datetime +import getpass +import multiprocessing +import os +import random +import string +from base64 import b64encode + +import orjson + + +def merge_dict(dict_base, dict_that_overrides): + if isinstance(dict_base, dict) and isinstance(dict_that_overrides, dict): + return {**dict_base, **{k: merge_dict(dict_base.get(k, {}), v) for k, v in dict_that_overrides.items()}} + else: + return dict_that_overrides + + +class BuildConfiguration: + def __init__(self): + config_file = os.environ.get("KEMONO_CONFIG") or "config.json" + config_location = os.path.join("./", config_file) + config = {} + + if os.path.exists(config_location): + with open(config_location) as f: + config.update(orjson.loads(f.read())) + + override_config_file = os.environ.get("KEMONO_OVERRIDE_CONFIG") or "config.override.json" + override_config_location = os.path.join("./", override_config_file) + override_config = {} + + if os.path.exists(config_location): + with open(config_location) as f: + config.update(orjson.loads(f.read())) + + if os.path.exists(override_config_location): + with open(override_config_location) as f: + override_config.update(orjson.loads(f.read())) + + config = merge_dict(config, override_config) + + self.sentry_dsn = config.get("sentry_dsn", None) + self.open_telemetry_endpoint = config.get("open_telemetry_endpoint", None) + self.development_mode = config.get("development_mode", True) + self.automatic_migrations = config.get("automatic_migrations", True) + # Kemono3 `BAN` URL prefix for cache purging. + self.ban_url: str | list[str] | None = config.get('ban_url') + self.enable_notifications = False + self.cache_ttl_for_recent_posts = int(config.get("cache_ttl_for_recent_posts", 5*60-10)) + from src.utils.utils import decode_b64 + + """ Configuration for the frontend server. """ + self.webserver = config.get("webserver", {}) + # Secret key used to encrypt sessions. + self.webserver["secret"] = self.webserver.get( + "secret", "".join(random.choice(string.ascii_letters) for _ in range(32)) + ) + # How many workers and threads should run at once? + self.webserver["workers"] = self.webserver.get("workers", multiprocessing.cpu_count()) + self.webserver["threads"] = self.webserver.get("threads", 1) + # If you've dealt with how the trust of forwarding IPs works upstream, flip this off. + self.webserver["ip_security"] = self.webserver.get("ip_security", True) + # header for user country iso, generater by DDOS-GUARD + self.webserver["country_header_key"] = self.webserver.get("country_header_key", "DDG-Connecting-Country") + # Set additional uWSGI options if you want. Overrides any of the other options. + self.webserver["uwsgi_options"] = self.webserver.get("uwsgi_options", {}) + # The port the site will be served on. + self.webserver["port"] = self.webserver.get("port", 6942) + # Logging mode. + self.webserver["logging"] = self.webserver.get("logging", "DEBUG" if self.development_mode else "ERROR") + # The URL at which the site is publicly accessible. + # NOTE: `site` at root of config acceptable for backwards compatibility. + if config.get("site"): + # Only set backwards-compatibility values if the value is actually there. + # Otherwise, `get()`s will see a `None` value instead of a non-existent one and throw things off. + self.webserver["site"] = config["site"] + self.webserver["site"] = self.webserver.get("site", f"http://localhost:{self.webserver["port"]}") + # The location of the resources that will be served. + self.webserver["static_folder"] = self.webserver.get( + "static_folder", + "../client/dev/static" if self.development_mode else "../client/dist/static", + ) + self.webserver["template_folder"] = self.webserver.get( + "template_folder", + "../client/dev/pages" if self.development_mode else "../client/dist/pages", + ) + self.webserver["jinja_bytecode_cache_path"] = self.webserver.get("jinja_bytecode_cache_path") + self.webserver["max_full_text_search_input_len"] = self.webserver.get("max_full_text_search_input_len", 500) + self.webserver["table_sample_bernoulli_sample_size"] = float( + self.webserver.get("table_sample_bernoulli_sample_size", 0.1) + ) + self.webserver["extra_pages_to_load_on_posts"] = int(self.webserver.get("extra_pages_to_load_on_posts", 1)) + self.webserver["pages_in_popular"] = int(self.webserver.get("pages_in_popular", 10)) + self.webserver["earliest_date_for_popular"] = datetime.date.fromisoformat( + self.webserver.get("earliest_date_for_popular", "2022-02-01") + ) + self.webserver["use_redis_by_lock_default_on_queries"] = self.webserver.get( + "use_redis_by_lock_default_on_queries", False + ) + + self.webserver["api"] = self.webserver.get("api", {}) + self.webserver["api"]["creators_location"] = self.webserver["api"].get("creators_location", "") + # Interface preferences and customization options. + self.webserver["ui"] = self.webserver.get("ui", {}) + # Use custom fileservers. By default, files will simply point to the root. + # If you have multiple, this'll let you load balance! + # Each value in the array may either be a string pointing to the address + # of each server or a two-element array containing an address and split + # percentage (out of 100) + self.webserver["ui"]["config"] = self.webserver["ui"].get("config", {}) + self.webserver["ui"]["config"]["artists_or_creators"] = self.webserver["ui"]["config"].get( + "artists_or_creators", "Creators" + ) + + self.webserver["ui"]["config"]["paysite_list"] = self.webserver["ui"]["config"].get("paysite_list", []) + + self.webserver["ui"]["sidebar"] = self.webserver["ui"].get("sidebar", {}) + self.webserver["ui"]["sidebar"]["disable_filehaus"] = self.webserver["ui"]["sidebar"].get( + "disable_filehaus", False + ) + self.webserver["ui"]["sidebar"]["disable_faq"] = self.webserver["ui"]["sidebar"].get("disable_faq", False) + self.webserver["ui"]["sidebar"]["disable_dms"] = self.webserver["ui"]["sidebar"].get("disable_dms", False) + + self.webserver["ui"]["home"] = self.webserver["ui"].get("home", {}) + self.webserver["ui"]["home"]["logo_path"] = self.webserver["ui"]["home"].get("logo_path", None) + self.webserver["ui"]["home"]["mascot_path"] = self.webserver["ui"]["home"].get("mascot_path", None) + self.webserver["ui"]["home"]["welcome_credits"] = decode_b64( + self.webserver["ui"]["home"].get("welcome_credits", None) + ) + self.webserver["ui"]["home"]["home_background_image"] = self.webserver["ui"]["home"].get( + "home_background_image", None + ) + + self.webserver["ui"]["home"]["announcements"] = self.webserver["ui"]["home"].get("announcements", []) + for announcement in self.webserver["ui"]["home"]["announcements"]: + announcement["date"] = datetime.datetime.fromisoformat(announcement["date"]).strftime("%B %d, %Y") + + self.webserver["ui"]["home"]["site_name"] = self.webserver["ui"]["home"].get("site_name") or "This website" + + self.webserver["ui"]["files_url_prepend"] = self.webserver["ui"].get("files_url_prepend") or {} + self.webserver["ui"]["files_url_prepend"]["icons_base_url"] = ( + self.webserver["ui"]["files_url_prepend"].get("icons_base_url") or "" + ) + self.webserver["ui"]["files_url_prepend"]["banners_base_url"] = ( + self.webserver["ui"]["files_url_prepend"].get("banners_base_url") or "" + ) + self.webserver["ui"]["files_url_prepend"]["thumbnails_base_url"] = ( + self.webserver["ui"]["files_url_prepend"].get("thumbnails_base_url") or "" + ) + + self.webserver["ui"]["fileservers"] = self.webserver["ui"].get("fileservers", None) + # Add custom links to the bottom of the sidebar. + # See `client/src/pages/components/shell.html` for an idea of what the format is like. + self.webserver["ui"]["sidebar_items"] = self.webserver["ui"].get("sidebar_items", []) + # Add custom HTML elements to the footer. + self.webserver["ui"]["footer_items"] = self.webserver["ui"].get("footer_items", []) + # Banner HTML. Each entry should be Base64-encoded. + self.webserver["ui"]["banner"] = self.webserver["ui"].get("banner", {}) + self.webserver["ui"]["banner"]["global"] = decode_b64(self.webserver["ui"]["banner"].get("global", None)) + self.webserver["ui"]["banner"]["welcome"] = decode_b64(self.webserver["ui"]["banner"].get("welcome", None)) + # Ads preferences. Each spot should be Base64-encoded. + self.webserver["ui"]["ads"] = self.webserver["ui"].get("ads", {}) + self.webserver["ui"]["ads"]["header"] = decode_b64(self.webserver["ui"]["ads"].get("header", None)) + self.webserver["ui"]["ads"]["middle"] = decode_b64(self.webserver["ui"]["ads"].get("middle", None)) + self.webserver["ui"]["ads"]["footer"] = decode_b64(self.webserver["ui"]["ads"].get("footer", None)) + self.webserver["ui"]["ads"]["slider"] = decode_b64(self.webserver["ui"]["ads"].get("slider", None)) + + # This value should be a (still Base64-encoded!) JSON list of objects. + # See https://docs.fluidplayer.com/docs/configuration/ads/#adlist + self.webserver["ui"]["ads"]["video"] = decode_b64( + self.webserver["ui"]["ads"].get("video", b64encode(orjson.dumps([])).decode()) + ) + # Matomo preferences. + self.webserver["ui"]["matomo"] = self.webserver["ui"].get("matomo", {}) + self.webserver["ui"]["matomo"]["enabled"] = self.webserver["ui"]["matomo"].get("enabled", False) + # Fill in the information based on the embed code you get from the panel; + """ + [...] + (function() { + var u="//{{ tracking_domain }}/"; + _paq.push(['setTrackerUrl', u+'{{ tracking_code }}']); + _paq.push(['setSiteId', '{{ site_id }}']); + var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; + g.async=true; g.src=u+'{{ tracking_code }}.js'; s.parentNode.insertBefore(g,s); + })(); + """ + self.webserver["ui"]["matomo"]["tracking_domain"] = self.webserver["ui"]["matomo"].get( + "tracking_domain", "beta.kemono.party" + ) + self.webserver["ui"]["matomo"]["tracking_code"] = self.webserver["ui"]["matomo"].get("tracking_code", "onomek") + self.webserver["ui"]["matomo"]["site_id"] = self.webserver["ui"]["matomo"].get("site_id", 2) + # ... or override the template by entering a plain Base64-encoded string. + self.webserver["ui"]["matomo"]["plain_code"] = decode_b64( + self.webserver["ui"]["matomo"].get("plain_code", None) + ) + # File extensions recognized as (browser-friendly) video; will automatically be embedded in post pages. + self.webserver["ui"]["video_extensions"] = self.webserver["ui"].get( + "video_extensions", [".mp4", ".webm", ".m4v", ".3gp", ".mov"] + ) + + self.archive_server = config.get("archive_server", {}) + self.archive_server["api_url"] = self.archive_server.get("api_url") + self.archive_server["serve_files"] = self.archive_server.get("serve_files", False) + self.archive_server["enabled"] = self.archive_server.get("enabled", False) + self.archive_server["file_serving_enabled"] = self.archive_server.get("file_serving_enabled", False) + + """ Filehaus configuration. """ + self.filehaus = config.get("filehaus", {}) + # If true, an account will be required for uploading. + self.filehaus["requires_account"] = self.filehaus.get("requires_account", True) + # Required account roles for uploading. + # If set to an empty list, no permissions will be required. + self.filehaus["required_roles"] = self.filehaus.get( + "required_roles", ["administrator", "moderator", "uploader"] + ) + # `tusd` configuration. + self.filehaus["tus"] = self.filehaus.get("tus", {}) + # Automatically manage `tusd` on port 1080. + # If you intend to store uploads on a different server from Kemono3, + # set a custom URL instead... + self.filehaus["tus"]["manage"] = self.filehaus["tus"].get("manage", False) + # ...right here. Note that if you do not allow automatic management and did + # not set a custom URL, uploads will be blackholed to a demo instance and + # Filehaus sharing will not function correctly. + # Do note that using `tusd` instances to enable Filehaus functionality requires + # the `post-create` hook to be pointed at Kemono's `/shares/tus` endpoint. + # `tusd --hooks-enabled-events post-create -hooks-http "http://127.0.0.1:6942/shares/tus" -upload-dir=./data` + self.filehaus["tus"]["url"] = self.filehaus["tus"].get( + "url", + "http://localhost:1080" if self.filehaus["tus"]["manage"] else "https://tusd.tusdemo.net/files/", + ) + + """ Database configuration. """ + self.database = config.get("database", {}) + self.database["host"] = self.database.get("host", "127.0.0.1") + self.database["port"] = self.database.get("port", 5432) + self.database["password"] = self.database.get("password", "kemono") + self.database["database"] = self.database.get("database", "kemono") + self.database["user"] = self.database.get("user", "kemono") + self.database["application_name"] = self.database.get("application_name", "Kemono") + + self.redis = config.get("redis", {}) + self.redis["compression"] = self.redis.get("compression", "zstd") + self.redis["default_ttl"] = int(self.redis.get("default_ttl", "600")) + self.redis["node_options"] = self.redis.get("defaults", {}) + self.redis["node_options"]["host"] = self.redis["node_options"].get("host", "127.0.0.1") + self.redis["node_options"]["port"] = self.redis["node_options"].get("port", 6379) + self.redis["node_options"]["db"] = self.redis["node_options"].get("db", 0) + self.redis["node_options"]["password"] = self.redis["node_options"].get("password", None) + self.redis["nodes"] = self.redis.get("nodes", {0: {"db": 0}}) + if isinstance(self.redis["nodes"], list): + self.redis["nodes"] = dict((i, host) for i, host in enumerate(self.redis["nodes"])) + else: + self.redis["nodes"] = {int(k): v for k, v in self.redis["nodes"].items()} + self.redis["keyspaces"] = self.redis.get("keyspaces", {}) + keyspaces = { + "account", + "saved_key_import_ids", + "saved_keys", + "top_artists", + "random_artist_keys", + "artist", + "artist_post_count", + "artists_by_update_time", + "dms", + "all_dms", + "all_dms_count", + "all_dms_by_query", + "all_dms_by_query_count", + "dms_count", + "favorite_artists", + "favorite_posts", + "notifications_for_account", + "random_post_keys", + "post", + "posts_incomplete_rewards", + "comments", + "posts_by_artist", + "artist_posts_offset", + "is_post_flagged", + "importer_logs", + "ratelimit", + "all_posts", + "all_shares", + "all_shares_count", + "all_posts_for_query", + "global_post_count", + "global_post_count_for_query", + "lock", # used in KemonoRedisLock + "lock-signal", # used in KemonoRedisLock + "imports", + "share_files", + "account_notifications", + "new_notifications", + "share", + "artist_shares", + "post_revisions", + "files", + "post_by_id", + "fancards", + "announcements", + "announcement_count", + "artist_share_count", + "discord_channels_for_server", + "discord_posts", + "popular_posts", + "tagged_posts", + "tags", + "file", + "archive_files", + "linked_accounts", + } + + for name in keyspaces: + self.redis["keyspaces"][name] = self.redis["keyspaces"].get(name, 0) + + +global_config = None + + +def Configuration(): + global global_config + if not global_config: + global_config = BuildConfiguration() + return global_config + +# todo use watcher to reload config object based on json changes diff --git a/src/internals/cache/decorator.py b/src/internals/cache/decorator.py new file mode 100644 index 0000000..98cb5c6 --- /dev/null +++ b/src/internals/cache/decorator.py @@ -0,0 +1,37 @@ +import functools +import pickle +import time + +from src.internals.cache.redis import KemonoRedisLock, get_conn + + +def cache(prefix, ttl=3600, lock=True): + def cache_decorator(func): + @functools.wraps(func) + def cache_wrapper(*args, **kwargs): + reload = kwargs.pop("reload", False) + redis = get_conn() + key = prefix + if len(args): + key_args = ":".join(str(arg) for arg in args) + key += f":{key_args}" + if len(kwargs): + key_kwargs = ":".join(str(kwarg) for kwarg in kwargs) + key += f":{key_kwargs}" + result = redis.get(key) + if result is None or reload: + rlock = KemonoRedisLock(redis, key, expire=60, auto_renewal=True) + if lock and not rlock.acquire(blocking=False): + time.sleep(0.1) + return cache_wrapper(*args, **kwargs) + result = func(*args, **kwargs) + redis.set(key, pickle.dumps(result), ex=ttl) + if lock: + rlock.release() + else: + result = pickle.loads(result) + return result + + return cache_wrapper + + return cache_decorator diff --git a/src/internals/cache/redis.py b/src/internals/cache/redis.py new file mode 100644 index 0000000..0a281b4 --- /dev/null +++ b/src/internals/cache/redis.py @@ -0,0 +1,101 @@ +import logging +from typing import Optional + +import rb +import redis_lock + +from src.config import Configuration + +cluster: Optional[rb.Cluster] = None + + +def get_cluster() -> rb.Cluster: + if not cluster: + raise Exception("Redis cluster not set") + return cluster + + +class KemonoRouter(rb.BaseRouter): + def get_host_for_key(self, key): + top_level_prefix_of_key = key.split(":")[0] + if self.key_spaces().get(top_level_prefix_of_key) is not None: + return self.key_spaces()[top_level_prefix_of_key] + else: + logging.error(f"Missing redis key space for {key}, {top_level_prefix_of_key}") + if Configuration().development_mode: + return 0 + raise rb.UnroutableCommand() + + @staticmethod + def key_spaces(): + return Configuration().redis["keyspaces"] + + def get_key(self, command, args): + from rb._rediscommands import COMMANDS + from rb.router import extract_keys + + spec = COMMANDS.get(command.upper()) + + keys = extract_keys(args, spec["key_spec"]) + + if len(keys) > 1: + args = [args[0]] + return super().get_key(command, args) + + +class KemonoRedisLock(redis_lock.Lock): + """Reword to make the module compatible with Redis-Blaster.""" + + def release(self): + if self._lock_renewal_thread is not None: + self._stop_lock_renewer() + # soft reimplementation of UNLOCK_SCRIPT in Python + self._client.delete(self._signal) + self._client.lpush(self._signal, 1) + self._client.pexpire(self._signal, self._signal_expire) + self._client.delete(self._name) + + def extend(self, expire=None): + if expire: + expire = int(expire) + if expire < 0: + raise ValueError("A negative expire is not acceptable.") + elif self._expire is not None: + expire = self._expire + else: + raise TypeError( + "To extend a lock 'expire' must be provided as an " + "argument to extend() method or at initialization time." + ) + # soft reimplementation of EXTEND_SCRIPT in Python + self._client.expire(self._name, expire) + + +def init(): + global cluster + cluster = rb.Cluster( + host_defaults=Configuration().redis["node_options"], + hosts=Configuration().redis["nodes"], + router_cls=KemonoRouter, + ) + return cluster + + +def get_conn(): + return get_cluster().get_routing_client() + + +def scan_keys(pattern, count=50000): + return cluster.get_local_client_for_key(pattern).scan_iter(match=pattern, count=count) + + +def set_multiple_expire_keys(keys, ex=Configuration().redis["default_ttl"]): + """only use if its for the same key space""" + + example_key = keys.keys().__iter__().__next__() + redis_client = get_cluster().get_local_client_for_key(example_key) + pipe = redis_client.pipeline() + for k in keys: + pipe.expire(k, ex) + pipe.execute() + redis_client.close() diff --git a/src/internals/database/database.py b/src/internals/database/database.py new file mode 100644 index 0000000..f9f308c --- /dev/null +++ b/src/internals/database/database.py @@ -0,0 +1,294 @@ +import logging +import re +import time +from typing import Any, Optional + +import psycopg +from flask import g +from psycopg.abc import Query +from psycopg.connection import Connection +from psycopg.cursor import Cursor +from psycopg.errors import QueryCanceled +from psycopg.rows import dict_row +from psycopg.types.string import TextLoader +from psycopg_pool import ConnectionPool + +from src.config import Configuration +from src.internals.cache.redis import KemonoRedisLock, get_conn +from src.internals.serializers import safe_dumper, safe_loader + +pool: Optional[ConnectionPool] = None + + +def init(): + global pool + if not pool: + try: + pool = ConnectionPool( + min_size=1, + max_size=Configuration().webserver["threads"], + kwargs=dict( + host=Configuration().database["host"], + dbname=Configuration().database["database"], + user=Configuration().database["user"], + password=Configuration().database["password"], + port=Configuration().database["port"], + row_factory=dict_row, + application_name=Configuration().database["application_name"], + autocommit=True, + ), + ) + # citext https://github.com/psycopg/psycopg/issues/227 + with pool.connection() as connection: + connection.cursor().execute("CREATE EXTENSION IF NOT EXISTS citext;") + psycopg.types.TypeInfo.fetch(connection, "citext").register() + psycopg.adapters.register_loader("citext", TextLoader) # this works for single db case + except Exception as error: + logging.exception(f"Failed to connect to the database: {error}") + raise + return True + + +def get_pool() -> ConnectionPool: + if not pool: + raise Exception("Database gone") + return pool + + +def close_pool(): + global pool + if pool: + pool.close() + pool = None + + +def get_connection() -> Connection: + if "connection" not in g: + g.connection = get_pool().getconn() + g.connection.autocommit = True + return g.connection + + +def get_cursor() -> Cursor: + if "cursor" not in g: + if "connection" not in g: + g.connection = get_pool().getconn() + g.connection.autocommit = True + g.cursor = g.connection.cursor() + return g.cursor + + +def get_client_binding_cursor() -> Cursor: + if "client_binding_cursor" not in g: + if "connection" not in g: + g.connection = get_pool().getconn() + g.connection.autocommit = True + g.client_binding_cursor = psycopg.ClientCursor(g.connection) + return g.client_binding_cursor + + +def reset_cursor() -> None: + try: + g.cursor.execute("ROLLBACK") + except Exception: # noqa + pass + try: + g.cursor.close() + except Exception: # noqa + pass + if "connection" in g: + if g.connection.closed: + get_pool().putconn(g.connection) + g.connection = get_pool().getconn() + g.cursor = g.connection.cursor() + else: + g.cursor = g.connection.cursor() + else: + g.connection = get_pool().getconn() + g.connection.autocommit = True + g.cursor = g.connection.cursor() + + +def get_from_cache(redis: Any, cache_key: str, cache_store_method: str): + if cache_store_method == "set": + return redis.get(cache_key) + elif cache_store_method == "rpush": + result = redis.lrange(cache_key, 0, -1) + return None if not result else result + elif cache_store_method == "sadd": + result = redis.smembers(cache_key) + return None if not result else result + raise Exception("Not Implemented") + + +def cached_query( + query: str, + cache_key: str, + params: tuple = (), + serialize_fn=safe_dumper, + deserialize_fn=safe_loader, + reload: bool = False, + single: bool = False, + ex: int = Configuration().redis["default_ttl"], + ex_on_null: Optional[int] = None, + prepare: bool = True, + client_bind: bool = False, + lock_enabled: bool = Configuration().webserver["use_redis_by_lock_default_on_queries"], + sets_to_fetch: list[int] | None = None, + cache_store_method: str = "set", +) -> Any: + redis = get_conn() + result = get_from_cache(redis, cache_key, cache_store_method) if not reload else None + if result is None: + lock = KemonoRedisLock(redis, cache_key, expire=60, auto_renewal=True) + was_locked = not lock.acquire(blocking=False) if lock_enabled else False + if was_locked: + while not lock.acquire(blocking=False): + time.sleep(0.1) + result = get_from_cache(redis, cache_key, cache_store_method) if not reload else None + if result is not None: + return deserialize_fn(result) + try: + if client_bind: + cursor = get_client_binding_cursor() + cursor.execute(query, params, prepare=prepare) + else: + cursor = get_cursor() + cursor.execute(query, params, prepare=prepare) + if single: + if sets_to_fetch: + if len(sets_to_fetch) == 1: + for i in range(sets_to_fetch[0]): + cursor.nextset() + result = cursor.fetchone() + else: + result = [] + for i in range(max(sets_to_fetch)): + if i in sets_to_fetch: + return result.append(cursor.fetchone()) + cursor.nextset() + else: + result = cursor.fetchone() # todo make it "or {}" so we can tell on MGET if either it was in cache + else: + if sets_to_fetch: + if len(sets_to_fetch) == 1: + for i in range(sets_to_fetch[0]): + cursor.nextset() + result = cursor.fetchall() + else: + result = [] + for i in range(max(sets_to_fetch)): + if i in sets_to_fetch: + return result.append(cursor.fetchall()) + cursor.nextset() + else: + result = cursor.fetchall() + if cache_store_method == "set": + redis.set( + cache_key, + serialize_fn(result), + ex=ex if not ex_on_null or result else ex_on_null, + ) + elif cache_store_method == "rpush": + to_push = serialize_fn(result) + if to_push: + redis.rpush( + cache_key, + *serialize_fn(result), + ) + redis.expire(cache_key, ex) + else: + redis.rpush( + cache_key, + *serialize_fn([{}]), + ) + redis.expire(cache_key, ex) + redis.rpop(cache_key) + elif cache_store_method == "sadd": + if result: + redis.sadd( + cache_key, + *serialize_fn(result), + ) + redis.expire(cache_key, ex) + else: + getattr(redis, cache_store_method)( + cache_key, + serialize_fn(result), + ) + except QueryCanceled: + reset_cursor() + raise + except Exception as query_exception: + if not re.search( + r"pgroonga: \[[\w-]+\]\[query\] failed to parse expression:", + str(query_exception), + ): + logging.exception("Failed cached query", extra=dict(query=query, params=params)) + reset_cursor() + raise + finally: + if lock_enabled: + lock.release() + else: + result = deserialize_fn(result) + return result + + +def cached_count( + query: str, + cache_key: str, + params: tuple = (), + reload: bool = False, + ex: int = Configuration().redis["default_ttl"], + ex_on_null: Optional[int] = None, + prepare: bool = True, + client_bind: bool = False, + lock_enabled: bool = Configuration().webserver["use_redis_by_lock_default_on_queries"], + sets_to_fetch: list[int] | None = None, +) -> int: + # todo: fix serialize_fn + result = cached_query( + query, + cache_key, + params, + lambda val: val["count"], + int, + reload, + True, + ex, + ex_on_null, + prepare, + client_bind, + lock_enabled, + sets_to_fetch, + ) + if isinstance(result, dict): + result = result["count"] + return result + + +def query_db(query: Query, params: tuple = ()) -> list[dict]: + cursor = get_cursor() + cursor.execute(query, params) + result = cursor.fetchall() + return result + + +def query_one_db( + query: Query, + params: tuple = (), +) -> Optional[dict]: + cursor = get_cursor() + cursor.execute(query, params) + return cursor.fetchone() + + +def query_rowcount_db( + query: Query, + params: tuple = (), + prepare=True, +) -> int: + cursor = get_cursor() + cursor.execute(query, params, prepare=prepare) + return cursor.rowcount diff --git a/src/internals/internal_types/__init__.py b/src/internals/internal_types/__init__.py new file mode 100644 index 0000000..347e0a1 --- /dev/null +++ b/src/internals/internal_types/__init__.py @@ -0,0 +1,3 @@ +from .abstract_dataclass import AbstractDataclass +from .database_entry import DatabaseEntry +from .pageprops import PageProps diff --git a/src/internals/internal_types/abstract_dataclass.py b/src/internals/internal_types/abstract_dataclass.py new file mode 100644 index 0000000..0213fcb --- /dev/null +++ b/src/internals/internal_types/abstract_dataclass.py @@ -0,0 +1,16 @@ +from abc import ABC +from dataclasses import dataclass + + +@dataclass +class AbstractDataclass(ABC): + """ + Prevents abstract dataclasses from being instantiated. + Source: + https://stackoverflow.com/questions/60590442/abstract-dataclass-without-abstract-methods-in-python-prohibit-instantiation + """ + + def __new__(cls, *args, **kwargs): + if cls == AbstractDataclass or cls.__bases__[0] == AbstractDataclass: + raise TypeError("Cannot instantiate abstract class.") + return super().__new__(cls) diff --git a/src/internals/internal_types/database_entry.py b/src/internals/internal_types/database_entry.py new file mode 100644 index 0000000..66a5396 --- /dev/null +++ b/src/internals/internal_types/database_entry.py @@ -0,0 +1,35 @@ +# from abc import abstractmethod +from dataclasses import dataclass, fields +from typing import Dict + +from src.internals.internal_types import AbstractDataclass + + +@dataclass +class DatabaseEntry(AbstractDataclass): + @classmethod + def init_from_dict(cls, dictionary: Dict): + """ + Init a dataclass instance off a dictionary. + """ + instance = cls( + **{key: value for key, value in dictionary.items() if key in {field.name for field in fields(cls)}} + ) + return instance + + # @abstractmethod + # def serialize(): + # """ + # Serialize python-specific property types. + # Mostly used for Redis caching. + # """ + # pass + + # @abstractmethod + # def deserialize(): + # """ + # Deserialize certain properties into python-specific types. + # Mostly for transforming the results returned by Redis cache. + # `Psycopg` already transforms between types where applicable. + # """ + # pass diff --git a/src/internals/internal_types/pageprops.py b/src/internals/internal_types/pageprops.py new file mode 100644 index 0000000..bb895e8 --- /dev/null +++ b/src/internals/internal_types/pageprops.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from src.internals.internal_types import AbstractDataclass + + +@dataclass(init=False) +class PageProps(AbstractDataclass): + """Base class for page `props`.""" + + # currentPage: str + # title: str diff --git a/src/internals/serializers/__init__.py b/src/internals/serializers/__init__.py new file mode 100644 index 0000000..9d8844a --- /dev/null +++ b/src/internals/serializers/__init__.py @@ -0,0 +1,36 @@ +import pickle + +import orjson + +from src.config import Configuration +from src.internals.serializers.compressors import compressors + +unsafe_dumper = orjson.dumps +unsafe_loader = orjson.loads + +safe_dumper = pickle.dumps +safe_loader = pickle.loads + +compression_choice = Configuration().redis["compression"] +assert Configuration().redis["compression"] in compressors + +if compression_choice != "raw": + compressor, decompressor = compressors[compression_choice] + + base_unsafe_dumper = unsafe_dumper + base_unsafe_loader = unsafe_loader + + base_safe_dumper = safe_dumper + base_safe_loader = safe_loader + + def unsafe_dumper(data): + return compressor(base_unsafe_dumper(data)) + + def unsafe_loader(data): + return base_unsafe_loader(decompressor(data)) + + def safe_dumper(data): + return compressor(base_safe_dumper(data)) + + def safe_loader(data): + return base_safe_loader(decompressor(data)) diff --git a/src/internals/serializers/account.py b/src/internals/serializers/account.py new file mode 100644 index 0000000..af40199 --- /dev/null +++ b/src/internals/serializers/account.py @@ -0,0 +1,23 @@ +import datetime + +from src.internals.serializers import unsafe_dumper, unsafe_loader + + +def serialize_account(account, dumper=unsafe_dumper): + return dumper(account) + + +def deserialize_account(account, loader=unsafe_loader): + return rebuild_account_fields(loader(account)) + + +def prepare_account_fields(account): + account["created_at"] = account["created_at"].isoformat() + return account + + +def rebuild_account_fields(account): + if not account: + return None + account["created_at"] = datetime.datetime.fromisoformat(account["created_at"]) + return account diff --git a/src/internals/serializers/artist.py b/src/internals/serializers/artist.py new file mode 100644 index 0000000..4a8f734 --- /dev/null +++ b/src/internals/serializers/artist.py @@ -0,0 +1,37 @@ +import datetime + +from src.internals.serializers import unsafe_dumper, unsafe_loader + + +def serialize_artists(artists, dumper=unsafe_dumper): + return dumper(artists) + + +def deserialize_artists(artists_str, loader=unsafe_loader): + artists = loader(artists_str) + for element in artists: + rebuild_artist_fields(element) + return artists + + +def serialize_artist(artist, dumper=unsafe_dumper): + return dumper(artist) + + +def deserialize_artist(artist_str, loader=unsafe_loader): + artist = loader(artist_str) + if artist is not None: + rebuild_artist_fields(artist) + return artist + + +def prepare_artist_fields(artist): + artist["indexed"] = artist["indexed"].isoformat() + artist["updated"] = artist["updated"].isoformat() + return artist + + +def rebuild_artist_fields(artist): + artist["indexed"] = datetime.datetime.fromisoformat(artist["indexed"]) + artist["updated"] = datetime.datetime.fromisoformat(artist["updated"]) + return artist diff --git a/src/internals/serializers/compressors.py b/src/internals/serializers/compressors.py new file mode 100644 index 0000000..b482b99 --- /dev/null +++ b/src/internals/serializers/compressors.py @@ -0,0 +1,39 @@ +import lz4.block +import zstandard + +zst_cctx = zstandard.ZstdCompressor(level=1, threads=1, write_checksum=False) + + +def compress_zstd(data): + if len(data) > 128: + return zst_cctx.compress(data) + else: + return data + + +def decompress_zstd(data): + if data[:4] == b"(\xb5/\xfd": + return zstandard.decompress(data) + else: + return data + + +def compress_lz4(data): + if len(data) > 1024: + return lz4.block.compress(data, mode="fast", acceleration=1, compression=1) + else: + return data + + +def decompress_lz4(data): + # todo this is really bad maybe always compress instead + if (data[0] == b"{" and data[-1] == b"}") or data[:2] == b"\x80\x04": + return data + return lz4.block.decompress(data) + + +compressors = { + "zstd": (compress_zstd, decompress_zstd), + "lz4": (compress_lz4, decompress_lz4), + "raw": (lambda x: x, lambda x: x), +} diff --git a/src/internals/serializers/dm.py b/src/internals/serializers/dm.py new file mode 100644 index 0000000..ef74b26 --- /dev/null +++ b/src/internals/serializers/dm.py @@ -0,0 +1,30 @@ +import datetime + +from src.internals.serializers import unsafe_dumper, unsafe_loader + + +def serialize_dms(dms, dumper=unsafe_dumper): + return dumper(dms) + + +def deserialize_dms(dms_str, loader=unsafe_loader): + dms = loader(dms_str) + for element in dms: + rebuild_dm_fields(element) + return dms + + +def rebuild_dm_fields(dm): + dm["added"] = datetime.datetime.fromisoformat(dm["added"]) + dm["published"] = datetime.datetime.fromisoformat(dm["published"]) + if dm.get("deleted_at"): + dm["deleted_at"] = datetime.datetime.fromisoformat(dm["deleted_at"]) + return dm + + +def prepare_dm_fields(dm): + dm["added"] = dm["added"].isoformat() + dm["published"] = dm["published"].isoformat() + if dm.get("deleted_at"): + dm["deleted_at"] = dm["deleted_at"].isoformat() + return dm diff --git a/src/internals/serializers/generic_with_dates.py b/src/internals/serializers/generic_with_dates.py new file mode 100644 index 0000000..9e525c9 --- /dev/null +++ b/src/internals/serializers/generic_with_dates.py @@ -0,0 +1,43 @@ +import copy +import datetime + +from src.internals.serializers import unsafe_dumper, unsafe_loader + + +def serialize_dict(data, dumper=None): + to_serialize = {"dates": [], "data": {}} + + for key, value in data.items(): + if isinstance(value, (datetime.datetime, datetime.date)): + to_serialize["dates"].append(key) + to_serialize["data"][key] = value.isoformat() + else: + to_serialize["data"][key] = value + + if dumper: + return dumper(to_serialize) + else: + return to_serialize + + +def deserialize_dict(data, loader=None): + if loader: + data = loader(data) + to_return = {} + for key, value in data["data"].items(): + if key in data["dates"]: + to_return[key] = datetime.datetime.fromisoformat(value) + else: + to_return[key] = value + return to_return + + +def serialize_dict_list(data, serialize_method=unsafe_dumper): + data = copy.deepcopy(data) + return serialize_method([serialize_dict(elem) for elem in data]) + + +def deserialize_dict_list(data, deserialize_method=unsafe_loader): + data = deserialize_method(data) + to_return = [deserialize_dict(elem) for elem in data] + return to_return diff --git a/src/internals/serializers/post.py b/src/internals/serializers/post.py new file mode 100644 index 0000000..9671443 --- /dev/null +++ b/src/internals/serializers/post.py @@ -0,0 +1,62 @@ +import datetime + +from src.internals.serializers import unsafe_dumper, unsafe_loader + + +def serialize_post(data, dumper=unsafe_dumper): + return dumper(data) + + +def deserialize_post(data, loader=unsafe_loader): + return rebuild_post_fields(loader(data)) + + +def serialize_post_list(data, dumper=unsafe_dumper): + return dumper(data) + + +def deserialize_post_list(data, loader=unsafe_loader): + posts = loader(data) + for element in posts: + rebuild_post_fields(element) + return posts + + +def serialize_posts_incomplete_rewards(posts_incomplete_rewards, dumper=unsafe_dumper): + return dumper(posts_incomplete_rewards) + + +def deserialize_posts_incomplete_rewards(posts_incomplete_rewards_str, loader=unsafe_loader): + obj = loader(posts_incomplete_rewards_str) + if obj: + obj["last_checked_at"] = datetime.datetime.fromisoformat(obj["last_checked_at"]) + return obj + + +def rebuild_post_fields(post): + if post is None: + return + if "added" in post: + post["added"] = datetime.datetime.fromisoformat(post["added"]) + if post.get("published"): + post["published"] = datetime.datetime.fromisoformat(post["published"]) + if post.get("edited"): + post["edited"] = datetime.datetime.fromisoformat(post["edited"]) + return post + + +def prepare_post_fields(post): + if post.get("added"): + post["added"] = post["added"].isoformat() + if post.get("published"): + post["published"] = post["published"].isoformat() + if post.get("edited"): + post["edited"] = post["edited"].isoformat() + return post + + +# def date_safe_serialize_post_list(data, encoder): +# return encoder([prepare_post_fields(x) for x in copy.deepcopy(data)]) + +# def date_safe_serialize_post(data, encoder): +# return encoder(prepare_post_fields(copy.deepcopy(data))) diff --git a/src/internals/tracing/__init__.py b/src/internals/tracing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/internals/tracing/custom_psycopg_instrumentor.py b/src/internals/tracing/custom_psycopg_instrumentor.py new file mode 100644 index 0000000..369ae5f --- /dev/null +++ b/src/internals/tracing/custom_psycopg_instrumentor.py @@ -0,0 +1,80 @@ +import re +import typing + +import wrapt +from opentelemetry import trace +from opentelemetry.semconv.trace import SpanAttributes +from opentelemetry.trace import Span, SpanKind +from psycopg import Cursor +from psycopg.sql import Composed + +_leading_comment_remover = re.compile(r"^/\*.*?\*/") +tracer = trace.get_tracer("psycopg3", "0") + +instrumented = False + + +def instrument_psycopg(): + global instrumented + if instrumented: + return + instrumented = True + + def _populate_span( + span: Span, + cursor, + *args: typing.Tuple[typing.Any, typing.Any], + ): + if not span.is_recording(): + return + statement = get_statement(cursor, args) + span.set_attribute(SpanAttributes.DB_SYSTEM, cursor._conn.pgconn.db) + + span.set_attribute(SpanAttributes.DB_STATEMENT, statement) + + span.set_attribute("db.statement.parameters", str(args[1]) if len(args) > 1 else "no parameters") + + def get_operation_name(cursor, args): + if not args: + return "" + + statement = args[0] + if isinstance(statement, Composed): + statement = statement.as_string(cursor) + + if isinstance(statement, str): + # Strip leading comments so we get the operation name. + return _leading_comment_remover.sub("", statement).split()[0] + + return "" + + def get_statement(cursor, args): + if not args: + return "" + + statement = args[0] + if isinstance(statement, Composed): + statement = statement.as_string(cursor) + return statement + + @wrapt.decorator + def traced_execution(func, instance, args, kwargs): + name = get_operation_name(instance, args) + if not name: + name = "traced_execution" + + with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span: + _populate_span(span, instance, *args) + return func(*args, **kwargs) + + Cursor.execute = traced_execution(Cursor.execute) + Cursor.executemany = traced_execution(Cursor.executemany) + + @wrapt.decorator + def count_results(func, instance, args, kwargs): + with trace.get_tracer("fetch_all").start_as_current_span("fetchall", kind=trace.SpanKind.CLIENT) as span: + response = func(*args, **kwargs) + span.set_attribute("len_results", len(response)) + return response + + Cursor.fetchall = count_results(Cursor.fetchall) diff --git a/src/internals/tracing/custom_rb_instrumentor.py b/src/internals/tracing/custom_rb_instrumentor.py new file mode 100644 index 0000000..e56030d --- /dev/null +++ b/src/internals/tracing/custom_rb_instrumentor.py @@ -0,0 +1,68 @@ +import time + +from opentelemetry import trace +from opentelemetry.semconv.trace import SpanAttributes +from wrapt import wrap_function_wrapper + +redis_instrumentor_kwargs = {} + +redis_tracer = trace.get_tracer("rb", "0") + + +def _traced_execute_command(func, instance, args, kwargs): + name = args[0] + if len(args) > 1: + name += f" {args[1][:30]}" + with redis_tracer.start_as_current_span(name, kind=trace.SpanKind.CLIENT) as span: + if span.is_recording(): + span.set_attribute( + SpanAttributes.DB_STATEMENT, + " ".join(x[:40] if isinstance(x, str) else str(x)[:40] for x in args[1:5]) + + ("..." if len(args) > 5 else ""), + ) + span.set_attribute("db.redis.args_length", len(args)) + response = func(*args, **kwargs) + return response + + +def _traced_send_command(func, instance, args, kwargs): + with redis_tracer.start_as_current_span("connection_send_command", kind=trace.SpanKind.CLIENT) as span: + response = func(*args, **kwargs) + + return response + + +def _traced_get_connection(func, instance, args, kwargs): + with redis_tracer.start_as_current_span(func.__name__, kind=trace.SpanKind.CLIENT) as span: + response = func(*args, **kwargs) + + return response + + +def _traced_generic(func, instance, args, kwargs): + with redis_tracer.start_as_current_span(func.__name__, kind=trace.SpanKind.CLIENT) as span: + response = func(*args, **kwargs) + return response + + +def _traced_time_generic(func, instance, args, kwargs): + current_span = trace.get_current_span() + + if current_span: + start = time.perf_counter() + response = func(*args, **kwargs) + current_span.set_attribute("time_" + func.__name__, (time.perf_counter() - start) * 1e6) + else: + response = func(*args, **kwargs) + + return response + + +def rb_instrument(): + wrap_function_wrapper("rb", f"clients.RoutingClient.execute_command", _traced_execute_command) + + wrap_function_wrapper("rb", f"clients.RoutingClient.parse_response", _traced_time_generic) + + wrap_function_wrapper("redis", f"connection.Connection.send_command", _traced_time_generic) + + wrap_function_wrapper("redis", f"connection.ConnectionPool.get_connection", _traced_time_generic) diff --git a/src/internals/tracing/tracing.py b/src/internals/tracing/tracing.py new file mode 100644 index 0000000..5a699d2 --- /dev/null +++ b/src/internals/tracing/tracing.py @@ -0,0 +1,75 @@ +import wrapt +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.flask import FlaskInstrumentor +from opentelemetry.instrumentation.requests import RequestsInstrumentor +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from src.internals.tracing.custom_psycopg_instrumentor import instrument_psycopg +from src.internals.tracing.custom_rb_instrumentor import rb_instrument + + +def open_telemetry_init(app, endpoint): + resource = Resource.create({"service.name": "kemono3"}) + otlp_exporter = OTLPSpanExporter( + endpoint=endpoint, + insecure=True, + ) + trace.set_tracer_provider(TracerProvider(resource=resource)) + trace.get_tracer_provider().add_span_processor( + BatchSpanProcessor( + otlp_exporter, + schedule_delay_millis=1 * 1e3, # should be 10 + max_queue_size=15000, + max_export_batch_size=15000, + ) + ) + + instrument_psycopg() + FlaskInstrumentor().instrument_app(app) + RequestsInstrumentor().instrument() + # RedisInstrumentor().instrument() + rb_instrument() + + import flask + + def trace_render_template(tracer_name): + @wrapt.decorator + def _traced_render_template(func, instance, args, kwargs): + with trace.get_tracer(tracer_name).start_as_current_span(func.__name__, kind=trace.SpanKind.CLIENT) as span: + current_span = trace.get_current_span() + if current_span: + current_span.set_attribute("template_name", args[0]) + return func(*args, **kwargs) + + return _traced_render_template + + flask.render_template = trace_render_template("render_template")(flask.render_template) + + import src.internals.database.database + + cached_query_arg_names = src.internals.database.database.cached_query.__annotations__ + cached_query_arg_names.pop("return") + cached_query_arg_names = tuple(cached_query_arg_names) + + def instrumented_def_cached_query(func, instance, args, kwargs): + current_span = trace.get_current_span() + + if current_span: + for arg_name, arg_value in zip(cached_query_arg_names, args): + current_span.set_attribute(arg_name, arg_value) + for k, v in kwargs.items(): + current_span.set_attribute(k, v) + with trace.get_tracer("cached_query").start_as_current_span( + "cached_query " + args[0].replace(" ", "")[:50], kind=trace.SpanKind.INTERNAL + ) as span: + return func(*args, **kwargs) + return func(*args, **kwargs) + + src.internals.database.database.cached_query = wrapt.decorator(instrumented_def_cached_query)( + src.internals.database.database.cached_query + ) + # src.internals.database.database.cached_query = generic_trace("cached_query")(src.internals.database.database.cached_query) + # wrap_function_wrapper("src", f"internals.database.database.cached_query", generic_trace()) diff --git a/src/internals/tracing/utils.py b/src/internals/tracing/utils.py new file mode 100644 index 0000000..3862db0 --- /dev/null +++ b/src/internals/tracing/utils.py @@ -0,0 +1,27 @@ +import time + +import wrapt +from opentelemetry import trace +from opentelemetry.trace import get_current_span + + +def generic_trace(tracer_name): + @wrapt.decorator + def _traced_generic(func, instance, args, kwargs): + with trace.get_tracer(tracer_name).start_as_current_span(func.__name__, kind=trace.SpanKind.CLIENT) as span: + return func(*args, **kwargs) + + return _traced_generic + + +@wrapt.decorator +def _traced_time_generic(func, instance, args, kwargs): + current_span = get_current_span() + if current_span: + start = time.perf_counter() + response = func(*args, **kwargs) + current_span.set_attribute("time_" + func.__name__, (time.perf_counter() - start) * 1e6) + else: + response = func(*args, **kwargs) + + return response diff --git a/src/lib/account.py b/src/lib/account.py new file mode 100644 index 0000000..3f5d784 --- /dev/null +++ b/src/lib/account.py @@ -0,0 +1,180 @@ +import base64 +import hashlib +from typing import Optional + +import bcrypt +from flask import current_app, flash, session +from nh3 import nh3 + +from src.internals.cache.redis import get_conn +from src.internals.database.database import cached_query, get_cursor, query_db, query_one_db +from src.internals.serializers.account import deserialize_account, serialize_account +from src.lib.artist import get_artist +from src.lib.favorites import add_favorite_artist +from src.lib.security import is_login_rate_limited +from src.types.account import ServiceKey +from src.types.account.account import Account + + +def load_account(account_id: Optional[str] = None, reload: bool = False): + if not account_id and "account_id" in session: + account_id = session["account_id"] + elif not account_id and "account_id" not in session: + return None + + key = f"account:{account_id}" + query = """ + SELECT id, username, created_at, role + FROM account + WHERE id = %s + """ + account_dict = cached_query(query, key, (account_id,), serialize_account, deserialize_account, reload, True) + return Account.init_from_dict(account_dict) + + +def get_saved_key_import_ids(key_id, reload=False): + key = f"saved_key_import_ids:{key_id}" + query = """ + SELECT * + FROM saved_session_key_import_ids + WHERE key_id = %s + """ + return cached_query(query, key, (key_id,), reload=reload) + + +def get_saved_keys(account_id: int, reload: bool = False): + # lets not cache this for now + # key = f"saved_keys:{account_id}" + query = """ + SELECT id, service, discord_channel_ids, added, dead + FROM saved_session_keys_with_hashes + WHERE contributor_id = %s + ORDER BY added DESC + """ + # results = cached_query(query, key, (account_id,), reload=reload) + results = query_db(query, (account_id,)) + return [ServiceKey.init_from_dict(service_key) for service_key in results] + + +def revoke_saved_keys(key_ids: list[int], account_id: int): + cursor = get_cursor() + query_args = dict(key_ids=key_ids, account_id=account_id) + query1 = """ + DELETE + FROM saved_session_key_import_ids skid + USING saved_session_keys_with_hashes sk + WHERE + skid.key_id = sk.id + AND sk.id = ANY (%(key_ids)s) + AND sk.contributor_id = %(account_id)s + """ + cursor.execute(query1, query_args) + query2 = """ + DELETE + FROM saved_session_keys_with_hashes + WHERE + id = ANY (%(key_ids)s) + AND contributor_id = %(account_id)s + """ + cursor.execute(query2, query_args) + redis = get_conn() + key = f"saved_keys:{account_id}" + redis.delete(key) + + +def get_login_info_for_username(username): + query = "SELECT id, password_hash FROM account WHERE username = %s" + return query_one_db(query, (username,)) + + +def get_password_hash_for_user_id(user_id: int) -> Optional[str]: + query = "SELECT password_hash FROM account WHERE id = %s" + return (query_one_db(query, (user_id,)) or {}).get("password_hash") + + +def set_password_hash_for_user_id(user_id: int, password_hash: str): + query = "UPDATE account SET password_hash = %s WHERE id = %s" + cursor = get_cursor() + cursor.execute(query, (password_hash, user_id)) + + +def is_logged_in(): + if "account_id" in session: + return True + return False + + +def create_account(username: str, password: str, favorites: Optional[list[dict]] = None) -> bool: + password_hash = bcrypt.hashpw(get_base_password_hash(password), bcrypt.gensalt()).decode() + + cursor = get_cursor() + query = """ + INSERT INTO account (username, password_hash) + VALUES (%s, %s) + ON CONFLICT (username) DO NOTHING + RETURNING id + """ + cursor.execute(query, (nh3.clean(username, tags=set()), password_hash)) + account = cursor.fetchone() + if not account: + return False + if account["id"] == 1: + cursor = get_cursor() + query = """ + UPDATE account + SET role = 'administrator' + WHERE id = 1 + """ + cursor.execute(query) + + if favorites is not None and isinstance(favorites, list): + for favorite in favorites: + artist = get_artist(favorite["service"], favorite["artist_id"]) + if artist is None: + continue + add_favorite_artist(account["id"], favorite["service"], favorite["artist_id"]) + + return True + + +def change_password(user_id: int, current_password: str, new_password: str) -> bool: + current_hash = get_password_hash_for_user_id(user_id) + + if not current_hash: + # hopefully not possible + raise Exception("Non-existent user ID provided") + + if not bcrypt.checkpw(get_base_password_hash(current_password), current_hash.encode()): + return False + + new_password_hash = bcrypt.hashpw(get_base_password_hash(new_password), bcrypt.gensalt()).decode() + set_password_hash_for_user_id(user_id, new_password_hash) + return True + + +def attempt_login(username: str, password: str) -> Optional[Account]: + if not username or not password: + return None + + account_info = get_login_info_for_username(username) + if account_info is None: + flash("Username or password is incorrect") + return None + + if current_app.config.get("ENABLE_LOGIN_RATE_LIMITING") and is_login_rate_limited(str(account_info["id"])): + flash("You're doing that too much. Try again in a little bit.") + return None + + if bcrypt.checkpw(get_base_password_hash(password), account_info["password_hash"].encode("utf-8")): + if account := load_account(account_info["id"], True): + session["account_id"] = account.id + return account + else: + raise Exception("Error loading account") + + flash("Username or password is incorrect") + return None + + +def get_base_password_hash(password: str): + return base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest()) diff --git a/src/lib/administrator.py b/src/lib/administrator.py new file mode 100644 index 0000000..1f7961f --- /dev/null +++ b/src/lib/administrator.py @@ -0,0 +1,76 @@ +from typing import Dict, List + +from src.internals.database.database import query_db, query_one_db, query_rowcount_db +from src.lib.notification import send_notifications +from src.lib.pagination import Pagination +from src.types.account import Account, AccountRoleChange, NotificationTypes + + +def get_account(account_id: str) -> Account: + account = query_one_db( + """ + SELECT id, username, created_at, role + FROM account + WHERE id = %s + """, + (account_id,), + ) + account = Account.init_from_dict(account) + return account + + +def count_accounts(queries: Dict[str, str]) -> int: + role = queries["role"] + params: tuple[str, ...] + if queries.get("name"): + params = (role, queries["name"]) + else: + params = (role,) + result = query_one_db( + f""" + SELECT COUNT(*) AS total_number_of_accounts + FROM account + WHERE + role = ANY(%s) + {"AND username LIKE %s" if len(params) > 1 else ""} + """, + params, + ) + return result["total_number_of_accounts"] if result else 0 + + +def get_accounts(pagination: Pagination, queries: Dict[str, str]) -> List[Account]: + params = (queries["role"], pagination.offset, pagination.limit) + if queries.get("name"): + params = (queries["role"], f"%%{queries["name"]}%%", pagination.offset, pagination.limit) + + accounts = query_db( + f""" + SELECT id, username, created_at, role + FROM account + WHERE + role = ANY(%s) + {"AND username LIKE %s" if len(params) > 3 else ""} + ORDER BY + created_at DESC, + username + OFFSET %s + LIMIT %s + """, + params, + ) + acc_list = [Account.init_from_dict(acc) for acc in accounts] + count = count_accounts(queries) + pagination.add_count(count) + return acc_list + + +def change_account_role(account_ids: List[str], extra_info: AccountRoleChange): + change_role_query = """ + UPDATE account + SET role = %s + WHERE id = ANY (%s) + """ + query_rowcount_db(change_role_query, (extra_info["new_role"], account_ids)) + send_notifications(account_ids, NotificationTypes.ACCOUNT_ROLE_CHANGE, extra_info) + return True diff --git a/src/lib/announcements.py b/src/lib/announcements.py new file mode 100644 index 0000000..756461a --- /dev/null +++ b/src/lib/announcements.py @@ -0,0 +1,54 @@ +from typing import List + +from src.internals.database.database import cached_count, cached_query, get_cursor + + +def get_artist_announcements( + service: str, artist_id: str, query: str | None = None, reload: bool = False +) -> List[dict]: + key = f"announcements:{service}:{artist_id}:{hash(query) if query else ""}" + params: tuple[str, ...] + + if query: + cur = get_cursor() + ts_query = cur.mogrify("AND to_tsvector('english', content) @@ websearch_to_tsquery(%s)", (query,)).decode() + else: + ts_query = "" + + if service == "fanbox": + query = f""" + SELECT *, 'fanbox' AS service + FROM fanbox_newsletters + WHERE user_id = %s {ts_query} + ORDER BY published DESC + """ + params = (artist_id,) + else: + query = f""" + SELECT * + FROM introductory_messages + WHERE service = %s AND user_id = %s {ts_query} + ORDER BY added DESC + """ + params = (service, artist_id) + + return cached_query(query, key, params, reload=reload) + + +def get_announcement_count(service: str, artist_id: str, query: str, reload: bool = True): + key = f"announcement_count:{service}:{artist_id}:{hash(query)}" + + if query: + cur = get_cursor() + ts_query = cur.mogrify("AND to_tsvector('english', content) @@ websearch_to_tsquery(%s)", (query,)).decode() + else: + ts_query = "" + + if service == "fanbox": + query = f"SELECT COUNT(1) FROM fanbox_newsletters WHERE user_id = %s {ts_query}" + params = (artist_id,) + else: + query = f"SELECT COUNT(1) FROM introductory_messages WHERE service = %s AND user_id = %s {ts_query}" + params = (service, artist_id) + + return cached_count(query, key, params, reload) diff --git a/src/lib/artist.py b/src/lib/artist.py new file mode 100644 index 0000000..186e5c4 --- /dev/null +++ b/src/lib/artist.py @@ -0,0 +1,222 @@ +import datetime +from typing import Optional + +from src.config import Configuration +from src.internals.cache.redis import get_conn +from src.internals.database.database import cached_query, get_cursor, get_pool +from src.internals.serializers import unsafe_dumper, unsafe_loader +from src.internals.serializers.artist import ( + deserialize_artist, + deserialize_artists, + serialize_artist, + serialize_artists, +) +from src.utils.utils import clear_web_cache_for_creator_links + + +def get_top_artists_by_faves(offset, count, reload=False): + key = f"top_artists:{offset}:{count}" + query = """ + SELECT l.*, count(*) + FROM lookup l + INNER JOIN account_artist_favorite aaf + ON l.id = aaf.artist_id AND l.service = aaf.service + WHERE + (l.id, l.service) NOT IN (SELECT id, service from dnp) + GROUP BY (l.id, l.service) + ORDER BY count(*) DESC + OFFSET %s + LIMIT %s + """ + return cached_query( + query, + key, + (offset, count), + serialize_artists, + deserialize_artists, + reload, + ex=int(datetime.timedelta(hours=24).total_seconds()), + lock_enabled=True, + ) + + +def get_random_artist_keys(count, reload=False): + key = f"random_artist_keys:{count}" + query = "SELECT id, service FROM lookup ORDER BY random() LIMIT %s" + return cached_query(query, key, (count,), unsafe_dumper, unsafe_loader, reload, lock_enabled=True) + + +def get_artist(service: str, artist_id: str, reload: bool = False) -> dict: + key = f"artist:{service}:{artist_id}" + if service == "onlyfans": + id_filter = "(id = %s or public_id = %s)" + params = (artist_id, artist_id, service) + else: + id_filter = "id = %s" + params = (artist_id, service) + query = f""" + SELECT * + FROM lookup + WHERE + {id_filter} + AND service = %s + AND (id, service) NOT IN (SELECT id, service from dnp); + """ + return cached_query( + query, + key, + params, + serialize_artist, + deserialize_artist, + reload, + True, + 86400, + Configuration().redis["default_ttl"], + ) + + +def get_artists_by_update_time(offset, limit, reload=False): + key = f"artists_by_update_time:{offset}:{limit}" + query = """ + SELECT * + FROM lookup + WHERE + (id, service) NOT IN (SELECT id, service from dnp) + ORDER BY updated desc + OFFSET %s + LIMIT %s + """ + return cached_query(query, key, (offset, limit), serialize_artists, deserialize_artists, reload) + + +def get_fancards_by_artist(artist_id, reload=False): + key = f"fancards:{artist_id}" + query = "select * from fanbox_fancards left join files on file_id = files.id where user_id = %s and file_id is not null order by added desc" + return cached_query(query, key, (artist_id,), reload=reload) + + +def create_unapproved_link_request(from_artist, to_artist, user_id, reason: Optional[str]): + query = """ + INSERT INTO unapproved_link_requests (from_service, from_id, to_service, to_id, requester_id, reason) + VALUES (%s, %s, %s, %s, %s, %s) + ON CONFLICT DO NOTHING + """ + cur = get_cursor() + cur.execute(query, (from_artist["service"], from_artist["id"], to_artist["service"], to_artist["id"], user_id, reason or None)) + + +def get_unapproved_links_with_artists(): + query = """ + SELECT + unapproved_link_requests.* + , row_to_json(from_creator.*) as from_creator + , row_to_json(to_creator.*) as to_creator + , row_to_json(requester.*) as requester + FROM unapproved_link_requests + JOIN lookup from_creator ON from_service = from_creator.service AND from_id = from_creator.id + JOIN lookup to_creator ON to_service = to_creator.service AND to_id = to_creator.id + JOIN account requester ON requester_id = requester.id + WHERE status = 'pending' + ORDER BY unapproved_link_requests.id ASC + """ + cur = get_cursor() + cur.execute(query) + return cur.fetchall() + + +def reject_unapproved_link_request(request_id: int): + query = """ + UPDATE unapproved_link_requests + SET status = 'rejected' + WHERE id = %s + """ + cur = get_cursor() + cur.execute(query, (request_id,)) + + +def approve_unapproved_link_request(request_id: int): + # move from the not same to the diffrent one + query1 = """ + UPDATE unapproved_link_requests + SET status = 'approved' + WHERE id = %s + OR (from_service, to_service, from_id, to_id) in (SELECT from_service, to_service, from_id, to_id from unapproved_link_requests WHERE id = %s) + OR (from_service, to_service, from_id, to_id) in (SELECT to_service, from_service, to_id, from_id from unapproved_link_requests WHERE id = %s) + Returning from_service, to_service, from_id, to_id + """ + query2 = """ + WITH next_relation_table AS ( + SELECT nextval('lookup_relation_id_seq') as next_id + ) + UPDATE lookup creators + SET relation_id = coalesce(from_creator.relation_id, to_creator.relation_id, (SELECT next_id FROM next_relation_table)) + FROM lookup from_creator, lookup to_creator, unapproved_link_requests link + WHERE + ( + (creators.id = link.from_id and creators.service = link.from_service) + OR + (creators.id = link.to_id and creators.service = link.to_service) + ) AND + link.id = %s AND + from_creator.service = link.from_service AND + from_creator.id = link.from_id AND + to_creator.service = link.to_service AND + to_creator.id = link.to_id + """ + cursor = get_cursor() + cursor.execute("BEGIN;") + cursor.execute(query1, (request_id, request_id, request_id)) + update_result = cursor.fetchone() + if not update_result: + raise Exception("Failed to change status of request") + cursor.execute(query2, (request_id,)) + cursor.execute("COMMIT;") + redis = get_conn() + redis.delete(f"linked_accounts:{update_result['from_service']}:{update_result['from_id']}") + redis.delete(f"linked_accounts:{update_result['to_service']}:{update_result['to_id']}") + redis.delete(f"artist:{update_result['from_service']}:{update_result['from_id']}") + redis.delete(f"artist:{update_result['to_service']}:{update_result['to_id']}") + clear_web_cache_for_creator_links(update_result['from_service'], update_result['from_id']) + clear_web_cache_for_creator_links(update_result['to_service'], update_result['to_id']) + + +def delete_creator_link(service: str, creator_id: str): + query = """ + UPDATE lookup + SET relation_id = NULL + WHERE service = %s AND id = %s + """ + cur = get_cursor() + cur.execute(query, (service, creator_id)) + + +def get_linked_creators(service: str, creator_id: str): + key = f"linked_accounts:{service}:{creator_id}" + query = """ + SELECT * FROM lookup l1 + JOIN lookup l2 ON l1.relation_id = l2.relation_id + WHERE l1.service = %s AND l1.id = %s + AND l1.ctid != l2.ctid + ORDER BY l1.id, l1.service, l2.id, l2.service + """ + return cached_query(query, key, (service, creator_id), reload=True) + + +def nload_query_artists(artist_ids: list[tuple[str, str]], dict_result=False): + # todo remake this like the nload for posts + if not artist_ids: + return [] + keys = [f"artist:{service}:{artist_id}" for service, artist_id in artist_ids] + redis = get_conn() + cached = (deserialize_artist(artist) for artist in (artist_str for artist_str in redis.mget(keys) if artist_str)) + in_cache = {(artist["service"], artist["id"]): artist for artist in cached if artist} + for service, artist_id in artist_ids: + if in_cache.get((service, artist_id)) is None: + in_cache[(service, artist_id)] = get_artist(service, artist_id) or {} + if dict_result: + return in_cache + + artists = [] + for service, artist_id in artist_ids: + artists.append(in_cache.get((service, artist_id))) + return artists diff --git a/src/lib/dms.py b/src/lib/dms.py new file mode 100644 index 0000000..de3f6b1 --- /dev/null +++ b/src/lib/dms.py @@ -0,0 +1,154 @@ +import base64 +from typing import List + +from src.internals.database.database import cached_count, cached_query, query_db, query_rowcount_db +from src.internals.serializers.dm import deserialize_dms, serialize_dms +from src.lib.artist import nload_query_artists +from src.types.kemono import Approved_DM, Unapproved_DM + +DM_FIELDS_LIST = ['"hash"', '"user"', "service", "content", "embed", "added", "published", "file"] +DM_FIELDS = ", ".join(DM_FIELDS_LIST) + + +def get_unapproved_dms(account_id: int, deleted=False) -> List[Unapproved_DM]: + query = f""" + SELECT {DM_FIELDS}, contributor_id, import_id + FROM unapproved_dms + WHERE contributor_id = %s and deleted_at is {"NOT" if deleted else ""} NULL + """ + result = query_db(query, (str(account_id),)) + + creator_dict = nload_query_artists([(each["service"], each["user"]) for each in result], True) + for dm in result: + dm["artist"] = (creator_dict.get((dm["service"], dm["user"])) or {}) + + return [Unapproved_DM.init_from_dict(dm) for dm in result] + + +def has_unapproved_dms(account_id: int, deleted=False) -> bool: + query = f""" + SELECT true + FROM unapproved_dms + WHERE contributor_id = %s and deleted_at is {"NOT" if deleted else ""} NULL + LIMIT 1 + """ + result = query_db(query, (str(account_id),)) + return bool(result) + + +def count_user_dms(service: str, user_id: str, reload: bool = False) -> int: + if service not in ("patreon",): + return 0 + key = f"dms_count:{service}:{user_id}" + query = 'SELECT COUNT(*) FROM dms WHERE service = %s AND "user" = %s' + return cached_count(query, key, (service, user_id), reload) + + +def get_artist_dms(service: str, artist_id: str, reload: bool = False) -> List[Approved_DM]: + key = f"dms:{service}:{artist_id}" + query = f""" + SELECT {DM_FIELDS} + FROM dms + WHERE service = %s AND "user" = %s + """ + result = cached_query(query, key, (service, artist_id), serialize_dms, deserialize_dms, reload) + return [Approved_DM.init_from_dict(dm) for dm in result] + + +def get_all_dms_count(reload: bool = False) -> int: + key = "all_dms_count" + query = "SELECT COUNT(*) FROM dms" + return cached_count(query, key, (), reload, lock_enabled=True) + + +def get_all_dms(offset: int, limit: int, reload: bool = False) -> List[Approved_DM]: + key = f"all_dms:{offset}" + query = f""" + SELECT {DM_FIELDS} + FROM dms + ORDER BY added DESC + OFFSET %s + LIMIT %s + """ + results = cached_query( + query, key, (offset, limit), serialize_dms, deserialize_dms, reload, lock_enabled=True + ) # maybe not lock? + + creator_dict = nload_query_artists([(each["service"], each["user"]) for each in results], True) + for dm in results: + dm["artist"] = (creator_dict.get((dm["service"], dm["user"])) or {}) + + return [Approved_DM.init_from_dict(dm) for dm in results] + + +def get_all_dms_by_query_count(text_query: str, reload: bool = False) -> int: + query = """ + SELECT COUNT(*) FROM dms WHERE content &@~ %s + """ + key = f"all_dms_by_query_count:{base64.b64encode(text_query.encode()).decode()}" + return cached_count(query, key, (text_query,), reload, lock_enabled=True) + + +def get_all_dms_by_query(text_query: str, offset: int, limit: int, reload: bool = False) -> List[Approved_DM]: + key = f"all_dms_by_query:{offset}:{limit}:{base64.b64encode(text_query.encode()).decode()}" + query = f""" + SELECT {DM_FIELDS} + FROM dms + WHERE content &@~ %s + ORDER BY added DESC + OFFSET %s + LIMIT %s + """ + results = cached_query( + query, key, (text_query, offset, limit), serialize_dms, deserialize_dms, reload, lock_enabled=True + ) + + creator_dict = nload_query_artists([(each["service"], each["user"]) for each in results], True) + for dm in results: + dm["artist"] = (creator_dict.get((dm["service"], dm["user"])) or {}) + + return [Approved_DM.init_from_dict(dm) for dm in results] + + +def cleanup_unapproved_dms(contributor_id: int, delete=False) -> int: + if delete: + return query_rowcount_db( + f""" + DELETE + FROM unapproved_dms + WHERE contributor_id = %s and deleted_at is NOT NULL + """, + (str(contributor_id),), + ) + else: + return query_rowcount_db( + f""" + UPDATE unapproved_dms + SET deleted_at = CURRENT_TIMESTAMP + WHERE contributor_id = %s and deleted_at is NULL + """, + (str(contributor_id),), + ) + + +def clean_dms_already_approved(contributor_id: int | None = None): + return query_rowcount_db( + f""" + DELETE FROM public.unapproved_dms + USING public.dms + WHERE public.unapproved_dms.hash = public.dms.hash {" AND contributor_id = %s" if contributor_id else ""}; + """, + (str(contributor_id),) if contributor_id else tuple(), + ) + + +def approve_dms(contributor_id: int, dm_hashes: list[str]): + insert_fields = DM_FIELDS.replace(", added", "") + query = f""" + INSERT INTO dms ({insert_fields}) + SELECT {insert_fields} + FROM unapproved_dms + WHERE contributor_id = %s AND hash = ANY(%s) + ON CONFLICT ("hash","user", service) DO NOTHING; + """ + query_rowcount_db(query, (str(contributor_id), dm_hashes)) diff --git a/src/lib/favorites.py b/src/lib/favorites.py new file mode 100644 index 0000000..09c344e --- /dev/null +++ b/src/lib/favorites.py @@ -0,0 +1,119 @@ +import logging + +from src.internals.cache.redis import get_conn +from src.internals.database.database import cached_query, query_rowcount_db +from src.internals.serializers.artist import deserialize_artist +from src.lib.artist import get_artist +from src.lib.post import get_post_multiple + + +def get_favorite_artists(account_id, reload=False): + key = f"favorite_artists:{account_id}" + query = """ + SELECT aaf.id, aaf.service, aaf.artist_id, pam.added as updated + FROM account_artist_favorite aaf + LEFT JOIN posts_added_max pam on pam.service = aaf.service and pam."user" = aaf.artist_id + WHERE account_id = %s + """ + user_favorite_artists = { + (fav["service"], fav["artist_id"]): fav + for fav in cached_query( + query, + key, + (account_id,), + reload=reload, + lock_enabled=True, + ) + } # keep this in lookup so we can grab fields when next time + + # mget artists to prevent n+1, todo better mget with query integrated like posts + keys = [f"artist:{service}:{artist_id}" for service, artist_id in user_favorite_artists.keys()] + redis = get_conn() + if keys: + cache_result = ( + deserialize_artist(artist) for artist in (artist_str for artist_str in redis.mget(keys) if artist_str) + ) + else: + cache_result = [] + in_cache = {(artist["service"], artist["id"]): artist for artist in cache_result if artist} + + artists = [] + for favorite_artist in user_favorite_artists.values(): + artist = in_cache.get((favorite_artist["service"], favorite_artist["artist_id"])) + if not artist: + artist = get_artist(favorite_artist["service"], favorite_artist["artist_id"]) + if artist: + artist["faved_seq"] = favorite_artist["id"] + artist["last_imported"] = artist["updated"] + artist["updated"] = favorite_artist["updated"] + artists.append(artist) + return artists + + +def get_favorite_posts(account_id, reload=False): + key = f"favorite_posts:{account_id}" + query = "select id, service, artist_id, post_id from account_post_favorite where account_id = %s" + favorites = cached_query(query, key, (account_id,), reload=reload) + + posts_to_loads = {(f["service"], f["artist_id"], f["post_id"]): f for f in favorites} + return_value = get_post_multiple(posts_to_loads) + + log_flag = False + for post in return_value: + if not post: + log_flag = True + continue + post["faved_seq"] = posts_to_loads[(post["service"], post["user"], post["id"])]["id"] + if log_flag: + logging.exception("Fav post not found for account faves", extra={"account_id": account_id}) + return return_value + + +def add_favorite_artist(account_id, service, artist_id): + query_rowcount_db( + "insert into account_artist_favorite (account_id, service, artist_id) values (%s, %s, %s) ON CONFLICT (account_id, service, artist_id) DO NOTHING", + (account_id, service, artist_id), + ) + # g.connection.commit() we needed this before because we were in a transaction + get_conn().delete(f"favorite_artists:{account_id}") + # get_favorite_artists(account_id, True) + + +def add_favorite_post(account_id, service, artist_id, post_id): + query = """ + INSERT INTO account_post_favorite (account_id, service, artist_id, post_id, created_at) + SELECT %s, %s, %s, %s, current_timestamp + WHERE EXISTS ( + SELECT 1 + FROM public.posts + WHERE service = %s AND id = %s + ) + ON CONFLICT (account_id, service, artist_id, post_id) DO NOTHING; + """ + query_rowcount_db( + query, + (account_id, service, artist_id, post_id, service, post_id), + ) + # g.connection.commit() we needed this before because we were in a transaction + get_conn().delete(f"favorite_posts:{account_id}") + # get_favorite_posts(account_id, True) + + +def remove_favorite_artist(account_id, service, artist_id): + query_rowcount_db( + "delete from account_artist_favorite where account_id = %s and service = %s and artist_id = %s", + (account_id, service, artist_id), + ) + # g.connection.commit() we needed this before because we were in a transaction + get_conn().delete(f"favorite_artists:{account_id}") + # get_favorite_artists(account_id, True) + + +def remove_favorite_post(account_id, service, artist_id, post_id): + query_rowcount_db( + "delete from account_post_favorite where account_id = %s and service = %s and artist_id = %s and post_id = %s", + (account_id, service, artist_id, post_id), + ) + # g.connection.commit() we needed this before because we were in a transaction + get_conn().delete(f"favorite_posts:{account_id}") + # get_favorite_posts(account_id, True) diff --git a/src/lib/filehaus.py b/src/lib/filehaus.py new file mode 100644 index 0000000..22b474f --- /dev/null +++ b/src/lib/filehaus.py @@ -0,0 +1,64 @@ +from src.internals.database.database import cached_count, cached_query + + +def get_share(share_id: int, reload=False): + key = f"share:{share_id}" + query = """ + SELECT * + FROM shares + WHERE id = %s + """ + return cached_query(query, key, (share_id,), reload=reload, single=True) + + +def get_shares(offset: int, limit: int = 50, reload=False): + key = f"all_shares:{limit}:{offset}:" + query = """ + SELECT * + FROM shares + ORDER BY id DESC + OFFSET %s + LIMIT %s + """ + return cached_query(query, key, (offset, limit), reload=reload, lock_enabled=True) + + +def get_all_shares_count(reload: bool = False) -> int: + return cached_count("SELECT COUNT(*) FROM shares", "all_shares_count", lock_enabled=True) + + +def get_artist_shares(artist_id, service, reload=False): + key = f"artist_shares:{service}:{artist_id}" + query = """ + SELECT * + FROM shares s + INNER JOIN lookup_share_relationships lsr on s.id = lsr.share_id + WHERE lsr.user_id = %s AND lsr.service = %s + ORDER BY s.id DESC + """ + # todo CONSTRAINT lookup_share_relationships_pkey PRIMARY KEY (share_id, service, user_id) should be user_id, service, share_id or we have other index + return cached_query(query, key, (artist_id, service), reload=reload) + + +def get_artist_share_count(service: str, artist_id: str, reload=False): + return 0 # disabled for now while feature is off + key = f"artist_share_count:{service}:{artist_id}" + query = """ + SELECT COUNT(*) + FROM lookup_share_relationships lsr + WHERE lsr.user_id = %s AND lsr.service = %s + """ + return cached_count(query, key, (service, artist_id), reload) + + +def get_files_for_share(share_id: int, reload=False): + key = f"share_files:{share_id}" + query = """ + SELECT * + FROM file_share_relationships fsr + LEFT JOIN files f + ON fsr.file_id = f.id + WHERE share_id = %s + ORDER frs.file_id DESC + """ + return cached_query(query, key, (share_id,), reload=reload) diff --git a/src/lib/files.py b/src/lib/files.py new file mode 100644 index 0000000..dc31db7 --- /dev/null +++ b/src/lib/files.py @@ -0,0 +1,205 @@ +import base64 +import logging +import re +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +import requests + +from src.config import Configuration +from src.internals.cache.redis import get_conn +from src.internals.database.database import query_rowcount_db, query_one_db +from src.internals.database.database import cached_query +from src.utils.utils import clear_web_cache_for_archive + +ARCHIVE_MIMES: list[str] = [ + "application/zip", + "application/7z", + "application/rar", + "application/x-7z-compressed", + "application/x-rar-compressed", + "application/x-zip-compressed", + "application/x-rar", +] + + +@dataclass +class File: + id: int + hash: str + mtime: datetime + ctime: datetime + mime: str + ext: str + added: datetime + size: int + ihash: Optional[str] + + +def get_file_relationships(file_hash: str, reload: bool = False): + key = f"files:by_hash:{file_hash}" + query = """ + SELECT + files.*, + ( + SELECT JSON_AGG(posts.*) + FROM ( + SELECT + file_id, + posts.id, + posts."user", + posts.service, + posts.title, + SUBSTRING(posts."content", 1, 50), + posts.published, + posts.file, + posts.attachments + FROM file_post_relationships post_files + LEFT JOIN posts ON post_files.post = posts.id AND post_files.service = posts.service + WHERE files.id = post_files.file_id AND posts.id is not NULL + LIMIT 1000 + ) AS posts + ) AS posts, + ( + SELECT JSON_AGG(discord_posts.*) + FROM ( + SELECT + file_id, + discord_posts.id, + discord_posts."server", + discord_posts.channel, + SUBSTRING(discord_posts."content", 1, 50), + discord_posts.published, + discord_posts.embeds, + discord_posts.mentions, + discord_posts.attachments + FROM file_discord_message_relationships discord_files + LEFT JOIN discord_posts ON discord_files.id = discord_posts.id + WHERE files.id = discord_files.file_id + LIMIT 1000 + ) AS discord_posts + ) AS discord_posts + FROM files + WHERE files.hash = %s + GROUP BY files.id; + """ + return cached_query(query, key, (file_hash,), reload=reload, single=True) + + +def get_file(file_hash: str) -> Optional[File]: + key = f"file:{file_hash}" + query = "SELECT * FROM files WHERE hash = %s" + if file := cached_query(query, key, (file_hash,), single=True): + return File(**file) + + +def get_archive(file_hash: str) -> Optional[tuple[File, str]]: + if not sha256_pattern.match(file_hash): + return None + if file := get_file(file_hash): + return (file, file.ext) if file.mime in ARCHIVE_MIMES else None + + +@dataclass +class ArchiveInfo: + file: File + file_list: list[str] + password: Optional[str] + + +archive_server_session = requests.Session() + + +def get_archive_files(file_hash: str) -> Optional[ArchiveInfo]: + if not Configuration().archive_server["enabled"]: + return None + arc_data = get_archive(file_hash) + if not arc_data: + return None + file, ext = arc_data + key = f"archive_files:{file.hash}" + query = "SELECT * FROM archive_files LEFT JOIN files ON archive_files.file_id = files.id WHERE files.hash = %s" + result = cached_query(query, key, (file.hash,), single=True) + if result: + return ArchiveInfo( + file, + result.get("files", []), + result.get("password"), + ) + else: + try: + files_api_call = archive_server_session.get( + f"{Configuration().archive_server["api_url"]}/list/data/{file_hash[0:2]}/{file_hash[2:4]}/{file_hash}{ext}" + ) + if files_api_call.status_code == 401: + files: list[str] = [] + else: + files_api_call.raise_for_status() + files: list[str] = files_api_call.json() + needs_pass_api_call = archive_server_session.get( + f"{Configuration().archive_server["api_url"]}/needs_password/data/{file_hash[0:2]}/{file_hash[2:4]}/{file_hash}{ext}" + ) + needs_pass_api_call.raise_for_status() + assert needs_pass_api_call.text in ("true", "false") + needs_pass = needs_pass_api_call.text == "true" + except Exception as e: + logging.exception("Failed to call archive server", exc_info=True) + return None + password = "" if needs_pass else None + query_rowcount_db("INSERT INTO archive_files (file_id, files, password) VALUES (%s, %s, %s) ON CONFLICT (file_id) DO NOTHING", (file.id, files, password)) + return ArchiveInfo(file, files, password) + + +def try_set_password(file_hash: str, passwords: list[str]) -> bool: + arc_data = get_archive(file_hash) + + if not arc_data: + return False + + (_, ext) = arc_data + url = f"{Configuration().archive_server["api_url"]}/try_password/data/{file_hash[0:2]}/{file_hash[2:4]}/{file_hash}{ext}" + password_call = requests.post( + url, + json={"passwords": passwords}, + ) + password: str = password_call.text + + if password: + update_result = query_one_db( + """ + UPDATE archive_files af + SET password = %s + FROM files + WHERE files.hash = %s AND af.file_id = files.id + RETURNING af.files + """, + (password, file_hash), + ) + files: list[str] | None = update_result["files"] if update_result else None + if not files: + try: + files_api_call = archive_server_session.get( + f"{Configuration().archive_server["api_url"]}/list/data/{file_hash[0:2]}/{file_hash[2:4]}/{file_hash}{ext}", + params=None if not password else {"password": password}, + ) + files_api_call.raise_for_status() + files: list[str] = files_api_call.json() + query_one_db( + """ + UPDATE archive_files af + SET files = %s + FROM files as f + WHERE f.hash = %s AND af.file_id = f.id + """, + (files, file_hash), + ) + except Exception: + logging.exception("Failed to update empty list of archives") + get_conn().delete(f"archive_files:{file_hash}") + clear_web_cache_for_archive(file_hash) + return True + return False + + +sha256_pattern = re.compile(r'^[0-9a-fA-F]{64}$') diff --git a/src/lib/imports/__init__.py b/src/lib/imports/__init__.py new file mode 100644 index 0000000..1c256fd --- /dev/null +++ b/src/lib/imports/__init__.py @@ -0,0 +1 @@ +from .lib import validate_import_key diff --git a/src/lib/imports/lib.py b/src/lib/imports/lib.py new file mode 100644 index 0000000..674a0f9 --- /dev/null +++ b/src/lib/imports/lib.py @@ -0,0 +1,15 @@ +from .types import SERVICE_CONSTRAINTS, ValidationResult + + +def validate_import_key(key: str, service: str) -> ValidationResult: + """ + Validates the key according to the `service` rules + """ + # Trim spaces from both sides. + formatted_key = key.strip() + if service in SERVICE_CONSTRAINTS: + errors = SERVICE_CONSTRAINTS[service](formatted_key, []) + else: + errors = ["Not a valid service."] + + return ValidationResult[str](is_valid=not errors, errors=errors, modified_result=formatted_key) diff --git a/src/lib/imports/types.py b/src/lib/imports/types.py new file mode 100644 index 0000000..12c1ee4 --- /dev/null +++ b/src/lib/imports/types.py @@ -0,0 +1,194 @@ +import base64 +import json +import logging +import re +from dataclasses import dataclass +from re import RegexFlag +from re import compile as compile_regexp +from typing import Callable, Generic, List, TypeVar +from urllib.parse import unquote + +import orjson + +T = TypeVar("T") +max_length = 1024 + + +@dataclass +class ValidationResult(Generic[T]): + is_valid: bool + errors: List[str] = None + modified_result: T = None + + +def afdianKey(key: str, errors: List[str]) -> list[str]: + return errors + + +def boostyKey(key: str, errors: List[str]) -> list[str]: + try: + orjson.loads(unquote(key)) + except ValueError: + errors.append("The key is not valid JSON.") + return errors + + +def discordKey(key: str, errors: List[str]) -> list[str]: + pattern_str = r"(mfa.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}.[a-z0-9_-]{6,7}.[a-z0-9_-]{27})" + pattern = compile_regexp(pattern_str, RegexFlag.IGNORECASE) + key_length = len(key) + + if key_length > max_length: + errors.append(f'The key length of "{key_length}" is over the maximum of "{max_length}".') + + if not pattern.match(key): + errors.append(f'The key doesn\'t match the required pattern of "{pattern_str}".') + + return errors + + +def dlsiteKey(key: str, errors: List[str]) -> list[str]: + key_length = len(key) + + if key_length > max_length: + errors.append(f'The key length of "{key_length}" is over the maximum of "{max_length}".') + + return errors + + +def fanboxKey(key: str, errors: List[str]) -> list[str]: + key_length = len(key) + pattern_str = r"^\d+_\w+$" + pattern = compile_regexp(pattern=pattern_str, flags=RegexFlag.IGNORECASE) + + if key_length > max_length: + errors.append(f'The key length of "{key_length}" is over the maximum of "{max_length}".') + + if not pattern.match(key): + errors.append("The key doesn't match the required pattern.") + + return errors + + +def fanslyKey(key: str, errors: List[str]) -> list[str]: + key_length = len(key) + pattern = compile_regexp(r"[a-zA-Z0-9]{71}$") + + if key_length == 71: + if not pattern.match(key): + errors.append("The key doesn't match the required pattern.") + else: + try: + fansly_key_obj = orjson.loads(base64.b64decode(key.encode())) + except Exception: + errors.append("Invalid key format.") + return errors + + if not pattern.match(fansly_key_obj.get("token", "")): + errors.append("The key doesn't match the required pattern.") + + return errors + + +def fantiaKey(key: str, errors: List[str]) -> list[str]: + req_lengths = [32, 64] + key_length = len(key) + + if not any(list(key_length == length for length in req_lengths)): + errors.append( + f"The key length of \"{key_length}\" is not a valid Fantia key. " + f"Accepted lengths: {", ".join(str(rl) for rl in req_lengths)}." + ) + + if not key.islower(): + errors.append("The key is not in lower case.") + + return errors + + +def gumroadKey(key: str, errors: List[str]) -> list[str]: + min_length = 200 + key_length = len(key) + + if key_length < min_length: + errors.append(f'The key length of "{key_length}" is less than minimum required "{min_length}".') + + if key_length > max_length: + errors.append(f'The key length of "{key_length}" is over the maximum of "{max_length}".') + + return errors + + +def onlyfansKey(key: str, errors: List[str]) -> list[str]: + key_decoded = orjson.loads(base64.b64decode(key.encode("utf8"))) + + if not key_decoded["auth_id"].isdigit(): + errors.append("User ID should be only digits.") + + try: + key_decoded["user_agent"].encode("ascii") + except UnicodeEncodeError: + errors.append("Invalid User Agent.") + + x_bc_pattern_str = r"[0-9a-f]+$" + pattern = re.compile(x_bc_pattern_str) + if not pattern.match(key_decoded["x-bc"]): + logging.exception(f"Invalid x-bc {key_decoded["x-bc"]}") + return "Invalid characters in x-bc." + + if len(key_decoded["x-bc"]) != 40: + # TODO: check logs if valid ones have 40 chars only + logging.exception(f"x-bc for only fans has length different from 40", extra=key_decoded) + + return [] + + +def patreonKey(key: str, errors: List[str]) -> list[str]: + req_length = 43 + key_length = len(key) + + if key_length != req_length: + errors.append(f'The key length of "{key_length}" is not a valid Patreon key. Required length: "{req_length}".') + + if not re.match(r"[\w-]{43}", key): + logging.exception("Invalid Patreon key", extra=dict(key=key)) + errors.append(f"The key encoding does not match, check if you copied a valid Patreon key.") + + return errors + + +def subscribestarKey(key: str, errors: List[str]) -> list[str]: + key_length = len(key) + + if key_length > max_length: + errors.append(f'The key length of "{key_length}" is over the maximum of "{max_length}".') + + return errors + + +def candfansKey(key: str, errors: List[str]) -> list[str]: + try: + parsed = set(json.loads(base64.b64decode(unquote(key))).keys()) == {"mac", "iv", "tag", "value"} + except Exception: + parsed = False + + if not parsed: + errors.append(f'The key was not decodable.') + + return errors + + +SERVICE_CONSTRAINTS: dict[str, Callable[[str, List[str]], list[str]]] = dict( + afdian=afdianKey, + boosty=boostyKey, + discord=discordKey, + dlsite=dlsiteKey, + fanbox=fanboxKey, + fansly=fanslyKey, + fantia=fantiaKey, + gumroad=gumroadKey, + onlyfans=onlyfansKey, + patreon=patreonKey, + subscribestar=subscribestarKey, + candfans=candfansKey, +) diff --git a/src/lib/moderator.py b/src/lib/moderator.py new file mode 100644 index 0000000..d345b5b --- /dev/null +++ b/src/lib/moderator.py @@ -0,0 +1,6 @@ +def get_moderator(): + pass + + +def get_moderators(): + pass diff --git a/src/lib/notification.py b/src/lib/notification.py new file mode 100644 index 0000000..07007a0 --- /dev/null +++ b/src/lib/notification.py @@ -0,0 +1,124 @@ +from typing import List, Optional + +import orjson + +from src.internals.cache.decorator import cache +from src.internals.cache.redis import get_conn +from src.internals.database.database import get_cursor, query_rowcount_db +from src.internals.serializers import safe_dumper, safe_loader +from src.types.account import Notification + + +@cache("account_notifications") +def count_account_notifications(account_id: int) -> int: + """ + TODO: fix `psycopg.ProgrammingError: no results to fetch` error + """ + try: + args_dict = {"account_id": account_id} + + cursor = get_cursor() + query = """ + SELECT + COUNT(*) AS notifications_count + FROM notifications + WHERE account_id = %(account_id)s + """ + cursor.execute(query, args_dict) + result = cursor.fetchone() + notifications_count = result["notifications_count"] + return notifications_count + except Exception: + return 0 + + +@cache("new_notifications") +def count_new_notifications(account_id: int) -> int: + """ + TODO: fix `psycopg.ProgrammingError: no results to fetch` error + """ + try: + args_dict = dict(account_id=account_id) + + cursor = get_cursor() + query = """ + SELECT + COUNT(*) as new_notifications_count + FROM notifications + WHERE + account_id = %(account_id)s + AND is_seen = FALSE + """ + cursor.execute(query, args_dict) + # doing this to avoid `psycopg.ProgrammingError: no results to fetch` error + if not cursor.rowcount: + return 0 + result = cursor.fetchone() + new_notifications_count: int = result["new_notifications_count"] + return new_notifications_count + except Exception: + return 0 + + +def set_notifications_as_seen(notification_ids: List[int]) -> bool: + query = """ + UPDATE notifications + SET is_seen = TRUE + WHERE id = ANY (%s) + """ + updated_count = query_rowcount_db(query, (notification_ids,)) + return bool(updated_count) + + +def get_account_notifications(account_id: int, reload: bool = False) -> List[Notification]: + redis = get_conn() + key = f"notifications_for_account:{account_id}" + notifications = redis.get(key) + result = None + + if notifications is None or reload: + args_dict = dict(account_id=account_id) + + cursor = get_cursor() + query = """ + SELECT id, account_id, type, created_at, is_seen, extra_info + FROM notifications + WHERE account_id = %(account_id)s + ORDER BY + created_at DESC + """ + cursor.execute(query, args_dict) + result = cursor.fetchall() + redis.set(key, safe_dumper(result), ex=60) + else: + result = safe_loader(notifications) + # TODO: fix this mess + notifications = [Notification.init_from_dict(notification) for notification in result] + return notifications + + +def send_notifications(account_ids: List[str], notification_type: int, extra_info: Optional[dict]) -> bool: + cursor = get_cursor() + if not account_ids: + return False + + if extra_info is not None: + extra_info = orjson.dumps(extra_info) + notification_values = f"(%s, {notification_type}, '{extra_info}')" + else: + notification_values = f"(%s, {notification_type}, NULL)" + + insert_queries_values_template = ",".join([notification_values] * len(account_ids)) + insert_query = f""" + INSERT INTO notifications (account_id, type, extra_info) + VALUES {insert_queries_values_template} + ; + """ + cursor.execute(insert_query, account_ids) + + for account_id in account_ids: + redis = get_conn() + redis.delete(f"account_notifications:{account_id}") + redis.delete(f"new_notifications:{account_id}") + + return True diff --git a/src/lib/pagination.py b/src/lib/pagination.py new file mode 100644 index 0000000..5a4b0cd --- /dev/null +++ b/src/lib/pagination.py @@ -0,0 +1,35 @@ +import math + +from flask import Request, url_for + +from src.utils.utils import limit_int, parse_int + + +class Pagination: + def __init__(self, request: Request) -> None: + self.current_page: int = parse_int(request.args.get("page"), 1) + self.limit: int = limit_int(int(request.args.get("limit") or 25), 25) + self.offset: int = self.calculate_offset(self.current_page, self.limit) + self.base: dict[str, str] = request.args.to_dict() + + self.base.pop("page", None) + + self.count: int | None = None + self.current_count: int | None = None + self.total_pages: int | None = None + + def add_count(self, count: int): + self.count = count + self.current_count = self.offset + self.limit if self.offset + self.limit < self.count else self.count + self.total_pages = math.ceil(self.count / self.limit) or 1 + + def calculate_offset(self, current_page: int, limit: int): + if current_page > 1: + offset = (current_page - 1) * limit + else: + offset = 0 + + return offset + + def create_paged_url(self, request: Request, page_number: int): + return url_for(request.endpoint, page=page_number, **self.base) diff --git a/src/lib/post.py b/src/lib/post.py new file mode 100644 index 0000000..0a0e896 --- /dev/null +++ b/src/lib/post.py @@ -0,0 +1,446 @@ +import itertools +import logging +import random + +from murmurhash2 import murmurhash2 + +from src.config import Configuration +from src.internals.cache.redis import get_conn, set_multiple_expire_keys +from src.internals.database.database import cached_count, cached_query, get_cursor +from src.internals.serializers import unsafe_dumper, unsafe_loader +from src.internals.serializers.post import ( + deserialize_post, + deserialize_post_list, + deserialize_posts_incomplete_rewards, + rebuild_post_fields, + serialize_post, + serialize_post_list, + serialize_posts_incomplete_rewards, +) +from src.utils.utils import fixed_size_batches, images_pattern + + +def get_random_post_key(table_fraction_percentage: float): + key = f"random_post_keys:redis_list:{table_fraction_percentage}" + + redis = get_conn() + result = redis.srandmember(key) + if result: + return unsafe_loader(result) + + query = "SELECT id, \"user\", service FROM posts TABLESAMPLE BERNOULLI (%s) WHERE (\"user\", service) NOT IN (SELECT id, service from dnp) and file != '{}' AND attachments != '{}'" + random_post_keys = cached_query( + query, + key, + (table_fraction_percentage,), + lambda x: [unsafe_dumper(el) for el in x], + lambda x: [unsafe_loader(el) for el in x], + ex=3600, + cache_store_method="sadd", + lock_enabled=True, + ) + if len(random_post_keys) == 0: + return None + return random.choice(random_post_keys) + + +def get_post(service, artist_id, post_id, reload=False): + key = f"post:{service}:{artist_id}:{post_id}" + query = """ + WITH main_post AS ( + SELECT * + FROM posts + WHERE service = %s + AND "user" = %s + AND id = %s + AND ("user", service) NOT IN (SELECT id, service FROM dnp) + ) + SELECT + main_post.*, + ( + SELECT id as "next" + FROM posts + WHERE service = %s + AND "user" = %s + AND published < (SELECT published FROM main_post) + AND id != %s + ORDER BY published DESC, id DESC + LIMIT 1 + ) AS "next", + ( + SELECT id as "prev" + FROM posts + WHERE service = %s + AND "user" = %s + AND published > (SELECT published FROM main_post) + AND id != %s + ORDER BY published ASC, id ASC + LIMIT 1 + ) AS "prev" + FROM main_post; + """ + return cached_query( + query, + key, + ( + service, + artist_id, + post_id, + service, + artist_id, + post_id, + service, + artist_id, + post_id, + ), + serialize_post, + deserialize_post, + reload, + True, + ) + + +def get_post_multiple(input_, reload=False): + if not input_: + return [] + key = "post:{service}:{artist_id}:{post_id}" + redis = get_conn() + + keys = [ + key.format(service=service, artist_id=artist_id, post_id=post_id) for (service, artist_id, post_id) in input_ + ] + cache_results = redis.mget(keys) + + missing_in_cache = [] + for input_el, cache_result in zip(input_, cache_results): + if cache_result is None: + missing_in_cache.append(input_el) + + if not missing_in_cache: + all_posts = [deserialize_post(el) for el in cache_results] + return [el for el in all_posts if el] + + query = """ + WITH input_values (service, "user", id) AS ( + VALUES {input_values} + ) + SELECT + ( + SELECT row_to_json(x) as post + FROM ( + WITH main_post AS ( + SELECT * + FROM posts + WHERE service = iv.service + AND "user" = iv."user" + AND id = iv.id + AND ("user", service) NOT IN (SELECT id, service FROM dnp) + ) + SELECT + main_post.*, + ( + SELECT id as "next" + FROM posts + WHERE service = iv.service + AND "user" = iv."user" + AND published < (SELECT published FROM main_post) + AND id != iv.id + ORDER BY published DESC, id DESC + LIMIT 1 + ) AS "next", + ( + SELECT id as "prev" + FROM posts + WHERE service = iv.service + AND "user" = iv."user" + AND published > (SELECT published FROM main_post) + AND id != iv.id + ORDER BY published ASC, id ASC + LIMIT 1 + ) AS "prev" + FROM main_post + ) x + WHERE + x.id = iv.id + ) + FROM + input_values AS iv + """ + + query_result = [] + for chunk_missing_in_cache in fixed_size_batches(missing_in_cache, [10000, 1000, 200, 40, 10, 4, 1]): + cursor = get_cursor() + formatted_query = query.format(input_values=" , ".join("(%s, %s, %s)" for _ in chunk_missing_in_cache)) + cursor.execute(formatted_query, list(itertools.chain(*chunk_missing_in_cache))) + query_result.extend(cursor.fetchall()) + + mset_results = {} + keys__result = {} + + if len(query_result) != len(missing_in_cache): + raise Exception(f"len(query_result) != missing_in_cache , {len(query_result)}, {len(missing_in_cache)}") + for r, missing in zip(query_result, missing_in_cache): + if not r["post"]: + mset_results[key.format(service=missing[0], artist_id=missing[1], post_id=missing[2])] = serialize_post({}) + keys__result[missing] = {} + continue + service = r["post"]["service"] + user = r["post"]["user"] + post_id = r["post"]["id"] + k = key.format(service=service, artist_id=user, post_id=post_id) + mset_results[k] = serialize_post(r["post"]) + keys__result[(service, user, post_id)] = r["post"] + + if not keys__result: + logging.exception("Failed to query any fav posts", extra=dict(keys__result=keys__result)) + + if mset_results: + redis.mset(mset_results) + set_multiple_expire_keys(mset_results) + else: + logging.exception("Failed to load any posts", extra=dict(missing_in_cache=missing_in_cache)) + + full_result = [] + for input_el, cache_result in zip(input_, cache_results): + if cache_result: + if deserialized_post := deserialize_post(cache_result): + full_result.append(deserialized_post) + else: + if query_result_in_result_dict := keys__result.get(input_el): + full_result.append(rebuild_post_fields(query_result_in_result_dict)) + + return full_result + + +def get_post_by_id(post_id, service, reload=True): + key = f"post_by_id:{service}:{post_id}" + query = 'select id, service, "user" from posts where id = %s and service = %s' + return cached_query(query, key, (post_id, service), reload=reload, single=True) + + +def get_posts_incomplete_rewards(post_id, artist_id, service, reload=False): + key = f"posts_incomplete_rewards:{service}:{post_id}" # todo add artist if other service needs + query = """ + SELECT * + FROM posts_incomplete_rewards + WHERE id = %s AND service = %s + """ + return cached_query( + query, + key, + (post_id, service), + serialize_posts_incomplete_rewards, + deserialize_posts_incomplete_rewards, + reload, + True, + ) + + +def get_post_comments(post_id, service, reload=False): + if service not in ("fanbox", "patreon"): + return [] + key = f"comments:{service}:{post_id}" + # we select only used fields to save memory and be faster + if service in ("fanbox", "patreon"): + revisions_select = """COALESCE(json_agg( + json_build_object( + 'id', comments_revisions.revision_id + , 'content', comments_revisions.content + , 'added', comments_revisions.added + ) + ORDER BY comments_revisions.published ASC + ) FILTER (WHERE comments_revisions.id IS NOT NULL), '[]') AS revisions""" + revisions_join = "LEFT JOIN comments_revisions ON comments_revisions.id = comments.id" + else: + revisions_select = "ARRAY[]::jsonb[]" + revisions_join = "" + + query = f""" + SELECT + comments.id + , comments.parent_id + , comments.commenter + , comments.commenter_name + , comments."content" + , comments.published + , {revisions_select} + FROM comments + {revisions_join} + WHERE comments.post_id = %s AND comments.service = %s + GROUP BY comments.id, comments.parent_id, comments.commenter, comments.commenter_name, comments."content", comments.published + """ + return cached_query(query, key, (post_id, service), serialize_post_list, deserialize_post_list, reload) + + +def get_all_posts_by_artist(artist_id, service, reload=False): + key = f"posts_by_artist:{service}:{artist_id}" + query = """ + SELECT * + FROM posts + WHERE + "user" = %s + AND service = %s + AND ("user", service) NOT IN (SELECT id, service from dnp); + """ + return cached_query( + query, key, (artist_id, service), serialize_post_list, deserialize_post_list, reload, lock_enabled=True + ) + + +def get_artist_posts_summary(artist_id, service, offset, limit, sort="id", reload=False): + """we need this to render html only so we reduce data size to half redis usage""" + key = f"artist_posts_offset:{service}:{artist_id}:{offset}:{sort}:summary" + assert sort in ("id", "published DESC NULLS LAST") # extra careful building queries with strings + query = f""" + SELECT id, "user", service, title, substring("content", 1, 50), published, file, attachments + FROM posts + WHERE "user" = %s AND service = %s + ORDER BY {sort} + OFFSET %s + LIMIT %s + """ + return cached_query( + query, + key, + (artist_id, service, offset, limit), + serialize_post_list, + deserialize_post_list, + reload, + lock_enabled=True, + ) + + +def get_artist_posts_full(artist_id, service, offset, limit, sort="id", reload=False): + key = f"artist_posts_offset:{service}:{artist_id}:{offset}:{sort}:full" + assert sort in ("id", "published DESC NULLS LAST") # extra careful building queries with strings + query = f""" + SELECT* + FROM posts + WHERE "user" = %s AND service = %s + ORDER BY {sort} + OFFSET %s + LIMIT %s + """ + return cached_query( + query, + key, + (artist_id, service, offset, limit), + serialize_post_list, + deserialize_post_list, + reload, + lock_enabled=True, + ) + + +def get_artist_post_count(service, artist_id, reload=False): + key = f"artist_post_count:{service}:{artist_id}" + query = 'SELECT count(*) as count FROM posts WHERE "user" = %s AND service = %s' + return cached_count(query, key, (artist_id, service), reload, lock_enabled=True) + + +def is_post_flagged(service, artist_id, post_id, reload=False): + key = f"is_post_flagged:{service}:{artist_id}:{post_id}" + query = 'SELECT COUNT(*) FROM booru_flags WHERE id = %s AND "user" = %s AND service = %s' + return cached_count(query, key, (post_id, artist_id, service), reload) + + +def get_post_revisions(service, artist_id, post_id, reload=False): + key = f"post_revisions:{service}:{artist_id}:{post_id}" + query = 'SELECT * FROM revisions WHERE service = %s AND "user" = %s AND id = %s order by revision_id desc' + return cached_query( + query, + key, + (service, artist_id, post_id), + serialize_post_list, + deserialize_post_list, + reload, + ) + + +def get_fileserver_for_value(value: str) -> str: + if Configuration().webserver["ui"]["fileservers"]: + path_hash = murmurhash2(value.encode(), 0) + max_value = 2**32 - 1 + path_percentage = (path_hash & max_value) * 100 // max_value + cumulative_percentage = 0 + entries: list[str | tuple[str, float]] = Configuration().webserver["ui"]["fileservers"] + total_servers = len(entries) + for i, entry in enumerate(entries): + if isinstance(entry, str): + if i >= total_servers - 1: + return entry + processed_entry = (entry, (100 / total_servers) // 0.01 / 100) + else: + processed_entry: tuple[str, float] = entry + name, percentage = processed_entry + if percentage == "": + return name + cumulative_percentage += percentage + if path_percentage < cumulative_percentage: + return name + return "" + + +def get_render_data_for_posts(posts): + result_previews = [] + result_attachments = [] + result_is_image = [] + + for post in posts: + previews = [] + attachments = [] + if "path" in post["file"]: + if images_pattern.search(post["file"]["path"]): + result_is_image.append(True) + previews.append( + { + "type": "thumbnail", + "server": get_fileserver_for_value(f"/data{post["file"]["path"]}"), + "name": post["file"].get("name"), + "path": post["file"]["path"], + } + ) + else: + result_is_image.append(False) + attachments.append( + { + "server": get_fileserver_for_value(f"/data{post["file"]["path"]}"), + "name": post["file"].get("name"), + "path": post["file"]["path"], + } + ) + else: + result_is_image.append(False) + + if len(post.get("embed") or []): + previews.append( + { + "type": "embed", + "url": post["embed"]["url"], + "subject": post["embed"]["subject"], + "description": post["embed"]["description"], + } + ) + for attachment in post["attachments"]: + if images_pattern.search(attachment["path"]): + previews.append( + { + "type": "thumbnail", + "name": attachment.get("name"), + "path": attachment["path"], + "server": get_fileserver_for_value(f"/data{attachment["path"]}"), + } + ) + else: + attachments.append( + { + "path": attachment["path"], + "name": attachment.get("name"), + "server": get_fileserver_for_value(f"/data{attachment["path"]}"), + } + ) + + result_previews.append(previews) + result_attachments.append(attachments) + + return result_previews, result_attachments, result_is_image diff --git a/src/lib/posts.py b/src/lib/posts.py new file mode 100644 index 0000000..9358ed5 --- /dev/null +++ b/src/lib/posts.py @@ -0,0 +1,265 @@ +import base64 +import itertools +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Optional, TypedDict + +from src.config import Configuration +from src.internals.cache.redis import get_conn +from src.internals.database.database import cached_count, cached_query +from src.internals.serializers.generic_with_dates import deserialize_dict_list, serialize_dict_list +from src.internals.serializers.post import deserialize_post_list, serialize_post_list +from src.utils.datetime_ import PeriodScale +from src.utils.utils import batched + + +class Post(TypedDict): + id: str + user: str + service: str + title: str + content: str + embed: dict + shared_file: bool + added: datetime + published: datetime + edited: datetime + file: dict + attachments: list[dict] + + +class PostWithFavCount(Post): + fav_count: int + + +def count_all_posts(reload=False) -> int: + key = "global_post_count" + query = 'SELECT COUNT(*) FROM posts WHERE ("user", service) NOT IN (SELECT id, service from dnp)' + return cached_count(query, key, reload=reload, ex=6000, lock_enabled=True) + + +def count_all_posts_for_query(q: str, reload=False) -> int: + q = " OR ".join(x.lower() for x in q.strip().split(" OR ")) + if q == "": + return count_all_posts(reload=reload) + key = f"global_post_count_for_query:{base64.b64encode(q.encode()).decode()}" + query = """ + SET random_page_cost = 0.0001; + SET LOCAL statement_timeout = 10000; + SELECT COUNT(*) + FROM posts + WHERE (title || ' ' || content) &@~ %s + AND ("user", service) NOT IN ( + SELECT id, service + FROM dnp + ); + """ + return cached_count(query, key, (q,), reload, prepare=False, client_bind=True, sets_to_fetch=[2], lock_enabled=True) + + +def count_all_posts_for_tag(tags: list[str], service: Optional[str] = None, artist_id: Optional[str] = None) -> int: + b = base64.b64encode(f"==TAG==\0{tags}".encode()).decode() + key = f"global_post_count_for_query:{b}" + query = """ + SELECT COUNT(*) + FROM POSTS + WHERE "tags" @> %s::citext[] + """ + params = (tags,) + + if service and artist_id: + query += """ + AND "service" = %s AND "user" = %s + """ + params += (service, artist_id) + + return cached_count(query, key, params) + + +def get_all_posts_summary(offset: int, limit=50, reload=False, cache_ttl=None): + # we need this version to reduce redis size and bandwidth in half + key = f"all_posts:summary:{limit}:{offset}" + query = """ + SELECT id, "user", service, title, substring("content", 1, 50), published, file, attachments + FROM posts + WHERE ("user", service) NOT IN (SELECT id, service from dnp) + ORDER BY added DESC + OFFSET %s + LIMIT %s + """ + extra = {} + if cache_ttl: + extra["ex"] = cache_ttl + return cached_query( + query, key, (offset, limit), serialize_dict_list, deserialize_dict_list, reload, lock_enabled=True, **extra + ) + + +def get_all_posts_full(offset: int, limit=50, reload=False): + key = f"all_posts:full:{limit}:{offset}" + query = """ + SELECT * + FROM posts + WHERE ("user", service) NOT IN (SELECT id, service from dnp) + ORDER BY added DESC + OFFSET %s + LIMIT %s + """ + return cached_query( + query, key, (offset, limit), serialize_dict_list, deserialize_dict_list, reload, lock_enabled=True + ) + + +def get_all_posts_for_query(q: str, offset: int, limit=50, reload=False): + q = " OR ".join(x.lower() for x in q.strip().split(" OR ")) + if q == "": + return get_all_posts_summary(0, limit, reload, cache_ttl=Configuration().cache_ttl_for_recent_posts) + key = f"all_posts_for_query:{base64.b64encode(q.encode()).decode()}:{limit}:{offset}" + query = """ + SET random_page_cost = 0.0001; + SET LOCAL statement_timeout = 10000; + SELECT id, "user", service, title, substring("content", 1, 50), published, file, attachments + FROM posts + WHERE (title || ' ' || content) &@~ %s + AND ("user", service) NOT IN ( + SELECT id, service + FROM dnp + ) + ORDER BY added DESC + LIMIT %s + OFFSET %s; + """ + return cached_query( + query, + key, + (q, limit, offset), + serialize_dict_list, + deserialize_dict_list, + reload, + prepare=False, + client_bind=True, + sets_to_fetch=[2], + lock_enabled=True, + ) + + +def get_all_channels_for_server(discord_server, reload=False): + key = f"discord_channels_for_server:{discord_server}" + query = "SELECT channel_id as id, name FROM discord_channels WHERE server_id = %s" + return cached_query(query, key, (discord_server,), reload=reload, ex_on_null=60, lock_enabled=True) + + +def get_popular_posts_for_date_range( + start_date: datetime, + end_date: datetime, + scale: PeriodScale, + page: int, + per_page: int, + pages_to_query: int, + expiry: int = Configuration().redis["default_ttl"], + reload: bool = False, +) -> list[PostWithFavCount]: + key = f"popular_posts:{scale}:{per_page}:{start_date.isoformat()}-{end_date.isoformat()}" + + redis = get_conn() + result = redis.lindex(key, page) + if result: + parsed_result = deserialize_post_list(result) + if parsed_result: + return parsed_result + else: + return [] + else: + if page != 0: + result = redis.lindex(key, 0) + if result: + return [] + + params = (start_date, end_date, pages_to_query * per_page) + order_factor = "COUNT(*)" + if scale == "recent": + order_factor = 'SUM((EXTRACT(EPOCH FROM ("created_at" - %s )) / EXTRACT(EPOCH FROM ( %s - %s )) ))::float' + params = (start_date, end_date, start_date, *params) + + query = f""" + WITH "top_faves" AS ( + SELECT "service", "post_id", { + order_factor + } as fav_count + FROM "account_post_favorite" + WHERE "created_at" BETWEEN %s AND %s + GROUP BY "service", "post_id" + ORDER BY fav_count DESC + LIMIT %s + ) + SELECT p.id, p."user", p.service, p.title, substring( p."content", 1, 50), p.published, p.file, p.attachments, tf."fav_count" + FROM "top_faves" tf + INNER JOIN "posts" p ON p."id" = tf."post_id" and p."service" = tf."service"; + """ + + result = cached_query( + query, + key, + params, + serialize_fn=lambda x: [serialize_post_list(cache_page) for cache_page in batched(x, per_page)], + deserialize_fn=lambda x: list(itertools.chain(*(deserialize_post_list(cache_page) for cache_page in x))), + ex=expiry, + reload=reload, + cache_store_method="rpush", + lock_enabled=True, + ) + return (result or [])[(page * per_page) : ((page + 1) * per_page)] + + +def get_tagged_posts( + tags: list[str], offset: int, limit: int, service: Optional[str] = None, artist_id: Optional[str] = None +) -> list[Post]: + key = f"tagged_posts:{tags}:{service}:{artist_id}:{offset}" + query = """ + SELECT * + FROM "posts" + WHERE "tags" @> %s::citext[] + """ + params: tuple[...] = (tags,) + if service and artist_id: + query += """ + AND "service" = %s AND "user" = %s ORDER BY published DESC + """ + params += (service, artist_id) + else: + query += " ORDER BY added DESC " + + query += "OFFSET %s LIMIT %s" + params += (str(offset), str(limit)) + + return cached_query(query, key, params) + + +@dataclass +class Tag: + tag: str + post_count: int + + +def get_all_tags(service: Optional[str] = None, creator_id: Optional[str] = None) -> list[Tag]: + if creator_id and not service: + raise Exception("Must be used with both creator_id and service") + key = f"tags:{service or ""}:{creator_id or ""}" + query = f""" + SELECT {"tag" if creator_id else "lower(tag)"} as tag, COUNT(1) AS post_count + FROM "posts" + CROSS JOIN UNNEST(tags) AS tag + """ + params: tuple[str, ...] = () + + if service and creator_id: + query += """WHERE "service" = %s AND "user" = %s """ + params += (service, creator_id) + + query += """ + GROUP BY tag + ORDER BY post_count DESC + LIMIT 2000 + """ + ex = int(timedelta(hours=(6 if creator_id else 24)).total_seconds()) + return cached_query(query, key, params, ex=ex) diff --git a/src/lib/security.py b/src/lib/security.py new file mode 100644 index 0000000..16eb387 --- /dev/null +++ b/src/lib/security.py @@ -0,0 +1,47 @@ +import hashlib +from datetime import timedelta +from rb import RoutingClient + +import requests +from flask import current_app + +from src.internals.cache.redis import get_conn + + +def is_password_compromised(password: str) -> bool: + h = hashlib.sha1(password.encode("utf-8")).hexdigest().upper() + first_five = h[0:5] + rest = h[5:] + + try: + resp = requests.get("https://api.pwnedpasswords.com/range/" + first_five) + if rest in resp.text: + return True + except Exception as e: + current_app.logger.error("Error calling pwnedpasswords API: " + str(e)) + return False + + return False + + +def is_rate_limited(r: RoutingClient, key: str, limit: int, period: timedelta): + if r.setnx(key, limit): + r.expire(key, int(period.total_seconds())) + bucket_val = r.get(key) + if bucket_val and int(bucket_val) > 0: + r.decrby(key, 1) + return False + return True + + +def is_login_rate_limited(account_id: str) -> bool: + return is_rate_limited(get_conn(), f"ratelimit:login:{account_id}", 10, timedelta(seconds=300)) + + +def is_upload_rate_limited(ip_address): + return is_rate_limited( + get_conn(), + f"ratelimit:uploads:{ip_address}", + 10, + timedelta(seconds=60 * 60 * 24), + ) diff --git a/src/pages/account/__init__.py b/src/pages/account/__init__.py new file mode 100644 index 0000000..681decc --- /dev/null +++ b/src/pages/account/__init__.py @@ -0,0 +1 @@ +from .blueprint import account_bp diff --git a/src/pages/account/administrator/__init__.py b/src/pages/account/administrator/__init__.py new file mode 100644 index 0000000..1ab7734 --- /dev/null +++ b/src/pages/account/administrator/__init__.py @@ -0,0 +1 @@ +from .blueprint import administrator diff --git a/src/pages/account/administrator/blueprint.py b/src/pages/account/administrator/blueprint.py new file mode 100644 index 0000000..cb6aa3f --- /dev/null +++ b/src/pages/account/administrator/blueprint.py @@ -0,0 +1,157 @@ +from flask import Blueprint, abort, g, make_response, render_template, request + +from src.lib.administrator import change_account_role, get_accounts +from src.lib.pagination import Pagination +from src.types.account import Account, AccountRoleChange, visible_roles + +from .types import Accounts, Dashboard, Role_Change + +# from datetime import datetime + +administrator = Blueprint( + "admin", + __name__, +) + + +@administrator.before_request +def check_credentials(): + account: Account = g.get("account") + if account.role != "administrator": + return abort(404) + + +@administrator.get("/administrator") +def get_admin(): + props = Dashboard() + + response = make_response( + render_template( + "account/administrator/dashboard.html", + props=props, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@administrator.get("/administrator/accounts") +def get_accounts_list(): + queries = request.args.to_dict() + queries["name"] = queries["name"] if queries.get("name") else None + + # transform `role` query into a list for db query + if queries.get("role") and queries["role"] != "all": + queries["role"] = [queries["role"]] + else: + queries["role"] = visible_roles + + pagination = Pagination(request) + accounts = get_accounts(pagination, queries) + props = Accounts( + accounts=accounts, + role_list=visible_roles, + pagination=pagination, + ) + + response = make_response( + render_template( + "account/administrator/accounts.html", + props=props, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@administrator.post("/administrator/accounts") +def change_account_roles(): + form_dict = request.form.to_dict(flat=False) + # convert ids to `int` + candidates = dict( + moderator=[int(id) for id in form_dict.get("moderator")] if form_dict.get("moderator") else [], + consumer=[int(id) for id in form_dict.get("consumer")] if form_dict.get("consumer") else [], + ) + + if candidates["moderator"]: + change_account_role( + candidates["moderator"], + AccountRoleChange( + old_role="consumer", + new_role="moderator", + ), + ) + if candidates["consumer"]: + change_account_role( + candidates["consumer"], + AccountRoleChange( + old_role="moderator", + new_role="consumer", + ), + ) + + props = Role_Change() + + response = make_response(render_template("success.html", props=props), 200) + response.headers["Cache-Control"] = "max-age=0, private, must-revalidate" + + return response + + +# @admin.route('/admin/accounts/', methods= ['GET']) +# def get_account_info(account_id: str): +# """ +# Detailed account page. +# """ +# account = get_account(account_id) +# props = admin_props.Account( +# account= account +# ) + +# response = make_response(render_template( +# 'admin/account_info.html', +# props = props, +# ), 200) +# response.headers['Cache-Control'] = 's-maxage=60' +# return response + +# @admin.route('/admin/accounts/', methods= ['POST']) +# def change_account(): +# pass + +# @admin.route('/admin/accounts//files') +# def get_account_files(account_id: str): +# """ +# The lists of approved/rejected/queued files for the given account. +# """ +# files = [] +# account = {} + +# props = admin_props.Account_Files( +# account= account, +# files= files +# ) +# response = make_response(render_template( +# 'admin/account_files.html', +# props = props, +# ), 200) +# response.headers['Cache-Control'] = 's-maxage=60' +# return response + +# @admin.route('/admin/mods/actions', methods= ['GET']) +# def get_moderators_audits(): +# """ +# The list of moderator actions. +# """ +# actions = [] +# props = admin_props.ModeratorActions( +# actions= actions +# ) +# response = make_response(render_template( +# 'admin/mods_actions.html', +# props = props, +# ), 200) +# response.headers['Cache-Control'] = 's-maxage=60' +# return response diff --git a/src/pages/account/administrator/types.py b/src/pages/account/administrator/types.py new file mode 100644 index 0000000..214d68f --- /dev/null +++ b/src/pages/account/administrator/types.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from typing import List + +from src.internals.internal_types import PageProps +from src.lib.pagination import Pagination +from src.types.account import Account + + +@dataclass +class Dashboard(PageProps): + currentPage: str = "admin" + + +@dataclass +class Accounts(PageProps): + accounts: List[Account] + role_list: List[str] + pagination: Pagination + currentPage: str = "admin" + + +@dataclass +class Role_Change(PageProps): + redirect: str = "/account/administrator/accounts" + currentPage: str = "admin" + + +# @dataclass +# class Account_Props(PageProps): +# account: Account +# currentPage: str = 'admin' + +# @dataclass +# class Account_Files: +# account: Account +# files: List[Dict] +# currentPage: str = 'admin' + +# @dataclass +# class ModeratorsActions(): +# actions: List[Dict] +# currentPage: str = 'admin' diff --git a/src/pages/account/blueprint.py b/src/pages/account/blueprint.py new file mode 100644 index 0000000..19b4a15 --- /dev/null +++ b/src/pages/account/blueprint.py @@ -0,0 +1,270 @@ +import re +from json import JSONDecodeError + +import orjson +from flask import Blueprint, current_app, flash, g, make_response, redirect, render_template, request, session, url_for + +from src.config import Configuration +from src.lib.account import ( + attempt_login, + create_account, + get_saved_key_import_ids, + get_saved_keys, + revoke_saved_keys, + change_password as db_change_password, +) +from src.lib.notification import count_account_notifications, get_account_notifications, set_notifications_as_seen +from src.lib.security import is_password_compromised +from src.types.account import Account +from src.types.props import SuccessProps +from src.utils.utils import set_query_parameter +from src.utils.decorators import require_login + +from .administrator import administrator +from .moderator import moderator +from .types import AccountPageProps, NotificationsProps, ServiceKeysProps + +USERNAME_REGEX = re.compile(r"^[a-z0-9_@+.\-]{3,15}$") +account_bp = Blueprint("account", __name__) + + +@account_bp.get("/account") +def get_account(): + account: Account = g.get("account") + if not account: + return redirect(url_for("account.get_login")) + + if Configuration().enable_notifications: + notifications_count = count_account_notifications(account.id) + else: + notifications_count = 0 + props = AccountPageProps(account=account, notifications_count=notifications_count) + + return make_response(render_template("account/home.html", props=props), 200) + + +@account_bp.get("/account/notifications") +def get_notifications(): + account: Account = g.get("account") + if not account: + return redirect(url_for("account.get_login")) + + if Configuration().enable_notifications: + notifications = get_account_notifications(account.id) + else: + notifications = [] + props = NotificationsProps(notifications=notifications) + + seen_notif_ids = [notification.id for notification in notifications if not notification.is_seen] + set_notifications_as_seen(seen_notif_ids) + + return make_response(render_template("account/notifications.html", props=props), 200) + + +@account_bp.get("/account/keys") +def get_account_keys(): + account: Account = g.get("account") + if not account: + return redirect(url_for("account.get_login")) + + saved_keys = get_saved_keys(account.id) + props = ServiceKeysProps(service_keys=saved_keys) + + saved_session_key_import_ids = [] + for key in saved_keys: + saved_session_key_import_ids.append(get_saved_key_import_ids(key.id)) + + response = make_response( + render_template("account/keys.html", props=props, import_ids=saved_session_key_import_ids), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@account_bp.post("/account/keys") +def revoke_service_keys(): + account: Account = g.get("account") + if not account: + return redirect(url_for("account.get_login")) + + keys_dict = request.form.to_dict(flat=False) + keys_for_revocation = [int(key) for key in keys_dict["revoke"]] if keys_dict.get("revoke") else [] + + revoke_saved_keys(keys_for_revocation, account.id) + + props = SuccessProps(currentPage="account", redirect="/account/keys") + + response = make_response(render_template("success.html", props=props), 200) + return response + + +@account_bp.get("/account/login") +def get_login(): + props = {"currentPage": "login"} + location = request.form.get("location", request.args.get("location", url_for("artists.list"))) + + if account := g.get("account"): + return redirect(set_query_parameter(location, {"logged_in": "yes", "role": account.role})) + + response = make_response( + render_template( + "account/login.html", + location=location, + props=props, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@account_bp.post("/account/login") +def post_login(): + location = request.form.get("location", request.args.get("location", url_for("artists.list"))) + + if account := g.get("account"): + return redirect(set_query_parameter(location, {"logged_in": "yes", "role": account.role})) + + username = request.form.get("username", "").replace("\x00", "") + password = request.form.get("password", "") + + if account := attempt_login(username, password): + return redirect(set_query_parameter(location, {"logged_in": "yes", "role": account.role})) + + return redirect(url_for("account.get_login", location=location)) + + + +@account_bp.route("/account/logout") +def logout(): + if "account_id" in session: + session.pop("account_id") + return redirect(url_for("artists.list")) + + +@account_bp.get("/account/register") +def get_register(): + props = { + "currentPage": "login", + "username_regex": USERNAME_REGEX.pattern, + } + location = request.form.get("location", request.args.get("location", url_for("artists.list"))) + + if g.get("account"): + return redirect(location) + + return make_response(render_template("account/register.html", props=props, location=location), 200) + + +@account_bp.post("/account/register") +def post_register(): + location = request.form.get("location", request.args.get("location", url_for("artists.list"))) + + username = request.form.get("username", "").replace("\x00", "").strip() + password = request.form.get("password", "").strip() + favorites_json = request.form.get("favorites", "[]") + confirm_password = request.form.get("confirm_password", "").strip() + + favorites = [] + if favorites_json != "": + try: + favorites = orjson.loads(favorites_json) + except JSONDecodeError: + pass + + errors = False + if username == "": + flash("Username cannot be empty") + errors = True + + if not USERNAME_REGEX.match(username): + flash("Invalid username") + errors = True + + if password == "": + flash("Password cannot be empty") + errors = True + + if password != confirm_password: + flash("Passwords do not match") + errors = True + + if current_app.config.get("ENABLE_PASSWORD_VALIDATOR") and is_password_compromised(password): + flash( + "We've detected that password was compromised in a data breach on another site. Please choose a different password." + ) + errors = True + + if not errors: + success = create_account(username, password, favorites) + if not success: + flash("Username already taken") + errors = True + + if not errors: + account = attempt_login(username, password) + if account is None: + current_app.logger.warning("Error logging into account immediately after creation") + flash("Account created successfully.") + return redirect(set_query_parameter(location, {"logged_in": "yes"})) + else: + flash("Account created successfully.") + return redirect(set_query_parameter(location, {"logged_in": "yes", "role": account.role})) + + return redirect(url_for("account.get_register", location=location)) + + +@account_bp.get("/account/change_password") +@require_login +def change_password(user: Account): + props = {"currentPage": "changepassword"} + + tmpl = render_template("account/change_password.html", props=props) + response = make_response(tmpl, 200) + response.headers["Cache-Control"] = "s-maxage=3600" + return response + + +@account_bp.post("/account/change_password") +@require_login +def post_change_password(user: Account): + current_password = request.form.get("current-password", "").strip() + new_password = request.form.get("new-password", "").strip() + new_password_conf = request.form.get("new-password-confirmation", "").strip() + + errors = False + + if not new_password: + flash("Password cannot be empty") + errors = True + + if new_password != new_password_conf: + flash("Passwords do not match") + errors = True + + if current_app.config.get("ENABLE_PASSWORD_VALIDATOR") and is_password_compromised(new_password): + flash( + "We've detected that password was compromised in a data breach on another site. Please choose a different password." + ) + errors = True + + if not errors: + if db_change_password(user.id, current_password, new_password): + flash("Password changed") + return redirect(url_for("account.get_account")) + else: + flash("Current password is invalid") + + return redirect(url_for("account.change_password")) + + +@account_bp.get("/.well-known/change-password") +def well_known_change_password(): + response = redirect(url_for("account.change_password")) + response.headers["Cache-Control"] = "s-maxage=604800" + return response + + +account_bp.register_blueprint(administrator, url_prefix="/account") +account_bp.register_blueprint(moderator, url_prefix="/account") diff --git a/src/pages/account/moderator/__init__.py b/src/pages/account/moderator/__init__.py new file mode 100644 index 0000000..1fe7f06 --- /dev/null +++ b/src/pages/account/moderator/__init__.py @@ -0,0 +1 @@ +from .blueprint import moderator diff --git a/src/pages/account/moderator/blueprint.py b/src/pages/account/moderator/blueprint.py new file mode 100644 index 0000000..f037c65 --- /dev/null +++ b/src/pages/account/moderator/blueprint.py @@ -0,0 +1,59 @@ +from flask import Blueprint, abort, make_response, render_template, g + +from src.lib.artist import get_unapproved_links_with_artists + +from .types import mod_props + +moderator = Blueprint("mod", __name__) + + +@moderator.before_request +def check_credentials(): + account = g.get("account") + if not account or (account.role != "moderator" and account.role != "administrator"): + return abort(code=404) + + +@moderator.get("/moderator") +def get_dashboard(): + props = mod_props.Dashboard() + + response = make_response( + render_template( + "account/moderator/dashboard.html", + props=props, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@moderator.route("/moderator/tasks/creator_links") +def get_creator_links(): + links = get_unapproved_links_with_artists() + props = {"currentPage": "mod"} + + response = make_response( + render_template( + "account/moderator/creator_links.html", + links=links, + props=props, + ), + 200 + ) + return response + + +# @moderator.route("/mod/tasks/files") +# def get_files(): +# files = [] +# props = mod_props.Files( +# files= files +# ) +# response = make_response(render_template( +# 'moderator_files.html', +# props = props, +# ), 200) +# response.headers['Cache-Control'] = 's-maxage=60' +# return response diff --git a/src/pages/account/moderator/types.py b/src/pages/account/moderator/types.py new file mode 100644 index 0000000..fd2c47f --- /dev/null +++ b/src/pages/account/moderator/types.py @@ -0,0 +1,21 @@ +from typing import Dict, List + + +class Dashboard: + def __init__(self) -> None: + self.current_page = "mod" + + +class Files: + def __init__(self, files: List[Dict]) -> None: + self.current_page = "mod" + self.files = files + + +class Moderator: + def __init__(self) -> None: + self.Dashboard = Dashboard + self.Files = Files + + +mod_props = Moderator() diff --git a/src/pages/account/types.py b/src/pages/account/types.py new file mode 100644 index 0000000..437464d --- /dev/null +++ b/src/pages/account/types.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from typing import List + +from src.internals.internal_types import PageProps +from src.types.account import Account, Notification, ServiceKey + + +@dataclass +class AccountPageProps(PageProps): + account: Account + notifications_count: int + currentPage: str = "account" + title: str = "Your account page" + + +@dataclass +class NotificationsProps(PageProps): + notifications: List[Notification] + currentPage: str = "account" + + +@dataclass +class ServiceKeysProps(PageProps): + service_keys: List[ServiceKey] + currentPage: str = "account" + title: str = "Your service keys" diff --git a/src/pages/api/__init__.py b/src/pages/api/__init__.py new file mode 100644 index 0000000..46e9682 --- /dev/null +++ b/src/pages/api/__init__.py @@ -0,0 +1,47 @@ +import pathlib + +import orjson +import yaml +from flask import Blueprint, make_response, render_template, send_from_directory +from yaml import CLoader as Loader + +from src.pages.api.v1 import v1api_bp + +api_bp = Blueprint("api", __name__, url_prefix="/api") + +api_bp.register_blueprint(v1api_bp) + + +@api_bp.get("/swagger_schema") +def swagger_schema(): + response = make_response( + render_template( + "swagger_schema.html", + props=dict( + json_spec=orjson.dumps( + yaml.load(open(pathlib.Path(__file__).parent / "schema.yaml", "r"), Loader=Loader) + ).decode() + ), + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@api_bp.get("/swagger_schema.yaml") +def swagger_schema_yaml(): + return send_from_directory(pathlib.Path(__file__).parent, "schema.yaml") + + +@api_bp.get("/schema") +def api_schema(): + response = make_response( + render_template( + "schema.html", + props=dict(), + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response diff --git a/src/pages/api/schema.yaml b/src/pages/api/schema.yaml new file mode 100644 index 0000000..a2692ab --- /dev/null +++ b/src/pages/api/schema.yaml @@ -0,0 +1,1378 @@ +openapi: 3.0.1 +info: + title: Kemono API + version: 1.0.0 +contact: + email: contact@kemono.party +servers: + - url: https://kemono.su/api/v1 + - url: https://coomer.su/api/v1 +tags: + - name: Posts + description: Version one + - name: Creators + - name: Comments + - name: Post Flagging + description: Flag post for re-import + - name: Discord + - name: Favorites + - name: File Search + - name: Misc +paths: + /creators.txt: + get: + tags: + - Posts + summary: List All Creators + description: List all creators with details. I blame DDG for .txt. + responses: + '200': + description: List of all creators + content: + application/json: + schema: + type: array + items: + type: object + properties: + favorited: + type: integer + description: The number of times this creator has been favorited + id: + type: string + description: The ID of the creator + indexed: + type: number + description: Timestamp when the creator was indexed, Unix time as integer + name: + type: string + description: The name of the creator + service: + type: string + description: The service for the creator + updated: + type: number + description: Timestamp when the creator was last updated, Unix time as integer + example: + - favorited: 1 + id: '21101760' + indexed: 1672534800 + name: RAIGYO + service: fanbox + updated: 1672534800 + /posts: + get: + tags: + - Posts + summary: List recent posts + description: List of recently imported posts + parameters: + - name: q + in: query + description: Search query + schema: + type: string + minLength: 3 + - name: o + in: query + description: Result offset, stepping of 50 is enforced + schema: + type: integer + responses: + '200': + description: List of recently added posts + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + user: + type: string + service: + type: string + title: + type: string + content: + type: string + embed: + type: object + shared_file: + type: boolean + added: + type: string + format: date-time + published: + type: string + format: date-time + edited: + type: string + format: date-time + file: + type: object + properties: + name: + type: string + path: + type: string + attachments: + type: array + items: + type: object + properties: + name: + type: string + path: + type: string + example: + - id: '1836570' + user: '6570768' + service: fanbox + title: 今日はFANBOXを始まりました! + content:

    みなさんこんにちは、影おじです。

    先週のように、FANBOXを始まりに決定しました!

    そしてFANBOXの更新内容について、アンケートのみなさん

    ありがとうございました!


    では更新内容の詳しいことはこちらです↓

    毎回の絵、元も差分がありませんの場合、ボナスとして差分イラストを支援者の皆様にプレゼント。

    もとも差分があれば、ボナスとしてヌード差分イラストを支援者の皆様にプレゼント。


    これから、仕事以外の時間、できる限り勤勉な更新したいと思います!

    どうぞよろしくお願いいたします!

    + embed: { } + shared_file: false + added: '2021-03-30T18:00:05.973913' + published: '2021-01-24T17:54:38' + edited: '2021-01-24T18:46:15' + file: + name: a99d9674-5490-400e-acca-4bed99590699.jpg + path: /5c/98/5c984d1f62f0990a0891d8fa359aecdff6ac1e26ac165ba7bb7f31cc99e7a674.jpg + attachments: [ ] + - id: '1836649' + user: '6570768' + service: fanbox + title: 忍ちゃん 脇コキ差分 + content: '' + embed: { } + shared_file: false + added: '2021-03-30T17:59:57.815397' + published: '2021-01-24T18:23:12' + edited: '2023-01-04T14:45:19' + file: + name: 4c5615f9-be74-4fa7-b88d-168fd37a2824.jpg + path: /d0/3c/d03c893927521536646619f5fb33426aa4b82dc12869865d6d666932755d9acd.jpg + attachments: + - name: 9cc982e4-1d94-4a1a-ac62-3dddd29f881c.png + path: /d7/4d/d74d1727f2c3fcf7a7cc2d244d677d93b4cc562a56904765e4e708523b34fb4c.png + - name: ab0e17d7-52e5-42c2-925b-5cfdb451df0c.png + path: /1b/67/1b677a8c0525e386bf2b2f013e36e29e4033feb2308798e4e5e3780da6c0e815.png + /{service}/user/{creator_id}/profile: + get: + summary: Get a creator + tags: + - Creators + parameters: + - name: service + in: path + description: The service where the creator is located + required: true + schema: + type: string + - name: creator_id + in: path + description: The ID of the creator + required: true + schema: + type: string + responses: + '200': + description: Creator details retrieved successfully + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the creator + public_id: + type: string + nullable: true + description: The public ID of the creator + service: + type: string + description: The service where the creator is located + name: + type: string + description: The creator's display name + indexed: + type: string + format: date-time + description: The time the creator was last indexed + updated: + type: string + format: date-time + description: The time the creator was last updated + '404': + description: The creator could not be found + content: + application/json: + schema: + type: object + properties: + error: + type: string + description: The error message + enum: ["Creator not found."] + /{service}/user/{creator_id}: + get: + summary: Get a list of creator posts + tags: + - Posts + parameters: + - name: service + in: path + description: The service where the post is located + required: true + schema: + type: string + - name: creator_id + in: path + description: The ID of the creator + required: true + schema: + type: string + - name: q + in: query + description: Search query + schema: + type: string + minLength: 3 + - name: o + in: query + description: Result offset, stepping of 50 is enforced + schema: + type: integer + responses: + '200': + description: Post details retrieved successfully + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + user: + type: string + service: + type: string + title: + type: string + content: + type: string + embed: + type: object + shared_file: + type: boolean + added: + type: string + format: date-time + published: + type: string + format: date-time + edited: + type: string + format: date-time + file: + type: object + properties: + name: + type: string + path: + type: string + attachments: + type: array + items: + type: object + properties: + name: + type: string + path: + type: string + example: + - id: '1836570' + user: '6570768' + service: fanbox + title: 今日はFANBOXを始まりました! + content:

    みなさんこんにちは、影おじです。

    先週のように、FANBOXを始まりに決定しました!

    そしてFANBOXの更新内容について、アンケートのみなさん

    ありがとうございました!


    では更新内容の詳しいことはこちらです↓

    毎回の絵、元も差分がありませんの場合、ボナスとして差分イラストを支援者の皆様にプレゼント。

    もとも差分があれば、ボナスとしてヌード差分イラストを支援者の皆様にプレゼント。


    これから、仕事以外の時間、できる限り勤勉な更新したいと思います!

    どうぞよろしくお願いいたします!

    + embed: { } + shared_file: false + added: '2021-03-30T18:00:05.973913' + published: '2021-01-24T17:54:38' + edited: '2021-01-24T18:46:15' + file: + name: a99d9674-5490-400e-acca-4bed99590699.jpg + path: /5c/98/5c984d1f62f0990a0891d8fa359aecdff6ac1e26ac165ba7bb7f31cc99e7a674.jpg + attachments: [ ] + - id: '1836649' + user: '6570768' + service: fanbox + title: 忍ちゃん 脇コキ差分 + content: '' + embed: { } + shared_file: false + added: '2021-03-30T17:59:57.815397' + published: '2021-01-24T18:23:12' + edited: '2023-01-04T14:45:19' + file: + name: 4c5615f9-be74-4fa7-b88d-168fd37a2824.jpg + path: /d0/3c/d03c893927521536646619f5fb33426aa4b82dc12869865d6d666932755d9acd.jpg + attachments: + - name: 9cc982e4-1d94-4a1a-ac62-3dddd29f881c.png + path: /d7/4d/d74d1727f2c3fcf7a7cc2d244d677d93b4cc562a56904765e4e708523b34fb4c.png + - name: ab0e17d7-52e5-42c2-925b-5cfdb451df0c.png + path: /1b/67/1b677a8c0525e386bf2b2f013e36e29e4033feb2308798e4e5e3780da6c0e815.png + '400': + description: Offset provided which is not a multiple of 50 + '404': + description: The creator could not be found + content: + application/json: + schema: + type: object + properties: + error: + type: string + description: The error message + enum: ["Creator not found."] + /{service}/user/{creator_id}/announcements: + get: + summary: Get creator announcements + tags: + - Posts + parameters: + - name: service + in: path + required: true + description: The service name + schema: + type: string + - name: creator_id + in: path + required: true + description: The creator's ID + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: array + items: + type: object + properties: + service: + type: string + user_id: + type: string + hash: + type: string + description: sha256 + content: + type: string + added: + type: string + format: date-time + description: isoformat UTC + example: + - service: patreon + user_id: '8693043' + hash: 820b7397c7f75efb13c4a8aa5d4aacfbb200749f3e1cec16e9f2951d158be8c2 + content: Hey guys, thank you so much for your support, that means a lot to me! + added: '2023-01-31T05:16:15.462035' + '404': + description: Artist not found + /{service}/user/{creator_id}/fancards: + get: + summary: Get fancards by creator, fanbox only + tags: + - Posts + parameters: + - name: service + in: path + required: true + description: The service name, has to be "fanbox" + schema: + type: string + - name: creator_id + in: path + required: true + description: The creator's ID + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + user_id: + type: string + file_id: + type: integer + hash: + type: string + mtime: + type: string + format: date-time + ctime: + type: string + format: date-time + mime: + type: string + ext: + type: string + added: + type: string + format: date-time + size: + type: integer + ihash: + type: string + example: + - id: 108058645 + user_id: '3316400' + file_id: 108058645 + hash: 727bf3f0d774a98c80cf6c76c3fb0e049522b88eb7f02c8d3fc59bae20439fcf + mtime: '2023-05-23T15:09:43.941195' + ctime: '2023-05-23T15:09:43.941195' + mime: image/jpeg + ext: .jpg + added: '2023-05-23T15:09:43.960578' + size: 339710 + ihash: null + - id: 103286760 + user_id: '3316400' + file_id: 103286760 + hash: 8b0d0f1be38efab9306b32c7b14b74ddd92a2513026c859a280fe737980a467d + mtime: '2023-04-26T14:16:53.205183' + ctime: '2023-04-26T14:16:53.205183' + mime: image/jpeg + ext: .jpg + added: '2023-04-26T14:16:53.289143' + size: 339764 + ihash: null + '404': + description: Artist not found + /{service}/user/{creator_id}/links: + get: + summary: Get a creator's linked accounts + tags: + - Creators + parameters: + - name: service + in: path + description: The service where the creator is located + required: true + schema: + type: string + - name: creator_id + in: path + description: The ID of the creator + required: true + schema: + type: string + responses: + '200': + description: Linked accounts retrieved successfully + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + description: The ID of the creator + public_id: + type: string + nullable: true + description: The public ID of the creator + service: + type: string + description: The service where the creator is located + name: + type: string + description: The creator's display name + indexed: + type: string + format: date-time + description: The time the creator was last indexed + updated: + type: string + format: date-time + description: The time the creator was last updated + '404': + description: The creator could not be found + content: + application/json: + schema: + type: object + properties: + error: + type: string + description: The error message + enum: ["Creator not found."] + /{service}/user/{creator_id}/post/{post_id}: + get: + summary: Get a specific post + tags: + - Posts + parameters: + - name: service + in: path + required: true + description: The service name + schema: + type: string + - name: creator_id + in: path + required: true + description: The creator's ID + schema: + type: string + - name: post_id + in: path + required: true + description: The post ID + schema: + type: string + responses: + '200': + description: Successful response + content: + application/json: + schema: + type: object + properties: + id: + type: string + user: + type: string + service: + type: string + title: + type: string + content: + type: string + embed: + type: object + shared_file: + type: boolean + added: + type: string + format: date-time + published: + type: string + format: date-time + edited: + type: string + format: date-time + file: + type: object + properties: + name: + type: string + path: + type: string + attachments: + type: array + items: + type: object + properties: + name: + type: string + path: + type: string + next: + type: string + prev: + type: string + example: + id: '1836570' + user: '6570768' + service: fanbox + title: 今日はFANBOXを始まりました! + content:

    みなさんこんにちは、影おじです。

    先週のように、FANBOXを始まりに決定しました!

    そしてFANBOXの更新内容について、アンケートのみなさん

    ありがとうございました!


    では更新内容の詳しいことはこちらです↓

    毎回の絵、元も差分がありませんの場合、ボナスとして差分イラストを支援者の皆様にプレゼント。

    もとも差分があれば、ボナスとしてヌード差分イラストを支援者の皆様にプレゼント。


    これから、仕事以外の時間、できる限り勤勉な更新したいと思います!

    どうぞよろしくお願いいたします!

    + embed: { } + shared_file: false + added: '2021-03-30T18:00:05.973913' + published: '2021-01-24T17:54:38' + edited: '2021-01-24T18:46:15' + file: + name: a99d9674-5490-400e-acca-4bed99590699.jpg + path: /5c/98/5c984d1f62f0990a0891d8fa359aecdff6ac1e26ac165ba7bb7f31cc99e7a674.jpg + attachments: [ ] + next: null + prev: '1836649' + '404': + description: Post not found + /discord/channel/{channel_id}: + get: + tags: + - Discord + summary: Get Discord channel posts by offset + parameters: + - name: channel_id + in: path + description: ID of the Discord channel + required: true + schema: + type: string + - name: o + in: query + description: Result offset, stepping of 150 is enforced + schema: + type: integer + responses: + '200': + description: Discord channel found + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + author: + type: object + properties: + id: + type: string + avatar: + type: string + username: + type: string + public_flags: + type: integer + discriminator: + type: string + server: + type: string + channel: + type: string + content: + type: string + added: + type: string + format: date-time + published: + type: string + format: date-time + edited: + type: string + format: date-time + embeds: + type: array + items: { } + mentions: + type: array + items: { } + attachments: + type: array + items: + type: object + properties: + name: + type: string + path: + type: string + example: + - id: '942909658610413578' + author: + id: '421590382300889088' + avatar: 0956f3dc18eba7da9daedc4e50fb96d0 + username: Merry + public_flags: 0 + discriminator: '7849' + server: '455285536341491714' + channel: '455287420959850496' + content: '@everyone Happy Valentine’s Day! 💜✨' + added: '2022-02-15T01:26:12.708959' + published: '2022-02-14T22:26:21.027000' + edited: null + embeds: [ ] + mentions: [ ] + attachments: [ ] + - id: '942909571947712594' + author: + id: '421590382300889088' + avatar: 0956f3dc18eba7da9daedc4e50fb96d0 + username: Merry + public_flags: 0 + discriminator: '7849' + server: '455285536341491714' + channel: '455287420959850496' + content: '' + added: '2022-02-15T01:26:13.006228' + published: '2022-02-14T22:26:00.365000' + edited: null + embeds: [ ] + mentions: [ ] + attachments: + - name: sofa_03.png + path: /3b/4e/3b4ed5aabdd85b26fbbc3ee9b0e5649df69167efe26b5abc24cc2a1159f446d4.png + '404': + description: Discord channel not found + /discord/channel/lookup/{discord_server}: + get: + tags: + - Discord + summary: Lookup Discord channels + parameters: + - name: discord_server + in: path + description: Discord Server ID + required: true + schema: + type: string + responses: + '200': + description: Discord channels found + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + example: + - id: '455285536341491716' + name: news + - id: '455287420959850496' + name: nyarla-lewds + '404': + description: Discord server not found + /account/favorites: + get: + tags: + - Favorites + security: + - cookieAuth: [ ] + summary: List Account Favorites + description: List account favorites (posts or creators) for the authenticated user (cookie session) + parameters: + - name: type + in: query + description: Type of favorites to list (post or creator (artist) ) + schema: + type: string + enum: + - post + - artist + responses: + '200': + description: List of account favorites + content: + application/json: + schema: + type: array + items: + type: object + properties: + faved_seq: + type: integer + description: The sequence number of the favorite + id: + type: string + description: The ID of the favorite (post or creator) + indexed: + type: string + description: Timestamp when the creator was indexed isoformat + last_imported: + type: string + description: Timestamp when the creator was last imported + name: + type: string + description: The name of the creator + service: + type: string + description: The service where the creator is located + updated: + type: string + description: Timestamp when the creator was last updated + '401': + $ref: '#/components/schemas/401' + /favorites/post/{service}/{creator_id}/{post_id}: + post: + tags: + - Favorites + security: + - cookieAuth: [ ] + summary: Add Favorite Post + description: Add a post to the user's favorite posts + parameters: + - name: service + in: path + description: Service of the post + required: true + schema: + type: string + - name: creator_id + in: path + description: The ID of the creator + required: true + schema: + type: string + - name: post_id + in: path + description: The ID of the post + required: true + schema: + type: string + responses: + '200': + description: Favorite post added successfully + content: { } + '302': + description: Redirect to login if not authenticated + content: { } + '401': + $ref: '#/components/schemas/401' + delete: + tags: + - Favorites + security: + - cookieAuth: [ ] + summary: Remove Favorite Post + description: Remove a post from the user's favorite posts + parameters: + - name: service + in: path + description: The service where the post is located + required: true + schema: + type: string + - name: creator_id + in: path + description: The ID of the creator + required: true + schema: + type: string + - name: post_id + in: path + description: The ID of the post + required: true + schema: + type: string + responses: + '200': + description: Unfavorite post removed successfully + content: { } + '302': + description: Redirect to login if not authenticated + content: { } + '401': + $ref: '#/components/schemas/401' + /favorites/creator/{service}/{creator_id}: + post: + tags: + - Favorites + security: + - cookieAuth: [ ] + summary: Add Favorite creator + description: Add an creator to the user's favorite creators + parameters: + - name: service + in: path + description: The service where the creator is located + required: true + schema: + type: string + - name: creator_id + in: path + description: The ID of the creator + required: true + schema: + type: string + responses: + '200': + description: Favorite creator added successfully + content: { } + '302': + description: Redirect to login if not authenticated + content: { } + '401': + $ref: '#/components/schemas/401' + delete: + tags: + - Favorites + security: + - cookieAuth: [ ] + summary: Remove Favorite Creator + description: Remove an creator from the user's favorite creators + parameters: + - name: service + in: path + description: The service where the creator is located + required: true + schema: + type: string + - name: creator_id + in: path + description: The ID of the creator + required: true + schema: + type: string + responses: + '200': + description: Favorite creator removed successfully + content: { } + '302': + description: Redirect to login if not authenticated + content: { } + '401': + $ref: '#/components/schemas/401' + /search_hash/{file_hash}: + get: + tags: + - File Search + summary: Lookup file by hash + parameters: + - name: file_hash + in: path + required: true + description: SHA-2 / SHA-256 + schema: + type: string + format: hex + minLength: 64 + maxLength: 64 + responses: + '200': + description: File found + content: + application/json: + schema: + type: object + properties: + id: + type: integer + hash: + type: string + mtime: + type: string + format: date-time + ctime: + type: string + format: date-time + mime: + type: string + ext: + type: string + added: + type: string + format: date-time + size: + type: integer + ihash: + type: string + posts: + type: array + items: + type: object + properties: + file_id: + type: integer + id: + type: string + user: + type: string + service: + type: string + title: + type: string + substring: + type: string + published: + type: string + format: date-time + file: + type: object + properties: + name: + type: string + path: + type: string + attachments: + type: array + items: + type: object + properties: + name: + type: string + path: + type: string + discord_posts: + type: array + items: + type: object + properties: + file_id: + type: integer + id: + type: string + server: + type: string + channel: + type: string + substring: + type: string + published: + type: string + format: date-time + embeds: + type: array + items: { } + mentions: + type: array + items: { } + attachments: + type: array + items: + type: object + properties: + name: + type: string + path: + type: string + example: + id: 40694581 + hash: b926020cf035af45a1351e0a7e2c983ebcc93b4c751998321a6593a98277cdeb + mtime: '2021-12-04T07:16:09.385539' + ctime: '2021-12-04T07:16:09.385539' + mime: image/png + ext: .png + added: '2021-12-04T07:16:09.443016' + size: 10869921 + ihash: null + posts: + - file_id: 108400151 + id: '5956097' + user: '21101760' + service: fanbox + title: Loli Bae + substring: |- + Thank you for your continued support! + いつも支援ありがとうご + published: '2023-05-14T00:00:00' + file: + name: 8f183dac-470d-4587-9657-23efe8890a7b.jpg + path: /e5/1f/e51fc831dfdac7a21cc650ad46af59340e35e2a051aed8c1e65633592f4dc11c.jpg + attachments: + - name: b644eb9c-cffa-400e-9bd6-40cccb2331ba.png + path: /5e/b3/5eb3197668ac23bd7c473d3c750334eb206b060c610e4ac5fa1a9370fd1314d9.png + - name: 17f295ba-a9f2-4034-aafc-bf74904ec144.png + path: /88/ad/88ad2ba77c89e4d7a9dbe1f9531ba3e3077a82aee2b61efa29fda122ebe1b516.png + discord_posts: + - file_id: 40694581 + id: '769704201495904286' + server: '455285536341491714' + channel: '769703874356445216' + substring: '' + published: '2020-10-24T23:29:42.049' + embeds: [ ] + mentions: [ ] + attachments: + - name: 3.png + path: /b9/26/b926020cf035af45a1351e0a7e2c983ebcc93b4c751998321a6593a98277cdeb.png + '404': + description: File not found + /{service}/user/{creator_id}/post/{post}/flag: + post: + tags: + - Post Flagging + summary: Flag a post + parameters: + - name: service + in: path + required: true + schema: + type: string + - name: creator_id + in: path + required: true + schema: + type: string + - name: post + in: path + required: true + schema: + type: string + responses: + '201': + description: Flagged successfully + content: { } + '409': + description: Already flagged + content: { } + get: + tags: + - Post Flagging + summary: Check if a Post is flagged + description: Check if a Post is flagged + parameters: + - name: service + in: path + description: The service where the post is located + required: true + schema: + type: string + - name: creator_id + in: path + description: The creator of the post + required: true + schema: + type: string + - name: post + in: path + description: The ID of the post to flag + required: true + schema: + type: string + responses: + '200': + description: The post is flagged + content: { } + '404': + description: The post has no flag + content: { } + /{service}/user/{creator_id}/post/{post_id}/revisions: + get: + tags: + - Posts + summary: List a Post's Revisions + description: List revisions of a specific post by service, creator_id, and post_id + parameters: + - name: service + in: path + description: The service where the post is located + required: true + schema: + type: string + - name: creator_id + in: path + description: The ID of the creator + required: true + schema: + type: string + - name: post_id + in: path + description: The ID of the post + required: true + schema: + type: string + responses: + '200': + description: List of post revisions + content: + application/json: + schema: + type: array + items: + type: object + properties: + revision_id: + type: integer + id: + type: string + user: + type: string + service: + type: string + title: + type: string + content: + type: string + embed: + type: object + shared_file: + type: boolean + added: + type: string + format: date-time + published: + type: string + format: date-time + edited: + type: string + format: date-time + file: + type: object + properties: + name: + type: string + path: + type: string + attachments: + type: array + items: + type: object + properties: + name: + type: string + path: + type: string + example: + - revision_id: 8059287 + id: '1836570' + user: '6570768' + service: fanbox + title: 今日はFANBOXを始まりました! + content:

    みなさんこんにちは、影おじです。

    先週のように、FANBOXを始まりに決定しました!

    そしてFANBOXの更新内容について、アンケートのみなさん

    ありがとうございました!


    では更新内容の詳しいことはこちらです↓

    毎回の絵、元も差分がありませんの場合、ボナスとして差分イラストを支援者の皆様にプレゼント。

    もとも差分があれば、ボナスとしてヌード差分イラストを支援者の皆様にプレゼント。


    これから、仕事以外の時間、できる限り勤勉な更新したいと思います!

    どうぞよろしくお願いいたします!

    + embed: { } + shared_file: false + added: '2023-09-19T13:19:57.416086' + published: '2021-01-24T17:54:38' + edited: '2021-01-24T18:46:15' + file: + name: 8c2be0fd-a130-4afb-9314-80f2501d94f7.jpg + path: /5c/98/5c984d1f62f0990a0891d8fa359aecdff6ac1e26ac165ba7bb7f31cc99e7a674.jpg + attachments: + - name: attachment1.jpg + path: /attachments/attachment1.jpg + - name: attachment2.jpg + path: /attachments/attachment2.jpg + - revision_id: 6770513 + id: '1836570' + user: '6570768' + service: fanbox + title: 今日はFANBOXを始まりました! + content:

    みなさんこんにちは、影おじです。

    先週のように、FANBOXを始まりに決定しました!

    そしてFANBOXの更新内容について、アンケートのみなさん

    ありがとうございました!


    では更新内容の詳しいことはこちらです↓

    毎回の絵、元も差分がありませんの場合、ボナスとして差分イラストを支援者の皆様にプレゼント。

    もとも差分があれば、ボナスとしてヌード差分イラストを支援者の皆様にプレゼント。


    これから、仕事以外の時間、できる限り勤勉な更新したいと思います!

    どうぞよろしくお願いいたします!

    + embed: { } + shared_file: false + added: '2023-07-28T23:51:25.477291' + published: '2021-01-24T17:54:38' + edited: '2021-01-24T18:46:15' + file: + name: 0d133e49-a2d4-4733-9044-dd57e25b1fce.jpg + path: /5c/98/5c984d1f62f0990a0891d8fa359aecdff6ac1e26ac165ba7bb7f31cc99e7a674.jpg + attachments: + - name: attachment3.jpg + path: /attachments/attachment3.jpg + - name: attachment4.jpg + path: /attachments/attachment4.jpg + '404': + description: Post not found + /{service}/user/{creator_id}/post/{post_id}/comments: + get: + tags: + - Comments + summary: List a post's comments + description: List comments for a specific post by service, creator_id, and post_id. + parameters: + - name: service + in: path + description: The post's service. + required: true + schema: + type: string + - name: creator_id + in: path + description: The service ID of the post's creator. + required: true + schema: + type: string + - name: post_id + in: path + description: The service ID of the post. + required: true + schema: + type: string + responses: + '200': + description: List of post comments. + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + parent_id: + type: string + nullable: true + commenter: + type: string + content: + type: string + published: + type: string + format: date-time + revisions: + type: array + items: + type: object + properties: + id: + type: integer + content: + type: string + added: + type: string + format: date-time + example: + - id: "121508687" + parent_id: null + commenter: "84534108" + content: "YOU DREW MORE YAYYYY" + published: "2023-11-05T20:17:47.635000" + revisions: + - id: 1 + content: "YOU DREW MORE YAYYYY2222222" + added: "2023-11-14T03:09:12.275975" + '404': + description: No comments found. + + /app_version: + get: + tags: + - Misc + summary: Git Commit Hash + description: Show current App commit hash + responses: + '200': + description: Commit Hash + content: + text/plain: + schema: + type: string + format: hex + minLength: 40 + maxLength: 40 + example: 3b9cd5fab1d35316436968fe85c90ff2de0cdca0 +components: + securitySchemes: + cookieAuth: + description: Session key that can be found in cookies after a successful login + type: apiKey + in: cookie + name: session + schemas: + '401': + title: Unauthorized + description: Unauthorized Access diff --git a/src/pages/api/v1/__init__.py b/src/pages/api/v1/__init__.py new file mode 100644 index 0000000..c1d60d9 --- /dev/null +++ b/src/pages/api/v1/__init__.py @@ -0,0 +1,9 @@ +import pkgutil + +from flask import Blueprint + +v1api_bp = Blueprint("v1", __name__, url_prefix="/v1") + + +for _, module_name, _ in pkgutil.iter_modules(__path__): + module = __import__(f"{__name__}.{module_name}", fromlist=[module_name]) diff --git a/src/pages/api/v1/app_version.py b/src/pages/api/v1/app_version.py new file mode 100644 index 0000000..f697887 --- /dev/null +++ b/src/pages/api/v1/app_version.py @@ -0,0 +1,12 @@ +import os + +from flask import make_response + +from src.pages.api.v1 import v1api_bp + + +@v1api_bp.get("/app_version") +def get_app_version(): + response = make_response(os.getenv("GIT_COMMIT_HASH") or "NOT_FOUND", 200) + response.headers["Cache-Control"] = "s-maxage=600" + return response diff --git a/src/pages/api/v1/comments.py b/src/pages/api/v1/comments.py new file mode 100644 index 0000000..3577308 --- /dev/null +++ b/src/pages/api/v1/comments.py @@ -0,0 +1,18 @@ +from flask import jsonify, make_response + +from src.lib.post import get_post_comments +from src.pages.api.v1 import v1api_bp + + +@v1api_bp.get("//user//post//comments") +def get_comments(service: str, creator_id: str, post_id: str): + comments = get_post_comments(post_id, service) + + if not comments: + response = make_response(jsonify({"error": "Not found"}), 404) + response.headers["Cache-Control"] = "s-maxage=600" + return response + + response = make_response(jsonify(comments), 200) + response.headers["Cache-Control"] = "s-maxage=600" + return response diff --git a/src/pages/api/v1/creators.py b/src/pages/api/v1/creators.py new file mode 100644 index 0000000..e86f19a --- /dev/null +++ b/src/pages/api/v1/creators.py @@ -0,0 +1,84 @@ +from flask import jsonify, make_response + +from src.internals.database.database import query_db +from src.lib.announcements import get_artist_announcements +from src.lib.artist import get_artist, get_fancards_by_artist, get_linked_creators +from src.pages.api.v1 import v1api_bp + + +@v1api_bp.get("/creators") +def all_creators(): + """this view must be cached at nginx/cdn level""" + query = """ + SELECT + l.id, + l.name, + l.service, + EXTRACT(epoch from l.indexed)::int AS indexed, + EXTRACT(epoch from l.updated)::int AS updated, + COALESCE(aaf.favorited, 0) AS favorited + FROM lookup l + LEFT JOIN ( + SELECT + artist_id, + service, + COUNT(*) AS favorited + FROM account_artist_favorite + GROUP BY artist_id, service + ) aaf ON + l.id = aaf.artist_id + AND l.service = aaf.service + WHERE + l.id NOT IN (SELECT id from dnp) + AND (l.id, l.service) NOT IN ( + SELECT id, service FROM lookup l WHERE NOT EXISTS (SELECT FROM posts WHERE service = l.service AND "user" = l.id) AND service != 'discord'); + """ + return make_response(jsonify(query_db(query)), 200) + + +@v1api_bp.get("//user//profile") +def get_creator(service, creator_id): + artist = get_artist(service, creator_id) + if not artist: + response = make_response(jsonify({"error": "Creator not found."}), 404) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + response = make_response(jsonify(artist), 200) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@v1api_bp.get("//user//announcements") +def get_announcements(service, creator_id): + artist = get_artist(service, creator_id) + if not artist: + response = make_response(jsonify({"error": "Artist not found."}), 404) + response.headers["Cache-Control"] = "s-maxage=600" + return response + announcements = get_artist_announcements(service, creator_id, reload=True) + response = make_response(jsonify(announcements), 200) + response.headers["Cache-Control"] = "s-maxage=600" + return response + + +@v1api_bp.get("//user//fancards") +def get_fancards(service, creator_id): + artist = get_artist(service, creator_id) + if not artist: + response = make_response(jsonify({"error": "Artist not found."}), 404) + response.headers["Cache-Control"] = "s-maxage=600" + return response + fancards = get_fancards_by_artist(creator_id, reload=True) + response = make_response(jsonify(fancards), 200) + response.headers["Cache-Control"] = "s-maxage=600" + return response + + +@v1api_bp.get("//user//links") +def get_linked_accounts(service, creator_id): + links = get_linked_creators(service, creator_id) + + response = make_response(jsonify(links), 200) + response.headers["Cache-Control"] = "s-maxage=600" + return response diff --git a/src/pages/api/v1/discord.py b/src/pages/api/v1/discord.py new file mode 100644 index 0000000..328e5d0 --- /dev/null +++ b/src/pages/api/v1/discord.py @@ -0,0 +1,30 @@ +from flask import jsonify, make_response, request + +from src.internals.database.database import cached_query +from src.internals.serializers.post import deserialize_post_list, serialize_post_list +from src.lib.posts import get_all_channels_for_server +from src.pages.api.v1 import v1api_bp +from src.utils.utils import parse_int, positive_or_none, step_int + + +@v1api_bp.get("/discord/channel/lookup/") +def discord_lookup(discord_server): + channels = get_all_channels_for_server(discord_server) + response = make_response(jsonify(channels), 200) + return response + + +@v1api_bp.get("/discord/channel/") +def discord_channel(channel_id): + limit = 150 + offset = positive_or_none(step_int(parse_int(request.args.get("o"), 0), limit)) + if offset is None: + response = make_response(jsonify({"error": "offset not multiple of 150"}), 400) + return response + query = "SELECT * FROM discord_posts WHERE channel = %s ORDER BY published desc OFFSET %s LIMIT %s" + params = (channel_id, offset, limit) + results = cached_query( + query, f"discord_posts:{channel_id}:{offset}:{limit}", params, serialize_post_list, deserialize_post_list + ) + response = make_response(jsonify(results), 200) + return response diff --git a/src/pages/api/v1/dms.py b/src/pages/api/v1/dms.py new file mode 100644 index 0000000..cf3c876 --- /dev/null +++ b/src/pages/api/v1/dms.py @@ -0,0 +1,15 @@ +from flask import jsonify, make_response, session + +from src.lib.dms import has_unapproved_dms +from src.pages.api.v1 import v1api_bp + + +@v1api_bp.get("/has_pending_dms") +def get_has_pending_dms(): + has_pending_dms = False + account_id = session.get("account_id") + if account_id: + has_pending_dms = has_unapproved_dms(account_id) + response = make_response(jsonify(has_pending_dms), 200) + response.headers["Cache-Control"] = "s-maxage=600" + return response diff --git a/src/pages/api/v1/favorites.py b/src/pages/api/v1/favorites.py new file mode 100644 index 0000000..9631860 --- /dev/null +++ b/src/pages/api/v1/favorites.py @@ -0,0 +1,55 @@ +from flask import jsonify, make_response, redirect, request, url_for + +from src.lib.favorites import ( + add_favorite_artist, + add_favorite_post, + get_favorite_artists, + get_favorite_posts, + remove_favorite_artist, + remove_favorite_post, +) +from src.pages.api.v1 import v1api_bp +from src.types.account.account import Account +from src.utils.decorators import require_login + + +@v1api_bp.get("/account/favorites") +@require_login +def list_account_favorites(user: Account): + fave_type = request.args.get("type", "artist") + if fave_type == "post": + favorites = get_favorite_posts(user.id) + else: + favorites = get_favorite_artists(user.id) + + response = make_response(jsonify(favorites), 200) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@v1api_bp.route("/favorites/post///", methods=["POST"]) +@require_login +def post_favorite_post(service, creator_id, post_id, user: Account): + add_favorite_post(user.id, service, creator_id, post_id) + return "", 204 + + +@v1api_bp.route("/favorites/creator//", methods=["POST"]) +@require_login +def post_favorite_artist(service, creator_id, user: Account): + add_favorite_artist(user.id, service, creator_id) + return "", 204 + + +@v1api_bp.route("/favorites/post///", methods=["DELETE"]) +@require_login +def delete_favorite_post(service, creator_id, post_id, user: Account): + remove_favorite_post(user.id, service, creator_id, post_id) + return "", 204 + + +@v1api_bp.route("/favorites/creator//", methods=["DELETE"]) +@require_login +def delete_favorite_artist(service, creator_id, user: Account): + remove_favorite_artist(user.id, service, creator_id) + return "", 204 diff --git a/src/pages/api/v1/files.py b/src/pages/api/v1/files.py new file mode 100644 index 0000000..620d6aa --- /dev/null +++ b/src/pages/api/v1/files.py @@ -0,0 +1,33 @@ +from flask import jsonify, make_response, request + +from src.config import Configuration +from src.lib.files import get_file_relationships, try_set_password +from src.pages.api.v1 import v1api_bp +from src.utils.utils import get_query_parameters_dict + + +@v1api_bp.get("/search_hash/") +def lookup_file(file_hash): + if not (len(file_hash) == 64 and all(c in "0123456789abcdefABCDEF" for c in file_hash)): + response = make_response(jsonify({"error": "Invalid SHA256 hash"}), 400) + return response + if not (file := get_file_relationships(file_hash)): + response = make_response("{}", 404) + response.headers["Cache-Control"] = "s-maxage=600" + return response + + response = make_response(jsonify(file), 200) + response.headers["Cache-Control"] = "s-maxage=600" + return response + + +@v1api_bp.get("/set_password") +def set_password(): + if not Configuration().archive_server["enabled"]: + return "false" + q = get_query_parameters_dict(request) + file_hash = q.get("file_hash") + passwords = [password for password in request.args.getlist("password") if password] + if not file_hash or not passwords or not try_set_password(file_hash, passwords): + return "false" + return "true" diff --git a/src/pages/api/v1/flags.py b/src/pages/api/v1/flags.py new file mode 100644 index 0000000..df045f2 --- /dev/null +++ b/src/pages/api/v1/flags.py @@ -0,0 +1,30 @@ +from src.config import Configuration +from src.internals.cache.redis import get_conn +from src.internals.database.database import query_db +from src.lib.post import is_post_flagged +from src.pages.api.v1 import v1api_bp + + +@v1api_bp.route("//user//post//flag", methods=["POST"]) +def flag_post_api(service, creator_id, post): + query = """ + INSERT INTO booru_flags (id, "user", service) + SELECT %s, %s, %s + WHERE NOT EXISTS ( + SELECT 1 + FROM booru_flags + WHERE id = %s AND "user" = %s AND service = %s + ) + RETURNING id, service + """ + rows_returned = query_db(query, (post, creator_id, service, post, creator_id, service)) + get_conn().set( + f"is_post_flagged:{service}:{creator_id}:{post}", len(rows_returned), ex=Configuration().redis["default_ttl"] + ) + + return "", (201 if len(rows_returned) else 409) + + +@v1api_bp.route("/user//post//flag", methods=["GET"]) +def flag_api(service, creator_id, post): + return "", 200 if is_post_flagged(service, creator_id, post) else 404 diff --git a/src/pages/api/v1/importer.py b/src/pages/api/v1/importer.py new file mode 100644 index 0000000..8cce42a --- /dev/null +++ b/src/pages/api/v1/importer.py @@ -0,0 +1,121 @@ +import base64 +import logging +import re + +import orjson +from flask import current_app, make_response, render_template, request, session + +from src.config import Configuration +from src.internals.cache.redis import get_conn +from src.internals.database.database import query_db, query_one_db +from src.lib.imports import validate_import_key +from src.pages.api.v1 import v1api_bp +from src.types.props import SuccessProps + + +@v1api_bp.post("/importer/submit") +def importer_submit(): + if not session.get("account_id") and request.form.get("save_dms") and request.form.get("service") == "patreon": + return "You must be logged in to import direct messages.", 401 + + if not request.form.get("session_key"): + return "Session key missing.", 401 + + key = request.form.get("session_key").strip().strip("\" \t'") + if request.form.get("service") == "onlyfans": + key = base64.b64encode( + orjson.dumps( + { + "sess": key, + "x-bc": request.form.get("x-bc").strip().strip("\" \t'"), + "auth_id": request.form.get("auth_id").strip().strip("\" \t'"), + "auth_uid_": "None", + "user_agent": request.form.get("user_agent").strip().strip("\" \t'"), + } + ) + ).decode() + result = validate_import_key(key, request.form.get("service")) + + discord_channels = None + if (input_channels := request.form.get("channel_ids")) and request.form.get("service") == "discord": + regex = r"https://discord\.com/channels/\d+/(?P\d+)" + input_channels = [ + re.match(regex, item).group("ch") if re.match(regex, item) else item for item in input_channels.split(",") + ] + discord_channels = list(s.strip() for s in re.split(r"[\s,.、。/']", ",".join(input_channels)) if s.strip()) + if any(not s.isdigit() for s in discord_channels): + msg = "Discord channel ids are numbers, the last number of the url (notice the / between the 2 numbers)" + logging.exception(msg, extra=dict(input_channels=input_channels, discord_channels=discord_channels)) + return msg, 422 + if not discord_channels: + msg = "Discord submit requires channels" + logging.exception(msg, extra=dict(input_channels=input_channels, discord_channels=discord_channels)) + return msg, 422 + discord_channels = ",".join(discord_channels) + + if not result.is_valid: + return "\n".join(result.errors), 422 + + formatted_key = result.modified_result if result.modified_result else key + service = request.form.get("service") + queue_name = f"import:{service}" + + existing_imports = query_db( + b""" + SELECT job_id + FROM jobs + WHERE finished_at IS null + AND queue_name = %s + AND job_input ->> 'key' = %s + """, + (queue_name, formatted_key) + ) + + if existing_imports: + existing_import = existing_imports[0]["job_id"] + props = SuccessProps( + message="This key is already being used for an import. Redirecting to logs...", + currentPage="import", + redirect=f"/importer/status/{existing_import}{"?dms=1" if request.form.get("save_dms") else ""}", + ) + + return make_response(render_template("success.html", props=props), 200) + + data = dict( + key=formatted_key, + service=service, + channel_ids=discord_channels, + auto_import=request.form.get("auto_import"), + save_session_key=request.form.get("save_session_key"), + save_dms=request.form.get("save_dms"), + contributor_id=session.get("account_id"), + priority=1, + country=request.headers.get(Configuration().webserver["country_header_key"]), + user_agent=request.headers.get("User-Agent"), + ) + query = b""" + INSERT INTO jobs (queue_name, priority, job_input) + VALUES (%s, %s, %s) + RETURNING job_id; + """ + import_id = query_one_db(query, (queue_name, 1, orjson.dumps(data).decode()))["job_id"] + + props = SuccessProps( + currentPage="import", + redirect=f"/importer/status/{import_id}{"?dms=1" if request.form.get("save_dms") else ""}", + ) + + return make_response(render_template("success.html", props=props), 200) + + +@v1api_bp.route("/importer/logs/") +def get_importer_logs(import_id: str): + redis = get_conn() + key = f"importer_logs:{import_id}" + llen = redis.llen(key) + messages = [] + if llen > 0: + messages = redis.lrange(key, 0, llen) + redis.expire(key, 60 * 60 * 48) + + return orjson.dumps([msg.decode() for msg in messages]), 200 diff --git a/src/pages/api/v1/posts.py b/src/pages/api/v1/posts.py new file mode 100644 index 0000000..7af06cc --- /dev/null +++ b/src/pages/api/v1/posts.py @@ -0,0 +1,95 @@ +from flask import jsonify, make_response, request + +from src.config import Configuration +from src.lib.post import get_artist_posts_full, get_post, get_post_revisions +from src.lib.posts import get_all_posts_for_query, get_all_posts_summary +from src.pages.api.v1 import v1api_bp +from src.pages.artists import do_artist_post_search +from src.utils.utils import get_query_parameters_dict, parse_int, positive_or_none, step_int + + +@v1api_bp.get("//user//posts") +@v1api_bp.get("//user/") +def list_posts_api(service, creator_id): + limit = 50 + offset = positive_or_none(step_int(parse_int(request.args.get("o"), 0), limit)) + if offset is None: + response = make_response(jsonify({"error": "offset not multiple of 50"}), 400) + return response + + query = request.args.get("q", default="").strip() + if not query or len(query) < 2: + posts = get_artist_posts_full(creator_id, service, offset, limit, "published DESC NULLS LAST") + else: + (posts, total_count) = do_artist_post_search(creator_id, service, query, offset, limit) + response = make_response(jsonify(posts), 200) + return response + + +@v1api_bp.get("//user//post/") +def get_post_api(service, creator_id, post_id): + post = get_post(service, creator_id, post_id) + if not post or post["user"] != creator_id: + response = make_response(jsonify({"error": "Not Found"}), 404) + return response + response = make_response(jsonify(post), 200) + return response + + +@v1api_bp.get("//user//post//revisions") +def list_post_revision_api(service, creator_id, post_id): + revisions = get_post_revisions(service, creator_id, post_id) + response = make_response(jsonify(revisions), 200 if revisions else 404) + response.headers["Cache-Control"] = "max-age=600" + return response + + +@v1api_bp.route("/posts") +def recent(): + limit = 50 + query_params = get_query_parameters_dict(request, on_errors="ignore", clean_query_string=True) + + extra_pages = Configuration().webserver["extra_pages_to_load_on_posts"] + max_offset = limit * 1000 # only load 1000 pages of any result + query = query_params.get("q", "").strip()[: Configuration().webserver["max_full_text_search_input_len"]] + o = query_params.pop("o", 0) + offset = positive_or_none(step_int(parse_int(o, 0), limit)) + if offset is None or offset > max_offset: + response = make_response(jsonify({"error": "offset not multiple of 150 or too large"}), 400) + return response + extra_offset = positive_or_none(step_int(parse_int(o, 0), limit * extra_pages)) + slice_offset = offset - extra_offset + if not query or len(query) < 2: + extra_results = get_all_posts_summary(extra_offset, limit * extra_pages, cache_ttl=Configuration().cache_ttl_for_recent_posts)[slice_offset : limit + slice_offset] + # true_count = count_all_posts() + # count = limit_int(count_all_posts(), max_offset) + else: + try: + extra_results = get_all_posts_for_query(query, extra_offset, limit * extra_pages) + except Exception as error: + if "failed to parse expression" not in str(error): + raise + query = "Failed to parse query." + query_params["q"] = query + extra_results = get_all_posts_for_query(query, extra_offset, limit * extra_pages) + + # count not used + # if not offset and len(extra_results) < limit: + # true_count = 0 + # count = len(extra_results) + # else: + # try: + # true_count = count_all_posts_for_query(query) + # count = limit_int(props["true_count"], max_offset) + # except Exception as count_error: # catch timeouts, set count as max offset + # logging.exception( + # "Caught error in count_all_posts_for_query", + # extra={"e": count_error}, + # ) + # true_count = 0 + # count = max_offset + + results = extra_results[slice_offset : limit + slice_offset] + response = make_response(jsonify(results), 200) + response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" + return response diff --git a/src/pages/api/v1/shares.py b/src/pages/api/v1/shares.py new file mode 100644 index 0000000..7931498 --- /dev/null +++ b/src/pages/api/v1/shares.py @@ -0,0 +1,69 @@ +import json + +from flask import abort, request, session + +from src.internals.database.database import get_cursor +from src.pages.api.v1 import v1api_bp +from src.types.account.account import Account +from src.utils.decorators import require_login + + +@v1api_bp.route("/shares/upload", methods=["POST"]) +@require_login +def upload_share(user: Account): + return "" # uploads disabled + if user.role not in ["administrator", "moderator", "uploader"]: + return abort(403) + + service = request.form.get("service", None) + user = request.form.get("user", None) + uploads = json.loads(request.form["uppyResult"]) + model = dict( + name=request.form.get("title"), + description=request.form.get("content", ""), + uploader=session.get("account_id"), + ) + query = """ + INSERT INTO shares ({fields}) + VALUES ({values}) + RETURNING * + """.format( + fields=",".join(model.keys()), values=",".join(["%s"] * len(model.values())) + ) + cursor = get_cursor() + cursor.execute(query, list(model.values())) + returned = cursor.fetchone() + share_id = returned["id"] + for upload in uploads: + for u in upload["successful"]: + file_rel = dict( + upload_id=u["tus"]["uploadUrl"].split("/files/")[-1], + upload_url=u["tus"]["uploadUrl"], + filename=u["name"], + share_id=share_id, + ) + get_cursor().execute( + """ + INSERT INTO file_share_relationships ({fields}) + VALUES ({values}) + """.format( + fields=",".join(file_rel.keys()), + values=",".join(["%s"] * len(file_rel.values())), + ), + list(file_rel.values()), + ) + + lookup_rel = dict(share_id=share_id, service=service, user_id=user) + get_cursor().execute( + """ + INSERT INTO lookup_share_relationships ({fields}) + VALUES ({values}) + """.format( + fields=",".join(lookup_rel.keys()), + values=",".join(["%s"] * len(lookup_rel.values())), + ), + list(lookup_rel.values()), + ) + + # This should redirect to the share + return "", 200 diff --git a/src/pages/artists.py b/src/pages/artists.py new file mode 100644 index 0000000..8d65f4b --- /dev/null +++ b/src/pages/artists.py @@ -0,0 +1,490 @@ +from flask import Blueprint, Response, abort, flash, g, make_response, redirect, render_template, request, session, url_for + +from src.lib.announcements import get_artist_announcements +from src.lib.artist import ( + delete_creator_link, + get_artist, + get_artists_by_update_time, + get_fancards_by_artist, + get_top_artists_by_faves, + create_unapproved_link_request, + get_linked_creators, +) +from src.lib.dms import count_user_dms, get_artist_dms +from src.lib.filehaus import get_artist_share_count, get_artist_shares +from src.lib.post import ( + get_all_posts_by_artist, + get_artist_post_count, + get_artist_posts_summary, + get_fileserver_for_value, + get_render_data_for_posts, +) +from src.lib.posts import count_all_posts_for_tag, get_all_tags, get_tagged_posts +from src.pages.artists_types import ( + ArtistAnnouncementsProps, + ArtistDisplayData, + ArtistDMsProps, + ArtistFancardsProps, + ArtistPageProps, + ArtistShareProps, + LinkedAccountsProps, +) +from src.types.paysites import Paysite, Paysites +from src.utils.utils import offset_list, parse_int, positive_or_none, sort_dict_list_by, step_int, take +from src.types.account.account import Account +from src.utils.decorators import require_login + +artists_bp = Blueprint("artists", __name__) + + +@artists_bp.route("/artists") +def list(): + base = dict() + limit = 50 + + results = get_top_artists_by_faves(0, limit) + props = dict( + currentPage="artists", + display="cached popular artists", + count=len(results), + limit=limit, + ) + + response = make_response(render_template("artists.html", props=props, results=results, base=base), 200) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@artists_bp.route("/artists/updated") +def updated(): + base = dict(commit=True, sort_by="updated") + limit = 50 + + results = get_artists_by_update_time(offset=0, limit=limit) + props = dict( + currentPage="artists", + display="cached updated artists", + count=len(results), + limit=limit, + ) + + response = make_response(render_template("artists.html", props=props, results=results, base=base), 200) + response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" + return response + + +@artists_bp.route("//user/") +def get(service: str, artist_id: str): + if service == "discord": + response = redirect(f"/discord/server/{artist_id}", 308) + response.headers["Cache-Control"] = "s-maxage=86400" + return response + + base = request.args.to_dict() + base.pop("o", None) + base["service"] = service + base["artist_id"] = artist_id + + artist = get_artist(service, artist_id) + if artist is None: + return redirect(url_for("artists.list")) + elif artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for("artists.get", service=service, artist_id=artist["id"])) + + query = request.args.get("q", default="").strip() + tags = sorted(request.args.getlist("tag")) + limit = 50 + offset = positive_or_none(step_int(parse_int(request.args.get("o"), 0), limit)) + if offset is None: + return redirect(url_for("artists.list")) + + if tags: + posts = get_tagged_posts(tags, offset, limit, service, artist_id) + total_count = count_all_posts_for_tag(tags, service, artist_id) + elif not query or len(query) < 2: + total_count = get_artist_post_count(service, artist_id) + if offset > total_count: + return redirect(url_for("artists.get", service=service, artist_id=artist_id)) + else: + posts = get_artist_posts_summary(artist_id, service, offset, limit, "published DESC NULLS LAST") + else: + (posts, total_count) = do_artist_post_search(artist_id, service, query, offset, limit) + + ( + result_previews, + result_attachments, + result_is_image, + ) = get_render_data_for_posts(posts) + + props = ArtistPageProps( + id=artist_id, + service=service, + session=session, + name=artist["name"], + count=total_count, + limit=limit, + artist=artist, + display_data=make_artist_display_data(artist), + dm_count=count_user_dms(service, artist_id), + share_count=get_artist_share_count(service, artist_id), + has_links="✔️" if artist["relation_id"] else "0", + ) + + response = make_response( + render_template( + "user.html", + props=props, + base=base, + results=posts, + result_previews=result_previews, + result_attachments=result_attachments, + result_is_image=result_is_image, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@artists_bp.get("//user//tags") +def get_tags(service: str, artist_id: str): + artist = get_artist(service, artist_id) + if not artist: + response = redirect(url_for("artists.list"), code=301) + response.headers["Cache-Control"] = "s-maxage=60" + return response + elif artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for("artists.get_tags", service=service, artist_id=artist["id"]), code=301) + + tags = get_all_tags(service, artist_id) + + response = make_response( + render_template( + "artist/tags.html", + props={ + "display_data": make_artist_display_data(artist), + "artist": artist, + "service": service, + "id": artist["id"], + "share_count": get_artist_share_count(service, artist_id), + "dm_count": count_user_dms(service, artist_id), + "has_links": "✔️" if artist["relation_id"] else "0", + }, + tags=tags, + service=service, + artist=artist, + ) + ) + response.headers["Cache-Control"] = "s-maxage=600" + return response + + +@artists_bp.route("/fanbox/user//fancards") +def get_fancards(artist_id: str): + service = "fanbox" + artist = get_artist(service, artist_id) + if not artist: + response = redirect(url_for("artists.list"), code=301) + response.headers["Cache-Control"] = "s-maxage=60" + return response + elif artist["public_id"] == artist_id: + return redirect(url_for("artists.get_fancards", artist_id=artist["id"]), code=301) + + fancards = get_fancards_by_artist(artist_id) + for fancard in fancards: + fhash = fancard["hash"] + ext = fancard["ext"] + fancard["path"] = f"/data/{fhash[0:2]}/{fhash[2:4]}/{fhash}{ext}" + fancard["server"] = get_fileserver_for_value(fancard["path"]) + + props = ArtistFancardsProps( + id=artist_id, + session=session, + artist=artist, + display_data=make_artist_display_data(artist), + fancards=fancards, + share_count=get_artist_share_count(artist_id=artist_id, service=service), + dm_count=count_user_dms(service, artist_id), + has_links="✔️" if artist["relation_id"] else "0", + ) + + response = make_response( + render_template( + "artist/fancards.html", + artist=artist, + fancards=fancards, + props=props, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@artists_bp.route("//user//shares") +def get_shares(service: str, artist_id: str): + base = request.args.to_dict() + base.pop("o", None) + base["service"] = service + base["artist_id"] = artist_id + + dm_count = count_user_dms(service, artist_id) + shares = get_artist_shares(artist_id, service) + + artist = get_artist(service, artist_id) + if artist is None: + return redirect(url_for("artists.list")) + elif artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for("artists.get_shares", service=service, artist_id=artist["id"]), code=301) + + props = ArtistShareProps( + display_data=make_artist_display_data(artist), + service=service, + session=session, + artist=artist, + id=artist_id, + dm_count=dm_count, + share_count=len(shares), + has_links="✔️" if artist["relation_id"] else "0", + ) + + response = make_response( + render_template("artist/shares.html", results=shares, props=props, base=base), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@artists_bp.route("//user//dms") +def get_dms(service: str, artist_id: str): + # pagination might be added at some point if we need it, but considering how few dms most artists end up having, we probably won't + # base = request.args.to_dict() + # base.pop('o', None) + # base["service"] = service + # base["artist_id"] = artist_id + + # offset = int(request.args.get('o') or 0) + # query = request.args.get('q') + # limit = limit_int(int(request.args.get('limit') or 25), 50) + + artist = get_artist(service, artist_id) + if artist is None: + return redirect(url_for("artists.list")) + elif artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for("artists.get_dms", service=service, artist_id=artist["id"]), code=301) + + dms = get_artist_dms(service, artist_id) + + props = ArtistDMsProps( + id=artist_id, + service=service, + session=session, + artist=artist, + display_data=make_artist_display_data(artist), + share_count=get_artist_share_count(service, artist_id), + dm_count=len(dms), + dms=dms, + has_links="✔️" if artist["relation_id"] else "0", + ) + + response = make_response( + render_template( + "artist/dms.html", + props=props, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@artists_bp.route("//user//announcements") +def get_announcements(service: str, artist_id: str) -> Response: + # offset = int(request.args.get("o") or 0) + query = request.args.get("q", "") + + artist = get_artist(service, artist_id) + if artist is None: + return redirect(url_for("artists.list")) + elif artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for("artists.get_announcements", service=service, artist_id=artist["id"]), code=301) + + announcements = get_artist_announcements(service, artist_id, query=query, reload=True) + # total_announcement_count = get_announcement_count(service=service, artist_id=artist_id, query=query, reload=True) + + props = ArtistAnnouncementsProps( + id=artist_id, + service=service, + artist=artist, + announcements=announcements, + # count=total_announcement_count, + share_count=get_artist_share_count(service, artist_id), + dm_count=count_user_dms(service, artist_id), + has_links="✔️" if artist["relation_id"] else "0", + session=session, + display_data=make_artist_display_data(artist), + ) + response: Response = make_response( + render_template( + "artist/announcements.html", + props=props, + base={"service": service, "artist_id": artist_id}, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@artists_bp.get("//user//links") +def get_linked_accounts(service: str, artist_id: str): + artist = get_artist(service, artist_id) + if not artist: + return redirect(url_for("artists.list")) + elif artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for("artists.get_linked_accounts", service=service, artist_id=artist["id"]), code=301) + links = get_linked_creators(service, artist_id) + + props = LinkedAccountsProps( + id=artist_id, + service=service, + artist=artist, + share_count=get_artist_share_count(service, artist_id), + dm_count=count_user_dms(service, artist_id), + has_links="✔️" if artist["relation_id"] else "0", + display_data=make_artist_display_data(artist), + ) + + response = make_response( + render_template( + "artist/linked_accounts.html", + props=props, + links=links, + base={"service": service, "artist_id": artist_id}, + ), + 200 + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@artists_bp.delete("//user//links") +@require_login +def delete_linked_account(service: str, creator_id: str, user: Account): + if user.role != "administrator": + abort(404) + else: + delete_creator_link(service, creator_id) + return "", 204 + + +@artists_bp.get("//user//links/new") +@require_login +def get_new_link_page(service: str, artist_id: str, user: Account): + artist = get_artist(service, artist_id) + if not artist: + return redirect(url_for("artists.list")) + elif artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for("artists.get_new_link_page", service=service, artist_id=artist["id"]), code=301) + + props = LinkedAccountsProps( + id=artist_id, + service=service, + artist=artist, + share_count=get_artist_share_count(service, artist_id), + dm_count=count_user_dms(service, artist_id), + has_links="✔️" if artist["relation_id"] else "0", + display_data=make_artist_display_data(artist), + ) + + response = make_response( + render_template( + "artist/new_linked_account.html", + props=props, + base={"service": service, "artist_id": artist_id}, + ), + 200 + ) + response.headers["Cache-Control"] = "s-maxage=600" + return response + + +@artists_bp.post("//user//links/new") +@require_login +def post_new_link_page(service: str, artist_id: str, user: Account): + dest_service, dest_artist_id = request.form.get("creator", "/").split("/") + reason = request.form.get("reason", "") + + from_artist = get_artist(service, artist_id) + to_artist = get_artist(dest_service, dest_artist_id) + + if not from_artist: + return redirect(url_for("artists.list")) + elif from_artist["public_id"] == artist_id and from_artist["id"] != artist_id: + return redirect(url_for("artists.post_new_link_page", service=service, artist_id=from_artist["id"]), code=301) + + props = LinkedAccountsProps( + id=artist_id, + service=service, + artist=from_artist, + share_count=get_artist_share_count(service, artist_id), + dm_count=count_user_dms(service, artist_id), + has_links="✔️" if from_artist["relation_id"] else "0", + display_data=make_artist_display_data(from_artist), + ) + + tmpl = render_template( + "artist/new_linked_account.html", + props=props, + base={"service": service, "artist_id": artist_id}, + ) + if not to_artist: + flash(f"Invalid creator (svc: {dest_service}, id: {dest_artist_id})") + response = make_response(tmpl, 404) + return response + + if len(reason) > 140: + flash("Reason is too long") + return tmpl, 422 + + if dest_service == service and dest_artist_id == artist_id: + flash("Can't link an artist to themself") + response = make_response(tmpl, 422) + return response + + if from_artist["relation_id"] == to_artist["relation_id"] and from_artist["relation_id"] is not None: + flash("Already linked") + response = make_response(tmpl, 422) + return response + + create_unapproved_link_request(from_artist, to_artist, user.id, reason) + flash("Request created. It will be shown here when approved.") + return redirect(url_for("artists.get_linked_accounts", service=service, artist_id=artist_id)) + + +def do_artist_post_search(artist_id, service, search, o, limit): + posts = get_all_posts_by_artist(artist_id, service) + search = search.lower() + + matches = [] + for post in posts: + if ( + search in post["content"].lower() + or search in post["title"].lower() + or search in " ".join(post["tags"] or []).lower() + ): + matches.append(post) + + matches = sort_dict_list_by(matches, "published", True) + + return take(limit, offset_list(o, matches)), len(matches) + + +def make_artist_display_data(artist: dict) -> ArtistDisplayData: + service_name: str = artist["service"] + pay_site: Paysite | None = getattr(Paysites, service_name, None) + + if pay_site: + return ArtistDisplayData(service=pay_site.title, href=pay_site.user.profile(artist)) + raise Exception("Service not found in Paysites") diff --git a/src/pages/artists_types.py b/src/pages/artists_types.py new file mode 100644 index 0000000..9cea111 --- /dev/null +++ b/src/pages/artists_types.py @@ -0,0 +1,99 @@ +from dataclasses import dataclass +from typing import Any, Dict, List + +from flask.sessions import SessionMixin + +from src.internals.internal_types import PageProps +from src.types.kemono import Approved_DM + + +@dataclass +class ArtistDisplayData: + service: str + href: str + + +@dataclass +class ArtistPageProps(PageProps): + currentPage = "posts" + id: str + service: str + session: SessionMixin + name: str + count: int + limit: int + artist: Dict + display_data: ArtistDisplayData + dm_count: int + share_count: int + has_links: str + + +@dataclass +class ArtistShareProps(PageProps): + currentPage = "shares" + id: str + service: str + session: SessionMixin + artist: Dict + display_data: ArtistDisplayData + dm_count: int + share_count: int + has_links: str + + +@dataclass +class ArtistDMsProps(PageProps): + currentPage = "dms" + id: str + service: str + session: SessionMixin + artist: Dict + display_data: ArtistDisplayData + dm_count: int + dms: List[Approved_DM] + share_count: int + has_links: str + + +@dataclass +class ArtistFancardsProps(PageProps): + id: str + session: SessionMixin + artist: Dict + display_data: ArtistDisplayData + fancards: List[Any] # todo remove any + share_count: int + dm_count: int + has_links: str + currentPage: str = "fancards" + service: str = "fanbox" + + +@dataclass +class ArtistAnnouncementsProps(PageProps): + id: str + service: str + artist: Dict + announcements: List[Any] # todo + # count: int + share_count: int + dm_count: int + has_links: str + session: SessionMixin + display_data: ArtistDisplayData + currentPage: str = "announcements" + limit: int = 50 + + +@dataclass +class LinkedAccountsProps(PageProps): + id: str + service: str + artist: dict + share_count: int + dm_count: int + has_links: str + linked_accounts = [] + display_data: ArtistDisplayData + currentPage: str = "linked_accounts" diff --git a/src/pages/creator_link_requests.py b/src/pages/creator_link_requests.py new file mode 100644 index 0000000..3ed6533 --- /dev/null +++ b/src/pages/creator_link_requests.py @@ -0,0 +1,26 @@ +from flask import Blueprint, abort, jsonify + +from src.lib.artist import approve_unapproved_link_request, reject_unapproved_link_request +from src.utils.decorators import require_login +from src.types.account.account import Account + + +bp = Blueprint("creator_link_requests", __name__) + + +@bp.post("/creator_link_requests//approve") +@require_login +def approve_request(request_id: int, user: Account): + if user.role not in ["moderator", "administrator"]: + return abort(404) + approve_unapproved_link_request(request_id) + return jsonify({"response": "approved"}) + + +@bp.post("/creator_link_requests//reject") +@require_login +def reject_request(request_id: int, user: Account): + if user.role not in ["moderator", "administrator"]: + return abort(404) + reject_unapproved_link_request(request_id) + return jsonify({"response": "rejected"}) diff --git a/src/pages/dms.py b/src/pages/dms.py new file mode 100644 index 0000000..13fcb04 --- /dev/null +++ b/src/pages/dms.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass +from typing import Dict, List + +from flask import Blueprint, make_response, redirect, render_template, request, url_for + +from src.config import Configuration +from src.internals.internal_types import PageProps +from src.lib.artist import get_artist +from src.lib.dms import get_all_dms, get_all_dms_by_query, get_all_dms_by_query_count, get_all_dms_count +from src.types.kemono import Approved_DM +from src.utils.utils import get_query_parameters_dict, parse_int, positive_or_none, step_int + + +@dataclass +class DMsProps(PageProps): + currentPage = "artists" + count: int + limit: int + dms: List[Approved_DM] + + +dms_bp = Blueprint("dms", __name__) + + +@dms_bp.route("/dms") +def get_dms(): + base = get_query_parameters_dict(request, on_errors="ignore", clean_query_string=True) + + limit = 50 + max_offset = limit * 1000 # only load 1000 pages of any result + offset = positive_or_none(step_int(parse_int(base.pop("o", 0), 0), limit)) + if offset is None or offset > max_offset: + return redirect(url_for("dms.get_dms")) + query = base.get("q", "").strip()[: Configuration().webserver["max_full_text_search_input_len"]] + + if not query or len(query) < 3: + total_count = get_all_dms_count() + if offset > total_count: + return redirect(url_for("dms.get_dms")) + dms = get_all_dms(offset, limit) + else: + total_count = get_all_dms_by_query_count(query) + if offset > total_count: + return redirect(url_for("dms.get_dms")) + dms = get_all_dms_by_query(query, offset, limit) + + props = DMsProps(count=total_count, limit=limit, dms=dms) + + response = make_response( + render_template( + "all_dms.html", + props=props, + base=base, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response diff --git a/src/pages/favorites.py b/src/pages/favorites.py new file mode 100644 index 0000000..ab41025 --- /dev/null +++ b/src/pages/favorites.py @@ -0,0 +1,60 @@ +from flask import Blueprint, make_response, redirect, render_template, request, url_for + +from src.lib.favorites import get_favorite_artists, get_favorite_posts +from src.types.account.account import Account +from src.utils.decorators import require_login +from src.utils.utils import offset_list, parse_int, positive_or_none, restrict_value, sort_dict_list_by, step_int, take + +favorites_bp = Blueprint("favorites", __name__) + + +@favorites_bp.route("/favorites", methods=["GET"]) +@require_login +def list_favorites(user: Account): + props = {"currentPage": "favorites"} + base = request.args.to_dict() + base.pop("o", None) + + fave_type = request.args.get("type", "artist") + if fave_type == "post": + favorites = get_favorite_posts(user.id) + sort_field = restrict_value(request.args.get("sort"), ["faved_seq", "published"], "faved_seq") + else: + fave_type = "artist" + favorites = get_favorite_artists(user.id) + sort_field = restrict_value( + request.args.get("sort"), + ["faved_seq", "updated", "last_imported"], + "updated", + ) + + limit = 50 + offset = positive_or_none(step_int(parse_int(request.args.get("o"), 0), limit)) + if offset is None: + return redirect(url_for("favorites.list_favorites")) + sort_asc = request.args.get("order") == "asc" + results = sort_and_filter_favorites(favorites, offset, sort_field, sort_asc) + + props["fave_type"] = fave_type + props["sort_field"] = sort_field + props["sort_asc"] = sort_asc + props["count"] = len(favorites) + props["limit"] = limit + + response = make_response( + render_template( + "favorites.html", + props=props, + base=base, + source="account", + results=results, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +def sort_and_filter_favorites(favorites, o, field, asc): + favorites = sort_dict_list_by(favorites, field, not asc) + return take(50, offset_list(o, favorites)) diff --git a/src/pages/filehaus.py b/src/pages/filehaus.py new file mode 100644 index 0000000..cf715b3 --- /dev/null +++ b/src/pages/filehaus.py @@ -0,0 +1,79 @@ +from flask import Blueprint, flash, make_response, redirect, render_template, request, url_for, g + +from src.config import Configuration +from src.lib.filehaus import get_all_shares_count, get_files_for_share, get_share, get_shares +from src.utils.utils import parse_int, positive_or_none, step_int + +filehaus_bp = Blueprint("filehaus", __name__) + + +@filehaus_bp.route("/share/") +def get_share_handler(share_id: str): + base = request.args.to_dict() + base.pop("o", None) + + props = dict(currentPage="shares") + share = get_share(int(share_id)) if share_id.isdigit() else None + if share is None: + response = redirect(url_for("filehaus.get_shares_page")) + return response + + share_files = get_files_for_share(share["id"]) + + response = make_response( + render_template("share.html", share_files=share_files, share=share, props=props, base=base), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@filehaus_bp.route("/shares") +def get_shares_page(): + base = request.args.to_dict() + base.pop("o", None) + + limit = 50 + offset = positive_or_none(step_int(parse_int(request.args.get("o"), 0), limit)) + # query = request.args.get('q') + + shares = None + total_count = None + (shares, total_count) = get_share_page(offset, limit) + + props = dict(currentPage="shares", count=total_count, shares=shares, limit=limit) + + response = make_response( + render_template( + "shares.html", + props=props, + base=base, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +def get_share_page(offset: int, limit: int): + posts = get_shares(offset, limit) + total_count = get_all_shares_count() + return posts, total_count + + +@filehaus_bp.route("/posts/upload") +def upload_post(): + account = g.get("account") + if Configuration().filehaus["requires_account"] and account is None: + flash("Filehaus uploading requires an account.") + return redirect(url_for("account.get_login")) + required_roles = Configuration().filehaus["required_roles"] + if len(required_roles) and account.role not in required_roles: + flash( + "Filehaus uploading requires elevated permissions. " "Please contact the administrator to change your role." + ) + return redirect(url_for("account.get_account")) + props = {"currentPage": "posts"} + response = make_response(render_template("upload.html", props=props), 200) + response.headers["Cache-Control"] = "s-maxage=60" + return response diff --git a/src/pages/files.py b/src/pages/files.py new file mode 100644 index 0000000..af3f793 --- /dev/null +++ b/src/pages/files.py @@ -0,0 +1,43 @@ +from flask import Blueprint, make_response, redirect, render_template, request, url_for + +from src.internals.database.database import get_cursor +from src.lib.files import get_file_relationships + +files_bp = Blueprint("files", __name__) + + +@files_bp.route("/search_hash", methods=["GET", "POST"]) +def search_hash(): + file_hash = request.args.get("hash") + if file_hash: + if not (len(file_hash) == 64 and all(c in "0123456789abcdefABCDEF" for c in file_hash)): + return redirect(url_for("files.search_hash")) + file_data: dict | None = get_file_relationships(file_hash) + for discord_post in (file_data["discord_posts"] or []) if file_data else []: + cursor = get_cursor() + cursor.execute( + "SELECT * FROM discord_channels WHERE channel_id = %s", + (discord_post["channel"],), + ) + lookup_result = cursor.fetchall() + discord_post["channel_name"] = lookup_result[0]["name"] if len(lookup_result) else "" + response = make_response( + render_template( + "search_results.html", + hash=file_hash, + file_data=file_data, + props={"currentPage": "search_hash"}, + ) + ) + response.headers["Cache-Control"] = "s-maxage=60" + else: + response = make_response( + render_template( + "search_hash.html", + props={"currentPage": "search_hash"}, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + + return response diff --git a/src/pages/help.py b/src/pages/help.py new file mode 100644 index 0000000..9191e2f --- /dev/null +++ b/src/pages/help.py @@ -0,0 +1,16 @@ +from flask import Blueprint, make_response, redirect, render_template, url_for + +help_app_bp = Blueprint("help_app", __name__) + + +@help_app_bp.route("/") +def help(): + return redirect(url_for("help_app.faq"), 302) + + +@help_app_bp.get("/faq") +def faq(): + props = dict(currentPage="help") + response = make_response(render_template("help/faq.html", props=props), 200) + response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" + return response diff --git a/src/pages/home.py b/src/pages/home.py new file mode 100644 index 0000000..3140220 --- /dev/null +++ b/src/pages/home.py @@ -0,0 +1,13 @@ +from flask import Blueprint, make_response, render_template, request + +home_bp = Blueprint("pages", __name__) + + +@home_bp.get("/") +def get_home(): + props = {} + base = request.args.to_dict() + base.pop("o", None) + response = make_response(render_template("home.html", props=props, base=base), 200) + response.headers["Cache-Control"] = "s-maxage=60" + return response diff --git a/src/pages/imports/__init__.py b/src/pages/imports/__init__.py new file mode 100644 index 0000000..c02a770 --- /dev/null +++ b/src/pages/imports/__init__.py @@ -0,0 +1 @@ +from .blueprint import importer_page_bp diff --git a/src/pages/imports/blueprint.py b/src/pages/imports/blueprint.py new file mode 100644 index 0000000..b5df5d8 --- /dev/null +++ b/src/pages/imports/blueprint.py @@ -0,0 +1,55 @@ +from flask import Blueprint, make_response, redirect, render_template, request, session, url_for + +from src.lib.dms import approve_dms, cleanup_unapproved_dms, get_unapproved_dms +from src.types.props import SuccessProps + +from .types import DMPageProps, ImportProps, StatusPageProps + +importer_page_bp = Blueprint("importer_page", __name__) + + +@importer_page_bp.get("/importer") +def importer(): + props = ImportProps() + + response = make_response(render_template("importer_list.html", props=props), 200) + response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" + return response + + +@importer_page_bp.get("/importer/tutorial") +def importer_tutorial(): + props = ImportProps() + + response = make_response(render_template("importer_tutorial.html", props=props), 200) + response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" + return response + + +@importer_page_bp.get("/importer/tutorial_fanbox") +def importer_tutorial_fanbox(): + props = ImportProps() + + response = make_response(render_template("importer_tutorial_fanbox.html", props=props), 200) + response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" + return response + + +@importer_page_bp.get("/importer/ok") +def importer_ok(): + props = ImportProps() + + response = make_response(render_template("importer_ok.html", props=props), 200) + response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" + return response + + +@importer_page_bp.get("/importer/status/") +def importer_status(import_id): + is_dms = bool(request.args.get("dms")) + + props = StatusPageProps(import_id=import_id, is_dms=is_dms) + response = make_response(render_template("importer_status.html", props=props), 200) + + response.headers["Cache-Control"] = "max-age=0, private, must-revalidate" + return response diff --git a/src/pages/imports/types.py b/src/pages/imports/types.py new file mode 100644 index 0000000..d9bbdd3 --- /dev/null +++ b/src/pages/imports/types.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import List + +from src.internals.internal_types import PageProps +from src.types.kemono import Unapproved_DM + + +@dataclass +class ImportProps(PageProps): + currentPage = "import" + + +@dataclass +class StatusPageProps(ImportProps): + import_id: str + is_dms: bool + + +@dataclass +class DMPageProps(ImportProps): + account_id: int + dms: List[Unapproved_DM] + status: str diff --git a/src/pages/post.py b/src/pages/post.py new file mode 100644 index 0000000..2258cba --- /dev/null +++ b/src/pages/post.py @@ -0,0 +1,254 @@ +import datetime +import json +import re +from pathlib import PurePath + +import dateutil.parser +from flask import Blueprint, make_response, redirect, render_template, url_for + +from src.config import Configuration +from src.lib.artist import get_artist +from src.lib.post import ( + get_fileserver_for_value, + get_post, + get_post_by_id, + get_post_comments, + get_post_revisions, + get_posts_incomplete_rewards, + is_post_flagged, +) +from src.utils.utils import images_pattern, sanitize_html + +post_bp = Blueprint("post", __name__) +video_extensions = Configuration().webserver["ui"]["video_extensions"] + + +@post_bp.route("//post/") +def get_by_id(service, post_id): + post = get_post_by_id(post_id, service) + + if post: + response = redirect( + url_for( + "post.get", + service=post["service"], + artist_id=post["user"], + post_id=post["id"], + ) + ) + response.headers["Cache-Control"] = "s-maxage=86400" + else: + response = redirect(url_for("artists.list"), code=301) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +@post_bp.route("//user//post/") +def get(service, artist_id, post_id): + artist = get_artist(service, artist_id) + if artist and artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for("post.get", service=service, artist_id=artist["id"], post_id=post_id), code=301) + + post: dict = get_post(service, artist_id, post_id) + if not post: + response = redirect(url_for("artists.get", service=service, artist_id=artist_id)) + return response + + attachments, comments, previews, videos, props = ready_post_props(post) + props["currentPage"] = "posts" + + response = make_response( + render_template( + "post.html", + props=props, + post=post, + comments=comments, + result_previews=previews, + result_attachments=attachments, + videos=videos, + archives_enabled=Configuration().archive_server["enabled"], + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + +def ready_post_props(post): + service = post["service"] + artist_id = post["user"] + post_id = post["id"] + + if service in ("patreon",): + if post["file"] and post["attachments"] and post["file"] == post["attachments"][0]: + post["attachments"] = post["attachments"][1:] + if service in ("fansly", "onlyfans"): + posts_incomplete_rewards = get_posts_incomplete_rewards(post_id, artist_id, service) + if posts_incomplete_rewards: + post["incomplete_rewards"] = "This post is missing paid rewards from a higher tier or payment." + if post["service"] == "onlyfans": + try: + rewards_info_text = ( + f"{posts_incomplete_rewards["incomplete_attachments_info"]["media_count"]} media, " + f"{posts_incomplete_rewards["incomplete_attachments_info"]["photo_count"]} photos, " + f"{posts_incomplete_rewards["incomplete_attachments_info"]["video_count"]} videos, " + f"for {posts_incomplete_rewards["incomplete_attachments_info"]["price"]}$." + ) + post["incomplete_rewards"] += "\n" + rewards_info_text + except Exception: + pass + elif post["service"] == "fansly": + try: + rewards_info_text = ( + f"Downloaded:{posts_incomplete_rewards["incomplete_attachments_info"]["complete"]} " + f"Missing:{posts_incomplete_rewards["incomplete_attachments_info"]["incomplete"]}" + ) + post["incomplete_rewards"] += "\n" + rewards_info_text + except Exception: + pass + previews = [] + attachments = [] + videos = [] + if "path" in post["file"]: + if images_pattern.search(post["file"]["path"]): + previews.append( + { + "type": "thumbnail", + "server": get_fileserver_for_value(f"/data{post["file"]["path"]}"), + "name": post["file"].get("name"), + "path": post["file"]["path"], + } + ) + else: + file_extension = PurePath(post["file"]["path"]).suffix + name_extension = PurePath(post["file"].get("name") or "").suffix + # filename without extension + stem = PurePath(post["file"]["path"]).stem + attachments.append( + { + "server": get_fileserver_for_value(f"/data{post["file"]["path"]}"), + "name": post["file"].get("name"), + "extension": file_extension, + "name_extension": name_extension, + "stem": stem, + "path": post["file"]["path"], + } + ) + if len(post.get("embed") or []): + previews.append( + { + "type": "embed", + "url": post["embed"]["url"], + "subject": post["embed"]["subject"], + "description": post["embed"]["description"], + } + ) + for attachment in post["attachments"]: + if images_pattern.search(attachment["path"]): + previews.append( + { + "type": "thumbnail", + "server": get_fileserver_for_value(f"/data{attachment["path"]}"), + "name": attachment["name"], + "path": attachment["path"], + } + ) + else: + file_extension = PurePath(attachment["path"]).suffix + name_extension = PurePath(attachment.get("name") or "").suffix + # filename without extension + stem = PurePath(attachment["path"]).stem + attachments.append( + { + "server": get_fileserver_for_value(f"/data{attachment["path"]}"), + "name": attachment.get("name"), + "extension": file_extension, + "name_extension": name_extension, + "stem": stem, + "path": attachment["path"], + } + ) + for i, attachment in enumerate(attachments): + if attachment["extension"] in video_extensions: + videos.append( + { + "index": i, + "path": attachment["path"], + "name": attachment.get("name"), + "extension": attachment["extension"], + "name_extension": attachment["name_extension"], + "server": get_fileserver_for_value(f"/data{attachment["path"]}"), + } + ) + + if post.get("poll") is not None: + post["poll"]["total_votes"] = sum(choice["votes"] for choice in post["poll"]["choices"]) + post["poll"]["created_at"] = datetime.datetime.fromisoformat(post["poll"]["created_at"]) + if post["poll"]["closes_at"]: + post["poll"]["closes_at"] = datetime.datetime.fromisoformat(post["poll"]["closes_at"]) + + if (captions := post.get("captions")) is not None: + for file_hash, caption_data in captions.items(): + for preview_data in [preview for preview in previews if preview.get("path") == file_hash]: + if isinstance(caption_data, dict): + preview_data["caption"] = caption_data.get("text") or "" + elif isinstance(caption_data, list): + preview_data["caption"] = " ".join(each.get("text") or "" for each in caption_data) + for video in [video for video in videos if video["path"] == file_hash]: + if isinstance(caption_data, dict): + video["caption"] = caption_data.get("text") or "" + elif isinstance(caption_data, list): + video["caption"] = " ".join(each.get("text") or "" for each in caption_data) + + props = { + "service": service, + "artist": get_artist(service, artist_id), + "flagged": is_post_flagged(service, artist_id, post_id), + "revisions": get_post_revisions(service, artist_id, post_id), + } + real_post = post if not post.get("revision_id") else get_post(service, artist_id, post_id) + all_revisions = [real_post] + props["revisions"] + for set_prev_next_revision in (post, *props["revisions"]): + set_prev_next_revision["prev"] = real_post["prev"] + set_prev_next_revision["next"] = real_post["next"] + + if props["revisions"]: + last_date = real_post["added"] + for i, rev in enumerate(all_revisions[:-1]): + rev["added"] = all_revisions[i + 1]["added"] + props["revisions"][-1]["added"] = last_date + + if real_post["service"] == "fanbox": + top_rev_stripped = all_revisions[0].copy() + top_rev_stripped.pop("file") + top_rev_stripped.pop("added") + top_rev_stripped.pop("revision_id", None) + for fanbox_attachment in top_rev_stripped["attachments"]: + if 41 >= len(fanbox_attachment["name"]) >= 39: + fanbox_attachment.pop("name", None) + for duplicated_check_rev in all_revisions[1:]: + duplicated_check_rev_file_stripped = duplicated_check_rev.copy() + duplicated_check_rev_file_stripped.pop("file") + duplicated_check_rev_file_stripped.pop("added") + duplicated_check_rev_file_stripped.pop("revision_id", None) + for fanbox_attachment in duplicated_check_rev_file_stripped["attachments"]: + if 41 >= len(fanbox_attachment["name"]) >= 39: + fanbox_attachment.pop("name", None) + if duplicated_check_rev_file_stripped == top_rev_stripped: + all_revisions.remove(duplicated_check_rev) + else: + top_rev_stripped = duplicated_check_rev_file_stripped + + if isinstance(post["tags"], str): + post["tags"] = [tag.strip('"') for tag in post["tags"][1:-1].split(",")] + + props["revisions"] = list(reversed([(i, rev) for i, rev in enumerate(reversed(all_revisions))])) + comments = get_post_comments(post_id, service) + if post["service"] == "fanbox": + post["content"] = DOWNLOAD_URL_FANBOX_REGEX.sub("", post["content"]) + post["content"] = sanitize_html(post["content"], allow_iframe=post["service"] == "fanbox") + + return attachments, comments, previews, videos, props + + +DOWNLOAD_URL_FANBOX_REGEX = re.compile(r"") diff --git a/src/pages/posts.py b/src/pages/posts.py new file mode 100644 index 0000000..c98dc1c --- /dev/null +++ b/src/pages/posts.py @@ -0,0 +1,228 @@ +import datetime +import logging +from typing import cast, get_args + +from flask import Blueprint, Response, make_response, redirect, render_template, request, url_for +from psycopg.errors import QueryCanceled +from src.lib.files import get_archive_files + +from src.config import Configuration +from src.lib.post import get_render_data_for_posts +from src.lib.posts import ( + count_all_posts, + count_all_posts_for_query, + count_all_posts_for_tag, + get_all_posts_for_query, + get_all_posts_summary, + get_all_tags, + get_popular_posts_for_date_range, + get_tagged_posts, +) +from src.utils.datetime_ import PeriodScale, parse_scale_string +from src.utils.utils import ( + get_query_parameters_dict, + limit_int, + parse_int, + parse_offset, + positive_or_none, + set_query_parameter, + step_int, +) + +posts_bp = Blueprint("posts", __name__) + + +@posts_bp.route("/posts") +def get_posts(): + props = { + "currentPage": "posts", + "limit": 50, + } + + query_params = get_query_parameters_dict(request, on_errors="ignore", clean_query_string=True) + + extra_pages = Configuration().webserver["extra_pages_to_load_on_posts"] + max_offset = props["limit"] * 1000 # only load 1000 pages of any result + query = query_params.get("q", "").strip()[: Configuration().webserver["max_full_text_search_input_len"]] + tags = request.args.getlist("tag") + o = query_params.pop("o", 0) + offset = positive_or_none(step_int(parse_int(o, 0), props["limit"])) + if offset is None or offset > max_offset: + return redirect(url_for("posts.get_posts")) + extra_offset = positive_or_none(step_int(parse_int(o, 0), props["limit"] * extra_pages)) + slice_offset = offset - extra_offset + # todo this true_count and count have no real meaning seeing it is displayed as {{props.true_count or props.count }} + if tags: + extra_results = get_tagged_posts(tags, extra_offset, props["limit"] * extra_pages) + total_count = count_all_posts_for_tag(tags) + props["true_count"] = total_count + props["count"] = limit_int(total_count, max_offset) + elif not query or len(query) < 2: + extra_results = get_all_posts_summary(extra_offset, props["limit"] * extra_pages, cache_ttl=Configuration().cache_ttl_for_recent_posts)[ + slice_offset : props["limit"] + slice_offset + ] + props["true_count"] = count_all_posts() + props["count"] = limit_int(count_all_posts(), max_offset) + else: + try: + extra_results = get_all_posts_for_query(query, extra_offset, props["limit"] * extra_pages) + except QueryCanceled: + return make_response("Query Timeout. Please fix your query text or try again later.", 408) + except Exception as error: + if "failed to parse expression" not in str(error): + raise + query = "Failed to parse query." + query_params["q"] = query + extra_results = get_all_posts_for_query(query, extra_offset, props["limit"] * extra_pages) + + if not offset and len(extra_results) < props["limit"]: + props["true_count"] = 0 + props["count"] = len(extra_results) + else: + try: + props["true_count"] = count_all_posts_for_query(query) + props["count"] = limit_int(props["true_count"], max_offset) + except Exception as count_error: # catch timeouts, set count as max offset + logging.exception( + "Caught error in count_all_posts_for_query", + extra={"e": count_error}, + ) + props["true_count"] = 0 + props["count"] = max_offset + + results = extra_results[slice_offset : props["limit"] + slice_offset] + + ( + result_previews, + result_attachments, + result_is_image, + ) = get_render_data_for_posts(results) + + response = make_response( + render_template( + "posts.html", + props=props, + results=results, + base=query_params, + result_previews=result_previews, + result_attachments=result_attachments, + result_is_image=result_is_image, + ), + 200, + ) + # response.headers["Cache-Control"] = "no-store, max-age=0" + return response + + +@posts_bp.route("/posts/popular") +def popular_posts() -> Response: + query_params = get_query_parameters_dict(request) + earliest_date_for_popular = Configuration().webserver.get("earliest_date_for_popular") + # checked below but doesn't typecheck without a cast + scale: PeriodScale = cast(PeriodScale, query_params.get("period", "recent")) + + if scale not in get_args(PeriodScale): + scale = "recent" + + info, valid_date = parse_scale_string(query_params.get("date"), scale) + does_not_match_step_date = scale != "recent" and info.date.date() != info.navigation_dates[scale][2] + if ( + not valid_date + or does_not_match_step_date + or info.date.date() > datetime.date.today() + or info.date.date() < earliest_date_for_popular + ): + correct_date = info.navigation_dates[scale][2].isoformat() + if info.date.date() > datetime.date.today(): + correct_date = datetime.date.today() + scale = "day" + elif info.date.date() < earliest_date_for_popular: + correct_date = earliest_date_for_popular + scale = "day" + new_url = set_query_parameter(url_for("posts.popular_posts"), {"date": correct_date, "period": scale}) + response = redirect(new_url) + cache_seconds = int(datetime.timedelta(days=7).total_seconds()) + if info.date.date() > datetime.date.today(): + cache_seconds = int(datetime.timedelta(hours=3).total_seconds()) + response.headers["Cache-Control"] = f"max-age={cache_seconds}" + return response + expiry = int(datetime.timedelta(days=30).total_seconds()) + if scale == "recent": + expiry = int(datetime.timedelta(minutes=30 + 1).total_seconds()) + elif info.max_date > datetime.datetime.utcnow(): + if scale == "day": + expiry = int(datetime.timedelta(hours=3).total_seconds()) + elif scale == "week": + expiry = int(datetime.timedelta(days=1).total_seconds()) + elif scale == "month": + if datetime.date.today().day < 7: + expiry = int(datetime.timedelta(days=1).total_seconds()) + else: + expiry = int(datetime.timedelta(days=5).total_seconds()) + + pages = Configuration().webserver.get("pages_in_popular") + per_page = 50 + offset = positive_or_none(step_int(parse_int(query_params.pop("o", 0), 0), per_page)) + if offset is None: + response = redirect(url_for("posts.popular_posts")) + response.headers["Cache-Control"] = f"max-age={int(datetime.timedelta(days=7).total_seconds())}" + return response + posts = get_popular_posts_for_date_range( + info.min_date, info.max_date, scale, offset // per_page, per_page, pages, expiry + ) + (previews, attachments, is_image) = get_render_data_for_posts(posts) + + response = make_response( + render_template( + "posts/popular.html", + info=info, + props={ + "currentPage": "popular_posts", + "today": datetime.date.today(), + "earliest_date_for_popular": Configuration().webserver.get("earliest_date_for_popular"), + "limit": per_page, + "count": pages * per_page, + }, + results=posts, + base=query_params, + result_previews=previews, + result_attachments=attachments, + result_is_image=is_image, + ), + 200, + ) + response.headers["Cache-Control"] = f"max-age={int(expiry)}" + return response + + +@posts_bp.get("/posts/tags") +def list_tags(): + response = make_response( + render_template( + "tags.html", + props={"currentPage": "tags"}, + tags=get_all_tags(), + ), + ) + response.headers["Cache-Control"] = "s-maxage=3600" + return response + + +@posts_bp.route("/discord/server/") +def discord_server(server_id): + response = make_response(render_template("discord.html"), 200) + response.headers["Cache-Control"] = "s-maxage=600" + return response + + +@posts_bp.route("/posts/archives/") +def list_archive(file_hash: str): + archive = get_archive_files(file_hash) + response = make_response(render_template( + "posts/archive.html", + props={}, + archive=archive, + file_serving_enabled=Configuration().archive_server["file_serving_enabled"], + ), 200) + response.headers["Cache-Control"] = "s-maxage=600" + return response diff --git a/src/pages/random_.py b/src/pages/random_.py new file mode 100644 index 0000000..3c74a69 --- /dev/null +++ b/src/pages/random_.py @@ -0,0 +1,45 @@ +import random + +from flask import Blueprint, redirect, url_for + +from src.config import Configuration +from src.lib.artist import get_random_artist_keys +from src.lib.post import get_random_post_key + +random_bp = Blueprint("random", __name__) + + +@random_bp.route("/posts/random") +def random_post(): + post = get_random_post_key(Configuration().webserver.get("table_sample_bernoulli_sample_size")) + if post is None: + return redirect(url_for("posts.get_posts")) + + return redirect( + url_for( + "post.get", + service=post["service"], + artist_id=post["user"], + post_id=post["id"], + ) + ) + + +@random_bp.route("/artists/random") +def random_artist(): + """todo decide after random posts with redis list if its worth""" + artist = get_random_artist() + if artist is None: + return redirect(url_for("artists.list")) + + # currently we don't get random discord artists but anyway... + if artist["service"] == "discord": + return redirect(url_for("posts.discord_server", server_id=artist["id"])) + return redirect(url_for("artists.get", service=artist["service"], artist_id=artist["id"])) + + +def get_random_artist(): + artists = get_random_artist_keys(1000) + if len(artists) == 0: + return None + return random.choice(artists) diff --git a/src/pages/review_dms.py b/src/pages/review_dms.py new file mode 100644 index 0000000..98571a6 --- /dev/null +++ b/src/pages/review_dms.py @@ -0,0 +1,47 @@ +from flask import Blueprint, make_response, redirect, render_template, request, url_for + +from src.lib.dms import approve_dms, cleanup_unapproved_dms, get_unapproved_dms, clean_dms_already_approved +from src.pages.imports.types import DMPageProps +from src.types.account.account import Account +from src.types.props import SuccessProps +from src.utils.decorators import require_login + +review_dms_bp = Blueprint("review_dms", __name__) + + +@review_dms_bp.get("/account/review_dms") +@require_login +def importer_dms(user: Account): + account_id_int = int(user.id) + status = "ignored" if request.args.get("status") == "ignored" else "pending" + dms = get_unapproved_dms(account_id_int, request.args.get("status") == "ignored") + + props = DMPageProps(account_id=account_id_int, dms=dms, status=status) + + response = make_response( + render_template( + "review_dms/review_dms.html", + props=props, + ), + 200, + ) + + response.headers["Cache-Control"] = "max-age=0, private, must-revalidate" + return response + + +@review_dms_bp.post("/account/review_dms") +@require_login +def approve_importer_dms(user: Account): + props = SuccessProps(currentPage="import", redirect="/account/review_dms") + approved_hashes = request.form.getlist("approved_hashes") + delete_ignored = bool(request.form.get("delete_ignored", default=False)) + approve_dms(int(user.id), approved_hashes) + clean_dms_already_approved(int(user.id)) + cleanup_unapproved_dms(int(user.id)) + if delete_ignored: + cleanup_unapproved_dms(int(user.id), delete=True) + + response = make_response(render_template("success.html", props=props), 200) + response.headers["Cache-Control"] = "max-age=0, private, must-revalidate" + return response diff --git a/src/pages/revisions.py b/src/pages/revisions.py new file mode 100644 index 0000000..37c3c31 --- /dev/null +++ b/src/pages/revisions.py @@ -0,0 +1,37 @@ +from flask import Blueprint, make_response, redirect, render_template, url_for + +from src.config import Configuration +from src.lib.post import get_post_revisions +from src.pages.post import ready_post_props + +revisions_bp = Blueprint("revisions", __name__) + + +@revisions_bp.route("//user//post//revision/") +def get(service: str, artist_id: str, post_id: str, revision_id: str): + revisions = get_post_revisions(service, artist_id, post_id) if revision_id.isdigit() else [] + revision = next((rev for rev in revisions if rev["revision_id"] == int(revision_id)), None) + if not revision or not ( + service == revision["service"] and artist_id == revision["user"] and post_id == revision["id"] + ): + response = redirect(url_for("post.get", service=service, artist_id=artist_id, post_id=get)) + return response + + attachments, comments, previews, videos, props = ready_post_props(revision) + props["currentPage"] = "revisions" + + response = make_response( + render_template( + "post.html", + props=props, + post=revision, + comments=comments, + result_previews=previews, + result_attachments=attachments, + videos=videos, + archives_enabled=Configuration().archive_server["enabled"], + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=600" + return response diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..5e43bf1 --- /dev/null +++ b/src/server.py @@ -0,0 +1,305 @@ +import json +import os +import pathlib + +import jinja2 +from flask import Flask, g, make_response, render_template, request, send_from_directory, session +from flask.json.provider import JSONProvider + +from src.config import Configuration + +app = Flask( + __name__, + static_folder=Configuration().webserver["static_folder"], + template_folder=Configuration().webserver["template_folder"], +) + +if jinja_bytecode_cache_path := Configuration().webserver["jinja_bytecode_cache_path"]: + pathlib.Path(jinja_bytecode_cache_path).mkdir(parents=True, exist_ok=True) + app.jinja_env.bytecode_cache = jinja2.FileSystemBytecodeCache(jinja_bytecode_cache_path) + +if Configuration().open_telemetry_endpoint: + from src.internals.tracing.tracing import open_telemetry_init + + open_telemetry_init(app, Configuration().open_telemetry_endpoint) + +import datetime +import logging +import re +from datetime import timedelta +from os import getenv, listdir +from os.path import exists, join, splitext +from typing import TypedDict +from urllib.parse import urljoin, quote_plus + +import dateutil.parser +import humanize +import orjson + +from src.lib.account import is_logged_in, load_account +from src.lib.notification import count_new_notifications +from src.lib.post import get_fileserver_for_value +from src.pages.account import account_bp +from src.pages.api import api_bp +from src.pages.artists import artists_bp +from src.pages.dms import dms_bp +from src.pages.favorites import favorites_bp +from src.pages.filehaus import filehaus_bp +from src.pages.files import files_bp +from src.pages.help import help_app_bp +from src.pages.home import home_bp +from src.pages.imports import importer_page_bp +from src.pages.post import post_bp +from src.pages.posts import posts_bp +from src.pages.random_ import random_bp +from src.pages.revisions import revisions_bp +from src.pages.review_dms import review_dms_bp +from src.pages.creator_link_requests import bp as link_request_bp +from src.types.account import Account +from src.utils.utils import ( + freesites, + paysites, + render_page_data, + sanitize_html, + url_is_for_non_logged_file_extension, + parse_int, +) + +app.url_map.strict_slashes = False + +app.register_blueprint(api_bp) +app.register_blueprint(home_bp) +app.register_blueprint(artists_bp) +app.register_blueprint(random_bp) +app.register_blueprint(post_bp) +app.register_blueprint(posts_bp) +app.register_blueprint(revisions_bp) +app.register_blueprint(account_bp) +app.register_blueprint(favorites_bp) +app.register_blueprint(filehaus_bp) +app.register_blueprint(files_bp) +app.register_blueprint(importer_page_bp) +app.register_blueprint(dms_bp) +app.register_blueprint(review_dms_bp) +app.register_blueprint(help_app_bp, url_prefix="/help") +app.register_blueprint(link_request_bp) + + +app.config.update( + dict( + ENABLE_PASSWORD_VALIDATOR=True, + ENABLE_LOGIN_RATE_LIMITING=True, + SESSION_REFRESH_EACH_REQUEST=False, + SESSION_COOKIE_SAMESITE="Lax", + SECRET_KEY=Configuration().webserver["secret"], + cache_TYPE="null" if Configuration().development_mode else "simple", + CACHE_DEFAULT_TIMEOUT=None if Configuration().development_mode else 60, + SEND_FILE_MAX_AGE_DEFAULT=0, + TEMPLATES_AUTO_RELOAD=True if Configuration().development_mode else False, + ) +) + + +def file_url(file: TypedDict("FILE", {"hash": str, "ext": str})) -> str: + file_hash = file["hash"] + ext = file["ext"] + fn = f"/data/{file_hash[0:2]}/{file_hash[2:4]}/{file_hash}.{ext}" + fs = get_fileserver_for_value(fn) + return fs + fn + + +class ORJSONProvider(JSONProvider): + def __init__(self, *args, **kwargs): + self.options = kwargs + super().__init__(*args, **kwargs) + + def loads(self, s, **kwargs): + if "object_hook" in kwargs: + return json.loads(s, **kwargs) + return orjson.loads(s) + + def dumps(self, obj, **kwargs): + return orjson.dumps(obj, option=orjson.OPT_NON_STR_KEYS).decode("utf-8") + + +app.json = ORJSONProvider(app) + + +def simple_datetime(obj: str | datetime.datetime) -> str: + if isinstance(obj, str): + obj = dateutil.parser.parse(obj) + return obj.strftime("%Y-%m-%d %H:%M:%S") + + +app.jinja_options = dict(trim_blocks=True, lstrip_blocks=True) +app.jinja_env.globals.update(is_logged_in=is_logged_in) +app.jinja_env.globals.update(render_page_data=render_page_data) +app.jinja_env.filters["relative_date"] = lambda val: humanize.naturaltime(val) +app.jinja_env.filters["simple_date"] = lambda dt: dt.strftime("%Y-%m-%d") # maybe change +app.jinja_env.filters["simple_datetime"] = simple_datetime +app.jinja_env.filters["regex_match"] = lambda val, rgx: re.search(rgx, val) +app.jinja_env.filters["regex_find"] = lambda val, rgx: re.findall(rgx, val) +app.jinja_env.filters["sanitize_html"] = sanitize_html +app.jinja_env.filters["file_url"] = file_url # todo use this instead of "{{ fancard.server or '' }}{{ fancard.path }}" +app.jinja_env.filters["quote_plus"] = lambda u: quote_plus(u or "") +app.jinja_env.filters["debug"] = lambda u: print(u) or u +app.jinja_env.filters["parse_int"] = lambda val: parse_int(val) + + +if Configuration().webserver["logging"]: + logging.basicConfig( + filename="../kemono.log", + level=logging.getLevelName(Configuration().webserver["logging"]), + ) + logging.getLogger("PIL").setLevel(logging.INFO) + logging.getLogger("requests").setLevel(logging.WARNING) + logging.getLogger("urllib3").setLevel(logging.WARNING) + logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR) + +if Configuration().sentry_dsn: + import sentry_sdk + from sentry_sdk.integrations.flask import FlaskIntegration + from sentry_sdk.integrations.redis import RedisIntegration + + sentry_sdk.utils.MAX_STRING_LENGTH = 2048 + sentry_sdk.serializer.MAX_EVENT_BYTES = 10**7 + sentry_sdk.serializer.MAX_DATABAG_DEPTH = 8 + sentry_sdk.serializer.MAX_DATABAG_BREADTH = 20 + sentry_sdk.init( + integrations=[FlaskIntegration(), RedisIntegration()], + dsn=Configuration().sentry_dsn, + release=os.getenv("GIT_COMMIT_HASH") or "NOT_FOUND", + max_request_body_size="always", + max_value_length=1024 * 4, + attach_stacktrace=True, + max_breadcrumbs=1000, + send_default_pii=True, + ) + +import src.internals.cache.redis as redis +import src.internals.database.database as database + +database.init() +redis.init() + +if exists(nondynamic_folder := join(Configuration().webserver["template_folder"].replace("..", "."), "nondynamic")): + + def nondynamic_view(page_file): + def nondynamic_view_func(): + if ".html" in page_file: + return make_response( + render_template( + f"nondynamic/{page_file}", + props=dict(), + base=request.args.to_dict(), + ), + 200, + ) + else: + return send_from_directory(nondynamic_folder, page_file) + + return nondynamic_view_func + + for page in listdir(nondynamic_folder): + app.get(f"/{splitext(page)[0]}", endpoint=f"get_{splitext(page)[0]}")(nondynamic_view(page)) + + +@app.before_request +def do_init_stuff(): + app.permanent_session_lifetime = timedelta(days=3650) + + g.page_data = {} + g.request_start_time = datetime.datetime.now() + g.freesites = freesites + g.artists_or_creators = Configuration().webserver["ui"]["config"]["artists_or_creators"] + g.paysite_list = Configuration().webserver["ui"]["config"]["paysite_list"] + + g.paysites = paysites + g.origin = Configuration().webserver["site"] + g.custom_links = Configuration().webserver["ui"]["sidebar_items"] + g.custom_footer = Configuration().webserver["ui"]["footer_items"] + + # Matomo. + g.matomo_enabled = Configuration().webserver["ui"]["matomo"]["enabled"] + g.matomo_plain_code = Configuration().webserver["ui"]["matomo"]["plain_code"] + g.matomo_domain = Configuration().webserver["ui"]["matomo"]["tracking_domain"] + g.matomo_code = Configuration().webserver["ui"]["matomo"]["tracking_code"] + g.matomo_site_id = Configuration().webserver["ui"]["matomo"]["site_id"] + + # navbar + g.disable_filehaus = Configuration().webserver["ui"]["sidebar"]["disable_filehaus"] + g.disable_faq = Configuration().webserver["ui"]["sidebar"]["disable_faq"] + g.disable_dms = Configuration().webserver["ui"]["sidebar"]["disable_dms"] + + # Ads. + g.header_ad = Configuration().webserver["ui"]["ads"]["header"] + g.middle_ad = Configuration().webserver["ui"]["ads"]["middle"] + g.footer_ad = Configuration().webserver["ui"]["ads"]["footer"] + g.slider_ad = Configuration().webserver["ui"]["ads"]["slider"] + g.video_ad = Configuration().webserver["ui"]["ads"]["video"] + + # Banners. + g.banner_global = Configuration().webserver["ui"]["banner"]["global"] + g.banner_welcome = Configuration().webserver["ui"]["banner"]["welcome"] + + # Branding path prepend + g.icons_prepend = Configuration().webserver["ui"]["files_url_prepend"]["icons_base_url"] + g.banners_prepend = Configuration().webserver["ui"]["files_url_prepend"]["banners_base_url"] + g.thumbnails_prepend = Configuration().webserver["ui"]["files_url_prepend"]["thumbnails_base_url"] + + g.mascot_path = Configuration().webserver["ui"]["home"]["mascot_path"] + g.logo_path = Configuration().webserver["ui"]["home"]["logo_path"] + g.welcome_credits = Configuration().webserver["ui"]["home"]["welcome_credits"] + g.home_background_image = Configuration().webserver["ui"]["home"]["home_background_image"] + g.announcements = Configuration().webserver["ui"]["home"]["announcements"] + g.site_name = Configuration().webserver["ui"]["home"]["site_name"] + g.canonical_url = urljoin(Configuration().webserver["site"], request.path) + + session.permanent = True + session.modified = False + + if account := load_account(): + g.account = account + if Configuration().enable_notifications: + g.new_notifications_count = count_new_notifications(g.account.id) + + +@app.after_request +def do_finish_stuff(response): + if not url_is_for_non_logged_file_extension(request.path): + start_time = g.request_start_time + end_time = datetime.datetime.now() + elapsed = end_time - start_time + app.logger.debug( + f"[{end_time.strftime("%Y-%m-%d %X")}] " + f"Completed {request.method} request to {request.url} " + f"in {elapsed.microseconds / 1000}ms" + ) + response.autocorrect_location_header = False + return response + + +@app.errorhandler(413) +def upload_exceeded(error): + props = {"redirect": request.headers.get("Referer") if request.headers.get("Referer") else "/"} + limit = int(getenv("REQUESTS_IMAGES")) if getenv("REQUESTS_IMAGES") else 1048576 + props["message"] = f"Submitted file exceeds the upload limit. {limit / 1024 / 1024} MB for requests images." + return render_template("error.html", props=props), 413 + + +@app.teardown_appcontext +def close(e): + # removing account just in case + g.pop("account", None) + cursor = g.pop("cursor", None) + if cursor is not None: + cursor.close() + connection = g.pop("connection", None) + if connection is not None: + try: + pool = database.get_pool() + if not connection.autocommit: + connection.commit() + pool.putconn(connection) + except Exception: + pass diff --git a/src/types/account/__init__.py b/src/types/account/__init__.py new file mode 100644 index 0000000..83f0ff8 --- /dev/null +++ b/src/types/account/__init__.py @@ -0,0 +1,3 @@ +from .account import Account, account_roles, visible_roles +from .notification import AccountRoleChange, Notification, NotificationTypes +from .service_key import ServiceKey diff --git a/src/types/account/account.py b/src/types/account/account.py new file mode 100644 index 0000000..cdf8c2f --- /dev/null +++ b/src/types/account/account.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from datetime import datetime + +from src.internals.internal_types import DatabaseEntry + +account_roles = ["consumer", "moderator", "administrator"] +visible_roles = account_roles[:-1] + + +@dataclass +class Account(DatabaseEntry): + id: int + username: str + created_at: datetime + role: str + + +# @dataclass +# class Consumer(Account): +# pass + +# @dataclass +# class Moderator(Account): +# pass + +# @dataclass +# class Administrator(Account): +# pass + +# class Agreement: +# """ +# The user's agreement. +# """ +# name: str +# agreed_at: datetime +# version: str +# def is_outdated(self, version: str) -> bool: +# current_version = parse_version(self.version) +# new_version = parse_version(version) +# return current_version < new_version + +# class __Import: +# """ +# The user's import. +# """ +# id: str +# service: str +# approved: list[str] +# rejected: list[str] +# pending: list[str] diff --git a/src/types/account/notification.py b/src/types/account/notification.py new file mode 100644 index 0000000..ba03bb2 --- /dev/null +++ b/src/types/account/notification.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from datetime import datetime +from enum import IntEnum, unique +from typing import Optional, TypedDict + +from src.internals.internal_types import DatabaseEntry + + +@dataclass +class Notification(DatabaseEntry): + id: int + account_id: int + type: str + created_at: datetime + extra_info: Optional[TypedDict] + is_seen: bool = False + + +@unique +class NotificationTypes(IntEnum): + ACCOUNT_ROLE_CHANGE = 1 + + +class AccountRoleChange(TypedDict): + old_role: str + new_role: str diff --git a/src/types/account/service_key.py b/src/types/account/service_key.py new file mode 100644 index 0000000..9c78d17 --- /dev/null +++ b/src/types/account/service_key.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +from datetime import datetime + +from src.internals.internal_types import DatabaseEntry + + +@dataclass +class ServiceKey(DatabaseEntry): + id: int + service: str + added: datetime + dead: bool + contributor_id: int = None + encrypted_key: str = None + discord_channel_ids: str = None diff --git a/src/types/kemono.py b/src/types/kemono.py new file mode 100644 index 0000000..cf6faaa --- /dev/null +++ b/src/types/kemono.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Dict, Optional + +from src.internals.internal_types import DatabaseEntry + + +@dataclass +class Unapproved_DM(DatabaseEntry): + """ + The DM which is shown to the importing user. + """ + + hash: str + user: str + artist: dict + import_id: str + contributor_id: str + service: str + content: str + embed: Dict + file: Dict + added: datetime + published: datetime + + +@dataclass +class Approved_DM(DatabaseEntry): + """ + The public visible DM. + """ + + hash: str + user: str + service: str + content: str + embed: Dict + file: Dict + added: datetime + published: datetime + artist: Dict | None = None + + +# class __Post: +# id: str +# user: str +# service: str +# added: datetime +# published: datetime +# edited: datetime +# file: dict +# attachments: list[dict] +# title: str +# content: str +# embed: dict +# shared_file: bool + + +class User: + def __init__( + self, + id: str, + name: str, + service: str, + indexed: datetime, + updated: datetime, + count: Optional[int], + ) -> None: + self.id = id + self.name = name + self.service = service + self.indexed = indexed + self.updated = updated + self.count = count if count else None + + +# class __Favorite_Post: +# id: str +# account_id: str +# service: str +# artist_id: str +# post_id: str + +# class __Favorite_User: +# id: str +# account_id: str +# service: str +# artist_id: str + +# class __Request: +# id: str +# service: str +# user: str +# post_id: str +# title: str +# description: str +# created: datetime +# image: str +# price: float +# votes: int +# ips: str +# status: str + +# class __Log: +# log0: str +# log: list[str] +# created: datetime diff --git a/src/types/paysites/__init__.py b/src/types/paysites/__init__.py new file mode 100644 index 0000000..e650c9f --- /dev/null +++ b/src/types/paysites/__init__.py @@ -0,0 +1,32 @@ +# isort: off +from .afdian import Afdian +from .base import Paysite, Service_Post, Service_User +from .boosty import Boosty +from .candfans import CandFans +from .discord import Discord +from .dlsite import DLSite +from .fanbox import Fanbox +from .fansly import Fansly +from .fantia import Fantia +from .gumroad import Gumroad +from .onlyfans import OnlyFans +from .patreon import Patreon +from .subscribestar import Subscribestar + + +# duplicated in /client/src/utils/_index.js + + +class Paysites: + afdian = Afdian() + boosty = Boosty() + discord = Discord() + dlsite = DLSite() + fanbox = Fanbox() + fansly = Fansly() + fantia = Fantia() + gumroad = Gumroad() + onlyfans = OnlyFans() + patreon = Patreon() + subscribestar = Subscribestar() + candfans = CandFans() diff --git a/src/types/paysites/afdian.py b/src/types/paysites/afdian.py new file mode 100644 index 0000000..38090f4 --- /dev/null +++ b/src/types/paysites/afdian.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return "" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return "" + + +@dataclass +class Afdian(Paysite): + name: str = "afdian" + title: str = "Afdian" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + color: str = "#9169df" diff --git a/src/types/paysites/base.py b/src/types/paysites/base.py new file mode 100644 index 0000000..32189af --- /dev/null +++ b/src/types/paysites/base.py @@ -0,0 +1,42 @@ +from abc import abstractmethod +from dataclasses import dataclass + +from src.internals.internal_types import AbstractDataclass + + +@dataclass +class Service_User(AbstractDataclass): + """ + User-related info for a service. + """ + + @abstractmethod + def profile(self, artist: dict) -> str: + """A profile link for the service""" + + +@dataclass +class Service_Post(AbstractDataclass): + """ + Post-related info for a service. + """ + + @abstractmethod + def link(self, post_id: str, user_id: str) -> str: + """ + A profile link for the service. + Because fanbox requires `post_id` and `artist_id` for post link, all services will have to have 2 arguments for this method. + """ + + +@dataclass +class Paysite(AbstractDataclass): + """ + Holds all info related to the paysite. + """ + + name: str + title: str + user: Service_User + post: Service_Post + color: str diff --git a/src/types/paysites/boosty.py b/src/types/paysites/boosty.py new file mode 100644 index 0000000..062d4d4 --- /dev/null +++ b/src/types/paysites/boosty.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return "" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return "" + + +@dataclass +class Boosty(Paysite): + name: str = "boosty" + title: str = "Boosty" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + color: str = "#fd6035" diff --git a/src/types/paysites/candfans.py b/src/types/paysites/candfans.py new file mode 100644 index 0000000..e7708e5 --- /dev/null +++ b/src/types/paysites/candfans.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return f"https://candfans.jp/{(artist or {}).get('public_id')}" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return f"https://candfans.jp/posts/comment/show/{post_id}" + + +@dataclass +class CandFans(Paysite): + name: str = "candfans" + title: str = "CandFans" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + color: str = "#e8486c" diff --git a/src/types/paysites/discord.py b/src/types/paysites/discord.py new file mode 100644 index 0000000..ac99892 --- /dev/null +++ b/src/types/paysites/discord.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return "" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return "" + + +@dataclass +class Discord(Paysite): + """ + TODO: finish links. + """ + + name: str = "discord" + title: str = "Discord" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + color: str = "#5165f6" diff --git a/src/types/paysites/dlsite.py b/src/types/paysites/dlsite.py new file mode 100644 index 0000000..a2db390 --- /dev/null +++ b/src/types/paysites/dlsite.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return f"https://www.dlsite.com/eng/circle/profile/=/maker_id/{(artist or {}).get('id')}" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return f"https://www.dlsite.com/ecchi-eng/work/=/product_id/{post_id}" + + +@dataclass +class DLSite(Paysite): + name: str = "dlsite" + title: str = "DLsite" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + color: str = "#052a83" diff --git a/src/types/paysites/fanbox.py b/src/types/paysites/fanbox.py new file mode 100644 index 0000000..8e357f3 --- /dev/null +++ b/src/types/paysites/fanbox.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return f"https://www.pixiv.net/fanbox/creator/{(artist or {}).get('id')}" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return f"https://www.pixiv.net/fanbox/creator/{user_id}/post/{post_id}" + + +@dataclass +class Fanbox(Paysite): + name: str = "fanbox" + title: str = "Pixiv Fanbox" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + color: str = "#2c333c" diff --git a/src/types/paysites/fansly.py b/src/types/paysites/fansly.py new file mode 100644 index 0000000..050b423 --- /dev/null +++ b/src/types/paysites/fansly.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return f"https://fansly.com/{(artist or {}).get('id')}" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return f"https://fansly.com/post/{post_id}/" + + +@dataclass +class Fansly(Paysite): + name: str = "fansly" + title: str = "Fansly" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + color: str = "#2399f7" diff --git a/src/types/paysites/fantia.py b/src/types/paysites/fantia.py new file mode 100644 index 0000000..2941272 --- /dev/null +++ b/src/types/paysites/fantia.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return f"https://fantia.jp/fanclubs/{(artist or {}).get('id')}" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return f"https://fantia.jp/posts/{post_id}" + + +@dataclass +class Fantia(Paysite): + name: str = "fantia" + title: str = "Fantia" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + color: str = "#e1097f" diff --git a/src/types/paysites/gumroad.py b/src/types/paysites/gumroad.py new file mode 100644 index 0000000..cfd6aca --- /dev/null +++ b/src/types/paysites/gumroad.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return f"https://gumroad.com/{(artist or {}).get('id')}" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return f"https://gumroad.com/l/{post_id}" + + +@dataclass +class Gumroad(Paysite): + name: str = "gumroad" + title: str = "Gumroad" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + color: str = "#2b9fa4" diff --git a/src/types/paysites/onlyfans.py b/src/types/paysites/onlyfans.py new file mode 100644 index 0000000..950390d --- /dev/null +++ b/src/types/paysites/onlyfans.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return f"https://onlyfans.com/{(artist or {}).get('public_id') or (artist or {}).get('id')}" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return f"https://onlyfans.com/{post_id}/{user_id}" + + +@dataclass +class OnlyFans(Paysite): + name: str = "onlyfans" + title: str = "OnlyFans" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + color: str = "#008ccf" diff --git a/src/types/paysites/patreon.py b/src/types/paysites/patreon.py new file mode 100644 index 0000000..3f915e1 --- /dev/null +++ b/src/types/paysites/patreon.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return f"https://www.patreon.com/user?u={(artist or {}).get('id')}" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return f"https://www.patreon.com/posts/{post_id}" + + +@dataclass +class Patreon(Paysite): + name: str = "patreon" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + title: str = "Patreon" + color: str = "#fa5742" diff --git a/src/types/paysites/subscribestar.py b/src/types/paysites/subscribestar.py new file mode 100644 index 0000000..d76c6ad --- /dev/null +++ b/src/types/paysites/subscribestar.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field + +from .base import Paysite, Service_Post, Service_User + + +@dataclass +class User(Service_User): + def profile(self, artist: dict) -> str: + return f"https://subscribestar.adult/{(artist or {}).get('id')}" + + +@dataclass +class Post(Service_Post): + def link(self, post_id: str, user_id: str) -> str: + return f"https://subscribestar.adult/posts/{post_id}" + + +@dataclass +class Subscribestar(Paysite): + name: str = "subscribestar" + title: str = "SubscribeStar" + user: User = field(default_factory=User) + post: Post = field(default_factory=Post) + color: str = "#009688" diff --git a/src/types/props.py b/src/types/props.py new file mode 100644 index 0000000..7db254f --- /dev/null +++ b/src/types/props.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from src.internals.internal_types import PageProps + + +@dataclass +class SuccessProps(PageProps): + """Props for `success` template.""" + + currentPage: str + redirect: str + message: str = "Success!" diff --git a/src/utils/datetime_.py b/src/utils/datetime_.py new file mode 100644 index 0000000..10af38a --- /dev/null +++ b/src/utils/datetime_.py @@ -0,0 +1,116 @@ +"""Datetime-related helper functions""" + +import calendar +from dataclasses import dataclass +from datetime import date, datetime, time, timedelta +from typing import Literal, get_args + +import dateutil.parser +from dateutil.relativedelta import relativedelta + +PeriodScale = Literal["recent", "day", "week", "month"] + + +@dataclass +class TimeRangeInfo: + date: datetime + min_date: datetime + max_date: datetime + navigation_dates: dict[PeriodScale, tuple[date, date, date]] + range_desc: str + scale: PeriodScale + + +def get_minmax_ts( + input_date: date, scale: PeriodScale, round_to: timedelta = timedelta(minutes=30) +) -> tuple[datetime, datetime]: + match scale: + case "recent": + # ignore date + today_as_datetime = datetime.combine(datetime.utcnow().date(), datetime.min.time()) + now_as_timedelta = datetime.utcnow() - today_as_datetime + round_factor = int(round_to.total_seconds()) + rounded_seconds = round(now_as_timedelta.total_seconds() / round_factor) * round_factor + rdt = today_as_datetime + timedelta(seconds=rounded_seconds) + return rdt - timedelta(days=1), rdt + case "day": + return ( + datetime.combine(input_date, time.min), + datetime.combine(input_date, time.max), + ) + case "week": + input_date = beginning_of_week(input_date) + return ( + datetime.combine(beginning_of_week(input_date), time.min), + datetime.combine(next_week(input_date), time.min), + ) + case "month": + input_date = input_date.replace(day=1) + last_day = calendar.monthrange(input_date.year, input_date.month)[1] + return ( + datetime.combine(input_date.replace(day=1), time.min), + datetime.combine(input_date.replace(day=last_day), time.max), + ) + case _: + raise Exception("Invalid scale") + + +def surrounding_dates_for_scale(input_date: date, scale: PeriodScale) -> tuple[date, date, date]: + match scale: + case "recent": # not meaningful + delta = relativedelta(hours=24) + case "day": + delta = relativedelta(days=1) + case "week": + input_date = beginning_of_week(input_date) + delta = relativedelta(days=7) + case "month": + input_date = input_date.replace(day=1) + delta = relativedelta(months=1) + case _: + raise Exception("Invalid scale") + return input_date - delta, input_date + delta, input_date + + +def date_range_description(input_date: datetime, scale: PeriodScale) -> str: + match scale: + case "recent": + return "the past 24 hours" + case "day": + return input_date.strftime("%B %d, %Y") + case "week": + min_date = beginning_of_week(input_date) + max_date = next_week(input_date) + return f"{min_date.strftime("%B %d, %Y")} - {max_date.strftime("%B %d, %Y")}" + case "month": + return input_date.strftime("%B %Y") + + +def beginning_of_week(input_date: date) -> date: + days_to_monday = input_date.weekday() - 1 if input_date.weekday() != 0 else 6 + return input_date - timedelta(days=days_to_monday) + + +def next_week(input_datetime: date) -> date: + return beginning_of_week(input_datetime + timedelta(days=7)) + + +def parse_scale_string(date_string: str | None, scale: PeriodScale = "recent") -> tuple[TimeRangeInfo, bool]: + valid_date = True + if date_string is None: + parsed_date = datetime.now() + else: + try: + parsed_date = dateutil.parser.parse(date_string) + except dateutil.parser.ParserError: + parsed_date = datetime.now() + valid_date = False + + nav: dict[PeriodScale, tuple[date, date, date]] = {} + for period in get_args(PeriodScale): + nav[period] = surrounding_dates_for_scale(parsed_date.date(), period) + desc = date_range_description(parsed_date, scale) + + (min_date, max_date) = get_minmax_ts(parsed_date, scale) + + return TimeRangeInfo(parsed_date, min_date, max_date, nav, desc, scale), valid_date diff --git a/src/utils/decorators.py b/src/utils/decorators.py new file mode 100644 index 0000000..9f2a639 --- /dev/null +++ b/src/utils/decorators.py @@ -0,0 +1,17 @@ +from functools import wraps +from flask import g, jsonify, request, redirect, url_for + + +def require_login(f): + @wraps(f) + def wrapper(*args, **kwargs): + # todo: avoid using g. + if g.get("account") is None: + if "/api" in request.path: + # kinda ugly but avoid redirecting non-html requests + return {}, 401 + else: + return redirect(url_for("account.get_login", location=request.path)) + kwargs["user"] = kwargs.get("user", g.account) + return f(*args, **kwargs) + return wrapper diff --git a/src/utils/random_.py b/src/utils/random_.py new file mode 100644 index 0000000..4d9d242 --- /dev/null +++ b/src/utils/random_.py @@ -0,0 +1,31 @@ +import string +from datetime import datetime, timedelta +from random import choice, randint + +varchar_vocab = string.ascii_letters + string.digits +text_vocab = string.printable +unix_epoch_start = datetime.fromtimestamp(30256871) + + +def generate_random_string(min_length: int = 5, max_length: int = 250, vocabulary: str = varchar_vocab) -> str: + string_length = randint(min_length, max_length) + result_string = "".join(choice(vocabulary) for char in range(string_length)) + + return result_string + + +def generate_random_number(min: int = 1, max: int = 999999) -> int: + return randint(min, max) + + +def generate_random_boolean() -> bool: + result = bool(randint(0, 1)) + + return result + + +def generate_random_date(min_date: datetime = unix_epoch_start, max_date: datetime = datetime.now()) -> datetime: + int_delta = int((max_date - min_date).total_seconds()) + random_second = randint(0, int_delta) + random_date = min_date + timedelta(seconds=random_second) + return random_date diff --git a/src/utils/utils.py b/src/utils/utils.py new file mode 100644 index 0000000..4162b46 --- /dev/null +++ b/src/utils/utils.py @@ -0,0 +1,359 @@ +import hashlib +import json +import logging +import random +import re +from base64 import b64decode +from datetime import date, datetime +from typing import Any, Generator, Iterable, Optional, SupportsIndex, TypeVar, cast +from urllib.parse import parse_qs, urlencode, urlparse, urlsplit, urlunsplit + +import flask +import httpx +import requests +from nh3 import nh3 +from retry import retry +from typing_extensions import Protocol + +from src.types.paysites import Paysites + +try: + from itertools import batched # type: ignore +except ImportError: # remove in 3.12 + from itertools import islice + + A = TypeVar("A") + + def batched(iterable: Iterable[A], chunk_size: int) -> Generator[tuple[A, ...], None, None]: + iterator = iter(iterable) + while chunk := tuple(islice(iterator, chunk_size)): + yield chunk + + +freesites = { + "kemono": { + "title": "Kemono", + "user": { + "profile": lambda service, user_id: f"/{service}/{"server" if service == "discord" else "user"}/{user_id}", + "icon": lambda service, user_id: flask.g.icons_prepend + f"/icons/{service}/{user_id}", + "banner": lambda service, user_id: flask.g.banners_prepend + f"/banners/{service}/{user_id}", + }, + "post": {"link": lambda service, user_id, post_id: f"/{service}/user/{user_id}/post/{post_id}"}, + } +} + +paysites = Paysites() + +images_pattern = re.compile(r"\.(gif|jpe?g|jpe|png|webp)$", re.IGNORECASE) + + +def set_query_parameter(url: str, new_params: dict[str:str]) -> str: + scheme, netloc, path, query_string, fragment = urlsplit(url) + query_params = parse_qs(query_string) + + for param_name, param_value in new_params.items(): + query_params[param_name] = [param_value] + + new_query_string = urlencode(query_params, doseq=True) + return urlunsplit((scheme, netloc, path, new_query_string, fragment)) + + +def get_query_parameters_dict( + request_to_extract: flask.Request, on_errors: str = "ignore", clean_query_string: bool = False +) -> dict[str, str]: + """helps prevent invalid encoded url params""" + parsed_url = urlparse(request_to_extract.url) + try: + query_params = parse_qs(parsed_url.query, errors=on_errors) + if on_errors == "ignore": + request_to_extract.query_string = ( + request_to_extract.query_string.decode(errors=on_errors).replace("\x00", "").encode() + ) + except UnicodeEncodeError: + return {} + return {key: value[0].replace("\x00", "") for key, value in query_params.items()} + + +def relative_time(date: datetime) -> str: + """Take a datetime and return its "age" as a string. + The age can be in second, minute, hour, day, month or year. Only the + biggest unit is considered, e.g. if it's 2 days and 3 hours, "2 days" will + be returned. + Make sure date is not in the future, or else it won't work. + Original Gist by 'zhangsen' @ https://gist.github.com/zhangsen/1199964 + """ + + def formatn(n: int, s: str) -> str: + """Add "s" if it's plural""" + + if n == 1: + return "1 %s" % s + return "%d %ss" % (n, s) + + def qnr(a: float, b: float): + """Return quotient and remaining""" + + return a / b, a % b + + class FormatDelta: + def __init__(self, dt: datetime): + now = datetime.now() + delta = now - dt + self.day = delta.days + self.second = delta.seconds + self.year, self.day = qnr(self.day, 365) + self.month, self.day = qnr(self.day, 30) + self.hour, self.second = qnr(self.second, 3600) + self.minute, self.second = qnr(self.second, 60) + + def format(self): + for period in ["year", "month", "day", "hour", "minute", "second"]: + n = getattr(self, period) + if n >= 1: + return "{0} ago".format(formatn(n, period)) + return "just now" + + return FormatDelta(date).format() + + +def allowed_file(mime: str, accepted: list[str]) -> bool: + return any(x in mime for x in accepted) + + +def url_is_for_non_logged_file_extension(path) -> bool: + parts = path.split("/") + if len(parts) == 0: + return False + + blocked_extensions = ["js", "css", "ico", "svg"] + for extension in blocked_extensions: + if ("." + extension) in parts[-1]: + return True + return False + + +def sort_dict_list_by(list_var, key, reverse=False): + return sorted(list_var, key=lambda v: (v[key] is None, v[key]), reverse=reverse) + + +def restrict_value(value, allowed, default=None): + if value not in allowed: + return default + return value + + +class GetItem(Protocol): + def __getitem__(self: "GetItem", key: SupportsIndex | slice, /) -> Any: + ... + + def __len__(self: "GetItem") -> int: + ... + + +T = TypeVar("T", bound=GetItem) + + +def take(num: int, list_var: T) -> T: + if len(list_var) <= num: + return list_var + return list_var[:num] + + +def offset_list(num: int, list_var: T) -> T: + if len(list_var) <= num: + return cast(T, []) + return list_var[num:] + + +def step_int(i: int, mod: int, fix=False) -> int | None: + """this ensures or fixes a number to make sure it is composed of multiple of mod""" + if i % mod: + if not fix: + return None + return i - (i % mod) + return i + + +def limit_int(i: int, limit: int) -> int: + if i > limit: + return limit + return i + + +def parse_int(string: str | None, default=0) -> int: + # XXX string shouldn't be nilable here + if string is None: + return default + try: + return_value = int(string) + if return_value > 9223372036854775807: # 2**63 largest int postgres accepts + return default + return return_value + except ValueError: + return default + + +def positive_or_none(input_int: int | None) -> int | None: + if input_int is None or input_int < 0: + return None + return input_int + + +def render_page_data() -> str: + return json.dumps(flask.g.page_data) + + +def generate_import_id(data) -> str: + salt = str(random.randrange(0, 1000)) + return take(16, hashlib.sha256((data + salt).encode("utf-8")).hexdigest()) + + +def decode_b64(s: Optional[str]) -> Optional[str]: + if s is not None: + return b64decode(s.encode()).decode() + + +# allow_iframe only for Fanbox +allowed_tags = { + "a", + "abbr", + "acronym", + "b", + "blockquote", + "code", + "em", + "i", + "li", + "ol", + "strong", + "ul", + "img", + "br", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "p", + "div", + "span", + "ul", + "ol", + "li", +} +allowed_tags_with_iframe = { + *allowed_tags, + "iframe" +} +allowed_attributes = { + "a": {"href", "title"}, + "acronym": {"title"}, + "abbr": {"title"}, + "img": {"src"}, +} +allowed_attributes_with_iframe = { + **allowed_attributes, + "iframe": {"src"}, +} + + +def sanitize_html(html: str, allow_iframe: bool = False) -> str: + local_allowed_tags = allowed_tags + local_allowed_attributes = allowed_attributes + if allow_iframe: + # Some Fanbox embeds require the usage of IFrame + local_allowed_tags = allowed_tags_with_iframe + local_allowed_attributes = allowed_attributes_with_iframe + return nh3.clean(html, attributes=local_allowed_attributes, tags=local_allowed_tags) + + +def fixed_size_batches(input_list: list, batch_sizes: list[int]) -> list[list]: + batches = [input_list] + for batch_size in batch_sizes: + if len(batches[-1]) > batch_size: + batches.extend(list(batched(batches.pop(), batch_size))) + elif len(batches[-1]) == batch_size: + break + + return batches + + +def date_isoformat_serializer(obj): + if isinstance(obj, (datetime, date)): + return obj.isoformat() + raise TypeError(f"Type {type(obj)} is not JSON serializable") + + +def parse_offset(s: Optional[str], limit: int = 50) -> int: + i = abs(parse_int(s)) + return round(i / limit) * limit + + +CONNECTION_ATTEMPTS = 5 + + +class CustomHTTPAdapter(requests.adapters.HTTPAdapter): + """https://github.com/psf/requests/issues/4664""" + + def send(self, *args, **kwargs): + saved = Exception("Saved exception") + for attempt in range(CONNECTION_ATTEMPTS): + if attempt > 1: + logging.exception("Retrying after connection error") + try: + return super().send(*args, **kwargs) + except ConnectionError as exc: + saved = exc + continue + + raise saved + + +clear_cache_session = requests.Session() +clear_cache_session.mount( + "https://", + CustomHTTPAdapter(pool_connections=20, max_retries=CONNECTION_ATTEMPTS, pool_maxsize=64), +) + + +def clear_web_cache(path): + from src.config import Configuration + ban_url = Configuration().ban_url + if not ban_url: + logging.info(f"Skipped clearing web cache because not url for path {path}") + return + if isinstance(ban_url, str): + ban_urls = [ban_url] + else: + ban_urls = ban_url + for ban_url in ban_urls: + if path[0] != "/": + path = "/" + path + full_path = f"{ban_url}{path}" + try: + retry(tries=5, delay=0.2, backoff=1.5)(clear_cache_session.request)("BAN", full_path) + logging.info(f"Cleared web cache for path {path}") + except Exception: + logging.exception("Failed to clear cache", extra=dict(path=path, full_path=full_path)) + + +def clear_web_cache_for_creator(service, creator_id): + clear_web_cache(f"/{service}/user/{creator_id}") + + +def clear_web_cache_for_creator_links(service, creator_id): + clear_web_cache(f"/{service}/user/{creator_id}/links") + + +def clear_web_cache_for_creator_dms(service, creator_id): + clear_web_cache(f"/{service}/user/{creator_id}/dms") + + +def clear_web_cache_for_post(service, creator_id, post_id): + clear_web_cache(f"/{service}/user/{creator_id}/post/{post_id}") + + +def clear_web_cache_for_archive(file_hash): + clear_web_cache(f"/posts/archives/{file_hash}") + diff --git a/uwsgi.ini b/uwsgi.ini new file mode 100644 index 0000000..859fc06 --- /dev/null +++ b/uwsgi.ini @@ -0,0 +1,26 @@ +[uwsgi] +strict = true +master = true +vacuum = true +single-interpreter = true +die-on-term = true +need-app = true +lazy-apps = true +enable-threads = true + +; Worker recycling. +max-requests = 0 +max-worker-lifetime = 3600 +reload-on-rss = 2048 +worker-reload-mercy = 60 + +; Harakiri (Longest amount of time an active worker can run without killing itself) +harakiri = 60 + +manage-script-name = true +mount = /=src.server:app + +listen = 1000 + +post-buffering = true +buffer-size = 8192 diff --git a/yoyo.ini b/yoyo.ini new file mode 100644 index 0000000..a0a0768 --- /dev/null +++ b/yoyo.ini @@ -0,0 +1,5 @@ +[DEFAULT] +sources = db/migrations +migration_table = _yoyo_migration +batch_mode = off +verbosity = 0