ctrl k
Showing first 81 files as there are too many
  • .dockerignore
    ■ ■ ■ ■ ■ ■
     1 +test/
     2 +archiver/
     3 +storage/
     4 +dist/
     5 +# client/
     6 +__pycache__
     7 +venv
     8 +.env
     9 +.env.*
     10 +.git
     11 +.gitignore
     12 +Dockerfile
     13 +Dockerfile*
     14 +docker-compose*
     15 +.dockerignore
     16 +*.code-workspace
     17 +.vscode
     18 +node_modules
     19 +npm-debug.log
     20 +README.md
     21 + 
  • .editorconfig
    ■ ■ ■ ■ ■ ■
     1 +https://EditorConfig.org
     2 + 
     3 +root = true
     4 + 
     5 +[*.py]
     6 +indent_style = space
     7 +indent_size = 4
     8 + 
     9 +[*.html]
     10 +indent_style = space
     11 +indent_size = 2
     12 + 
     13 +[*.svg]
     14 +indent_style = space
     15 +indent_size = 2
     16 + 
     17 +[*.js]
     18 +indent_style = space
     19 +indent_size = 2
     20 + 
     21 +[*.scss]
     22 +indent_style = space
     23 +indent_size = 2
     24 + 
  • .flake8
    ■ ■ ■ ■ ■ ■
     1 +[flake8]
     2 +ignore =
     3 + # Some template strings can easily go over the limit
     4 + E501
     5 + # Doesn't work well with __init__.py files
     6 + F401
     7 + # flake struggles with endpoints returning tuples on exceptions
     8 + E722
     9 +exclude =
     10 + .git,
     11 + __pycache__,
     12 + venv
     13 +max-complexity = 14
     14 + 
     15 +[pycodestyle]
     16 +max_line_length = 120
     17 +ignore =
     18 + E501
     19 + 
  • .github/workflows/build.yml
    ■ ■ ■ ■ ■ ■
     1 +on:
     2 + push:
     3 + paths-ignore:
     4 + - '**.md'
     5 + workflow_dispatch:
     6 + 
     7 +jobs:
     8 + build:
     9 + runs-on: [ubuntu-latest]
     10 + steps:
     11 + -
     12 + name: Checkout
     13 + uses: actions/checkout@v2
     14 + -
     15 + name: Docker meta
     16 + id: docker_meta
     17 + uses: crazy-max/ghaction-docker-meta@v2
     18 + with:
     19 + images: |
     20 + ghcr.io/OpenYiff/Kemono2
     21 + tags: |
     22 + type=ref,event=branch
     23 + -
     24 + name: Set up QEMU
     25 + uses: docker/setup-qemu-action@v1
     26 + -
     27 + name: Set up Docker Buildx
     28 + uses: docker/setup-buildx-action@v1
     29 + with:
     30 + buildkitd-flags: "--debug"
     31 + -
     32 + name: Available platforms
     33 + run: echo ${{ steps.buildx.outputs.platforms }}
     34 + -
     35 + name: Login to GHCR
     36 + if: github.event_name != 'pull_request'
     37 + uses: docker/login-action@v1
     38 + with:
     39 + registry: ghcr.io
     40 + username: ${{ secrets.GHCR_USERNAME }}
     41 + password: ${{ secrets.GHCR_TOKEN }}
     42 + -
     43 + name: Build container
     44 + uses: docker/build-push-action@v2
     45 + with:
     46 + context: .
     47 + push: ${{ github.event_name != 'pull_request' }}
     48 + file: ./docker/Dockerfile
     49 + platforms: linux/amd64
     50 + tags: ${{ steps.docker_meta.outputs.tags }}
     51 + 
  • .gitmodules
    ■ ■ ■ ■ ■
  • .pre-commit-config.yaml
    ■ ■ ■ ■ ■ ■
     1 +repos:
     2 +- repo: https://github.com/pre-commit/pre-commit-hooks
     3 + rev: 38b88246ccc552bffaaf54259d064beeee434539 # frozen: v4.0.1
     4 + hooks:
     5 + - id: trailing-whitespace
     6 + - id: end-of-file-fixer
     7 + - id: check-yaml
     8 + - id: check-added-large-files
     9 +- repo: https://github.com/pycqa/flake8
     10 + rev: 'cbeb4c9c4137cff1568659fcc48e8b85cddd0c8d' # frozen: 4.0.1
     11 + hooks:
     12 + - id: flake8
     13 +- repo: https://github.com/pre-commit/mirrors-autopep8
     14 + rev: '7d14f78422aef2153a90e33373d2515bcc99038d' # frozen: v1.5.7
     15 + hooks:
     16 + - id: autopep8
     17 + 
  • .vscode/extensions.json
    ■ ■ ■ ■ ■ ■
     1 +{
     2 + "recommendations": [
     3 + "ms-python.python",
     4 + "ms-azuretools.vscode-docker",
     5 + "ms-python.vscode-pylance",
     6 + "ms-toolsai.jupyter",
     7 + "rioj7.command-variable"
     8 + ]
     9 +}
     10 + 
  • .vscode/launch.json
    ■ ■ ■ ■ ■ ■
     1 +{
     2 + // Use IntelliSense to learn about possible attributes.
     3 + // Hover to view descriptions of existing attributes.
     4 + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
     5 + "version": "0.2.0",
     6 + "configurations": [
     7 + {
     8 + "name": "Python: Module",
     9 + "type": "python",
     10 + "request": "launch",
     11 + "module": "${command:extension.commandvariable.file.relativeDirDots}.${fileBasenameNoExtension}"
     12 + },
     13 + {
     14 + "name": "Python: Current File",
     15 + "type": "python",
     16 + "request": "launch",
     17 + "program": "${file}",
     18 + "console": "integratedTerminal",
     19 + "env": {
     20 + "PYTHONPATH": "${workspaceFolder}"
     21 + },
     22 + }
     23 + ]
     24 +}
     25 + 
  • .vscode/settings.json
    ■ ■ ■ ■ ■ ■
     1 +{
     2 + "json.schemas": [
     3 + {
     4 + "fileMatch": [
     5 + "/json-schema/*.schema.json"
     6 + ],
     7 + "url": "/json-schema/_meta.schema.json"
     8 + },
     9 + {
     10 + "fileMatch": [
     11 + "/json-schema/api/*.json"
     12 + ],
     13 + "url": "/json-schema/api.schema.json"
     14 + },
     15 + ],
     16 + "[json]": {
     17 + "editor.tabSize": 2,
     18 + },
     19 + "[jsonc]": {
     20 + "editor.tabSize": 2,
     21 + },
     22 + "[python]": {
     23 + "editor.tabSize": 4,
     24 + },
     25 + "python.linting.enabled": true,
     26 + "python.linting.flake8Enabled": true,
     27 + "python.linting.flake8Args": [
     28 + "--verbose"
     29 + ],
     30 + "python.formatting.autopep8Args": [],
     31 + "python.sortImports.args": [
     32 + "--multi-line",
     33 + "3"
     34 + ],
     35 + "python.analysis.diagnosticSeverityOverrides": {
     36 + // flake8 already covers it
     37 + "reportUndefinedVariable": "none",
     38 + }
     39 +}
     40 + 
  • LICENSE
    ■ ■ ■ ■ ■ ■
     1 + GNU AFFERO GENERAL PUBLIC LICENSE
     2 + Version 3, 19 November 2007
     3 + 
     4 + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
     5 + Everyone is permitted to copy and distribute verbatim copies
     6 + of this license document, but changing it is not allowed.
     7 + 
     8 + Preamble
     9 + 
     10 + The GNU Affero General Public License is a free, copyleft license for
     11 +software and other kinds of works, specifically designed to ensure
     12 +cooperation with the community in the case of network server software.
     13 + 
     14 + The licenses for most software and other practical works are designed
     15 +to take away your freedom to share and change the works. By contrast,
     16 +our General Public Licenses are intended to guarantee your freedom to
     17 +share and change all versions of a program--to make sure it remains free
     18 +software for all its users.
     19 + 
     20 + When we speak of free software, we are referring to freedom, not
     21 +price. Our General Public Licenses are designed to make sure that you
     22 +have the freedom to distribute copies of free software (and charge for
     23 +them if you wish), that you receive source code or can get it if you
     24 +want it, that you can change the software or use pieces of it in new
     25 +free programs, and that you know you can do these things.
     26 + 
     27 + Developers that use our General Public Licenses protect your rights
     28 +with two steps: (1) assert copyright on the software, and (2) offer
     29 +you this License which gives you legal permission to copy, distribute
     30 +and/or modify the software.
     31 + 
     32 + A secondary benefit of defending all users' freedom is that
     33 +improvements made in alternate versions of the program, if they
     34 +receive widespread use, become available for other developers to
     35 +incorporate. Many developers of free software are heartened and
     36 +encouraged by the resulting cooperation. However, in the case of
     37 +software used on network servers, this result may fail to come about.
     38 +The GNU General Public License permits making a modified version and
     39 +letting the public access it on a server without ever releasing its
     40 +source code to the public.
     41 + 
     42 + The GNU Affero General Public License is designed specifically to
     43 +ensure that, in such cases, the modified source code becomes available
     44 +to the community. It requires the operator of a network server to
     45 +provide the source code of the modified version running there to the
     46 +users of that server. Therefore, public use of a modified version, on
     47 +a publicly accessible server, gives the public access to the source
     48 +code of the modified version.
     49 + 
     50 + An older license, called the Affero General Public License and
     51 +published by Affero, was designed to accomplish similar goals. This is
     52 +a different license, not a version of the Affero GPL, but Affero has
     53 +released a new version of the Affero GPL which permits relicensing under
     54 +this license.
     55 + 
     56 + The precise terms and conditions for copying, distribution and
     57 +modification follow.
     58 + 
     59 + TERMS AND CONDITIONS
     60 + 
     61 + 0. Definitions.
     62 + 
     63 + "This License" refers to version 3 of the GNU Affero General Public License.
     64 + 
     65 + "Copyright" also means copyright-like laws that apply to other kinds of
     66 +works, such as semiconductor masks.
     67 + 
     68 + "The Program" refers to any copyrightable work licensed under this
     69 +License. Each licensee is addressed as "you". "Licensees" and
     70 +"recipients" may be individuals or organizations.
     71 + 
     72 + To "modify" a work means to copy from or adapt all or part of the work
     73 +in a fashion requiring copyright permission, other than the making of an
     74 +exact copy. The resulting work is called a "modified version" of the
     75 +earlier work or a work "based on" the earlier work.
     76 + 
     77 + A "covered work" means either the unmodified Program or a work based
     78 +on the Program.
     79 + 
     80 + To "propagate" a work means to do anything with it that, without
     81 +permission, would make you directly or secondarily liable for
     82 +infringement under applicable copyright law, except executing it on a
     83 +computer or modifying a private copy. Propagation includes copying,
     84 +distribution (with or without modification), making available to the
     85 +public, and in some countries other activities as well.
     86 + 
     87 + To "convey" a work means any kind of propagation that enables other
     88 +parties to make or receive copies. Mere interaction with a user through
     89 +a computer network, with no transfer of a copy, is not conveying.
     90 + 
     91 + An interactive user interface displays "Appropriate Legal Notices"
     92 +to the extent that it includes a convenient and prominently visible
     93 +feature that (1) displays an appropriate copyright notice, and (2)
     94 +tells the user that there is no warranty for the work (except to the
     95 +extent that warranties are provided), that licensees may convey the
     96 +work under this License, and how to view a copy of this License. If
     97 +the interface presents a list of user commands or options, such as a
     98 +menu, a prominent item in the list meets this criterion.
     99 + 
     100 + 1. Source Code.
     101 + 
     102 + The "source code" for a work means the preferred form of the work
     103 +for making modifications to it. "Object code" means any non-source
     104 +form of a work.
     105 + 
     106 + A "Standard Interface" means an interface that either is an official
     107 +standard defined by a recognized standards body, or, in the case of
     108 +interfaces specified for a particular programming language, one that
     109 +is widely used among developers working in that language.
     110 + 
     111 + The "System Libraries" of an executable work include anything, other
     112 +than the work as a whole, that (a) is included in the normal form of
     113 +packaging a Major Component, but which is not part of that Major
     114 +Component, and (b) serves only to enable use of the work with that
     115 +Major Component, or to implement a Standard Interface for which an
     116 +implementation is available to the public in source code form. A
     117 +"Major Component", in this context, means a major essential component
     118 +(kernel, window system, and so on) of the specific operating system
     119 +(if any) on which the executable work runs, or a compiler used to
     120 +produce the work, or an object code interpreter used to run it.
     121 + 
     122 + The "Corresponding Source" for a work in object code form means all
     123 +the source code needed to generate, install, and (for an executable
     124 +work) run the object code and to modify the work, including scripts to
     125 +control those activities. However, it does not include the work's
     126 +System Libraries, or general-purpose tools or generally available free
     127 +programs which are used unmodified in performing those activities but
     128 +which are not part of the work. For example, Corresponding Source
     129 +includes interface definition files associated with source files for
     130 +the work, and the source code for shared libraries and dynamically
     131 +linked subprograms that the work is specifically designed to require,
     132 +such as by intimate data communication or control flow between those
     133 +subprograms and other parts of the work.
     134 + 
     135 + The Corresponding Source need not include anything that users
     136 +can regenerate automatically from other parts of the Corresponding
     137 +Source.
     138 + 
     139 + The Corresponding Source for a work in source code form is that
     140 +same work.
     141 + 
     142 + 2. Basic Permissions.
     143 + 
     144 + All rights granted under this License are granted for the term of
     145 +copyright on the Program, and are irrevocable provided the stated
     146 +conditions are met. This License explicitly affirms your unlimited
     147 +permission to run the unmodified Program. The output from running a
     148 +covered work is covered by this License only if the output, given its
     149 +content, constitutes a covered work. This License acknowledges your
     150 +rights of fair use or other equivalent, as provided by copyright law.
     151 + 
     152 + You may make, run and propagate covered works that you do not
     153 +convey, without conditions so long as your license otherwise remains
     154 +in force. You may convey covered works to others for the sole purpose
     155 +of having them make modifications exclusively for you, or provide you
     156 +with facilities for running those works, provided that you comply with
     157 +the terms of this License in conveying all material for which you do
     158 +not control copyright. Those thus making or running the covered works
     159 +for you must do so exclusively on your behalf, under your direction
     160 +and control, on terms that prohibit them from making any copies of
     161 +your copyrighted material outside their relationship with you.
     162 + 
     163 + Conveying under any other circumstances is permitted solely under
     164 +the conditions stated below. Sublicensing is not allowed; section 10
     165 +makes it unnecessary.
     166 + 
     167 + 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
     168 + 
     169 + No covered work shall be deemed part of an effective technological
     170 +measure under any applicable law fulfilling obligations under article
     171 +11 of the WIPO copyright treaty adopted on 20 December 1996, or
     172 +similar laws prohibiting or restricting circumvention of such
     173 +measures.
     174 + 
     175 + When you convey a covered work, you waive any legal power to forbid
     176 +circumvention of technological measures to the extent such circumvention
     177 +is effected by exercising rights under this License with respect to
     178 +the covered work, and you disclaim any intention to limit operation or
     179 +modification of the work as a means of enforcing, against the work's
     180 +users, your or third parties' legal rights to forbid circumvention of
     181 +technological measures.
     182 + 
     183 + 4. Conveying Verbatim Copies.
     184 + 
     185 + You may convey verbatim copies of the Program's source code as you
     186 +receive it, in any medium, provided that you conspicuously and
     187 +appropriately publish on each copy an appropriate copyright notice;
     188 +keep intact all notices stating that this License and any
     189 +non-permissive terms added in accord with section 7 apply to the code;
     190 +keep intact all notices of the absence of any warranty; and give all
     191 +recipients a copy of this License along with the Program.
     192 + 
     193 + You may charge any price or no price for each copy that you convey,
     194 +and you may offer support or warranty protection for a fee.
     195 + 
     196 + 5. Conveying Modified Source Versions.
     197 + 
     198 + You may convey a work based on the Program, or the modifications to
     199 +produce it from the Program, in the form of source code under the
     200 +terms of section 4, provided that you also meet all of these conditions:
     201 + 
     202 + a) The work must carry prominent notices stating that you modified
     203 + it, and giving a relevant date.
     204 + 
     205 + b) The work must carry prominent notices stating that it is
     206 + released under this License and any conditions added under section
     207 + 7. This requirement modifies the requirement in section 4 to
     208 + "keep intact all notices".
     209 + 
     210 + c) You must license the entire work, as a whole, under this
     211 + License to anyone who comes into possession of a copy. This
     212 + License will therefore apply, along with any applicable section 7
     213 + additional terms, to the whole of the work, and all its parts,
     214 + regardless of how they are packaged. This License gives no
     215 + permission to license the work in any other way, but it does not
     216 + invalidate such permission if you have separately received it.
     217 + 
     218 + d) If the work has interactive user interfaces, each must display
     219 + Appropriate Legal Notices; however, if the Program has interactive
     220 + interfaces that do not display Appropriate Legal Notices, your
     221 + work need not make them do so.
     222 + 
     223 + A compilation of a covered work with other separate and independent
     224 +works, which are not by their nature extensions of the covered work,
     225 +and which are not combined with it such as to form a larger program,
     226 +in or on a volume of a storage or distribution medium, is called an
     227 +"aggregate" if the compilation and its resulting copyright are not
     228 +used to limit the access or legal rights of the compilation's users
     229 +beyond what the individual works permit. Inclusion of a covered work
     230 +in an aggregate does not cause this License to apply to the other
     231 +parts of the aggregate.
     232 + 
     233 + 6. Conveying Non-Source Forms.
     234 + 
     235 + You may convey a covered work in object code form under the terms
     236 +of sections 4 and 5, provided that you also convey the
     237 +machine-readable Corresponding Source under the terms of this License,
     238 +in one of these ways:
     239 + 
     240 + a) Convey the object code in, or embodied in, a physical product
     241 + (including a physical distribution medium), accompanied by the
     242 + Corresponding Source fixed on a durable physical medium
     243 + customarily used for software interchange.
     244 + 
     245 + b) Convey the object code in, or embodied in, a physical product
     246 + (including a physical distribution medium), accompanied by a
     247 + written offer, valid for at least three years and valid for as
     248 + long as you offer spare parts or customer support for that product
     249 + model, to give anyone who possesses the object code either (1) a
     250 + copy of the Corresponding Source for all the software in the
     251 + product that is covered by this License, on a durable physical
     252 + medium customarily used for software interchange, for a price no
     253 + more than your reasonable cost of physically performing this
     254 + conveying of source, or (2) access to copy the
     255 + Corresponding Source from a network server at no charge.
     256 + 
     257 + c) Convey individual copies of the object code with a copy of the
     258 + written offer to provide the Corresponding Source. This
     259 + alternative is allowed only occasionally and noncommercially, and
     260 + only if you received the object code with such an offer, in accord
     261 + with subsection 6b.
     262 + 
     263 + d) Convey the object code by offering access from a designated
     264 + place (gratis or for a charge), and offer equivalent access to the
     265 + Corresponding Source in the same way through the same place at no
     266 + further charge. You need not require recipients to copy the
     267 + Corresponding Source along with the object code. If the place to
     268 + copy the object code is a network server, the Corresponding Source
     269 + may be on a different server (operated by you or a third party)
     270 + that supports equivalent copying facilities, provided you maintain
     271 + clear directions next to the object code saying where to find the
     272 + Corresponding Source. Regardless of what server hosts the
     273 + Corresponding Source, you remain obligated to ensure that it is
     274 + available for as long as needed to satisfy these requirements.
     275 + 
     276 + e) Convey the object code using peer-to-peer transmission, provided
     277 + you inform other peers where the object code and Corresponding
     278 + Source of the work are being offered to the general public at no
     279 + charge under subsection 6d.
     280 + 
     281 + A separable portion of the object code, whose source code is excluded
     282 +from the Corresponding Source as a System Library, need not be
     283 +included in conveying the object code work.
     284 + 
     285 + A "User Product" is either (1) a "consumer product", which means any
     286 +tangible personal property which is normally used for personal, family,
     287 +or household purposes, or (2) anything designed or sold for incorporation
     288 +into a dwelling. In determining whether a product is a consumer product,
     289 +doubtful cases shall be resolved in favor of coverage. For a particular
     290 +product received by a particular user, "normally used" refers to a
     291 +typical or common use of that class of product, regardless of the status
     292 +of the particular user or of the way in which the particular user
     293 +actually uses, or expects or is expected to use, the product. A product
     294 +is a consumer product regardless of whether the product has substantial
     295 +commercial, industrial or non-consumer uses, unless such uses represent
     296 +the only significant mode of use of the product.
     297 + 
     298 + "Installation Information" for a User Product means any methods,
     299 +procedures, authorization keys, or other information required to install
     300 +and execute modified versions of a covered work in that User Product from
     301 +a modified version of its Corresponding Source. The information must
     302 +suffice to ensure that the continued functioning of the modified object
     303 +code is in no case prevented or interfered with solely because
     304 +modification has been made.
     305 + 
     306 + If you convey an object code work under this section in, or with, or
     307 +specifically for use in, a User Product, and the conveying occurs as
     308 +part of a transaction in which the right of possession and use of the
     309 +User Product is transferred to the recipient in perpetuity or for a
     310 +fixed term (regardless of how the transaction is characterized), the
     311 +Corresponding Source conveyed under this section must be accompanied
     312 +by the Installation Information. But this requirement does not apply
     313 +if neither you nor any third party retains the ability to install
     314 +modified object code on the User Product (for example, the work has
     315 +been installed in ROM).
     316 + 
     317 + The requirement to provide Installation Information does not include a
     318 +requirement to continue to provide support service, warranty, or updates
     319 +for a work that has been modified or installed by the recipient, or for
     320 +the User Product in which it has been modified or installed. Access to a
     321 +network may be denied when the modification itself materially and
     322 +adversely affects the operation of the network or violates the rules and
     323 +protocols for communication across the network.
     324 + 
     325 + Corresponding Source conveyed, and Installation Information provided,
     326 +in accord with this section must be in a format that is publicly
     327 +documented (and with an implementation available to the public in
     328 +source code form), and must require no special password or key for
     329 +unpacking, reading or copying.
     330 + 
     331 + 7. Additional Terms.
     332 + 
     333 + "Additional permissions" are terms that supplement the terms of this
     334 +License by making exceptions from one or more of its conditions.
     335 +Additional permissions that are applicable to the entire Program shall
     336 +be treated as though they were included in this License, to the extent
     337 +that they are valid under applicable law. If additional permissions
     338 +apply only to part of the Program, that part may be used separately
     339 +under those permissions, but the entire Program remains governed by
     340 +this License without regard to the additional permissions.
     341 + 
     342 + When you convey a copy of a covered work, you may at your option
     343 +remove any additional permissions from that copy, or from any part of
     344 +it. (Additional permissions may be written to require their own
     345 +removal in certain cases when you modify the work.) You may place
     346 +additional permissions on material, added by you to a covered work,
     347 +for which you have or can give appropriate copyright permission.
     348 + 
     349 + Notwithstanding any other provision of this License, for material you
     350 +add to a covered work, you may (if authorized by the copyright holders of
     351 +that material) supplement the terms of this License with terms:
     352 + 
     353 + a) Disclaiming warranty or limiting liability differently from the
     354 + terms of sections 15 and 16 of this License; or
     355 + 
     356 + b) Requiring preservation of specified reasonable legal notices or
     357 + author attributions in that material or in the Appropriate Legal
     358 + Notices displayed by works containing it; or
     359 + 
     360 + c) Prohibiting misrepresentation of the origin of that material, or
     361 + requiring that modified versions of such material be marked in
     362 + reasonable ways as different from the original version; or
     363 + 
     364 + d) Limiting the use for publicity purposes of names of licensors or
     365 + authors of the material; or
     366 + 
     367 + e) Declining to grant rights under trademark law for use of some
     368 + trade names, trademarks, or service marks; or
     369 + 
     370 + f) Requiring indemnification of licensors and authors of that
     371 + material by anyone who conveys the material (or modified versions of
     372 + it) with contractual assumptions of liability to the recipient, for
     373 + any liability that these contractual assumptions directly impose on
     374 + those licensors and authors.
     375 + 
     376 + All other non-permissive additional terms are considered "further
     377 +restrictions" within the meaning of section 10. If the Program as you
     378 +received it, or any part of it, contains a notice stating that it is
     379 +governed by this License along with a term that is a further
     380 +restriction, you may remove that term. If a license document contains
     381 +a further restriction but permits relicensing or conveying under this
     382 +License, you may add to a covered work material governed by the terms
     383 +of that license document, provided that the further restriction does
     384 +not survive such relicensing or conveying.
     385 + 
     386 + If you add terms to a covered work in accord with this section, you
     387 +must place, in the relevant source files, a statement of the
     388 +additional terms that apply to those files, or a notice indicating
     389 +where to find the applicable terms.
     390 + 
     391 + Additional terms, permissive or non-permissive, may be stated in the
     392 +form of a separately written license, or stated as exceptions;
     393 +the above requirements apply either way.
     394 + 
     395 + 8. Termination.
     396 + 
     397 + You may not propagate or modify a covered work except as expressly
     398 +provided under this License. Any attempt otherwise to propagate or
     399 +modify it is void, and will automatically terminate your rights under
     400 +this License (including any patent licenses granted under the third
     401 +paragraph of section 11).
     402 + 
     403 + However, if you cease all violation of this License, then your
     404 +license from a particular copyright holder is reinstated (a)
     405 +provisionally, unless and until the copyright holder explicitly and
     406 +finally terminates your license, and (b) permanently, if the copyright
     407 +holder fails to notify you of the violation by some reasonable means
     408 +prior to 60 days after the cessation.
     409 + 
     410 + Moreover, your license from a particular copyright holder is
     411 +reinstated permanently if the copyright holder notifies you of the
     412 +violation by some reasonable means, this is the first time you have
     413 +received notice of violation of this License (for any work) from that
     414 +copyright holder, and you cure the violation prior to 30 days after
     415 +your receipt of the notice.
     416 + 
     417 + Termination of your rights under this section does not terminate the
     418 +licenses of parties who have received copies or rights from you under
     419 +this License. If your rights have been terminated and not permanently
     420 +reinstated, you do not qualify to receive new licenses for the same
     421 +material under section 10.
     422 + 
     423 + 9. Acceptance Not Required for Having Copies.
     424 + 
     425 + You are not required to accept this License in order to receive or
     426 +run a copy of the Program. Ancillary propagation of a covered work
     427 +occurring solely as a consequence of using peer-to-peer transmission
     428 +to receive a copy likewise does not require acceptance. However,
     429 +nothing other than this License grants you permission to propagate or
     430 +modify any covered work. These actions infringe copyright if you do
     431 +not accept this License. Therefore, by modifying or propagating a
     432 +covered work, you indicate your acceptance of this License to do so.
     433 + 
     434 + 10. Automatic Licensing of Downstream Recipients.
     435 + 
     436 + Each time you convey a covered work, the recipient automatically
     437 +receives a license from the original licensors, to run, modify and
     438 +propagate that work, subject to this License. You are not responsible
     439 +for enforcing compliance by third parties with this License.
     440 + 
     441 + An "entity transaction" is a transaction transferring control of an
     442 +organization, or substantially all assets of one, or subdividing an
     443 +organization, or merging organizations. If propagation of a covered
     444 +work results from an entity transaction, each party to that
     445 +transaction who receives a copy of the work also receives whatever
     446 +licenses to the work the party's predecessor in interest had or could
     447 +give under the previous paragraph, plus a right to possession of the
     448 +Corresponding Source of the work from the predecessor in interest, if
     449 +the predecessor has it or can get it with reasonable efforts.
     450 + 
     451 + You may not impose any further restrictions on the exercise of the
     452 +rights granted or affirmed under this License. For example, you may
     453 +not impose a license fee, royalty, or other charge for exercise of
     454 +rights granted under this License, and you may not initiate litigation
     455 +(including a cross-claim or counterclaim in a lawsuit) alleging that
     456 +any patent claim is infringed by making, using, selling, offering for
     457 +sale, or importing the Program or any portion of it.
     458 + 
     459 + 11. Patents.
     460 + 
     461 + A "contributor" is a copyright holder who authorizes use under this
     462 +License of the Program or a work on which the Program is based. The
     463 +work thus licensed is called the contributor's "contributor version".
     464 + 
     465 + A contributor's "essential patent claims" are all patent claims
     466 +owned or controlled by the contributor, whether already acquired or
     467 +hereafter acquired, that would be infringed by some manner, permitted
     468 +by this License, of making, using, or selling its contributor version,
     469 +but do not include claims that would be infringed only as a
     470 +consequence of further modification of the contributor version. For
     471 +purposes of this definition, "control" includes the right to grant
     472 +patent sublicenses in a manner consistent with the requirements of
     473 +this License.
     474 + 
     475 + Each contributor grants you a non-exclusive, worldwide, royalty-free
     476 +patent license under the contributor's essential patent claims, to
     477 +make, use, sell, offer for sale, import and otherwise run, modify and
     478 +propagate the contents of its contributor version.
     479 + 
     480 + In the following three paragraphs, a "patent license" is any express
     481 +agreement or commitment, however denominated, not to enforce a patent
     482 +(such as an express permission to practice a patent or covenant not to
     483 +sue for patent infringement). To "grant" such a patent license to a
     484 +party means to make such an agreement or commitment not to enforce a
     485 +patent against the party.
     486 + 
     487 + If you convey a covered work, knowingly relying on a patent license,
     488 +and the Corresponding Source of the work is not available for anyone
     489 +to copy, free of charge and under the terms of this License, through a
     490 +publicly available network server or other readily accessible means,
     491 +then you must either (1) cause the Corresponding Source to be so
     492 +available, or (2) arrange to deprive yourself of the benefit of the
     493 +patent license for this particular work, or (3) arrange, in a manner
     494 +consistent with the requirements of this License, to extend the patent
     495 +license to downstream recipients. "Knowingly relying" means you have
     496 +actual knowledge that, but for the patent license, your conveying the
     497 +covered work in a country, or your recipient's use of the covered work
     498 +in a country, would infringe one or more identifiable patents in that
     499 +country that you have reason to believe are valid.
     500 + 
     501 + If, pursuant to or in connection with a single transaction or
     502 +arrangement, you convey, or propagate by procuring conveyance of, a
     503 +covered work, and grant a patent license to some of the parties
     504 +receiving the covered work authorizing them to use, propagate, modify
     505 +or convey a specific copy of the covered work, then the patent license
     506 +you grant is automatically extended to all recipients of the covered
     507 +work and works based on it.
     508 + 
     509 + A patent license is "discriminatory" if it does not include within
     510 +the scope of its coverage, prohibits the exercise of, or is
     511 +conditioned on the non-exercise of one or more of the rights that are
     512 +specifically granted under this License. You may not convey a covered
     513 +work if you are a party to an arrangement with a third party that is
     514 +in the business of distributing software, under which you make payment
     515 +to the third party based on the extent of your activity of conveying
     516 +the work, and under which the third party grants, to any of the
     517 +parties who would receive the covered work from you, a discriminatory
     518 +patent license (a) in connection with copies of the covered work
     519 +conveyed by you (or copies made from those copies), or (b) primarily
     520 +for and in connection with specific products or compilations that
     521 +contain the covered work, unless you entered into that arrangement,
     522 +or that patent license was granted, prior to 28 March 2007.
     523 + 
     524 + Nothing in this License shall be construed as excluding or limiting
     525 +any implied license or other defenses to infringement that may
     526 +otherwise be available to you under applicable patent law.
     527 + 
     528 + 12. No Surrender of Others' Freedom.
     529 + 
     530 + If conditions are imposed on you (whether by court order, agreement or
     531 +otherwise) that contradict the conditions of this License, they do not
     532 +excuse you from the conditions of this License. If you cannot convey a
     533 +covered work so as to satisfy simultaneously your obligations under this
     534 +License and any other pertinent obligations, then as a consequence you may
     535 +not convey it at all. For example, if you agree to terms that obligate you
     536 +to collect a royalty for further conveying from those to whom you convey
     537 +the Program, the only way you could satisfy both those terms and this
     538 +License would be to refrain entirely from conveying the Program.
     539 + 
     540 + 13. Remote Network Interaction; Use with the GNU General Public License.
     541 + 
     542 + Notwithstanding any other provision of this License, if you modify the
     543 +Program, your modified version must prominently offer all users
     544 +interacting with it remotely through a computer network (if your version
     545 +supports such interaction) an opportunity to receive the Corresponding
     546 +Source of your version by providing access to the Corresponding Source
     547 +from a network server at no charge, through some standard or customary
     548 +means of facilitating copying of software. This Corresponding Source
     549 +shall include the Corresponding Source for any work covered by version 3
     550 +of the GNU General Public License that is incorporated pursuant to the
     551 +following paragraph.
     552 + 
     553 + Notwithstanding any other provision of this License, you have
     554 +permission to link or combine any covered work with a work licensed
     555 +under version 3 of the GNU General Public License into a single
     556 +combined work, and to convey the resulting work. The terms of this
     557 +License will continue to apply to the part which is the covered work,
     558 +but the work with which it is combined will remain governed by version
     559 +3 of the GNU General Public License.
     560 + 
     561 + 14. Revised Versions of this License.
     562 + 
     563 + The Free Software Foundation may publish revised and/or new versions of
     564 +the GNU Affero General Public License from time to time. Such new versions
     565 +will be similar in spirit to the present version, but may differ in detail to
     566 +address new problems or concerns.
     567 + 
     568 + Each version is given a distinguishing version number. If the
     569 +Program specifies that a certain numbered version of the GNU Affero General
     570 +Public License "or any later version" applies to it, you have the
     571 +option of following the terms and conditions either of that numbered
     572 +version or of any later version published by the Free Software
     573 +Foundation. If the Program does not specify a version number of the
     574 +GNU Affero General Public License, you may choose any version ever published
     575 +by the Free Software Foundation.
     576 + 
     577 + If the Program specifies that a proxy can decide which future
     578 +versions of the GNU Affero General Public License can be used, that proxy's
     579 +public statement of acceptance of a version permanently authorizes you
     580 +to choose that version for the Program.
     581 + 
     582 + Later license versions may give you additional or different
     583 +permissions. However, no additional obligations are imposed on any
     584 +author or copyright holder as a result of your choosing to follow a
     585 +later version.
     586 + 
     587 + 15. Disclaimer of Warranty.
     588 + 
     589 + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
     590 +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
     591 +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
     592 +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
     593 +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
     594 +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
     595 +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
     596 +ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
     597 + 
     598 + 16. Limitation of Liability.
     599 + 
     600 + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
     601 +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
     602 +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
     603 +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
     604 +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
     605 +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
     606 +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
     607 +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
     608 +SUCH DAMAGES.
     609 + 
     610 + 17. Interpretation of Sections 15 and 16.
     611 + 
     612 + If the disclaimer of warranty and limitation of liability provided
     613 +above cannot be given local legal effect according to their terms,
     614 +reviewing courts shall apply local law that most closely approximates
     615 +an absolute waiver of all civil liability in connection with the
     616 +Program, unless a warranty or assumption of liability accompanies a
     617 +copy of the Program in return for a fee.
     618 + 
     619 + END OF TERMS AND CONDITIONS
     620 + 
     621 + How to Apply These Terms to Your New Programs
     622 + 
     623 + If you develop a new program, and you want it to be of the greatest
     624 +possible use to the public, the best way to achieve this is to make it
     625 +free software which everyone can redistribute and change under these terms.
     626 + 
     627 + To do so, attach the following notices to the program. It is safest
     628 +to attach them to the start of each source file to most effectively
     629 +state the exclusion of warranty; and each file should have at least
     630 +the "copyright" line and a pointer to where the full notice is found.
     631 + 
     632 + <one line to give the program's name and a brief idea of what it does.>
     633 + Copyright (C) <year> <name of author>
     634 + 
     635 + This program is free software: you can redistribute it and/or modify
     636 + it under the terms of the GNU Affero General Public License as published
     637 + by the Free Software Foundation, either version 3 of the License, or
     638 + (at your option) any later version.
     639 + 
     640 + This program is distributed in the hope that it will be useful,
     641 + but WITHOUT ANY WARRANTY; without even the implied warranty of
     642 + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
     643 + GNU Affero General Public License for more details.
     644 + 
     645 + You should have received a copy of the GNU Affero General Public License
     646 + along with this program. If not, see <https://www.gnu.org/licenses/>.
     647 + 
     648 +Also add information on how to contact you by electronic and paper mail.
     649 + 
     650 + If your software can interact with users remotely through a computer
     651 +network, you should also make sure that it provides a way for users to
     652 +get its source. For example, if your program is a web application, its
     653 +interface could display a "Source" link that leads users to an archive
     654 +of the code. There are many ways you could offer source, and different
     655 +solutions will be better for different programs; see section 13 for the
     656 +specific requirements.
     657 + 
     658 + You should also get your employer (if you work as a programmer) or school,
     659 +if any, to sign a "copyright disclaimer" for the program, if necessary.
     660 +For more information on this, and how to apply and follow the GNU AGPL, see
     661 +<https://www.gnu.org/licenses/>.
  • Nekohouse.sublime-project
    ■ ■ ■ ■ ■
     1 +{
     2 + "python_interpreter": "venv/bin/python3",
     3 + "auto_python_builder_enabled": true
     4 +}
     5 + 
  • README.md
    ■ ■ ■ ■ ■ ■
     1 + 
     2 +<div align = center>
     3 + 
     4 +<img
     5 + src = 'client/static/kemono-logo.svg'
     6 + width = 120
     7 +/>
     8 + 
     9 +# Kemono Project
     10 + 
     11 +*Frontend designed for Paysite leaking.*
     12 + 
     13 +<br>
     14 +<br>
     15 + 
     16 +[![Button Website]][Website]
     17 + 
     18 +[![Button Setup]][Setup]
     19 +[![Button FAQ]][FAQ]
     20 + 
     21 +[![Button Develop]][Develop]
     22 + 
     23 +<br>
     24 +<br>
     25 + 
     26 +<img
     27 + src = 'resources/Preview.png'
     28 + width = 700
     29 +/>
     30 + 
     31 +</div>
     32 + 
     33 +<br>
     34 + 
     35 + 
     36 +<!----------------------------------------------------------------------------->
     37 + 
     38 +[Website]: https://kemono.party/
     39 + 
     40 +[Develop]: docs/Develop.md
     41 +[Setup]: docs/Setup.md
     42 +[FAQ]: docs/FAQ.md
     43 + 
     44 + 
     45 +<!---------------------------------[ Buttons ]--------------------------------->
     46 + 
     47 +[Button Website]: https://img.shields.io/badge/Website-e6702f?style=for-the-badge&logoColor=white&logo=FirefoxBrowser
     48 + 
     49 +[Button Develop]: https://img.shields.io/badge/Develop-3955A3?style=for-the-badge&logoColor=white&logo=VisualStudioCode
     50 +[Button Setup]: https://img.shields.io/badge/Setup-3EAAAF?style=for-the-badge&logoColor=white&logo=GitBook
     51 +[Button FAQ]: https://img.shields.io/badge/FAQ-569A31?style=for-the-badge&logoColor=white&logo=AskUbuntu
     52 + 
  • client/.dockerignore
    ■ ■ ■ ■ ■ ■
     1 +# webpack output
     2 +**/dev
     3 +**/dist
     4 + 
     5 +**/.classpath
     6 +**/.dockerignore
     7 +**/.env
     8 +**/.git
     9 +**/.gitignore
     10 +**/.project
     11 +**/.settings
     12 +**/.toolstarget
     13 +**/.vs
     14 +**/.vscode
     15 +**/*.code-workspace
     16 +**/*.*proj.user
     17 +**/*.dbmdl
     18 +**/*.jfm
     19 +**/azds.yaml
     20 +**/charts
     21 +**/docker-compose*
     22 +**/compose*
     23 +**/Dockerfile*
     24 +**/node_modules
     25 +**/npm-debug.log
     26 +**/obj
     27 +**/secrets.dev.yaml
     28 +**/values.dev.yaml
     29 +README.md
     30 + 
  • client/.vscode/extensions.json
    ■ ■ ■ ■ ■ ■
     1 +{
     2 + "recommendations": []
     3 +}
     4 + 
  • client/.vscode/settings.json
    ■ ■ ■ ■ ■ ■
     1 +{
     2 + "files.exclude": {
     3 + "node_modules": true,
     4 + },
     5 + // this option does work and is required for emmet in jinja to work
     6 + "files.associations": {
     7 + "*.html": "jinja-html"
     8 + },
     9 + "emmet.includeLanguages": {
     10 + "jinja-html": "html"
     11 + },
     12 + "search.exclude": {
     13 + "**/node_modules": true,
     14 + "**/bower_components": true,
     15 + "**/*.code-search": true,
     16 + "**/dev": true,
     17 + "**/dist": true
     18 + },
     19 + "javascript.preferences.importModuleSpecifierEnding": "js",
     20 + "javascript.preferences.quoteStyle": "double",
     21 + "javascript.format.semicolons": "insert",
     22 + "[jinja-html]": {
     23 + "editor.tabSize": 2
     24 + },
     25 + "[javascript]": {
     26 + "editor.tabSize": 2
     27 + },
     28 + "[scss]": {
     29 + "editor.tabSize": 2
     30 + },
     31 +}
     32 + 
  • client/Dockerfile
    ■ ■ ■ ■ ■ ■
     1 +# syntax=docker/dockerfile:1
     2 + 
     3 +FROM node:16.14
     4 + 
     5 +ENV NODE_ENV=production
     6 + 
     7 +WORKDIR /app
     8 + 
     9 +COPY ["package.json", "package-lock.json", "/app/"]
     10 + 
     11 +RUN npm install -g npm
     12 +RUN npm ci --also=dev
     13 + 
     14 +COPY . /app
     15 + 
     16 +CMD [ "npm", "run", "build" ]
  • client/Dockerfile.dev
    ■ ■ ■ ■ ■ ■
     1 +# syntax=docker/dockerfile:1
     2 + 
     3 +FROM node:12.22
     4 + 
     5 +ENV NODE_ENV=development
     6 + 
     7 +WORKDIR /app
     8 + 
     9 +COPY ["package.json", "package-lock.json*", "./"]
     10 + 
     11 +RUN npm install
     12 + 
     13 +COPY . .
     14 + 
     15 +CMD ["npm", "run", "dev"]
     16 + 
  • client/configs/build-templates.js
    ■ ■ ■ ■ ■ ■
     1 +const path = require("path");
     2 +const fse = require("fs-extra");
     3 +const HTMLWebpackPlugin = require("html-webpack-plugin");
     4 + 
     5 +/**
     6 + * @typedef BuildOptions
     7 + * @property {string} fileExtension
     8 + * @property {string} outputPrefix
     9 + * @property {HTMLWebpackPlugin.Options} pluginOptions Webpack plugin options.
     10 + */
     11 + 
     12 +/** */
     13 +class TemplateFile {
     14 + /**
     15 + * @param {fse.Dirent} dirent
     16 + * @param {string} path Absolute path to the file.
     17 + */
     18 + constructor(dirent, path) {
     19 + this.dirent = dirent;
     20 + this.path = path;
     21 + }
     22 +}
     23 + 
     24 +/**
     25 + * Builds an array of HTML webpack plugins from the provided folder.
     26 + * @param {string} basePath Absolute path to the template folder.
     27 + * @param {BuildOptions} options Build optons.
     28 + */
     29 + function buildHTMLWebpackPluginsRecursive(basePath, options) {
     30 + /**
     31 + * @type {HTMLWebpackPlugin[]}
     32 + */
     33 + const plugins = [];
     34 + const files = walkFolder(basePath);
     35 + 
     36 + files.forEach(( file ) => {
     37 + const isTemplateFile = file.dirent.isFile() && file.path.endsWith(`${options.fileExtension}`);
     38 + 
     39 + if (isTemplateFile) {
     40 + const outputBase = path.relative(basePath, file.path);
     41 + const outputPath = path.join(path.basename(basePath), outputBase);
     42 + 
     43 + const webpackPlugin = new HTMLWebpackPlugin({
     44 + ...options.pluginOptions,
     45 + template: file.path,
     46 + filename: outputPath,
     47 + });
     48 +
     49 + plugins.push(webpackPlugin);
     50 + }
     51 + 
     52 + });
     53 + 
     54 + return plugins;
     55 +}
     56 + 
     57 +/**
     58 + * @param {string} folderPath Absolute path to the folder.
     59 + * @param {TemplateFile[]} files
     60 + */
     61 +function walkFolder(folderPath, files = [], currentCount = 0) {
     62 + const nestedLimit = 1000;
     63 + const folderContents = fse.readdirSync(folderPath, {withFileTypes: true});
     64 + 
     65 + folderContents.forEach((entry) => {
     66 + const file = entry.isFile() && entry;
     67 + const folder = entry.isDirectory() && entry;
     68 + 
     69 + if (file) {
     70 + const filePath = path.join(folderPath, file.name);
     71 + files.push(new TemplateFile(file, filePath));
     72 + return;
     73 + }
     74 + 
     75 + if (folder) {
     76 + currentCount++;
     77 + 
     78 + if (currentCount > nestedLimit) {
     79 + throw new Error(`The folder at "${folderPath}" contains more than ${nestedLimit} folders.`);
     80 + }
     81 + 
     82 + const newFolderPath = path.join(folderPath, folder.name);
     83 + 
     84 + return walkFolder(newFolderPath, files, currentCount);
     85 + }
     86 + 
     87 + });
     88 + 
     89 + return files;
     90 +}
     91 + 
     92 +module.exports = {
     93 + buildHTMLWebpackPluginsRecursive
     94 +}
     95 + 
  • client/configs/emmet/snippets.json
    ■ ■ ■ ■ ■ ■
     1 +{
     2 + "html": {
     3 + "snippets": {
     4 + }
     5 + }
     6 +}
     7 + 
  • client/configs/vars.js
    ■ ■ ■ ■ ■ ■
     1 +const path = require("path");
     2 + 
     3 +require('dotenv').config({
     4 + path: path.resolve(__dirname, "..", "..")
     5 +});
     6 + 
     7 +const kemonoSite = process.env.KEMONO_SITE || "http://localhost:5000";
     8 +const nodeEnv = process.env.NODE_ENV || "production"
     9 + 
     10 +module.exports = {
     11 + kemonoSite,
     12 + nodeEnv,
     13 +}
     14 + 
  • client/jsconfig.json
    ■ ■ ■ ■ ■ ■
     1 +{
     2 + "compilerOptions": {
     3 + "baseUrl": ".",
     4 + "module": "commonJS",
     5 + "target": "es2015",
     6 + "moduleResolution": "node"
     7 + },
     8 + "exclude": ["node_modules", "dist", "dev", "src"]
     9 +}
  • client/package-lock.json
    Diff is too large to be displayed.
  • client/package.json
    ■ ■ ■ ■ ■ ■
     1 +{
     2 + "name": "kemono-2-client",
     3 + "version": "0.2.1",
     4 + "description": "frontend for kemono 2",
     5 + "private": true,
     6 + "scripts": {
     7 + "predev": "rimraf dev/*",
     8 + "dev": "webpack serve --config webpack.dev.js",
     9 + "build": "webpack --config webpack.prod.js"
     10 + },
     11 + "keywords": [],
     12 + "author": "BassOfBass",
     13 + "license": "ISC",
     14 + "dependencies": {
     15 + "@babel/runtime": "^7.13.10",
     16 + "@uppy/core": "^3.0.1",
     17 + "@uppy/dashboard": "^3.0.1",
     18 + "@uppy/form": "^3.0.0",
     19 + "@uppy/tus": "^3.0.1",
     20 + "date-fns": "^2.20.1",
     21 + "fluid-player": "^3.12.0",
     22 + "fsevents": "^2.3.3",
     23 + "inter-ui": "^3.19.3",
     24 + "oboe": "^2.1.5",
     25 + "purecss": "^3.0.0",
     26 + "unfetch": "^4.1.0",
     27 + "unraw": "^2.0.0"
     28 + },
     29 + "devDependencies": {
     30 + "@babel/core": "^7.13.15",
     31 + "@babel/plugin-transform-runtime": "^7.13.15",
     32 + "@babel/preset-env": "^7.13.15",
     33 + "babel-loader": "^8.2.2",
     34 + "buffer": "^6.0.3",
     35 + "copy-webpack-plugin": "^8.1.1",
     36 + "css-loader": "^5.2.1",
     37 + "dotenv": "^8.2.0",
     38 + "fs-extra": "^10.0.0",
     39 + "html-webpack-plugin": "^5.3.1",
     40 + "mini-css-extract-plugin": "^1.4.1",
     41 + "postcss": "^8.2.9",
     42 + "postcss-loader": "^5.2.0",
     43 + "postcss-preset-env": "^6.7.0",
     44 + "rimraf": "^3.0.2",
     45 + "sass": "^1.32.8",
     46 + "sass-loader": "^11.0.1",
     47 + "stream-browserify": "^3.0.0",
     48 + "style-loader": "^2.0.0",
     49 + "webpack": "^5.31.2",
     50 + "webpack-cli": "^4.6.0",
     51 + "webpack-dev-server": "^3.11.2",
     52 + "webpack-manifest-plugin": "^3.1.1",
     53 + "webpack-merge": "^5.7.3"
     54 + }
     55 +}
     56 + 
  • client/src/api/_index.js
    ■ ■ ■ ■ ■ ■
     1 +export { kemonoAPI } from "./kemono/_index";
     2 +export { paysitesAPI } from "./paysites/_index";
     3 + 
  • client/src/api/kemono/_index.js
    ■ ■ ■ ■ ■ ■
     1 +import { favorites } from "./favorites";
     2 +import { posts } from "./posts";
     3 +import { api } from "./api";
     4 + 
     5 +/**
     6 + * @type {KemonoAPI}
     7 + */
     8 +export const kemonoAPI = {
     9 + favorites,
     10 + posts,
     11 + api
     12 +}
     13 + 
  • client/src/api/kemono/api.js
    ■ ■ ■ ■ ■ ■
     1 +import { KemonoError } from "@wp/utils";
     2 +import { kemonoFetch } from "./kemono-fetch";
     3 + 
     4 +export const api = {
     5 + bans,
     6 + bannedArtist,
     7 + creators,
     8 + logs
     9 +};
     10 + 
     11 +async function bans() {
     12 + try {
     13 + const response = await kemonoFetch('/api/bans', { method: "GET" });
     14 + 
     15 + if (!response || !response.ok) {
     16 + 
     17 + alert(new KemonoError(6));
     18 + return null;
     19 + }
     20 + 
     21 + /**
     22 + * @type {KemonoAPI.API.BanItem[]}
     23 + */
     24 + const banItems = await response.json();
     25 + 
     26 + return banItems;
     27 + 
     28 + } catch (error) {
     29 + console.error(error);
     30 + }
     31 +}
     32 + 
     33 +/**
     34 + * @param {string} id
     35 + * @param {string} service
     36 + */
     37 +async function bannedArtist(id, service) {
     38 + const params = new URLSearchParams([
     39 + ["service", service ],
     40 + ]).toString();
     41 + 
     42 + try {
     43 + const response = await kemonoFetch(`/api/lookup/cache/${id}?${params}`);
     44 + 
     45 + if (!response || !response.ok) {
     46 + alert(new KemonoError(7));
     47 + return null;
     48 + }
     49 + 
     50 + /**
     51 + * @type {KemonoAPI.API.BannedArtist}
     52 + */
     53 + const artist = await response.json();
     54 + 
     55 + return artist;
     56 + 
     57 + } catch (error) {
     58 + console.error(error);
     59 + }
     60 +}
     61 + 
     62 +async function creators() {
     63 + try {
     64 + const response = await kemonoFetch('/api/creators', { method: "GET" });
     65 + 
     66 + if (!response || !response.ok) {
     67 + 
     68 + alert(new KemonoError(8));
     69 + return null;
     70 + }
     71 + 
     72 + /**
     73 + * @type {KemonoAPI.User[]}
     74 + */
     75 + const artists = await response.json();
     76 + 
     77 + return artists;
     78 + 
     79 + } catch (error) {
     80 + console.error(error);
     81 + }
     82 +}
     83 + 
     84 +async function logs(importID) {
     85 + try {
     86 + const response = await kemonoFetch(`/api/logs/${importID}`, { method: "GET" });
     87 + 
     88 + if (!response || !response.ok) {
     89 + alert(new KemonoError(9));
     90 + return null;
     91 + }
     92 + 
     93 + /**
     94 + * @type {KemonoAPI.API.LogItem[]}
     95 + */
     96 + const logs = await response.json();
     97 + 
     98 + return logs;
     99 + 
     100 + } catch (error) {
     101 + console.error(error);
     102 + }
     103 +}
     104 + 
  • client/src/api/kemono/favorites.js
    ■ ■ ■ ■ ■ ■
     1 +import { KemonoError } from "@wp/utils";
     2 +import { kemonoFetch } from "./kemono-fetch";
     3 + 
     4 +/**
     5 + * @type {KemonoAPI.Favorites}
     6 + */
     7 +export const favorites = {
     8 + retrieveFavoriteArtists,
     9 + favoriteArtist,
     10 + unfavoriteArtist,
     11 + retrieveFavoritePosts,
     12 + favoritePost,
     13 + unfavoritePost
     14 +};
     15 + 
     16 +async function retrieveFavoriteArtists() {
     17 + const params = new URLSearchParams([
     18 + ["type", "artist"]
     19 + ]).toString();
     20 + 
     21 + try {
     22 + const response = await kemonoFetch(`/api/favorites?${params}`);
     23 + 
     24 + if (!response || !response.ok) {
     25 + throw new Error(`Error ${response.status}: ${response.statusText}`);
     26 + }
     27 + /**
     28 + * @type {string}
     29 + */
     30 + const favs = await response.text();
     31 + return favs;
     32 + 
     33 + } catch (error) {
     34 + console.error(error);
     35 + }
     36 + 
     37 +}
     38 + 
     39 +/**
     40 + * @param {string} service
     41 + * @param {string} userID
     42 + */
     43 +async function favoriteArtist(service, userID) {
     44 + try {
     45 + const response = await kemonoFetch(
     46 + `/favorites/artist/${service}/${userID}`,
     47 + { method: "POST" }
     48 + );
     49 + 
     50 + if (!response || !response.ok) {
     51 + alert(new KemonoError(3));
     52 + return false;
     53 + }
     54 + 
     55 + return true;
     56 + 
     57 + } catch (error) {
     58 + console.error(error);
     59 + }
     60 +}
     61 + 
     62 +/**
     63 + * @param {string} service
     64 + * @param {string} userID
     65 + */
     66 +async function unfavoriteArtist(service, userID) {
     67 + try {
     68 + const response = await kemonoFetch(
     69 + `/favorites/artist/${service}/${userID}`,
     70 + { method: "DELETE" }
     71 + );
     72 + 
     73 + if (!response || !response.ok) {
     74 + alert(new KemonoError(4));
     75 + return false;
     76 + }
     77 + 
     78 + return true;
     79 + 
     80 + } catch (error) {
     81 + console.error(error);
     82 + }
     83 +}
     84 + 
     85 +async function retrieveFavoritePosts() {
     86 + const params = new URLSearchParams([
     87 + ["type", "post"]
     88 + ]).toString();
     89 + 
     90 + try {
     91 + const response = await kemonoFetch(`/api/favorites?${params}`);
     92 + 
     93 + if (!response || !response.ok) {
     94 + throw new Error(`Error ${response.status}: ${response.statusText}`);
     95 + }
     96 + 
     97 + /**
     98 + * @type {KemonoAPI.Post[]}
     99 + */
     100 + const favs = await response.json();
     101 + /**
     102 + * @type {KemonoAPI.Favorites.Post[]}
     103 + */
     104 + const transformedFavs = favs.map((post) => {
     105 + return {
     106 + id: post.id,
     107 + service:post.service,
     108 + user: post.user
     109 + }
     110 + });
     111 + 
     112 + return JSON.stringify(transformedFavs);
     113 +
     114 + } catch (error) {
     115 + console.error(error);
     116 + }
     117 +}
     118 + 
     119 +/**
     120 + * @param {string} service
     121 + * @param {string} user
     122 + * @param {string} post_id
     123 + */
     124 +async function favoritePost(service, user, post_id) {
     125 + try {
     126 + const response = await kemonoFetch(
     127 + `/favorites/post/${service}/${user}/${post_id}`,
     128 + { method: 'POST' }
     129 + );
     130 + 
     131 + if (!response || !response.ok) {
     132 + alert(new KemonoError(1));
     133 + return false;
     134 + }
     135 + 
     136 + return true;
     137 + 
     138 + } catch (error) {
     139 + console.error(error);
     140 + }
     141 +}
     142 + 
     143 +/**
     144 + * @param {string} service
     145 + * @param {string} user
     146 + * @param {string} post_id
     147 + */
     148 +async function unfavoritePost(service, user, post_id) {
     149 + try {
     150 + const response = await kemonoFetch(
     151 + `/favorites/post/${service}/${user}/${post_id}`,
     152 + { method: "DELETE" }
     153 + );
     154 + 
     155 + if (!response || !response.ok) {
     156 + alert(new KemonoError(2));
     157 + return false;
     158 + }
     159 + 
     160 + return true;
     161 + 
     162 + } catch (error) {
     163 + console.error(error);
     164 + }
     165 +}
  • client/src/api/kemono/kemono-fetch.js
    ■ ■ ■ ■ ■ ■
     1 +import { isLoggedIn } from "@wp/js/account";
     2 + 
     3 +/**
     4 + * Generic request for Kemono API.
     5 + * @param {RequestInfo} endpoint
     6 + * @param {RequestInit} options
     7 + * @returns {Promise<Response>}
     8 + */
     9 + export async function kemonoFetch(endpoint, options) {
     10 + try {
     11 + const response = await fetch(endpoint, options);
     12 + 
     13 + // doing this because the server returns `401` before redirecting
     14 + // in case of favs
     15 + if (response.status === 401) {
     16 + // server logged the account out
     17 + if (isLoggedIn) {
     18 + localStorage.removeItem('logged_in');
     19 + localStorage.removeItem('favs');
     20 + localStorage.removeItem('post_favs');
     21 + location.href = '/account/logout';
     22 + return;
     23 + }
     24 + const loginURL = new URL("/account/login", location.origin).toString();
     25 + location = addURLParam(loginURL, "redir", location.pathname);
     26 + return;
     27 + }
     28 + 
     29 + return response;
     30 + 
     31 + } catch (error) {
     32 + console.error(`Kemono request error: ${error}`);
     33 + }
     34 +}
     35 + 
     36 +/**
     37 + * @param {string} url
     38 + * @param {string} paramName
     39 + * @param {string} paramValue
     40 + * @returns {string}
     41 + */
     42 +function addURLParam(url, paramName, paramValue) {
     43 + var newURL = new URL(url);
     44 + newURL.searchParams.set(paramName, paramValue);
     45 + return newURL.toString();
     46 +}
     47 + 
  • client/src/api/kemono/posts.js
    ■ ■ ■ ■ ■ ■
     1 +import { kemonoFetch } from "./kemono-fetch";
     2 +import { KemonoError } from "@wp/utils";
     3 + 
     4 + 
     5 +export const posts = {
     6 + attemptFlag
     7 +};
     8 + 
     9 +/**
     10 + * @param {string} service
     11 + * @param {string} user
     12 + * @param {string} post_id
     13 + */
     14 +async function attemptFlag(service, user, post_id) {
     15 + try {
     16 + const response = await kemonoFetch(`/api/${service}/user/${user}/post/${post_id}/flag`, { method: "POST" });
     17 + 
     18 + if (!response || !response.ok) {
     19 + 
     20 + alert(new KemonoError(5));
     21 + return false;
     22 + }
     23 + 
     24 + return true;
     25 + 
     26 + } catch (error) {
     27 + console.error(error);
     28 + }
     29 +}
  • client/src/api/paysites/_index.js
    ■ ■ ■ ■ ■ ■
     1 +export const paysitesAPI = {};
     2 + 
  • client/src/assets/loading.gif
  • client/src/css/_index.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "variables";
     2 +@use "animations";
     3 +@use "sass-mixins";
     4 +@use "base";
     5 +@use "attributes";
     6 +@use "blocks";
     7 +@use "legacy";
     8 + 
  • client/src/css/animations.scss
    ■ ■ ■ ■ ■ ■
     1 +@keyframes fadeInOpacity {
     2 + 0% {
     3 + opacity: 0;
     4 + }
     5 + 100% {
     6 + opacity: 1;
     7 + }
     8 +}
     9 + 
  • client/src/css/attributes.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "./variables" as *;
     2 +/* Attributes */
     3 +// only selectors by attributes
     4 +// and their pseudo-classes/elements go there
     5 +// base tags are for easier grouping/folding
     6 + 
     7 +a {
     8 + // internal links
     9 + // &[href^=#{$kemono-site}],
     10 + &[href^="/"],
     11 + &[href^="./"],
     12 + &[href^="../"] {
     13 + --local-colour1-primary: var(--anchour-internal-colour1-primary);
     14 + --local-colour1-secondary: var(--anchour-internal-colour1-secondary);
     15 + --local-colour2-primary: var(--anchour-internal-colour2-primary);
     16 + --local-colour2-secondary: var(--anchour-internal-colour2-secondary);
     17 + }
     18 + 
     19 + // local links
     20 + &[href^="#"] {
     21 + --local-colour1-primary: var(--anchour-local-colour1-primary);
     22 + // the same color because visited state is irrelevant
     23 + --local-colour1-secondary: var(--anchour-local-colour1-primary);
     24 + --local-colour2-primary: var(--anchour-local-colour2-primary);
     25 + --local-colour2-secondary: var(--anchour-local-colour2-secondary);
     26 + }
     27 + 
     28 + // email links
     29 + &[href^="mailto:"] {
     30 +
     31 + // &::before {
     32 + // content: "\1F4E7"; // email icon
     33 + // padding-right: $size-little;
     34 + // }
     35 + 
     36 + &::after {
     37 + content: " (\01f4e8\02197\01f441)"; // mail sent, NE arrow, eye
     38 + }
     39 + }
     40 + 
     41 + // telephone links
     42 + &[href^="tel:"] {
     43 + 
     44 + &::before {
     45 + content: "\260e"; // phone icon
     46 + padding-right: $size-little;
     47 + }
     48 + }
     49 +}
     50 + 
     51 +input {
     52 + 
     53 + &[type="submit"] {
     54 + min-height: 24px;
     55 + text-align: center;
     56 + color: var(--colour0-primary);
     57 + background-image: linear-gradient(
     58 + hsl(220, 7%, 17%),
     59 + hsl(0, 0%, 7%)
     60 + );
     61 + border-radius: 5px;
     62 + border: 0;
     63 + }
     64 + 
     65 + &[type="text"],
     66 + &[type="password"],
     67 + &[type="number"] {
     68 + border-radius: 2px;
     69 + box-shadow: inset 0 1px 3px hsl(228, 7%, 13%);
     70 + background: var(--colour1-secondary);
     71 + color: var(--colour0-primary);
     72 + border: 0;
     73 + }
     74 + 
     75 + &[type="checkbox"],
     76 + &[type="radio"] {
     77 + cursor: pointer;
     78 + }
     79 +}
     80 + 
  • client/src/css/base.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "variables.scss" as *;
     2 +@use "inter-ui/default" as inter-ui with (
     3 + $inter-font-path: '~inter-ui/Inter (web)'
     4 +);
     5 +
     6 +@import "purecss/build/grids-min.css";
     7 +@import "purecss/build/grids-responsive-min.css";
     8 +@import "purecss/build/base-min.css";
     9 + 
     10 +@include inter-ui.weight-400;
     11 +@include inter-ui.weight-700;
     12 + 
     13 +html {
     14 + box-sizing: border-box;
     15 + height: 100%;
     16 + width: 100%;
     17 + font-size: 100%;
     18 + font-family: Inter, Arial, sans-serif;
     19 + padding: 0;
     20 + margin: 0;
     21 + overflow-wrap: break-word;
     22 + overflow: auto;
     23 +}
     24 + 
     25 +*,
     26 +*::before,
     27 +*::after {
     28 + box-sizing: inherit;
     29 + margin: 0;
     30 +}
     31 + 
     32 +body {
     33 + display: flex;
     34 + position: relative;
     35 + height: 100%;
     36 + width: 100%;
     37 + color: var(--colour0-primary);
     38 + background-color: var(--colour1-primary);
     39 + padding: 0;
     40 + margin: 0;
     41 +}
     42 + 
     43 +main {}
     44 + 
     45 +h1 {
     46 + text-transform: capitalize;
     47 + font-size: 1.7rem;
     48 + font-weight: normal;
     49 + margin: 0;
     50 +}
     51 + 
     52 +h2 {
     53 + font-size: 1.6rem;
     54 + font-weight: normal;
     55 + line-height: 1.35;
     56 + margin: 0;
     57 +}
     58 + 
     59 +h3 {
     60 + font-size: 1.5rem;
     61 + font-weight: normal;
     62 + margin: 0;
     63 +}
     64 + 
     65 +h4 {
     66 + font-size: 1.4rem;
     67 + font-weight: normal;
     68 + margin: 0;
     69 +}
     70 + 
     71 +h5 {
     72 + font-size: 1.3rem;
     73 + font-weight: normal;
     74 + margin: 0;
     75 +}
     76 + 
     77 +h6 {
     78 + font-size: 1.2rem;
     79 + font-weight: normal;
     80 + margin: 0;
     81 +}
     82 + 
     83 +p,
     84 +li,
     85 +dd,
     86 +dt {
     87 + line-height: 1.5;
     88 +}
     89 + 
     90 +p, ul, ol {
     91 + margin: $size-small 0;
     92 +}
     93 + 
     94 +ul, ol {
     95 + padding-left: $size-normal;
     96 +}
     97 + 
     98 +ul.horizontal {
     99 + padding-left: 0;
     100 + & li {
     101 + list-style: none;
     102 + display: inline;
     103 +
     104 + &:after {
     105 + content: " \00b7";
     106 + }
     107 +
     108 + &:last-child {
     109 + &:after {
     110 + content: none;
     111 + }
     112 + }
     113 + }
     114 +}
     115 + 
     116 +a {
     117 + --local-colour1-primary: var(--anchour-colour1-primary);
     118 + --local-colour1-secondary: var(--anchour-colour1-secondary);
     119 + --local-colour2-primary: var(--anchour-colour2-primary);
     120 + --local-colour2-secondary: var(--anchour-colour2-secondary);
     121 + 
     122 + outline: none;
     123 + text-decoration: none;
     124 + border-bottom: $size-nano solid transparent;
     125 + padding: 2px;
     126 + transition-property: color, border-color, background-color;
     127 + transition-duration: var(--duration-global);
     128 + 
     129 + &:link {
     130 + color: var(--local-colour1-primary);
     131 + }
     132 + 
     133 + &:visited {
     134 + color: var(--local-colour1-secondary);
     135 + }
     136 + 
     137 + &:focus {
     138 + // background-color: var(--local-colour2-primary);
     139 + border-bottom-color: var(--local-colour1-primary);
     140 + }
     141 + 
     142 + &:hover {
     143 + // background-color: var(--local-colour2-secondary);
     144 + border-bottom-color: var(--local-colour1-primary);
     145 + }
     146 + 
     147 + &:active {
     148 + // background-color: var(--local-colour1-primary);
     149 + color: var(--local-colour2-primary);
     150 + border-bottom-color: var(--local-colour2-primary);
     151 + }
     152 +}
     153 + 
     154 +img {
     155 + max-width: 100%;
     156 + height: auto;
     157 +}
     158 + 
     159 +button,
     160 +input,
     161 +select,
     162 +textarea,
     163 +.pure-g [class *= "pure-u"] {
     164 + font-family: Helvetica, sans-serif;
     165 +}
     166 + 
     167 +label {
     168 + cursor: pointer;
     169 +}
     170 + 
     171 +textarea {
     172 + width: 100%;
     173 + -webkit-box-sizing: border-box;
     174 + -moz-box-sizing: border-box;
     175 + box-sizing: border-box;
     176 + min-height: 3rem;
     177 + box-shadow: inset 0 1px 3px #1f2024;
     178 + background:#3a3d43;
     179 + color: var(--colour0-primary);
     180 + // border: none;
     181 + border: 1px solid grey;
     182 + min-height: 75px;
     183 + overflow: auto;
     184 + outline: none;
     185 + resize: none;
     186 +}
     187 + 
     188 +pre {
     189 + white-space: pre-wrap; /* Since CSS 2.1 */
     190 + word-wrap: break-word; /* Internet Explorer 5.5+ */
     191 +}
     192 + 
     193 +select {
     194 + color: var(--colour0-primary);
     195 + background-image: linear-gradient(hsl(220, 7%, 25%), #111111);
     196 + border-radius: 5px;
     197 + border-color: #111;
     198 + text-align-last: center;
     199 + cursor: pointer;
     200 +}
     201 + 
     202 +select * {
     203 + color: #000;
     204 +}
     205 + 
     206 +select::-ms-expand {
     207 + color: #000;
     208 +}
     209 + 
     210 +button {
     211 + cursor: pointer;
     212 + 
     213 + // prevents onclick events from firind on children
     214 + & > * {
     215 + pointer-events: none;
     216 + }
     217 +}
     218 + 
     219 +form.fancy {
     220 + flex-direction: column;
     221 + display: flex;
     222 +}
     223 + 
     224 +table {
     225 + width: 100%;
     226 + border-collapse: collapse;
     227 + & a {
     228 + display: flex;
     229 + }
     230 +}
     231 + 
     232 +tr:nth-of-type(odd) {
     233 + background: var(--colour1-secondary);
     234 +}
     235 + 
     236 +th {
     237 + background: var(--colour1-tertiary);
     238 + font-weight: bold;
     239 +}
     240 + 
     241 +td, th {
     242 + padding: 6px;
     243 + border: 1px solid var(--colour1-tertiary);
     244 + text-align: left;
     245 +}
     246 + 
     247 +td:before {
     248 + padding: 6px;
     249 +}
     250 + 
     251 + 
  • client/src/css/blocks/_index.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "form";
     2 + 
  • client/src/css/blocks/form.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "../variables.scss" as *;
     2 + 
     3 +.form {
     4 + max-width: $width-mobile;
     5 + padding: $size-normal;
     6 + margin: 0 auto;
     7 + 
     8 + &--bigger {
     9 + max-width: $width-phone;
     10 + }
     11 + 
     12 + &--wide {
     13 + max-width: $width-tablet;
     14 + }
     15 + 
     16 + &--controller {
     17 + display: none;
     18 + }
     19 + 
     20 + &__section,
     21 + &__fieldset {
     22 + padding-bottom: $size-normal;
     23 + transition-property: opacity, visibility;
     24 + transition-duration: var(--duration-global);
     25 + 
     26 + &:last-child {
     27 + padding-bottom: 0;
     28 + }
     29 + 
     30 + &--hidden {
     31 + max-height: 0;
     32 + opacity: 0;
     33 + visibility: hidden;
     34 + padding: 0;
     35 + margin: 0;
     36 + }
     37 + }
     38 + 
     39 + &__section {
     40 + &--buttons {
     41 + text-align: center;
     42 + }
     43 + 
     44 + &--radio, &--checkbox {
     45 + position: relative;
     46 + display: flex;
     47 + flex-flow: row wrap;
     48 + justify-content: space-around;
     49 + align-items: center;
     50 + gap: $size-normal;
     51 + & .form__input {
     52 + -webkit-appearance: none;
     53 + place-content: center;
     54 + appearance: none;
     55 + display: grid;
     56 + padding: 0;
     57 + margin: 0;
     58 + flex: 0 1;
     59 + &:checked + .form__label {
     60 + opacity: 1;
     61 + }
     62 + }
     63 + & .form__label {
     64 + flex: 1 1;
     65 + opacity: 0.5;
     66 + transition-property: opacity;
     67 + transition-duration: 250ms;
     68 + }
     69 + }
     70 + 
     71 + &--radio {
     72 + & .form__input {
     73 + border-radius: 50%;
     74 + &:checked {
     75 + border: 0.15em solid var(--colour0-primary);
     76 + &:before {
     77 + transform: scale(1);
     78 + }
     79 + }
     80 + &:before {
     81 + box-shadow: inset 1em 1em var(--colour0-primary);
     82 + transition: 120ms transform ease-in-out;
     83 + transform: scale(0);
     84 + border-radius: 50%;
     85 + content: "";
     86 + height: 1em;
     87 + width: 1em;
     88 + }
     89 + }
     90 + }
     91 + 
     92 + &--checkbox {
     93 + & .form__input {
     94 + &:before {
     95 + content: "×";
     96 + font-size: 2em;
     97 + margin: 0 auto;
     98 + color: hsl(0, 100%, 60%);
     99 + }
     100 + &:checked:before {
     101 + content: "\2713"; /* checkmark */
     102 + color: hsl(120, 100%, 50%);
     103 + }
     104 + }
     105 + }
     106 + }
     107 + 
     108 + &__input,
     109 + &__button,
     110 + &__select {
     111 + box-sizing: border-box;
     112 + min-height: $button-min-width;
     113 + min-width: $button-min-height;
     114 + width: 100%;
     115 + font-family: inherit;
     116 + font-size: 18px;
     117 + border-radius: 10px;
     118 + padding: $size-small;
     119 + }
     120 + 
     121 + &__subtitle {
     122 + display: block;
     123 + line-height: normal;
     124 + color: hsl(0, 0%, 45%);
     125 + }
     126 + 
     127 + &__input {
     128 + background: hsl(224, 7%, 32%);
     129 + color: hsl(0, 0%, 100%);
     130 + border: 0;
     131 + }
     132 + 
     133 + &__option {
     134 + color: hsl(0, 0%, 100%);
     135 + background-color: hsl(224, 7%, 32%);
     136 + }
     137 + 
     138 + /* quick hack to overwrite attribute rules */
     139 + &__input.form__input--text,
     140 + &__input.form__input--password {
     141 + text-align: left;
     142 + }
     143 + 
     144 + &__button {
     145 + cursor: pointer;
     146 + 
     147 + &--submit {
     148 + max-width: $width-phone;
     149 + text-align: center;
     150 + color: var(--colour0-primary);
     151 + background-image: linear-gradient(#32373e, #262a31);
     152 + box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.2), 0 4px 6px -4px rgb(0 0 0 / 0.2);
     153 + border: 1px solid #000;
     154 + transition: none;
     155 + transition-property: box-shadow;
     156 + transition-duration: var(--duration-global);
     157 +
     158 + &:hover,
     159 + &:focus-within {
     160 + box-shadow: none;
     161 + }
     162 + 
     163 + &:disabled {
     164 + color: #666;
     165 + background-image: linear-gradient(#4a5059, #4a5059);
     166 + cursor: not-allowed;
     167 + }
     168 + }
     169 + }
     170 +}
     171 + 
  • client/src/css/legacy.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "./variables.scss" as *;
     2 + 
     3 +/*
     4 + TODO: Spread the styles around page/component/block files.
     5 +*/
     6 + 
     7 +.flash_messages {
     8 + background-color: #3a3d43;
     9 + padding: 10px;
     10 + text-align: center;
     11 +}
     12 + 
     13 +.subtitle {
     14 + color: #737373;
     15 +}
     16 + 
     17 +.no-posts {
     18 + text-align: center;
     19 +}
     20 + 
     21 +.activity-view {
     22 + padding: 5px;
     23 + margin: 0.5rem;
     24 + border-radius: 0.25rem;
     25 + background: #3a3d43;
     26 + display: flex;
     27 +}
     28 + 
     29 +.activity-view-avatar {
     30 + border-radius: 0.25rem;
     31 + background-size: cover;
     32 + background-position: center;
     33 + width: 50px;
     34 + height: 50px;
     35 +}
     36 + 
     37 +.activity-view-p {
     38 + margin-left: 5px;
     39 +}
     40 + 
     41 +.activity-view-name {
     42 + font-weight: bold;
     43 + font-size: 20px;
     44 +}
     45 + 
     46 +.activity-view-update {
     47 + display: block;
     48 +}
     49 + 
     50 +.jumbo {
     51 + padding: 10px;
     52 + margin: 0.5rem;
     53 + border-radius: 0.25rem;
     54 + background: hsl(220, 7%, 25%);
     55 + 
     56 + & p, & h2 {
     57 + margin: 0;
     58 + }
     59 +}
     60 + 
     61 +.jumbo-user {
     62 + text-align: center;
     63 +}
     64 + 
     65 +.jumbo-user-avatar {
     66 + margin: 0 auto;
     67 + border-radius: 0.25rem;
     68 + background-size: cover;
     69 + background-position: center;
     70 + width: 50px;
     71 + height: 50px;
     72 +}
     73 + 
     74 +.favorites-opts select {
     75 + margin-left: 0.5rem;
     76 +}
     77 + 
     78 +.opts {
     79 + float: right;
     80 + position: relative;
     81 +}
     82 + 
     83 +.opts select {
     84 + position: absolute;
     85 + top: 0.5rem;
     86 + right: 0.5rem;
     87 +}
     88 + 
     89 +.visually-hidden {
     90 + position: absolute;
     91 + left: -100vw;
     92 +}
     93 + 
     94 +.text-card {
     95 + height: 500px;
     96 + background: #3a3d43;
     97 + border-radius: 2px;
     98 + margin: 0.5rem;
     99 + box-shadow:
     100 + 0 2px 2px 0 rgba(0,0,0,0.14),
     101 + 0 1px 5px 0 rgba(0,0,0,0.12),
     102 + 0 3px 1px -2px rgba(0,0,0,0.2);
     103 + position: relative;
     104 + overflow-y: auto;
     105 +}
     106 + 
     107 +.card-image {
     108 + overflow: hidden;
     109 + position: relative;
     110 + max-height: 310px;
     111 +}
     112 + 
     113 +.card-reveal {
     114 + border-radius: 2px;
     115 + position: absolute;
     116 + background-color: #3a3d43;
     117 + width: 100%;
     118 + overflow-y: auto;
     119 + top: 0;
     120 + height: 100%;
     121 + z-index: 1;
     122 + display: none;
     123 +}
     124 + 
     125 +.card-reveal-content {
     126 + padding: 20px;
     127 +}
     128 + 
     129 +.card-reveal-content img {
     130 + max-width: 100%;
     131 +}
     132 + 
     133 +.card-content {
     134 + overflow: hidden;
     135 + padding: 20px;
     136 + border-radius: 0 0 2px 2px;
     137 + height: 170px;
     138 +}
     139 + 
     140 +.card-title {
     141 + font-size: 24px;
     142 + font-weight: 300;
     143 +}
     144 + 
     145 +.card-action {
     146 + position: absolute;
     147 + bottom: 0;
     148 + left: 0;
     149 + right: 0;
     150 + border-top: 1px solid rgba(160,160,160,0.2);
     151 + padding: 20px;
     152 +}
     153 + 
     154 +.card-action a {
     155 + margin-right: 10px;
     156 +}
     157 + 
     158 +.card-image img {
     159 + border-radius: 2px 2px 0 0;
     160 + position: relative;
     161 + left: 0;
     162 + right: 0;
     163 + top: 0;
     164 + bottom: 0;
     165 + width: 100%;
     166 +}
     167 + 
     168 +/* thumbnails */
     169 +.thumb-standard { border: 1px solid #000 }
     170 +.thumb-child { border: 1px solid #cc0 }
     171 +.thumb-parent { border: 1px solid #0f0 }
     172 +.thumb-shared { border: 1px solid #ff7f00 }
     173 + 
     174 +.thumb-link {
     175 + margin: 2px;
     176 +}
     177 + 
     178 +.thumb {
     179 + width: 200px;
     180 + height: 200px;
     181 +}
     182 + 
     183 +.thumb:hover .thumb-with-image-overlay {
     184 + display: block;
     185 +}
     186 + 
     187 +.thumb-with-image {
     188 + display: flex;
     189 + flex-direction: column;
     190 + justify-content: center;
     191 + align-items: center;
     192 + background-image: url("@wp/assets/loading.gif");
     193 + background-position: center;
     194 + background-repeat: no-repeat;
     195 + position: relative;
     196 +}
     197 + 
     198 +.thumb-image {
     199 + width: 100%;
     200 + height: 100%;
     201 + object-fit: contain;
     202 +}
     203 + 
     204 +.thumb-with-image-overlay {
     205 + display: none;
     206 + z-index: 9;
     207 + width: 100%;
     208 + height: 100%;
     209 + position: absolute;
     210 + top: 0;
     211 + text-align: center;
     212 + background-image:
     213 + linear-gradient(
     214 + to bottom,
     215 + rgba(0, 0, 0, 1),
     216 + rgba(0, 0, 0, 0)
     217 + );
     218 +}
     219 + 
     220 +.thumb-with-text {
     221 + overflow-y: auto;
     222 + text-align: center;
     223 +}
     224 + 
     225 +.thumb-with-text * {
     226 + padding: 2px;
     227 +}
     228 + 
     229 +.thumb * {
     230 + margin: auto;
     231 +}
     232 + 
     233 +.thumb h3,
     234 +.thumb p {
     235 + color: #fff;
     236 +}
     237 + 
     238 +/* sidebar */
     239 + 
     240 +.sidebar {
     241 + float: left;
     242 + margin-right: 5px;
     243 + margin-left: 5px;
     244 + width: 195px;
     245 + flex-shrink: 0;
     246 + display: flex;
     247 + flex-direction: column;
     248 + word-break: break-word;
     249 +}
     250 + 
     251 +.search-input {
     252 + max-width: $width-mobile;
     253 + min-height: 30px;
     254 + text-align: left;
     255 + padding: 8px;
     256 + width: 100%;
     257 +}
     258 + 
     259 +/* search */
     260 + 
     261 +.results li {
     262 + display: block;
     263 + list-style-type: none;
     264 + line-height: 1.8em;
     265 +}
     266 + 
     267 +.page img {
     268 + max-width: 100%;
     269 +}
     270 + 
     271 +/* pagination */
     272 +.paginator {
     273 + text-align: center;
     274 + & form .controls {
     275 + display: flex;
     276 + justify-content: center;
     277 + @media (min-width: #{$width-tablet + 1}) {
     278 + justify-content: normal;
     279 + text-align: left;
     280 + &.c_align {
     281 + justify-content: center;
     282 + }
     283 + }
     284 + &.c_align {
     285 + & .search-input {
     286 + text-align: center;
     287 + }
     288 + }
     289 + }
     290 +}
     291 + 
     292 +.paginator menu {
     293 + padding: 0;
     294 + margin: 5px auto;
     295 + display: table;
     296 + 
     297 + &>li, &>a {
     298 + border: 1px solid var(--colour0-tertirary);;
     299 + display: table-cell;
     300 + line-height: 33px;
     301 + color: var(--colour0-secondary);
     302 + user-select: none;
     303 + cursor: pointer;
     304 + padding: 0;
     305 + min-width: 35px;
     306 + transition-property: color background-color;
     307 + 
     308 + @media (min-width: #{600px + 1}) {
     309 + &.pagination-mobile:not(:last-child) {
     310 + display: none;
     311 + }
     312 + 
     313 + &.pagination-mobile:last-child {
     314 + min-width: unset;
     315 + border-right: none;
     316 + border-top: none;
     317 + border-bottom: none;
     318 + &>* {
     319 + display: none;
     320 + }
     321 + }
     322 + }
     323 + 
     324 + @media (max-width: 600px) {
     325 + &.pagination-button-optional {
     326 + display: none;
     327 + }
     328 + &.pagination-desktop {
     329 + display: none;
     330 + }
     331 + }
     332 + 
     333 + &.pagination-button-disabled {
     334 + color: var(--colour0-tertirary);
     335 + background-color: unset;
     336 + cursor: default;
     337 + }
     338 + &.pagination-button-current {
     339 + background-color: var(--anchour-internal-colour2-primary);
     340 + color: var(--anchour-internal-colour1-secondary);
     341 + border-color: var(--anchour-internal-colour1-primary);
     342 + }
     343 + &.pagination-button-after-current {
     344 + border-left: 1px solid var(--anchour-internal-colour1-primary);
     345 + }
     346 + &:not(.pagination-button-disabled):hover,
     347 + &:not(.pagination-button-disabled):focus,
     348 + &:not(.pagination-button-disabled):active {
     349 + background-color: var(--colour0-tertirary);
     350 + color: var(--colour0-primary);
     351 + }
     352 + &>b {
     353 + padding: 0 9px;
     354 + }
     355 + &:not(:last-child) {
     356 + border-right: none;
     357 + }
     358 + }
     359 +}
     360 + 
     361 +menu li {
     362 + display: inline;
     363 + list-style-type: none;
     364 + margin: 0;
     365 + padding: 0 .2em;
     366 +}
     367 + 
     368 +/* posts */
     369 + 
     370 +.embed-view {
     371 + border: 1px solid #111;
     372 + padding: 2px;
     373 +}
     374 + 
     375 +/* media queries */
     376 +@media only screen and (max-width : 568px) {
     377 + .thumb {
     378 + width: 160px;
     379 + height: 160px;
     380 + }
     381 + .sidebar {
     382 + width: auto;
     383 + }
     384 + #paginator-bottom {
     385 + margin-bottom: env(safe-area-inset-bottom);
     386 + }
     387 +}
     388 + 
     389 +/* search forms */
     390 + 
     391 +.search-form {
     392 + display: table;
     393 + padding: .5rem;
     394 + margin-left: 5px;
     395 + background-color: hsl(220, 7%, 25%);
     396 + margin: 0px auto 8px auto;
     397 + 
     398 + &-hidden {
     399 + display: none;
     400 + }
     401 + 
     402 + & > div {
     403 + display: table-row;
     404 + line-height: 1.5em;
     405 + margin-bottom: 2em;
     406 + }
     407 + 
     408 + & small {
     409 + display: block;
     410 + line-height: normal;
     411 + }
     412 + 
     413 + & label,
     414 + & input {
     415 + display: table-cell;
     416 + padding-right: 1em;
     417 + white-space: nowrap;
     418 + text-align: left;
     419 + }
     420 + 
     421 + & label {
     422 + text-align: right;
     423 + font-weight: 700;
     424 + }
     425 +}
     426 + 
     427 +/* search results */
     428 +.search-results tbody td {
     429 + height: 2.25em;
     430 + padding-right: 10px;
     431 +}
     432 + 
     433 +thead th {
     434 + font-weight: 700;
     435 + text-align: left;
     436 + padding-right: 8px;
     437 +}
     438 + 
     439 +.user-icon {
     440 + display: inline-block;
     441 + width: 40px;
     442 + height: 40px;
     443 + border-radius: 0.25rem;
     444 + overflow: hidden;
     445 + 
     446 + // quick hack to apply styles
     447 + 
     448 + & img {
     449 + width: 100%;
     450 + height: 100%;
     451 + object-fit: cover;
     452 + }
     453 +}
     454 + 
     455 +.ad-container {
     456 + text-align: center;
     457 +}
     458 + 
     459 +.ad-container * {
     460 + max-width: 100%;
     461 +}
     462 + 
  • client/src/css/sass-mixins.scss
    ■ ■ ■ ■ ■ ■
     1 +/* SASS mixins go there */
     2 +@use "variables.scss" as *;
     3 + 
     4 +@mixin article_card() {
     5 + display: flex;
     6 + flex-flow: column nowrap;
     7 + border-radius: 10px;
     8 + background-color: var(--colour1-tertiary);
     9 + padding: 0;
     10 +
     11 + & > * {
     12 + flex: 0 0 auto;
     13 + padding: $size-small;
     14 + }
     15 +}
     16 + 
  • client/src/css/variables.scss
    ■ ■ ■ ■ ■ ■
     1 +/* SASS variables */
     2 +// screen widths
     3 +$width-feature: 240px;
     4 +$width-mobile: 360px;
     5 +$width-phone: 480px;
     6 +$width-tablet: 720px;
     7 +$width-laptop: 1280px;
     8 +$width-desktop: 1920px;
     9 + 
     10 +// sizes for borders/padding/margins/gaps
     11 +$size-nano: 0.0625em;
     12 +$size-thin: 0.125em;
     13 +$size-little: 0.25em;
     14 +$size-small: 0.5em;
     15 +$size-normal: 1em;
     16 +$size-big: 2em;
     17 +$size-large: 3em;
     18 + 
     19 +// buttons
     20 +$button-min-width: 44px;
     21 +$button-min-height: 44px;
     22 + 
     23 +// min sidebar width do not touch
     24 +$sidebar-min-width: 1020px;
     25 + 
     26 +/* CSS variables */
     27 +:root {
     28 + // base colours
     29 + --colour0-primary: hsl(0, 0%, 95%);
     30 + --colour0-secondary: hsl(0, 0%, 70%);
     31 + --colour0-tertirary: hsl(0, 0%, 45%);
     32 + --colour1-primary: hsl(230, 6%, 18%);
     33 + --colour1-primary-transparent: hsla(230, 6%, 18%, 0.75);
     34 + --colour1-secondary: hsl(220, 7%, 33%);
     35 + --colour1-secondary-transparent: hsl(220, 7%, 33%);
     36 + --colour1-tertiary: hsl(210, 15%, 5%);
     37 + 
     38 + /* Buttons */
     39 + --submit-colour1-primary: hsl(200, 100%, 70%);
     40 + --submit-colour1-secondary: hsl(240, 100%, 50%);
     41 + --submit-colour2-primary: hsl(240, 100%, 50%);
     42 + --submit-colour2-secondary: hsl(240, 100%, 50%);
     43 + --positive-colour1-primary: hsl(120, 100%, 45%);
     44 + --positive-colour1-secondary: hsl(120, 100%, 30%);
     45 + --negative-colour1-primary: hsl(0, 100%, 60%);
     46 + --favourite-colour1-primary: hsl(51, 100%, 50%);
     47 + --favourite-colour2-primary: hsl(60, 100%, 30%);
     48 + /* END Buttons */
     49 + 
     50 + /* Links */
     51 + // external
     52 + --anchour-colour1-primary: hsl(200, 100%, 80%);
     53 + --anchour-colour1-secondary: hsla(210, 70%, 70%);
     54 + --anchour-colour2-primary: hsl(240, 100%, 40%);
     55 + --anchour-colour2-secondary: hsla(230, 70%, 40%);
     56 + // local
     57 + --anchour-local-colour1-primary: hsl(20, 100%, 80%);
     58 + --anchour-local-colour1-secondary: hsla(20, 70%, 70%);
     59 + --anchour-local-colour2-primary: hsl(30, 100%, 20%);
     60 + --anchour-local-colour2-secondary: hsla(30, 80%, 20%);
     61 + // internal
     62 + --anchour-internal-colour1-primary: hsl(260, 100%, 80%);
     63 + --anchour-internal-colour1-secondary: hsla(260, 70%, 70%);
     64 + --anchour-internal-colour2-primary: hsl(280, 100%, 30%);
     65 + --anchour-internal-colour2-secondary: hsla(280, 70%, 30%);
     66 + // email
     67 + // --anchour-email-colour1-primary: hsl(260, 100%, 80%);
     68 + // --anchour-email-colour1-secondary: hsla(260, 70%, 70%);
     69 + // --anchour-email-colour2-primary: hsl(280, 100%, 30%);
     70 + // --anchour-email-colour2-secondary: hsla(280, 70%, 30%);
     71 + /* END Links */
     72 + 
     73 + // durations
     74 + --duration-fast: 250ms;
     75 + --duration-global: var(--duration-fast);
     76 +}
     77 + 
  • client/src/development/entry.js
    ■ ■ ■ ■ ■ ■
     1 +import "./entry.scss";
     2 + 
  • client/src/development/entry.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "../css";
     2 +@use "../pages";
     3 +@use "../pages/development";
     4 + 
  • client/src/env/derived-vars.js
    ■ ■ ■ ■ ■
     1 +import { NODE_ENV, KEMONO_SITE } from "./env-vars.js";
     2 + 
     3 +export const IS_DEVELOPMENT = NODE_ENV === "development";
     4 +export const SITE_HOSTNAME = new URL(KEMONO_SITE).hostname;
     5 + 
  • client/src/env/env-vars.js
    ■ ■ ■ ■ ■ ■
     1 +/*
     2 + https://webpack.js.org/plugins/define-plugin/
     3 +*/
     4 + 
     5 +/**
     6 + * @type {string}
     7 + */
     8 +export const KEMONO_SITE = BUNDLER_ENV_KEMONO_SITE;
     9 +/**
     10 + * @type {string}
     11 + */
     12 +export const NODE_ENV = BUNDLER_ENV_NODE_ENV;
     13 + 
  • client/src/js/account.js
    ■ ■ ■ ■ ■ ■
     1 +export const isLoggedIn = localStorage.getItem('logged_in') === "yes";
     2 + 
  • client/src/js/admin.js
    ■ ■ ■ ■ ■ ■
     1 +import "./admin.scss";
     2 +import { fixImageLinks } from "@wp/utils";
     3 +import { initSections } from "./page-loader";
     4 +import { adminPageScripts } from "@wp/pages";
     5 + 
     6 +fixImageLinks(document.images);
     7 +initSections(adminPageScripts);
     8 + 
  • client/src/js/admin.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "../css";
     2 +@use "../pages/components";
     3 +@use "../pages/account/administrator";
     4 + 
  • client/src/js/component-factory.js
    ■ ■ ■ ■ ■ ■
     1 +/**
     2 + * @type {Map<string, HTMLElement>}
     3 + */
     4 +const components = new Map();
     5 + 
     6 +/**
     7 + * @param {HTMLElement} footer
     8 + */
     9 +export function initComponentFactory(footer) {
     10 + const container = footer.querySelector(".component-container");
     11 + /**
     12 + * @type {NodeListOf<HTMLElement}
     13 + */
     14 + const componentElements = container.querySelectorAll(`#${container.id} > *`);
     15 + 
     16 + componentElements.forEach((component) => {
     17 + components.set(component.className.trim(), component);
     18 + });
     19 + container.remove();
     20 +}
     21 + 
     22 +/**
     23 + * @param {string} className
     24 + */
     25 +export function createComponent(className) {
     26 + const componentSkeleton = components.get(className);
     27 + 
     28 + if (!componentSkeleton) {
     29 + return console.error(`Component "${className}" doesn't exist.`);
     30 + }
     31 + 
     32 + const newInstance = componentSkeleton.cloneNode(true);
     33 + 
     34 + return newInstance;
     35 +}
     36 + 
  • client/src/js/favorites.js
    ■ ■ ■ ■ ■ ■
     1 +import { kemonoAPI } from "@wp/api";
     2 + 
     3 +export async function initFavorites() {
     4 + let artistFavs = localStorage.getItem('favs');
     5 + let postFavs = localStorage.getItem('post_favs');
     6 + 
     7 + if (!artistFavs || artistFavs === "undefined") {
     8 + /**
     9 + * @type {string}
     10 + */
     11 + const favs = await kemonoAPI.favorites.retrieveFavoriteArtists();
     12 + 
     13 + if (favs) {
     14 + localStorage.setItem("favs", favs);
     15 + }
     16 + }
     17 + 
     18 + if (!postFavs || postFavs === "undefined") {
     19 + /**
     20 + * @type {string}
     21 + */
     22 + const favs = await kemonoAPI.favorites.retrieveFavoritePosts();
     23 + 
     24 + if (favs) {
     25 + localStorage.setItem("post_favs", favs);
     26 + }
     27 + }
     28 +}
     29 + 
     30 +async function saveFavouriteArtists() {
     31 + try {
     32 + const favs = await kemonoAPI.favorites.retrieveFavoriteArtists();
     33 +
     34 + if (!favs) {
     35 + alert("Could not retrieve favorite artists");
     36 + return false;
     37 + }
     38 + 
     39 + localStorage.setItem("favs", favs);
     40 + return true;
     41 + 
     42 + } catch (error) {
     43 + console.error(error);
     44 + }
     45 +}
     46 + 
     47 +async function saveFavouritePosts() {
     48 + try {
     49 + const favs = await kemonoAPI.favorites.retrieveFavoritePosts();
     50 +
     51 + if (!favs) {
     52 + alert("Could not retrieve favorite posts");
     53 + return false;
     54 + }
     55 + 
     56 + localStorage.setItem("post_favs", favs);
     57 + return true;
     58 + 
     59 + } catch (error) {
     60 + console.error(error);
     61 + }
     62 +}
     63 + 
     64 +/**
     65 + * @param {string} id
     66 + * @param {string} service
     67 + * @returns {Promise<KemonoAPI.Favorites.User> | undefined}
     68 + */
     69 +export async function findFavouriteArtist(id, service) {
     70 + /**
     71 + * @type {KemonoAPI.Favorites.User[]}
     72 + */
     73 + let favList;
     74 + 
     75 + try {
     76 + favList = JSON.parse(localStorage.getItem("favs"));
     77 + 
     78 + } catch (error) {
     79 + // corrupted entry
     80 + if (error instanceof SyntaxError) {
     81 + const isSaved = await saveFavouriteArtists();
     82 + 
     83 + if (!isSaved) {
     84 + return undefined;
     85 + }
     86 + 
     87 + return await findFavouriteArtist(id, service);
     88 + }
     89 + }
     90 + 
     91 + if (!favList) {
     92 + return undefined;
     93 + }
     94 +
     95 + const favArtist = favList.find((favItem) => {
     96 + return favItem.id === id && favItem.service === service;
     97 + });
     98 + 
     99 + return favArtist;
     100 +}
     101 + 
     102 +/**
     103 + * @param {string} service
     104 + * @param {string} user
     105 + * @param {string} postID
     106 + * @returns {Promise<KemonoAPI.Favorites.Post> | undefined}
     107 + */
     108 +export async function findFavouritePost(service, user, postID) {
     109 + /**
     110 + * @type {KemonoAPI.Favorites.Post[]}
     111 + */
     112 + let favList;
     113 +
     114 + try {
     115 + favList = JSON.parse(localStorage.getItem("post_favs"));
     116 +
     117 + if (!favList) {
     118 + return undefined;
     119 + }
     120 + 
     121 + const favPost = favList.find((favItem) => {
     122 + const isMatch = favItem.id === postID
     123 + && favItem.service === service
     124 + && favItem.user === user;
     125 + return isMatch;
     126 + });
     127 +
     128 + return favPost;
     129 + 
     130 + } catch (error) {
     131 + // corrupted entry
     132 + if (error instanceof SyntaxError) {
     133 + const isSaved = await saveFavouritePosts();
     134 + 
     135 + if (!isSaved) {
     136 + return undefined;
     137 + }
     138 + 
     139 + return await findFavouritePost(service, user, postID);
     140 + }
     141 + }
     142 +}
     143 + 
     144 +/**
     145 + * @param {string} id
     146 + * @param {string} service
     147 + */
     148 +export async function addFavouriteArtist(id, service) {
     149 + const isFavorited = await kemonoAPI.favorites.favoriteArtist(service, id);
     150 + 
     151 + if (!isFavorited) {
     152 + return false;
     153 + }
     154 + 
     155 + const newFavs = await kemonoAPI.favorites.retrieveFavoriteArtists();
     156 + localStorage.setItem("favs", newFavs);
     157 + 
     158 + return true;
     159 +}
     160 + 
     161 +/**
     162 + * @param {string} id
     163 + * @param {string} service
     164 + */
     165 +export async function removeFavouriteArtist(id, service) {
     166 + const isUnfavorited = await kemonoAPI.favorites.unfavoriteArtist(service, id);
     167 + 
     168 + if (!isUnfavorited) {
     169 + return false
     170 + }
     171 + 
     172 + const favItems = await kemonoAPI.favorites.retrieveFavoriteArtists();
     173 + localStorage.setItem("favs", favItems);
     174 + 
     175 + return true;
     176 +}
     177 + 
     178 +/**
     179 + * @param {string} service
     180 + * @param {string} user
     181 + * @param {string} postID
     182 + */
     183 +export async function addFavouritePost(service, user, postID) {
     184 + const isFavorited = await kemonoAPI.favorites.favoritePost(service, user, postID);
     185 + 
     186 + if (!isFavorited) {
     187 + return false;
     188 + }
     189 + 
     190 + const newFavs = await kemonoAPI.favorites.retrieveFavoritePosts();
     191 + localStorage.setItem("post_favs", newFavs);
     192 + 
     193 + return true;
     194 +};
     195 + 
     196 +/**
     197 + * @param {string} service
     198 + * @param {string} user
     199 + * @param {string} postID
     200 + * @returns
     201 + */
     202 +export async function removeFavouritePost(service, user, postID) {
     203 + const isUnfavorited = await kemonoAPI.favorites.unfavoritePost(service, user, postID);
     204 + 
     205 + if (!isUnfavorited) {
     206 + return false
     207 + }
     208 + 
     209 + const favItems = await kemonoAPI.favorites.retrieveFavoritePosts();
     210 + localStorage.setItem("post_favs", favItems);
     211 + 
     212 + return true;
     213 +};
     214 + 
  • client/src/js/feature-detect.js
    ■ ■ ■ ■ ■ ■
     1 +export const features = {
     2 + localStorage: isLocalStorageAvailable()
     3 +}
     4 + 
     5 +function isLocalStorageAvailable() {
     6 + try {
     7 + localStorage.setItem("__storage_test__", "__storage_test__");
     8 + localStorage.removeItem("__storage_test__");
     9 + return true;
     10 + } catch (error) {
     11 + return false;
     12 + }
     13 +}
     14 + 
  • client/src/js/global.js
    ■ ■ ■ ■ ■ ■
     1 +import "./global.scss";
     2 +import { isLoggedIn } from "@wp/js/account";
     3 +import { initFavorites } from "@wp/js/favorites";
     4 +import { fixImageLinks } from "@wp/utils";
     5 +import { globalPageScripts } from "@wp/pages";
     6 +import { initSections } from "./page-loader";
     7 + 
     8 +if (isLoggedIn) {
     9 + initFavorites()
     10 +}
     11 +fixImageLinks(document.images);
     12 +initSections(globalPageScripts);
     13 +Array.from(document.getElementsByTagName('textarea')).forEach(tx => {
     14 + tx.setAttribute('style', 'height:' + tx.scrollHeight + 'px;overflow-y:hidden;');
     15 + tx.addEventListener('input', e => {
     16 + e.target.style.height = 'auto';
     17 + e.target.style.height = e.target.scrollHeight + 'px';
     18 + }, false);
     19 +});
     20 + 
  • client/src/js/global.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "../css";
     2 +@use "../pages";
     3 + 
  • client/src/js/moderator.js
    ■ ■ ■ ■ ■ ■
     1 +import "./moderator.scss";
     2 +import { fixImageLinks } from "@wp/utils";
     3 +import { initSections } from "./page-loader";
     4 +import { moderatorPageScripts } from "@wp/pages";
     5 + 
     6 +fixImageLinks(document.images);
     7 +initSections(moderatorPageScripts);
     8 + 
  • client/src/js/moderator.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "../pages/moderator";
     2 + 
  • client/src/js/page-loader.js
    ■ ■ ■ ■ ■ ■
     1 +import { initShell } from "@wp/components";
     2 +import { initComponentFactory } from "./component-factory";
     3 + 
     4 +/**
     5 + * Initialises the scripts on the page.
     6 + * @param {Map<string, (section: HTMLElement) => void>} pages The map of page names and their callbacks.
     7 + */
     8 +export function initSections(pages) {
     9 + const sidebar = document.querySelector(".global-sidebar");
     10 + const main = document.querySelector("main");
     11 + /**
     12 + * @type {HTMLElement}
     13 + */
     14 + const footer = document.querySelector(".global-footer");
     15 + /**
     16 + * @type {NodeListOf<HTMLElement>}
     17 + */
     18 + const sections = main.querySelectorAll("main > .site-section");
     19 + 
     20 + initComponentFactory(footer);
     21 + initShell(sidebar);
     22 + sections.forEach(section => {
     23 + const sectionName = /site-section--([a-z\-]+)/i.exec(section.className)[1];
     24 + 
     25 + if (pages.has(sectionName)) {
     26 + const sectionCallback = pages.get(sectionName);
     27 + sectionCallback(section);
     28 + }
     29 + });
     30 + 
     31 +}
     32 + 
  • client/src/js/resumable.js
    ■ ■ ■ ■ ■ ■
     1 +/*
     2 +* MIT Licensed
     3 +* http://www.23developer.com/opensource
     4 +* http://github.com/23/resumable.js
     5 +* Steffen Tiedemann Christensen, steffen@23company.com
     6 +*/
     7 + 
     8 +(function(){
     9 +"use strict";
     10 + 
     11 + var Resumable = function(opts){
     12 + if ( !(this instanceof Resumable) ) {
     13 + return new Resumable(opts);
     14 + }
     15 + this.version = 1.0;
     16 + // SUPPORTED BY BROWSER?
     17 + // Check if these features are support by the browser:
     18 + // - File object type
     19 + // - Blob object type
     20 + // - FileList object type
     21 + // - slicing files
     22 + this.support = (
     23 + (typeof(File)!=='undefined')
     24 + &&
     25 + (typeof(Blob)!=='undefined')
     26 + &&
     27 + (typeof(FileList)!=='undefined')
     28 + &&
     29 + (!!Blob.prototype.webkitSlice||!!Blob.prototype.mozSlice||!!Blob.prototype.slice||false)
     30 + );
     31 + if(!this.support) return(false);
     32 + 
     33 + 
     34 + // PROPERTIES
     35 + var $ = this;
     36 + $.files = [];
     37 + $.defaults = {
     38 + chunkSize:1*1024*1024,
     39 + forceChunkSize:false,
     40 + simultaneousUploads:3,
     41 + fileParameterName:'file',
     42 + chunkNumberParameterName: 'resumableChunkNumber',
     43 + chunkSizeParameterName: 'resumableChunkSize',
     44 + currentChunkSizeParameterName: 'resumableCurrentChunkSize',
     45 + totalSizeParameterName: 'resumableTotalSize',
     46 + typeParameterName: 'resumableType',
     47 + identifierParameterName: 'resumableIdentifier',
     48 + fileNameParameterName: 'resumableFilename',
     49 + relativePathParameterName: 'resumableRelativePath',
     50 + totalChunksParameterName: 'resumableTotalChunks',
     51 + dragOverClass: 'dragover',
     52 + throttleProgressCallbacks: 0.5,
     53 + query:{},
     54 + headers:{},
     55 + preprocess:null,
     56 + preprocessFile:null,
     57 + method:'multipart',
     58 + uploadMethod: 'POST',
     59 + testMethod: 'GET',
     60 + prioritizeFirstAndLastChunk:false,
     61 + target:'/',
     62 + testTarget: null,
     63 + parameterNamespace:'',
     64 + testChunks:true,
     65 + generateUniqueIdentifier:null,
     66 + getTarget:null,
     67 + maxChunkRetries:100,
     68 + chunkRetryInterval:undefined,
     69 + permanentErrors:[400, 401, 403, 404, 409, 415, 500, 501],
     70 + maxFiles:undefined,
     71 + withCredentials:false,
     72 + xhrTimeout:0,
     73 + clearInput:true,
     74 + chunkFormat:'blob',
     75 + setChunkTypeFromFile:false,
     76 + maxFilesErrorCallback:function (files, errorCount) {
     77 + var maxFiles = $.getOpt('maxFiles');
     78 + alert('Please upload no more than ' + maxFiles + ' file' + (maxFiles === 1 ? '' : 's') + ' at a time.');
     79 + },
     80 + minFileSize:1,
     81 + minFileSizeErrorCallback:function(file, errorCount) {
     82 + alert(file.fileName||file.name +' is too small, please upload files larger than ' + $h.formatSize($.getOpt('minFileSize')) + '.');
     83 + },
     84 + maxFileSize:undefined,
     85 + maxFileSizeErrorCallback:function(file, errorCount) {
     86 + alert(file.fileName||file.name +' is too large, please upload files less than ' + $h.formatSize($.getOpt('maxFileSize')) + '.');
     87 + },
     88 + fileType: [],
     89 + fileTypeErrorCallback: function(file, errorCount) {
     90 + alert(file.fileName||file.name +' has type not allowed, please upload files of type ' + $.getOpt('fileType') + '.');
     91 + }
     92 + };
     93 + $.opts = opts||{};
     94 + $.getOpt = function(o) {
     95 + var $opt = this;
     96 + // Get multiple option if passed an array
     97 + if(o instanceof Array) {
     98 + var options = {};
     99 + $h.each(o, function(option){
     100 + options[option] = $opt.getOpt(option);
     101 + });
     102 + return options;
     103 + }
     104 + // Otherwise, just return a simple option
     105 + if ($opt instanceof ResumableChunk) {
     106 + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; }
     107 + else { $opt = $opt.fileObj; }
     108 + }
     109 + if ($opt instanceof ResumableFile) {
     110 + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; }
     111 + else { $opt = $opt.resumableObj; }
     112 + }
     113 + if ($opt instanceof Resumable) {
     114 + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; }
     115 + else { return $opt.defaults[o]; }
     116 + }
     117 + };
     118 + $.indexOf = function(array, obj) {
     119 + if (array.indexOf) { return array.indexOf(obj); }
     120 + for (var i = 0; i < array.length; i++) {
     121 + if (array[i] === obj) { return i; }
     122 + }
     123 + return -1;
     124 + }
     125 + 
     126 + // EVENTS
     127 + // catchAll(event, ...)
     128 + // fileSuccess(file), fileProgress(file), fileAdded(file, event), filesAdded(files, filesSkipped), fileRetry(file),
     129 + // fileError(file, message), complete(), progress(), error(message, file), pause()
     130 + $.events = [];
     131 + $.on = function(event,callback){
     132 + $.events.push(event.toLowerCase(), callback);
     133 + };
     134 + $.fire = function(){
     135 + // `arguments` is an object, not array, in FF, so:
     136 + var args = [];
     137 + for (var i=0; i<arguments.length; i++) args.push(arguments[i]);
     138 + // Find event listeners, and support pseudo-event `catchAll`
     139 + var event = args[0].toLowerCase();
     140 + for (var i=0; i<=$.events.length; i+=2) {
     141 + if($.events[i]==event) $.events[i+1].apply($,args.slice(1));
     142 + if($.events[i]=='catchall') $.events[i+1].apply(null,args);
     143 + }
     144 + if(event=='fileerror') $.fire('error', args[2], args[1]);
     145 + if(event=='fileprogress') $.fire('progress');
     146 + };
     147 + 
     148 + 
     149 + // INTERNAL HELPER METHODS (handy, but ultimately not part of uploading)
     150 + var $h = {
     151 + stopEvent: function(e){
     152 + e.stopPropagation();
     153 + e.preventDefault();
     154 + },
     155 + each: function(o,callback){
     156 + if(typeof(o.length)!=='undefined') {
     157 + for (var i=0; i<o.length; i++) {
     158 + // Array or FileList
     159 + if(callback(o[i])===false) return;
     160 + }
     161 + } else {
     162 + for (i in o) {
     163 + // Object
     164 + if(callback(i,o[i])===false) return;
     165 + }
     166 + }
     167 + },
     168 + generateUniqueIdentifier:function(file, event){
     169 + var custom = $.getOpt('generateUniqueIdentifier');
     170 + if(typeof custom === 'function') {
     171 + return custom(file, event);
     172 + }
     173 + var relativePath = file.webkitRelativePath||file.relativePath||file.fileName||file.name; // Some confusion in different versions of Firefox
     174 + var size = file.size;
     175 + return(size + '-' + relativePath.replace(/[^0-9a-zA-Z_-]/img, ''));
     176 + },
     177 + contains:function(array,test) {
     178 + var result = false;
     179 + 
     180 + $h.each(array, function(value) {
     181 + if (value == test) {
     182 + result = true;
     183 + return false;
     184 + }
     185 + return true;
     186 + });
     187 + 
     188 + return result;
     189 + },
     190 + formatSize:function(size){
     191 + if(size<1024) {
     192 + return size + ' bytes';
     193 + } else if(size<1024*1024) {
     194 + return (size/1024.0).toFixed(0) + ' KB';
     195 + } else if(size<1024*1024*1024) {
     196 + return (size/1024.0/1024.0).toFixed(1) + ' MB';
     197 + } else {
     198 + return (size/1024.0/1024.0/1024.0).toFixed(1) + ' GB';
     199 + }
     200 + },
     201 + getTarget:function(request, params){
     202 + var target = $.getOpt('target');
     203 + 
     204 + if (request === 'test' && $.getOpt('testTarget')) {
     205 + target = $.getOpt('testTarget') === '/' ? $.getOpt('target') : $.getOpt('testTarget');
     206 + }
     207 + 
     208 + if (typeof target === 'function') {
     209 + return target(params);
     210 + }
     211 + 
     212 + var separator = target.indexOf('?') < 0 ? '?' : '&';
     213 + var joinedParams = params.join('&');
     214 + 
     215 + if (joinedParams) target = target + separator + joinedParams;
     216 + 
     217 + return target;
     218 + }
     219 + };
     220 + 
     221 + var onDrop = function(e){
     222 + e.currentTarget.classList.remove($.getOpt('dragOverClass'));
     223 + $h.stopEvent(e);
     224 + 
     225 + //handle dropped things as items if we can (this lets us deal with folders nicer in some cases)
     226 + if (e.dataTransfer && e.dataTransfer.items) {
     227 + loadFiles(e.dataTransfer.items, e);
     228 + }
     229 + //else handle them as files
     230 + else if (e.dataTransfer && e.dataTransfer.files) {
     231 + loadFiles(e.dataTransfer.files, e);
     232 + }
     233 + };
     234 + var onDragLeave = function(e){
     235 + e.currentTarget.classList.remove($.getOpt('dragOverClass'));
     236 + };
     237 + var onDragOverEnter = function(e) {
     238 + e.preventDefault();
     239 + var dt = e.dataTransfer;
     240 + if ($.indexOf(dt.types, "Files") >= 0) { // only for file drop
     241 + e.stopPropagation();
     242 + dt.dropEffect = "copy";
     243 + dt.effectAllowed = "copy";
     244 + e.currentTarget.classList.add($.getOpt('dragOverClass'));
     245 + } else { // not work on IE/Edge....
     246 + dt.dropEffect = "none";
     247 + dt.effectAllowed = "none";
     248 + }
     249 + };
     250 + 
     251 + /**
     252 + * processes a single upload item (file or directory)
     253 + * @param {Object} item item to upload, may be file or directory entry
     254 + * @param {string} path current file path
     255 + * @param {File[]} items list of files to append new items to
     256 + * @param {Function} cb callback invoked when item is processed
     257 + */
     258 + function processItem(item, path, items, cb) {
     259 + var entry;
     260 + if(item.isFile){
     261 + // file provided
     262 + return item.file(function(file){
     263 + file.relativePath = path + file.name;
     264 + items.push(file);
     265 + cb();
     266 + });
     267 + }else if(item.isDirectory){
     268 + // item is already a directory entry, just assign
     269 + entry = item;
     270 + }else if(item instanceof File) {
     271 + items.push(item);
     272 + }
     273 + if('function' === typeof item.webkitGetAsEntry){
     274 + // get entry from file object
     275 + entry = item.webkitGetAsEntry();
     276 + }
     277 + if(entry && entry.isDirectory){
     278 + // directory provided, process it
     279 + return processDirectory(entry, path + entry.name + '/', items, cb);
     280 + }
     281 + if('function' === typeof item.getAsFile){
     282 + // item represents a File object, convert it
     283 + item = item.getAsFile();
     284 + if(item instanceof File) {
     285 + item.relativePath = path + item.name;
     286 + items.push(item);
     287 + }
     288 + }
     289 + cb(); // indicate processing is done
     290 + }
     291 + 
     292 + 
     293 + /**
     294 + * cps-style list iteration.
     295 + * invokes all functions in list and waits for their callback to be
     296 + * triggered.
     297 + * @param {Function[]} items list of functions expecting callback parameter
     298 + * @param {Function} cb callback to trigger after the last callback has been invoked
     299 + */
     300 + function processCallbacks(items, cb){
     301 + if(!items || items.length === 0){
     302 + // empty or no list, invoke callback
     303 + return cb();
     304 + }
     305 + // invoke current function, pass the next part as continuation
     306 + items[0](function(){
     307 + processCallbacks(items.slice(1), cb);
     308 + });
     309 + }
     310 + 
     311 + /**
     312 + * recursively traverse directory and collect files to upload
     313 + * @param {Object} directory directory to process
     314 + * @param {string} path current path
     315 + * @param {File[]} items target list of items
     316 + * @param {Function} cb callback invoked after traversing directory
     317 + */
     318 + function processDirectory (directory, path, items, cb) {
     319 + var dirReader = directory.createReader();
     320 + var allEntries = [];
     321 + 
     322 + function readEntries () {
     323 + dirReader.readEntries(function(entries){
     324 + if (entries.length) {
     325 + allEntries = allEntries.concat(entries);
     326 + return readEntries();
     327 + }
     328 + 
     329 + // process all conversion callbacks, finally invoke own one
     330 + processCallbacks(
     331 + allEntries.map(function(entry){
     332 + // bind all properties except for callback
     333 + return processItem.bind(null, entry, path, items);
     334 + }),
     335 + cb
     336 + );
     337 + });
     338 + }
     339 + 
     340 + readEntries();
     341 + }
     342 + 
     343 + /**
     344 + * process items to extract files to be uploaded
     345 + * @param {File[]} items items to process
     346 + * @param {Event} event event that led to upload
     347 + */
     348 + function loadFiles(items, event) {
     349 + if(!items.length){
     350 + return; // nothing to do
     351 + }
     352 + $.fire('beforeAdd');
     353 + var files = [];
     354 + processCallbacks(
     355 + Array.prototype.map.call(items, function(item){
     356 + // bind all properties except for callback
     357 + var entry = item;
     358 + if('function' === typeof item.webkitGetAsEntry){
     359 + entry = item.webkitGetAsEntry();
     360 + }
     361 + return processItem.bind(null, entry, "", files);
     362 + }),
     363 + function(){
     364 + if(files.length){
     365 + // at least one file found
     366 + appendFilesFromFileList(files, event);
     367 + }
     368 + }
     369 + );
     370 + };
     371 + 
     372 + var appendFilesFromFileList = function(fileList, event){
     373 + // check for uploading too many files
     374 + var errorCount = 0;
     375 + var o = $.getOpt(['maxFiles', 'minFileSize', 'maxFileSize', 'maxFilesErrorCallback', 'minFileSizeErrorCallback', 'maxFileSizeErrorCallback', 'fileType', 'fileTypeErrorCallback']);
     376 + if (typeof(o.maxFiles)!=='undefined' && o.maxFiles<(fileList.length+$.files.length)) {
     377 + // if single-file upload, file is already added, and trying to add 1 new file, simply replace the already-added file
     378 + if (o.maxFiles===1 && $.files.length===1 && fileList.length===1) {
     379 + $.removeFile($.files[0]);
     380 + } else {
     381 + o.maxFilesErrorCallback(fileList, errorCount++);
     382 + return false;
     383 + }
     384 + }
     385 + var files = [], filesSkipped = [], remaining = fileList.length;
     386 + var decreaseReamining = function(){
     387 + if(!--remaining){
     388 + // all files processed, trigger event
     389 + if(!files.length && !filesSkipped.length){
     390 + // no succeeded files, just skip
     391 + return;
     392 + }
     393 + window.setTimeout(function(){
     394 + $.fire('filesAdded', files, filesSkipped);
     395 + },0);
     396 + }
     397 + };
     398 + $h.each(fileList, function(file){
     399 + var fileName = file.name;
     400 + var fileType = file.type; // e.g video/mp4
     401 + if(o.fileType.length > 0){
     402 + var fileTypeFound = false;
     403 + for(var index in o.fileType){
     404 + // For good behaviour we do some inital sanitizing. Remove spaces and lowercase all
     405 + o.fileType[index] = o.fileType[index].replace(/\s/g, '').toLowerCase();
     406 + 
     407 + // Allowing for both [extension, .extension, mime/type, mime/*]
     408 + var extension = ((o.fileType[index].match(/^[^.][^/]+$/)) ? '.' : '') + o.fileType[index];
     409 + 
     410 + if ((fileName.substr(-1 * extension.length).toLowerCase() === extension) ||
     411 + //If MIME type, check for wildcard or if extension matches the files tiletype
     412 + (extension.indexOf('/') !== -1 && (
     413 + (extension.indexOf('*') !== -1 && fileType.substr(0, extension.indexOf('*')) === extension.substr(0, extension.indexOf('*'))) ||
     414 + fileType === extension
     415 + ))
     416 + ){
     417 + fileTypeFound = true;
     418 + break;
     419 + }
     420 + }
     421 + if (!fileTypeFound) {
     422 + o.fileTypeErrorCallback(file, errorCount++);
     423 + return true;
     424 + }
     425 + }
     426 + 
     427 + if (typeof(o.minFileSize)!=='undefined' && file.size<o.minFileSize) {
     428 + o.minFileSizeErrorCallback(file, errorCount++);
     429 + return true;
     430 + }
     431 + if (typeof(o.maxFileSize)!=='undefined' && file.size>o.maxFileSize) {
     432 + o.maxFileSizeErrorCallback(file, errorCount++);
     433 + return true;
     434 + }
     435 + 
     436 + function addFile(uniqueIdentifier){
     437 + if (!$.getFromUniqueIdentifier(uniqueIdentifier)) {(function(){
     438 + file.uniqueIdentifier = uniqueIdentifier;
     439 + var f = new ResumableFile($, file, uniqueIdentifier);
     440 + $.files.push(f);
     441 + files.push(f);
     442 + f.container = (typeof event != 'undefined' ? event.srcElement : null);
     443 + window.setTimeout(function(){
     444 + $.fire('fileAdded', f, event)
     445 + },0);
     446 + })()} else {
     447 + filesSkipped.push(file);
     448 + };
     449 + decreaseReamining();
     450 + }
     451 + // directories have size == 0
     452 + var uniqueIdentifier = $h.generateUniqueIdentifier(file, event);
     453 + if(uniqueIdentifier && typeof uniqueIdentifier.then === 'function'){
     454 + // Promise or Promise-like object provided as unique identifier
     455 + uniqueIdentifier
     456 + .then(
     457 + function(uniqueIdentifier){
     458 + // unique identifier generation succeeded
     459 + addFile(uniqueIdentifier);
     460 + },
     461 + function(){
     462 + // unique identifier generation failed
     463 + // skip further processing, only decrease file count
     464 + decreaseReamining();
     465 + }
     466 + );
     467 + }else{
     468 + // non-Promise provided as unique identifier, process synchronously
     469 + addFile(uniqueIdentifier);
     470 + }
     471 + });
     472 + };
     473 + 
     474 + // INTERNAL OBJECT TYPES
     475 + function ResumableFile(resumableObj, file, uniqueIdentifier){
     476 + var $ = this;
     477 + $.opts = {};
     478 + $.getOpt = resumableObj.getOpt;
     479 + $._prevProgress = 0;
     480 + $.resumableObj = resumableObj;
     481 + $.file = file;
     482 + $.fileName = file.fileName||file.name; // Some confusion in different versions of Firefox
     483 + $.size = file.size;
     484 + $.relativePath = file.relativePath || file.webkitRelativePath || $.fileName;
     485 + $.uniqueIdentifier = uniqueIdentifier;
     486 + $._pause = false;
     487 + $.container = '';
     488 + $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished
     489 + var _error = uniqueIdentifier !== undefined;
     490 + 
     491 + // Callback when something happens within the chunk
     492 + var chunkEvent = function(event, message){
     493 + // event can be 'progress', 'success', 'error' or 'retry'
     494 + switch(event){
     495 + case 'progress':
     496 + $.resumableObj.fire('fileProgress', $, message);
     497 + break;
     498 + case 'error':
     499 + $.abort();
     500 + _error = true;
     501 + $.chunks = [];
     502 + $.resumableObj.fire('fileError', $, message);
     503 + break;
     504 + case 'success':
     505 + if(_error) return;
     506 + $.resumableObj.fire('fileProgress', $, message); // it's at least progress
     507 + if($.isComplete()) {
     508 + $.resumableObj.fire('fileSuccess', $, message);
     509 + }
     510 + break;
     511 + case 'retry':
     512 + $.resumableObj.fire('fileRetry', $);
     513 + break;
     514 + }
     515 + };
     516 + 
     517 + // Main code to set up a file object with chunks,
     518 + // packaged to be able to handle retries if needed.
     519 + $.chunks = [];
     520 + $.abort = function(){
     521 + // Stop current uploads
     522 + var abortCount = 0;
     523 + $h.each($.chunks, function(c){
     524 + if(c.status()=='uploading') {
     525 + c.abort();
     526 + abortCount++;
     527 + }
     528 + });
     529 + if(abortCount>0) $.resumableObj.fire('fileProgress', $);
     530 + };
     531 + $.cancel = function(){
     532 + // Reset this file to be void
     533 + var _chunks = $.chunks;
     534 + $.chunks = [];
     535 + // Stop current uploads
     536 + $h.each(_chunks, function(c){
     537 + if(c.status()=='uploading') {
     538 + c.abort();
     539 + $.resumableObj.uploadNextChunk();
     540 + }
     541 + });
     542 + $.resumableObj.removeFile($);
     543 + $.resumableObj.fire('fileProgress', $);
     544 + };
     545 + $.retry = function(){
     546 + $.bootstrap();
     547 + var firedRetry = false;
     548 + $.resumableObj.on('chunkingComplete', function(){
     549 + if(!firedRetry) $.resumableObj.upload();
     550 + firedRetry = true;
     551 + });
     552 + };
     553 + $.bootstrap = function(){
     554 + $.abort();
     555 + _error = false;
     556 + // Rebuild stack of chunks from file
     557 + $.chunks = [];
     558 + $._prevProgress = 0;
     559 + var round = $.getOpt('forceChunkSize') ? Math.ceil : Math.floor;
     560 + var maxOffset = Math.max(round($.file.size/$.getOpt('chunkSize')),1);
     561 + for (var offset=0; offset<maxOffset; offset++) {(function(offset){
     562 + $.chunks.push(new ResumableChunk($.resumableObj, $, offset, chunkEvent));
     563 + $.resumableObj.fire('chunkingProgress',$,offset/maxOffset);
     564 + })(offset)}
     565 + window.setTimeout(function(){
     566 + $.resumableObj.fire('chunkingComplete',$);
     567 + },0);
     568 + };
     569 + $.progress = function(){
     570 + if(_error) return(1);
     571 + // Sum up progress across everything
     572 + var ret = 0;
     573 + var error = false;
     574 + $h.each($.chunks, function(c){
     575 + if(c.status()=='error') error = true;
     576 + ret += c.progress(true); // get chunk progress relative to entire file
     577 + });
     578 + ret = (error ? 1 : (ret>0.99999 ? 1 : ret));
     579 + ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused
     580 + $._prevProgress = ret;
     581 + return(ret);
     582 + };
     583 + $.isUploading = function(){
     584 + var uploading = false;
     585 + $h.each($.chunks, function(chunk){
     586 + if(chunk.status()=='uploading') {
     587 + uploading = true;
     588 + return(false);
     589 + }
     590 + });
     591 + return(uploading);
     592 + };
     593 + $.isComplete = function(){
     594 + var outstanding = false;
     595 + if ($.preprocessState === 1) {
     596 + return(false);
     597 + }
     598 + $h.each($.chunks, function(chunk){
     599 + var status = chunk.status();
     600 + if(status=='pending' || status=='uploading' || chunk.preprocessState === 1) {
     601 + outstanding = true;
     602 + return(false);
     603 + }
     604 + });
     605 + return(!outstanding);
     606 + };
     607 + $.pause = function(pause){
     608 + if(typeof(pause)==='undefined'){
     609 + $._pause = ($._pause ? false : true);
     610 + }else{
     611 + $._pause = pause;
     612 + }
     613 + };
     614 + $.isPaused = function() {
     615 + return $._pause;
     616 + };
     617 + $.preprocessFinished = function(){
     618 + $.preprocessState = 2;
     619 + $.upload();
     620 + };
     621 + $.upload = function () {
     622 + var found = false;
     623 + if ($.isPaused() === false) {
     624 + var preprocess = $.getOpt('preprocessFile');
     625 + if(typeof preprocess === 'function') {
     626 + switch($.preprocessState) {
     627 + case 0: $.preprocessState = 1; preprocess($); return(true);
     628 + case 1: return(true);
     629 + case 2: break;
     630 + }
     631 + }
     632 + $h.each($.chunks, function (chunk) {
     633 + if (chunk.status() == 'pending' && chunk.preprocessState !== 1) {
     634 + chunk.send();
     635 + found = true;
     636 + return(false);
     637 + }
     638 + });
     639 + }
     640 + return(found);
     641 + }
     642 + $.markChunksCompleted = function (chunkNumber) {
     643 + if (!$.chunks || $.chunks.length <= chunkNumber) {
     644 + return;
     645 + }
     646 + for (var num = 0; num < chunkNumber; num++) {
     647 + $.chunks[num].markComplete = true;
     648 + }
     649 + };
     650 + 
     651 + // Bootstrap and return
     652 + $.resumableObj.fire('chunkingStart', $);
     653 + $.bootstrap();
     654 + return(this);
     655 + }
     656 + 
     657 + 
     658 + function ResumableChunk(resumableObj, fileObj, offset, callback){
     659 + var $ = this;
     660 + $.opts = {};
     661 + $.getOpt = resumableObj.getOpt;
     662 + $.resumableObj = resumableObj;
     663 + $.fileObj = fileObj;
     664 + $.fileObjSize = fileObj.size;
     665 + $.fileObjType = fileObj.file.type;
     666 + $.offset = offset;
     667 + $.callback = callback;
     668 + $.lastProgressCallback = (new Date);
     669 + $.tested = false;
     670 + $.retries = 0;
     671 + $.pendingRetry = false;
     672 + $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished
     673 + $.markComplete = false;
     674 + 
     675 + // Computed properties
     676 + var chunkSize = $.getOpt('chunkSize');
     677 + $.loaded = 0;
     678 + $.startByte = $.offset*chunkSize;
     679 + $.endByte = Math.min($.fileObjSize, ($.offset+1)*chunkSize);
     680 + if ($.fileObjSize-$.endByte < chunkSize && !$.getOpt('forceChunkSize')) {
     681 + // The last chunk will be bigger than the chunk size, but less than 2*chunkSize
     682 + $.endByte = $.fileObjSize;
     683 + }
     684 + $.xhr = null;
     685 + 
     686 + // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session
     687 + $.test = function(){
     688 + // Set up request and listen for event
     689 + $.xhr = new XMLHttpRequest();
     690 + 
     691 + var testHandler = function(e){
     692 + $.tested = true;
     693 + var status = $.status();
     694 + if(status=='success') {
     695 + $.callback(status, $.message());
     696 + $.resumableObj.uploadNextChunk();
     697 + } else {
     698 + $.send();
     699 + }
     700 + };
     701 + $.xhr.addEventListener('load', testHandler, false);
     702 + $.xhr.addEventListener('error', testHandler, false);
     703 + $.xhr.addEventListener('timeout', testHandler, false);
     704 + 
     705 + // Add data from the query options
     706 + var params = [];
     707 + var parameterNamespace = $.getOpt('parameterNamespace');
     708 + var customQuery = $.getOpt('query');
     709 + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $);
     710 + $h.each(customQuery, function(k,v){
     711 + params.push([encodeURIComponent(parameterNamespace+k), encodeURIComponent(v)].join('='));
     712 + });
     713 + // Add extra data to identify chunk
     714 + params = params.concat(
     715 + [
     716 + // define key/value pairs for additional parameters
     717 + ['chunkNumberParameterName', $.offset + 1],
     718 + ['chunkSizeParameterName', $.getOpt('chunkSize')],
     719 + ['currentChunkSizeParameterName', $.endByte - $.startByte],
     720 + ['totalSizeParameterName', $.fileObjSize],
     721 + ['typeParameterName', $.fileObjType],
     722 + ['identifierParameterName', $.fileObj.uniqueIdentifier],
     723 + ['fileNameParameterName', $.fileObj.fileName],
     724 + ['relativePathParameterName', $.fileObj.relativePath],
     725 + ['totalChunksParameterName', $.fileObj.chunks.length]
     726 + ].filter(function(pair){
     727 + // include items that resolve to truthy values
     728 + // i.e. exclude false, null, undefined and empty strings
     729 + return $.getOpt(pair[0]);
     730 + })
     731 + .map(function(pair){
     732 + // map each key/value pair to its final form
     733 + return [
     734 + parameterNamespace + $.getOpt(pair[0]),
     735 + encodeURIComponent(pair[1])
     736 + ].join('=');
     737 + })
     738 + );
     739 + // Append the relevant chunk and send it
     740 + $.xhr.open($.getOpt('testMethod'), $h.getTarget('test', params));
     741 + $.xhr.timeout = $.getOpt('xhrTimeout');
     742 + $.xhr.withCredentials = $.getOpt('withCredentials');
     743 + // Add data from header options
     744 + var customHeaders = $.getOpt('headers');
     745 + if(typeof customHeaders === 'function') {
     746 + customHeaders = customHeaders($.fileObj, $);
     747 + }
     748 + $h.each(customHeaders, function(k,v) {
     749 + $.xhr.setRequestHeader(k, v);
     750 + });
     751 + $.xhr.send(null);
     752 + };
     753 + 
     754 + $.preprocessFinished = function(){
     755 + $.preprocessState = 2;
     756 + $.send();
     757 + };
     758 + 
     759 + // send() uploads the actual data in a POST call
     760 + $.send = function(){
     761 + var preprocess = $.getOpt('preprocess');
     762 + if(typeof preprocess === 'function') {
     763 + switch($.preprocessState) {
     764 + case 0: $.preprocessState = 1; preprocess($); return;
     765 + case 1: return;
     766 + case 2: break;
     767 + }
     768 + }
     769 + if($.getOpt('testChunks') && !$.tested) {
     770 + $.test();
     771 + return;
     772 + }
     773 + 
     774 + // Set up request and listen for event
     775 + $.xhr = new XMLHttpRequest();
     776 + 
     777 + // Progress
     778 + $.xhr.upload.addEventListener('progress', function(e){
     779 + if( (new Date) - $.lastProgressCallback > $.getOpt('throttleProgressCallbacks') * 1000 ) {
     780 + $.callback('progress');
     781 + $.lastProgressCallback = (new Date);
     782 + }
     783 + $.loaded=e.loaded||0;
     784 + }, false);
     785 + $.loaded = 0;
     786 + $.pendingRetry = false;
     787 + $.callback('progress');
     788 + 
     789 + // Done (either done, failed or retry)
     790 + var doneHandler = function(e){
     791 + var status = $.status();
     792 + if(status=='success'||status=='error') {
     793 + $.callback(status, $.message());
     794 + $.resumableObj.uploadNextChunk();
     795 + } else {
     796 + $.callback('retry', $.message());
     797 + $.abort();
     798 + $.retries++;
     799 + var retryInterval = $.getOpt('chunkRetryInterval');
     800 + if(retryInterval !== undefined) {
     801 + $.pendingRetry = true;
     802 + setTimeout($.send, retryInterval);
     803 + } else {
     804 + $.send();
     805 + }
     806 + }
     807 + };
     808 + $.xhr.addEventListener('load', doneHandler, false);
     809 + $.xhr.addEventListener('error', doneHandler, false);
     810 + $.xhr.addEventListener('timeout', doneHandler, false);
     811 + 
     812 + // Set up the basic query data from Resumable
     813 + var query = [
     814 + ['chunkNumberParameterName', $.offset + 1],
     815 + ['chunkSizeParameterName', $.getOpt('chunkSize')],
     816 + ['currentChunkSizeParameterName', $.endByte - $.startByte],
     817 + ['totalSizeParameterName', $.fileObjSize],
     818 + ['typeParameterName', $.fileObjType],
     819 + ['identifierParameterName', $.fileObj.uniqueIdentifier],
     820 + ['fileNameParameterName', $.fileObj.fileName],
     821 + ['relativePathParameterName', $.fileObj.relativePath],
     822 + ['totalChunksParameterName', $.fileObj.chunks.length],
     823 + ].filter(function(pair){
     824 + // include items that resolve to truthy values
     825 + // i.e. exclude false, null, undefined and empty strings
     826 + return $.getOpt(pair[0]);
     827 + })
     828 + .reduce(function(query, pair){
     829 + // assign query key/value
     830 + query[$.getOpt(pair[0])] = pair[1];
     831 + return query;
     832 + }, {});
     833 + // Mix in custom data
     834 + var customQuery = $.getOpt('query');
     835 + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $);
     836 + $h.each(customQuery, function(k,v){
     837 + query[k] = v;
     838 + });
     839 + 
     840 + var func = ($.fileObj.file.slice ? 'slice' : ($.fileObj.file.mozSlice ? 'mozSlice' : ($.fileObj.file.webkitSlice ? 'webkitSlice' : 'slice')));
     841 + var bytes = $.fileObj.file[func]($.startByte, $.endByte, $.getOpt('setChunkTypeFromFile') ? $.fileObj.file.type : "");
     842 + var data = null;
     843 + var params = [];
     844 + 
     845 + var parameterNamespace = $.getOpt('parameterNamespace');
     846 + if ($.getOpt('method') === 'octet') {
     847 + // Add data from the query options
     848 + data = bytes;
     849 + $h.each(query, function (k, v) {
     850 + params.push([encodeURIComponent(parameterNamespace + k), encodeURIComponent(v)].join('='));
     851 + });
     852 + } else {
     853 + // Add data from the query options
     854 + data = new FormData();
     855 + $h.each(query, function (k, v) {
     856 + data.append(parameterNamespace + k, v);
     857 + params.push([encodeURIComponent(parameterNamespace + k), encodeURIComponent(v)].join('='));
     858 + });
     859 + if ($.getOpt('chunkFormat') == 'blob') {
     860 + data.append(parameterNamespace + $.getOpt('fileParameterName'), bytes, $.fileObj.fileName);
     861 + }
     862 + else if ($.getOpt('chunkFormat') == 'base64') {
     863 + var fr = new FileReader();
     864 + fr.onload = function (e) {
     865 + data.append(parameterNamespace + $.getOpt('fileParameterName'), fr.result);
     866 + $.xhr.send(data);
     867 + }
     868 + fr.readAsDataURL(bytes);
     869 + }
     870 + }
     871 + 
     872 + var target = $h.getTarget('upload', params);
     873 + var method = $.getOpt('uploadMethod');
     874 + 
     875 + $.xhr.open(method, target);
     876 + if ($.getOpt('method') === 'octet') {
     877 + $.xhr.setRequestHeader('Content-Type', 'application/octet-stream');
     878 + }
     879 + $.xhr.timeout = $.getOpt('xhrTimeout');
     880 + $.xhr.withCredentials = $.getOpt('withCredentials');
     881 + // Add data from header options
     882 + var customHeaders = $.getOpt('headers');
     883 + if(typeof customHeaders === 'function') {
     884 + customHeaders = customHeaders($.fileObj, $);
     885 + }
     886 + 
     887 + $h.each(customHeaders, function(k,v) {
     888 + $.xhr.setRequestHeader(k, v);
     889 + });
     890 + 
     891 + if ($.getOpt('chunkFormat') == 'blob') {
     892 + $.xhr.send(data);
     893 + }
     894 + };
     895 + $.abort = function(){
     896 + // Abort and reset
     897 + if($.xhr) $.xhr.abort();
     898 + $.xhr = null;
     899 + };
     900 + $.status = function(){
     901 + // Returns: 'pending', 'uploading', 'success', 'error'
     902 + if($.pendingRetry) {
     903 + // if pending retry then that's effectively the same as actively uploading,
     904 + // there might just be a slight delay before the retry starts
     905 + return('uploading');
     906 + } else if($.markComplete) {
     907 + return 'success';
     908 + } else if(!$.xhr) {
     909 + return('pending');
     910 + } else if($.xhr.readyState<4) {
     911 + // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening
     912 + return('uploading');
     913 + } else {
     914 + if($.xhr.status == 200 || $.xhr.status == 201) {
     915 + // HTTP 200, 201 (created)
     916 + return('success');
     917 + } else if($h.contains($.getOpt('permanentErrors'), $.xhr.status) || $.retries >= $.getOpt('maxChunkRetries')) {
     918 + // HTTP 400, 404, 409, 415, 500, 501 (permanent error)
     919 + return('error');
     920 + } else {
     921 + // this should never happen, but we'll reset and queue a retry
     922 + // a likely case for this would be 503 service unavailable
     923 + $.abort();
     924 + return('pending');
     925 + }
     926 + }
     927 + };
     928 + $.message = function(){
     929 + return($.xhr ? $.xhr.responseText : '');
     930 + };
     931 + $.progress = function(relative){
     932 + if(typeof(relative)==='undefined') relative = false;
     933 + var factor = (relative ? ($.endByte-$.startByte)/$.fileObjSize : 1);
     934 + if($.pendingRetry) return(0);
     935 + if((!$.xhr || !$.xhr.status) && !$.markComplete) factor*=.95;
     936 + var s = $.status();
     937 + switch(s){
     938 + case 'success':
     939 + case 'error':
     940 + return(1*factor);
     941 + case 'pending':
     942 + return(0*factor);
     943 + default:
     944 + return($.loaded/($.endByte-$.startByte)*factor);
     945 + }
     946 + };
     947 + return(this);
     948 + }
     949 + 
     950 + // QUEUE
     951 + $.uploadNextChunk = function(){
     952 + var found = false;
     953 + 
     954 + // In some cases (such as videos) it's really handy to upload the first
     955 + // and last chunk of a file quickly; this let's the server check the file's
     956 + // metadata and determine if there's even a point in continuing.
     957 + if ($.getOpt('prioritizeFirstAndLastChunk')) {
     958 + $h.each($.files, function(file){
     959 + if(file.chunks.length && file.chunks[0].status()=='pending' && file.chunks[0].preprocessState === 0) {
     960 + file.chunks[0].send();
     961 + found = true;
     962 + return(false);
     963 + }
     964 + if(file.chunks.length>1 && file.chunks[file.chunks.length-1].status()=='pending' && file.chunks[file.chunks.length-1].preprocessState === 0) {
     965 + file.chunks[file.chunks.length-1].send();
     966 + found = true;
     967 + return(false);
     968 + }
     969 + });
     970 + if(found) return(true);
     971 + }
     972 + 
     973 + // Now, simply look for the next, best thing to upload
     974 + $h.each($.files, function(file){
     975 + found = file.upload();
     976 + if(found) return(false);
     977 + });
     978 + if(found) return(true);
     979 + 
     980 + // The are no more outstanding chunks to upload, check is everything is done
     981 + var outstanding = false;
     982 + $h.each($.files, function(file){
     983 + if(!file.isComplete()) {
     984 + outstanding = true;
     985 + return(false);
     986 + }
     987 + });
     988 + if(!outstanding) {
     989 + // All chunks have been uploaded, complete
     990 + $.fire('complete');
     991 + }
     992 + return(false);
     993 + };
     994 + 
     995 + 
     996 + // PUBLIC METHODS FOR RESUMABLE.JS
     997 + $.assignBrowse = function(domNodes, isDirectory){
     998 + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes];
     999 + $h.each(domNodes, function(domNode) {
     1000 + var input;
     1001 + if(domNode.tagName==='INPUT' && domNode.type==='file'){
     1002 + input = domNode;
     1003 + } else {
     1004 + input = document.createElement('input');
     1005 + input.setAttribute('type', 'file');
     1006 + input.style.display = 'none';
     1007 + domNode.addEventListener('click', function(){
     1008 + input.style.opacity = 0;
     1009 + input.style.display='block';
     1010 + input.focus();
     1011 + input.click();
     1012 + input.style.display='none';
     1013 + }, false);
     1014 + domNode.appendChild(input);
     1015 + }
     1016 + var maxFiles = $.getOpt('maxFiles');
     1017 + if (typeof(maxFiles)==='undefined'||maxFiles!=1){
     1018 + input.setAttribute('multiple', 'multiple');
     1019 + } else {
     1020 + input.removeAttribute('multiple');
     1021 + }
     1022 + if(isDirectory){
     1023 + input.setAttribute('webkitdirectory', 'webkitdirectory');
     1024 + } else {
     1025 + input.removeAttribute('webkitdirectory');
     1026 + }
     1027 + var fileTypes = $.getOpt('fileType');
     1028 + if (typeof (fileTypes) !== 'undefined' && fileTypes.length >= 1) {
     1029 + input.setAttribute('accept', fileTypes.map(function (e) {
     1030 + e = e.replace(/\s/g, '').toLowerCase();
     1031 + if(e.match(/^[^.][^/]+$/)){
     1032 + e = '.' + e;
     1033 + }
     1034 + return e;
     1035 + }).join(','));
     1036 + }
     1037 + else {
     1038 + input.removeAttribute('accept');
     1039 + }
     1040 + // When new files are added, simply append them to the overall list
     1041 + input.addEventListener('change', function(e){
     1042 + appendFilesFromFileList(e.target.files,e);
     1043 + var clearInput = $.getOpt('clearInput');
     1044 + if (clearInput) {
     1045 + e.target.value = '';
     1046 + }
     1047 + }, false);
     1048 + });
     1049 + };
     1050 + $.assignDrop = function(domNodes){
     1051 + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes];
     1052 + 
     1053 + $h.each(domNodes, function(domNode) {
     1054 + domNode.addEventListener('dragover', onDragOverEnter, false);
     1055 + domNode.addEventListener('dragenter', onDragOverEnter, false);
     1056 + domNode.addEventListener('dragleave', onDragLeave, false);
     1057 + domNode.addEventListener('drop', onDrop, false);
     1058 + });
     1059 + };
     1060 + $.unAssignDrop = function(domNodes) {
     1061 + if (typeof(domNodes.length) == 'undefined') domNodes = [domNodes];
     1062 + 
     1063 + $h.each(domNodes, function(domNode) {
     1064 + domNode.removeEventListener('dragover', onDragOverEnter);
     1065 + domNode.removeEventListener('dragenter', onDragOverEnter);
     1066 + domNode.removeEventListener('dragleave', onDragLeave);
     1067 + domNode.removeEventListener('drop', onDrop);
     1068 + });
     1069 + };
     1070 + $.isUploading = function(){
     1071 + var uploading = false;
     1072 + $h.each($.files, function(file){
     1073 + if (file.isUploading()) {
     1074 + uploading = true;
     1075 + return(false);
     1076 + }
     1077 + });
     1078 + return(uploading);
     1079 + };
     1080 + $.upload = function(){
     1081 + // Make sure we don't start too many uploads at once
     1082 + if($.isUploading()) return;
     1083 + // Kick off the queue
     1084 + $.fire('uploadStart');
     1085 + for (var num=1; num<=$.getOpt('simultaneousUploads'); num++) {
     1086 + $.uploadNextChunk();
     1087 + }
     1088 + };
     1089 + $.pause = function(){
     1090 + // Resume all chunks currently being uploaded
     1091 + $h.each($.files, function(file){
     1092 + file.abort();
     1093 + });
     1094 + $.fire('pause');
     1095 + };
     1096 + $.cancel = function(){
     1097 + $.fire('beforeCancel');
     1098 + for(var i = $.files.length - 1; i >= 0; i--) {
     1099 + $.files[i].cancel();
     1100 + }
     1101 + $.fire('cancel');
     1102 + };
     1103 + $.progress = function(){
     1104 + var totalDone = 0;
     1105 + var totalSize = 0;
     1106 + // Resume all chunks currently being uploaded
     1107 + $h.each($.files, function(file){
     1108 + totalDone += file.progress()*file.size;
     1109 + totalSize += file.size;
     1110 + });
     1111 + return(totalSize>0 ? totalDone/totalSize : 0);
     1112 + };
     1113 + $.addFile = function(file, event){
     1114 + appendFilesFromFileList([file], event);
     1115 + };
     1116 + $.addFiles = function(files, event){
     1117 + appendFilesFromFileList(files, event);
     1118 + };
     1119 + $.removeFile = function(file){
     1120 + for(var i = $.files.length - 1; i >= 0; i--) {
     1121 + if($.files[i] === file) {
     1122 + $.files.splice(i, 1);
     1123 + }
     1124 + }
     1125 + };
     1126 + $.getFromUniqueIdentifier = function(uniqueIdentifier){
     1127 + var ret = false;
     1128 + $h.each($.files, function(f){
     1129 + if(f.uniqueIdentifier==uniqueIdentifier) ret = f;
     1130 + });
     1131 + return(ret);
     1132 + };
     1133 + $.getSize = function(){
     1134 + var totalSize = 0;
     1135 + $h.each($.files, function(file){
     1136 + totalSize += file.size;
     1137 + });
     1138 + return(totalSize);
     1139 + };
     1140 + $.handleDropEvent = function (e) {
     1141 + onDrop(e);
     1142 + };
     1143 + $.handleChangeEvent = function (e) {
     1144 + appendFilesFromFileList(e.target.files, e);
     1145 + e.target.value = '';
     1146 + };
     1147 + $.updateQuery = function(query){
     1148 + $.opts.query = query;
     1149 + };
     1150 + 
     1151 + return(this);
     1152 + };
     1153 + 
     1154 + 
     1155 + // Node.js-style export for Node and Component
     1156 + if (typeof module != 'undefined') {
     1157 + // left here for backwards compatibility
     1158 + module.exports = Resumable;
     1159 + module.exports.Resumable = Resumable;
     1160 + } else if (typeof define === "function" && define.amd) {
     1161 + // AMD/requirejs: Define the module
     1162 + define(function(){
     1163 + return Resumable;
     1164 + });
     1165 + } else {
     1166 + // Browser: Expose to window
     1167 + window.Resumable = Resumable;
     1168 + }
     1169 + 
     1170 +})();
     1171 + 
  • client/src/jsconfig.json
    ■ ■ ■ ■ ■ ■
     1 +{
     2 + "compilerOptions": {
     3 + "baseUrl": ".",
     4 + "paths": {
     5 + "@wp/pages": ["./pages/_index.js"],
     6 + "@wp/components": ["./pages/components/_index.js"],
     7 + "@wp/env/*": ["./env/*"],
     8 + "@wp/lib/*": ["./lib/*"],
     9 + "@wp/js/*": ["./js/*"],
     10 + "@wp/css/*": ["./css/*"],
     11 + "@wp/assets/*": ["./assets/*"],
     12 + "@wp/api": ["./api/_index.js"],
     13 + "@wp/utils": ["./utils/_index.js"],
     14 + },
     15 + "target": "es6",
     16 + "module": "es6",
     17 + "lib": ["dom", "dom.iterable"],
     18 + "moduleResolution": "node",
     19 + "allowSyntheticDefaultImports": true
     20 + }
     21 +}
     22 + 
  • client/src/lib/_index.js
    ■ ■ ■ ■ ■ ■
     1 +export { validateImportKey } from "./imports/_index.js";
     2 +export { isAntiscraperLink } from "./antiantiscraper.js";
     3 + 
  • client/src/lib/antiantiscraper.js
    ■ ■ ■ ■ ■ ■
     1 +const antiscraperHost = "anti-scraper.herokuapp.com";
     2 + 
     3 +/**
     4 + * @param {string} urlString
     5 + */
     6 +export function isAntiscraperLink(urlString) {
     7 + const url = new URL(urlString);
     8 + 
     9 + if (url.hostname.includes(antiscraperHost)) {
     10 + return true;
     11 + }
     12 + 
     13 + return false;
     14 +}
     15 + 
  • client/src/lib/imports/lib.js
    ■ ■ ■ ■ ■ ■
     1 +import { isLowerCase } from "@wp/utils";
     2 + 
     3 +/**
     4 + * @typedef ValidationResult
     5 + * @property {boolean} isValid
     6 + * @property {string[]} [errors]
     7 + * @property {any} [result] A modified result, if any.
     8 + */
     9 + 
     10 +/**
     11 + * @callback KeyValidator
     12 + * @param {string} key
     13 + * @param {string[]} errors
     14 + * @returns {string[]} An array of error messages, if any.
     15 + */
     16 + 
     17 +const maxLength = 1024;
     18 + 
     19 +/**
     20 + * @type {Record<string, KeyValidator>}
     21 + */
     22 +const serviceConstraints = {
     23 + patreon: patreonKey,
     24 + fanbox: fanboxKey,
     25 + gumroad: gumroadKey,
     26 + subscribestar: subscribestarKey,
     27 + dlsite: dlsiteKey,
     28 + discord: discordKey,
     29 + fantia: fantiaKey,
     30 +}
     31 + 
     32 +/**
     33 + * Validates the key according to these rules:
     34 + * - Trim spaces from both sides.
     35 + * @param {string} key
     36 + * @param {string} service
     37 + * @returns {ValidationResult}
     38 + */
     39 +export function validateImportKey(key, service) {
     40 + const formattedKey = key.trim();
     41 + const errors = serviceConstraints[service](key, []);
     42 + 
     43 + return {
     44 + isValid: !errors.length,
     45 + errors,
     46 + result: formattedKey
     47 + }
     48 +}
     49 + 
     50 +/**
     51 + * @type KeyValidator
     52 + */
     53 +function patreonKey(key, errors) {
     54 + const reqLength = 43;
     55 + if (key.length !== reqLength) {
     56 + errors.push(`The key length of "${key.length}" is not a valid Patreon key. Required length: "${reqLength}".`)
     57 + }
     58 + 
     59 + return errors
     60 +}
     61 + 
     62 +/**
     63 + * @type KeyValidator
     64 + */
     65 +function fanboxKey(key, errors) {
     66 + const pattern = /^\d+_\w+$/i;
     67 + 
     68 + if (key.length > maxLength) {
     69 + errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`)
     70 + }
     71 + 
     72 + if (!key.match(pattern)) {
     73 + errors.push(`The key doesn't match the required pattern of "${String(pattern)}"`);
     74 + }
     75 + 
     76 + return errors;
     77 +}
     78 + 
     79 +/**
     80 + * @type KeyValidator
     81 + */
     82 +function fantiaKey(key, errors) {
     83 + const reqLengths = [32, 64];
     84 + 
     85 + if (
     86 + reqLengths
     87 + .map(reqLength => key.length !== reqLength)
     88 + .every(v => v === false)
     89 + ) {
     90 + errors.push(
     91 + `The key length of "${key.length}" is not a valid Fantia key. ` +
     92 + `Accepted lengths: ${reqLengths.join(', ')}.`
     93 + )
     94 + }
     95 + 
     96 + if (!isLowerCase(key)) {
     97 + errors.push(`The key is not in lower case.`)
     98 + }
     99 + 
     100 + return errors;
     101 +}
     102 + 
     103 + 
     104 +/**
     105 + * @type KeyValidator
     106 + */
     107 +function gumroadKey(key, errors) {
     108 + const minLength = 200;
     109 + 
     110 + if (key.length < minLength) {
     111 + errors.push(`The key length of "${key.length}" is less than minimum required "${minLength}".`);
     112 + }
     113 + 
     114 + if (key.length > maxLength) {
     115 + errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`)
     116 + }
     117 + 
     118 + return errors;
     119 +}
     120 + 
     121 +/**
     122 + * @type KeyValidator
     123 + */
     124 +function subscribestarKey(key, errors) {
     125 + 
     126 + if (key.length > maxLength) {
     127 + errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`)
     128 + }
     129 + 
     130 + return errors;
     131 +}
     132 + 
     133 +/**
     134 + * @type KeyValidator
     135 + */
     136 +function dlsiteKey(key, errors) {
     137 + 
     138 + if (key.length > maxLength) {
     139 + errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`)
     140 + }
     141 + 
     142 + return errors;
     143 +}
     144 + 
     145 +/**
     146 + * @type KeyValidator
     147 + */
     148 +function discordKey(key, errors) {
     149 + const pattern = /(mfa.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}.[a-z0-9_-]{6,7}.[a-z0-9_-]{27})/i;
     150 + 
     151 + if (!key.match(pattern)) {
     152 + errors.push(`The key doesn't match the required pattern of "${String(pattern)}".`)
     153 + }
     154 + 
     155 + return errors;
     156 +}
     157 + 
  • client/src/pages/_index.js
    ■ ■ ■ ■ ■ ■
     1 +import { bansPage } from "./help/_index.js";
     2 +import { userPage } from "./user";
     3 +import { registerPage } from "./account/_index.js";
     4 +import { postPage } from "./post";
     5 +import { scrapePage } from "./scrape";
     6 +import { importerPage } from "./importer_list";
     7 +import { importerStatusPage } from "./importer_status";
     8 +import { postsPage } from "./posts";
     9 +import { artistPage } from "./artist";
     10 +import { artistsPage } from "./artists";
     11 +import { updatedPage } from "./updated";
     12 +import { uploadPage } from "./upload";
     13 + 
     14 +import { registerPaginatorKeybinds } from "@wp/components";
     15 + 
     16 +export { adminPageScripts } from "./account/administrator/_index.js";
     17 +export { moderatorPageScripts } from "./account/moderator/_index.js";
     18 +/**
     19 + * The map of page names and their callbacks.
     20 + */
     21 +export const globalPageScripts = new Map([
     22 + ["user", userPage],
     23 + ["register", registerPage],
     24 + ["post", postPage],
     25 + ["scrape", scrapePage],
     26 + ["importer", importerPage],
     27 + ["bans", bansPage],
     28 + ["importer-status", importerStatusPage],
     29 + ["posts", postsPage],
     30 + ["artist", artistPage],
     31 + ["artists", artistsPage],
     32 + ["updated", updatedPage],
     33 + ["upload", uploadPage],
     34 + ["all-dms", registerPaginatorKeybinds],
     35 + ["favorites", registerPaginatorKeybinds],
     36 +]);
     37 + 
  • client/src/pages/_index.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "components";
     2 +@use "home";
     3 +@use "help";
     4 +@use "post";
     5 +@use "scrape";
     6 +@use "artist";
     7 +@use "user";
     8 +@use "importer";
     9 +@use "importer_status";
     10 +@use "posts";
     11 +@use "favorites";
     12 +@use "account";
     13 +@use "upload";
     14 +@use "challenge";
     15 + 
  • client/src/pages/account/_index.js
    ■ ■ ■ ■ ■ ■
     1 +export { registerPage } from "./register.js";
     2 + 
  • client/src/pages/account/_index.scss
    ■ ■ ■ ■ ■
     1 +@use "home";
     2 +@use "components";
     3 +@use "notifications";
     4 +@use "keys";
     5 + 
  • client/src/pages/account/administrator/_index.js
    ■ ■ ■ ■ ■
     1 +/**
     2 + * @type {Map<string, (section: HTMLElement) => void>}
     3 + */
     4 +export const adminPageScripts = new Map();
     5 + 
  • client/src/pages/account/administrator/_index.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "accounts";
     2 +@use "shell";
     3 + 
  • client/src/pages/account/administrator/account_files.html
    ■ ■ ■ ■ ■ ■
     1 +{% extends 'account/administrator/shell.html' %}
     2 + 
     3 +{% block content %}
     4 +<section class="site-section site-section--admin-account-files">
     5 + <header class="site-section__header">
     6 + <h1 class="site-section__heading">
     7 + Files information for {{ props.account.username }} ({{ props.account.id }})
     8 + </h1>
     9 + </header>
     10 + <ul>
     11 + {% for file in props.files %}
     12 + <li>
     13 + File info
     14 + </li>
     15 + {% else %}
     16 + <li>No files for this account</li>
     17 + {% endfor %}
     18 + </ul>
     19 +</section>
     20 +{% endblock content %}
     21 + 
  • client/src/pages/account/administrator/account_info.html
    ■ ■ ■ ■ ■ ■
     1 +{% extends 'account/administrator/shell.html' %}
     2 + 
     3 +{% from 'components/timestamp.html' import timestamp %}
     4 + 
     5 +{% block content %}
     6 +<section class="site-section site-section--admin-account-info">
     7 + <header class="site-section__header">
     8 + <h1 class="site-section__heading">
     9 + Account information for {{ props.account.username }} ({{ props.account.id }})
     10 + </h1>
     11 + </header>
     12 + <dl>
     13 + <div>
     14 + <dt>Created:</dt>
     15 + <dd>
     16 + {{ timestamp(props.account.created_at) }}
     17 + </dd>
     18 + </div>
     19 + </dl>
     20 +</section>
     21 +{% endblock content %}
     22 + 
  • client/src/pages/account/administrator/accounts.html
    ■ ■ ■ ■ ■ ■
     1 +{% extends 'account/administrator/shell.html' %}
     2 + 
     3 +{% from 'components/card_list.html' import card_list %}
     4 +{% from 'components/cards/account.html' import account_card %}
     5 +{% from 'components/paginator_new.html' import paginator, paginator_controller %}
     6 + 
     7 +{% block content %}
     8 +<section class="site-section site-section--admin-accounts">
     9 + <header class="site-section__header">
     10 + <h1 class="site-section__heading">
     11 + Accounts
     12 + </h1>
     13 + </header>
     14 + <form
     15 + id="accounts-filter"
     16 + class="form"
     17 + method="GET"
     18 + >
     19 + <div class="form__section">
     20 + <label for="search-name" class="form__label">Name:</label>
     21 + <input
     22 + id="search-name"
     23 + class="form__input"
     24 + type="text"
     25 + name="name"
     26 + {% if request.args.get('name') %}
     27 + value="{{ request.args.get('name') }}"
     28 + {% endif %}
     29 + >
     30 + </div>
     31 + <div class="form__section">
     32 + <label
     33 + for="accounts-filter__roles" class="form__label"
     34 + >
     35 + Roles:
     36 + </label>
     37 + <select
     38 + id="accounts-filter__roles"
     39 + class="form__select"
     40 + name="role"
     41 + >
     42 + <option
     43 + class="form__option"
     44 + value="all"
     45 + {% if request.args.get('role') == 'all' %}
     46 + selected
     47 + {% endif %}
     48 + >
     49 + All
     50 + </option>
     51 + {% for role in props.role_list %}
     52 + <option
     53 + class="form__option"
     54 + value="{{ role }}"
     55 + {% if request.args.get('role') == role %}
     56 + selected
     57 + {% endif %}
     58 + >
     59 + {{ role | capitalize }}
     60 + </option>
     61 + {% endfor %}
     62 + </select>
     63 + </div>
     64 + <div class="form__section">
     65 + <button
     66 + class="form__button form__button--submit"
     67 + type="submit"
     68 + >
     69 + Search
     70 + </button>
     71 + </div>
     72 + </form>
     73 +
     74 + <form
     75 + id="account-list"
     76 + method="POST"
     77 + >
     78 + {{ paginator('account-pages', request, props.pagination) }}
     79 + {% call card_list('legacy') %}
     80 + {% for account in props.accounts %}
     81 + {% if account.role == 'moderator' %}
     82 + <div class="form__section account__view account__view--demote">
     83 + <input
     84 + type="checkbox"
     85 + name="consumer"
     86 + id="account-{{ account.id }}"
     87 + class="form__input account__role-check"
     88 + value="{{ account.id }}"
     89 + >
     90 + <div class="account__info">
     91 + {{ account_card(account) }}
     92 + <label
     93 + for="account-{{ account.id }}"
     94 + class="form__label account__label"
     95 + >Demote</label>
     96 + </div>
     97 +
     98 + </div>
     99 + {% elif account.role == 'consumer'%}
     100 + <div class="form__section account__view account__view--promote">
     101 + <input
     102 + type="checkbox"
     103 + name="moderator"
     104 + id="account-{{ account.id }}"
     105 + class="form__input account__role-check"
     106 + value="{{ account.id }}"
     107 + >
     108 + <div class="account__info">
     109 + {{ account_card(account) }}
     110 + <label
     111 + for="account-{{ account.id }}"
     112 + class="form__label account__label"
     113 + >Promote</label>
     114 + </div>
     115 +
     116 + </div>
     117 + {% else %}
     118 + {% endif %}
     119 + {% else %}
     120 + <p>No accounts found.</p>
     121 + {% endfor %}
     122 + {% endcall %}
     123 + {# {{ paginator('account-pages', request, props.pagination) }} #}
     124 + {% if props.accounts | length %}
     125 + <div class="form__section form__section--buttons">
     126 + <button
     127 + class="form__button form__button--submit"
     128 + type="submit"
     129 + >
     130 + Submit
     131 + </button>
     132 + </div>
     133 + {% endif %}
     134 + </form>
     135 + {{ paginator_controller(
     136 + 'account-pages',
     137 + request,
     138 + props.pagination
     139 + ) }}
     140 +</section>
     141 +{% endblock content %}
     142 + 
  • client/src/pages/account/administrator/accounts.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "../../../css/variables" as *;
     2 + 
     3 +.site-section--admin-accounts {
     4 + .account {
     5 + &__view {
     6 + position: relative;
     7 + padding: 0;
     8 + margin-bottom: 3em;
     9 + 
     10 + &--promote {
     11 + & .account__info {
     12 + border-radius: 10px 10px 0 10px;
     13 + }
     14 + 
     15 + & .account__label {
     16 + right: 0;
     17 + }
     18 + }
     19 + 
     20 + &--demote {
     21 + & .account__info {
     22 + border-radius: 10px 10px 10px 0;
     23 + }
     24 + 
     25 + & .account__label {
     26 + left: 0;
     27 + }
     28 + }
     29 + }
     30 +
     31 + &__role-check {
     32 + position: absolute;
     33 + visibility: hidden;
     34 + opacity: 0;
     35 + max-width: 1px;
     36 +
     37 + &:checked + .account__info {
     38 + --local-colour1: var(--colour1-tertiary);
     39 + --local-background-colour1: var(--submit-colour1-primary);
     40 + --local-border-colour1: var(--submit-colour1-primary);
     41 + }
     42 + }
     43 +
     44 + &__info {
     45 + --local-colour1: var(--colour0-primary);
     46 + --local-background-colour1: var(--colour1-tertiary);
     47 + --local-border-colour1: var(--colour1-tertiary);
     48 + 
     49 + max-width: var(--card-size);
     50 + height: 100%;
     51 + border-radius: 10px;
     52 + border: $size-thin solid var(--local-border-colour1);
     53 + overflow: hidden;
     54 + transition-duration: var(--duration-global);
     55 + transition-property: border-color;
     56 + 
     57 + & .account-card {
     58 + height: 100%;
     59 + border-radius: 0;
     60 + }
     61 + }
     62 +
     63 + &__label {
     64 + position: absolute;
     65 + top: 100%;
     66 + color: var(--local-colour1);
     67 + background-color: var(--local-background-colour1);
     68 + border-radius: 0 0 10px 10px;
     69 + border: $size-thin solid var(--local-border-colour1);
     70 + border-top: none;
     71 + padding: $size-small;
     72 + transition-duration: var(--duration-global);
     73 + transition-property: color, background-color, border-color;
     74 + }
     75 + }
     76 +}
     77 + 
  • client/src/pages/account/administrator/dashboard.html
    ■ ■ ■ ■ ■ ■
     1 +{% extends 'account/administrator/shell.html' %}
     2 + 
     3 +{% block content %}
     4 +<section class="site-section site-section--admin-dashboard">
     5 + <header class="site-section__header">
     6 + <h1 class="site-section__heading">
     7 + Admin dashboard
     8 + </h1>
     9 + </header>
     10 + <nav>
     11 + <ul>
     12 + <li>
     13 + <a href="/account/administrator/accounts">Accounts</a>
     14 + </li>
     15 + </ul>
     16 + </nav>
     17 +</section>
     18 +{% endblock content %}
     19 + 
  • client/src/pages/account/administrator/mods_actions.html
    ■ ■ ■ ■ ■ ■
     1 +{% extends 'account/administrator/shell.html' %}
     2 + 
     3 +{% block content %}
     4 +<section class="site-section site-section--admin-mods-actions">
     5 + <header class="site-section__header">
     6 + <h1 class="site-section__heading">
     7 + Moderator actions
     8 + </h1>
     9 + </header>
     10 + <ul>
     11 + {% for action in props.actions %}
     12 + <li>action</li>
     13 + {% else %}
     14 + <li>No actions found</li>
     15 + {% endfor %}
     16 + </ul>
     17 +</section>
     18 +{% endblock content %}
     19 + 
  • client/src/pages/account/administrator/shell.html
    ■ ■ ■ ■ ■ ■
     1 +{% extends 'components/shell.html' %}
     2 + 
     3 +{# TODO: filter only admin entry #}
     4 +{% block bundler_output %}
     5 + <% for (const css in htmlWebpackPlugin.files.css) { %>
     6 + <% if (htmlWebpackPlugin.files.css[css].startsWith("/static/bundle/css/admin")) { %>
     7 + <link rel="stylesheet" href="<%= htmlWebpackPlugin.files.css[css] %>">
     8 + <% } %>
     9 + <% } %>
     10 + <% for (const chunk in htmlWebpackPlugin.files.chunks) { %>
     11 + <script src="<%= htmlWebpackPlugin.files.chunks[chunk].entry %>" defer></script>
     12 + <% } %>
     13 + <% for (const scriptPath in htmlWebpackPlugin.files.js) { %>
     14 + <% 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")) { %>
     15 + <script src="<%= htmlWebpackPlugin.files.js[scriptPath] %>" defer></script>
     16 + <% } %>
     17 + <% } %>
     18 +{% endblock bundler_output %}
     19 + 
  • client/src/pages/account/administrator/shell.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "../../components/shell";
     2 + 
  • client/src/pages/account/components/_index.scss
    ■ ■ ■ ■ ■ ■
     1 +@use "notification";
     2 +@use "service_key";
     3 + 
  • client/src/pages/account/components/notification.html
    ■ ■ ■ ■ ■ ■
     1 +{% from 'components/timestamp.html' import timestamp %}
     2 + 
     3 +{% macro ACCOUNT_ROLE_CHANGE(extra_info) %}
     4 + Your role was changed from {{ extra_info.old_role }} to {{ extra_info.new_role }}.
     5 +{% endmacro %}
     6 + 
     7 +{% set notification_types = {
     8 + 1: ACCOUNT_ROLE_CHANGE
     9 +} %}
     10 + 
     11 +{% macro notification_item(notification) %}
     12 + <li class="notifications__item {{ 'notifications__item--seen' if notification.is_seen }}">
     13 + <span class="notifications__date">
     14 + {{ timestamp(notification.created_at) }}
     15 + </span>
     16 + <span class="notifications__message">
     17 + {{ notification_types[notification.type](notification.extra_info) }}
     18 + </span>
     19 + </li>
     20 +{% endmacro %}
     21 + 
  • client/src/pages/account/components/notification.scss
    ■ ■ ■ ■ ■ ■
     1 +.notifications {
     2 + &__item {
     3 + &--seen {
     4 + opacity: 0.5;
     5 + }
     6 + }
     7 +}
     8 + 
  • client/src/pages/account/components/service_key.html
    ■ ■ ■ ■ ■ ■
     1 +{% from 'components/cards/base.html' import card, card_header, card_body, card_footer %}
     2 +{% from 'components/timestamp.html' import timestamp %}
     3 + 
     4 +{% macro service_key_card(service_key, import_ids, class_name= none) %}
     5 + {% set paysite = g.external_sites[service_key.service] %}
     6 + 
     7 + {% call card(class_name= class_name) %}
     8 + {% call card_header() %}
     9 + <h2>
     10 + {{ paysite.title }}
     11 + </h2>
     12 + {% endcall %}
     13 + 
     14 + {% call card_body() %}
     15 + <dl class="service-key__stats">
     16 + <div class="service-key__stat">
     17 + <dt>Status:</dt>
     18 + {% if not service_key.dead %}
     19 + <dd class="service-key__status">
     20 + Alive
     21 + </dd>
     22 + {% else %}
     23 + <dd class="service-key__status service-key__status--dead">
     24 + Dead
     25 + </dd>
     26 + {% endif %}
     27 + </div>
     28 + <div class="service-key__stat">
     29 + {% if import_ids %}
     30 + <dt>Logs</dt>
     31 + <ul>
     32 + {% for log in import_ids %}
     33 + <li><a href="/importer/status/{{ log['import_id'] }}">{{ log['import_id'] }}</a></li>
     34 + {% endfor %}
     35 + </ul>
     36 + {% endif %}
     37 + </div>
     38 + </dl>
     39 + {% endcall %}
     40 + 
     41 + {% call card_footer() %}
     42 + <dl class="service-key__stats">
     43 + <div class="service-key__stat">
     44 + <dd>Added:</dd>
     45 + <dt>{{ timestamp(service_key.added) }}</dt>
     46 + </div>
     47 + </dl>
     48 + {% endcall %}
     49 + {% endcall %}
     50 +{% endmacro %}
     51 + 
  • client/src/pages/account/components/service_key.scss
    ■ ■ ■ ■ ■ ■
     1 +.service-key {
     2 + 
     3 + &__stats {
     4 + }
     5 + 
     6 + &__stat {
     7 + 
     8 + & > * {
     9 + display: inline;
     10 + }
     11 + }
     12 + 
     13 + &__status {
     14 + color: var(--positive-colour1-primary);
     15 + 
     16 + &--dead {
     17 + color: var(--negative-colour1-primary);
     18 + }
     19 + }
     20 +}
     21 + 
  • client/src/pages/account/home.html
    ■ ■ ■ ■ ■ ■
     1 +{% extends 'components/shell.html' %}
     2 + 
     3 +{% from 'components/image_link.html' import image_link %}
     4 + 
     5 +{% from 'components/timestamp.html' import timestamp %}
     6 +{% from 'components/links.html' import kemono_link %}
     7 + 
     8 +{% set role_links = {
     9 + "consumer": "/account",
     10 + "moderator": "/account/moderator",
     11 + "administrator": "/account/administrator"
     12 +} %}
     13 + 
     14 +{% block title %}
     15 + <title>{{ props.title }}</title>
     16 +{% endblock title %}
     17 + 
     18 +{% block content %}
     19 +<section class="site-section site-section--account">
     20 + <div class="account-view">
     21 + <div class="account-view__header">
     22 + {# Finishing icons later... #}
     23 + {# <div class="account-view__icon">
     24 + {{ image_link(
     25 + src='/static/user-placeholder.jpg',
     26 + class_name=''
     27 + ) }}
     28 + </div> #}
     29 + <span class="account-view__greeting">
     30 + Hello,
     31 + <span class="account-view__identity">
     32 + {{ props.account.username }}
     33 + </span>
     34 + </span>
     35 + 
     36 + <div class="account-view__info">
     37 + Joined {{ props.account.created_at|relative_date }} |
     38 + <span class="account-view__role">
     39 + {{ kemono_link(
     40 + url= role_links[props.account.role],
     41 + text= props.account.role,
     42 + is_noop= false
     43 + ) }}
     44 + </span>
     45 + </div>
     46 + </div>
     47 + <p class="def-list__section">
     48 + Notifications:
     49 + <span>
     50 + {{ kemono_link(
     51 + url= '/account/notifications',
     52 + text= props.notifications_count,
     53 + is_noop= false
     54 + ) }}
     55 + </span>
     56 + </p>
     57 + <p class="def-list__section">
     58 + {{ kemono_link(
     59 + url= '/account/keys',
     60 + text= 'Keys',
     61 + is_noop= false
     62 + ) }}
     63 + </p>
     64 + </div>
     65 +</section>
     66 +{% endblock content %}
     67 + 
  • client/src/pages/account/home.scss
    ■ ■ ■ ■ ■ ■
     1 +.site-section--account {
     2 + max-width: 480px;
     3 + background-color: hsl(220, 7%, 25%);
     4 + box-shadow: 1px 2px 5px 4px rgb(0 0 0 / 0.2);
     5 + border-radius: 4px;
     6 + // border: 0.5px solid rgba(17, 17, 17, 1);
     7 + & .account-view {
     8 + margin: 0 auto;
     9 + padding: 8px;
     10 +
     11 + &__header {
     12 + text-align: center;
     13 + }
     14 +
     15 + &__greeting {
     16 + color: hsl(0, 0%, 45%);
     17 + font-weight: 300;
     18 + font-size: 28px;
     19 + }
     20 + 
     21 + &__identity {
     22 + color: #fff;
     23 + font-weight: normal;
     24 + }
     25 + 
     26 + &__info {
     27 + font-size: 12px;
     28 + color: hsl(0, 0%, 45%);
     29 + }
     30 +
     31 + &__role {
     32 + text-transform: capitalize;
     33 + }
     34 + }
     35 +}
     36 + 
Please wait...
Page is in error, reload to recover