From b143186d85b86df76dcdc2aa93868fe9dcaeabf1 Mon Sep 17 00:00:00 2001 From: SA Date: Tue, 26 Nov 2024 00:11:49 +0100 Subject: [PATCH] squash --- .dockerignore | 1 + .gitignore | 2 +- client/.dockerignore | 29 - client/.vscode/extensions.json | 3 - client/.vscode/settings.json | 12 +- client/Dockerfile | 16 - client/Dockerfile.dev | 15 - client/configs/build-templates.js | 92 - client/configs/emmet/snippets.json | 5 - client/configs/vars.js | 158 +- client/extra.d.ts | 10 + client/jsconfig.json | 9 - client/package-lock.json | 2968 ++++++++++++++++- client/package.json | 47 +- client/scripts/validate.mjs | 29 + client/src/api/_index.js | 2 - client/src/api/account/account.ts | 19 + .../src/api/account/administrator/accounts.ts | 40 + .../api/account/administrator/change-roles.ts | 26 + client/src/api/account/administrator/index.ts | 2 + .../src/api/account/auto-import-keys/get.ts | 19 + .../src/api/account/auto-import-keys/index.ts | 2 + .../api/account/auto-import-keys/revoke.ts | 15 + client/src/api/account/change-password.ts | 24 + client/src/api/account/dms/get.ts | 22 + client/src/api/account/dms/index.ts | 2 + client/src/api/account/dms/review.ts | 21 + .../api/account/favorites/favorite-post.ts | 25 + .../api/account/favorites/favorite-profile.ts | 17 + .../favorites/get-favourite-artists.ts | 15 + .../account/favorites/get-favourite-posts.ts | 15 + client/src/api/account/favorites/index.ts | 4 + client/src/api/account/index.ts | 4 + client/src/api/account/moderator/index.ts | 5 + .../moderator/profile-link-requests.ts | 30 + client/src/api/account/notifications.ts | 17 + client/src/api/account/profiles.ts | 42 + client/src/api/authentication/index.ts | 3 + client/src/api/authentication/login.ts | 29 + client/src/api/authentication/logout.ts | 9 + client/src/api/authentication/register.ts | 20 + client/src/api/dms/all.ts | 29 + client/src/api/dms/has-pending.ts | 8 + client/src/api/dms/index.ts | 3 + client/src/api/dms/profile.ts | 27 + .../navigation/account.html => api/errors.ts} | 0 client/src/api/fetch.ts | 144 + client/src/api/files/archive-file.ts | 30 + client/src/api/files/index.ts | 2 + client/src/api/files/search-by-hash.ts | 56 + client/src/api/imports/create-import.ts | 26 + client/src/api/imports/get-import.ts | 9 + client/src/api/imports/index.ts | 2 + client/src/api/kemono/_index.js | 14 - client/src/api/kemono/api.js | 100 - client/src/api/kemono/dms.js | 22 - client/src/api/kemono/favorites.js | 142 - client/src/api/kemono/kemono-fetch.js | 46 - client/src/api/kemono/posts.js | 26 - client/src/api/paysites/_index.js | 1 - client/src/api/posts/announcements.ts | 9 + client/src/api/posts/flag.ts | 25 + client/src/api/posts/index.ts | 7 + client/src/api/posts/popular.ts | 53 + client/src/api/posts/post.ts | 57 + client/src/api/posts/posts.ts | 35 + client/src/api/posts/random.ts | 15 + client/src/api/posts/revision.ts | 39 + client/src/api/profiles/discord/index.ts | 29 + client/src/api/profiles/fancards.ts | 9 + client/src/api/profiles/index.ts | 6 + client/src/api/profiles/links.ts | 21 + client/src/api/profiles/posts.ts | 58 + client/src/api/profiles/profile.ts | 14 + client/src/api/profiles/profiles.ts | 12 + client/src/api/profiles/random.ts | 14 + client/src/api/shares/index.ts | 3 + client/src/api/shares/profile.ts | 37 + client/src/api/shares/share.ts | 15 + client/src/api/shares/shares.ts | 24 + client/src/api/tags/all.ts | 14 + client/src/api/tags/index.ts | 2 + client/src/api/tags/profile.ts | 29 + client/src/browser/hooks/index.ts | 3 + client/src/browser/hooks/use-client.tsx | 38 + client/src/browser/hooks/use-interval.tsx | 27 + .../browser/hooks/use-route-path-pattern.tsx | 11 + client/src/browser/storage/local/index.ts | 46 + client/src/components/_index.scss | 11 + client/src/components/ads/ads.tsx | 59 + client/src/components/ads/index.ts | 1 + client/src/components/buttons/_index.scss | 1 + .../buttons}/buttons.scss | 0 client/src/components/buttons/buttons.tsx | 18 + client/src/components/buttons/index.ts | 1 + .../{pages => }/components/cards/_index.scss | 3 +- .../{pages => }/components/cards/account.scss | 2 +- client/src/components/cards/account.tsx | 28 + .../{pages => }/components/cards/base.scss | 2 +- client/src/components/cards/base.tsx | 41 + .../cards}/card_list.scss | 0 client/src/components/cards/card_list.tsx | 122 + .../src/{pages => }/components/cards/dm.scss | 2 +- client/src/components/cards/dm.tsx | 84 + client/src/components/cards/index.ts | 8 + .../components/cards/no_results.scss | 2 +- client/src/components/cards/no_results.tsx | 21 + .../{pages => }/components/cards/post.scss | 2 +- client/src/components/cards/post.tsx | 181 + .../cards/profile.scss} | 2 +- client/src/components/cards/profile.tsx | 132 + client/src/components/cards/share.tsx | 28 + .../dangerous-content/dangerous.tsx | 44 + .../src/components/dangerous-content/index.ts | 1 + client/src/components/dates/_index.scss | 1 + client/src/components/dates/index.ts | 1 + .../dates}/timestamp.scss | 0 client/src/components/dates/timestamp.tsx | 21 + client/src/components/details/details.tsx | 17 + client/src/components/details/index.ts | 1 + client/src/components/errors/api-error.tsx | 82 + client/src/components/errors/error-view.tsx | 67 + client/src/components/errors/index.ts | 1 + .../src/components/errors/invalid-error.tsx | 36 + client/src/components/flash_messages.tsx | 19 + client/src/components/formatting/index.ts | 1 + client/src/components/formatting/pre.tsx | 9 + client/src/components/forms/index.ts | 4 + client/src/components/forms/inputs/hidden.tsx | 9 + client/src/components/forms/inputs/index.ts | 1 + client/src/components/forms/router.tsx | 68 + client/src/components/forms/section.tsx | 8 + client/src/components/forms/submit_button.tsx | 19 + client/src/components/forms/types.ts | 11 + client/src/components/images/_index.scss | 2 + .../images}/fancy_image.scss | 0 client/src/components/images/fancy_image.tsx | 57 + .../images}/image_link.scss | 0 client/src/components/images/image_link.tsx | 31 + client/src/components/images/index.ts | 2 + .../components/importer_states.scss | 0 client/src/components/layout/_index.scss | 2 + client/src/components/layout/footer.tsx | 11 + client/src/components/layout/index.ts | 2 + .../layout}/shell.scss | 1 - client/src/components/layout/shell.tsx | 416 +++ .../layout}/sidebar.scss | 2 +- client/src/components/layout/sidebar.tsx | 143 + client/src/components/links/_index.scss | 1 + client/src/components/links/index.ts | 9 + .../links}/links.scss | 0 client/src/components/links/links.tsx | 141 + .../src/components/lists/details.module.scss | 46 + client/src/components/lists/details.tsx | 64 + client/src/components/lists/index.ts | 13 + client/src/components/lists/standard.tsx | 26 + client/src/components/loading/_index.scss | 1 + client/src/components/loading/index.ts | 1 + .../loading}/loading_icon.scss | 0 .../src/components/loading/loading_icon.tsx | 19 + .../src/components/meta/block-component.tsx | 27 + client/src/components/meta/index.ts | 2 + client/src/components/meta/types.ts | 18 + client/src/components/pages/_index.scss | 1 + client/src/components/pages/account.tsx | 34 + client/src/components/pages/error.module.scss | 5 + client/src/components/pages/error.tsx | 42 + client/src/components/pages/index.ts | 4 + client/src/components/pages/profile.tsx | 39 + .../components => components/pages}/site.scss | 0 client/src/components/pages/site.tsx | 47 + client/src/components/pagination/_index.scss | 1 + client/src/components/pagination/index.ts | 2 + .../src/components/pagination/paginator.tsx | 224 ++ .../pagination}/paginator_new.scss | 0 .../components/pagination/paginator_new.tsx | 152 + .../src/{pages => }/components/tooltip.scss | 2 +- client/src/components/tooltip.tsx | 26 + client/src/css/base.scss | 15 +- client/src/css/config/variables.scss | 6 + client/src/css/legacy.scss | 2 +- client/src/development/entry.js | 1 - client/src/development/entry.scss | 3 - .../account}/_index.scss | 0 client/src/entities/account/index.ts | 34 + client/src/entities/account/lib/auth.ts | 51 + .../entities/account/lib/favorite-posts.ts | 185 + .../account/lib/favourite-profiles.ts | 172 + .../account}/notification.scss | 0 client/src/entities/account/notification.tsx | 27 + client/src/entities/account/notifications.ts | 8 + client/src/entities/account/roles.ts | 3 + .../account}/service_key.scss | 0 client/src/entities/account/service_key.tsx | 71 + client/src/entities/account/types.ts | 75 + client/src/entities/dms/index.ts | 1 + client/src/entities/dms/types.ts | 18 + .../files/file_hash_search.module.scss} | 2 +- .../src/entities/files/file_hash_search.tsx | 37 + client/src/entities/files/index.ts | 8 + client/src/entities/files/types.ts | 58 + client/src/entities/paysites/index.ts | 3 + client/src/entities/paysites/lib/validate.ts | 11 + client/src/entities/paysites/list.ts | 101 + client/src/entities/paysites/types.ts | 6 + .../entities/posts/discord-server.module.scss | 21 + client/src/entities/posts/discord-server.tsx | 35 + client/src/entities/posts/discord.module.scss | 65 + client/src/entities/posts/discord.tsx | 162 + client/src/entities/posts/index.ts | 21 + .../src/entities/posts/overview.module.scss | 3 + client/src/entities/posts/overview.tsx | 1060 ++++++ client/src/entities/posts/period.ts | 15 + client/src/entities/posts/types.ts | 141 + client/src/entities/profiles/headers.tsx | 185 + client/src/entities/profiles/index.ts | 4 + client/src/entities/profiles/lib/get.ts | 155 + client/src/entities/profiles/tabs.tsx | 119 + client/src/entities/profiles/types.ts | 23 + client/src/entities/tags/index.ts | 2 + client/src/entities/tags/lib/get.ts | 7 + client/src/entities/tags/types.ts | 4 + client/src/env/derived-vars.js | 4 - client/src/env/derived-vars.ts | 30 + client/src/env/env-vars.js | 31 - client/src/env/env-vars.ts | 126 + client/src/index.html | 13 + client/src/index.scss | 3 + client/src/index.tsx | 23 + client/src/js/account.js | 1 - client/src/js/admin.js | 7 - client/src/js/admin.scss | 3 - client/src/js/component-factory.js | 35 - client/src/js/favorites.js | 207 -- client/src/js/feature-detect.js | 13 - client/src/js/global.js | 18 - client/src/js/global.scss | 2 - client/src/js/moderator.js | 7 - client/src/js/moderator.scss | 1 - client/src/js/page-loader.js | 37 - client/src/js/pending-review-dms.js | 19 - client/src/js/resumable.js | 1242 ------- client/src/jsconfig.json | 21 - client/src/lib/_index.js | 1 - client/src/lib/api/error.ts | 33 + client/src/lib/api/index.ts | 1 + client/src/lib/errors/error.ts | 11 + client/src/lib/errors/index.ts | 2 + client/src/lib/errors/invalid.ts | 16 + client/src/lib/http/index.ts | 1 + client/src/lib/http/status.ts | 66 + client/src/lib/imports/lib.js | 148 - client/src/lib/pagination/index.ts | 2 + client/src/lib/pagination/lib.ts | 12 + client/src/lib/pagination/types.ts | 9 + client/src/lib/range/index.ts | 22 + client/src/lib/types/index.ts | 7 + client/src/lib/urls/account.ts | 94 + client/src/lib/urls/administrator.ts | 36 + client/src/lib/urls/artists.ts | 45 + client/src/lib/urls/authentication.ts | 21 + client/src/lib/urls/dms.ts | 16 + client/src/lib/urls/documentation.ts | 7 + client/src/lib/urls/files.ts | 40 + client/src/lib/urls/importer.ts | 7 + client/src/lib/urls/index.ts | 58 + client/src/lib/urls/internal-url.ts | 40 + client/src/lib/urls/kemono.ts | 13 + client/src/lib/urls/moderator.ts | 7 + client/src/lib/urls/posts.ts | 113 + client/src/lib/urls/profiles.ts | 126 + client/src/lib/urls/shares.ts | 21 + client/src/lib/urls/tags.ts | 8 + client/src/pages/2257.tsx | 49 + client/src/pages/_index.js | 43 - client/src/pages/_index.scss | 7 +- client/src/pages/account/_index.js | 2 - client/src/pages/account/_index.scss | 2 +- .../src/pages/account/administrator/_index.js | 4 - .../pages/account/administrator/_index.scss | 1 - .../account/administrator/account_files.html | 20 - .../account/administrator/account_info.html | 21 - .../pages/account/administrator/accounts.html | 141 - .../pages/account/administrator/accounts.tsx | 243 ++ .../account/administrator/dashboard.html | 18 - .../pages/account/administrator/dashboard.tsx | 20 + .../account/administrator/mods_actions.html | 18 - .../pages/account/administrator/shell.html | 18 - .../pages/account/administrator/shell.scss | 1 - client/src/pages/account/change_password.html | 44 - client/src/pages/account/change_password.js | 45 - client/src/pages/account/change_password.tsx | 116 + .../account/components/notification.html | 20 - .../pages/account/components/service_key.html | 50 - client/src/pages/account/favorites/legacy.tsx | 6 + .../pages/account/favorites/posts.module.scss | 14 + client/src/pages/account/favorites/posts.tsx | 184 + .../account/favorites/profiles.module.scss | 14 + .../src/pages/account/favorites/profiles.tsx | 189 ++ client/src/pages/account/home.html | 80 - client/src/pages/account/home.tsx | 130 + client/src/pages/account/keys.html | 53 - client/src/pages/account/keys.tsx | 113 + client/src/pages/account/login.html | 50 - client/src/pages/account/login.tsx | 95 + client/src/pages/account/moderator/_index.js | 4 - .../account/moderator/creator_links.html | 55 - .../pages/account/moderator/creator_links.js | 22 - .../pages/account/moderator/dashboard.html | 18 - .../src/pages/account/moderator/dashboard.tsx | 23 + client/src/pages/account/moderator/files.html | 11 - .../pages/account/moderator/profile_links.tsx | 140 + client/src/pages/account/notifications.html | 27 - client/src/pages/account/notifications.tsx | 40 + client/src/pages/account/register.html | 73 - client/src/pages/account/register.js | 96 - client/src/pages/account/register.tsx | 185 + client/src/pages/all_dms.html | 50 - client/src/pages/all_dms.tsx | 103 + client/src/pages/artist/announcements.html | 77 - client/src/pages/artist/dms.html | 56 - client/src/pages/artist/fancards.html | 64 - client/src/pages/artist/linked_accounts.html | 58 - client/src/pages/artist/linked_accounts.js | 32 - .../src/pages/artist/new_linked_account.html | 84 - client/src/pages/artist/new_linked_account.js | 111 - client/src/pages/artist/shares.html | 56 - client/src/pages/artist/tags.html | 66 - client/src/pages/artists.html | 101 - client/src/pages/artists.js | 400 --- client/src/pages/authentication/logout.tsx | 9 + client/src/pages/components/_index.js | 10 - client/src/pages/components/_index.scss | 16 - client/src/pages/components/ads.html | 29 - client/src/pages/components/buttons.html | 7 - client/src/pages/components/card_list.html | 11 - client/src/pages/components/card_list.js | 116 - client/src/pages/components/cards/_index.js | 151 - .../src/pages/components/cards/account.html | 29 - client/src/pages/components/cards/base.html | 26 - client/src/pages/components/cards/dm.html | 70 - .../pages/components/cards/no_results.html | 18 - client/src/pages/components/cards/post.html | 117 - client/src/pages/components/cards/share.html | 66 - client/src/pages/components/cards/user.html | 93 - client/src/pages/components/fancy_image.html | 23 - client/src/pages/components/fancy_image.js | 59 - .../pages/components/file_hash_search.html | 20 - .../src/pages/components/flash_messages.html | 9 - client/src/pages/components/footer.html | 5 - client/src/pages/components/forms/base.html | 8 - .../pages/components/forms/submit_button.html | 9 - client/src/pages/components/headers.html | 76 - client/src/pages/components/image_link.html | 25 - client/src/pages/components/image_link.js | 78 - .../src/pages/components/import_sidebar.html | 11 - .../src/pages/components/importer_states.html | 4 - client/src/pages/components/links.html | 56 - client/src/pages/components/links.js | 53 - client/src/pages/components/lists/_index.scss | 2 - client/src/pages/components/lists/base.html | 18 - client/src/pages/components/lists/base.scss | 23 - client/src/pages/components/lists/faq.html | 18 - client/src/pages/components/lists/faq.scss | 10 - client/src/pages/components/loading_icon.html | 9 - client/src/pages/components/loading_icon.js | 9 - .../src/pages/components/meta/attributes.html | 7 - .../pages/components/navigation/_index.scss | 5 - .../pages/components/navigation/account.scss | 1 - .../src/pages/components/navigation/base.html | 23 - .../src/pages/components/navigation/base.scss | 24 - .../pages/components/navigation/global.html | 35 - .../pages/components/navigation/global.scss | 110 - .../pages/components/navigation/local.html | 18 - .../pages/components/navigation/local.scss | 16 - .../pages/components/navigation/sidebar.html | 75 - client/src/pages/components/paginator.html | 72 - client/src/pages/components/paginator.js | 12 - .../src/pages/components/paginator_new.html | 116 - client/src/pages/components/shell.html | 221 -- client/src/pages/components/shell.js | 106 - client/src/pages/components/site.html | 27 - client/src/pages/components/site_section.html | 13 - .../src/pages/components/support_sidebar.html | 8 - client/src/pages/components/tabs.html | 68 - client/src/pages/components/timestamp.html | 14 - client/src/pages/components/timestamp.js | 39 - client/src/pages/components/tooltip.html | 22 - client/src/pages/components/tooltip.js | 65 - client/src/pages/contact.tsx | 20 + client/src/pages/development/_index.scss | 2 - client/src/pages/development/closure.html | 26 - .../pages/development/components/_index.scss | 2 - .../pages/development/components/forms.html | 17 - .../pages/development/components/forms.scss | 31 - .../pages/development/components/inputs.html | 25 - .../pages/development/components/inputs.scss | 11 - .../src/pages/development/components/nav.html | 15 - client/src/pages/development/config.html | 45 - .../src/pages/development/design/_index.scss | 1 - .../development/design/current/home.html | 14 - client/src/pages/development/design/home.html | 16 - .../development/design/upcoming/home.html | 14 - .../pages/development/design/wip/_index.scss | 1 - .../pages/development/design/wip/home.html | 34 - .../pages/development/design/wip/home.scss | 2 - client/src/pages/development/home.html | 52 - client/src/pages/development/shell.html | 18 - .../src/pages/development/test_entries.html | 51 - client/src/pages/discord-channel.module.scss | 5 + client/src/pages/discord-channel.tsx | 77 + client/src/pages/discord.html | 27 - client/src/pages/discord.module.scss | 5 + client/src/pages/discord.tsx | 39 + client/src/pages/dmca.tsx | 144 + .../src/pages/documentation/api.module.scss | 4 + client/src/pages/documentation/api.tsx | 27 + client/src/pages/error.html | 9 - client/src/pages/fanboximports.tsx | 38 + client/src/pages/favorites.html | 111 - client/src/pages/favorites.scss | 16 - client/src/pages/favorites.tsx | 6 + client/src/pages/gumroad-and-co.tsx | 54 + client/src/pages/help/faq.html | 48 - client/src/pages/help/license.html | 26 - client/src/pages/help/posts.html | 25 - client/src/pages/home.html | 58 - client/src/pages/home.scss | 8 +- client/src/pages/home.tsx | 102 + client/src/pages/importer_list.html | 269 -- client/src/pages/importer_list.js | 174 - client/src/pages/importer_list.tsx | 470 +++ client/src/pages/importer_ok.html | 11 - client/src/pages/importer_ok.tsx | 14 + client/src/pages/importer_status.html | 60 - client/src/pages/importer_status.js | 164 - client/src/pages/importer_status.tsx | 167 + client/src/pages/importer_tutorial.html | 76 - client/src/pages/importer_tutorial.tsx | 154 + .../src/pages/importer_tutorial_fanbox.html | 38 - client/src/pages/importer_tutorial_fanbox.tsx | 79 + client/src/pages/matrix.tsx | 42 + client/src/pages/post-revision.tsx | 243 ++ client/src/pages/post.html | 420 --- client/src/pages/post.js | 375 --- client/src/pages/post.tsx | 235 ++ client/src/pages/post/data.tsx | 30 + client/src/pages/posts.html | 57 - client/src/pages/posts.js | 35 - client/src/pages/posts.tsx | 110 + client/src/pages/posts/archive.html | 53 - client/src/pages/posts/archive.tsx | 110 + client/src/pages/posts/popular.html | 114 - client/src/pages/posts/popular.tsx | 271 ++ client/src/pages/posts/random.tsx | 10 + client/src/pages/{user.scss => profile.scss} | 0 client/src/pages/profile.tsx | 225 ++ .../src/pages/{artist => profile}/_index.scss | 1 + client/src/pages/profile/announcements.tsx | 108 + client/src/pages/{artist => profile}/dms.scss | 0 client/src/pages/profile/dms.tsx | 101 + .../pages/{artist => profile}/fancards.scss | 0 client/src/pages/profile/fancards.tsx | 123 + .../{artist => profile}/linked_accounts.scss | 0 client/src/pages/profile/linked_accounts.tsx | 100 + .../src/pages/profile/new_linked_account.tsx | 272 ++ client/src/pages/{ => profile}/tags.scss | 2 +- client/src/pages/profile/tags.tsx | 73 + client/src/pages/profiles.module.scss | 7 + client/src/pages/profiles.tsx | 333 ++ client/src/pages/profiles/random.tsx | 10 + client/src/pages/profiles/updated.tsx | 97 + client/src/pages/review_dms/dms.js | 14 - client/src/pages/review_dms/review_dms.html | 81 - .../pages/review_dms/review_dms.module.scss | 3 + client/src/pages/review_dms/review_dms.tsx | 157 + client/src/pages/schema.html | 7 - client/src/pages/search_hash.html | 12 - client/src/pages/search_hash.js | 43 - client/src/pages/search_hash.tsx | 129 + client/src/pages/search_results.html | 41 - client/src/pages/search_results.js | 0 client/src/pages/share.html | 31 - client/src/pages/share.tsx | 54 + client/src/pages/shares-all.tsx | 75 + client/src/pages/shares.html | 49 - client/src/pages/shares.tsx | 107 + client/src/pages/success.html | 8 - client/src/pages/swagger_schema.html | 32 - client/src/pages/tags-all.tsx | 36 + client/src/pages/tags.html | 31 - client/src/pages/updated.html | 28 - client/src/pages/updated.js | 23 - client/src/pages/upload.html | 87 - client/src/pages/upload.js | 57 - client/src/pages/upload.tsx | 180 + client/src/pages/user.html | 91 - client/src/pages/user.js | 122 - client/src/router.tsx | 417 +++ client/src/templates/page.html | 17 - client/src/types/global.d.ts | 93 - client/src/utils/_index.js | 217 -- client/src/utils/kemono-error.js | 23 - client/static/boosty.svg | 1 + client/static/small_icons/boosty.png | Bin 0 -> 154 bytes client/tsconfig.json | 23 + client/webpack.config.js | 108 +- client/webpack.dev.js | 46 +- client/webpack.prod.js | 52 +- config.example.json | 8 +- ...1110_00_DASAD-add-favorite-counts-table.py | 62 + db/schema/public/accounts.sql | 52 + db/schema/public/artists.sql | 107 + db/schema/public/dms.sql | 64 + db/schema/public/extensions.sql | 5 + db/schema/public/files.sql | 67 + db/schema/public/posts/comments.sql | 34 + db/schema/public/posts/discord.sql | 59 + db/schema/public/posts/fanbox.sql | 62 + db/schema/public/posts/posts.sql | 115 + db/schema/public/posts/triggers.sql | 27 + db/schema/public/schema.sql | 157 + db/schema/public/shares.sql | 33 + docker-compose.yml | 2 +- docs/FAQ.md | 98 +- docs/code-style.md | 15 + docs/database.md | 37 + docs/develop.md | 67 +- pyproject.toml | 1 + requirements.txt | 6 +- src/cmd/__init__.py | 2 +- src/cmd/web.py | 10 +- src/config.py | 1 + src/internals/database/database.py | 5 +- src/lib/account.py | 54 +- src/lib/announcements.py | 48 +- src/lib/api.py | 18 + src/lib/artist.py | 193 +- src/lib/favorites.py | 76 +- src/lib/filehaus.py | 121 +- src/lib/files.py | 85 +- src/lib/post.py | 234 +- src/lib/posts.py | 55 +- src/pages/account/__init__.py | 1 - src/pages/account/administrator/__init__.py | 1 - src/pages/account/administrator/blueprint.py | 157 - src/pages/account/administrator/types.py | 42 - src/pages/account/blueprint.py | 270 -- src/pages/account/moderator/__init__.py | 1 - src/pages/account/moderator/blueprint.py | 59 - src/pages/account/moderator/types.py | 21 - src/pages/api/__init__.py | 48 +- src/pages/api/schema.yaml | 1849 +++++++++- src/pages/api/v1/account.py | 228 ++ src/pages/api/v1/administrator.py | 92 + src/pages/api/v1/authentication.py | 122 + src/pages/api/v1/comments.py | 5 +- src/pages/api/v1/creators.py | 446 ++- src/pages/api/v1/dms.py | 67 +- src/pages/api/v1/favorites.py | 16 +- src/pages/api/v1/files.py | 74 +- src/pages/api/v1/flags.py | 12 +- src/pages/api/v1/importer.py | 165 +- src/pages/api/v1/moderator.py | 40 + src/pages/api/v1/posts.py | 507 ++- src/pages/artists.py | 457 +-- src/pages/artists_types.py | 7 - src/pages/creator_link_requests.py | 26 - src/pages/dms.py | 58 - src/pages/favorites.py | 60 - src/pages/filehaus.py | 79 - src/pages/files.py | 43 - src/pages/help.py | 16 - src/pages/home.py | 13 - src/pages/imports/__init__.py | 1 - src/pages/imports/blueprint.py | 55 - src/pages/imports/types.py | 23 - src/pages/post.py | 74 +- src/pages/posts.py | 228 -- src/pages/random_.py | 45 - src/pages/review_dms.py | 47 - src/pages/revisions.py | 37 - src/server.py | 49 +- src/types/paysites/__init__.py | 6 + src/types/paysites/boosty.py | 4 +- 585 files changed, 23224 insertions(+), 13112 deletions(-) delete mode 100644 client/.dockerignore delete mode 100644 client/.vscode/extensions.json delete mode 100644 client/Dockerfile delete mode 100644 client/Dockerfile.dev delete mode 100644 client/configs/build-templates.js delete mode 100644 client/configs/emmet/snippets.json create mode 100644 client/extra.d.ts delete mode 100644 client/jsconfig.json create mode 100644 client/scripts/validate.mjs delete mode 100644 client/src/api/_index.js create mode 100644 client/src/api/account/account.ts create mode 100644 client/src/api/account/administrator/accounts.ts create mode 100644 client/src/api/account/administrator/change-roles.ts create mode 100644 client/src/api/account/administrator/index.ts create mode 100644 client/src/api/account/auto-import-keys/get.ts create mode 100644 client/src/api/account/auto-import-keys/index.ts create mode 100644 client/src/api/account/auto-import-keys/revoke.ts create mode 100644 client/src/api/account/change-password.ts create mode 100644 client/src/api/account/dms/get.ts create mode 100644 client/src/api/account/dms/index.ts create mode 100644 client/src/api/account/dms/review.ts create mode 100644 client/src/api/account/favorites/favorite-post.ts create mode 100644 client/src/api/account/favorites/favorite-profile.ts create mode 100644 client/src/api/account/favorites/get-favourite-artists.ts create mode 100644 client/src/api/account/favorites/get-favourite-posts.ts create mode 100644 client/src/api/account/favorites/index.ts create mode 100644 client/src/api/account/index.ts create mode 100644 client/src/api/account/moderator/index.ts create mode 100644 client/src/api/account/moderator/profile-link-requests.ts create mode 100644 client/src/api/account/notifications.ts create mode 100644 client/src/api/account/profiles.ts create mode 100644 client/src/api/authentication/index.ts create mode 100644 client/src/api/authentication/login.ts create mode 100644 client/src/api/authentication/logout.ts create mode 100644 client/src/api/authentication/register.ts create mode 100644 client/src/api/dms/all.ts create mode 100644 client/src/api/dms/has-pending.ts create mode 100644 client/src/api/dms/index.ts create mode 100644 client/src/api/dms/profile.ts rename client/src/{pages/components/navigation/account.html => api/errors.ts} (100%) create mode 100644 client/src/api/fetch.ts create mode 100644 client/src/api/files/archive-file.ts create mode 100644 client/src/api/files/index.ts create mode 100644 client/src/api/files/search-by-hash.ts create mode 100644 client/src/api/imports/create-import.ts create mode 100644 client/src/api/imports/get-import.ts create mode 100644 client/src/api/imports/index.ts delete mode 100644 client/src/api/kemono/_index.js delete mode 100644 client/src/api/kemono/api.js delete mode 100644 client/src/api/kemono/dms.js delete mode 100644 client/src/api/kemono/favorites.js delete mode 100644 client/src/api/kemono/kemono-fetch.js delete mode 100644 client/src/api/kemono/posts.js delete mode 100644 client/src/api/paysites/_index.js create mode 100644 client/src/api/posts/announcements.ts create mode 100644 client/src/api/posts/flag.ts create mode 100644 client/src/api/posts/index.ts create mode 100644 client/src/api/posts/popular.ts create mode 100644 client/src/api/posts/post.ts create mode 100644 client/src/api/posts/posts.ts create mode 100644 client/src/api/posts/random.ts create mode 100644 client/src/api/posts/revision.ts create mode 100644 client/src/api/profiles/discord/index.ts create mode 100644 client/src/api/profiles/fancards.ts create mode 100644 client/src/api/profiles/index.ts create mode 100644 client/src/api/profiles/links.ts create mode 100644 client/src/api/profiles/posts.ts create mode 100644 client/src/api/profiles/profile.ts create mode 100644 client/src/api/profiles/profiles.ts create mode 100644 client/src/api/profiles/random.ts create mode 100644 client/src/api/shares/index.ts create mode 100644 client/src/api/shares/profile.ts create mode 100644 client/src/api/shares/share.ts create mode 100644 client/src/api/shares/shares.ts create mode 100644 client/src/api/tags/all.ts create mode 100644 client/src/api/tags/index.ts create mode 100644 client/src/api/tags/profile.ts create mode 100644 client/src/browser/hooks/index.ts create mode 100644 client/src/browser/hooks/use-client.tsx create mode 100644 client/src/browser/hooks/use-interval.tsx create mode 100644 client/src/browser/hooks/use-route-path-pattern.tsx create mode 100644 client/src/browser/storage/local/index.ts create mode 100644 client/src/components/_index.scss create mode 100644 client/src/components/ads/ads.tsx create mode 100644 client/src/components/ads/index.ts create mode 100644 client/src/components/buttons/_index.scss rename client/src/{pages/components => components/buttons}/buttons.scss (100%) create mode 100644 client/src/components/buttons/buttons.tsx create mode 100644 client/src/components/buttons/index.ts rename client/src/{pages => }/components/cards/_index.scss (67%) rename client/src/{pages => }/components/cards/account.scss (67%) create mode 100644 client/src/components/cards/account.tsx rename client/src/{pages => }/components/cards/base.scss (94%) create mode 100644 client/src/components/cards/base.tsx rename client/src/{pages/components => components/cards}/card_list.scss (100%) create mode 100644 client/src/components/cards/card_list.tsx rename client/src/{pages => }/components/cards/dm.scss (95%) create mode 100644 client/src/components/cards/dm.tsx create mode 100644 client/src/components/cards/index.ts rename client/src/{pages => }/components/cards/no_results.scss (67%) create mode 100644 client/src/components/cards/no_results.tsx rename client/src/{pages => }/components/cards/post.scss (97%) create mode 100644 client/src/components/cards/post.tsx rename client/src/{pages/components/cards/user.scss => components/cards/profile.scss} (97%) create mode 100644 client/src/components/cards/profile.tsx create mode 100644 client/src/components/cards/share.tsx create mode 100644 client/src/components/dangerous-content/dangerous.tsx create mode 100644 client/src/components/dangerous-content/index.ts create mode 100644 client/src/components/dates/_index.scss create mode 100644 client/src/components/dates/index.ts rename client/src/{pages/components => components/dates}/timestamp.scss (100%) create mode 100644 client/src/components/dates/timestamp.tsx create mode 100644 client/src/components/details/details.tsx create mode 100644 client/src/components/details/index.ts create mode 100644 client/src/components/errors/api-error.tsx create mode 100644 client/src/components/errors/error-view.tsx create mode 100644 client/src/components/errors/index.ts create mode 100644 client/src/components/errors/invalid-error.tsx create mode 100644 client/src/components/flash_messages.tsx create mode 100644 client/src/components/formatting/index.ts create mode 100644 client/src/components/formatting/pre.tsx create mode 100644 client/src/components/forms/index.ts create mode 100644 client/src/components/forms/inputs/hidden.tsx create mode 100644 client/src/components/forms/inputs/index.ts create mode 100644 client/src/components/forms/router.tsx create mode 100644 client/src/components/forms/section.tsx create mode 100644 client/src/components/forms/submit_button.tsx create mode 100644 client/src/components/forms/types.ts create mode 100644 client/src/components/images/_index.scss rename client/src/{pages/components => components/images}/fancy_image.scss (100%) create mode 100644 client/src/components/images/fancy_image.tsx rename client/src/{pages/components => components/images}/image_link.scss (100%) create mode 100644 client/src/components/images/image_link.tsx create mode 100644 client/src/components/images/index.ts rename client/src/{pages => }/components/importer_states.scss (100%) create mode 100644 client/src/components/layout/_index.scss create mode 100644 client/src/components/layout/footer.tsx create mode 100644 client/src/components/layout/index.ts rename client/src/{pages/components => components/layout}/shell.scss (99%) create mode 100644 client/src/components/layout/shell.tsx rename client/src/{pages/components/navigation => components/layout}/sidebar.scss (98%) create mode 100644 client/src/components/layout/sidebar.tsx create mode 100644 client/src/components/links/_index.scss create mode 100644 client/src/components/links/index.ts rename client/src/{pages/components => components/links}/links.scss (100%) create mode 100644 client/src/components/links/links.tsx create mode 100644 client/src/components/lists/details.module.scss create mode 100644 client/src/components/lists/details.tsx create mode 100644 client/src/components/lists/index.ts create mode 100644 client/src/components/lists/standard.tsx create mode 100644 client/src/components/loading/_index.scss create mode 100644 client/src/components/loading/index.ts rename client/src/{pages/components => components/loading}/loading_icon.scss (100%) create mode 100644 client/src/components/loading/loading_icon.tsx create mode 100644 client/src/components/meta/block-component.tsx create mode 100644 client/src/components/meta/index.ts create mode 100644 client/src/components/meta/types.ts create mode 100644 client/src/components/pages/_index.scss create mode 100644 client/src/components/pages/account.tsx create mode 100644 client/src/components/pages/error.module.scss create mode 100644 client/src/components/pages/error.tsx create mode 100644 client/src/components/pages/index.ts create mode 100644 client/src/components/pages/profile.tsx rename client/src/{pages/components => components/pages}/site.scss (100%) create mode 100644 client/src/components/pages/site.tsx create mode 100644 client/src/components/pagination/_index.scss create mode 100644 client/src/components/pagination/index.ts create mode 100644 client/src/components/pagination/paginator.tsx rename client/src/{pages/components => components/pagination}/paginator_new.scss (100%) create mode 100644 client/src/components/pagination/paginator_new.tsx rename client/src/{pages => }/components/tooltip.scss (94%) create mode 100644 client/src/components/tooltip.tsx delete mode 100644 client/src/development/entry.js delete mode 100644 client/src/development/entry.scss rename client/src/{pages/account/components => entities/account}/_index.scss (100%) create mode 100644 client/src/entities/account/index.ts create mode 100644 client/src/entities/account/lib/auth.ts create mode 100644 client/src/entities/account/lib/favorite-posts.ts create mode 100644 client/src/entities/account/lib/favourite-profiles.ts rename client/src/{pages/account/components => entities/account}/notification.scss (100%) create mode 100644 client/src/entities/account/notification.tsx create mode 100644 client/src/entities/account/notifications.ts create mode 100644 client/src/entities/account/roles.ts rename client/src/{pages/account/components => entities/account}/service_key.scss (100%) create mode 100644 client/src/entities/account/service_key.tsx create mode 100644 client/src/entities/account/types.ts create mode 100644 client/src/entities/dms/index.ts create mode 100644 client/src/entities/dms/types.ts rename client/src/{pages/components/file_hash_search.scss => entities/files/file_hash_search.module.scss} (73%) create mode 100644 client/src/entities/files/file_hash_search.tsx create mode 100644 client/src/entities/files/index.ts create mode 100644 client/src/entities/files/types.ts create mode 100644 client/src/entities/paysites/index.ts create mode 100644 client/src/entities/paysites/lib/validate.ts create mode 100644 client/src/entities/paysites/list.ts create mode 100644 client/src/entities/paysites/types.ts create mode 100644 client/src/entities/posts/discord-server.module.scss create mode 100644 client/src/entities/posts/discord-server.tsx create mode 100644 client/src/entities/posts/discord.module.scss create mode 100644 client/src/entities/posts/discord.tsx create mode 100644 client/src/entities/posts/index.ts create mode 100644 client/src/entities/posts/overview.module.scss create mode 100644 client/src/entities/posts/overview.tsx create mode 100644 client/src/entities/posts/period.ts create mode 100644 client/src/entities/posts/types.ts create mode 100644 client/src/entities/profiles/headers.tsx create mode 100644 client/src/entities/profiles/index.ts create mode 100644 client/src/entities/profiles/lib/get.ts create mode 100644 client/src/entities/profiles/tabs.tsx create mode 100644 client/src/entities/profiles/types.ts create mode 100644 client/src/entities/tags/index.ts create mode 100644 client/src/entities/tags/lib/get.ts create mode 100644 client/src/entities/tags/types.ts delete mode 100644 client/src/env/derived-vars.js create mode 100644 client/src/env/derived-vars.ts delete mode 100644 client/src/env/env-vars.js create mode 100644 client/src/env/env-vars.ts create mode 100644 client/src/index.html create mode 100644 client/src/index.scss create mode 100644 client/src/index.tsx delete mode 100644 client/src/js/account.js delete mode 100644 client/src/js/admin.js delete mode 100644 client/src/js/admin.scss delete mode 100644 client/src/js/component-factory.js delete mode 100644 client/src/js/favorites.js delete mode 100644 client/src/js/feature-detect.js delete mode 100644 client/src/js/global.js delete mode 100644 client/src/js/global.scss delete mode 100644 client/src/js/moderator.js delete mode 100644 client/src/js/moderator.scss delete mode 100644 client/src/js/page-loader.js delete mode 100644 client/src/js/pending-review-dms.js delete mode 100644 client/src/js/resumable.js delete mode 100644 client/src/jsconfig.json delete mode 100644 client/src/lib/_index.js create mode 100644 client/src/lib/api/error.ts create mode 100644 client/src/lib/api/index.ts create mode 100644 client/src/lib/errors/error.ts create mode 100644 client/src/lib/errors/index.ts create mode 100644 client/src/lib/errors/invalid.ts create mode 100644 client/src/lib/http/index.ts create mode 100644 client/src/lib/http/status.ts delete mode 100644 client/src/lib/imports/lib.js create mode 100644 client/src/lib/pagination/index.ts create mode 100644 client/src/lib/pagination/lib.ts create mode 100644 client/src/lib/pagination/types.ts create mode 100644 client/src/lib/range/index.ts create mode 100644 client/src/lib/types/index.ts create mode 100644 client/src/lib/urls/account.ts create mode 100644 client/src/lib/urls/administrator.ts create mode 100644 client/src/lib/urls/artists.ts create mode 100644 client/src/lib/urls/authentication.ts create mode 100644 client/src/lib/urls/dms.ts create mode 100644 client/src/lib/urls/documentation.ts create mode 100644 client/src/lib/urls/files.ts create mode 100644 client/src/lib/urls/importer.ts create mode 100644 client/src/lib/urls/index.ts create mode 100644 client/src/lib/urls/internal-url.ts create mode 100644 client/src/lib/urls/kemono.ts create mode 100644 client/src/lib/urls/moderator.ts create mode 100644 client/src/lib/urls/posts.ts create mode 100644 client/src/lib/urls/profiles.ts create mode 100644 client/src/lib/urls/shares.ts create mode 100644 client/src/lib/urls/tags.ts create mode 100644 client/src/pages/2257.tsx delete mode 100644 client/src/pages/_index.js delete mode 100644 client/src/pages/account/_index.js delete mode 100644 client/src/pages/account/administrator/_index.js delete mode 100644 client/src/pages/account/administrator/account_files.html delete mode 100644 client/src/pages/account/administrator/account_info.html delete mode 100644 client/src/pages/account/administrator/accounts.html create mode 100644 client/src/pages/account/administrator/accounts.tsx delete mode 100644 client/src/pages/account/administrator/dashboard.html create mode 100644 client/src/pages/account/administrator/dashboard.tsx delete mode 100644 client/src/pages/account/administrator/mods_actions.html delete mode 100644 client/src/pages/account/administrator/shell.html delete mode 100644 client/src/pages/account/administrator/shell.scss delete mode 100644 client/src/pages/account/change_password.html delete mode 100644 client/src/pages/account/change_password.js create mode 100644 client/src/pages/account/change_password.tsx delete mode 100644 client/src/pages/account/components/notification.html delete mode 100644 client/src/pages/account/components/service_key.html create mode 100644 client/src/pages/account/favorites/legacy.tsx create mode 100644 client/src/pages/account/favorites/posts.module.scss create mode 100644 client/src/pages/account/favorites/posts.tsx create mode 100644 client/src/pages/account/favorites/profiles.module.scss create mode 100644 client/src/pages/account/favorites/profiles.tsx delete mode 100644 client/src/pages/account/home.html create mode 100644 client/src/pages/account/home.tsx delete mode 100644 client/src/pages/account/keys.html create mode 100644 client/src/pages/account/keys.tsx delete mode 100644 client/src/pages/account/login.html create mode 100644 client/src/pages/account/login.tsx delete mode 100644 client/src/pages/account/moderator/_index.js delete mode 100644 client/src/pages/account/moderator/creator_links.html delete mode 100644 client/src/pages/account/moderator/creator_links.js delete mode 100644 client/src/pages/account/moderator/dashboard.html create mode 100644 client/src/pages/account/moderator/dashboard.tsx delete mode 100644 client/src/pages/account/moderator/files.html create mode 100644 client/src/pages/account/moderator/profile_links.tsx delete mode 100644 client/src/pages/account/notifications.html create mode 100644 client/src/pages/account/notifications.tsx delete mode 100644 client/src/pages/account/register.html delete mode 100644 client/src/pages/account/register.js create mode 100644 client/src/pages/account/register.tsx delete mode 100644 client/src/pages/all_dms.html create mode 100644 client/src/pages/all_dms.tsx delete mode 100644 client/src/pages/artist/announcements.html delete mode 100644 client/src/pages/artist/dms.html delete mode 100644 client/src/pages/artist/fancards.html delete mode 100644 client/src/pages/artist/linked_accounts.html delete mode 100644 client/src/pages/artist/linked_accounts.js delete mode 100644 client/src/pages/artist/new_linked_account.html delete mode 100644 client/src/pages/artist/new_linked_account.js delete mode 100644 client/src/pages/artist/shares.html delete mode 100644 client/src/pages/artist/tags.html delete mode 100644 client/src/pages/artists.html delete mode 100644 client/src/pages/artists.js create mode 100644 client/src/pages/authentication/logout.tsx delete mode 100644 client/src/pages/components/_index.js delete mode 100644 client/src/pages/components/_index.scss delete mode 100644 client/src/pages/components/ads.html delete mode 100644 client/src/pages/components/buttons.html delete mode 100644 client/src/pages/components/card_list.html delete mode 100644 client/src/pages/components/card_list.js delete mode 100644 client/src/pages/components/cards/_index.js delete mode 100644 client/src/pages/components/cards/account.html delete mode 100644 client/src/pages/components/cards/base.html delete mode 100644 client/src/pages/components/cards/dm.html delete mode 100644 client/src/pages/components/cards/no_results.html delete mode 100644 client/src/pages/components/cards/post.html delete mode 100644 client/src/pages/components/cards/share.html delete mode 100644 client/src/pages/components/cards/user.html delete mode 100644 client/src/pages/components/fancy_image.html delete mode 100644 client/src/pages/components/fancy_image.js delete mode 100644 client/src/pages/components/file_hash_search.html delete mode 100644 client/src/pages/components/flash_messages.html delete mode 100644 client/src/pages/components/footer.html delete mode 100644 client/src/pages/components/forms/base.html delete mode 100644 client/src/pages/components/forms/submit_button.html delete mode 100644 client/src/pages/components/headers.html delete mode 100644 client/src/pages/components/image_link.html delete mode 100644 client/src/pages/components/image_link.js delete mode 100644 client/src/pages/components/import_sidebar.html delete mode 100644 client/src/pages/components/importer_states.html delete mode 100644 client/src/pages/components/links.html delete mode 100644 client/src/pages/components/links.js delete mode 100644 client/src/pages/components/lists/_index.scss delete mode 100644 client/src/pages/components/lists/base.html delete mode 100644 client/src/pages/components/lists/base.scss delete mode 100644 client/src/pages/components/lists/faq.html delete mode 100644 client/src/pages/components/lists/faq.scss delete mode 100644 client/src/pages/components/loading_icon.html delete mode 100644 client/src/pages/components/loading_icon.js delete mode 100644 client/src/pages/components/meta/attributes.html delete mode 100644 client/src/pages/components/navigation/_index.scss delete mode 100644 client/src/pages/components/navigation/account.scss delete mode 100644 client/src/pages/components/navigation/base.html delete mode 100644 client/src/pages/components/navigation/base.scss delete mode 100644 client/src/pages/components/navigation/global.html delete mode 100644 client/src/pages/components/navigation/global.scss delete mode 100644 client/src/pages/components/navigation/local.html delete mode 100644 client/src/pages/components/navigation/local.scss delete mode 100644 client/src/pages/components/navigation/sidebar.html delete mode 100644 client/src/pages/components/paginator.html delete mode 100644 client/src/pages/components/paginator.js delete mode 100644 client/src/pages/components/paginator_new.html delete mode 100644 client/src/pages/components/shell.html delete mode 100644 client/src/pages/components/shell.js delete mode 100644 client/src/pages/components/site.html delete mode 100644 client/src/pages/components/site_section.html delete mode 100644 client/src/pages/components/support_sidebar.html delete mode 100644 client/src/pages/components/tabs.html delete mode 100644 client/src/pages/components/timestamp.html delete mode 100644 client/src/pages/components/timestamp.js delete mode 100644 client/src/pages/components/tooltip.html delete mode 100644 client/src/pages/components/tooltip.js create mode 100644 client/src/pages/contact.tsx delete mode 100644 client/src/pages/development/_index.scss delete mode 100644 client/src/pages/development/closure.html delete mode 100644 client/src/pages/development/components/_index.scss delete mode 100644 client/src/pages/development/components/forms.html delete mode 100644 client/src/pages/development/components/forms.scss delete mode 100644 client/src/pages/development/components/inputs.html delete mode 100644 client/src/pages/development/components/inputs.scss delete mode 100644 client/src/pages/development/components/nav.html delete mode 100644 client/src/pages/development/config.html delete mode 100644 client/src/pages/development/design/_index.scss delete mode 100644 client/src/pages/development/design/current/home.html delete mode 100644 client/src/pages/development/design/home.html delete mode 100644 client/src/pages/development/design/upcoming/home.html delete mode 100644 client/src/pages/development/design/wip/_index.scss delete mode 100644 client/src/pages/development/design/wip/home.html delete mode 100644 client/src/pages/development/design/wip/home.scss delete mode 100644 client/src/pages/development/home.html delete mode 100644 client/src/pages/development/shell.html delete mode 100644 client/src/pages/development/test_entries.html create mode 100644 client/src/pages/discord-channel.module.scss create mode 100644 client/src/pages/discord-channel.tsx delete mode 100644 client/src/pages/discord.html create mode 100644 client/src/pages/discord.module.scss create mode 100644 client/src/pages/discord.tsx create mode 100644 client/src/pages/dmca.tsx create mode 100644 client/src/pages/documentation/api.module.scss create mode 100644 client/src/pages/documentation/api.tsx delete mode 100644 client/src/pages/error.html create mode 100644 client/src/pages/fanboximports.tsx delete mode 100644 client/src/pages/favorites.html delete mode 100644 client/src/pages/favorites.scss create mode 100644 client/src/pages/favorites.tsx create mode 100644 client/src/pages/gumroad-and-co.tsx delete mode 100644 client/src/pages/help/faq.html delete mode 100644 client/src/pages/help/license.html delete mode 100644 client/src/pages/help/posts.html delete mode 100644 client/src/pages/home.html create mode 100644 client/src/pages/home.tsx delete mode 100644 client/src/pages/importer_list.html delete mode 100644 client/src/pages/importer_list.js create mode 100644 client/src/pages/importer_list.tsx delete mode 100644 client/src/pages/importer_ok.html create mode 100644 client/src/pages/importer_ok.tsx delete mode 100644 client/src/pages/importer_status.html delete mode 100644 client/src/pages/importer_status.js create mode 100644 client/src/pages/importer_status.tsx delete mode 100644 client/src/pages/importer_tutorial.html create mode 100644 client/src/pages/importer_tutorial.tsx delete mode 100644 client/src/pages/importer_tutorial_fanbox.html create mode 100644 client/src/pages/importer_tutorial_fanbox.tsx create mode 100644 client/src/pages/matrix.tsx create mode 100644 client/src/pages/post-revision.tsx delete mode 100644 client/src/pages/post.html delete mode 100644 client/src/pages/post.js create mode 100644 client/src/pages/post.tsx create mode 100644 client/src/pages/post/data.tsx delete mode 100644 client/src/pages/posts.html delete mode 100644 client/src/pages/posts.js create mode 100644 client/src/pages/posts.tsx delete mode 100644 client/src/pages/posts/archive.html create mode 100644 client/src/pages/posts/archive.tsx delete mode 100644 client/src/pages/posts/popular.html create mode 100644 client/src/pages/posts/popular.tsx create mode 100644 client/src/pages/posts/random.tsx rename client/src/pages/{user.scss => profile.scss} (100%) create mode 100644 client/src/pages/profile.tsx rename client/src/pages/{artist => profile}/_index.scss (80%) create mode 100644 client/src/pages/profile/announcements.tsx rename client/src/pages/{artist => profile}/dms.scss (100%) create mode 100644 client/src/pages/profile/dms.tsx rename client/src/pages/{artist => profile}/fancards.scss (100%) create mode 100644 client/src/pages/profile/fancards.tsx rename client/src/pages/{artist => profile}/linked_accounts.scss (100%) create mode 100644 client/src/pages/profile/linked_accounts.tsx create mode 100644 client/src/pages/profile/new_linked_account.tsx rename client/src/pages/{ => profile}/tags.scss (94%) create mode 100644 client/src/pages/profile/tags.tsx create mode 100644 client/src/pages/profiles.module.scss create mode 100644 client/src/pages/profiles.tsx create mode 100644 client/src/pages/profiles/random.tsx create mode 100644 client/src/pages/profiles/updated.tsx delete mode 100644 client/src/pages/review_dms/dms.js delete mode 100644 client/src/pages/review_dms/review_dms.html create mode 100644 client/src/pages/review_dms/review_dms.module.scss create mode 100644 client/src/pages/review_dms/review_dms.tsx delete mode 100644 client/src/pages/schema.html delete mode 100644 client/src/pages/search_hash.html delete mode 100644 client/src/pages/search_hash.js create mode 100644 client/src/pages/search_hash.tsx delete mode 100644 client/src/pages/search_results.html delete mode 100644 client/src/pages/search_results.js delete mode 100644 client/src/pages/share.html create mode 100644 client/src/pages/share.tsx create mode 100644 client/src/pages/shares-all.tsx delete mode 100644 client/src/pages/shares.html create mode 100644 client/src/pages/shares.tsx delete mode 100644 client/src/pages/success.html delete mode 100644 client/src/pages/swagger_schema.html create mode 100644 client/src/pages/tags-all.tsx delete mode 100644 client/src/pages/tags.html delete mode 100644 client/src/pages/updated.html delete mode 100644 client/src/pages/updated.js delete mode 100644 client/src/pages/upload.html delete mode 100644 client/src/pages/upload.js create mode 100644 client/src/pages/upload.tsx delete mode 100644 client/src/pages/user.html delete mode 100644 client/src/pages/user.js create mode 100644 client/src/router.tsx delete mode 100644 client/src/templates/page.html delete mode 100644 client/src/types/global.d.ts delete mode 100644 client/src/utils/_index.js delete mode 100644 client/src/utils/kemono-error.js create mode 100644 client/static/boosty.svg create mode 100644 client/static/small_icons/boosty.png create mode 100644 client/tsconfig.json create mode 100644 db/migrations/20241110_00_DASAD-add-favorite-counts-table.py create mode 100644 db/schema/public/accounts.sql create mode 100644 db/schema/public/artists.sql create mode 100644 db/schema/public/dms.sql create mode 100644 db/schema/public/extensions.sql create mode 100644 db/schema/public/files.sql create mode 100644 db/schema/public/posts/comments.sql create mode 100644 db/schema/public/posts/discord.sql create mode 100644 db/schema/public/posts/fanbox.sql create mode 100644 db/schema/public/posts/posts.sql create mode 100644 db/schema/public/posts/triggers.sql create mode 100644 db/schema/public/schema.sql create mode 100644 db/schema/public/shares.sql create mode 100644 docs/code-style.md create mode 100644 docs/database.md create mode 100644 src/lib/api.py delete mode 100644 src/pages/account/__init__.py delete mode 100644 src/pages/account/administrator/__init__.py delete mode 100644 src/pages/account/administrator/blueprint.py delete mode 100644 src/pages/account/administrator/types.py delete mode 100644 src/pages/account/blueprint.py delete mode 100644 src/pages/account/moderator/__init__.py delete mode 100644 src/pages/account/moderator/blueprint.py delete mode 100644 src/pages/account/moderator/types.py create mode 100644 src/pages/api/v1/account.py create mode 100644 src/pages/api/v1/administrator.py create mode 100644 src/pages/api/v1/authentication.py create mode 100644 src/pages/api/v1/moderator.py delete mode 100644 src/pages/creator_link_requests.py delete mode 100644 src/pages/dms.py delete mode 100644 src/pages/favorites.py delete mode 100644 src/pages/filehaus.py delete mode 100644 src/pages/files.py delete mode 100644 src/pages/help.py delete mode 100644 src/pages/home.py delete mode 100644 src/pages/imports/__init__.py delete mode 100644 src/pages/imports/blueprint.py delete mode 100644 src/pages/imports/types.py delete mode 100644 src/pages/posts.py delete mode 100644 src/pages/random_.py delete mode 100644 src/pages/review_dms.py delete mode 100644 src/pages/revisions.py diff --git a/.dockerignore b/.dockerignore index 305b559..4e81ff3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ test/ storage/ dist/ client/dev/ +client/dist/ client/node_modules/ __pycache__ venv diff --git a/.gitignore b/.gitignore index 94a0099..46e04fc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ dev_* client/dev # Dev file server -storage/ +/storage/ # Javascript packages node_modules diff --git a/client/.dockerignore b/client/.dockerignore deleted file mode 100644 index f3c73a0..0000000 --- a/client/.dockerignore +++ /dev/null @@ -1,29 +0,0 @@ -# webpack output -**/dev -**/dist - -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.code-workspace -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/azds.yaml -**/charts -**/docker-compose* -**/compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -README.md diff --git a/client/.vscode/extensions.json b/client/.vscode/extensions.json deleted file mode 100644 index 1612a87..0000000 --- a/client/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": [] -} diff --git a/client/.vscode/settings.json b/client/.vscode/settings.json index 6a2ff3b..8b32f58 100644 --- a/client/.vscode/settings.json +++ b/client/.vscode/settings.json @@ -1,14 +1,9 @@ { + "typescript.tsdk": "./node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, "files.exclude": { "node_modules": true }, - // this option does work and is required for emmet in jinja to work - "files.associations": { - "*.html": "jinja-html" - }, - "emmet.includeLanguages": { - "jinja-html": "html" - }, "search.exclude": { "**/node_modules": true, "**/bower_components": true, @@ -19,9 +14,6 @@ "javascript.preferences.importModuleSpecifierEnding": "js", "javascript.preferences.quoteStyle": "double", "javascript.format.semicolons": "insert", - "[jinja-html]": { - "editor.tabSize": 2 - }, "[javascript]": { "editor.tabSize": 2 }, diff --git a/client/Dockerfile b/client/Dockerfile deleted file mode 100644 index f0a5c79..0000000 --- a/client/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -# syntax=docker/dockerfile:1 - -FROM node:16.14 - -ENV NODE_ENV=production - -WORKDIR /app - -COPY ["package.json", "package-lock.json", "/app/"] - -RUN npm install -g npm -RUN npm ci --also=dev - -COPY . /app - -CMD [ "npm", "run", "build" ] \ No newline at end of file diff --git a/client/Dockerfile.dev b/client/Dockerfile.dev deleted file mode 100644 index d6beb4e..0000000 --- a/client/Dockerfile.dev +++ /dev/null @@ -1,15 +0,0 @@ -# syntax=docker/dockerfile:1 - -FROM node:12.22 - -ENV NODE_ENV=development - -WORKDIR /app - -COPY ["package.json", "package-lock.json*", "./"] - -RUN npm install - -COPY . . - -CMD ["npm", "run", "dev"] diff --git a/client/configs/build-templates.js b/client/configs/build-templates.js deleted file mode 100644 index 49f560b..0000000 --- a/client/configs/build-templates.js +++ /dev/null @@ -1,92 +0,0 @@ -const path = require("path"); -const fse = require("fs-extra"); -const HTMLWebpackPlugin = require("html-webpack-plugin"); - -/** - * @typedef BuildOptions - * @property {string} fileExtension - * @property {string} outputPrefix - * @property {HTMLWebpackPlugin.Options} pluginOptions Webpack plugin options. - */ - -/** */ -class TemplateFile { - /** - * @param {fse.Dirent} dirent - * @param {string} path Absolute path to the file. - */ - constructor(dirent, path) { - this.dirent = dirent; - this.path = path; - } -} - -/** - * Builds an array of HTML webpack plugins from the provided folder. - * @param {string} basePath Absolute path to the template folder. - * @param {BuildOptions} options Build optons. - */ -function buildHTMLWebpackPluginsRecursive(basePath, options) { - /** - * @type {HTMLWebpackPlugin[]} - */ - const plugins = []; - const files = walkFolder(basePath); - - files.forEach((file) => { - const isTemplateFile = file.dirent.isFile() && file.path.endsWith(`${options.fileExtension}`); - - if (isTemplateFile) { - const outputBase = path.relative(basePath, file.path); - const outputPath = path.join(path.basename(basePath), outputBase); - - const webpackPlugin = new HTMLWebpackPlugin({ - ...options.pluginOptions, - template: file.path, - filename: outputPath, - }); - - plugins.push(webpackPlugin); - } - }); - - return plugins; -} - -/** - * @param {string} folderPath Absolute path to the folder. - * @param {TemplateFile[]} files - */ -function walkFolder(folderPath, files = [], currentCount = 0) { - const nestedLimit = 1000; - const folderContents = fse.readdirSync(folderPath, { withFileTypes: true }); - - folderContents.forEach((entry) => { - const file = entry.isFile() && entry; - const folder = entry.isDirectory() && entry; - - if (file) { - const filePath = path.join(folderPath, file.name); - files.push(new TemplateFile(file, filePath)); - return; - } - - if (folder) { - currentCount++; - - if (currentCount > nestedLimit) { - throw new Error(`The folder at "${folderPath}" contains more than ${nestedLimit} folders.`); - } - - const newFolderPath = path.join(folderPath, folder.name); - - return walkFolder(newFolderPath, files, currentCount); - } - }); - - return files; -} - -module.exports = { - buildHTMLWebpackPluginsRecursive, -}; diff --git a/client/configs/emmet/snippets.json b/client/configs/emmet/snippets.json deleted file mode 100644 index 8ec5f82..0000000 --- a/client/configs/emmet/snippets.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "html": { - "snippets": {} - } -} diff --git a/client/configs/vars.js b/client/configs/vars.js index 997dea1..e5dac3b 100644 --- a/client/configs/vars.js +++ b/client/configs/vars.js @@ -1,21 +1,167 @@ +// @ts-check const path = require("path"); +const fs = require("fs"); -require("dotenv").config({ - path: path.resolve(__dirname, "..", ".."), -}); +/** + * @typedef IConfiguration + * @property {string} site + * @property {string} [sentry_dsn_js] + * @property {boolean} development_mode + * @property {boolean} automatic_migrations + * @property {IServerConfig} webserver + * @property {IArchiveServerConfig} [archive_server] + */ -const kemonoSite = process.env.KEMONO_SITE || "http://localhost:5000"; -const nodeEnv = process.env.NODE_ENV || "production"; +/** + * @typedef IServerConfig + * @property {IUIConfig} ui + * @property {number} port + * @property {string} [base_url] + */ + +/** + * @typedef IUIConfig + * @property {IHomeConfig} home + * @property {{paysite_list: string[], artists_or_creators: string}} config + * @property {IMatomoConfig} [matomo] + * @property {ISidebarConfig} [sidebar] + * @property {unknown[]} sidebar_items + * @property {unknown[]} [footer_items] + * @property {IBannerConfig} [banner] + * @property {IAdsConfig} [ads] + */ + +/** + * @typedef IMatomoConfig + * @property {boolean} enabled + * @property {string} plain_code b64-encoded string + * @property {string} tracking_domain + * @property {string} tracking_code + * @property {string} site_id + */ + +/** + * @typedef ISidebarConfig + * @property {boolean} [disable_dms] + * @property {boolean} [disable_faq] + * @property {boolean} [disable_filehaus] + */ + +/** + * @typedef IBannerConfig + * @property {string} [global] b64-encoded string + * @property {string} [welcome] b64-encoded string + */ + +/** + * @typedef IHomeConfig + * @property {string} [site_name] + * @property {string} [mascot_path] + * @property {string} [logo_path] + * @property {string} [welcome_credits] b64-encoded string + * @property {string} [home_background_image] + * @property {{ title: string, date: string, content: string }[]} [announcements] + */ + +/** + * @typedef IAdsConfig + * @property {string} [header] b64-encoded string + * @property {string} [middle] b64-encoded string + * @property {string} [footer] b64-encoded string + * @property {string} [slider] b64-encoded string + * @property {string} [video] b64-encoded JSON string + */ + +/** + * @typedef IArchiveServerConfig + * @property {boolean} [enabled] + */ + +const configuration = getConfiguration(); +const apiServerBaseURL = configuration.webserver.base_url; +const sentryDSN = configuration.sentry_dsn_js; +const apiServerPort = !apiServerBaseURL + ? undefined + : configuration.webserver.port; +const siteName = configuration.webserver.ui.home.site_name || "Kemono"; +const homeBackgroundImage = + configuration.webserver.ui.home.home_background_image; +const homeMascotPath = configuration.webserver.ui.home.mascot_path; +const homeLogoPath = configuration.webserver.ui.home.logo_path; +const homeWelcomeCredits = configuration.webserver.ui.home.welcome_credits; +const homeAnnouncements = configuration.webserver.ui.home.announcements; +// TODO: in development it should point to webpack server +const kemonoSite = configuration.site || "http://localhost:5000"; +const paysiteList = configuration.webserver.ui.config.paysite_list; +const artistsOrCreators = + configuration.webserver.ui.config.artists_or_creators ?? "Artists"; +const disableDMs = configuration.webserver.ui.sidebar?.disable_dms ?? true; +const disableFAQ = configuration.webserver.ui.sidebar?.disable_faq ?? true; +const disableFilehaus = + configuration.webserver.ui.sidebar?.disable_filehaus ?? true; +const sidebarItems = configuration.webserver.ui.sidebar_items; +const footerItems = configuration.webserver.ui.footer_items; +const bannerGlobal = configuration.webserver.ui.banner?.global; +const bannerWelcome = configuration.webserver.ui.banner?.welcome; +const headerAd = configuration.webserver.ui.ads?.header; +const middleAd = configuration.webserver.ui.ads?.middle; +const footerAd = configuration.webserver.ui.ads?.footer; +const sliderAd = configuration.webserver.ui.ads?.slider; +const videoAd = configuration.webserver.ui.ads?.video; +const isArchiveServerEnabled = configuration.archive_server?.enabled ?? false; +const analyticsEnabled = configuration.webserver.ui.matomo?.enabled ?? false; +const analyticsCode = configuration.webserver.ui.matomo?.plain_code; const iconsPrepend = process.env.ICONS_PREPEND || ""; const bannersPrepend = process.env.BANNERS_PREPEND || ""; const thumbnailsPrepend = process.env.THUMBNAILS_PREPEND || ""; const creatorsLocation = process.env.CREATORS_LOCATION || ""; +/** + * @TODO config validation + * @returns {IConfiguration} + */ +function getConfiguration() { + const configPath = path.resolve(__dirname, "..", "..", "config.json"); + // TODO: async reading + const fileContent = fs.readFileSync(configPath, { encoding: "utf8" }); + /** + * @type {IConfiguration} + */ + const config = JSON.parse(fileContent); + + return config; +} + module.exports = { kemonoSite, - nodeEnv, + sentryDSN, + siteName, iconsPrepend, bannersPrepend, thumbnailsPrepend, creatorsLocation, + artistsOrCreators, + disableDMs, + disableFAQ, + disableFilehaus, + sidebarItems, + footerItems, + bannerGlobal, + bannerWelcome, + homeBackgroundImage, + homeMascotPath, + homeLogoPath, + paysiteList, + homeWelcomeCredits, + homeAnnouncements, + headerAd, + middleAd, + footerAd, + sliderAd, + videoAd, + isArchiveServerEnabled, + apiServerBaseURL, + apiServerPort, + analyticsEnabled, + analyticsCode, }; diff --git a/client/extra.d.ts b/client/extra.d.ts new file mode 100644 index 0000000..d675f08 --- /dev/null +++ b/client/extra.d.ts @@ -0,0 +1,10 @@ +// required for typescript not to choke on css modules +declare module '*.scss' { + const content: Record; + export default content; +} + +declare module '*.yaml' { + const data: any + export default data +} diff --git a/client/jsconfig.json b/client/jsconfig.json deleted file mode 100644 index 5c02774..0000000 --- a/client/jsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "module": "commonJS", - "target": "es2015", - "moduleResolution": "node" - }, - "exclude": ["node_modules", "dist", "dev", "src"] -} diff --git a/client/package-lock.json b/client/package-lock.json index 6492016..a10dd18 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "kemono-2-client", - "version": "0.2.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kemono-2-client", - "version": "0.2.1", + "version": "1.0.0", "license": "ISC", "dependencies": { "@babel/runtime": "^7.22.10", @@ -14,22 +14,37 @@ "@uppy/dashboard": "^3.5.1", "@uppy/form": "^3.0.2", "@uppy/tus": "^3.1.3", + "clsx": "^2.1.0", "diff": "^5.1.0", "fluid-player": "^3.22.0", "micromodal": "^0.4.10", "purecss": "^3.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-helmet-async": "^2.0.5", + "react-router-dom": "^6.24.0", "sha256-wasm": "^2.2.2", + "swagger-ui-react": "^5.17.14", "whatwg-fetch": "^3.6.17" }, "devDependencies": { "@babel/core": "^7.22.10", "@babel/plugin-transform-runtime": "^7.22.10", "@babel/preset-env": "^7.22.10", + "@babel/preset-react": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@hyperjump/json-schema": "^1.9.3", + "@types/micromodal": "^0.3.5", + "@types/node": "^20.1.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/sha256-wasm": "^2.2.3", + "@types/swagger-ui-react": "^4.18.3", + "@types/webpack-bundle-analyzer": "^4.7.0", "babel-loader": "^8.3.0", "buffer": "^6.0.3", "copy-webpack-plugin": "^8.1.1", "css-loader": "^5.2.7", - "dotenv": "^8.6.0", "fs-extra": "^10.1.0", "html-webpack-plugin": "^5.5.3", "mini-css-extract-plugin": "^1.6.2", @@ -41,11 +56,15 @@ "sass-loader": "^11.1.1", "stream-browserify": "^3.0.0", "style-loader": "^2.0.0", + "ts-loader": "^9.5.1", + "typescript": "^5.3.3", "webpack": "^5.88.2", + "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", "webpack-manifest-plugin": "^5.0.0", - "webpack-merge": "^5.9.0" + "webpack-merge": "^5.9.0", + "yaml": "^2.4.5" } }, "node_modules/@ampproject/remapping": { @@ -62,13 +81,14 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.10.tgz", - "integrity": "sha512-/KKIMG4UEL35WmI9OlvMhurwtytjvXoFcGNrOvyG9zIzA8YmPjVtIZUf7b05+TPO7G7/GEmLHDaoCgACHl9hhA==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.22.10", - "chalk": "^2.4.2" + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" @@ -114,14 +134,15 @@ } }, "node_modules/@babel/generator": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.22.10.tgz", - "integrity": "sha512-79KIf7YiWjjdZ81JnLujDRApWtl7BxTqWD88+FFdQEIOG8LJ0etDOM7CXuIgGJa55sGOwZVwuEsaLEm0PJ5/+A==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz", + "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.10", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", + "@babel/types": "^7.25.6", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" }, "engines": { @@ -129,12 +150,13 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", - "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz", + "integrity": "sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -169,19 +191,18 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.10.tgz", - "integrity": "sha512-5IBb77txKYQPpOEdUdIhBx8VrZyDCQ+H82H0+5dX1TmuscP5vJKEE3cKurjtIw/vFwzbVH48VweE78kVDBrqjA==", + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.4.tgz", + "integrity": "sha512-ro/bFs3/84MDgDmMwbcHgDa8/E6J3QKNTk4xJJnVeFtGE+tL0K26E3pNxhYz2b67fJpt7Aphw5XcploKXuCvCQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", - "@babel/helper-member-expression-to-functions": "^7.22.5", - "@babel/helper-optimise-call-expression": "^7.22.5", - "@babel/helper-replace-supers": "^7.22.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-member-expression-to-functions": "^7.24.8", + "@babel/helper-optimise-call-expression": "^7.24.7", + "@babel/helper-replace-supers": "^7.25.0", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", + "@babel/traverse": "^7.25.4", "semver": "^6.3.1" }, "engines": { @@ -259,40 +280,44 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.5.tgz", - "integrity": "sha512-aBiH1NKMG0H2cGZqspNvsaBe6wNGjbJjuLy29aU+eDZjSbbN53BaxlpB02xm9v34pLTZ1nIQPFYn2qMZoa5BQQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.8.tgz", + "integrity": "sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.8", + "@babel/types": "^7.24.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.5.tgz", - "integrity": "sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", + "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.22.9.tgz", - "integrity": "sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-module-imports": "^7.22.5", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.5" + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-simple-access": "^7.24.7", + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" }, "engines": { "node": ">=6.9.0" @@ -302,22 +327,24 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.22.5.tgz", - "integrity": "sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz", + "integrity": "sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", - "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", + "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -340,14 +367,15 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.22.9", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.22.9.tgz", - "integrity": "sha512-LJIKvvpgPOPUThdYqcX6IXRuIcTkcAub0IaDRGCZH0p5GPUp7PhRU9QVgFcDDd51BaPkk77ZjqFwh6DZTAEmGg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.0.tgz", + "integrity": "sha512-q688zIvQVYtZu+i2PsdIu/uWGRpfxzr5WESsfpShfZECkO+d2o+WROWezCi/Q6kJ0tfPa5+pUGUlfx2HhrA3Bg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-member-expression-to-functions": "^7.22.5", - "@babel/helper-optimise-call-expression": "^7.22.5" + "@babel/helper-member-expression-to-functions": "^7.24.8", + "@babel/helper-optimise-call-expression": "^7.24.7", + "@babel/traverse": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -357,24 +385,28 @@ } }, "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", + "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.22.5.tgz", - "integrity": "sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.7.tgz", + "integrity": "sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.5" + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -393,28 +425,31 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz", - "integrity": "sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", + "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.5.tgz", - "integrity": "sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", + "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -448,24 +483,30 @@ } }, "node_modules/@babel/highlight": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.10.tgz", - "integrity": "sha512-78aUtVcT7MUscr0K5mIEnkwxPE0MaxkR5RxRwuHaQ+JuU5AmTPhY+do2mdzVTnIJJpyBglql2pehuBIWHug+WQ==", + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", + "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.22.10.tgz", - "integrity": "sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.6" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -634,6 +675,22 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz", + "integrity": "sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-logical-assignment-operators": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", @@ -736,6 +793,22 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.4.tgz", + "integrity": "sha512-uMOCoHVU52BsSWxPOMVv5qKRdeSlPuImUCB2dlPuBSU+W2/ROE7/Zg8F2Kepbk+8yBa68LlRKxO+xgEVWorsDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-unicode-sets-regex": { "version": "7.18.6", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", @@ -1109,14 +1182,15 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.22.5.tgz", - "integrity": "sha512-B4pzOXj+ONRmuaQTg05b3y/4DuFz3WcCNAXPLb2Q0GT0TrGKGxNKV4jwsXts+StaM0LQczZbOpj8o1DLPDJIiA==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.8.tgz", + "integrity": "sha512-WHsk9H8XxRs3JXKWFiqtQebdh9b/pTk4EgueygFzYlTKAg0Ud985mSevdNjdXdFBATSKVJGQXP1tv6aGbssLKA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.22.5", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/helper-simple-access": "^7.22.5" + "@babel/helper-module-transforms": "^7.24.8", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-simple-access": "^7.24.7" }, "engines": { "node": ">=6.9.0" @@ -1354,6 +1428,75 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.24.7.tgz", + "integrity": "sha512-H/Snz9PFxKsS1JLI4dJLtnJgCJRoo0AUm3chP6NYr+9En1JMKloheEiLIhlp5MDVznWo+H3AAC1Mc8lmUEpsgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.2.tgz", + "integrity": "sha512-KQsqEAVBpU82NM/B/N9j9WOdphom1SZH3R+2V7INrQUH+V9EBFwZsEJl8eBIVeQE62FxJCc70jzEZwqU7RcVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/plugin-syntax-jsx": "^7.24.7", + "@babel/types": "^7.25.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.24.7.tgz", + "integrity": "sha512-QG9EnzoGn+Qar7rxuW+ZOsbWOt56FvvI93xInqsZDC5fsekx1AlIO4KIJ5M+D0p0SqSH156EpmZyXq630B8OlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.24.7.tgz", + "integrity": "sha512-PLgBVk3fzbmEjBJ/u8kFzOqS9tUeDjiaWud/rRym/yjCo/M9cASPlnrd2ZmmZpQT40fOOrvR8jh+n8jikrOhNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-regenerator": { "version": "7.22.10", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.22.10.tgz", @@ -1481,6 +1624,26 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.2.tgz", + "integrity": "sha512-lBwRvjSmqiMYe/pS0+1gggjJleUJi7NzjvQ1Fkqtt69hBa/0t1YuW/MLQMAPixfwaQOHUXsd6jeU3Z+vdGv3+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-create-class-features-plugin": "^7.25.0", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", + "@babel/plugin-syntax-typescript": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-unicode-escapes": { "version": "7.22.10", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.22.10.tgz", @@ -1652,6 +1815,47 @@ "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/preset-react": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.24.7.tgz", + "integrity": "sha512-AAH4lEkpmzFWrGVlHaxJB7RLH21uPQ9+He+eFLWHmF9IuFQVugz8eAsamaW0DXRrTfco5zj1wWtpdcXJUOfsag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-validator-option": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.24.7", + "@babel/plugin-transform-react-jsx-development": "^7.24.7", + "@babel/plugin-transform-react-pure-annotations": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.24.7.tgz", + "integrity": "sha512-SyXRe3OdWwIwalxDg5UtJnJQO+YPcTfwiIY2B0Xlddh9o7jpWLvv8X1RthIeDOxQ+O1ML5BLPCONToObyVQVuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-validator-option": "^7.24.7", + "@babel/plugin-syntax-jsx": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/regjsgen": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", @@ -1669,35 +1873,47 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/template": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.5.tgz", - "integrity": "sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==", - "dev": true, + "node_modules/@babel/runtime-corejs3": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.25.6.tgz", + "integrity": "sha512-Gz0Nrobx8szge6kQQ5Z5MX9L3ObqNwCQY1PSwSNzreFL7aHGxv8Fp2j3ETV6/wWdbiV+mW6OSm8oQhg3Tcsniw==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.22.5", - "@babel/parser": "^7.22.5", - "@babel/types": "^7.22.5" + "core-js-pure": "^3.30.2", + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.24.7", + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.22.10.tgz", - "integrity": "sha512-Q/urqV4pRByiNNpb/f5OSv28ZlGJiFiiTh+GAHktbIrkPhPbl90+uW6SmpoLyZqutrg9AEaEf3Q/ZBRHBXgxig==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", + "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.22.10", - "@babel/generator": "^7.22.10", - "@babel/helper-environment-visitor": "^7.22.5", - "@babel/helper-function-name": "^7.22.5", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.22.10", - "@babel/types": "^7.22.10", - "debug": "^4.1.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.6", + "@babel/parser": "^7.25.6", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6", + "debug": "^4.3.1", "globals": "^11.1.0" }, "engines": { @@ -1705,19 +1921,26 @@ } }, "node_modules/@babel/types": { - "version": "7.22.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.22.10.tgz", - "integrity": "sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.5", + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.0.2.tgz", + "integrity": "sha512-NVf/1YycDMs6+FxS0Tb/W8MjJRDQdXF+tBfDtZ5UZeiRUkTmwKc4vmYCKZTyymfJk1gnMsauvZSX/HiV9jOABw==", + "license": "MIT" + }, "node_modules/@csstools/cascade-layer-name-parser": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.4.tgz", @@ -2531,15 +2754,110 @@ "node": ">=10.0.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "node_modules/@hyperjump/browser": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.1.4.tgz", + "integrity": "sha512-85rfa3B79MssMOxNChvXJhfgvIXqA2FEzwrxKe9iMpCKZVQIxQe54w210VeFM0D33pVOeNskg7TyptSjenY2+w==", "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@hyperjump/json-pointer": "^1.1.0", + "@hyperjump/uri": "^1.2.0", + "content-type": "^1.0.5", + "just-curry-it": "^5.3.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/json-pointer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@hyperjump/json-pointer/-/json-pointer-1.1.0.tgz", + "integrity": "sha512-tFCKxMKDKK3VEdtUA3EBOS9GmSOS4mbrTjh9v3RnK10BphDMOb6+bxTh++/ae1AyfHyWb6R54O/iaoAtPMZPCg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/json-schema": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.9.3.tgz", + "integrity": "sha512-NZyQ+PSQKUVIO0PInwqk2EOOObJD/ZqR9awzZeOddwtJyLZaxim9/xizZ6gGxGZi5ZGIdIB1mkBTM9fBu85E4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hyperjump/json-pointer": "^1.1.0", + "@hyperjump/pact": "^1.2.0", + "@hyperjump/uri": "^1.2.0", + "content-type": "^1.0.4", + "json-stringify-deterministic": "^1.0.12", + "just-curry-it": "^5.3.0", + "uuid": "^9.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + }, + "peerDependencies": { + "@hyperjump/browser": "^1.1.0" + } + }, + "node_modules/@hyperjump/json-schema/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@hyperjump/pact": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@hyperjump/pact/-/pact-1.3.0.tgz", + "integrity": "sha512-/UIKatOtyZ3kN4A7AQmqZKzg/6es9jKyeWbfrenb2rDb3I9W4ZrVZT8q1zDrI/G+849I6Eq0ybzV1mmEC9zoDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "just-curry-it": "^5.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/uri": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@hyperjump/uri/-/uri-1.2.2.tgz", + "integrity": "sha512-Zn8AZb/j54KKUCckmcOzKCSCKpIpMVBc60zYaajD8Dq/1g4UN6TfAFi+uDa5o/6rf+I+5xDZjZpdzwfuhlC0xQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" @@ -2555,10 +2873,11 @@ } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.0.0" } @@ -2580,10 +2899,11 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.19", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", - "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -2630,6 +2950,517 @@ "node": ">= 8" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.28", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", + "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@remix-run/router": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.17.0.tgz", + "integrity": "sha512-2D6XaHEVvkCn682XBnipbJjgZUU7xjLtA4dGJRBVUKpEaDYOZMENZoZjAOSb7qirxt5RupjzZxz4fK2FO+EFPw==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@swagger-api/apidom-ast": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-alpha.9.tgz", + "integrity": "sha512-SAOQrFSFwgDiI4QSIPDwAIJEb4Za+8bu45sNojgV3RMtCz+n4Agw66iqGsDib5YSI/Cg1h4AKFovT3iWdfGWfw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-error": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "unraw": "^3.0.0" + } + }, + "node_modules/@swagger-api/apidom-core": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-core/-/apidom-core-1.0.0-alpha.9.tgz", + "integrity": "sha512-vGl8BWRf6ODl39fxElcIOjRE2QG5AJhn8tTNMqjjHB/2WppNBuxOVStYZeVJoWfK03OPK8v4Fp/TAcaP9+R7DQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-ast": "^1.0.0-alpha.9", + "@swagger-api/apidom-error": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "minim": "~0.23.8", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "short-unique-id": "^5.0.2", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-error": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-error/-/apidom-error-1.0.0-alpha.9.tgz", + "integrity": "sha512-FU/2sFSgsICB9HYFELJ79caRpXXzlAV41QTHsAM46WfRehbzZUQpOBQm4jRi3qJGSa/Jk+mQ7Vt8HLRFMpJFfg==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7" + } + }, + "node_modules/@swagger-api/apidom-json-pointer": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-json-pointer/-/apidom-json-pointer-1.0.0-alpha.9.tgz", + "integrity": "sha512-/W8Ktbgbs29zdhed6KHTFk0qmuIRbvEFi8wu2MHGQ5UT4i99Bdu2OyUiayhnpejWztfQxDgL08pjrQPEwgY8Yg==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-error": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-ns-api-design-systems": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-api-design-systems/-/apidom-ns-api-design-systems-1.0.0-alpha.9.tgz", + "integrity": "sha512-aduC2vbwGgn6ia9IkKpqBYBaKyIDGM/80M3oU3DFgaYIIwynzuwVpN1TkBOLIFy3mAzkWoYKUS0jdZJhMy/6Ug==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-error": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-ns-asyncapi-2": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-asyncapi-2/-/apidom-ns-asyncapi-2-1.0.0-alpha.9.tgz", + "integrity": "sha512-hZjxXJgMt517ADnAauWJh01k7WNRwkbWT5p6b7AXF2H3tl549A2hhLnIg3BBSE3GwB3Nv25GyrI3aA/1dFVC8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-json-schema-draft-7": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-draft-4": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-4/-/apidom-ns-json-schema-draft-4-1.0.0-alpha.9.tgz", + "integrity": "sha512-OfX4UBb08C0xD5+F80dQAM2yt5lXxcURWkVEeCwxz7i23BB3nNEbnZXNV91Qo9eaJflPh8dO9iiHQxvfw5IgSg==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-ast": "^1.0.0-alpha.9", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.4" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-draft-6": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-6/-/apidom-ns-json-schema-draft-6-1.0.0-alpha.9.tgz", + "integrity": "sha512-qzUVRSSrnlYGMhK6w57o/RboNvy1FO0iFgEnTk56dD4wN49JRNuFqKI18IgXc1W2r9tTTG70nG1khe4cPE8TNg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-error": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.4" + } + }, + "node_modules/@swagger-api/apidom-ns-json-schema-draft-7": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-json-schema-draft-7/-/apidom-ns-json-schema-draft-7-1.0.0-alpha.9.tgz", + "integrity": "sha512-Zml8Z8VCckdFjvTogaec1dabd85hg1+xZDseWcCuD0tYkaTY/sZ8zzI0dz6/4HsKCb58qjiWSa0w60N8Syr6WQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-error": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-json-schema-draft-6": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.4" + } + }, + "node_modules/@swagger-api/apidom-ns-openapi-2": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-2/-/apidom-ns-openapi-2-1.0.0-alpha.9.tgz", + "integrity": "sha512-WUZxt7Gs7P4EQsGtoD6cKAjf0uDJhkUxsIW9Bb4EAgO6tdp7LlXhbJ0fJ2QycCLY717SfJbvGLfhuSfTYo4Iow==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-error": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-ns-openapi-3-0": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-0/-/apidom-ns-openapi-3-0-1.0.0-alpha.9.tgz", + "integrity": "sha512-7ra5uoZGrfCn1LabfJLueChPcYXyg24//LCYBtjTstyueqd5Vp7JCPeP5NnJSAaqVAP47r8ygceBPoxNp9k1EQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-error": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-json-schema-draft-4": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-ns-openapi-3-1": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-openapi-3-1/-/apidom-ns-openapi-3-1-1.0.0-alpha.9.tgz", + "integrity": "sha512-nQOwNQgf0C8EVjf2loAAl4ifRuVOdcqycvXUdcTpsUfHN3fbndR8IKpb26mQNmnACmqgmX+LkbMdW9b+K6089g==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-ast": "^1.0.0-alpha.9", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-json-pointer": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-ns-workflows-1": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ns-workflows-1/-/apidom-ns-workflows-1-1.0.0-alpha.9.tgz", + "integrity": "sha512-yKo0p8OkQmDib93Kt1yqWmI7JsD6D9qUHxr/SCuAmNNWny1hxm7cZGoKJwJlGd0uAg84j4vmzWOlG3AsJbnT8g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "ts-mixer": "^6.0.3" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-json": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-json/-/apidom-parser-adapter-api-design-systems-json-1.0.0-alpha.9.tgz", + "integrity": "sha512-xfVMR4HrTzXU0HB4TtxwkNbUIa/cQrPa0BWutJZ0fMYMAtUox2s8GsFYnJfZP52XfpSHFM1VPclivorZqET14g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-api-design-systems": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-api-design-systems-yaml": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-api-design-systems-yaml/-/apidom-parser-adapter-api-design-systems-yaml-1.0.0-alpha.9.tgz", + "integrity": "sha512-lJZkrhZ8qRTtc5fSLKefCv8j7Xzo8UBfMjpqTJhmETAtU8YfVV2i2znjgxJpm0QwV6FVQqGfK1+ASZQWPLiVcA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-api-design-systems": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-json-2": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-json-2/-/apidom-parser-adapter-asyncapi-json-2-1.0.0-alpha.9.tgz", + "integrity": "sha512-65nmKdPzw4C1bmtYn+3zoxXCI6Gnobr0StI9XE0YWiK+lpso7RH3Cgyl1yPZ0DBRVGzP+Fn9FVzmDNulEfR95w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-asyncapi-2": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-asyncapi-yaml-2/-/apidom-parser-adapter-asyncapi-yaml-2-1.0.0-alpha.9.tgz", + "integrity": "sha512-RLI4FpVB3vB6mIuT77yrsv5V2LMZ80dW9XpV+Fmbd4Jkdj+ysAFwT38cI4AsUMOxixpTDIXY1oWD7AjvylHhQQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-asyncapi-2": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-json": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-json/-/apidom-parser-adapter-json-1.0.0-alpha.9.tgz", + "integrity": "sha512-aOewp8/3zobf/O+5Jx8y7+bX3BPRfRlHIv15qp4YVTsLs6gLISWSzTO9JpWe9cR+AfhpsAalFq4t1LwIkmLk4A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-ast": "^1.0.0-alpha.9", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-error": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "tree-sitter": "=0.20.4", + "tree-sitter-json": "=0.20.2", + "web-tree-sitter": "=0.20.3" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-2": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-2/-/apidom-parser-adapter-openapi-json-2-1.0.0-alpha.9.tgz", + "integrity": "sha512-zgtsAfkplCFusX2P/saqdn10J8P3kQizCXxHLvxd2j0EhMJk2wfu4HYN5Pej/7/qf/OR1QZxqtacwebd4RfpXA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-openapi-2": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-0": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-0/-/apidom-parser-adapter-openapi-json-3-0-1.0.0-alpha.9.tgz", + "integrity": "sha512-iPuHf0cAZSUhSv8mB0FnVgatTc26cVYohgqz2cvjoGofdqoh5KKIfxOkWlIhm+qGuBp71CfZUrPYPRsd0dHgeg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-json-3-1": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-json-3-1/-/apidom-parser-adapter-openapi-json-3-1-1.0.0-alpha.9.tgz", + "integrity": "sha512-jwkfO7tzZyyrAgok+O9fKFOv1q/5njMb9DBc3D/ZF3ZLTcnEw8uj4V2HkjKxUweH5k8ip/gc8ueKmO/i7p2fng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-2": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-2/-/apidom-parser-adapter-openapi-yaml-2-1.0.0-alpha.9.tgz", + "integrity": "sha512-jEIDpjbjwFKXQXS/RHJeA4tthsguLoz+nJPYS3AOLfuSiby5QXsKTxgqHXxG/YJqF1xJbZL+5KcF8UyiDePumw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-openapi-2": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-0/-/apidom-parser-adapter-openapi-yaml-3-0-1.0.0-alpha.9.tgz", + "integrity": "sha512-ieJL8dfIF8fmP3uJRNh/duJa3cCIIv6MzUe6o4uPT/oTDroy4qIATvnq9Dq/gtAv6rcPRpA9VhyghJ1DmjKsZQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-openapi-yaml-3-1/-/apidom-parser-adapter-openapi-yaml-3-1-1.0.0-alpha.9.tgz", + "integrity": "sha512-EatIH7PZQSNDsRn9ompc62MYzboY7wAkjfYz+2FzBaSA8Vl5/+740qGQj22tu/xhwW4K72aV2NNL1m47QVF7hA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-workflows-json-1": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-workflows-json-1/-/apidom-parser-adapter-workflows-json-1-1.0.0-alpha.9.tgz", + "integrity": "sha512-LylC2cQdAmvR7bXqwMwBt6FHTMVGinwIdI8pjl4EbPT9hCVm1rdED53caTYM4gCm+CJGRw20r4gb9vn3+N6RrA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-workflows-1": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-workflows-yaml-1": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-workflows-yaml-1/-/apidom-parser-adapter-workflows-yaml-1-1.0.0-alpha.9.tgz", + "integrity": "sha512-TlA4+1ca33D7fWxO5jKBytSCv86IGI4Lze4JfrawWUXZ5efhi4LiNmW5TrGlZUyvL7yJtZcA4tn3betlj6jVwA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-ns-workflows-1": "^1.0.0-alpha.9", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-parser-adapter-yaml-1-2/-/apidom-parser-adapter-yaml-1-2-1.0.0-alpha.9.tgz", + "integrity": "sha512-jSIHEB7lbh+MP3BhYIXFkeivDR01kugXN70e5FskW7oet2TIARsVEPheWKQFSP1U8bUZA4bsp9h9gOQ9xEeErw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-ast": "^1.0.0-alpha.9", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@swagger-api/apidom-error": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0", + "tree-sitter": "=0.20.4", + "tree-sitter-yaml": "=0.5.0", + "web-tree-sitter": "=0.20.3" + } + }, + "node_modules/@swagger-api/apidom-reference": { + "version": "1.0.0-alpha.9", + "resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.0.0-alpha.9.tgz", + "integrity": "sha512-KQ6wB5KplqdSsjxdA8BaQulj5zlF5VBCd5KP3RN/9vvixgsD/gyrVY59nisdzmPTqiL6yjhk612eQ96MgG8KiA==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.20.7", + "@swagger-api/apidom-core": "^1.0.0-alpha.9", + "@types/ramda": "~0.30.0", + "axios": "^1.4.0", + "minimatch": "^7.4.3", + "process": "^0.11.10", + "ramda": "~0.30.0", + "ramda-adjunct": "^5.0.0" + }, + "optionalDependencies": { + "@swagger-api/apidom-error": "^1.0.0-alpha.1", + "@swagger-api/apidom-json-pointer": "^1.0.0-alpha.1", + "@swagger-api/apidom-ns-asyncapi-2": "^1.0.0-alpha.1", + "@swagger-api/apidom-ns-openapi-2": "^1.0.0-alpha.1", + "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-alpha.1", + "@swagger-api/apidom-ns-openapi-3-1": "^1.0.0-alpha.1", + "@swagger-api/apidom-ns-workflows-1": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-api-design-systems-json": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-api-design-systems-yaml": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-asyncapi-json-2": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-asyncapi-yaml-2": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-json": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-openapi-json-2": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-openapi-json-3-0": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-openapi-json-3-1": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-openapi-yaml-2": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-0": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-openapi-yaml-3-1": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-workflows-json-1": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-workflows-yaml-1": "^1.0.0-alpha.1", + "@swagger-api/apidom-parser-adapter-yaml-1-2": "^1.0.0-alpha.1" + } + }, + "node_modules/@swagger-api/apidom-reference/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@swagger-api/apidom-reference/node_modules/minimatch": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", + "integrity": "sha512-sBz8G/YjVniEz6lKPNpKxXwazJe4c19fEfV2GDMX6AjFz+MX9uDWIZW8XreVhkFW3fkIdTv/gxWr/Kks5FFAVw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@transloadit/prettier-bytes": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.9.tgz", @@ -2723,6 +3554,15 @@ "@types/send": "*" } }, + "node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -2750,6 +3590,13 @@ "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==", "dev": true }, + "node_modules/@types/micromodal": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@types/micromodal/-/micromodal-0.3.5.tgz", + "integrity": "sha512-xDref7Vyx0nhfJWpeEkVrSb5l1GuHIyxfePxuHSTP3eW587Qe3hzKcBy0V+1Wjuyh21UhJH46eP43czH2ZRpGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", @@ -2762,18 +3609,52 @@ "integrity": "sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==", "dev": true }, + "node_modules/@types/prop-types": { + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "devOptional": true + }, "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", "dev": true }, + "node_modules/@types/ramda": { + "version": "0.30.2", + "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.30.2.tgz", + "integrity": "sha512-PyzHvjCalm2BRYjAU6nIB3TprYwMNOUY/7P/N8bSzp9W/yM2YrtGtAnnVtaCNSeOZ8DzKyFDvaqQs7LnWwwmBA==", + "license": "MIT", + "dependencies": { + "types-ramda": "^0.30.1" + } + }, "node_modules/@types/range-parser": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", "dev": true }, + "node_modules/@types/react": { + "version": "18.2.73", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.73.tgz", + "integrity": "sha512-XcGdod0Jjv84HOC7N5ziY3x+qL0AfmubvKOZ9hJjJ2yd5EE+KYjWhdOjt387e9HPheHkdggF9atTifMRtyAaRA==", + "devOptional": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.2.23", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.23.tgz", + "integrity": "sha512-ZQ71wgGOTmDYpnav2knkjr3qXdAFu0vsk8Ci5w3pGAIdj7/kKAyn+VsQDhXsmzzzepAiI9leWMmubXz690AI/A==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -2810,6 +3691,16 @@ "@types/node": "*" } }, + "node_modules/@types/sha256-wasm": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@types/sha256-wasm/-/sha256-wasm-2.2.3.tgz", + "integrity": "sha512-yaESPxpToMojUgEAjVsk0XAxXcVK8e2dyRfFzD9U8wKsdnV4SGrI3tg6tUrRoEW4PEmfWvKFJ2KocSUt5AETfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sockjs": { "version": "0.3.33", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.33.tgz", @@ -2819,6 +3710,40 @@ "@types/node": "*" } }, + "node_modules/@types/swagger-ui-react": { + "version": "4.18.3", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-react/-/swagger-ui-react-4.18.3.tgz", + "integrity": "sha512-Mo/R7IjDVwtiFPs84pWvh5pI9iyNGBjmfielxqbOh2Jv+8WVSDVe8Nu25kb5BOuV2xmGS3o33jr6nwDJMBcX+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", + "license": "MIT" + }, + "node_modules/@types/webpack-bundle-analyzer": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@types/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.7.0.tgz", + "integrity": "sha512-c5i2ThslSNSG8W891BRvOd/RoCjI2zwph8maD22b1adtSns20j+0azDDMCK06DiVrzTgnwiDl5Ntmu1YRJw8Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "tapable": "^2.2.0", + "webpack": "^5" + } + }, "node_modules/@types/ws": { "version": "8.5.5", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.5.tgz", @@ -3187,10 +4112,11 @@ } }, "node_modules/acorn": { - "version": "8.10.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", - "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -3207,6 +4133,19 @@ "acorn": "^8" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -3309,6 +4248,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -3329,11 +4269,16 @@ "node": ">= 8" } }, + "node_modules/apg-lite": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/apg-lite/-/apg-lite-1.0.4.tgz", + "integrity": "sha512-B32zCN3IdHIc99Vy7V9BaYTUzLeRA8YXYY1aQD1/5I2aqIrO0coi4t6hJPqMisidlBxhyME8UexkHt31SlR6Og==", + "license": "BSD-2-Clause" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-flatten": { "version": "2.1.2", @@ -3350,6 +4295,21 @@ "node": ">=8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autolinker": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-3.16.2.tgz", + "integrity": "sha512-JiYl7j2Z19F9NdTmirENSUUIIL/9MytEWtmzhfmsKPCp9E+G35Y0UNCMoM9tFigxT59qSc8Ml2dlZXOCVTYwuA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + } + }, "node_modules/autoprefixer": { "version": "10.4.15", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz", @@ -3387,6 +4347,17 @@ "postcss": "^8.1.0" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/b4a": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", @@ -3453,14 +4424,12 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -3536,6 +4505,43 @@ "node": ">=8" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -3757,6 +4763,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -3766,6 +4773,36 @@ "node": ">=4" } }, + "node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -3793,6 +4830,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, "node_modules/chrome-trace-event": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", @@ -3803,9 +4847,10 @@ } }, "node_modules/classnames": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", - "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" }, "node_modules/clean-css": { "version": "5.3.2", @@ -3833,6 +4878,14 @@ "node": ">=6" } }, + "node_modules/clsx": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", + "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "engines": { + "node": ">=6" + } + }, "node_modules/codem-isoboxer": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/codem-isoboxer/-/codem-isoboxer-0.3.9.tgz", @@ -3843,6 +4896,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "1.1.3" } @@ -3851,7 +4905,8 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/colorette": { "version": "2.0.20", @@ -3868,6 +4923,28 @@ "lodash.uniqby": "4.5.0" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", @@ -3991,6 +5068,15 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "dev": true }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, "node_modules/copy-webpack-plugin": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-8.1.1.tgz", @@ -4047,6 +5133,17 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-js-pure": { + "version": "3.38.1", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.38.1.tgz", + "integrity": "sha512-BY8Etc1FZqdw1glX0XNOq2FDwfrg/VGqoZOZCdaL+UmdaqDwQwYXkMJT4t6In+zfEfOJDcM9T0KdbBeJg8KKCQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4266,6 +5363,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "license": "MIT" + }, "node_modules/cssdb": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.7.0.tgz", @@ -4294,6 +5397,12 @@ "node": ">=4" } }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true + }, "node_modules/custom-error-instance": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/custom-error-instance/-/custom-error-instance-2.1.1.tgz", @@ -4316,6 +5425,13 @@ "ua-parser-js": "^1.0.2" } }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -4333,6 +5449,40 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/default-gateway": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-6.0.3.tgz", @@ -4354,6 +5504,15 @@ "node": ">=8" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -4373,6 +5532,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -4472,6 +5641,12 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.4.tgz", + "integrity": "sha512-2gnshi6OshmuKil8rMZuQCGiUF3cUxHY3NGDzUAdUx/NPEe5DVnO8BDoAQouvgwnx0R/+a6jUn36Z0FSdq8vww==", + "license": "(MPL-2.0 OR Apache-2.0)" + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -4496,15 +5671,22 @@ "tslib": "^2.0.3" } }, - "node_modules/dotenv": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", - "integrity": "sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==", - "dev": true, + "node_modules/drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=4" } }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4535,6 +5717,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.15.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", @@ -4609,6 +5801,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } @@ -4716,6 +5909,16 @@ "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -4800,6 +6003,12 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-patch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz", + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -4824,6 +6033,19 @@ "reusify": "^1.0.4" } }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/faye-websocket": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", @@ -4924,16 +6146,16 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", - "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", - "dev": true, + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -4943,6 +6165,28 @@ } } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4974,6 +6218,13 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -5061,6 +6312,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5142,6 +6400,22 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -5165,6 +6439,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -5193,6 +6468,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hast-util-parse-selector": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz", + "integrity": "sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-6.0.0.tgz", + "integrity": "sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "comma-separated-tokens": "^1.0.0", + "hast-util-parse-selector": "^2.0.0", + "property-information": "^5.0.0", + "space-separated-tokens": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -5202,6 +6504,15 @@ "he": "bin/he" } }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/hls.js": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.4.10.tgz", @@ -5254,6 +6565,13 @@ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", "integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -5420,7 +6738,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -5453,8 +6770,7 @@ "node_modules/immutable": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.2.tgz", - "integrity": "sha512-oGXzbEDem9OOpDWZu88jGiYCvIsLHMvGw+8OXlpsvTFvIQplQbjg1B1cvKg8f7Hoch6+NGjpPsH1Fr+Mc2D1aA==", - "dev": true + "integrity": "sha512-oGXzbEDem9OOpDWZu88jGiYCvIsLHMvGw+8OXlpsvTFvIQplQbjg1B1cvKg8f7Hoch6+NGjpPsH1Fr+Mc2D1aA==" }, "node_modules/import-fresh": { "version": "3.3.0", @@ -5512,8 +6828,14 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC", + "optional": true }, "node_modules/interpret": { "version": "3.1.1", @@ -5524,6 +6846,15 @@ "node": ">=10.13.0" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/ipaddr.js": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", @@ -5630,6 +6961,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5764,17 +7105,21 @@ "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.5.tgz", "integrity": "sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==" }, + "node_modules/js-file-download": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz", + "integrity": "sha512-rML+NkoD08p5Dllpjo0ffy4jRHeY6Zsapvr/W86N7E0yuzAO6qa5X9+xog6zQNlH102J7IXljNY2FtS6Lj3ucg==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -5806,6 +7151,16 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/json-stringify-deterministic": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/json-stringify-deterministic/-/json-stringify-deterministic-1.0.12.tgz", + "integrity": "sha512-q3PN0lbUdv0pmurkBNdJH3pfFvOTL/Zp0lquqpvcjfKzt6Y0j49EPHAmVHCAS4Ceq/Y+PejWTzyiVpoY71+D6g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5830,6 +7185,13 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/just-curry-it": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/just-curry-it/-/just-curry-it-5.3.0.tgz", + "integrity": "sha512-silMIRiFjUWlfaDhkgSzpuAyQ6EX/o09Eu8ZBfmFwQMbax7+LQzeIU2CBrICT6Ne4l86ITCGvUCBpCubWYy0Yw==", + "dev": true, + "license": "MIT" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -5963,8 +7325,7 @@ "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, "node_modules/lodash.throttle": { "version": "4.1.1", @@ -5980,6 +7341,17 @@ "lodash._baseuniq": "~4.6.0" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -5989,6 +7361,20 @@ "tslib": "^2.0.3" } }, + "node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6106,7 +7492,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -6123,7 +7508,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -6140,6 +7524,19 @@ "node": ">=6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-document": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz", @@ -6187,6 +7584,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/minim": { + "version": "0.23.8", + "resolved": "https://registry.npmjs.org/minim/-/minim-0.23.8.tgz", + "integrity": "sha512-bjdr2xW1dBCMsMGGsUeqM4eFI60m94+szhxWys+B1ztIt6gWSfeGBdSVCIawezeHYLYn0j6zrsXdQS/JllBzww==", + "license": "MIT", + "dependencies": { + "lodash": "^4.15.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -6205,6 +7614,33 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, + "node_modules/mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -6229,6 +7665,13 @@ "resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz", "integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==" }, + "node_modules/nan": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", + "license": "MIT", + "optional": true + }, "node_modules/nanoassert": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-2.0.0.tgz", @@ -6251,6 +7694,13 @@ "node": "^14 || ^16 || >=18" } }, + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "license": "MIT", + "optional": true + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -6266,6 +7716,15 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/neotraverse": { + "version": "0.6.18", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-0.6.18.tgz", + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -6276,6 +7735,74 @@ "tslib": "^2.0.3" } }, + "node_modules/node-abi": { + "version": "3.67.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.67.0.tgz", + "integrity": "sha512-bLn/fU/ALVBE9wj+p4Y21ZJWYFjUXLXPi/IewyLZkx3ApxKDNBWCKdReeKOtD8dWpOdDCeMyLh6ZewzcLsG2Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch-commonjs": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch-commonjs/-/node-fetch-commonjs-3.3.2.tgz", + "integrity": "sha512-VBlAiynj3VMLrotgwOS3OyECFxas5y7ltLcK4t41lMUZeaK15Ym4QRkqN0EQKAFL42q9i21EPKjzLUPfltR72A==", + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -6333,6 +7860,15 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.12.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", @@ -6373,7 +7909,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, + "devOptional": true, "dependencies": { "wrappy": "1" } @@ -6410,6 +7946,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-path-templating": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/openapi-path-templating/-/openapi-path-templating-1.6.0.tgz", + "integrity": "sha512-1atBNwOUrZXthTvlvvX8k8ovFEF3iA8mDidYMkdOtvVdndBhTrspbwGXNOzEUaJhm9iUl4Tf5uQaeTLAJvwPig==", + "license": "Apache-2.0", + "dependencies": { + "apg-lite": "^1.0.3" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/openapi-server-url-templating": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/openapi-server-url-templating/-/openapi-server-url-templating-1.1.0.tgz", + "integrity": "sha512-dtyTFKx2xVcO0W8JKaluXIHC9l/MLjHeflBaWjiWNMCHp/TBs9dEjQDbj/VFlHR4omFOKjjmqm1pW1aCAhmPBg==", + "license": "Apache-2.0", + "dependencies": { + "apg-lite": "^1.0.3" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6539,6 +8109,24 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -7495,6 +9083,33 @@ "url": "https://opencollective.com/preact" } }, + "node_modules/prebuild-install": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", + "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/pretty-error": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", @@ -7505,6 +9120,15 @@ "renderkid": "^3.0.0" } }, + "node_modules/prismjs": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", + "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -7519,6 +9143,17 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -7529,6 +9164,19 @@ "signal-exit": "^3.0.2" } }, + "node_modules/property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -7551,6 +9199,23 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", @@ -7605,11 +9270,49 @@ } ] }, + "node_modules/ramda": { + "version": "0.30.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz", + "integrity": "sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ramda" + } + }, + "node_modules/ramda-adjunct": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ramda-adjunct/-/ramda-adjunct-5.1.0.tgz", + "integrity": "sha512-8qCpl2vZBXEJyNbi4zqcgdfHtcdsWjOGbiNSEnEBrM6Y0OKOT8UxJbIVGm1TIcjaSu2MxaWcgtsNlKlCk7o7qg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ramda-adjunct" + }, + "peerDependencies": { + "ramda": ">= 0.30.0" + } + }, + "node_modules/randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "license": "MIT", + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -7647,11 +9350,205 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-copy-to-clipboard": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz", + "integrity": "sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==", + "license": "MIT", + "dependencies": { + "copy-to-clipboard": "^3.3.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^15.3.0 || 16 || 17 || 18" + } + }, + "node_modules/react-debounce-input": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-debounce-input/-/react-debounce-input-3.3.0.tgz", + "integrity": "sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^15.3.0 || 16 || 17 || 18" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-helmet-async": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz", + "integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==", + "license": "Apache-2.0", + "dependencies": { + "invariant": "^2.2.4", + "react-fast-compare": "^3.2.2", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.6.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-immutable-proptypes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-immutable-proptypes/-/react-immutable-proptypes-2.2.0.tgz", + "integrity": "sha512-Vf4gBsePlwdGvSZoLSBfd4HAP93HDauMY4fDjXhreg/vg6F3Fj/MXDNyTbltPC/xZKmZc+cjLu3598DdYK6sgQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.2" + }, + "peerDependencies": { + "immutable": ">=3.6.2" + } + }, + "node_modules/react-immutable-pure-component": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-immutable-pure-component/-/react-immutable-pure-component-2.2.2.tgz", + "integrity": "sha512-vkgoMJUDqHZfXXnjVlG3keCxSO/U6WeDQ5/Sl0GK2cH8TOxEzQ5jXqDXHEL/jqk6fsNxV05oH5kD7VNMUE2k+A==", + "license": "MIT", + "peerDependencies": { + "immutable": ">= 2 || >= 4.0.0-rc", + "react": ">= 16.6", + "react-dom": ">= 16.6" + } + }, + "node_modules/react-inspector": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/react-inspector/-/react-inspector-6.0.2.tgz", + "integrity": "sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.4 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.2.tgz", + "integrity": "sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.3", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25", + "react": "^18.0", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "6.24.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.24.0.tgz", + "integrity": "sha512-sQrgJ5bXk7vbcC4BxQxeNa5UmboFm35we1AFK0VvQaz9g0LzxEIuLOhHIoZ8rnu9BO21ishGeL9no1WB76W/eg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.17.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.24.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.24.0.tgz", + "integrity": "sha512-960sKuau6/yEwS8e+NVEidYQb1hNjAYM327gjEyXlc6r3Skf2vtwuJ2l7lssdegD2YjoKG5l8MsVyeTDlVeY8g==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.17.0", + "react-router": "6.24.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-syntax-highlighter": { + "version": "15.5.0", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.5.0.tgz", + "integrity": "sha512-+zq2myprEnQmH5yw6Gqc8lD55QHnpKaU8TOcFeC/Lg/MQSs8UknEA0JC4nTZGFAXC2J2Hyj/ijJ7NlabyPi2gg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.3.1", + "highlight.js": "^10.4.1", + "lowlight": "^1.17.0", + "prismjs": "^1.27.0", + "refractor": "^3.6.0" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, + "devOptional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -7685,6 +9582,45 @@ "node": ">= 10.13.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-immutable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/redux-immutable/-/redux-immutable-4.0.0.tgz", + "integrity": "sha512-SchSn/DWfGb3oAejd+1hhHx01xUoxY+V7TeK0BKqpkLKiQPVFf7DYzEaKmrEVxsWxielKfSK9/Xq66YyxgR1cg==", + "license": "BSD-3-Clause", + "peerDependencies": { + "immutable": "^3.8.1 || ^4.0.0-rc.1" + } + }, + "node_modules/refractor": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-3.6.0.tgz", + "integrity": "sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==", + "license": "MIT", + "dependencies": { + "hastscript": "^6.0.0", + "parse-entities": "^2.0.0", + "prismjs": "~1.27.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/refractor/node_modules/prismjs": { + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.27.0.tgz", + "integrity": "sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -7764,6 +9700,31 @@ "node": ">= 0.10" } }, + "node_modules/remarkable": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/remarkable/-/remarkable-2.0.1.tgz", + "integrity": "sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "autolinker": "^3.11.0" + }, + "bin": { + "remarkable": "bin/remarkable.js" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/remarkable/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/renderkid": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", @@ -7777,6 +9738,15 @@ "strip-ansi": "^6.0.1" } }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7791,6 +9761,12 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.4", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz", @@ -7838,6 +9814,15 @@ "node": ">=4" } }, + "node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -7898,7 +9883,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -7976,6 +9960,14 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==" }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/schema-utils": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.1.tgz", @@ -8066,6 +10058,21 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true }, + "node_modules/serialize-error": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serialize-javascript": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-5.0.1.tgz", @@ -8174,6 +10181,19 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "dev": true }, + "node_modules/sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + }, + "bin": { + "sha.js": "bin.js" + } + }, "node_modules/sha256-wasm": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/sha256-wasm/-/sha256-wasm-2.2.2.tgz", @@ -8195,6 +10215,12 @@ "node": ">=8" } }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8225,6 +10251,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/short-unique-id": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/short-unique-id/-/short-unique-id-5.2.0.tgz", + "integrity": "sha512-cMGfwNyfDZ/nzJ2k2M+ClthBIh//GlZl1JEf47Uoa9XR11bz8Pa2T2wQO4bVrRdH48LrIDWJahQziKo3MjhsWg==", + "license": "Apache-2.0", + "bin": { + "short-unique-id": "bin/short-unique-id", + "suid": "bin/short-unique-id" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -8244,6 +10280,68 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -8298,6 +10396,16 @@ "source-map": "^0.6.0" } }, + "node_modules/space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/spdy": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", @@ -8328,6 +10436,12 @@ "wbuf": "^1.7.3" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -8351,7 +10465,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, + "devOptional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -8377,6 +10491,16 @@ "node": ">=6" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/style-loader": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", @@ -8420,6 +10544,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^3.0.0" }, @@ -8439,6 +10564,93 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-client": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/swagger-client/-/swagger-client-3.29.2.tgz", + "integrity": "sha512-7dOIAodJeUsYbvWTpDODY2+bfJcZ34WG84TByMet76OJ/ZjOLHZtJSgMFxEvnh9+yR0qn8wvHUdfg27ylg2eiQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.22.15", + "@swagger-api/apidom-core": ">=1.0.0-alpha.9 <1.0.0-beta.0", + "@swagger-api/apidom-error": ">=1.0.0-alpha.9 <1.0.0-beta.0", + "@swagger-api/apidom-json-pointer": ">=1.0.0-alpha.9 <1.0.0-beta.0", + "@swagger-api/apidom-ns-openapi-3-1": ">=1.0.0-alpha.9 <1.0.0-beta.0", + "@swagger-api/apidom-reference": ">=1.0.0-alpha.9 <1.0.0-beta.0", + "cookie": "~0.6.0", + "deepmerge": "~4.3.0", + "fast-json-patch": "^3.0.0-1", + "js-yaml": "^4.1.0", + "neotraverse": "=0.6.18", + "node-abort-controller": "^3.1.1", + "node-fetch-commonjs": "^3.3.2", + "openapi-path-templating": "^1.5.1", + "openapi-server-url-templating": "^1.0.0", + "ramda-adjunct": "^5.0.0" + } + }, + "node_modules/swagger-client/node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/swagger-ui-react": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-react/-/swagger-ui-react-5.17.14.tgz", + "integrity": "sha512-mCXerZrbcn4ftPYifUF0+iKIRTHoVCv0HcJc/sXl9nCe3oeWdsjmOWVqKabzzAkAa0NwsbKNJFv2UL/Ivnf6VQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime-corejs3": "^7.24.5", + "@braintree/sanitize-url": "=7.0.2", + "base64-js": "^1.5.1", + "classnames": "^2.5.1", + "css.escape": "1.5.1", + "deep-extend": "0.6.0", + "dompurify": "=3.1.4", + "ieee754": "^1.2.1", + "immutable": "^3.x.x", + "js-file-download": "^0.4.12", + "js-yaml": "=4.1.0", + "lodash": "^4.17.21", + "prop-types": "^15.8.1", + "randexp": "^0.5.3", + "randombytes": "^2.1.0", + "react-copy-to-clipboard": "5.1.0", + "react-debounce-input": "=3.3.0", + "react-immutable-proptypes": "2.2.0", + "react-immutable-pure-component": "^2.2.0", + "react-inspector": "^6.0.1", + "react-redux": "^9.1.2", + "react-syntax-highlighter": "^15.5.0", + "redux": "^5.0.1", + "redux-immutable": "^4.0.0", + "remarkable": "^2.0.1", + "reselect": "^5.1.0", + "serialize-error": "^8.1.0", + "sha.js": "^2.4.11", + "swagger-client": "^3.28.1", + "url-parse": "^1.5.10", + "xml": "=1.0.1", + "xml-but-prettier": "^1.0.1", + "zenscroll": "^4.0.2" + }, + "peerDependencies": { + "react": ">=16.8.0 <19", + "react-dom": ">=16.8.0 <19" + } + }, + "node_modules/swagger-ui-react/node_modules/immutable": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", + "integrity": "sha512-15gZoQ38eYjEjxkorfbcgBKBL6R7T459OuK+CpcWt7O3KF4uPCx2tD0uFETlUDIyo+1789crbMhTvQBSR5yBMg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -8448,6 +10660,36 @@ "node": ">=6" } }, + "node_modules/tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/terser": { "version": "5.19.2", "resolved": "https://registry.npmjs.org/terser/-/terser-5.19.2.tgz", @@ -8565,6 +10807,12 @@ "node": ">=8.0" } }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -8574,11 +10822,211 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tree-sitter": { + "version": "0.20.4", + "resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.20.4.tgz", + "integrity": "sha512-rjfR5dc4knG3jnJNN/giJ9WOoN1zL/kZyrS0ILh+eqq8RNcIbiXA63JsMEgluug0aNvfQvK4BfCErN1vIzvKog==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "nan": "^2.17.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/tree-sitter-json": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/tree-sitter-json/-/tree-sitter-json-0.20.2.tgz", + "integrity": "sha512-eUxrowp4F1QEGk/i7Sa+Xl8Crlfp7J0AXxX1QdJEQKQYMWhgMbCIgyQvpO3Q0P9oyTrNQxRLlRipDS44a8EtRw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "nan": "^2.18.0" + } + }, + "node_modules/tree-sitter-yaml": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/tree-sitter-yaml/-/tree-sitter-yaml-0.5.0.tgz", + "integrity": "sha512-POJ4ZNXXSWIG/W4Rjuyg36MkUD4d769YRUGKRqN+sVaj/VCo6Dh6Pkssn1Rtewd5kybx+jT1BWMyWN0CijXnMA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "nan": "^2.14.0" + } + }, + "node_modules/ts-loader": { + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.1.tgz", + "integrity": "sha512-rNH3sK9kGZcH9dYzC7CewQm4NtxJTjSEVRJ2DyBZR7f8/wcta+iV44UPCXc5+nzDzivKtlzV6c9P4e+oFhDLYg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-loader/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ts-loader/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ts-loader/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ts-loader/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/ts-loader/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-loader/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-loader/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ts-loader/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-loader/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/ts-toolbelt": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", + "license": "Apache-2.0" + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } }, "node_modules/tus-js-client": { "version": "3.1.1", @@ -8594,6 +11042,18 @@ "url-parse": "^1.5.7" } }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -8607,6 +11067,28 @@ "node": ">= 0.6" } }, + "node_modules/types-ramda": { + "version": "0.30.1", + "resolved": "https://registry.npmjs.org/types-ramda/-/types-ramda-0.30.1.tgz", + "integrity": "sha512-1HTsf5/QVRmLzcGfldPFvkVsAdi1db1BBKzi7iW3KBUlOICg/nKnFS+jGqDJS3YD8VsWbAh7JiHeBvbsw8RPxA==", + "license": "MIT", + "dependencies": { + "ts-toolbelt": "^9.6.0" + } + }, + "node_modules/typescript": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", + "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/ua-parser-js": { "version": "1.0.35", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.35.tgz", @@ -8683,6 +11165,12 @@ "node": ">= 0.8" } }, + "node_modules/unraw": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz", + "integrity": "sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==", + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz", @@ -8731,11 +11219,20 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", + "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "devOptional": true }, "node_modules/utila": { "version": "0.4.0", @@ -8800,6 +11297,22 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/web-tree-sitter": { + "version": "0.20.3", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.20.3.tgz", + "integrity": "sha512-zKGJW9r23y3BcJusbgvnOH2OYAW40MXAOi9bi3Gcc7T4Gms9WWgXF8m6adsJWpGJEhgOzCrfiz1IzKowJWrtYw==", + "license": "MIT", + "optional": true + }, "node_modules/webpack": { "version": "5.88.2", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", @@ -8847,6 +11360,78 @@ } } }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/webpack-cli": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", @@ -9254,7 +11839,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "devOptional": true }, "node_modules/ws": { "version": "8.13.0", @@ -9277,12 +11862,49 @@ } } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "license": "MIT" + }, + "node_modules/xml-but-prettier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml-but-prettier/-/xml-but-prettier-1.0.1.tgz", + "integrity": "sha512-C2CJaadHrZTqESlH03WOyw0oZTtoy2uEg6dSDF6YRg+9GnYNub53RRemLpnvtbHDFelxMx4LajiFsYeR6XJHgQ==", + "license": "MIT", + "dependencies": { + "repeat-string": "^1.5.2" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yaml": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -9294,6 +11916,12 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zenscroll": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zenscroll/-/zenscroll-4.0.2.tgz", + "integrity": "sha512-jEA1znR7b4C/NnaycInCU6h/d15ZzCd1jmsruqOKnZP6WXQSMH3W2GL+OXbkruslU4h+Tzuos0HdswzRUk/Vgg==", + "license": "Unlicense" } } } diff --git a/client/package.json b/client/package.json index 14cb424..8a8234c 100644 --- a/client/package.json +++ b/client/package.json @@ -1,37 +1,64 @@ { "name": "kemono-2-client", - "version": "0.2.1", + "version": "1.0.0", "description": "frontend for kemono 2", "private": true, - "scripts": { - "dev": "webpack serve --config webpack.dev.js", - "build": "webpack --config webpack.prod.js" - }, - "keywords": [], "author": "BassOfBass", "license": "ISC", + "scripts": { + "dev": "webpack serve --config webpack.dev.js", + "validate": "node scripts/validate.mjs", + "build": "webpack --config webpack.prod.js" + }, + "imports": { + "#storage/*": "./src/browser/storage/*/index.ts", + "#hooks": "./src/browser/hooks/index.ts", + "#components/*": "./src/components/*/index.ts", + "#env/*": "./src/env/*.ts", + "#lib/*": "./src/lib/*/index.ts", + "#pages/*": "./src/pages/*.tsx", + "#entities/*": "./src/entities/*/index.ts", + "#css": "./src/css/*.scss", + "#assets/*": "./src/assets/*", + "#api/*": "./src/api/*/index.ts" + }, "dependencies": { "@babel/runtime": "^7.22.10", "@uppy/core": "^3.4.0", "@uppy/dashboard": "^3.5.1", "@uppy/form": "^3.0.2", "@uppy/tus": "^3.1.3", + "clsx": "^2.1.0", "diff": "^5.1.0", "fluid-player": "^3.22.0", "micromodal": "^0.4.10", "purecss": "^3.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-helmet-async": "^2.0.5", + "react-router-dom": "^6.24.0", "sha256-wasm": "^2.2.2", + "swagger-ui-react": "^5.17.14", "whatwg-fetch": "^3.6.17" }, "devDependencies": { "@babel/core": "^7.22.10", "@babel/plugin-transform-runtime": "^7.22.10", "@babel/preset-env": "^7.22.10", + "@babel/preset-react": "^7.24.7", + "@babel/preset-typescript": "^7.24.7", + "@hyperjump/json-schema": "^1.9.3", + "@types/micromodal": "^0.3.5", + "@types/node": "^20.1.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/sha256-wasm": "^2.2.3", + "@types/swagger-ui-react": "^4.18.3", + "@types/webpack-bundle-analyzer": "^4.7.0", "babel-loader": "^8.3.0", "buffer": "^6.0.3", "copy-webpack-plugin": "^8.1.1", "css-loader": "^5.2.7", - "dotenv": "^8.6.0", "fs-extra": "^10.1.0", "html-webpack-plugin": "^5.5.3", "mini-css-extract-plugin": "^1.6.2", @@ -43,10 +70,14 @@ "sass-loader": "^11.1.1", "stream-browserify": "^3.0.0", "style-loader": "^2.0.0", + "ts-loader": "^9.5.1", + "typescript": "^5.3.3", "webpack": "^5.88.2", + "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1", "webpack-manifest-plugin": "^5.0.0", - "webpack-merge": "^5.9.0" + "webpack-merge": "^5.9.0", + "yaml": "^2.4.5" } } diff --git a/client/scripts/validate.mjs b/client/scripts/validate.mjs new file mode 100644 index 0000000..7eee874 --- /dev/null +++ b/client/scripts/validate.mjs @@ -0,0 +1,29 @@ +// @ts-check +import path from "node:path"; +import fs from "node:fs/promises"; +import { cwd } from "node:process"; +import { validate } from "@hyperjump/json-schema/openapi-3-1"; +import YAML from "yaml"; + +const schemaPath = path.join(cwd(), "..", "src", "pages", "api", "schema.yaml"); + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); + +async function run() { + const fileContent = await fs.readFile(schemaPath, { encoding: "utf8" }); + const parsedSchema = YAML.parse(fileContent) + const output = await validate( + "https://spec.openapis.org/oas/3.1/schema-base", + parsedSchema, + // the library doesn't support values beyond `"FLAG"` and `"BASIC"` + // but it's the only library in js which can validate OpenAPI 3.1 schemas + "BASIC" + ); + + if (!output.valid) { + throw new Error("Failed to validate OpenAPI Schema.") + } +} diff --git a/client/src/api/_index.js b/client/src/api/_index.js deleted file mode 100644 index 6990896..0000000 --- a/client/src/api/_index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { kemonoAPI } from "./kemono/_index"; -export { paysitesAPI } from "./paysites/_index"; diff --git a/client/src/api/account/account.ts b/client/src/api/account/account.ts new file mode 100644 index 0000000..5df66d9 --- /dev/null +++ b/client/src/api/account/account.ts @@ -0,0 +1,19 @@ +import { IAccount } from "#entities/account"; +import { apiFetch } from "../fetch"; + +interface IResult { + props: { + currentPage: "account"; + title: string; + account: IAccount; + notifications_count: number; + }; +} + +export async function fetchAccount() { + const path = "/account"; + + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/api/account/administrator/accounts.ts b/client/src/api/account/administrator/accounts.ts new file mode 100644 index 0000000..1af870d --- /dev/null +++ b/client/src/api/account/administrator/accounts.ts @@ -0,0 +1,40 @@ +import { IAccontRole, IAccount } from "#entities/account"; +import { IPagination } from "#lib/pagination"; +import { apiFetch } from "../../fetch"; + +interface IResult { + pagination: IPagination; + accounts: IAccount[]; + role_list: IAccontRole[]; + currentPage: "admin"; +} + +export async function fetchAccounts( + page?: number, + name?: string, + role?: string, + limit?: number +) { + const path = `/account/administrator/accounts`; + const params = new URLSearchParams(); + + if (page) { + params.set("page", String(page)); + } + + if (name) { + params.set("name", name); + } + + if (role) { + params.set("role", role); + } + + if (limit) { + params.set("limit", String(limit)); + } + + const result = await apiFetch(path, { method: "GET" }, params); + + return result; +} diff --git a/client/src/api/account/administrator/change-roles.ts b/client/src/api/account/administrator/change-roles.ts new file mode 100644 index 0000000..5dd7f4c --- /dev/null +++ b/client/src/api/account/administrator/change-roles.ts @@ -0,0 +1,26 @@ +import { apiFetch } from "../../fetch"; + +interface IBody { + moderator?: number[]; + consumer?: number[]; +} + +export async function fetchChangeRolesOfAccounts( + moderators?: string[], + consumers?: string[] +) { + const path = `/account/administrator/accounts`; + const body: IBody = {}; + + if (moderators && moderators.length !== 0) { + body.moderator = moderators.map((id) => Number(id)); + } + + if (consumers && consumers.length !== 0) { + body.consumer = consumers.map((id) => Number(id)); + } + + await apiFetch(path, { method: "POST", body }); + + return true; +} diff --git a/client/src/api/account/administrator/index.ts b/client/src/api/account/administrator/index.ts new file mode 100644 index 0000000..efda5b5 --- /dev/null +++ b/client/src/api/account/administrator/index.ts @@ -0,0 +1,2 @@ +export { fetchAccounts } from "./accounts"; +export { fetchChangeRolesOfAccounts } from "./change-roles"; diff --git a/client/src/api/account/auto-import-keys/get.ts b/client/src/api/account/auto-import-keys/get.ts new file mode 100644 index 0000000..780af68 --- /dev/null +++ b/client/src/api/account/auto-import-keys/get.ts @@ -0,0 +1,19 @@ +import { IAutoImportKey } from "#entities/account"; +import { apiFetch } from "../../fetch"; + +interface IResult { + props: { + currentPage: "account"; + title: "Your service keys"; + service_keys: IAutoImportKey[]; + }; + import_ids: { key_id: string; import_id: string }[]; +} + +export async function fetchAccountAutoImportKeys() { + const path = `/account/keys`; + + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/api/account/auto-import-keys/index.ts b/client/src/api/account/auto-import-keys/index.ts new file mode 100644 index 0000000..134ec93 --- /dev/null +++ b/client/src/api/account/auto-import-keys/index.ts @@ -0,0 +1,2 @@ +export { fetchAccountAutoImportKeys } from "./get"; +export { fetchRevokeAutoImportKeys } from "./revoke"; diff --git a/client/src/api/account/auto-import-keys/revoke.ts b/client/src/api/account/auto-import-keys/revoke.ts new file mode 100644 index 0000000..fe94fe1 --- /dev/null +++ b/client/src/api/account/auto-import-keys/revoke.ts @@ -0,0 +1,15 @@ +import { apiFetch } from "../../fetch"; + +interface IBody { + revoke: number[]; +} + +export async function fetchRevokeAutoImportKeys(keyIDs: number[]) { + const path = `/account/keys`; + const body: IBody = { + revoke: keyIDs, + }; + await apiFetch(path, { method: "POST", body }); + + return true; +} diff --git a/client/src/api/account/change-password.ts b/client/src/api/account/change-password.ts new file mode 100644 index 0000000..4d5eb0f --- /dev/null +++ b/client/src/api/account/change-password.ts @@ -0,0 +1,24 @@ +import { apiFetch } from "../fetch"; + +interface IBody { + "current-password": string; + "new-password": string; + "new-password-confirmation": string; +} + +export async function fetchAccountChangePassword( + currentPassword: string, + newPassword: string, + newPasswordConfirmation: string +) { + const path = `/account/change_password`; + const body: IBody = { + "current-password": currentPassword, + "new-password": newPassword, + "new-password-confirmation": newPasswordConfirmation, + }; + + const result = await apiFetch(path, { method: "POST", body }); + + return result; +} diff --git a/client/src/api/account/dms/get.ts b/client/src/api/account/dms/get.ts new file mode 100644 index 0000000..95cf6f3 --- /dev/null +++ b/client/src/api/account/dms/get.ts @@ -0,0 +1,22 @@ +import { IUnapprovedDM } from "#entities/dms"; +import { apiFetch } from "../../fetch"; + +interface IResult { + currentPage: "import"; + account_id: number; + status: "ignored" | "pending"; + dms: IUnapprovedDM[]; +} + +export async function fetchDMsForReview(status?: "ignored" | "pending") { + const path = `/account/review_dms`; + const params = new URLSearchParams(); + + if (status) { + params.set("status", status); + } + + const result = await apiFetch(path, { method: "GET" }, params); + + return result; +} diff --git a/client/src/api/account/dms/index.ts b/client/src/api/account/dms/index.ts new file mode 100644 index 0000000..55f9cda --- /dev/null +++ b/client/src/api/account/dms/index.ts @@ -0,0 +1,2 @@ +export { fetchDMsForReview } from "./get"; +export { fetchApproveDMs } from "./review" diff --git a/client/src/api/account/dms/review.ts b/client/src/api/account/dms/review.ts new file mode 100644 index 0000000..5d6b6a5 --- /dev/null +++ b/client/src/api/account/dms/review.ts @@ -0,0 +1,21 @@ +import { apiFetch } from "../../fetch"; + +interface IBody { + approved_hashes: string[]; + delete_ignored?: boolean; +} + +export async function fetchApproveDMs( + hashes: string[], + isIgnoredDeleted?: boolean +) { + const path = `/account/review_dms`; + const body: IBody = { + approved_hashes: hashes, + delete_ignored: isIgnoredDeleted, + }; + + const result = await apiFetch(path, { method: "POST", body }); + + return result; +} diff --git a/client/src/api/account/favorites/favorite-post.ts b/client/src/api/account/favorites/favorite-post.ts new file mode 100644 index 0000000..435f6fe --- /dev/null +++ b/client/src/api/account/favorites/favorite-post.ts @@ -0,0 +1,25 @@ +import { apiFetch } from "../../fetch"; + +export async function apiFavoritePost( + service: string, + profileID: string, + postID: string +) { + const path = `/favorites/post/${service}/${profileID}/${postID}`; + + await apiFetch(path, { method: "POST" }); + + return true; +} + +export async function apiUnfavoritePost( + service: string, + profileID: string, + postID: string +) { + const path = `/favorites/post/${service}/${profileID}/${postID}`; + + await apiFetch(path, { method: "DELETE" }); + + return true; +} diff --git a/client/src/api/account/favorites/favorite-profile.ts b/client/src/api/account/favorites/favorite-profile.ts new file mode 100644 index 0000000..4dd7add --- /dev/null +++ b/client/src/api/account/favorites/favorite-profile.ts @@ -0,0 +1,17 @@ +import { apiFetch } from "../../fetch"; + +export async function apiFavoriteProfile(service: string, profileID: string) { + const path = `/favorites/creator/${service}/${profileID}`; + + await apiFetch(path, { method: "POST" }); + + return true; +} + +export async function apiUnfavoriteProfile(service: string, profileID: string) { + const path = `/favorites/creator/${service}/${profileID}`; + + await apiFetch(path, { method: "DELETE" }); + + return true; +} diff --git a/client/src/api/account/favorites/get-favourite-artists.ts b/client/src/api/account/favorites/get-favourite-artists.ts new file mode 100644 index 0000000..b579f45 --- /dev/null +++ b/client/src/api/account/favorites/get-favourite-artists.ts @@ -0,0 +1,15 @@ +import { IFavouriteArtist } from "#entities/account"; +import { apiFetch } from "../../fetch"; + +export async function fetchFavouriteProfiles() { + const path = `/account/favorites`; + const params = new URLSearchParams([["type", "artist"]]); + + const data = await apiFetch( + path, + { method: "GET" }, + params + ); + + return data; +} diff --git a/client/src/api/account/favorites/get-favourite-posts.ts b/client/src/api/account/favorites/get-favourite-posts.ts new file mode 100644 index 0000000..3c32a4e --- /dev/null +++ b/client/src/api/account/favorites/get-favourite-posts.ts @@ -0,0 +1,15 @@ +import { IFavouritePost } from "#entities/account"; +import { apiFetch } from "../../fetch"; + +export async function fetchFavouritePosts() { + const path = `/account/favorites`; + const params = new URLSearchParams([["type", "post"]]); + + const data = await apiFetch( + path, + { method: "GET" }, + params + ); + + return data; +} diff --git a/client/src/api/account/favorites/index.ts b/client/src/api/account/favorites/index.ts new file mode 100644 index 0000000..961927c --- /dev/null +++ b/client/src/api/account/favorites/index.ts @@ -0,0 +1,4 @@ +export { fetchFavouriteProfiles } from "./get-favourite-artists"; +export { fetchFavouritePosts } from "./get-favourite-posts"; +export { apiFavoritePost, apiUnfavoritePost } from "./favorite-post"; +export { apiFavoriteProfile, apiUnfavoriteProfile } from "./favorite-profile"; diff --git a/client/src/api/account/index.ts b/client/src/api/account/index.ts new file mode 100644 index 0000000..e87cc7a --- /dev/null +++ b/client/src/api/account/index.ts @@ -0,0 +1,4 @@ +export { fetchAccount } from "./account"; +export { fetchAccountNotifications } from "./notifications"; +export { fetchAddProfileLink } from "./profiles"; +export { fetchAccountChangePassword } from "./change-password"; diff --git a/client/src/api/account/moderator/index.ts b/client/src/api/account/moderator/index.ts new file mode 100644 index 0000000..36ddfc1 --- /dev/null +++ b/client/src/api/account/moderator/index.ts @@ -0,0 +1,5 @@ +export { + fetchProfileLinkRequests, + fetchApproveLinkRequest, + fetchRejectLinkRequest, +} from "./profile-link-requests"; diff --git a/client/src/api/account/moderator/profile-link-requests.ts b/client/src/api/account/moderator/profile-link-requests.ts new file mode 100644 index 0000000..1744820 --- /dev/null +++ b/client/src/api/account/moderator/profile-link-requests.ts @@ -0,0 +1,30 @@ +import { IProfileLinkRequest } from "#entities/account"; +import { apiFetch } from "../../fetch"; + +export async function fetchProfileLinkRequests() { + const path = `/account/moderator/tasks/creator_links`; + + const linkRequests = await apiFetch(path, { + method: "GET", + }); + + return linkRequests; +} + +export async function fetchApproveLinkRequest(requestID: string) { + const path = `/account/moderator/creator_link_requests/${requestID}/approve`; + const resp = await apiFetch<{ response: "approved" }>(path, { + method: "POST", + }); + + return resp; +} + +export async function fetchRejectLinkRequest(requestID: string) { + const path = `/account/moderator/creator_link_requests/${requestID}/reject`; + const resp = await apiFetch<{ response: "rejected" }>(path, { + method: "POST", + }); + + return resp; +} diff --git a/client/src/api/account/notifications.ts b/client/src/api/account/notifications.ts new file mode 100644 index 0000000..c4fb9b0 --- /dev/null +++ b/client/src/api/account/notifications.ts @@ -0,0 +1,17 @@ +import { INotification } from "#entities/account"; +import { apiFetch } from "../fetch"; + +interface IResult { + props: { + currentPage: "account"; + notifications: INotification[]; + }; +} + +export async function fetchAccountNotifications() { + const path = "/account/notifications"; + + const result = await apiFetch(path, { method: "GET" }); + + return result.props; +} diff --git a/client/src/api/account/profiles.ts b/client/src/api/account/profiles.ts new file mode 100644 index 0000000..b541720 --- /dev/null +++ b/client/src/api/account/profiles.ts @@ -0,0 +1,42 @@ +import { IArtist } from "#entities/profiles"; +import { apiFetch } from "../fetch"; + +interface IResult { + message: string + props: { + id: string + service: string + artist: IArtist + share_count: number + has_links: "✔️" | "0" + display_data: { + service: string + href: string + } + } +} + +interface IBody { + service: string; + artist_id: string; + reason?: string; +} + +export async function fetchAddProfileLink( + service: string, + profileID: string, + linkService: string, + linkProfileID: string, + reason?: string +) { + const path = `/${service}/user/${profileID}/links/new`; + const body: IBody = { + service: linkService, + artist_id: linkProfileID, + reason, + }; + + const result = await apiFetch(path, { method: "POST", body }); + + return result; +} diff --git a/client/src/api/authentication/index.ts b/client/src/api/authentication/index.ts new file mode 100644 index 0000000..f03bd79 --- /dev/null +++ b/client/src/api/authentication/index.ts @@ -0,0 +1,3 @@ +export { fetchRegisterAccount } from "./register"; +export { fetchLoginAccount } from "./login"; +export { fetchLogoutAccount } from "./logout"; diff --git a/client/src/api/authentication/login.ts b/client/src/api/authentication/login.ts new file mode 100644 index 0000000..6b87158 --- /dev/null +++ b/client/src/api/authentication/login.ts @@ -0,0 +1,29 @@ +import { IAccount } from "#entities/account"; +import { apiFetch } from "../fetch"; +import { ensureAPIError } from "#lib/api"; +import { fetchAccount } from "../account/account"; + +export async function fetchLoginAccount(username: string, password: string) { + const path = `/authentication/login`; + const body = { + username, + password, + }; + + try { + const result = await apiFetch(path, { method: "POST", body }); + + return result; + } catch (error) { + ensureAPIError(error); + + // account is already logged in + if (error.response.status !== 409) { + throw error; + } + + const result = await fetchAccount(); + + return result.props.account; + } +} diff --git a/client/src/api/authentication/logout.ts b/client/src/api/authentication/logout.ts new file mode 100644 index 0000000..70c487b --- /dev/null +++ b/client/src/api/authentication/logout.ts @@ -0,0 +1,9 @@ +import { apiFetch } from "../fetch"; + +export async function fetchLogoutAccount() { + const path = `/authentication/logout`; + + const result = await apiFetch(path, { method: "POST"}); + + return result; +} diff --git a/client/src/api/authentication/register.ts b/client/src/api/authentication/register.ts new file mode 100644 index 0000000..0d99f05 --- /dev/null +++ b/client/src/api/authentication/register.ts @@ -0,0 +1,20 @@ +import { apiFetch } from "../fetch"; + +export async function fetchRegisterAccount( + userName: string, + password: string, + confirmPassword: string, + favorites?: string +) { + const path = `/authentication/register`; + const body = { + username: userName, + password, + confirm_password: confirmPassword, + favorites_json: favorites, + }; + + const result = await apiFetch(path, { method: "POST", body }); + + return result; +} diff --git a/client/src/api/dms/all.ts b/client/src/api/dms/all.ts new file mode 100644 index 0000000..102e5a5 --- /dev/null +++ b/client/src/api/dms/all.ts @@ -0,0 +1,29 @@ +import { IApprovedDM } from "#entities/dms"; +import { apiFetch } from "../fetch"; + +interface IResult { + props: { + currentPage: "artists"; + count: number; + limit: number; + dms: IApprovedDM[]; + }; + base: {}; +} + +export async function fetchDMs(offset?: number, query?: string) { + const path = "/dms"; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + if (query) { + params.set("q", query); + } + + const result = await apiFetch(path, { method: "GET" }, params); + + return result; +} diff --git a/client/src/api/dms/has-pending.ts b/client/src/api/dms/has-pending.ts new file mode 100644 index 0000000..df67b28 --- /dev/null +++ b/client/src/api/dms/has-pending.ts @@ -0,0 +1,8 @@ +import { apiFetch } from "../fetch"; + +export async function fetchHasPendingDMs() { + const path = `/has_pending_dms`; + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/api/dms/index.ts b/client/src/api/dms/index.ts new file mode 100644 index 0000000..3fd3257 --- /dev/null +++ b/client/src/api/dms/index.ts @@ -0,0 +1,3 @@ +export { fetchDMs } from "./all"; +export { fetchProfileDMs } from "./profile"; +export { fetchHasPendingDMs } from "./has-pending"; diff --git a/client/src/api/dms/profile.ts b/client/src/api/dms/profile.ts new file mode 100644 index 0000000..a5eeb89 --- /dev/null +++ b/client/src/api/dms/profile.ts @@ -0,0 +1,27 @@ +import { IArtist } from "#entities/profiles"; +import { IApprovedDM } from "#entities/dms"; +import { apiFetch } from "../fetch"; + +interface IResult { + props: { + id: string; + service: string; + artist: IArtist; + display_data: { + service: string; + href: string; + }; + + share_count: number; + dm_count: number; + dms: IApprovedDM[]; + has_links: "✔️" | "0"; + }; +} + +export async function fetchProfileDMs(service: string, profileID: string) { + const path = `/${service}/user/${profileID}/dms`; + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/pages/components/navigation/account.html b/client/src/api/errors.ts similarity index 100% rename from client/src/pages/components/navigation/account.html rename to client/src/api/errors.ts diff --git a/client/src/api/fetch.ts b/client/src/api/fetch.ts new file mode 100644 index 0000000..ff2250d --- /dev/null +++ b/client/src/api/fetch.ts @@ -0,0 +1,144 @@ +import { logoutAccount } from "#entities/account"; +import { HTTP_STATUS } from "#lib/http"; +import { APIError } from "#lib/api"; + +const urlBase = `/api/v1`; +const jsonHeaders = new Headers(); +jsonHeaders.append("Content-Type", "application/json"); + +/** + * TODO: discriminated union with JSONable body signature + */ +interface IOptions extends Omit { + method: "GET" | "POST" | "DELETE"; + body?: any; +} + +/** + * Generic request for Kemono API. + * @param path + * A path to the endpoint, realtive to the base API path. + */ +export async function apiFetch( + path: string, + options: IOptions, + searchParams?: URLSearchParams +): Promise { + // `URL` constructor requires a full origin + // to be present in either of arguments + // but the argument for `fetch()` accepts relative paths just fine + // so we are doing some gymnastics in order not to depend + // on browser context (does not exist on server) + // or an env variable (not needed if the origin is the same). + const url = new URL(`${urlBase}${path}`, "https://example.com"); + url.search = !searchParams ? "" : String(searchParams); + + url.searchParams.sort(); + + const apiPath = `${url.pathname}${ + // `URL.search` param includes `?` even with no params + // so we include it conditionally + searchParams?.size !== 0 ? url.search : "" + }`; + + let finalOptions: RequestInit; + { + if (!options.body) { + finalOptions = { + ...options, + credentials: "same-origin", + }; + } else { + const jsonBody = JSON.stringify(options.body); + finalOptions = { + ...options, + headers: jsonHeaders, + body: jsonBody, + credentials: "same-origin", + }; + } + } + const request = new Request(apiPath, finalOptions); + const response = await fetch(request); + + if (!response.ok) { + // server logged the account out + if (response.status === 401) { + await logoutAccount(true); + + throw new APIError( + `Failed to fetch from API due to lack of credentials. Reason: ${response.status} - ${response.statusText}.`, + { request, response } + ); + } + + if (response.status === 400 || response.status === 422) { + let body: string | undefined; + // doing it this way because response doesn't allow + // parsing body several times + // and cloning response is a bit too much + const text = (await response.text()).trim(); + + try { + const json = JSON.parse(text); + body = JSON.stringify(json); + } catch (error) { + body = text; + } + + throw new APIError( + `Failed to fetch from API due to client inputs. Reason: ${ + response.status + } - ${response.statusText}.${!body ? "" : ` ${body}`}`, + { request, response } + ); + } + + if (response.status === 404) { + let body: string | undefined; + // doing it this way because response doesn't allow + // parsing body several times + // and cloning response is a bit too much + const text = (await response.text()).trim(); + + try { + const json = JSON.parse(text); + body = JSON.stringify(json); + } catch (error) { + body = text; + } + + throw new APIError( + `Failed to fetch from API because path "${ + response.url + }" doesn't exist. Reason: ${response.status} - ${response.statusText}.${ + !body ? "" : ` ${body}` + }`, + { request, response } + ); + } + + if (response.status === HTTP_STATUS.SERVICE_UNAVAILABLE) { + throw new APIError("API is in maintenance or not available.", { + request, + response, + }); + } + + if (response.status >= 500) { + throw new APIError("Failed to fetch from API due to server error.", { + request, + response, + }); + } + + throw new APIError("Failed to fetch from API for unknown reasons.", { + request, + response, + }); + } + + const resultBody: ReturnShape = await response.json(); + + return resultBody; +} diff --git a/client/src/api/files/archive-file.ts b/client/src/api/files/archive-file.ts new file mode 100644 index 0000000..e5d6359 --- /dev/null +++ b/client/src/api/files/archive-file.ts @@ -0,0 +1,30 @@ +import { IArchiveFile } from "#entities/files"; +import { apiFetch } from "../fetch"; + +interface IResult { + archive: IArchiveFile | null + file_serving_enabled: boolean +} + +export async function fetchArchiveFile(fileHash: string) { + const path = `/posts/archives/${fileHash}` + + const result = await apiFetch( path, { method: "GET" }) + + return result +} + +export async function fetchSetArchiveFilePassword( + archiveHash: string, + password: string +) { + const path = `/set_password`; + const params = new URLSearchParams([ + ["file_hash", archiveHash], + ["password", password], + ]); + + const result = await apiFetch(path, { method: "GET" }, params); + + return result; +} diff --git a/client/src/api/files/index.ts b/client/src/api/files/index.ts new file mode 100644 index 0000000..a45d565 --- /dev/null +++ b/client/src/api/files/index.ts @@ -0,0 +1,2 @@ +export { fetchArchiveFile, fetchSetArchiveFilePassword } from "./archive-file"; +export { fetchSearchFileByHash } from "./search-by-hash"; diff --git a/client/src/api/files/search-by-hash.ts b/client/src/api/files/search-by-hash.ts new file mode 100644 index 0000000..a4bacea --- /dev/null +++ b/client/src/api/files/search-by-hash.ts @@ -0,0 +1,56 @@ +import { apiFetch } from "../fetch"; + +interface IResult { + id: number; + hash: string; + mtime: string; + + ctime: string; + + mime: string; + ext: string; + added: string; + + size: number; + ihash: string; + posts: IPostResult[]; + + discord_posts: IDiscordPostResult[]; +} + +interface IPostResult { + file_id: number; + id: string; + user: string; + service: string; + title: string; + substring: string; + published: string; + + file: { + name: string; + path: string; + }; + attachments: { name: string; path: string }[]; +} + +interface IDiscordPostResult { + file_id: number; + id: string; + server: string; + channel: string; + substring: string; + published: string; + + embeds: unknown[]; + mentions: unknown[]; + attachments: { name: string; path: string }[]; +} + +export async function fetchSearchFileByHash(fileHash: string) { + const path = `/search_hash/${fileHash}`; + + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/api/imports/create-import.ts b/client/src/api/imports/create-import.ts new file mode 100644 index 0000000..dd9b7a2 --- /dev/null +++ b/client/src/api/imports/create-import.ts @@ -0,0 +1,26 @@ +import { apiFetch } from "../fetch"; + +interface IBody { + session_key: string; + service: string; + auto_import?: string; + save_session_key?: string; + save_dms?: boolean; + channel_ids?: string; + "x-bc"?: string; + auth_id?: string; + user_agent?: string; +} + +interface IResult { + import_id: string; +} + +export async function fetchCreateImport(input: IBody) { + const path = `/importer/submit`; + const body: IBody = input; + + const result = await apiFetch(path, { method: "POST", body }); + + return result; +} diff --git a/client/src/api/imports/get-import.ts b/client/src/api/imports/get-import.ts new file mode 100644 index 0000000..94376a3 --- /dev/null +++ b/client/src/api/imports/get-import.ts @@ -0,0 +1,9 @@ +import { apiFetch } from "../fetch"; + +export async function fetchImportLogs(importID: string) { + const path = `/importer/logs/${importID}`; + + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/api/imports/index.ts b/client/src/api/imports/index.ts new file mode 100644 index 0000000..958814d --- /dev/null +++ b/client/src/api/imports/index.ts @@ -0,0 +1,2 @@ +export { fetchImportLogs } from "./get-import"; +export { fetchCreateImport } from "./create-import"; diff --git a/client/src/api/kemono/_index.js b/client/src/api/kemono/_index.js deleted file mode 100644 index a41d235..0000000 --- a/client/src/api/kemono/_index.js +++ /dev/null @@ -1,14 +0,0 @@ -import { favorites } from "./favorites"; -import { posts } from "./posts"; -import { api } from "./api"; -import { dms } from "./dms"; - -/** - * @type {KemonoAPI} - */ -export const kemonoAPI = { - favorites, - posts, - api, - dms, -}; diff --git a/client/src/api/kemono/api.js b/client/src/api/kemono/api.js deleted file mode 100644 index 7031be8..0000000 --- a/client/src/api/kemono/api.js +++ /dev/null @@ -1,100 +0,0 @@ -import { KemonoError } from "@wp/utils"; -import { kemonoFetch } from "./kemono-fetch"; -import { CREATORS_LOCATION } from "@wp/env/env-vars"; - -export const api = { - bans, - bannedArtist, - creators, - logs, -}; - -async function bans() { - try { - const response = await kemonoFetch("/api/v1/creators/bans", { method: "GET" }); - - if (!response || !response.ok) { - alert(new KemonoError(6)); - return null; - } - - /** - * @type {KemonoAPI.API.BanItem[]} - */ - const banItems = await response.json(); - - return banItems; - } catch (error) { - console.error(error); - } -} - -/** - * @param {string} id - * @param {string} service - */ -async function bannedArtist(id, service) { - const params = new URLSearchParams([["service", service]]).toString(); - - try { - const response = await kemonoFetch(`/api/v1/lookup/cache/${id}?${params}`); - - if (!response || !response.ok) { - alert(new KemonoError(7)); - return null; - } - - /** - * @type {KemonoAPI.API.BannedArtist} - */ - const artist = await response.json(); - - return artist; - } catch (error) { - console.error(error); - } -} - -async function creators() { - try { - const response = await kemonoFetch(CREATORS_LOCATION || "/api/v1/creators", { - method: "GET", - }); - - if (!response || !response.ok) { - alert(new KemonoError(8)); - return null; - } - - /** - * @type {KemonoAPI.User[]} - */ - const artists = await response.json(); - - return artists; - } catch (error) { - console.error(error); - } -} - -async function logs(importID) { - try { - const response = await kemonoFetch(`/api/v1/importer/logs/${importID}`, { - method: "GET", - }); - - if (!response || !response.ok) { - alert(new KemonoError(9)); - return null; - } - - /** - * @type {KemonoAPI.API.LogItem[]} - */ - const logs = await response.json(); - - return logs; - } catch (error) { - console.error(error); - } -} diff --git a/client/src/api/kemono/dms.js b/client/src/api/kemono/dms.js deleted file mode 100644 index 6f4dd78..0000000 --- a/client/src/api/kemono/dms.js +++ /dev/null @@ -1,22 +0,0 @@ -import { KemonoError } from "@wp/utils"; -import { kemonoFetch } from "./kemono-fetch"; - -/** - * @type {KemonoAPI.DMs} - */ -export const dms = { - retrieveHasPendingDMs, -}; - -async function retrieveHasPendingDMs() { - try { - const response = await kemonoFetch(`/api/v1/has_pending_dms`); - - if (!response || !response.ok) { - throw new Error(`Error ${response.status}: ${response.statusText}`); - } - return await response.json(); - } catch (error) { - console.error(error); - } -} diff --git a/client/src/api/kemono/favorites.js b/client/src/api/kemono/favorites.js deleted file mode 100644 index 6b87f84..0000000 --- a/client/src/api/kemono/favorites.js +++ /dev/null @@ -1,142 +0,0 @@ -import { KemonoError } from "@wp/utils"; -import { kemonoFetch } from "./kemono-fetch"; - -/** - * @type {KemonoAPI.Favorites} - */ -export const favorites = { - retrieveFavoriteArtists, - favoriteArtist, - unfavoriteArtist, - retrieveFavoritePosts, - favoritePost, - unfavoritePost, -}; - -async function retrieveFavoriteArtists() { - const params = new URLSearchParams([["type", "artist"]]).toString(); - - try { - const response = await kemonoFetch(`/api/v1/account/favorites?${params}`); - - if (!response || !response.ok) { - throw new Error(`Error ${response.status}: ${response.statusText}`); - } - /** - * @type {string} - */ - const favs = await response.text(); - return favs; - } catch (error) { - console.error(error); - } -} - -/** - * @param {string} service - * @param {string} userID - */ -async function favoriteArtist(service, userID) { - try { - const response = await kemonoFetch(`/api/v1/favorites/creator/${service}/${userID}`, { method: "POST" }); - - if (!response || !response.ok) { - alert(new KemonoError(3)); - return false; - } - - return true; - } catch (error) { - console.error(error); - } -} - -/** - * @param {string} service - * @param {string} userID - */ -async function unfavoriteArtist(service, userID) { - try { - const response = await kemonoFetch(`/api/v1/favorites/creator/${service}/${userID}`, { method: "DELETE" }); - - if (!response || !response.ok) { - alert(new KemonoError(4)); - return false; - } - - return true; - } catch (error) { - console.error(error); - } -} - -async function retrieveFavoritePosts() { - const params = new URLSearchParams([["type", "post"]]).toString(); - - try { - const response = await kemonoFetch(`/api/v1/account/favorites?${params}`); - - if (!response || !response.ok) { - throw new Error(`Error ${response.status}: ${response.statusText}`); - } - - /** - * @type {KemonoAPI.Post[]} - */ - const favs = await response.json(); - /** - * @type {KemonoAPI.Favorites.Post[]} - */ - const transformedFavs = favs.map((post) => { - return { - id: post.id, - service: post.service, - user: post.user, - }; - }); - - return JSON.stringify(transformedFavs); - } catch (error) { - console.error(error); - } -} - -/** - * @param {string} service - * @param {string} user - * @param {string} post_id - */ -async function favoritePost(service, user, post_id) { - try { - const response = await kemonoFetch(`/api/v1/favorites/post/${service}/${user}/${post_id}`, { method: "POST" }); - - if (!response || !response.ok) { - alert(new KemonoError(1)); - return false; - } - - return true; - } catch (error) { - console.error(error); - } -} - -/** - * @param {string} service - * @param {string} user - * @param {string} post_id - */ -async function unfavoritePost(service, user, post_id) { - try { - const response = await kemonoFetch(`/api/v1/favorites/post/${service}/${user}/${post_id}`, { method: "DELETE" }); - - if (!response || !response.ok) { - alert(new KemonoError(2)); - return false; - } - - return true; - } catch (error) { - console.error(error); - } -} diff --git a/client/src/api/kemono/kemono-fetch.js b/client/src/api/kemono/kemono-fetch.js deleted file mode 100644 index 539609a..0000000 --- a/client/src/api/kemono/kemono-fetch.js +++ /dev/null @@ -1,46 +0,0 @@ -import { isLoggedIn } from "@wp/js/account"; - -/** - * Generic request for Kemono API. - * @param {RequestInfo} endpoint - * @param {RequestInit} options - * @returns {Promise} - */ -export async function kemonoFetch(endpoint, options) { - try { - const response = await fetch(endpoint, options); - - // doing this because the server returns `401` before redirecting - // in case of favs - if (response.status === 401) { - // server logged the account out - if (isLoggedIn) { - localStorage.removeItem("logged_in"); - localStorage.removeItem("role"); - localStorage.removeItem("favs"); - localStorage.removeItem("post_favs"); - location.href = "/account/logout"; - return; - } - const loginURL = new URL("/account/login", location.origin).toString(); - location = addURLParam(loginURL, "location", location.pathname); - return; - } - - return response; - } catch (error) { - console.error(`Kemono request error: ${error}`); - } -} - -/** - * @param {string} url - * @param {string} paramName - * @param {string} paramValue - * @returns {string} - */ -function addURLParam(url, paramName, paramValue) { - var newURL = new URL(url); - newURL.searchParams.set(paramName, paramValue); - return newURL.toString(); -} diff --git a/client/src/api/kemono/posts.js b/client/src/api/kemono/posts.js deleted file mode 100644 index b9f97bb..0000000 --- a/client/src/api/kemono/posts.js +++ /dev/null @@ -1,26 +0,0 @@ -import { kemonoFetch } from "./kemono-fetch"; -import { KemonoError } from "@wp/utils"; - -export const posts = { - attemptFlag, -}; - -/** - * @param {string} service - * @param {string} user - * @param {string} post_id - */ -async function attemptFlag(service, user, post_id) { - try { - const response = await kemonoFetch(`/api/v1/${service}/user/${user}/post/${post_id}/flag`, { method: "POST" }); - - if (!response || !response.ok) { - alert(new KemonoError(5)); - return false; - } - - return true; - } catch (error) { - console.error(error); - } -} diff --git a/client/src/api/paysites/_index.js b/client/src/api/paysites/_index.js deleted file mode 100644 index 0e1948b..0000000 --- a/client/src/api/paysites/_index.js +++ /dev/null @@ -1 +0,0 @@ -export const paysitesAPI = {}; diff --git a/client/src/api/posts/announcements.ts b/client/src/api/posts/announcements.ts new file mode 100644 index 0000000..968d6fc --- /dev/null +++ b/client/src/api/posts/announcements.ts @@ -0,0 +1,9 @@ +import { IAnnouncement } from "#entities/posts"; +import { apiFetch } from "../fetch"; + +export async function fetchAnnouncements(service: string, profileID: string) { + const path = `/${service}/user/${profileID}/announcements`; + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/api/posts/flag.ts b/client/src/api/posts/flag.ts new file mode 100644 index 0000000..3f122b9 --- /dev/null +++ b/client/src/api/posts/flag.ts @@ -0,0 +1,25 @@ +import { ensureAPIError } from "#lib/api"; +import { HTTP_STATUS } from "#lib/http"; +import { apiFetch } from "../fetch"; + +export async function flagPost( + service: string, + profileID: string, + postID: string +) { + const path = `/${service}/user/${profileID}/post/${postID}/flag`; + + try { + await apiFetch(path, { method: "POST" }); + + return true; + } catch (error) { + ensureAPIError(error); + + if (error.response.status !== HTTP_STATUS.CONFLICT) { + throw error; + } + + return true; + } +} diff --git a/client/src/api/posts/index.ts b/client/src/api/posts/index.ts new file mode 100644 index 0000000..22767b0 --- /dev/null +++ b/client/src/api/posts/index.ts @@ -0,0 +1,7 @@ +export { fetchPosts } from "./posts"; +export { fetchPost, fetchPostComments, fetchPostData } from "./post"; +export { fetchPostRevision } from "./revision"; +export { fetchPopularPosts } from "./popular"; +export { fetchAnnouncements } from "./announcements"; +export { fetchRandomPost } from "./random"; +export { flagPost } from "./flag"; diff --git a/client/src/api/posts/popular.ts b/client/src/api/posts/popular.ts new file mode 100644 index 0000000..e988cb8 --- /dev/null +++ b/client/src/api/posts/popular.ts @@ -0,0 +1,53 @@ +import { IPopularPostsPeriod, IPostWithFavorites } from "#entities/posts"; +import { apiFetch } from "../fetch"; + +interface IResult { + info: { + date: string; + min_date: string; + max_date: string; + navigation_dates: Record; + range_desc: string; + scale: IPopularPostsPeriod; + }; + props: { + currentPage: "popular_posts"; + today: string; + earliest_date_for_popular: string; + limit: number; + count: number; + }; + results: IPostWithFavorites[]; + base: {}; + result_previews: ( + | { type: "thumbnail"; server: string; name: string; path: string } + | { type: "embed"; url: string; subject: string; description: string } + )[]; + result_attachments: { server: string; name: string; path: string }[]; + result_is_image: boolean; +} + +export async function fetchPopularPosts( + date?: string, + scale?: IPopularPostsPeriod, + offset?: number +) { + const path = `/posts/popular`; + const params = new URLSearchParams(); + + if (date) { + params.set("date", date); + } + + if (scale) { + params.set("period", scale); + } + + if (offset) { + params.set("o", String(offset)); + } + + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/api/posts/post.ts b/client/src/api/posts/post.ts new file mode 100644 index 0000000..d85712c --- /dev/null +++ b/client/src/api/posts/post.ts @@ -0,0 +1,57 @@ +import { + IComment, + IPost, + IPostAttachment, + IPostPreview, + IPostRevision, + IPostVideo, +} from "#entities/posts"; +import { apiFetch } from "../fetch"; + +interface IResult { + post: IPost; + attachments: IPostAttachment[]; + previews: IPostPreview[]; + videos: IPostVideo[]; + props: { + service: string; + flagged?: 0; + revisions: [number, IPost][]; + }; +} + +export async function fetchPost( + service: string, + profileID: string, + postID: string +) { + const path = `/${service}/user/${profileID}/post/${postID}`; + const result = await apiFetch(path, { method: "GET" }); + + return result; +} + +export async function fetchPostComments( + service: string, + profileID: string, + postID: string +) { + const path = `/${service}/user/${profileID}/post/${postID}/comments`; + const result = await apiFetch(path, { method: "GET" }); + + return result; +} + +interface IPostData { + service: string; + artist_id: string; + post_id: string; +} + +export async function fetchPostData(service: string, postID: string) { + const path = `/${service}/post/${postID}`; + + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/api/posts/posts.ts b/client/src/api/posts/posts.ts new file mode 100644 index 0000000..0e08673 --- /dev/null +++ b/client/src/api/posts/posts.ts @@ -0,0 +1,35 @@ +import { IPost } from "#entities/posts"; +import { apiFetch } from "../fetch"; + +interface IResult { + count: number; + true_count: number; + posts: IPost[]; +} + +export async function fetchPosts( + offset?: number, + query?: string, + tags?: string[] +) { + const path = "/posts"; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + if (query) { + params.set("q", query); + } + + if (tags) { + for (const tag of tags) { + params.set("tag", tag); + } + } + + const result = await apiFetch(path, { method: "GET" }, params); + + return result; +} diff --git a/client/src/api/posts/random.ts b/client/src/api/posts/random.ts new file mode 100644 index 0000000..3ef08dc --- /dev/null +++ b/client/src/api/posts/random.ts @@ -0,0 +1,15 @@ +import { apiFetch } from "../fetch"; + +interface IResult { + service: string; + artist_id: string; + post_id: string; +} + +export async function fetchRandomPost() { + const path = `/posts/random`; + + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/api/posts/revision.ts b/client/src/api/posts/revision.ts new file mode 100644 index 0000000..50924d3 --- /dev/null +++ b/client/src/api/posts/revision.ts @@ -0,0 +1,39 @@ +import { IArtistDetails } from "#entities/profiles"; +import { + IComment, + IPost, + IPostAttachment, + IPostPreview, + IPostRevision, + IPostVideo, +} from "#entities/posts"; +import { apiFetch } from "../fetch"; + +interface IResult { + props: { + currentPage: "revisions"; + service: string; + artist: IArtistDetails; + flagged?: 0; + revisions: [number, IPostRevision][]; + }; + post: IPost; + comments: IComment[]; + result_previews: IPostPreview[]; + result_attachments: IPostAttachment[]; + videos: IPostVideo[]; + archives_enabled: boolean; +} + +export async function fetchPostRevision( + service: string, + profileID: string, + postID: string, + revisionID: string +) { + const path = `/${service}/user/${profileID}/post/${postID}/revision/${revisionID}`; + + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/api/profiles/discord/index.ts b/client/src/api/profiles/discord/index.ts new file mode 100644 index 0000000..487a305 --- /dev/null +++ b/client/src/api/profiles/discord/index.ts @@ -0,0 +1,29 @@ +import { IDiscordChannelMessage } from "#entities/posts"; +import { apiFetch } from "../../fetch"; + +export async function fetchDiscordServer(serverID: string) { + const path = `/discord/channel/lookup/${serverID}`; + + const result = await apiFetch<{ id: string; name: string }[]>(path, { + method: "GET", + }); + + return result; +} + +export async function fetchDiscordChannel(channelID: string, offset?: number) { + const path = `/discord/channel/${channelID}`; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + const result = await apiFetch( + path, + { method: "GET" }, + params + ); + + return result; +} diff --git a/client/src/api/profiles/fancards.ts b/client/src/api/profiles/fancards.ts new file mode 100644 index 0000000..ac87b90 --- /dev/null +++ b/client/src/api/profiles/fancards.ts @@ -0,0 +1,9 @@ +import { IFanCard } from "#entities/files"; +import { apiFetch } from "../fetch"; + +export async function fetchFanboxProfileFancards(profileID: string) { + const path = `/fanbox/user/${profileID}/fancards`; + const cards = await apiFetch(path, { method: "GET" }); + + return cards; +} diff --git a/client/src/api/profiles/index.ts b/client/src/api/profiles/index.ts new file mode 100644 index 0000000..1eedbc8 --- /dev/null +++ b/client/src/api/profiles/index.ts @@ -0,0 +1,6 @@ +export { fetchProfiles } from "./profiles"; +export { fetchRandomArtist } from "./random"; +export { fetchArtistProfile } from "./profile"; +export { fetchFanboxProfileFancards } from "./fancards"; +export { fetchProfileLinks } from "./links"; +export { fetchProfilePosts } from "./posts"; diff --git a/client/src/api/profiles/links.ts b/client/src/api/profiles/links.ts new file mode 100644 index 0000000..eb361b6 --- /dev/null +++ b/client/src/api/profiles/links.ts @@ -0,0 +1,21 @@ +import { apiFetch } from "../fetch"; + +interface IResult + extends Array<{ + id: string; + + public_id: string | null; + service: string; + name: string; + + indexed: string; + + updated: string; + }> {} + +export async function fetchProfileLinks(service: string, profileID: string) { + const path = `/${service}/user/${profileID}/links`; + const links = await apiFetch(path, { method: "GET" }); + + return links; +} diff --git a/client/src/api/profiles/posts.ts b/client/src/api/profiles/posts.ts new file mode 100644 index 0000000..ea23d1a --- /dev/null +++ b/client/src/api/profiles/posts.ts @@ -0,0 +1,58 @@ +import { IArtist } from "#entities/profiles"; +import { IPost } from "#entities/posts"; +import { apiFetch } from "../fetch"; + +interface IResult { + props: { + currentPage: "posts"; + id: string; + service: string; + name: string; + count: number; + limit: number; + artist: IArtist; + display_data: { + service: string; + href: string; + }; + dm_count: number; + share_count: number; + has_links: "0" | "✔️"; + }; + + base: Record + results: IPost[] + result_previews: Record[] + result_atachments: Record[] + result_is_image: boolean[] + disable_service_icons: true +} + +export async function fetchProfilePosts( + service: string, + profileID: string, + offset?: number, + query?: string, + tags?: string[] +) { + const path = `/${service}/user/${profileID}/posts-legacy`; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + if (query) { + params.set("q", query); + } + + if (tags && tags.length) { + for (const tag of tags) { + params.append("tag", tag); + } + } + + const result = await apiFetch(path, { method: "GET" }, params); + + return result; +} diff --git a/client/src/api/profiles/profile.ts b/client/src/api/profiles/profile.ts new file mode 100644 index 0000000..b051d3d --- /dev/null +++ b/client/src/api/profiles/profile.ts @@ -0,0 +1,14 @@ +import { IArtistDetails } from "#entities/profiles"; +import { apiFetch } from "../fetch"; + +export async function fetchArtistProfile( + service: string, + artistID: string +): Promise { + const path = `/${service}/user/${artistID}/profile`; + const result = await apiFetch(path, { + method: "GET", + }); + + return result; +} diff --git a/client/src/api/profiles/profiles.ts b/client/src/api/profiles/profiles.ts new file mode 100644 index 0000000..413d96c --- /dev/null +++ b/client/src/api/profiles/profiles.ts @@ -0,0 +1,12 @@ +import { IArtistWithFavs } from "#entities/profiles"; +import { IS_DEVELOPMENT } from "#env/derived-vars"; +import { apiFetch } from "../fetch"; + +export async function fetchProfiles(): Promise { + const path = IS_DEVELOPMENT ? "/creators" : "/creators.txt"; + const result = await apiFetch(path, { + method: "GET", + }); + + return result; +} diff --git a/client/src/api/profiles/random.ts b/client/src/api/profiles/random.ts new file mode 100644 index 0000000..57aa891 --- /dev/null +++ b/client/src/api/profiles/random.ts @@ -0,0 +1,14 @@ +import { apiFetch } from "../fetch"; + +interface IArtistData { + service: string; + artist_id: string; +} + +export async function fetchRandomArtist(): Promise { + const result = await apiFetch("/artists/random", { + method: "GET", + }); + + return result; +} diff --git a/client/src/api/shares/index.ts b/client/src/api/shares/index.ts new file mode 100644 index 0000000..d2f68bf --- /dev/null +++ b/client/src/api/shares/index.ts @@ -0,0 +1,3 @@ +export { fetchShares } from "./shares"; +export { fetchShare } from "./share"; +export { fetchProfileShares } from "./profile"; diff --git a/client/src/api/shares/profile.ts b/client/src/api/shares/profile.ts new file mode 100644 index 0000000..a4c4753 --- /dev/null +++ b/client/src/api/shares/profile.ts @@ -0,0 +1,37 @@ +import { IArtist } from "#entities/profiles"; +import { IShare } from "#entities/files"; +import { apiFetch } from "../fetch"; + +interface IResult { + results: IShare[]; + base: Record; + props: { + display_data: { + service: string; + href: string; + }; + service: string; + artist: IArtist; + id: string; + dm_count: number; + share_count: number; + has_links: "✔️" | "0"; + }; +} + +export async function fetchProfileShares( + service: string, + profileID: string, + offset?: number +) { + const path = `/${service}/user/${profileID}/shares`; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + const result = await apiFetch(path, { method: "GET" }, params); + + return result; +} diff --git a/client/src/api/shares/share.ts b/client/src/api/shares/share.ts new file mode 100644 index 0000000..8e2d2ca --- /dev/null +++ b/client/src/api/shares/share.ts @@ -0,0 +1,15 @@ +import { IShare, IShareFile } from "#entities/files"; +import { apiFetch } from "../fetch"; + +interface IResult { + share: IShare; + share_files: IShareFile[]; + base: unknown; +} + +export async function fetchShare(shareID: string) { + const path = `/share/${shareID}`; + const result = await apiFetch(path, { method: "GET" }); + + return result; +} diff --git a/client/src/api/shares/shares.ts b/client/src/api/shares/shares.ts new file mode 100644 index 0000000..749a57d --- /dev/null +++ b/client/src/api/shares/shares.ts @@ -0,0 +1,24 @@ +import { IShare } from "#entities/files"; +import { apiFetch } from "../fetch"; + +interface IResult { + base: Record; + props: { + currentPage: "shares"; + count: number; + shares: IShare[]; + }; +} + +export async function fetchShares(offset?: number) { + const path = `/shares`; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + const result = await apiFetch(path, { method: "GET" }, params); + + return result; +} diff --git a/client/src/api/tags/all.ts b/client/src/api/tags/all.ts new file mode 100644 index 0000000..82a195f --- /dev/null +++ b/client/src/api/tags/all.ts @@ -0,0 +1,14 @@ +import { ITag } from "#entities/tags" +import { apiFetch } from "../fetch" + +interface IResult { + props: { currentPage: "tags" } + tags: ITag[] +} + +export async function fetchTags() { + const path = "/posts/tags" + const result = await apiFetch(path, { method: "GET" }) + + return result +} diff --git a/client/src/api/tags/index.ts b/client/src/api/tags/index.ts new file mode 100644 index 0000000..dbba2e3 --- /dev/null +++ b/client/src/api/tags/index.ts @@ -0,0 +1,2 @@ +export { fetchTags } from "./all"; +export { fetchProfileTags } from "./profile"; diff --git a/client/src/api/tags/profile.ts b/client/src/api/tags/profile.ts new file mode 100644 index 0000000..ec94fbb --- /dev/null +++ b/client/src/api/tags/profile.ts @@ -0,0 +1,29 @@ +import { IArtist } from "#entities/profiles"; +import { ITag } from "#entities/tags"; +import { apiFetch } from "../fetch"; + +interface IResult { + props: { + display_data: { + service: string; + href: string; + }; + artist: IArtist; + service: string; + id: string; + share_count: number; + dm_count: number; + has_links: "✔️" | "0"; + }; + + tags: ITag[]; + service: string; + artist: IArtist; +} + +export async function fetchProfileTags(service: string, profileID: string) { + const path = `/${service}/user/${profileID}/tags`; + const tags = await apiFetch(path, { method: "GET" }); + + return tags; +} diff --git a/client/src/browser/hooks/index.ts b/client/src/browser/hooks/index.ts new file mode 100644 index 0000000..fdb7ad5 --- /dev/null +++ b/client/src/browser/hooks/index.ts @@ -0,0 +1,3 @@ +export { ClientProvider, useClient } from "./use-client"; +export { useRoutePathPattern } from "./use-route-path-pattern"; +export { useInterval } from "./use-interval"; diff --git a/client/src/browser/hooks/use-client.tsx b/client/src/browser/hooks/use-client.tsx new file mode 100644 index 0000000..9c37724 --- /dev/null +++ b/client/src/browser/hooks/use-client.tsx @@ -0,0 +1,38 @@ +import { + ReactNode, + createContext, + useContext, + useEffect, + useState, +} from "react"; + +interface IClientContext { + isClient: boolean; +} + +const defaultContext: IClientContext = { isClient: false }; +const ClientContext = createContext(defaultContext); + +interface IProps { + children?: ReactNode; +} + +export function ClientProvider({ children }: IProps) { + const [isClient, switchIsClient] = useState(false); + + useEffect(() => { + switchIsClient(true); + }, []); + + return ( + + {children} + + ); +} + +export function useClient(): boolean { + const { isClient } = useContext(ClientContext); + + return isClient; +} diff --git a/client/src/browser/hooks/use-interval.tsx b/client/src/browser/hooks/use-interval.tsx new file mode 100644 index 0000000..bc4b1f3 --- /dev/null +++ b/client/src/browser/hooks/use-interval.tsx @@ -0,0 +1,27 @@ +import { useEffect, useRef } from "react"; + +/** + * Stolen from + * https://overreacted.io/making-setinterval-declarative-with-react-hooks/ + */ +export function useInterval(callback: () => void, delay: number | null) { + const savedCallback = useRef(); + + // Remember the latest callback. + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + // Set up the interval. + useEffect(() => { + if (delay !== null) { + let id = setInterval(tick, delay); + + return () => clearInterval(id); + } + + function tick() { + savedCallback.current!(); + } + }, [delay]); +} diff --git a/client/src/browser/hooks/use-route-path-pattern.tsx b/client/src/browser/hooks/use-route-path-pattern.tsx new file mode 100644 index 0000000..81a7ce3 --- /dev/null +++ b/client/src/browser/hooks/use-route-path-pattern.tsx @@ -0,0 +1,11 @@ +import { useLocation } from "react-router-dom"; + +/** + * TODO: path pattern without circular reference + * on the route config object. + */ +export function useRoutePathPattern(): string { + const location = useLocation(); + + return location.pathname; +} diff --git a/client/src/browser/storage/local/index.ts b/client/src/browser/storage/local/index.ts new file mode 100644 index 0000000..184a44d --- /dev/null +++ b/client/src/browser/storage/local/index.ts @@ -0,0 +1,46 @@ +const storageNames = [ + "favorites", + "logged_in", + "role", + "favs", + "post_favs", + "has_pending_review_dms", + "last_checked_has_pending_review_dms", + "sidebar_state", +] as const; + +export interface ILocalStorageSchema { + favs: { + service: string; + id: string; + }[]; + post_favs: { + id: string; + service: string; + user: string; + }[]; +} + +type ILocalStorageName = (typeof storageNames)[number]; + +export function getLocalStorageItem(name: ILocalStorageName) { + return localStorage.getItem(name); +} + +export function setLocalStorageItem(name: ILocalStorageName, value: string) { + localStorage.setItem(name, value); +} + +export function deleteLocalStorageItem(name: ILocalStorageName) { + localStorage.removeItem(name); +} + +export function isLocalStorageAvailable() { + try { + localStorage.setItem("__storage_test__", "__storage_test__"); + localStorage.removeItem("__storage_test__"); + return true; + } catch (error) { + return false; + } +} diff --git a/client/src/components/_index.scss b/client/src/components/_index.scss new file mode 100644 index 0000000..686afef --- /dev/null +++ b/client/src/components/_index.scss @@ -0,0 +1,11 @@ +@use "layout"; +@use "pages"; +@use "images"; +@use "links"; +@use "dates"; +@use "cards"; +@use "loading"; +@use "buttons"; +@use "tooltip"; +@use "pagination"; +@use "importer_states"; diff --git a/client/src/components/ads/ads.tsx b/client/src/components/ads/ads.tsx new file mode 100644 index 0000000..e6c27b8 --- /dev/null +++ b/client/src/components/ads/ads.tsx @@ -0,0 +1,59 @@ +import { useLocation } from "react-router-dom"; +import { HEADER_AD, MIDDLE_AD, FOOTER_AD, SLIDER_AD } from "#env/env-vars"; +import { DangerousContent } from "#components/dangerous-content"; + +export function HeaderAd() { + const location = useLocation(); + const key = `${location.pathname}${location.search}`; + + return !HEADER_AD ? undefined : ( + + ); +} + +export function MiddleAd() { + const location = useLocation(); + const key = `${location.pathname}${location.search}`; + + return !MIDDLE_AD ? undefined : ( + + ); +} + +export function FooterAd() { + const location = useLocation(); + const key = `${location.pathname}${location.search}`; + + return !FOOTER_AD ? undefined : ( + + ); +} + +export function SliderAd() { + const location = useLocation(); + const key = `${location.pathname}${location.search}`; + + return !SLIDER_AD ? undefined : ( + + ); +} diff --git a/client/src/components/ads/index.ts b/client/src/components/ads/index.ts new file mode 100644 index 0000000..7bb70ee --- /dev/null +++ b/client/src/components/ads/index.ts @@ -0,0 +1 @@ +export { MiddleAd, HeaderAd, FooterAd, SliderAd } from "./ads"; diff --git a/client/src/components/buttons/_index.scss b/client/src/components/buttons/_index.scss new file mode 100644 index 0000000..7051716 --- /dev/null +++ b/client/src/components/buttons/_index.scss @@ -0,0 +1 @@ +@use "./buttons"; diff --git a/client/src/pages/components/buttons.scss b/client/src/components/buttons/buttons.scss similarity index 100% rename from client/src/pages/components/buttons.scss rename to client/src/components/buttons/buttons.scss diff --git a/client/src/components/buttons/buttons.tsx b/client/src/components/buttons/buttons.tsx new file mode 100644 index 0000000..ccb5014 --- /dev/null +++ b/client/src/components/buttons/buttons.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from "react"; +import { IBlockProps, createBlockComponent } from "#components/meta"; + +interface IProps extends IBlockProps<"button"> { + className?: string; + isFocusable?: boolean; + children?: ReactNode; +} + +export const Button = createBlockComponent("button", Component); + +export function Component({ isFocusable = true, children, ...props }: IProps) { + return ( + + ); +} diff --git a/client/src/components/buttons/index.ts b/client/src/components/buttons/index.ts new file mode 100644 index 0000000..ee532b9 --- /dev/null +++ b/client/src/components/buttons/index.ts @@ -0,0 +1 @@ +export { Button } from "./buttons"; diff --git a/client/src/pages/components/cards/_index.scss b/client/src/components/cards/_index.scss similarity index 67% rename from client/src/pages/components/cards/_index.scss rename to client/src/components/cards/_index.scss index 545d0b3..fe557c0 100644 --- a/client/src/pages/components/cards/_index.scss +++ b/client/src/components/cards/_index.scss @@ -1,6 +1,7 @@ +@use "card_list"; @use "base"; @use "account"; @use "post"; -@use "user"; +@use "profile"; @use "dm"; @use "no_results"; diff --git a/client/src/pages/components/cards/account.scss b/client/src/components/cards/account.scss similarity index 67% rename from client/src/pages/components/cards/account.scss rename to client/src/components/cards/account.scss index 7c003a1..5fd39b4 100644 --- a/client/src/pages/components/cards/account.scss +++ b/client/src/components/cards/account.scss @@ -1,4 +1,4 @@ -@use "../../../css/sass-mixins" as mixins; +@use "../../css/sass-mixins" as mixins; .account-card { @include mixins.article-card(); diff --git a/client/src/components/cards/account.tsx b/client/src/components/cards/account.tsx new file mode 100644 index 0000000..c867cc8 --- /dev/null +++ b/client/src/components/cards/account.tsx @@ -0,0 +1,28 @@ +import { Timestamp } from "#components/dates"; +import { IAccount } from "#entities/account"; + +interface IProps { + account: IAccount; +} + +export function AccountCard({ account }: IProps) { + const { id, username, role, created_at } = account; + + return ( +
+
+

{username}

+
+ +
+

+ Role: {role} +

+
+ +
+ +
+
+ ); +} diff --git a/client/src/pages/components/cards/base.scss b/client/src/components/cards/base.scss similarity index 94% rename from client/src/pages/components/cards/base.scss rename to client/src/components/cards/base.scss index 08990e1..17acf8c 100644 --- a/client/src/pages/components/cards/base.scss +++ b/client/src/components/cards/base.scss @@ -1,4 +1,4 @@ -@use "../../../css/config/variables" as *; +@use "../../css/config/variables" as *; .card { display: grid; diff --git a/client/src/components/cards/base.tsx b/client/src/components/cards/base.tsx new file mode 100644 index 0000000..c5c6774 --- /dev/null +++ b/client/src/components/cards/base.tsx @@ -0,0 +1,41 @@ +import clsx from "clsx"; +import { ReactNode } from "react"; + +interface ICardProps { + className?: string; + children?: ReactNode; +} + +interface ICardHeaderProps { + className?: string; + children?: ReactNode; +} + +interface ICardBodyProps { + className?: string; + children?: ReactNode; +} + +interface ICardFooterProps { + className?: string; + children?: ReactNode; +} + +export function Card({ className, children }: ICardProps) { + return
{children}
; +} +export function CardHeader({ className, children }: ICardHeaderProps) { + return ( +
{children}
+ ); +} +export function CardBody({ className, children }: ICardBodyProps) { + return ( +
{children}
+ ); +} +export function CardFooter({ className, children }: ICardFooterProps) { + return ( +
{children}
+ ); +} diff --git a/client/src/pages/components/card_list.scss b/client/src/components/cards/card_list.scss similarity index 100% rename from client/src/pages/components/card_list.scss rename to client/src/components/cards/card_list.scss diff --git a/client/src/components/cards/card_list.tsx b/client/src/components/cards/card_list.tsx new file mode 100644 index 0000000..b3c465c --- /dev/null +++ b/client/src/components/cards/card_list.tsx @@ -0,0 +1,122 @@ +import clsx from "clsx"; +import { ReactNode, useEffect, useRef } from "react"; + +interface IProps { + layout?: "legacy" | "phone"; + className?: string; + children: ReactNode; +} + +const defaultThumbSize = 180; + +export function CardList({ layout = "legacy", className, children }: IProps) { + const cardListRef = useRef(null); + + useEffect(() => { + if (layout === "phone") { + return; + } + + const ref = cardListRef.current; + + if (!ref) { + return; + } + + try { + const cookies = getCookies(); + const thumbSizeValue = parseInt(cookies?.thumbSize); + let thumbSizeSetting = isNaN(thumbSizeValue) ? undefined : thumbSizeValue; + + if (!thumbSizeSetting) { + thumbSizeSetting = defaultThumbSize; + addCookie("thumbSize", String(defaultThumbSize), 399); + } + + const thumbSize = + parseInt(String(thumbSizeSetting)) === + parseInt(String(defaultThumbSize)) + ? undefined + : thumbSizeSetting; + + function handleResize() { + updateThumbsizes(ref!, defaultThumbSize, thumbSize); + } + + updateThumbsizes(ref!, defaultThumbSize, thumbSize); + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + } catch (error) { + return console.error(error); + } + }, []); + + return ( +
+
+
+ {children} +
+
+ ); +} + +function getCookies(): Record { + const cookies = document.cookie.split(";").reduce( + (cookies, cookie) => ( + // @ts-expect-error whatever + (cookies[cookie.split("=")[0].trim()] = decodeURIComponent( + cookie.split("=")[1] + )), + cookies + ), + {} + ); + + return cookies; +} + +function setCookie(name: "thumbSize", value: string, daysToExpire: number) { + const date = new Date(); + date.setTime(date.getTime() + daysToExpire * 24 * 60 * 60 * 1000); + const expires = "expires=" + date.toUTCString(); + document.cookie = name + "=" + value + "; " + expires + ";path=/"; +} + +function addCookie(name: "thumbSize", newValue: string, daysToExpire: number) { + const existingCookie = document.cookie + .split(";") + .find((cookie) => cookie.trim().startsWith(name + "=")); + + if (!existingCookie) { + setCookie(name, newValue, daysToExpire); + } +} + +/** + * TODO: move into card component + */ +function updateThumbsizes( + element: HTMLDivElement, + defaultSize: number, + thumbSizeSetting?: number +) { + let thumbSize = thumbSizeSetting ? thumbSizeSetting : defaultSize; + + if (!thumbSizeSetting) { + let viewportWidth = window.innerWidth; + let offset = 24; + let viewportWidthExcludingMargin = viewportWidth - offset; + let howManyFit = viewportWidthExcludingMargin / thumbSize; + + if (howManyFit < 2.0 && 1.5 < howManyFit) { + thumbSize = viewportWidthExcludingMargin / 2; + } else if (howManyFit > 12) { + thumbSize = defaultSize * 1.5; + } + } + element.style.setProperty("--card-size", `${thumbSize}px`); +} diff --git a/client/src/pages/components/cards/dm.scss b/client/src/components/cards/dm.scss similarity index 95% rename from client/src/pages/components/cards/dm.scss rename to client/src/components/cards/dm.scss index 2f57bb7..07f8bee 100644 --- a/client/src/pages/components/cards/dm.scss +++ b/client/src/components/cards/dm.scss @@ -1,4 +1,4 @@ -@use "../../../css/config/variables" as *; +@use "../../css/config/variables" as *; .dm-card { position: relative; diff --git a/client/src/components/cards/dm.tsx b/client/src/components/cards/dm.tsx new file mode 100644 index 0000000..c15be33 --- /dev/null +++ b/client/src/components/cards/dm.tsx @@ -0,0 +1,84 @@ +import clsx from "clsx"; +import { createProfilePageURL } from "#lib/urls"; +import { IArtist } from "#entities/profiles"; +import { IApprovedDM } from "#entities/dms"; +import { paysites } from "#entities/paysites"; +import { FancyLink } from "../links"; + +interface IProps { + dm: IApprovedDM; + isPrivate?: boolean; + isGlobal?: boolean; + artist?: IArtist; + className?: string; +} + +export function DMCard({ + dm, + isGlobal = false, + isPrivate = false, + artist, + className, +}: IProps) { + const { service, user } = dm; + const paysite = paysites[service]; + const profilePageURL = String( + createProfilePageURL({ service, profileID: user }) + ); + const remoteProfilePageURL = paysite.user.profile(artist?.id ?? user); + + return ( +
+ {!isGlobal ? undefined : ( +
+ + {artist?.name ?? user} + + + ({paysite.title}) + +
+ )} + + {!isPrivate ? undefined : ( +
+ + {artist?.name ?? user} + + + ({paysite.title}) + +
+ )} + +
+
+
{dm.content}
+
+
+ +
+ {dm.published ? ( +
+ Published: {dm.published.slice(0, 7)} +
+ ) : /* this is to detect if its not DM */ dm.user_id ? ( +
Added: {dm.added.slice(0, 7)}
+ ) : ( +
Added: {dm.added}
+ )} +
+
+ ); +} diff --git a/client/src/components/cards/index.ts b/client/src/components/cards/index.ts new file mode 100644 index 0000000..193ce4b --- /dev/null +++ b/client/src/components/cards/index.ts @@ -0,0 +1,8 @@ +export { CardList } from "./card_list"; +export { NoResults } from "./no_results"; +export { Card, CardHeader, CardBody, CardFooter } from "./base"; +export { AccountCard } from "./account"; +export { PostCard, PostFavoriteCard } from "./post"; +export { ArtistCard } from "./profile"; +export { DMCard } from "./dm"; +export { ShareCard } from "./share"; diff --git a/client/src/pages/components/cards/no_results.scss b/client/src/components/cards/no_results.scss similarity index 67% rename from client/src/pages/components/cards/no_results.scss rename to client/src/components/cards/no_results.scss index 9b12b98..f36302b 100644 --- a/client/src/pages/components/cards/no_results.scss +++ b/client/src/components/cards/no_results.scss @@ -1,4 +1,4 @@ -@use "../../../css/config/variables" as *; +@use "../../css/config/variables" as *; .card--no-results { flex: 0 1 $width-phone; diff --git a/client/src/components/cards/no_results.tsx b/client/src/components/cards/no_results.tsx new file mode 100644 index 0000000..4c9e632 --- /dev/null +++ b/client/src/components/cards/no_results.tsx @@ -0,0 +1,21 @@ +import { Card, CardBody, CardHeader } from "./base"; + +interface IProps { + title?: string; + message?: string; +} + +export function NoResults({ + title = "Nobody here but us chickens!", + message = "There are no items found.", +}: IProps) { + return ( + + +

{title}

+
+ + {message} +
+ ); +} diff --git a/client/src/pages/components/cards/post.scss b/client/src/components/cards/post.scss similarity index 97% rename from client/src/pages/components/cards/post.scss rename to client/src/components/cards/post.scss index 79c0459..d94606f 100644 --- a/client/src/pages/components/cards/post.scss +++ b/client/src/components/cards/post.scss @@ -1,4 +1,4 @@ -@use "../../../css/config/variables" as *; +@use "../../css/config/variables" as *; .post-card { width: var(--card-size); diff --git a/client/src/components/cards/post.tsx b/client/src/components/cards/post.tsx new file mode 100644 index 0000000..107d6eb --- /dev/null +++ b/client/src/components/cards/post.tsx @@ -0,0 +1,181 @@ +import clsx from "clsx"; +import { THUMBNAILS_PREPEND } from "#env/env-vars"; +import { createPostURL } from "#lib/urls"; +import { Timestamp } from "#components/dates"; +import { KemonoLink } from "#components/links"; +import { IPost, IPostWithFavorites } from "#entities/posts"; + +interface IProps { + post: IPost; + isFavourite?: boolean; + isServiceIconsDisabled?: boolean; +} + +const fileExtendsions = [".gif", ".jpeg", ".jpg", ".jpe", ".png", ".webp"]; +const someServices = ["fansly", "candfans", "boosty", "gumroad"]; + +export function PostCard({ + post, + isServiceIconsDisabled, + isFavourite = false, +}: IProps) { + const { + service, + user: artistID, + id, + title, + content, + published, + attachments, + } = post; + const postLink = String(createPostURL(service, artistID, id)); + const srcNS = findNamespace(post); + + return ( +
+ +
+ {title && title !== "DM" + ? title + : !content || content?.length < 50 + ? content + : `${content.slice(0, 50)}...`} +
+ + {!srcNS.src ? undefined : ( +
+ +
+ )} + +
+
+
+ {!published ? undefined : } +
+ {!attachments.length ? ( + <>No attachments + ) : ( + <> + {attachments.length}{" "} + {attachments.length === 1 ? "attachment" : "attachments"} + + )} +
+
+ {isServiceIconsDisabled ? undefined : ( + + )} +
+
+
+
+ ); +} + +interface IFavProps { + post: IPostWithFavorites; + isServiceIconsDisabled?: boolean; +} + +export function PostFavoriteCard({ post, isServiceIconsDisabled }: IFavProps) { + const { + service, + user: profileID, + id, + title, + content, + published, + attachments, + fav_count, + } = post; + const postLink = String(createPostURL(service, profileID, id)); + const srcNS = findNamespace(post); + + return ( +
+ +
+ {title && title !== "DM" + ? title + : !content || content?.length < 50 + ? content + : `${content.slice(0, 50)}...`} +
+ + {srcNS.src && ( +
+ +
+ )} + +
+
+
+ {published && } + +
+ {attachments.length === 0 ? ( + <>No attachments + ) : ( + <> + {attachments.length}{" "} + {attachments.length === 1 ? "attachment" : "attachments"} + + )} + +
+ <> + {fav_count} {fav_count > 1 ? "favorites" : "favorite"} + +
+
+ {!isServiceIconsDisabled && ( + + )} +
+
+
+
+ ); +} + +function findNamespace(post: IPost) { + const srcNS: { found: boolean; src?: string } = { found: false }; + const path = post.file?.path?.toLowerCase(); + const isValidpath = path && fileExtendsions.find((ext) => path.endsWith(ext)); + + if (isValidpath) { + srcNS.src = path; + } + + if (!isValidpath && someServices.includes(post.service)) { + const matchedFile = post.attachments.find((file) => + fileExtendsions.find((ext) => file.path?.endsWith(ext)) + ); + + srcNS.src = matchedFile?.path; + } + + return srcNS; +} diff --git a/client/src/pages/components/cards/user.scss b/client/src/components/cards/profile.scss similarity index 97% rename from client/src/pages/components/cards/user.scss rename to client/src/components/cards/profile.scss index 10602a1..5d328cd 100644 --- a/client/src/pages/components/cards/user.scss +++ b/client/src/components/cards/profile.scss @@ -1,4 +1,4 @@ -@use "../../../css/config/variables" as *; +@use "../../css/config/variables" as *; .user-card { position: relative; diff --git a/client/src/components/cards/profile.tsx b/client/src/components/cards/profile.tsx new file mode 100644 index 0000000..27e23c9 --- /dev/null +++ b/client/src/components/cards/profile.tsx @@ -0,0 +1,132 @@ +import clsx from "clsx"; +import { IArtist, IArtistWithFavs } from "#entities/profiles"; +import { paysites } from "#entities/paysites"; +import { + createBannerURL, + createIconURL, + createProfilePageURL, +} from "#lib/urls"; +import { useClient } from "#hooks"; +import { Image } from "#components/images"; +import { Timestamp } from "#components/dates"; +import { KemonoLink } from "#components/links"; + +interface IProps { + artist: IArtist | IArtistWithFavs; + isUpdated?: boolean; + isIndexed?: boolean; + isCount?: boolean; + isFavorite?: boolean; + singleOf?: string; + pluralOf?: string; + isDate?: boolean; + className?: string; +} + +interface IHeaderProps { + isCount?: boolean; + isDate?: boolean; +} + +export function ArtistCard({ + artist, + isUpdated = false, + isIndexed = false, + isCount = false, + isFavorite = false, + singleOf, + pluralOf, + isDate = false, + className, +}: IProps) { + const isClient = useClient(); + const profileLink = String( + createProfilePageURL({ + service: artist.service, + profileID: artist.id, + }) + ); + const profileIcon = createIconURL(artist.service, artist.id); + const profileBanner = createBannerURL(artist.service, artist.id); + const updatedDateTime = new Date(Number(artist.updated) * 1000).toISOString(); + const indexedDateTime = new Date(Number(artist.indexed) * 1000).toISOString(); + + return ( + + {/* Icon. */} +
+
+ +
+
+ + {/* Secondary identifiers and elements. */} +
+ + {paysites[artist.service].title} + + +
{artist.name}
+ + {isUpdated && ( +
+ +
+ )} + + {isIndexed && ( +
+ +
+ )} + + {!isCount ? undefined : ( +
+ {"favorited" in artist ? ( + <> + {artist.favorited}{" "} + {artist.favorited > 1 ? pluralOf : singleOf} + + ) : ( + <>No {pluralOf ? pluralOf : "None"} + )} +
+ )} +
+
+ ); +} + +export function ArtistCardHeader({ + isCount = false, + isDate = false, +}: IHeaderProps) { + return ( +
+
Icon
+
Name
+
Service
+ + {!isCount ? undefined : ( +
Times favorited
+ )} + + {!isDate ? undefined :
Updated
} +
+ ); +} diff --git a/client/src/components/cards/share.tsx b/client/src/components/cards/share.tsx new file mode 100644 index 0000000..e83d1c0 --- /dev/null +++ b/client/src/components/cards/share.tsx @@ -0,0 +1,28 @@ +import { IShare } from "#entities/files"; +import { createSharePageURL } from "#lib/urls"; + +interface IProps { + share: IShare; +} + +export function ShareCard({ share }: IProps) { + const { id, name, description, added } = share; + + return ( + + ); +} diff --git a/client/src/components/dangerous-content/dangerous.tsx b/client/src/components/dangerous-content/dangerous.tsx new file mode 100644 index 0000000..1a022a9 --- /dev/null +++ b/client/src/components/dangerous-content/dangerous.tsx @@ -0,0 +1,44 @@ +import { + createElement, + useEffect, + useRef, + ComponentPropsWithoutRef, +} from "react"; + +interface IProps + extends Omit, "dangerouslySetInnerHTML"> { + html: string; + allowRerender?: boolean; +} + +/** + * [`dangerouslySetInnerHTML`](https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html) + * but also runs ` +
    + {videos.map((video, index) => ( + + ))} +
+ + )} + + {attachments && attachments.length !== 0 && ( + <> +

Downloads

+
    + {attachments.map((attachment, index) => ( + + ))} +
+ + )} + + {incomplete_rewards && ( +
+

+        
+ )} + + {poll && } + + {content && ( + <> +

Content

+ {/* TODO: rewrite without this */} +
+

+          
+ + )} + + {previews && } + + ); +} + +/** + * Apply some fixes to the content of the post. + */ +function cleanupBody(postBody: HTMLElement, service: string) { + const postContent = postBody.querySelector(".post__content"); + const isNoPostContent = + !postContent || + (!postContent.childElementCount && !postContent.childNodes.length); + + // content is empty + if (isNoPostContent) { + return; + } + + // pixiv post + if (service === "fanbox") { + // its contents is a text node + if (!postContent.childElementCount && postContent.childNodes.length === 1) { + // wrap the text node into `
`
+      const [textNode] = Array.from(postContent.childNodes);
+      const pre = document.createElement("pre");
+      textNode.after(pre);
+      pre.appendChild(textNode);
+    }
+
+    // remove paragraphs with only `
` in them + const paragraphs = postContent.querySelectorAll("p"); + paragraphs.forEach((para) => { + if ( + para.childElementCount === 1 && + para.firstElementChild?.tagName === "BR" + ) { + para.remove(); + } + }); + } + + Array.from(document.links).forEach((anchour) => { + // remove links to fanbox from the post + const hostname = anchour.hostname; + if (hostname.includes("downloads.fanbox.cc")) { + if (anchour.classList.contains("image-link")) { + anchour.remove(); + } else { + let el = document.createElement("span"); + el.textContent = anchour.textContent; + anchour.replaceWith(el); + } + } else if (hostname.includes("fanbox.cc")) { + anchour.href = anchour.href.replace( + /https?:\/\/(?:[a-zA-Z0-9-]*.)?fanbox\.cc\/(?:(?:manage\/)|(?:@[a-zA-Z\d]+\/)|)posts\/(\d+)/g, + "/fanbox/post/$1" + ); + } else if (hostname.includes("patreon.com")) { + anchour.href = anchour.href.replace( + /https?:\/\/(?:[\w-]*.)?patreon\.com\/posts\/.*\b(\d+)\b(?:\?.*)?/g, + "/patreon/post/$1" + ); + } + }); + + // Remove needless spaces and empty paragraphs. + /** + * @type {NodeListOf { + if ( + paragraph.nextElementSibling && + paragraph.nextElementSibling.tagName === "BR" + ) { + paragraph.nextElementSibling.remove(); + paragraph.remove(); + } else { + paragraph.remove(); + } + }); +} + +interface IPostVideoProps { + video: IPostVideo; +} + +function PostVideo({ video }: IPostVideoProps) { + return ( +
  • + {video.name} + + {!video.caption ? undefined : {video.caption}} + + +
  • + ); +} + +interface IPostAttachmentProps + extends Pick { + attachment: IPostAttachment; +} + +const archiveFileExtension = [".zip", ".rar", ".7z"]; + +function PostAttachment({ + attachment, + archives_enabled, +}: IPostAttachmentProps) { + const { name, path, server, extension, name_extension, stem } = attachment; + const isArchiveFile = Boolean( + archives_enabled && + (archiveFileExtension.includes(extension) || + archiveFileExtension.includes(name_extension)) + ); + + return ( +
  • + + Download {name} + + {isArchiveFile && ( + <> + {/* TODO: a proper URL function */}( + browse ») + + )} +
  • + ); +} + +interface IPostPollProps { + poll: IPostPoll; +} + +function PostPoll({ poll }: IPostPollProps) { + const { + title, + description, + choices, + total_votes, + created_at, + closes_at, + allow_multiple, + } = poll; + + return ( + <> +

    Poll

    + +
    +
    +

    {title}

    + {!description ? undefined :

    {description}

    } +
    + +
      + {choices.map((choice) => { + const percentage = (choice.votes / (total_votes ?? 1)) * 100; + + return ( +
    • + {choice.text} + {choice.votes} + +
    • + ); + })} +
    + +
    +
      +
    • + {created_at} +
    • + {closes_at && ( +
    • + —{closes_at} +
    • + )} + + {allow_multiple && ( + <> + +
    • multiple choice
    • + + )} + + +
    • {total_votes} votes
    • +
    +
    +
    + {String(poll)} + + ); +} + +interface IPostPreviewsProps + extends Pick, "previews"> {} + +function PostPreviews({ previews }: IPostPreviewsProps) { + return ( + <> +

    Files

    +
    + {previews.map((preview, index) => + preview.type === "thumbnail" ? ( + + ) : ( + + ) + )} +
    + + ); +} + +interface IPreviewThumbnailProps { + preview: IPreviewThumbnail; +} + +function PreviewThumbnail({ preview }: IPreviewThumbnailProps) { + const [isExpanded, switchExpansion] = useState(false); + const { server, path, name, caption } = preview; + const url = String(createPreviewURL(path, name, server)); + const downloadName = encodeURIComponent(name); + const thumbnailRef = useRef(null); + + return ( + + ); +} + +interface IPreviewEmbedProps { + preview: IPreviewEmbed; +} + +function PreviewEmbed({ preview }: IPreviewEmbedProps) { + const { url, description, subject } = preview; + + return ( + +
    +

    + {!subject ? "(No title)" : subject} +

    + {description &&

    {description}

    } +
    +
    + ); +} + +interface IPostFooterProps extends Pick { + service: string; + profileID: string; + profileName?: string; +} + +function PostFooter({ + comments, + service, + profileID, + profileName, +}: IPostFooterProps) { + return ( +
    +

    Comments

    + {/* TODO: comment filters */} + Loading comments...

    }> + }> + {(comments: IComment[]) => ( +
    + {!comments ? ( +

    No comments found for this post.

    + ) : ( + comments.map((comment) => ( + + )) + )} +
    + )} +
    +
    +
    + ); +} + +interface IPostCommentProps { + comment: IComment; + postProfileID: string; + postProfileName?: string; + service: string; +} + +function PostComment({ + comment, + postProfileID, + postProfileName, + service, +}: IPostCommentProps) { + const { + id, + commenter, + commenter_name, + revisions, + parent_id, + content, + published, + } = comment; + const isProfileComment = commenter === postProfileID; + const modalID = `comment-revisions-${id}`; + + return ( +
    +
    + {!isProfileComment ? ( + + {commenter_name ?? "Anonymous"} + + ) : ( + <> + {/* TODO: a proper local link */} + + + + + {postProfileName ?? "Post's profile"} + + + )} + + {revisions && revisions.length !== 0 && ( + <> + MicroModal.show(modalID)} + > + ( + + edited + + ) + +
    +
    +
    +
    +
    +

    + Comment edits +

    + + +
    + +
    + {[...revisions, comment].map((revision) => ( +
    + + {revision.published ?? revision.added} + + {revision.content} +
    + ))} +
    +
    +
    +
    +
    + + )} +
    + +
    + {parent_id && ( + + )} +
    +          

    + {service !== "boosty" ? ( + content + ) : ( +

    +                {content.replace(
    +                  '/size/large" title="',
    +                  '/size/small" title="'
    +                )}
    +              
    + )} +

    +
    +
    +
    + +
    +
    + ); +} + +interface IPostActionsProps extends Pick { + service: string; + profileID: string; + postID: string; +} + +function PostActions({ + service, + profileID, + postID, + flagged, +}: IPostActionsProps) { + return ( +
    + + + +
    + ); +} + +interface IFlagButtonProps + extends Pick< + IPostActionsProps, + "flagged" | "service" | "profileID" | "postID" + > {} + +/** + * TODO: promptless flagging + */ +function FlagButton({ service, profileID, postID, flagged }: IFlagButtonProps) { + const [isFlagged, switchFlag] = useState(Boolean(flagged)); + const [isFlagging, switchFlagging] = useState(false); + const renderKey = `${postID}${profileID}${service}`; + + async function handleFlagging() { + const isConfirmed = confirm( + "Are you sure you want to flag this post for reimport? Only do this if data in the post is broken/corrupted/incomplete.\nThis is not a deletion button." + ); + + if (!isConfirmed) { + return; + } + + try { + switchFlagging(true); + + await flagPost(service, profileID, postID); + + switchFlag(true); + + } finally { + switchFlagging(false); + } + } + + return !isFlagged ? ( + + ) : ( + + + Flagged + + ); +} + +interface IFavoriteButtonProps { + service: string; + profileID: string; + postID: string; +} + +function FavoriteButton({ service, profileID, postID }: IFavoriteButtonProps) { + const [isFavorite, switchFavorite] = useState(false); + const [isLoading, switchLoading] = useState(true); + const renderKey = `${postID}${profileID}${service}`; + + useEffect(() => { + (async () => { + try { + switchLoading(true); + + const isLoggedIn = await isRegisteredAccount(); + + if (!isLoggedIn) { + return; + } + + const isFav = await isFavouritePost(service, profileID, postID); + + switchFavorite(isFav); + } catch (error) { + // TODO: better error handling + console.error(error); + } finally { + switchLoading(false); + } + })(); + }, [service, profileID, postID]); + + async function handleFavorite() { + try { + switchLoading(true); + await addPostToFavourites(service, profileID, postID); + switchFavorite(true); + } catch (error) { + // TODO: better error handling + console.error(error); + } finally { + switchLoading(false); + } + } + async function handleUnfavorite() { + try { + switchLoading(true); + await removePostFromFavourites(service, profileID, postID); + switchFavorite(false); + } catch (error) { + // TODO: better error handling + console.error(error); + } finally { + switchLoading(false); + } + } + + return isFavorite ? ( + + ) : ( + + ); +} + +function addShowTagsButton() { + let div = document.querySelector("#post-tags > div"); + + if (document.getElementById("show-tag-overflow-button")) { + // @ts-expect-error no fucking idea what it does + document.getElementById("show-tag-overflow-button").remove(); + } + // @ts-expect-error no fucking idea what it does + + if (div && div.offsetWidth < div.scrollWidth) { + // tags overflow + let button = document.createElement("a"); + button.href = "javascript:void 0"; + button.id = "show-tag-overflow-button"; + button.textContent = "Show all »"; + button.onclick = (e) => { + if (div.classList.contains("show-overflow")) { + div.classList.remove("show-overflow"); + button.textContent = "Show all»"; + } else { + div.classList.add("show-overflow"); + button.textContent = "« Hide"; + } + }; + // @ts-expect-error no fucking idea what it does + div.parentElement.appendChild(button); + } +} diff --git a/client/src/entities/posts/period.ts b/client/src/entities/posts/period.ts new file mode 100644 index 0000000..8e9754f --- /dev/null +++ b/client/src/entities/posts/period.ts @@ -0,0 +1,15 @@ +const periods = ["recent", "day", "week", "month"] as const; + +export type IPopularPostsPeriod = (typeof periods)[number]; + +export function isValidPeriod(value: unknown): value is IPopularPostsPeriod { + return periods.includes(value as IPopularPostsPeriod); +} + +export function validatePeriod( + value: unknown +): asserts value is IPopularPostsPeriod { + if (!isValidPeriod(value)) { + throw new Error(`"${value}" is not a valid period.`); + } +} diff --git a/client/src/entities/posts/types.ts b/client/src/entities/posts/types.ts new file mode 100644 index 0000000..45e76b0 --- /dev/null +++ b/client/src/entities/posts/types.ts @@ -0,0 +1,141 @@ +export interface IPost { + service: string; + /** + * ID of the profile. + */ + user: string; + id: string; + title?: string; + content?: string; + file?: { + path?: string; + name: string; + }; + shared_file: boolean; + embed: {}; + attachments: IPostAttachment[]; + added: string; + published?: string; + edited?: string; + prev?: string; + next?: string; + revision_id?: string; + tags: string[] | null; + incomplete_rewards?: string; + poll?: IPostPoll; +} + +export interface IPostWithFavorites extends IPost { + fav_count: number; +} + +export interface IPostRevision { + revision_id?: string; + added?: string; +} + +export interface IPostVideo { + index: string; + name: string; + caption: string; + extension: string; + server?: string; + path: string; +} + +export interface IPostAttachment { + path: string; + name: string; + server?: string; + extension: string; + name_extension: string; + stem: string; +} + +export interface IPostPoll { + title: string; + description?: string; + total_votes?: number; + choices: { votes: number; text: string }[]; + created_at: string; + closes_at: string; + allow_multiple?: boolean; +} + +export type IPostPreview = IPreviewThumbnail | IPreviewEmbed; + +export interface IPreviewThumbnail { + type: "thumbnail"; + server?: string; + path: string; + name: string; + caption?: string; +} + +export interface IPreviewEmbed { + type: "embed"; + url: string; + subject?: string; + description?: string; +} + +export interface IComment { + id: string; + commenter: string; + commenter_name?: string; + revisions?: ICommentRevision[]; + parent_id?: string; + published: string; + added?: string; + content: string; +} + +export interface ICommentRevision { + id: string; + published?: string; + added?: string; + content: string; +} + +export interface IAnnouncement { + service: string; + user_id: string; + hash: string; + content: string; + added: string; + published?: string; +} + +export interface IDiscordChannelMessage { + id: string; + author: IDiscordChannelMessageAuthor; + server: string; + channel: string; + content: string; + added: string; + published: string; + edited: string; + embeds: IDiscordEmbed[]; + mentions: unknown[]; + attachments: IDiscordAttachment[]; +} + +export interface IDiscordChannelMessageAuthor { + id: string; + avatar: string; + username: string; + public_flags: number; + + discriminator: string; +} + +export interface IDiscordAttachment { + name: string; + path: string; +} + +export interface IDiscordEmbed { + url: string + title?: string + description?: string +} diff --git a/client/src/entities/profiles/headers.tsx b/client/src/entities/profiles/headers.tsx new file mode 100644 index 0000000..30839ed --- /dev/null +++ b/client/src/entities/profiles/headers.tsx @@ -0,0 +1,185 @@ +import clsx from "clsx"; +import { useEffect, useState } from "react"; +import { + createBannerURL, + createFileUploadPageURL, + createIconURL, + createProfilePageURL, +} from "#lib/urls"; +import { ImageBackground, ImageLink } from "#components/images"; +import { paysites } from "#entities/paysites"; +import { + addProfileToFavourites, + isFavouriteProfile, + isRegisteredAccount, + removeProfileFromFavourites, +} from "#entities/account"; + +interface IProps { + service: string; + profileID: string; + profileName?: string; +} + +/** + * TODO: asset imports instead of literal paths + */ +const paysiteIcons = { + patreon: "/static/patreon.svg", + fanbox: "/static/fanbox.svg", + boosty: "/static/boosty.svg", + gumroad: "/static/gumroad.svg", + subscribestar: "/static/subscribestar.png", + dlsite: "/static/dlsite.png", + fantia: "/static/fantia.png", + onlyfans: "/static/onlyfans.svg", + fansly: "/static/fansly.svg", + candfans: "/static/candfans.png", +} as const; + +export function ProfileHeader({ service, profileID, profileName }: IProps) { + const artistIcon = createIconURL(service, profileID); + const artistBanner = createBannerURL(service, profileID); + const externalProfileURL = paysites[service].user.profile(profileID); + const paysiteIconURL = paysiteIcons[service as keyof typeof paysiteIcons]; + + return ( +
    + + {/* TODO: remove self-referencing link */} + + + +
    + ); +} + +interface IFavouriteButtonProps { + service: string; + profileID: string; +} + +function FavouriteButton({ service, profileID }: IFavouriteButtonProps) { + const [isFavourited, switchFavourite] = useState(false); + const [isLoading, switchLoading] = useState(true); + const renderKey = `${service}${profileID}`; + + useEffect(() => { + (async () => { + try { + switchLoading(true); + const isLoggedIn = await isRegisteredAccount(); + + if (!isLoggedIn) { + return; + } + + const result = await isFavouriteProfile(service, profileID); + + switchFavourite(result); + } catch (error) { + // TODO: better error handling + console.error(error); + } finally { + switchLoading(false); + } + })(); + }, [service, profileID]); + + async function handleFavouriting() { + try { + switchLoading(true); + await addProfileToFavourites(service, profileID); + switchFavourite(true); + } catch (error) { + // TODO: better error handling + console.error(error); + } finally { + switchLoading(false); + } + } + + async function handleUnfavouriting() { + try { + switchLoading(true); + await removeProfileFromFavourites(service, profileID); + switchFavourite(false); + } catch (error) { + // TODO: better error handling + console.error(error); + } finally { + switchLoading(false); + } + } + + return !isFavourited ? ( + + ) : ( + + ); +} diff --git a/client/src/entities/profiles/index.ts b/client/src/entities/profiles/index.ts new file mode 100644 index 0000000..92f7f68 --- /dev/null +++ b/client/src/entities/profiles/index.ts @@ -0,0 +1,4 @@ +export { getArtists, getArtist } from "./lib/get"; +export { ProfileHeader } from "./headers"; +export { Tabs } from "./tabs"; +export type { IArtistDetails, IArtist, IArtistWithFavs } from "./types"; diff --git a/client/src/entities/profiles/lib/get.ts b/client/src/entities/profiles/lib/get.ts new file mode 100644 index 0000000..51eb735 --- /dev/null +++ b/client/src/entities/profiles/lib/get.ts @@ -0,0 +1,155 @@ +import { PAGINATION_LIMIT } from "#lib/pagination"; +import { fetchArtistProfile, fetchProfiles } from "#api/profiles"; +import { findFavouriteProfiles } from "#entities/account"; +import { IArtistDetails, IArtistWithFavs } from "../types"; + +// the original page is a clusterfuck which pulls an entire artist list +// and does sorting/ordering/filtering on client +// rewriting it requires rewriting backend endpoints +// so for now it does the same +// TODO: rewrite it on backend +let allArtists: Awaited> | undefined = + undefined; + +export interface IGetArtistsArgs { + service?: string; + offset?: number; + order?: "asc" | "desc"; + sort_by?: "favorited" | "indexed" | "updated" | "name" | "service"; + query?: string; +} + +interface IGetArtistsResult { + artists: (IArtistWithFavs & { isFavourite: boolean })[]; + count: number; +} + +export async function getArtists({ + offset = 0, + service, + order = "desc", + sort_by = "favorited", + query, +}: IGetArtistsArgs): Promise { + if (!allArtists) { + allArtists = await fetchProfiles(); + } + + const normalizedQuery = query?.trim().toLowerCase(); + // MDN recommends doing this for large arrays + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/localeCompare + const compare = new Intl.Collator().compare; + + const filteredArtists = allArtists + .filter((profile) => { + let isServiceMatched = true + + if (service) { + isServiceMatched = profile.service === service; + } + + let isQueryMatched = true + + if (normalizedQuery) { + const normalizedName = profile.name.trim().toLowerCase(); + const normalizedID = profile.id.trim().toLowerCase(); + + return ( + normalizedID.includes(normalizedQuery) || + normalizedName.includes(normalizedQuery) + ); + } + + return isServiceMatched && isQueryMatched; + }) + .sort((prev, next) => { + switch (sort_by) { + case "favorited": { + return prev.favorited === next.favorited + ? 0 + : prev.favorited > next.favorited + ? 1 + : -1; + } + + case "service": { + return compare(prev.service, next.service); + } + + case "name": { + return compare(prev.name, next.name); + } + + case "indexed": { + // @ts-expect-error fuck dates + const prevIndexed = prev.indexed * 1000; + // @ts-expect-error fuck dates + const nextIndexed = next.indexed * 1000; + + return prevIndexed === nextIndexed + ? 0 + : prevIndexed > nextIndexed + ? 1 + : -1; + } + case "updated": { + // @ts-expect-error fuck dates + const prevUpdated = prev.updated * 1000; + // @ts-expect-error fuck dates + const nextUpdated = next.updated * 1000; + + return prevUpdated === nextUpdated + ? 0 + : prevUpdated > nextUpdated + ? 1 + : -1; + } + + default: { + throw new Error(`Unknown sorting type "${sort_by satisfies never}".`); + } + } + }); + + // artists are only sorted by one field + // so we can get away with "desc" just slicing off the end + // without changing sorting logic + const slicedArtists = + order === "asc" + ? filteredArtists.slice(offset, offset + PAGINATION_LIMIT) + : filteredArtists + .slice( + -offset + -PAGINATION_LIMIT, + offset === 0 ? undefined : -offset + ) + .reverse(); + const profilesData: Parameters[0] = + slicedArtists.map(({ id, service }) => { + return { id, service }; + }); + const favArtists = await findFavouriteProfiles(profilesData); + const resultArtists: IGetArtistsResult["artists"] = slicedArtists.map( + (artist) => { + const fav = favArtists.find( + ({ id, service }) => id === artist.id && service === artist.service + ); + + return { + ...artist, + isFavourite: fav === undefined ? false : true, + }; + } + ); + const count = filteredArtists.length; + + return { artists: resultArtists, count }; +} + +export async function getArtist( + service: string, + id: string +): Promise { + const profile = await fetchArtistProfile(service, id); + + return profile; +} diff --git a/client/src/entities/profiles/tabs.tsx b/client/src/entities/profiles/tabs.tsx new file mode 100644 index 0000000..313279e --- /dev/null +++ b/client/src/entities/profiles/tabs.tsx @@ -0,0 +1,119 @@ +import { ReactNode } from "react"; +import { + createProfileAnnouncementsURL, + createProfileDMsURL, + createProfileFancardsURL, + createProfileLinksURL, + createProfileSharesURL, + createProfileTagsURL, + createProfilePageURL, +} from "#lib/urls"; +import { KemonoLink } from "#components/links"; + +interface IProps { + currentPage: + | "posts" + | "fancards" + | "announcements" + | "tabs" + | "dms" + | "shares" + | "linked_accounts"; + service: string; + artistID: string; + dmCount?: number; + shareCount?: number; + hasLinks?: boolean; +} + +interface ITabProps { + href: string; + isActive?: boolean; + children: ReactNode; +} + +const announcementServices = ["fanbox", "patreon"]; +const tabServices = ["patreon", "onlyfans", "fansly", "candfans"]; + +export function Tabs({ + currentPage, + service, + artistID, + dmCount, + shareCount, + hasLinks, +}: IProps) { + return ( +
      + + Posts + + + {service !== "fanbox" ? undefined : ( + + Fancards + + )} + + {!announcementServices.includes(service) ? undefined : ( + + Announcements + + )} + + {/* TODO: fix the typos mismatch */} + {!tabServices.includes(service) ? undefined : ( + + Tags + + )} + + {!dmCount ? undefined : ( + + DMs ({dmCount}) + + )} + + {!shareCount ? undefined : ( + + Uploads ({shareCount}) + + )} + + + {!hasLinks ? <>Linked Accounts : <>Linked Accounts (✔️)} + +
    + ); +} + +function Tab({ href, isActive, children }: ITabProps) { + return ( +
  • + + {children} + +
  • + ); +} diff --git a/client/src/entities/profiles/types.ts b/client/src/entities/profiles/types.ts new file mode 100644 index 0000000..60162b7 --- /dev/null +++ b/client/src/entities/profiles/types.ts @@ -0,0 +1,23 @@ +export interface IArtistDetails { + id: string + public_id: string | null + service: string + name: string + indexed: string + updated: string +} + +export interface IArtist { + id: string; + name: string; + service: string; + indexed: string; + updated: string; + public_id: string; + relation_id: number; +} + +export interface IArtistWithFavs extends IArtist { + count: number; + favorited: number +} diff --git a/client/src/entities/tags/index.ts b/client/src/entities/tags/index.ts new file mode 100644 index 0000000..18cafdc --- /dev/null +++ b/client/src/entities/tags/index.ts @@ -0,0 +1,2 @@ +export { getTags } from "./lib/get"; +export type { ITag } from "./types"; diff --git a/client/src/entities/tags/lib/get.ts b/client/src/entities/tags/lib/get.ts new file mode 100644 index 0000000..c290e30 --- /dev/null +++ b/client/src/entities/tags/lib/get.ts @@ -0,0 +1,7 @@ +import { fetchProfileTags } from "#api/tags"; + +export async function getTags(service: string, profileID: string) { + const result = await fetchProfileTags(service, profileID); + + return result; +} diff --git a/client/src/entities/tags/types.ts b/client/src/entities/tags/types.ts new file mode 100644 index 0000000..c89621e --- /dev/null +++ b/client/src/entities/tags/types.ts @@ -0,0 +1,4 @@ +export interface ITag { + tag: string; + post_count: number; +} diff --git a/client/src/env/derived-vars.js b/client/src/env/derived-vars.js deleted file mode 100644 index 674271a..0000000 --- a/client/src/env/derived-vars.js +++ /dev/null @@ -1,4 +0,0 @@ -import { KEMONO_SITE, NODE_ENV } from "./env-vars.js"; - -export const IS_DEVELOPMENT = NODE_ENV === "development"; -export const SITE_HOSTNAME = new URL(KEMONO_SITE).hostname; diff --git a/client/src/env/derived-vars.ts b/client/src/env/derived-vars.ts new file mode 100644 index 0000000..0212991 --- /dev/null +++ b/client/src/env/derived-vars.ts @@ -0,0 +1,30 @@ +import { + KEMONO_SITE, + NODE_ENV, + PAYSITE_LIST, + ARTISTS_OR_CREATORS, + API_SERVER_BASE_URL, + API_SERVER_PORT, +} from "./env-vars"; +import { IPaySite, paysites } from "#entities/paysites"; + +export const IS_DEVELOPMENT = NODE_ENV === "development"; +export const SITE_HOSTNAME = new URL(KEMONO_SITE).hostname; +export const AVAILABLE_PAYSITES = PAYSITE_LIST.reduce( + (availablePaysites, paysiteName) => { + const paysite = paysites[paysiteName]; + availablePaysites[paysiteName] = paysite; + + return availablePaysites; + }, + {} +); +export const AVAILABLE_PAYSITE_LIST = PAYSITE_LIST.map< + IPaySite & { name: string } +>((paysiteName) => { + return { ...AVAILABLE_PAYSITES[paysiteName], name: paysiteName }; +}); +export const ARTISTS_OR_CREATORS_LOWERCASE = ARTISTS_OR_CREATORS.toLowerCase(); +export const API_SERVER_URL = !API_SERVER_BASE_URL + ? "" + : `${API_SERVER_BASE_URL}${!API_SERVER_PORT ? "" : `:${API_SERVER_PORT}`}`; diff --git a/client/src/env/env-vars.js b/client/src/env/env-vars.js deleted file mode 100644 index 50a3f5d..0000000 --- a/client/src/env/env-vars.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - https://webpack.js.org/plugins/define-plugin/ -*/ - -/** - * @type {string} - */ -export const KEMONO_SITE = BUNDLER_ENV_KEMONO_SITE; -/** - * @type {string} - */ -export const NODE_ENV = BUNDLER_ENV_NODE_ENV; -/** - * @type {string} - */ -export const ICONS_PREPEND = BUNDLER_ENV_ICONS_PREPEND; -/** - * @type {string} - */ -export const BANNERS_PREPEND = BUNDLER_ENV_BANNERS_PREPEND; -/** - * @type {string} - */ -export const THUMBNAILS_PREPEND = BUNDLER_ENV_THUMBNAILS_PREPEND; -/** - * @type {string} - */ -export const CREATORS_LOCATION = BUNDLER_ENV_CREATORS_LOCATION; -/** - * @type {string} - */ diff --git a/client/src/env/env-vars.ts b/client/src/env/env-vars.ts new file mode 100644 index 0000000..adf99fe --- /dev/null +++ b/client/src/env/env-vars.ts @@ -0,0 +1,126 @@ +/* + https://webpack.js.org/plugins/define-plugin/ +*/ + +import type { INavItem } from "#components/layout"; + +export const KEMONO_SITE = BUNDLER_ENV_KEMONO_SITE; + +export const SENTRY_DSN = BUNDLER_ENV_SENTRY_DSN; + +export const SITE_NAME = BUNDLER_ENV_SITE_NAME; + +export const NODE_ENV = process.env.NODE_ENV; + +export const ICONS_PREPEND = BUNDLER_ENV_ICONS_PREPEND; + +export const BANNERS_PREPEND = BUNDLER_ENV_BANNERS_PREPEND; + +export const THUMBNAILS_PREPEND = BUNDLER_ENV_THUMBNAILS_PREPEND; + +export const CREATORS_LOCATION = BUNDLER_ENV_CREATORS_LOCATION; + +export const ARTISTS_OR_CREATORS = BUNDLER_ENV_ARTISTS_OR_CREATORS; + +export const DISABLE_DMS = BUNDLER_ENV_DISABLE_DMS; + +export const DISABLE_FAQ = BUNDLER_ENV_DISABLE_FAQ; + +export const DISABLE_FILEHAUS = BUNDLER_ENV_DISABLE_FILEHAUS; + +export const SIDEBAR_ITEMS = BUNDLER_ENV_SIDEBAR_ITEMS; + +export const FOOTER_ITEMS = BUNDLER_ENV_FOOTER_ITEMS; + +/** + * b64-encoded string + */ +export const BANNER_GLOBAL = BUNDLER_ENV_BANNER_GLOBAL; + +/** + * b64-encoded string + */ +export const BANNER_WELCOME = BUNDLER_ENV_BANNER_WELCOME; + +export const HOME_BACKGROUND_IMAGE = BUNDLER_ENV_HOME_BACKGROUND_IMAGE; + +export const HOME_MASCOT_PATH = BUNDLER_ENV_HOME_MASCOT_PATH; + +export const HOME_LOGO_PATH = BUNDLER_ENV_HOME_LOGO_PATH; + +/** + * b64-encoded string + */ +export const HOME_WELCOME_CREDITS = BUNDLER_ENV_HOME_WELCOME_CREDITS; + +export const PAYSITE_LIST = BUNDLER_ENV_PAYSITE_LIST; + +/** + * b64-encoded string + */ +export const HEADER_AD = BUNDLER_ENV_HEADER_AD; + +/** + * b64-encoded string + */ +export const MIDDLE_AD = BUNDLER_ENV_MIDDLE_AD; + +/** + * b64-encoded string + */ +export const FOOTER_AD = BUNDLER_ENV_FOOTER_AD; + +/** + * b64-encoded string + */ +export const SLIDER_AD = BUNDLER_ENV_SLIDER_AD; + +/** + * b64-encoded JSON string + */ +export const VIDEO_AD = BUNDLER_ENV_VIDEO_AD; + +export const IS_ARCHIVER_ENABLED = BUNDLER_ENV_IS_ARCHIVER_ENABLED; + +export const API_SERVER_BASE_URL = BUNDLER_ENV_API_SERVER_BASE_URL; + +export const API_SERVER_PORT = BUNDLER_ENV_API_SERVER_PORT; + +// stolen from https://stackoverflow.com/a/76844373 +declare global { + const BUNDLER_ENV_KEMONO_SITE: string; + const BUNDLER_ENV_SENTRY_DSN: string | undefined; + const BUNDLER_ENV_SITE_NAME: string; + const BUNDLER_ENV_ICONS_PREPEND: string; + const BUNDLER_ENV_BANNERS_PREPEND: string; + const BUNDLER_ENV_THUMBNAILS_PREPEND: string; + const BUNDLER_ENV_CREATORS_LOCATION: string; + const BUNDLER_ENV_ARTISTS_OR_CREATORS: string; + const BUNDLER_ENV_DISABLE_DMS: boolean; + const BUNDLER_ENV_DISABLE_FAQ: boolean; + const BUNDLER_ENV_DISABLE_FILEHAUS: boolean; + const BUNDLER_ENV_SIDEBAR_ITEMS: INavItem[]; + const BUNDLER_ENV_FOOTER_ITEMS: unknown[] | undefined; + const BUNDLER_ENV_BANNER_GLOBAL: string | undefined; + const BUNDLER_ENV_BANNER_WELCOME: string | undefined; + const BUNDLER_ENV_HOME_BACKGROUND_IMAGE: string | undefined; + const BUNDLER_ENV_HOME_MASCOT_PATH: string | undefined; + const BUNDLER_ENV_HOME_LOGO_PATH: string | undefined; + const BUNDLER_ENV_HOME_WELCOME_CREDITS: string; + const BUNDLER_ENV_PAYSITE_LIST: string[]; + const BUNDLER_ENV_HOME_ANNOUNCEMENTS: IAnnouncement[] | undefined; + const BUNDLER_ENV_HEADER_AD: string; + const BUNDLER_ENV_MIDDLE_AD: string; + const BUNDLER_ENV_FOOTER_AD: string; + const BUNDLER_ENV_SLIDER_AD: string; + const BUNDLER_ENV_VIDEO_AD: string; + const BUNDLER_ENV_IS_ARCHIVER_ENABLED: boolean; + const BUNDLER_ENV_API_SERVER_BASE_URL: string | undefined; + const BUNDLER_ENV_API_SERVER_PORT: number | undefined; +} + +interface IAnnouncement { + title: string; + date: string; + content: string; +} diff --git a/client/src/index.html b/client/src/index.html new file mode 100644 index 0000000..ce29e74 --- /dev/null +++ b/client/src/index.html @@ -0,0 +1,13 @@ + + + + + + <%= htmlWebpackPlugin.options.title %> + <%= analytics %> + + + +
    + + diff --git a/client/src/index.scss b/client/src/index.scss new file mode 100644 index 0000000..d344024 --- /dev/null +++ b/client/src/index.scss @@ -0,0 +1,3 @@ +@use "./css"; +@use "./components"; +@use "./pages"; diff --git a/client/src/index.tsx b/client/src/index.tsx new file mode 100644 index 0000000..22832a3 --- /dev/null +++ b/client/src/index.tsx @@ -0,0 +1,23 @@ +import "./index.scss"; +// TODO: nuke/inline these styles +import "purecss/build/base-min.css"; +import "purecss/build/grids-min.css"; +import "purecss/build/grids-responsive-min.css"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { RouterProvider } from "react-router-dom"; +import { router } from "./router"; + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("Root element is missing."); +} + +const root = createRoot(rootElement); + +root.render( + + + +); diff --git a/client/src/js/account.js b/client/src/js/account.js deleted file mode 100644 index 9701d8f..0000000 --- a/client/src/js/account.js +++ /dev/null @@ -1 +0,0 @@ -export const isLoggedIn = localStorage.getItem("logged_in") === "yes"; diff --git a/client/src/js/admin.js b/client/src/js/admin.js deleted file mode 100644 index 99fd033..0000000 --- a/client/src/js/admin.js +++ /dev/null @@ -1,7 +0,0 @@ -import "./admin.scss"; -import { fixImageLinks } from "@wp/utils"; -import { initSections } from "./page-loader"; -import { adminPageScripts } from "@wp/pages"; - -fixImageLinks(document.images); -initSections(adminPageScripts); diff --git a/client/src/js/admin.scss b/client/src/js/admin.scss deleted file mode 100644 index 9a46059..0000000 --- a/client/src/js/admin.scss +++ /dev/null @@ -1,3 +0,0 @@ -@use "../css"; -@use "../pages/components"; -@use "../pages/account/administrator"; diff --git a/client/src/js/component-factory.js b/client/src/js/component-factory.js deleted file mode 100644 index 8dfaabb..0000000 --- a/client/src/js/component-factory.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @type {Map} - */ -const components = new Map(); - -/** - * @param {HTMLElement} footer - */ -export function initComponentFactory(footer) { - const container = footer.querySelector(".component-container"); - /** - * @type {NodeListOf *`); - - componentElements.forEach((component) => { - components.set(component.className.trim(), component); - }); - container.remove(); -} - -/** - * @param {string} className - */ -export function createComponent(className) { - const componentSkeleton = components.get(className); - - if (!componentSkeleton) { - return console.error(`Component "${className}" doesn't exist.`); - } - - const newInstance = componentSkeleton.cloneNode(true); - - return newInstance; -} diff --git a/client/src/js/favorites.js b/client/src/js/favorites.js deleted file mode 100644 index e678435..0000000 --- a/client/src/js/favorites.js +++ /dev/null @@ -1,207 +0,0 @@ -import { kemonoAPI } from "@wp/api"; - -export async function initFavorites() { - let artistFavs = localStorage.getItem("favs"); - let postFavs = localStorage.getItem("post_favs"); - - if (!artistFavs || artistFavs === "undefined") { - /** - * @type {string} - */ - const favs = await kemonoAPI.favorites.retrieveFavoriteArtists(); - - if (favs) { - localStorage.setItem("favs", favs); - } - } - - if (!postFavs || postFavs === "undefined") { - /** - * @type {string} - */ - const favs = await kemonoAPI.favorites.retrieveFavoritePosts(); - - if (favs) { - localStorage.setItem("post_favs", favs); - } - } -} - -async function saveFavouriteArtists() { - try { - const favs = await kemonoAPI.favorites.retrieveFavoriteArtists(); - - if (!favs) { - alert("Could not retrieve favorite artists"); - return false; - } - - localStorage.setItem("favs", favs); - return true; - } catch (error) { - console.error(error); - } -} - -async function saveFavouritePosts() { - try { - const favs = await kemonoAPI.favorites.retrieveFavoritePosts(); - - if (!favs) { - alert("Could not retrieve favorite posts"); - return false; - } - - localStorage.setItem("post_favs", favs); - return true; - } catch (error) { - console.error(error); - } -} - -/** - * @param {string} id - * @param {string} service - * @returns {Promise | undefined} - */ -export async function findFavouriteArtist(id, service) { - /** - * @type {KemonoAPI.Favorites.User[]} - */ - let favList; - - try { - favList = JSON.parse(localStorage.getItem("favs")); - } catch (error) { - // corrupted entry - if (error instanceof SyntaxError) { - const isSaved = await saveFavouriteArtists(); - - if (!isSaved) { - return undefined; - } - - return await findFavouriteArtist(id, service); - } - } - - if (!favList) { - return undefined; - } - - const favArtist = favList.find((favItem) => { - return favItem.id === id && favItem.service === service; - }); - - return favArtist; -} - -/** - * @param {string} service - * @param {string} user - * @param {string} postID - * @returns {Promise | undefined} - */ -export async function findFavouritePost(service, user, postID) { - /** - * @type {KemonoAPI.Favorites.Post[]} - */ - let favList; - - try { - favList = JSON.parse(localStorage.getItem("post_favs")); - - if (!favList) { - return undefined; - } - - const favPost = favList.find((favItem) => { - const isMatch = favItem.id === postID && favItem.service === service && favItem.user === user; - return isMatch; - }); - - return favPost; - } catch (error) { - // corrupted entry - if (error instanceof SyntaxError) { - const isSaved = await saveFavouritePosts(); - - if (!isSaved) { - return undefined; - } - - return await findFavouritePost(service, user, postID); - } - } -} - -/** - * @param {string} id - * @param {string} service - */ -export async function addFavouriteArtist(id, service) { - const isFavorited = await kemonoAPI.favorites.favoriteArtist(service, id); - - if (!isFavorited) { - return false; - } - - const newFavs = await kemonoAPI.favorites.retrieveFavoriteArtists(); - localStorage.setItem("favs", newFavs); - - return true; -} - -/** - * @param {string} id - * @param {string} service - */ -export async function removeFavouriteArtist(id, service) { - const isUnfavorited = await kemonoAPI.favorites.unfavoriteArtist(service, id); - - if (!isUnfavorited) { - return false; - } - - const favItems = await kemonoAPI.favorites.retrieveFavoriteArtists(); - localStorage.setItem("favs", favItems); - - return true; -} - -/** - * @param {string} service - * @param {string} user - * @param {string} postID - */ -export async function addFavouritePost(service, user, postID) { - const isFavorited = await kemonoAPI.favorites.favoritePost(service, user, postID); - - if (!isFavorited) { - return false; - } - - const newFavs = await kemonoAPI.favorites.retrieveFavoritePosts(); - localStorage.setItem("post_favs", newFavs); - - return true; -} - -/** - * @param {string} service - * @param {string} user - * @param {string} postID - * @returns - */ -export async function removeFavouritePost(service, user, postID) { - const isUnfavorited = await kemonoAPI.favorites.unfavoritePost(service, user, postID); - - if (!isUnfavorited) { - return false; - } - - const favItems = await kemonoAPI.favorites.retrieveFavoritePosts(); - localStorage.setItem("post_favs", favItems); - - return true; -} diff --git a/client/src/js/feature-detect.js b/client/src/js/feature-detect.js deleted file mode 100644 index 0286d95..0000000 --- a/client/src/js/feature-detect.js +++ /dev/null @@ -1,13 +0,0 @@ -export const features = { - localStorage: isLocalStorageAvailable(), -}; - -function isLocalStorageAvailable() { - try { - localStorage.setItem("__storage_test__", "__storage_test__"); - localStorage.removeItem("__storage_test__"); - return true; - } catch (error) { - return false; - } -} diff --git a/client/src/js/global.js b/client/src/js/global.js deleted file mode 100644 index 333a110..0000000 --- a/client/src/js/global.js +++ /dev/null @@ -1,18 +0,0 @@ -import "./global.scss"; -import "purecss/build/base-min.css"; -import "purecss/build/grids-min.css"; -import "purecss/build/grids-responsive-min.css"; -import 'whatwg-fetch'; /* fetch polyfill */ -import { isLoggedIn } from "@wp/js/account"; -import { initFavorites } from "@wp/js/favorites"; -import { fixImageLinks } from "@wp/utils"; -import { globalPageScripts } from "@wp/pages"; -import { initSections } from "./page-loader"; -import { initPendingReviewDms } from "@wp/js/pending-review-dms"; - -if (isLoggedIn) { - initFavorites(); - initPendingReviewDms(); -} -fixImageLinks(document.images); -initSections(globalPageScripts); diff --git a/client/src/js/global.scss b/client/src/js/global.scss deleted file mode 100644 index a6e8a86..0000000 --- a/client/src/js/global.scss +++ /dev/null @@ -1,2 +0,0 @@ -@use "../css"; -@use "../pages"; diff --git a/client/src/js/moderator.js b/client/src/js/moderator.js deleted file mode 100644 index ed160e6..0000000 --- a/client/src/js/moderator.js +++ /dev/null @@ -1,7 +0,0 @@ -import "./moderator.scss"; -import { fixImageLinks } from "@wp/utils"; -import { initSections } from "./page-loader"; -import { moderatorPageScripts } from "@wp/pages"; - -fixImageLinks(document.images); -initSections(moderatorPageScripts); diff --git a/client/src/js/moderator.scss b/client/src/js/moderator.scss deleted file mode 100644 index ab8a9c1..0000000 --- a/client/src/js/moderator.scss +++ /dev/null @@ -1 +0,0 @@ -@use "../pages/moderator"; diff --git a/client/src/js/page-loader.js b/client/src/js/page-loader.js deleted file mode 100644 index 46c7d8c..0000000 --- a/client/src/js/page-loader.js +++ /dev/null @@ -1,37 +0,0 @@ -import { initShell } from "@wp/components"; -import { initComponentFactory } from "./component-factory"; - -/** - * Initialises the scripts on the page. - * @param {Map void>} pages The map of page names and their callbacks. - */ -export function initSections(pages) { - const sidebar = document.querySelector(".global-sidebar"); - const main = document.querySelector("main"); - /** - * @type {HTMLElement} - */ - const footer = document.querySelector(".global-footer"); - /** - * @type {NodeListOf} - */ - const sections = main.querySelectorAll("main > .site-section"); - - initComponentFactory(footer); - initShell(sidebar); - sections.forEach((section) => { - const sectionNames = section.className.match(/site-section--([a-z\-]+)/ig); - - if (sectionNames && sectionNames.length > 0) { - sectionNames.forEach((sectionName) => { - sectionName = sectionName.replace('site-section--', ''); - if (pages.has(sectionName)) { - const sectionCallbacks = pages.get(sectionName); - sectionCallbacks.forEach((sectionCallback) => { - sectionCallback(section); - }); - } - }); - } - }); -} diff --git a/client/src/js/pending-review-dms.js b/client/src/js/pending-review-dms.js deleted file mode 100644 index ff04258..0000000 --- a/client/src/js/pending-review-dms.js +++ /dev/null @@ -1,19 +0,0 @@ -import { kemonoAPI } from "@wp/api"; - -export async function initPendingReviewDms(forceReload= false, minutesForRecheck = 30) { - let HasPendingReviewDms = localStorage.getItem("has_pending_review_dms") === "true"; - let LastCheckedHasPendingReviewDms = parseInt(localStorage.getItem("last_checked_has_pending_review_dms")); - - if (forceReload || !LastCheckedHasPendingReviewDms || (LastCheckedHasPendingReviewDms < Date.now() - minutesForRecheck * 60 * 1000)) { - /** - * @type {string} - */ - HasPendingReviewDms = await kemonoAPI.dms.retrieveHasPendingDMs(); - localStorage.setItem("has_pending_review_dms", HasPendingReviewDms.toString()); - localStorage.setItem("last_checked_has_pending_review_dms", Date.now().toString()); - } - if (HasPendingReviewDms) - document.querySelector(".review_dms > img").src = "/static/menu/red_dm.svg"; - else - document.querySelector(".review_dms > img").src = "/static/menu/dm.svg"; -} diff --git a/client/src/js/resumable.js b/client/src/js/resumable.js deleted file mode 100644 index bf05040..0000000 --- a/client/src/js/resumable.js +++ /dev/null @@ -1,1242 +0,0 @@ -/* - * MIT Licensed - * http://www.23developer.com/opensource - * http://github.com/23/resumable.js - * Steffen Tiedemann Christensen, steffen@23company.com - */ - -(function () { - "use strict"; - - var Resumable = function (opts) { - if (!(this instanceof Resumable)) { - return new Resumable(opts); - } - this.version = 1.0; - // SUPPORTED BY BROWSER? - // Check if these features are support by the browser: - // - File object type - // - Blob object type - // - FileList object type - // - slicing files - this.support = - typeof File !== "undefined" && - typeof Blob !== "undefined" && - typeof FileList !== "undefined" && - (!!Blob.prototype.webkitSlice || !!Blob.prototype.mozSlice || !!Blob.prototype.slice || false); - if (!this.support) return false; - - // PROPERTIES - var $ = this; - $.files = []; - $.defaults = { - chunkSize: 1 * 1024 * 1024, - forceChunkSize: false, - simultaneousUploads: 3, - fileParameterName: "file", - chunkNumberParameterName: "resumableChunkNumber", - chunkSizeParameterName: "resumableChunkSize", - currentChunkSizeParameterName: "resumableCurrentChunkSize", - totalSizeParameterName: "resumableTotalSize", - typeParameterName: "resumableType", - identifierParameterName: "resumableIdentifier", - fileNameParameterName: "resumableFilename", - relativePathParameterName: "resumableRelativePath", - totalChunksParameterName: "resumableTotalChunks", - dragOverClass: "dragover", - throttleProgressCallbacks: 0.5, - query: {}, - headers: {}, - preprocess: null, - preprocessFile: null, - method: "multipart", - uploadMethod: "POST", - testMethod: "GET", - prioritizeFirstAndLastChunk: false, - target: "/", - testTarget: null, - parameterNamespace: "", - testChunks: true, - generateUniqueIdentifier: null, - getTarget: null, - maxChunkRetries: 100, - chunkRetryInterval: undefined, - permanentErrors: [400, 401, 403, 404, 409, 415, 500, 501], - maxFiles: undefined, - withCredentials: false, - xhrTimeout: 0, - clearInput: true, - chunkFormat: "blob", - setChunkTypeFromFile: false, - maxFilesErrorCallback: function (files, errorCount) { - var maxFiles = $.getOpt("maxFiles"); - alert("Please upload no more than " + maxFiles + " file" + (maxFiles === 1 ? "" : "s") + " at a time."); - }, - minFileSize: 1, - minFileSizeErrorCallback: function (file, errorCount) { - alert( - file.fileName || - file.name + - " is too small, please upload files larger than " + - $h.formatSize($.getOpt("minFileSize")) + - ".", - ); - }, - maxFileSize: undefined, - maxFileSizeErrorCallback: function (file, errorCount) { - alert( - file.fileName || - file.name + " is too large, please upload files less than " + $h.formatSize($.getOpt("maxFileSize")) + ".", - ); - }, - fileType: [], - fileTypeErrorCallback: function (file, errorCount) { - alert( - file.fileName || - file.name + " has type not allowed, please upload files of type " + $.getOpt("fileType") + ".", - ); - }, - }; - $.opts = opts || {}; - $.getOpt = function (o) { - var $opt = this; - // Get multiple option if passed an array - if (o instanceof Array) { - var options = {}; - $h.each(o, function (option) { - options[option] = $opt.getOpt(option); - }); - return options; - } - // Otherwise, just return a simple option - if ($opt instanceof ResumableChunk) { - if (typeof $opt.opts[o] !== "undefined") { - return $opt.opts[o]; - } else { - $opt = $opt.fileObj; - } - } - if ($opt instanceof ResumableFile) { - if (typeof $opt.opts[o] !== "undefined") { - return $opt.opts[o]; - } else { - $opt = $opt.resumableObj; - } - } - if ($opt instanceof Resumable) { - if (typeof $opt.opts[o] !== "undefined") { - return $opt.opts[o]; - } else { - return $opt.defaults[o]; - } - } - }; - $.indexOf = function (array, obj) { - if (array.indexOf) { - return array.indexOf(obj); - } - for (var i = 0; i < array.length; i++) { - if (array[i] === obj) { - return i; - } - } - return -1; - }; - - // EVENTS - // catchAll(event, ...) - // fileSuccess(file), fileProgress(file), fileAdded(file, event), filesAdded(files, filesSkipped), fileRetry(file), - // fileError(file, message), complete(), progress(), error(message, file), pause() - $.events = []; - $.on = function (event, callback) { - $.events.push(event.toLowerCase(), callback); - }; - $.fire = function () { - // `arguments` is an object, not array, in FF, so: - var args = []; - for (var i = 0; i < arguments.length; i++) args.push(arguments[i]); - // Find event listeners, and support pseudo-event `catchAll` - var event = args[0].toLowerCase(); - for (var i = 0; i <= $.events.length; i += 2) { - if ($.events[i] == event) $.events[i + 1].apply($, args.slice(1)); - if ($.events[i] == "catchall") $.events[i + 1].apply(null, args); - } - if (event == "fileerror") $.fire("error", args[2], args[1]); - if (event == "fileprogress") $.fire("progress"); - }; - - // INTERNAL HELPER METHODS (handy, but ultimately not part of uploading) - var $h = { - stopEvent: function (e) { - e.stopPropagation(); - e.preventDefault(); - }, - each: function (o, callback) { - if (typeof o.length !== "undefined") { - for (var i = 0; i < o.length; i++) { - // Array or FileList - if (callback(o[i]) === false) return; - } - } else { - for (i in o) { - // Object - if (callback(i, o[i]) === false) return; - } - } - }, - generateUniqueIdentifier: function (file, event) { - var custom = $.getOpt("generateUniqueIdentifier"); - if (typeof custom === "function") { - return custom(file, event); - } - var relativePath = file.webkitRelativePath || file.relativePath || file.fileName || file.name; // Some confusion in different versions of Firefox - var size = file.size; - return size + "-" + relativePath.replace(/[^0-9a-zA-Z_-]/gim, ""); - }, - contains: function (array, test) { - var result = false; - - $h.each(array, function (value) { - if (value == test) { - result = true; - return false; - } - return true; - }); - - return result; - }, - formatSize: function (size) { - if (size < 1024) { - return size + " bytes"; - } else if (size < 1024 * 1024) { - return (size / 1024.0).toFixed(0) + " KB"; - } else if (size < 1024 * 1024 * 1024) { - return (size / 1024.0 / 1024.0).toFixed(1) + " MB"; - } else { - return (size / 1024.0 / 1024.0 / 1024.0).toFixed(1) + " GB"; - } - }, - getTarget: function (request, params) { - var target = $.getOpt("target"); - - if (request === "test" && $.getOpt("testTarget")) { - target = $.getOpt("testTarget") === "/" ? $.getOpt("target") : $.getOpt("testTarget"); - } - - if (typeof target === "function") { - return target(params); - } - - var separator = target.indexOf("?") < 0 ? "?" : "&"; - var joinedParams = params.join("&"); - - if (joinedParams) target = target + separator + joinedParams; - - return target; - }, - }; - - var onDrop = function (e) { - e.currentTarget.classList.remove($.getOpt("dragOverClass")); - $h.stopEvent(e); - - //handle dropped things as items if we can (this lets us deal with folders nicer in some cases) - if (e.dataTransfer && e.dataTransfer.items) { - loadFiles(e.dataTransfer.items, e); - } - //else handle them as files - else if (e.dataTransfer && e.dataTransfer.files) { - loadFiles(e.dataTransfer.files, e); - } - }; - var onDragLeave = function (e) { - e.currentTarget.classList.remove($.getOpt("dragOverClass")); - }; - var onDragOverEnter = function (e) { - e.preventDefault(); - var dt = e.dataTransfer; - if ($.indexOf(dt.types, "Files") >= 0) { - // only for file drop - e.stopPropagation(); - dt.dropEffect = "copy"; - dt.effectAllowed = "copy"; - e.currentTarget.classList.add($.getOpt("dragOverClass")); - } else { - // not work on IE/Edge.... - dt.dropEffect = "none"; - dt.effectAllowed = "none"; - } - }; - - /** - * processes a single upload item (file or directory) - * @param {Object} item item to upload, may be file or directory entry - * @param {string} path current file path - * @param {File[]} items list of files to append new items to - * @param {Function} cb callback invoked when item is processed - */ - function processItem(item, path, items, cb) { - var entry; - if (item.isFile) { - // file provided - return item.file(function (file) { - file.relativePath = path + file.name; - items.push(file); - cb(); - }); - } else if (item.isDirectory) { - // item is already a directory entry, just assign - entry = item; - } else if (item instanceof File) { - items.push(item); - } - if ("function" === typeof item.webkitGetAsEntry) { - // get entry from file object - entry = item.webkitGetAsEntry(); - } - if (entry && entry.isDirectory) { - // directory provided, process it - return processDirectory(entry, path + entry.name + "/", items, cb); - } - if ("function" === typeof item.getAsFile) { - // item represents a File object, convert it - item = item.getAsFile(); - if (item instanceof File) { - item.relativePath = path + item.name; - items.push(item); - } - } - cb(); // indicate processing is done - } - - /** - * cps-style list iteration. - * invokes all functions in list and waits for their callback to be - * triggered. - * @param {Function[]} items list of functions expecting callback parameter - * @param {Function} cb callback to trigger after the last callback has been invoked - */ - function processCallbacks(items, cb) { - if (!items || items.length === 0) { - // empty or no list, invoke callback - return cb(); - } - // invoke current function, pass the next part as continuation - items[0](function () { - processCallbacks(items.slice(1), cb); - }); - } - - /** - * recursively traverse directory and collect files to upload - * @param {Object} directory directory to process - * @param {string} path current path - * @param {File[]} items target list of items - * @param {Function} cb callback invoked after traversing directory - */ - function processDirectory(directory, path, items, cb) { - var dirReader = directory.createReader(); - var allEntries = []; - - function readEntries() { - dirReader.readEntries(function (entries) { - if (entries.length) { - allEntries = allEntries.concat(entries); - return readEntries(); - } - - // process all conversion callbacks, finally invoke own one - processCallbacks( - allEntries.map(function (entry) { - // bind all properties except for callback - return processItem.bind(null, entry, path, items); - }), - cb, - ); - }); - } - - readEntries(); - } - - /** - * process items to extract files to be uploaded - * @param {File[]} items items to process - * @param {Event} event event that led to upload - */ - function loadFiles(items, event) { - if (!items.length) { - return; // nothing to do - } - $.fire("beforeAdd"); - var files = []; - processCallbacks( - Array.prototype.map.call(items, function (item) { - // bind all properties except for callback - var entry = item; - if ("function" === typeof item.webkitGetAsEntry) { - entry = item.webkitGetAsEntry(); - } - return processItem.bind(null, entry, "", files); - }), - function () { - if (files.length) { - // at least one file found - appendFilesFromFileList(files, event); - } - }, - ); - } - - var appendFilesFromFileList = function (fileList, event) { - // check for uploading too many files - var errorCount = 0; - var o = $.getOpt([ - "maxFiles", - "minFileSize", - "maxFileSize", - "maxFilesErrorCallback", - "minFileSizeErrorCallback", - "maxFileSizeErrorCallback", - "fileType", - "fileTypeErrorCallback", - ]); - if (typeof o.maxFiles !== "undefined" && o.maxFiles < fileList.length + $.files.length) { - // if single-file upload, file is already added, and trying to add 1 new file, simply replace the already-added file - if (o.maxFiles === 1 && $.files.length === 1 && fileList.length === 1) { - $.removeFile($.files[0]); - } else { - o.maxFilesErrorCallback(fileList, errorCount++); - return false; - } - } - var files = [], - filesSkipped = [], - remaining = fileList.length; - var decreaseReamining = function () { - if (!--remaining) { - // all files processed, trigger event - if (!files.length && !filesSkipped.length) { - // no succeeded files, just skip - return; - } - window.setTimeout(function () { - $.fire("filesAdded", files, filesSkipped); - }, 0); - } - }; - $h.each(fileList, function (file) { - var fileName = file.name; - var fileType = file.type; // e.g video/mp4 - if (o.fileType.length > 0) { - var fileTypeFound = false; - for (var index in o.fileType) { - // For good behaviour we do some inital sanitizing. Remove spaces and lowercase all - o.fileType[index] = o.fileType[index].replace(/\s/g, "").toLowerCase(); - - // Allowing for both [extension, .extension, mime/type, mime/*] - var extension = (o.fileType[index].match(/^[^.][^/]+$/) ? "." : "") + o.fileType[index]; - - if ( - fileName.substr(-1 * extension.length).toLowerCase() === extension || - //If MIME type, check for wildcard or if extension matches the files tiletype - (extension.indexOf("/") !== -1 && - ((extension.indexOf("*") !== -1 && - fileType.substr(0, extension.indexOf("*")) === extension.substr(0, extension.indexOf("*"))) || - fileType === extension)) - ) { - fileTypeFound = true; - break; - } - } - if (!fileTypeFound) { - o.fileTypeErrorCallback(file, errorCount++); - return true; - } - } - - if (typeof o.minFileSize !== "undefined" && file.size < o.minFileSize) { - o.minFileSizeErrorCallback(file, errorCount++); - return true; - } - if (typeof o.maxFileSize !== "undefined" && file.size > o.maxFileSize) { - o.maxFileSizeErrorCallback(file, errorCount++); - return true; - } - - function addFile(uniqueIdentifier) { - if (!$.getFromUniqueIdentifier(uniqueIdentifier)) { - (function () { - file.uniqueIdentifier = uniqueIdentifier; - var f = new ResumableFile($, file, uniqueIdentifier); - $.files.push(f); - files.push(f); - f.container = typeof event != "undefined" ? event.srcElement : null; - window.setTimeout(function () { - $.fire("fileAdded", f, event); - }, 0); - })(); - } else { - filesSkipped.push(file); - } - decreaseReamining(); - } - // directories have size == 0 - var uniqueIdentifier = $h.generateUniqueIdentifier(file, event); - if (uniqueIdentifier && typeof uniqueIdentifier.then === "function") { - // Promise or Promise-like object provided as unique identifier - uniqueIdentifier.then( - function (uniqueIdentifier) { - // unique identifier generation succeeded - addFile(uniqueIdentifier); - }, - function () { - // unique identifier generation failed - // skip further processing, only decrease file count - decreaseReamining(); - }, - ); - } else { - // non-Promise provided as unique identifier, process synchronously - addFile(uniqueIdentifier); - } - }); - }; - - // INTERNAL OBJECT TYPES - function ResumableFile(resumableObj, file, uniqueIdentifier) { - var $ = this; - $.opts = {}; - $.getOpt = resumableObj.getOpt; - $._prevProgress = 0; - $.resumableObj = resumableObj; - $.file = file; - $.fileName = file.fileName || file.name; // Some confusion in different versions of Firefox - $.size = file.size; - $.relativePath = file.relativePath || file.webkitRelativePath || $.fileName; - $.uniqueIdentifier = uniqueIdentifier; - $._pause = false; - $.container = ""; - $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished - var _error = uniqueIdentifier !== undefined; - - // Callback when something happens within the chunk - var chunkEvent = function (event, message) { - // event can be 'progress', 'success', 'error' or 'retry' - switch (event) { - case "progress": - $.resumableObj.fire("fileProgress", $, message); - break; - case "error": - $.abort(); - _error = true; - $.chunks = []; - $.resumableObj.fire("fileError", $, message); - break; - case "success": - if (_error) return; - $.resumableObj.fire("fileProgress", $, message); // it's at least progress - if ($.isComplete()) { - $.resumableObj.fire("fileSuccess", $, message); - } - break; - case "retry": - $.resumableObj.fire("fileRetry", $); - break; - } - }; - - // Main code to set up a file object with chunks, - // packaged to be able to handle retries if needed. - $.chunks = []; - $.abort = function () { - // Stop current uploads - var abortCount = 0; - $h.each($.chunks, function (c) { - if (c.status() == "uploading") { - c.abort(); - abortCount++; - } - }); - if (abortCount > 0) $.resumableObj.fire("fileProgress", $); - }; - $.cancel = function () { - // Reset this file to be void - var _chunks = $.chunks; - $.chunks = []; - // Stop current uploads - $h.each(_chunks, function (c) { - if (c.status() == "uploading") { - c.abort(); - $.resumableObj.uploadNextChunk(); - } - }); - $.resumableObj.removeFile($); - $.resumableObj.fire("fileProgress", $); - }; - $.retry = function () { - $.bootstrap(); - var firedRetry = false; - $.resumableObj.on("chunkingComplete", function () { - if (!firedRetry) $.resumableObj.upload(); - firedRetry = true; - }); - }; - $.bootstrap = function () { - $.abort(); - _error = false; - // Rebuild stack of chunks from file - $.chunks = []; - $._prevProgress = 0; - var round = $.getOpt("forceChunkSize") ? Math.ceil : Math.floor; - var maxOffset = Math.max(round($.file.size / $.getOpt("chunkSize")), 1); - for (var offset = 0; offset < maxOffset; offset++) { - (function (offset) { - $.chunks.push(new ResumableChunk($.resumableObj, $, offset, chunkEvent)); - $.resumableObj.fire("chunkingProgress", $, offset / maxOffset); - })(offset); - } - window.setTimeout(function () { - $.resumableObj.fire("chunkingComplete", $); - }, 0); - }; - $.progress = function () { - if (_error) return 1; - // Sum up progress across everything - var ret = 0; - var error = false; - $h.each($.chunks, function (c) { - if (c.status() == "error") error = true; - ret += c.progress(true); // get chunk progress relative to entire file - }); - ret = error ? 1 : ret > 0.99999 ? 1 : ret; - ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused - $._prevProgress = ret; - return ret; - }; - $.isUploading = function () { - var uploading = false; - $h.each($.chunks, function (chunk) { - if (chunk.status() == "uploading") { - uploading = true; - return false; - } - }); - return uploading; - }; - $.isComplete = function () { - var outstanding = false; - if ($.preprocessState === 1) { - return false; - } - $h.each($.chunks, function (chunk) { - var status = chunk.status(); - if (status == "pending" || status == "uploading" || chunk.preprocessState === 1) { - outstanding = true; - return false; - } - }); - return !outstanding; - }; - $.pause = function (pause) { - if (typeof pause === "undefined") { - $._pause = $._pause ? false : true; - } else { - $._pause = pause; - } - }; - $.isPaused = function () { - return $._pause; - }; - $.preprocessFinished = function () { - $.preprocessState = 2; - $.upload(); - }; - $.upload = function () { - var found = false; - if ($.isPaused() === false) { - var preprocess = $.getOpt("preprocessFile"); - if (typeof preprocess === "function") { - switch ($.preprocessState) { - case 0: - $.preprocessState = 1; - preprocess($); - return true; - case 1: - return true; - case 2: - break; - } - } - $h.each($.chunks, function (chunk) { - if (chunk.status() == "pending" && chunk.preprocessState !== 1) { - chunk.send(); - found = true; - return false; - } - }); - } - return found; - }; - $.markChunksCompleted = function (chunkNumber) { - if (!$.chunks || $.chunks.length <= chunkNumber) { - return; - } - for (var num = 0; num < chunkNumber; num++) { - $.chunks[num].markComplete = true; - } - }; - - // Bootstrap and return - $.resumableObj.fire("chunkingStart", $); - $.bootstrap(); - return this; - } - - function ResumableChunk(resumableObj, fileObj, offset, callback) { - var $ = this; - $.opts = {}; - $.getOpt = resumableObj.getOpt; - $.resumableObj = resumableObj; - $.fileObj = fileObj; - $.fileObjSize = fileObj.size; - $.fileObjType = fileObj.file.type; - $.offset = offset; - $.callback = callback; - $.lastProgressCallback = new Date(); - $.tested = false; - $.retries = 0; - $.pendingRetry = false; - $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished - $.markComplete = false; - - // Computed properties - var chunkSize = $.getOpt("chunkSize"); - $.loaded = 0; - $.startByte = $.offset * chunkSize; - $.endByte = Math.min($.fileObjSize, ($.offset + 1) * chunkSize); - if ($.fileObjSize - $.endByte < chunkSize && !$.getOpt("forceChunkSize")) { - // The last chunk will be bigger than the chunk size, but less than 2*chunkSize - $.endByte = $.fileObjSize; - } - $.xhr = null; - - // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session - $.test = function () { - // Set up request and listen for event - $.xhr = new XMLHttpRequest(); - - var testHandler = function (e) { - $.tested = true; - var status = $.status(); - if (status == "success") { - $.callback(status, $.message()); - $.resumableObj.uploadNextChunk(); - } else { - $.send(); - } - }; - $.xhr.addEventListener("load", testHandler, false); - $.xhr.addEventListener("error", testHandler, false); - $.xhr.addEventListener("timeout", testHandler, false); - - // Add data from the query options - var params = []; - var parameterNamespace = $.getOpt("parameterNamespace"); - var customQuery = $.getOpt("query"); - if (typeof customQuery == "function") customQuery = customQuery($.fileObj, $); - $h.each(customQuery, function (k, v) { - params.push([encodeURIComponent(parameterNamespace + k), encodeURIComponent(v)].join("=")); - }); - // Add extra data to identify chunk - params = params.concat( - [ - // define key/value pairs for additional parameters - ["chunkNumberParameterName", $.offset + 1], - ["chunkSizeParameterName", $.getOpt("chunkSize")], - ["currentChunkSizeParameterName", $.endByte - $.startByte], - ["totalSizeParameterName", $.fileObjSize], - ["typeParameterName", $.fileObjType], - ["identifierParameterName", $.fileObj.uniqueIdentifier], - ["fileNameParameterName", $.fileObj.fileName], - ["relativePathParameterName", $.fileObj.relativePath], - ["totalChunksParameterName", $.fileObj.chunks.length], - ] - .filter(function (pair) { - // include items that resolve to truthy values - // i.e. exclude false, null, undefined and empty strings - return $.getOpt(pair[0]); - }) - .map(function (pair) { - // map each key/value pair to its final form - return [parameterNamespace + $.getOpt(pair[0]), encodeURIComponent(pair[1])].join("="); - }), - ); - // Append the relevant chunk and send it - $.xhr.open($.getOpt("testMethod"), $h.getTarget("test", params)); - $.xhr.timeout = $.getOpt("xhrTimeout"); - $.xhr.withCredentials = $.getOpt("withCredentials"); - // Add data from header options - var customHeaders = $.getOpt("headers"); - if (typeof customHeaders === "function") { - customHeaders = customHeaders($.fileObj, $); - } - $h.each(customHeaders, function (k, v) { - $.xhr.setRequestHeader(k, v); - }); - $.xhr.send(null); - }; - - $.preprocessFinished = function () { - $.preprocessState = 2; - $.send(); - }; - - // send() uploads the actual data in a POST call - $.send = function () { - var preprocess = $.getOpt("preprocess"); - if (typeof preprocess === "function") { - switch ($.preprocessState) { - case 0: - $.preprocessState = 1; - preprocess($); - return; - case 1: - return; - case 2: - break; - } - } - if ($.getOpt("testChunks") && !$.tested) { - $.test(); - return; - } - - // Set up request and listen for event - $.xhr = new XMLHttpRequest(); - - // Progress - $.xhr.upload.addEventListener( - "progress", - function (e) { - if (new Date() - $.lastProgressCallback > $.getOpt("throttleProgressCallbacks") * 1000) { - $.callback("progress"); - $.lastProgressCallback = new Date(); - } - $.loaded = e.loaded || 0; - }, - false, - ); - $.loaded = 0; - $.pendingRetry = false; - $.callback("progress"); - - // Done (either done, failed or retry) - var doneHandler = function (e) { - var status = $.status(); - if (status == "success" || status == "error") { - $.callback(status, $.message()); - $.resumableObj.uploadNextChunk(); - } else { - $.callback("retry", $.message()); - $.abort(); - $.retries++; - var retryInterval = $.getOpt("chunkRetryInterval"); - if (retryInterval !== undefined) { - $.pendingRetry = true; - setTimeout($.send, retryInterval); - } else { - $.send(); - } - } - }; - $.xhr.addEventListener("load", doneHandler, false); - $.xhr.addEventListener("error", doneHandler, false); - $.xhr.addEventListener("timeout", doneHandler, false); - - // Set up the basic query data from Resumable - var query = [ - ["chunkNumberParameterName", $.offset + 1], - ["chunkSizeParameterName", $.getOpt("chunkSize")], - ["currentChunkSizeParameterName", $.endByte - $.startByte], - ["totalSizeParameterName", $.fileObjSize], - ["typeParameterName", $.fileObjType], - ["identifierParameterName", $.fileObj.uniqueIdentifier], - ["fileNameParameterName", $.fileObj.fileName], - ["relativePathParameterName", $.fileObj.relativePath], - ["totalChunksParameterName", $.fileObj.chunks.length], - ] - .filter(function (pair) { - // include items that resolve to truthy values - // i.e. exclude false, null, undefined and empty strings - return $.getOpt(pair[0]); - }) - .reduce(function (query, pair) { - // assign query key/value - query[$.getOpt(pair[0])] = pair[1]; - return query; - }, {}); - // Mix in custom data - var customQuery = $.getOpt("query"); - if (typeof customQuery == "function") customQuery = customQuery($.fileObj, $); - $h.each(customQuery, function (k, v) { - query[k] = v; - }); - - var func = $.fileObj.file.slice - ? "slice" - : $.fileObj.file.mozSlice - ? "mozSlice" - : $.fileObj.file.webkitSlice - ? "webkitSlice" - : "slice"; - var bytes = $.fileObj.file[func]( - $.startByte, - $.endByte, - $.getOpt("setChunkTypeFromFile") ? $.fileObj.file.type : "", - ); - var data = null; - var params = []; - - var parameterNamespace = $.getOpt("parameterNamespace"); - if ($.getOpt("method") === "octet") { - // Add data from the query options - data = bytes; - $h.each(query, function (k, v) { - params.push([encodeURIComponent(parameterNamespace + k), encodeURIComponent(v)].join("=")); - }); - } else { - // Add data from the query options - data = new FormData(); - $h.each(query, function (k, v) { - data.append(parameterNamespace + k, v); - params.push([encodeURIComponent(parameterNamespace + k), encodeURIComponent(v)].join("=")); - }); - if ($.getOpt("chunkFormat") == "blob") { - data.append(parameterNamespace + $.getOpt("fileParameterName"), bytes, $.fileObj.fileName); - } else if ($.getOpt("chunkFormat") == "base64") { - var fr = new FileReader(); - fr.onload = function (e) { - data.append(parameterNamespace + $.getOpt("fileParameterName"), fr.result); - $.xhr.send(data); - }; - fr.readAsDataURL(bytes); - } - } - - var target = $h.getTarget("upload", params); - var method = $.getOpt("uploadMethod"); - - $.xhr.open(method, target); - if ($.getOpt("method") === "octet") { - $.xhr.setRequestHeader("Content-Type", "application/octet-stream"); - } - $.xhr.timeout = $.getOpt("xhrTimeout"); - $.xhr.withCredentials = $.getOpt("withCredentials"); - // Add data from header options - var customHeaders = $.getOpt("headers"); - if (typeof customHeaders === "function") { - customHeaders = customHeaders($.fileObj, $); - } - - $h.each(customHeaders, function (k, v) { - $.xhr.setRequestHeader(k, v); - }); - - if ($.getOpt("chunkFormat") == "blob") { - $.xhr.send(data); - } - }; - $.abort = function () { - // Abort and reset - if ($.xhr) $.xhr.abort(); - $.xhr = null; - }; - $.status = function () { - // Returns: 'pending', 'uploading', 'success', 'error' - if ($.pendingRetry) { - // if pending retry then that's effectively the same as actively uploading, - // there might just be a slight delay before the retry starts - return "uploading"; - } else if ($.markComplete) { - return "success"; - } else if (!$.xhr) { - return "pending"; - } else if ($.xhr.readyState < 4) { - // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening - return "uploading"; - } else { - if ($.xhr.status == 200 || $.xhr.status == 201) { - // HTTP 200, 201 (created) - return "success"; - } else if ( - $h.contains($.getOpt("permanentErrors"), $.xhr.status) || - $.retries >= $.getOpt("maxChunkRetries") - ) { - // HTTP 400, 404, 409, 415, 500, 501 (permanent error) - return "error"; - } else { - // this should never happen, but we'll reset and queue a retry - // a likely case for this would be 503 service unavailable - $.abort(); - return "pending"; - } - } - }; - $.message = function () { - return $.xhr ? $.xhr.responseText : ""; - }; - $.progress = function (relative) { - if (typeof relative === "undefined") relative = false; - var factor = relative ? ($.endByte - $.startByte) / $.fileObjSize : 1; - if ($.pendingRetry) return 0; - if ((!$.xhr || !$.xhr.status) && !$.markComplete) factor *= 0.95; - var s = $.status(); - switch (s) { - case "success": - case "error": - return 1 * factor; - case "pending": - return 0 * factor; - default: - return ($.loaded / ($.endByte - $.startByte)) * factor; - } - }; - return this; - } - - // QUEUE - $.uploadNextChunk = function () { - var found = false; - - // In some cases (such as videos) it's really handy to upload the first - // and last chunk of a file quickly; this let's the server check the file's - // metadata and determine if there's even a point in continuing. - if ($.getOpt("prioritizeFirstAndLastChunk")) { - $h.each($.files, function (file) { - if (file.chunks.length && file.chunks[0].status() == "pending" && file.chunks[0].preprocessState === 0) { - file.chunks[0].send(); - found = true; - return false; - } - if ( - file.chunks.length > 1 && - file.chunks[file.chunks.length - 1].status() == "pending" && - file.chunks[file.chunks.length - 1].preprocessState === 0 - ) { - file.chunks[file.chunks.length - 1].send(); - found = true; - return false; - } - }); - if (found) return true; - } - - // Now, simply look for the next, best thing to upload - $h.each($.files, function (file) { - found = file.upload(); - if (found) return false; - }); - if (found) return true; - - // The are no more outstanding chunks to upload, check is everything is done - var outstanding = false; - $h.each($.files, function (file) { - if (!file.isComplete()) { - outstanding = true; - return false; - } - }); - if (!outstanding) { - // All chunks have been uploaded, complete - $.fire("complete"); - } - return false; - }; - - // PUBLIC METHODS FOR RESUMABLE.JS - $.assignBrowse = function (domNodes, isDirectory) { - if (typeof domNodes.length == "undefined") domNodes = [domNodes]; - $h.each(domNodes, function (domNode) { - var input; - if (domNode.tagName === "INPUT" && domNode.type === "file") { - input = domNode; - } else { - input = document.createElement("input"); - input.setAttribute("type", "file"); - input.style.display = "none"; - domNode.addEventListener( - "click", - function () { - input.style.opacity = 0; - input.style.display = "block"; - input.focus(); - input.click(); - input.style.display = "none"; - }, - false, - ); - domNode.appendChild(input); - } - var maxFiles = $.getOpt("maxFiles"); - if (typeof maxFiles === "undefined" || maxFiles != 1) { - input.setAttribute("multiple", "multiple"); - } else { - input.removeAttribute("multiple"); - } - if (isDirectory) { - input.setAttribute("webkitdirectory", "webkitdirectory"); - } else { - input.removeAttribute("webkitdirectory"); - } - var fileTypes = $.getOpt("fileType"); - if (typeof fileTypes !== "undefined" && fileTypes.length >= 1) { - input.setAttribute( - "accept", - fileTypes - .map(function (e) { - e = e.replace(/\s/g, "").toLowerCase(); - if (e.match(/^[^.][^/]+$/)) { - e = "." + e; - } - return e; - }) - .join(","), - ); - } else { - input.removeAttribute("accept"); - } - // When new files are added, simply append them to the overall list - input.addEventListener( - "change", - function (e) { - appendFilesFromFileList(e.target.files, e); - var clearInput = $.getOpt("clearInput"); - if (clearInput) { - e.target.value = ""; - } - }, - false, - ); - }); - }; - $.assignDrop = function (domNodes) { - if (typeof domNodes.length == "undefined") domNodes = [domNodes]; - - $h.each(domNodes, function (domNode) { - domNode.addEventListener("dragover", onDragOverEnter, false); - domNode.addEventListener("dragenter", onDragOverEnter, false); - domNode.addEventListener("dragleave", onDragLeave, false); - domNode.addEventListener("drop", onDrop, false); - }); - }; - $.unAssignDrop = function (domNodes) { - if (typeof domNodes.length == "undefined") domNodes = [domNodes]; - - $h.each(domNodes, function (domNode) { - domNode.removeEventListener("dragover", onDragOverEnter); - domNode.removeEventListener("dragenter", onDragOverEnter); - domNode.removeEventListener("dragleave", onDragLeave); - domNode.removeEventListener("drop", onDrop); - }); - }; - $.isUploading = function () { - var uploading = false; - $h.each($.files, function (file) { - if (file.isUploading()) { - uploading = true; - return false; - } - }); - return uploading; - }; - $.upload = function () { - // Make sure we don't start too many uploads at once - if ($.isUploading()) return; - // Kick off the queue - $.fire("uploadStart"); - for (var num = 1; num <= $.getOpt("simultaneousUploads"); num++) { - $.uploadNextChunk(); - } - }; - $.pause = function () { - // Resume all chunks currently being uploaded - $h.each($.files, function (file) { - file.abort(); - }); - $.fire("pause"); - }; - $.cancel = function () { - $.fire("beforeCancel"); - for (var i = $.files.length - 1; i >= 0; i--) { - $.files[i].cancel(); - } - $.fire("cancel"); - }; - $.progress = function () { - var totalDone = 0; - var totalSize = 0; - // Resume all chunks currently being uploaded - $h.each($.files, function (file) { - totalDone += file.progress() * file.size; - totalSize += file.size; - }); - return totalSize > 0 ? totalDone / totalSize : 0; - }; - $.addFile = function (file, event) { - appendFilesFromFileList([file], event); - }; - $.addFiles = function (files, event) { - appendFilesFromFileList(files, event); - }; - $.removeFile = function (file) { - for (var i = $.files.length - 1; i >= 0; i--) { - if ($.files[i] === file) { - $.files.splice(i, 1); - } - } - }; - $.getFromUniqueIdentifier = function (uniqueIdentifier) { - var ret = false; - $h.each($.files, function (f) { - if (f.uniqueIdentifier == uniqueIdentifier) ret = f; - }); - return ret; - }; - $.getSize = function () { - var totalSize = 0; - $h.each($.files, function (file) { - totalSize += file.size; - }); - return totalSize; - }; - $.handleDropEvent = function (e) { - onDrop(e); - }; - $.handleChangeEvent = function (e) { - appendFilesFromFileList(e.target.files, e); - e.target.value = ""; - }; - $.updateQuery = function (query) { - $.opts.query = query; - }; - - return this; - }; - - // Node.js-style export for Node and Component - if (typeof module != "undefined") { - // left here for backwards compatibility - module.exports = Resumable; - module.exports.Resumable = Resumable; - } else if (typeof define === "function" && define.amd) { - // AMD/requirejs: Define the module - define(function () { - return Resumable; - }); - } else { - // Browser: Expose to window - window.Resumable = Resumable; - } -})(); diff --git a/client/src/jsconfig.json b/client/src/jsconfig.json deleted file mode 100644 index 2f547e4..0000000 --- a/client/src/jsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "baseUrl": ".", - "paths": { - "@wp/pages": ["./pages/_index.js"], - "@wp/components": ["./pages/components/_index.js"], - "@wp/env/*": ["./env/*"], - "@wp/lib/*": ["./lib/*"], - "@wp/js/*": ["./js/*"], - "@wp/css/*": ["./css/*"], - "@wp/assets/*": ["./assets/*"], - "@wp/api": ["./api/_index.js"], - "@wp/utils": ["./utils/_index.js"] - }, - "target": "es6", - "module": "es6", - "lib": ["dom", "dom.iterable"], - "moduleResolution": "node", - "allowSyntheticDefaultImports": true - } -} diff --git a/client/src/lib/_index.js b/client/src/lib/_index.js deleted file mode 100644 index 4e67407..0000000 --- a/client/src/lib/_index.js +++ /dev/null @@ -1 +0,0 @@ -export { validateImportKey } from "./imports/_index.js"; diff --git a/client/src/lib/api/error.ts b/client/src/lib/api/error.ts new file mode 100644 index 0000000..da2ac38 --- /dev/null +++ b/client/src/lib/api/error.ts @@ -0,0 +1,33 @@ +import { InvalidErrorType } from "#lib/errors"; + +interface IAPIError extends Error { + request: Request; + response: Response; +} + +interface IAPIErrorOptions extends ErrorOptions { + request: Request; + response: Response; +} + +export class APIError extends Error implements IAPIError { + request: Request; + response: Response; + + constructor(message: string, options: IAPIErrorOptions) { + super(message); + + this.request = options.request; + this.response = options.response; + } +} + +export function isAPIError(input: unknown): input is APIError { + return input instanceof APIError; +} + +export function ensureAPIError(input: unknown): asserts input is APIError { + if (!isAPIError(input)) { + throw new InvalidErrorType(input); + } +} diff --git a/client/src/lib/api/index.ts b/client/src/lib/api/index.ts new file mode 100644 index 0000000..65724d6 --- /dev/null +++ b/client/src/lib/api/index.ts @@ -0,0 +1 @@ +export { APIError, ensureAPIError, isAPIError } from "./error"; diff --git a/client/src/lib/errors/error.ts b/client/src/lib/errors/error.ts new file mode 100644 index 0000000..7527883 --- /dev/null +++ b/client/src/lib/errors/error.ts @@ -0,0 +1,11 @@ +import { InvalidErrorType } from "./invalid"; + +export function isError(input: unknown): input is Error { + return input instanceof Error; +} + +export function ensureError(input: unknown): asserts input is Error { + if (!isError(input)) { + throw new InvalidErrorType(input); + } +} diff --git a/client/src/lib/errors/index.ts b/client/src/lib/errors/index.ts new file mode 100644 index 0000000..4b08512 --- /dev/null +++ b/client/src/lib/errors/index.ts @@ -0,0 +1,2 @@ +export { isError, ensureError } from "./error"; +export { InvalidErrorType } from "./invalid"; diff --git a/client/src/lib/errors/invalid.ts b/client/src/lib/errors/invalid.ts new file mode 100644 index 0000000..4473fe5 --- /dev/null +++ b/client/src/lib/errors/invalid.ts @@ -0,0 +1,16 @@ +interface IInvalidErrorType extends Error { + payload: unknown; +} + +/** + * An error for when the value thrown is not a subclass of `Error` class. + */ +export class InvalidErrorType extends Error implements IInvalidErrorType { + payload: unknown; + + constructor(payload: unknown) { + super("Invalid input error type."); + + this.payload = payload; + } +} diff --git a/client/src/lib/http/index.ts b/client/src/lib/http/index.ts new file mode 100644 index 0000000..45525f7 --- /dev/null +++ b/client/src/lib/http/index.ts @@ -0,0 +1 @@ +export { HTTP_STATUS } from "./status"; diff --git a/client/src/lib/http/status.ts b/client/src/lib/http/status.ts new file mode 100644 index 0000000..e20801f --- /dev/null +++ b/client/src/lib/http/status.ts @@ -0,0 +1,66 @@ +export const HTTP_STATUS = { + /** + * The request succeeded. The result and meaning of "success" depends on the HTTP method: + * + * `GET`: The resource has been fetched and transmitted in the message body. + * + * `HEAD`: Representation headers are included in the response without any message body. + * + * `PUT` or `POST`: The resource describing the result of the action is transmitted in the message body. + * + * `TRACE`: The message body contains the request as received by the server. + */ + OK: 200, + /** + * The URL of the requested resource has been changed permanently. The new URL is given in the response. + */ + MOVED_PERMANENTLY: 301, + /** + * This response code means that the URI of requested resource has been changed temporarily. Further changes in the URI might be made in the future, so the same URI should be used by the client in future requests. + */ + FOUND: 302, + /** + * The server sent this response to direct the client to get the requested resource at another URI with a GET request. + */ + SEE_OTHER: 303, + /** + * The server sends this response to direct the client to get the requested resource at another URI with the same method that was used in the prior request. This has the same semantics as the 302 Found response code, with the exception that the user agent must not change the HTTP method used: if a POST was used in the first request, a POST must be used in the redirected request. + */ + TEMPORARY_REDIRECT: 307, + /** + * This means that the resource is now permanently located at another URI, specified by the Location response header. This has the same semantics as the 301 Moved Permanently HTTP response code, with the exception that the user agent must not change the HTTP method used: if a POST was used in the first request, a POST must be used in the second request. + */ + PERMANENT_REDIRECT: 308, + /** + * The server cannot or will not process the request due to something that is perceived to be a client error (e.g., malformed request syntax, invalid request message framing, or deceptive request routing). + */ + BAD_REQUEST: 400, + /** + * Although the HTTP standard specifies "unauthorized", semantically this response means "unauthenticated". That is, the client must authenticate itself to get the requested response. + */ + UNAUTHORIZED: 401, + /** + * The client does not have access rights to the content; that is, it is unauthorized, so the server is refusing to give the requested resource. Unlike 401 Unauthorized, the client's identity is known to the server. + */ + FORBIDDEN: 403, + /** + * The server cannot find the requested resource. In the browser, this means the URL is not recognized. In an API, this can also mean that the endpoint is valid but the resource itself does not exist. Servers may also send this response instead of 403 Forbidden to hide the existence of a resource from an unauthorized client. This response code is probably the most well known due to its frequent occurrence on the web. + */ + NOT_FOUND: 404, + /** + * This response is sent when a request conflicts with the current state of the server. In WebDAV remote web authoring, 409 responses are errors sent to the client so that a user might be able to resolve a conflict and resubmit the request. + */ + CONFLICT: 409, + /** + * The request was well-formed but was unable to be followed due to semantic errors. + */ + UNPROCESSABLE_CONTENT: 422, + /** + * The server has encountered a situation it does not know how to handle. This error is generic, indicating that the server cannot find a more appropriate 5XX status code to respond with. + */ + INTERNAL_SERVER_ERROR: 500, + /** + * The server is not ready to handle the request. Common causes are a server that is down for maintenance or that is overloaded. Note that together with this response, a user-friendly page explaining the problem should be sent. This response should be used for temporary conditions and the Retry-After HTTP header should, if possible, contain the estimated time before the recovery of the service. The webmaster must also take care about the caching-related headers that are sent along with this response, as these temporary condition responses should usually not be cached. + */ + SERVICE_UNAVAILABLE: 503, +} as const; diff --git a/client/src/lib/imports/lib.js b/client/src/lib/imports/lib.js deleted file mode 100644 index e2be172..0000000 --- a/client/src/lib/imports/lib.js +++ /dev/null @@ -1,148 +0,0 @@ -import { isLowerCase } from "@wp/utils"; - -/** - * @typedef ValidationResult - * @property {boolean} isValid - * @property {string[]} [errors] - * @property {any} [result] A modified result, if any. - */ - -/** - * @callback KeyValidator - * @param {string} key - * @param {string[]} errors - * @returns {string[]} An array of error messages, if any. - */ - -const maxLength = 1024; - -/** - * @type {Record} - */ -const serviceConstraints = { - patreon: patreonKey, - fanbox: fanboxKey, - gumroad: gumroadKey, - subscribestar: subscribestarKey, - dlsite: dlsiteKey, - discord: discordKey, - fantia: fantiaKey, -}; - -/** - * Validates the key according to these rules: - * - Trim spaces from both sides. - * @param {string} key - * @param {string} service - * @returns {ValidationResult} - */ -export function validateImportKey(key, service) { - const formattedKey = key.trim(); - const errors = serviceConstraints[service](key, []); - - return { - isValid: !errors.length, - errors, - result: formattedKey, - }; -} - -/** - * @type KeyValidator - */ -function patreonKey(key, errors) { - const reqLength = 43; - if (key.length !== reqLength) { - errors.push(`The key length of "${key.length}" is not a valid Patreon key. Required length: "${reqLength}".`); - } - - return errors; -} - -/** - * @type KeyValidator - */ -function fanboxKey(key, errors) { - const pattern = /^\d+_\w+$/i; - - if (key.length > maxLength) { - errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`); - } - - if (!key.match(pattern)) { - errors.push(`The key doesn't match the required pattern of "${String(pattern)}"`); - } - - return errors; -} - -/** - * @type KeyValidator - */ -function fantiaKey(key, errors) { - const reqLengths = [32, 64]; - - if (reqLengths.map((reqLength) => key.length !== reqLength).every((v) => v === false)) { - errors.push( - `The key length of "${key.length}" is not a valid Fantia key. ` + `Accepted lengths: ${reqLengths.join(", ")}.`, - ); - } - - if (!isLowerCase(key)) { - errors.push(`The key is not in lower case.`); - } - - return errors; -} - -/** - * @type KeyValidator - */ -function gumroadKey(key, errors) { - const minLength = 200; - - if (key.length < minLength) { - errors.push(`The key length of "${key.length}" is less than minimum required "${minLength}".`); - } - - if (key.length > maxLength) { - errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`); - } - - return errors; -} - -/** - * @type KeyValidator - */ -function subscribestarKey(key, errors) { - if (key.length > maxLength) { - errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`); - } - - return errors; -} - -/** - * @type KeyValidator - */ -function dlsiteKey(key, errors) { - if (key.length > maxLength) { - errors.push(`The key length of "${key.length}" is over the maximum of "${maxLength}".`); - } - - return errors; -} - -/** - * @type KeyValidator - */ -function discordKey(key, errors) { - const pattern = /(mfa.[a-z0-9_-]{20,})|([a-z0-9_-]{23,28}.[a-z0-9_-]{6,7}.[a-z0-9_-]{27})/i; - - if (!key.match(pattern)) { - errors.push(`The key doesn't match the required pattern of "${String(pattern)}".`); - } - - return errors; -} diff --git a/client/src/lib/pagination/index.ts b/client/src/lib/pagination/index.ts new file mode 100644 index 0000000..fd45929 --- /dev/null +++ b/client/src/lib/pagination/index.ts @@ -0,0 +1,2 @@ +export { PAGINATION_LIMIT, parseOffset } from "./lib"; +export type { IPagination } from "./types"; diff --git a/client/src/lib/pagination/lib.ts b/client/src/lib/pagination/lib.ts new file mode 100644 index 0000000..f274a1e --- /dev/null +++ b/client/src/lib/pagination/lib.ts @@ -0,0 +1,12 @@ +export const PAGINATION_LIMIT = 50; + +export function parseOffset(offset: string | number, limit = PAGINATION_LIMIT) { + const parsedOffset = + typeof offset === "number" ? offset : parseInt(offset.trim(), 10); + + if (parsedOffset % limit !== 0) { + throw new Error(`Offset ${offset} is not a multiple of ${limit}.`); + } + + return parsedOffset; +} diff --git a/client/src/lib/pagination/types.ts b/client/src/lib/pagination/types.ts new file mode 100644 index 0000000..7294846 --- /dev/null +++ b/client/src/lib/pagination/types.ts @@ -0,0 +1,9 @@ +export interface IPagination { + current_page: number; + limit: number; + base: unknown; + offset: number; + count: number; + current_count: number; + total_pages: number; +} diff --git a/client/src/lib/range/index.ts b/client/src/lib/range/index.ts new file mode 100644 index 0000000..e933303 --- /dev/null +++ b/client/src/lib/range/index.ts @@ -0,0 +1,22 @@ +/** + * Python [`range()`](https://docs.python.org/3/library/functions.html#func-range) but in javascript. + */ +export function createRange(start: number, stop: number, step = 1) { + if (step === 0) { + throw new RangeError("Step must not be equal to zero."); + } + + const length = stop - start; + let currentValue = start; + // running `Array.fill()` because I don't remember off top of my head + // if `Array.map()` iterates over sparse values or not + const range = new Array(length).fill(null).map(() => { + const oldCurrentValue = currentValue; + + currentValue = currentValue + step; + + return oldCurrentValue; + }); + + return range; +} diff --git a/client/src/lib/types/index.ts b/client/src/lib/types/index.ts new file mode 100644 index 0000000..f7595db --- /dev/null +++ b/client/src/lib/types/index.ts @@ -0,0 +1,7 @@ +/** + * Stolen from [StackOverflow answer](https://stackoverflow.com/questions/46376468/how-to-get-type-of-array-items). + */ +export type ElementType> = + ContainerType extends Iterable ? ElementType : never; + +export type StrictOmit = Omit; diff --git a/client/src/lib/urls/account.ts b/client/src/lib/urls/account.ts new file mode 100644 index 0000000..2651147 --- /dev/null +++ b/client/src/lib/urls/account.ts @@ -0,0 +1,94 @@ +import { InternalURL } from "./internal-url"; + +export function createAccountPageURL() { + const path = "/account"; + + return new InternalURL(path); +} + +export function createModeratorPageURL() { + const path = `/account/moderator`; + + return new InternalURL(path); +} + +export function createAdministratorPageURL() { + const path = `/account/administrator`; + + return new InternalURL(path); +} + +export function createAccountNotificationsPageURL() { + const path = `/account/notifications`; + + return new InternalURL(path); +} + +export function createAccountImportKeysPageURL() { + const path = `/account/keys`; + + return new InternalURL(path); +} + +export function createAccountDMsReviewPageURL(status?: string) { + const path = `/account/review_dms`; + const params = new URLSearchParams(); + + if (status) { + params.set("status", status); + } + + return new InternalURL(path, params); +} + +export function createAccountPasswordChangePageURL() { + const path = `/account/change_password`; + + return new InternalURL(path); +} + +export function createAccountFavoriteProfilesPageURL( + offset?: number, + sortBy?: string, + order?: string +) { + const path = "/account/favorites/artists"; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + if (sortBy) { + params.set("sort_by", sortBy); + } + + if (order) { + params.set("order", order); + } + + return new InternalURL(path, params); +} + +export function createAccountFavoritePostsPageURL( + offset?: number, + sortBy?: string, + order?: string +) { + const path = "/account/favorites/posts"; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + if (sortBy) { + params.set("sort_by", sortBy); + } + + if (order) { + params.set("order", order); + } + + return new InternalURL(path, params); +} diff --git a/client/src/lib/urls/administrator.ts b/client/src/lib/urls/administrator.ts new file mode 100644 index 0000000..c278eb9 --- /dev/null +++ b/client/src/lib/urls/administrator.ts @@ -0,0 +1,36 @@ +import { IAccount } from "#entities/account"; +import { InternalURL } from "./internal-url"; + +export function createAccountsPageURL( + page?: number, + name?: string, + role?: string, + limit?: number +) { + const path = `/account/administrator/accounts`; + const params = new URLSearchParams(); + + if (page) { + params.set("page", String(page)); + } + + if (name) { + params.set("name", name); + } + + if (role) { + params.set("role", role); + } + + if (limit) { + params.set("limit", String(limit)); + } + + return new InternalURL(path); +} + +export function createAccountDetailsPageURL(id: IAccount["id"]) { + const path = `/account/administrator/accounts/${id}`; + + return new InternalURL(path); +} diff --git a/client/src/lib/urls/artists.ts b/client/src/lib/urls/artists.ts new file mode 100644 index 0000000..e659358 --- /dev/null +++ b/client/src/lib/urls/artists.ts @@ -0,0 +1,45 @@ +import { InternalURL } from "./internal-url"; + +export function createArtistsPageURL( + offset?: number, + query?: string, + service?: string, + sortBy?: string, + order?: string +): InternalURL { + const path = "/artists"; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + if (query) { + params.set("q", query); + } + + if (service) { + params.set("service", service); + } + + if (sortBy) { + params.set("sort_by", sortBy); + } + + if (order) { + params.set("order", order); + } + + return new InternalURL(path, params); +} + +export function createArtistsUpdatedPageURL(offset?: number): InternalURL { + const path = "/artists/updated"; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + return new InternalURL(path, params); +} diff --git a/client/src/lib/urls/authentication.ts b/client/src/lib/urls/authentication.ts new file mode 100644 index 0000000..2c5cdfc --- /dev/null +++ b/client/src/lib/urls/authentication.ts @@ -0,0 +1,21 @@ +import { InternalURL } from "./internal-url"; + +export function createRegistrationPageURL(location: string) { + const path = `/authentication/register`; + const params = new URLSearchParams([["location", location]]); + + return new InternalURL(path, params); +} + +export function createLoginPageURL(location: string) { + const path = `/authentication/login`; + const params = new URLSearchParams([["location", location]]); + + return new InternalURL(path, params); +} + +export function createLogoutPageURL() { + const path = `/authentication/logout`; + + return new InternalURL(path); +} diff --git a/client/src/lib/urls/dms.ts b/client/src/lib/urls/dms.ts new file mode 100644 index 0000000..78e5e10 --- /dev/null +++ b/client/src/lib/urls/dms.ts @@ -0,0 +1,16 @@ +import { InternalURL } from "./internal-url"; + +export function createDMsPageURL(offset?: number, query?: string) { + const path = "/dms"; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + if (query) { + params.set("q", String(query)); + } + + return new InternalURL(path, params); +} diff --git a/client/src/lib/urls/documentation.ts b/client/src/lib/urls/documentation.ts new file mode 100644 index 0000000..d758e8b --- /dev/null +++ b/client/src/lib/urls/documentation.ts @@ -0,0 +1,7 @@ +import { InternalURL } from "./internal-url"; + +export function createAPIDocumentationPageURL() { + const path = `/documentation/api`; + + return new InternalURL(path); +} diff --git a/client/src/lib/urls/files.ts b/client/src/lib/urls/files.ts new file mode 100644 index 0000000..d0afbf8 --- /dev/null +++ b/client/src/lib/urls/files.ts @@ -0,0 +1,40 @@ +import { InternalURL } from "./internal-url"; + +export function createFileURL(hash: string, extension: string) { + const path = `/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}.${extension}`; + + return new InternalURL(path); +} + +export function createThumbnailURL(filePath: string) { + const path = `/thumbnail/data${filePath}`; + + return new InternalURL(path); +} + +export function createArchiveFileURL( + archiveHash: string, + archiveExtension: string, + fileName: string, + password?: string +) { + const path = `/archive_files/${archiveHash}${archiveExtension}`; + const params = new URLSearchParams([["file_name", fileName]]); + + if (password) { + params.set("password", password); + } + + return new InternalURL(path, params); +} + +export function createFileSearchPageURL(hash?: string) { + const path = `/search_hash`; + const params = new URLSearchParams(); + + if (hash) { + params.set("hash", hash); + } + + return new InternalURL(path, params); +} diff --git a/client/src/lib/urls/importer.ts b/client/src/lib/urls/importer.ts new file mode 100644 index 0000000..3ad1514 --- /dev/null +++ b/client/src/lib/urls/importer.ts @@ -0,0 +1,7 @@ +import { InternalURL } from "./internal-url"; + +export function createImporterStatusPageURL(importID: string) { + const path = `/importer/status/${importID}`; + + return new InternalURL(path); +} diff --git a/client/src/lib/urls/index.ts b/client/src/lib/urls/index.ts new file mode 100644 index 0000000..72abc1b --- /dev/null +++ b/client/src/lib/urls/index.ts @@ -0,0 +1,58 @@ +export { createIconURL, createBannerURL } from "./kemono"; +export { + createProfilePageURL, + createProfileFancardsURL, + createProfileAnnouncementsURL, + createProfileTagsURL, + createProfileTagURL, + createProfileDMsURL, + createProfileSharesURL, + createProfileLinksURL, + createProfileNewLinksPageURL, + createProfilesSharesPageURL, + createDiscordChannelPageURL, + createDiscordServerPageURL, + createProfileTagsPageURL +} from "./profiles"; +export { createArtistsPageURL, createArtistsUpdatedPageURL } from "./artists"; +export { + createPostsPageURL, + createPostURL, + createPostRevisionPageURL, + createFileUploadPageURL, + createAttachmentURL, + createPreviewURL, + createPopularPostsPageURL, +} from "./posts"; +export { createTagPageURL } from "./tags"; +export { createSharePageURL, createSharesPageURL } from "./shares"; +export { createDMsPageURL } from "./dms"; +export { + createRegistrationPageURL, + createLoginPageURL, + createLogoutPageURL, +} from "./authentication"; +export { + createAccountPageURL, + createModeratorPageURL, + createAdministratorPageURL, + createAccountNotificationsPageURL, + createAccountImportKeysPageURL, + createAccountDMsReviewPageURL, + createAccountPasswordChangePageURL, + createAccountFavoriteProfilesPageURL, + createAccountFavoritePostsPageURL, +} from "./account"; +export { + createFileURL, + createThumbnailURL, + createArchiveFileURL, + createFileSearchPageURL, +} from "./files"; +export { createImporterStatusPageURL } from "./importer"; +export { + createAccountsPageURL, + createAccountDetailsPageURL, +} from "./administrator"; +export { createProfileLinkRequestsPageURL } from "./moderator"; +export { createAPIDocumentationPageURL } from "./documentation"; diff --git a/client/src/lib/urls/internal-url.ts b/client/src/lib/urls/internal-url.ts new file mode 100644 index 0000000..c95d109 --- /dev/null +++ b/client/src/lib/urls/internal-url.ts @@ -0,0 +1,40 @@ +export class InternalURL extends URL { + constructor( + pathname: string, + searchParams?: URLSearchParams, + fragment?: string + ) { + // `URL` constructor requires a full origin + // to be present in either of arguments + // but in the context of DOM elements the relative URL is just fine + // so we are doing some gymnastics in order not to depend + // on browser context (does not exist on server) + // or an env variable (not needed if the origin is the same). + super(pathname, "https://example.com"); + + if (searchParams && searchParams.size !== 0) { + this.search = searchParams.toString(); + } + + if (fragment) { + this.hash = fragment; + } + } + + toString(): string { + const isParams = this.searchParams.size !== 0; + + if (isParams) { + this.searchParams.sort(); + } + + const params = !isParams ? "" : this.search; + const fragment = this.hash.slice(1).length === 0 ? "" : this.hash; + + return `${this.pathname}${params}${fragment}`; + } + + toJSON(): string { + return this.toString(); + } +} diff --git a/client/src/lib/urls/kemono.ts b/client/src/lib/urls/kemono.ts new file mode 100644 index 0000000..bab3423 --- /dev/null +++ b/client/src/lib/urls/kemono.ts @@ -0,0 +1,13 @@ +import { BANNERS_PREPEND, ICONS_PREPEND } from "#env/env-vars"; + +export function createIconURL(service: string, artistID: string) { + const path = `${ICONS_PREPEND}/icons/${service}/${artistID}`; + + return path; +} + +export function createBannerURL(service: string, artistID: string) { + const path = `${BANNERS_PREPEND}/banners/${service}/${artistID}`; + + return path; +} diff --git a/client/src/lib/urls/moderator.ts b/client/src/lib/urls/moderator.ts new file mode 100644 index 0000000..a148d57 --- /dev/null +++ b/client/src/lib/urls/moderator.ts @@ -0,0 +1,7 @@ +import { InternalURL } from "./internal-url"; + +export function createProfileLinkRequestsPageURL() { + const path = `/account/moderator/tasks/creator_links`; + + return new InternalURL(path); +} diff --git a/client/src/lib/urls/posts.ts b/client/src/lib/urls/posts.ts new file mode 100644 index 0000000..6bc367b --- /dev/null +++ b/client/src/lib/urls/posts.ts @@ -0,0 +1,113 @@ +import { IPopularPostsPeriod } from "#entities/posts"; +import { InternalURL } from "./internal-url"; + +export function createPostsPageURL( + offset?: number, + query?: string, + tags?: string[] +) { + const path = `posts`; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + if (query) { + params.set("q", query); + } + + if (tags) { + for (const tag of tags) { + params.set("tag", tag); + } + } + + return new InternalURL(path, params); +} + +export function createPostURL( + service: string, + profileID: string, + postID: string +) { + const path = `/${service}/user/${profileID}/post/${postID}`; + + return new InternalURL(path); +} + +export function createPostRevisionPageURL( + service: string, + profileID: string, + postID: string, + revisionID: string +) { + const path = `/${service}/user/${profileID}/post/${postID}/revision/${revisionID}`; + + return new InternalURL(path); +} + +export function createFileUploadPageURL(service: string, profileID: string) { + const path = "/posts/upload"; + const searchParams = new URLSearchParams([ + ["service", service], + ["user", profileID], + ]); + + return new InternalURL(path, searchParams); +} + +export function createAttachmentURL( + path: string, + name: string, + server?: string +) { + const pathname = `/data${path}`; + const params = new URLSearchParams([["f", name]]); + + if (server) { + const url = new URL(pathname, server); + url.search = String(params); + + return url; + } + + return new InternalURL(pathname, params); +} + +export function createPreviewURL(path: string, name: string, server?: string) { + const pathname = `/data${path}`; + const params = new URLSearchParams([["f", name]]); + + if (server) { + const url = new URL(pathname, server); + url.search = String(params); + + return url; + } + + return new InternalURL(pathname, params); +} + +export function createPopularPostsPageURL( + date?: string, + scale?: IPopularPostsPeriod, + offset?: number +) { + const path = `/posts/popular`; + const params = new URLSearchParams(); + + if (date) { + params.set("date", date); + } + + if (scale) { + params.set("period", scale); + } + + if (offset) { + params.set("o", String(offset)); + } + + return new InternalURL(path, params); +} diff --git a/client/src/lib/urls/profiles.ts b/client/src/lib/urls/profiles.ts new file mode 100644 index 0000000..b6c40ed --- /dev/null +++ b/client/src/lib/urls/profiles.ts @@ -0,0 +1,126 @@ +import { InternalURL } from "./internal-url"; + +interface ICreateProfileURLArg { + service: string; + profileID: string; + offset?: number; + query?: string; + tags?: string[]; +} + +export function createProfilePageURL({ + service, + profileID, + offset, + query, + tags, +}: ICreateProfileURLArg) { + const segment = service === "discord" ? "server" : "user"; + const path = `/${service}/${segment}/${profileID}`; + const searchParams = new URLSearchParams(); + + if (offset) { + searchParams.set("o", String(offset)); + } + + if (query) { + searchParams.set("q", query); + } + + if (tags) { + for (const tag of tags) { + searchParams.set("tag", tag); + } + } + + return new InternalURL(path, searchParams); +} + +export function createProfileFancardsURL(service: string, profileID: string) { + const path = `/${service}/user/${profileID}/fancards`; + + return new InternalURL(path); +} + +export function createProfileAnnouncementsURL( + service: string, + profileID: string +) { + return new InternalURL(`/${service}/user/${profileID}/announcements`); +} + +export function createProfileTagsURL(service: string, profileID: string) { + return new InternalURL(`/${service}/user/${profileID}/tags`); +} + +export function createProfileTagURL( + service: string, + profileID: string, + tag: string +) { + const path = `/${service}/user/${profileID}`; + const params = new URLSearchParams([["tag", tag]]); + + return new InternalURL(path, params); +} + +export function createProfileDMsURL(service: string, profileID: string) { + return new InternalURL(`/${service}/user/${profileID}/dms`); +} + +export function createProfileSharesURL(service: string, profileID: string) { + return new InternalURL(`/${service}/user/${profileID}/shares`); +} + +export function createProfileLinksURL(service: string, profileID: string) { + return new InternalURL(`/${service}/user/${profileID}/links`); +} + +export function createProfileNewLinksPageURL( + service: string, + profileID: string +) { + return new InternalURL(`/account/${service}/user/${profileID}/links/new`); +} + +export function createProfilesSharesPageURL( + service: string, + profileID: string, + offset?: number +) { + const path = `/${service}/user/${profileID}/shares`; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + return new InternalURL(path, params); +} + +export function createProfileTagsPageURL(service: string, profileID: string) { + const path = `/${service}/user/${profileID}/tags`; + + return new InternalURL(path) +} + +export function createDiscordServerPageURL(serverID: string) { + const path = `/discord/server/${serverID}`; + + return new InternalURL(path); +} + +export function createDiscordChannelPageURL( + serverID: string, + channelID: string, + offset?: number +) { + const path = `/discord/server/${serverID}/${channelID}`; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + return new InternalURL(path, params); +} diff --git a/client/src/lib/urls/shares.ts b/client/src/lib/urls/shares.ts new file mode 100644 index 0000000..5378fd6 --- /dev/null +++ b/client/src/lib/urls/shares.ts @@ -0,0 +1,21 @@ +import { IShare } from "#entities/files"; +import { InternalURL } from "./internal-url"; + +export function createSharesPageURL( + offset?: number +) { + const path = `/shares`; + const params = new URLSearchParams(); + + if (offset) { + params.set("o", String(offset)); + } + + return new InternalURL(path, params); +} + +export function createSharePageURL(shareID: IShare["id"]) { + const path = `/share/${shareID}`; + + return new InternalURL(path); +} diff --git a/client/src/lib/urls/tags.ts b/client/src/lib/urls/tags.ts new file mode 100644 index 0000000..d9d6992 --- /dev/null +++ b/client/src/lib/urls/tags.ts @@ -0,0 +1,8 @@ +import { InternalURL } from "./internal-url"; + +export function createTagPageURL(tag: string) { + const path = "/posts"; + const searchParams = new URLSearchParams([["tag", tag]]); + + return new InternalURL(path, searchParams); +} diff --git a/client/src/pages/2257.tsx b/client/src/pages/2257.tsx new file mode 100644 index 0000000..131b614 --- /dev/null +++ b/client/src/pages/2257.tsx @@ -0,0 +1,49 @@ +import { PageSkeleton } from "#components/pages"; + +export function Compliance2257Page() { + const title = "18 U.S.C. 2257 Compliance Statement"; + const heading = "18 U.S.C. 2257 Compliance Statement"; + + return ( + +

    + Kemono is not a producer (whether primary or secondary as defined in 18 + U.S.C. 2257) of any of the content found on this website. The website's + activities, with respect to such content, are limited to the + transmission, storage, retrieval, and/or hosting of content on behalf of + third party users. +

    + +

    + Please direct any requests you may have regarding 2257 records in + relation to any content found on Kemono directly to the respective + uploader, artist, or producer of said content. +

    + +

    + Kemono abides by the following procedures regarding uploaded content to + ensure compliance: +

    + +
      +
    • + Requiring all users to be over 18 years old to use the site, register + an account, or upload content. +
    • +
    • + Prohibiting the upload of any photographs or videos of any real person + who is or appears to be under the age of 18. +
    • +
    • + Moderating all uploaded content and expeditiously removing any content + found to be in violation of these policies. +
    • +
    + +

    + For further assistance, or for any questions regarding this notice, + please contact us. +

    +
    + ); +} diff --git a/client/src/pages/_index.js b/client/src/pages/_index.js deleted file mode 100644 index 76948bd..0000000 --- a/client/src/pages/_index.js +++ /dev/null @@ -1,43 +0,0 @@ -import { userPage } from "./user"; -import { viewLinkedAccountsPage } from "./artist/linked_accounts.js"; -import { newLinkedAccountPage } from "./artist/new_linked_account.js"; -import { changePasswordPage, registerPage } from "./account/_index.js"; -import { postPage } from "./post"; -import { importerPage } from "./importer_list"; -import { importerStatusPage } from "./importer_status"; -import { postsPage } from "./posts"; -import { artistsPage } from "./artists"; -import { updatedPage } from "./updated"; -import { uploadPage } from "./upload"; -import { searchHashPage } from "./search_hash"; -import { registerPaginatorKeybinds } from "@wp/components"; -import { reviewDMsPage } from "./review_dms/dms"; -import { creatorLinksPage } from "./account/moderator/creator_links"; - -export { adminPageScripts } from "./account/administrator/_index.js"; -export { moderatorPageScripts } from "./account/moderator/_index.js"; -/** - * The map of page names and their callbacks. - */ -export const globalPageScripts = new Map([ - ["user", [userPage]], - ["register", [registerPage]], - ["change-password", [changePasswordPage]], - ["linked-account", [viewLinkedAccountsPage]], - ["new-linked-account", [newLinkedAccountPage]], - ["post", [postPage]], - ["importer", [importerPage]], - ["importer-status", [importerStatusPage]], - ["posts", [postsPage]], - ["popular-posts", [postsPage]], - ["artists", [artistsPage]], - ["updated", [updatedPage]], - ["upload", [uploadPage]], - ["all-dms", [registerPaginatorKeybinds]], - ["favorites", [registerPaginatorKeybinds]], - ["file-hash-search", [searchHashPage]], - ["review-dms", [reviewDMsPage]], - // trying to load moderator scripts by initSections(moderatorPageScripts) breaks this pile of junk - // so it's going here instead - ["moderator-creator-links", [creatorLinksPage]], -]); diff --git a/client/src/pages/_index.scss b/client/src/pages/_index.scss index 76d2a0e..e8843d3 100644 --- a/client/src/pages/_index.scss +++ b/client/src/pages/_index.scss @@ -1,12 +1,9 @@ -@use "components"; @use "home"; @use "post"; -@use "artist"; -@use "user"; +@use "profile/index"; +@use "profile.scss"; @use "review_dms"; @use "importer_status"; @use "posts"; -@use "favorites"; @use "account"; @use "upload"; -@use "tags"; diff --git a/client/src/pages/account/_index.js b/client/src/pages/account/_index.js deleted file mode 100644 index 43d0ac4..0000000 --- a/client/src/pages/account/_index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { registerPage } from "./register.js"; -export { changePasswordPage } from "./change_password.js"; diff --git a/client/src/pages/account/_index.scss b/client/src/pages/account/_index.scss index 78ca863..76df913 100644 --- a/client/src/pages/account/_index.scss +++ b/client/src/pages/account/_index.scss @@ -1,5 +1,5 @@ @use "home"; -@use "components"; @use "notifications"; @use "keys"; @use "moderator"; +@use "register"; diff --git a/client/src/pages/account/administrator/_index.js b/client/src/pages/account/administrator/_index.js deleted file mode 100644 index b892de3..0000000 --- a/client/src/pages/account/administrator/_index.js +++ /dev/null @@ -1,4 +0,0 @@ -/** - * @type {Map void>} - */ -export const adminPageScripts = new Map(); diff --git a/client/src/pages/account/administrator/_index.scss b/client/src/pages/account/administrator/_index.scss index 08e6287..240eaae 100644 --- a/client/src/pages/account/administrator/_index.scss +++ b/client/src/pages/account/administrator/_index.scss @@ -1,2 +1 @@ @use "accounts"; -@use "shell"; diff --git a/client/src/pages/account/administrator/account_files.html b/client/src/pages/account/administrator/account_files.html deleted file mode 100644 index f3ec702..0000000 --- a/client/src/pages/account/administrator/account_files.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends 'account/administrator/shell.html' %} - -{% block content %} - -{% endblock content %} diff --git a/client/src/pages/account/administrator/account_info.html b/client/src/pages/account/administrator/account_info.html deleted file mode 100644 index 3fccee2..0000000 --- a/client/src/pages/account/administrator/account_info.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends 'account/administrator/shell.html' %} - -{% from 'components/timestamp.html' import timestamp %} - -{% block content %} - -{% endblock content %} diff --git a/client/src/pages/account/administrator/accounts.html b/client/src/pages/account/administrator/accounts.html deleted file mode 100644 index 4043707..0000000 --- a/client/src/pages/account/administrator/accounts.html +++ /dev/null @@ -1,141 +0,0 @@ -{% extends 'account/administrator/shell.html' %} - -{% from 'components/card_list.html' import card_list %} -{% from 'components/cards/account.html' import account_card %} -{% from 'components/paginator_new.html' import paginator, paginator_controller %} - -{% block content %} -
    -
    -

    - Accounts -

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

    No accounts found.

    - {% endfor %} - {% endcall %} - {# {{ paginator('account-pages', request, props.pagination) }} #} - {% if props.accounts | length %} -
    - -
    - {% endif %} -
    - {{ paginator_controller( - 'account-pages', - request, - props.pagination - ) }} -
    -{% endblock content %} diff --git a/client/src/pages/account/administrator/accounts.tsx b/client/src/pages/account/administrator/accounts.tsx new file mode 100644 index 0000000..53647ee --- /dev/null +++ b/client/src/pages/account/administrator/accounts.tsx @@ -0,0 +1,243 @@ +import { + ActionFunctionArgs, + LoaderFunctionArgs, + redirect, + useLoaderData, +} from "react-router-dom"; +import clsx from "clsx"; +import { IPagination } from "#lib/pagination"; +import { createAccountsPageURL } from "#lib/urls"; +import { + fetchAccounts, + fetchChangeRolesOfAccounts, +} from "#api/account/administrator"; +import { FormRouter } from "#components/forms"; +import { Pagination, PaginationController } from "#components/pagination"; +import { CardList, AccountCard } from "#components/cards"; +import { PageSkeleton, createAccountPageLoader } from "#components/pages"; +import { IAccontRole, IAccount, accountRoles } from "#entities/account"; + +interface IProps { + name?: string; + role?: IRole; + roleList: IAccontRole[]; + pagination: IPagination; + accounts: IAccount[]; + limit?: number; +} + +type IRole = "all" | IAccontRole; + +const roleValues = ["all", ...accountRoles] as const satisfies IRole[]; + +function isRoleValue(input: unknown): input is IRole { + return roleValues.includes(input as IRole); +} + +export function AdministratorAccountsPage() { + const { name, role, roleList, pagination, limit, accounts } = + useLoaderData() as IProps; + const title = "Accounts"; + const heading = "Accounts"; + const formID = "account-pages"; + + return ( + + +
    + + +
    +
    + + +
    +
    + +
    +
    + + + + String(createAccountsPageURL(page, name, role, limit)) + } + /> + + + {accounts.length === 0 ? ( +

    No accounts found.

    + ) : ( + accounts.map((account) => + account.role === "moderator" ? ( +
    + + +
    + + +
    +
    + ) : account.role === "consumer" ? ( +
    + +
    + + +
    +
    + ) : undefined + ) + )} +
    + + {accounts.length !== 0 && ( +
    + +
    + )} +
    + + +
    + ); +} + +export const loader = createAccountPageLoader(async function loader({ + request, +}: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + + let page: number | undefined = undefined; + { + const inputValue = searchParams.get("page")?.trim(); + if (inputValue) { + page = Number.parseInt(inputValue, 10); + } + } + + const name = searchParams.get("name")?.trim(); + + let role: undefined | IRole; + { + const inputValue = searchParams.get("role")?.trim(); + if (inputValue) { + if (!isRoleValue(inputValue)) { + throw new Error(`Invalid role value "${inputValue}".`); + } + + role = inputValue; + } + } + + let limit: undefined | number = undefined; + { + const inputValue = searchParams.get("limit")?.trim(); + if (inputValue) { + limit = Number.parseInt(inputValue, 10); + } + } + + const { accounts, pagination, role_list } = await fetchAccounts( + page, + name, + role, + limit + ); + + return { + accounts, + pagination, + roleList: role_list, + name, + role, + limit, + }; +}); + +export async function action({ request }: ActionFunctionArgs) { + try { + if (request.method !== "POST") { + throw new Error(`Unknown method "${request.method}".`); + } + + const data = await request.formData(); + const moderators = data.getAll("moderator") as string[]; + const consumers = data.getAll("consumer") as string[]; + + await fetchChangeRolesOfAccounts(moderators, consumers); + + return redirect(String(createAccountsPageURL())); + } catch (error) { + return error; + } +} diff --git a/client/src/pages/account/administrator/dashboard.html b/client/src/pages/account/administrator/dashboard.html deleted file mode 100644 index 7ba801e..0000000 --- a/client/src/pages/account/administrator/dashboard.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends 'account/administrator/shell.html' %} - -{% block content %} -
    -
    -

    - Admin dashboard -

    -
    - -
    -{% endblock content %} diff --git a/client/src/pages/account/administrator/dashboard.tsx b/client/src/pages/account/administrator/dashboard.tsx new file mode 100644 index 0000000..e4008e7 --- /dev/null +++ b/client/src/pages/account/administrator/dashboard.tsx @@ -0,0 +1,20 @@ +import { createAccountPageLoader, PageSkeleton } from "#components/pages"; +import { KemonoLink } from "#components/links"; + +export function AdministratorDashboardPage() { + return ( + + + + ); +} + +export const loader = createAccountPageLoader(); diff --git a/client/src/pages/account/administrator/mods_actions.html b/client/src/pages/account/administrator/mods_actions.html deleted file mode 100644 index 44a8f89..0000000 --- a/client/src/pages/account/administrator/mods_actions.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends 'account/administrator/shell.html' %} - -{% block content %} -
    -
    -

    - Moderator actions -

    -
    -
      - {% for action in props.actions %} -
    • action
    • - {% else %} -
    • No actions found
    • - {% endfor %} -
    -
    -{% endblock content %} diff --git a/client/src/pages/account/administrator/shell.html b/client/src/pages/account/administrator/shell.html deleted file mode 100644 index 8a8724f..0000000 --- a/client/src/pages/account/administrator/shell.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends 'components/shell.html' %} - -{# TODO: filter only admin entry #} -{% block bundler_output %} - <% for (const css in htmlWebpackPlugin.files.css) { %> - <% if (htmlWebpackPlugin.files.css[css].startsWith("/static/bundle/css/admin")) { %> - - <% } %> - <% } %> - <% for (const chunk in htmlWebpackPlugin.files.chunks) { %> - - <% } %> - <% for (const scriptPath in htmlWebpackPlugin.files.js) { %> - <% if (htmlWebpackPlugin.files.js[scriptPath].startsWith("/static/bundle/js/admin") || htmlWebpackPlugin.files.js[scriptPath].startsWith("/static/bundle/js/runtime") || htmlWebpackPlugin.files.js[scriptPath].startsWith("/static/bundle/js/vendors")) { %> - - <% } %> - <% } %> -{% endblock bundler_output %} diff --git a/client/src/pages/account/administrator/shell.scss b/client/src/pages/account/administrator/shell.scss deleted file mode 100644 index 0dc0330..0000000 --- a/client/src/pages/account/administrator/shell.scss +++ /dev/null @@ -1 +0,0 @@ -@use "../../components/shell"; diff --git a/client/src/pages/account/change_password.html b/client/src/pages/account/change_password.html deleted file mode 100644 index 5064837..0000000 --- a/client/src/pages/account/change_password.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends "components/shell.html" %} - -{% block content %} - -{% endblock %} diff --git a/client/src/pages/account/change_password.js b/client/src/pages/account/change_password.js deleted file mode 100644 index 5119ac6..0000000 --- a/client/src/pages/account/change_password.js +++ /dev/null @@ -1,45 +0,0 @@ -export function changePasswordPage() { - let passwordInput = () => document.getElementById("current-password"); - let newPasswordInput = () => document.getElementById("new-password"); - let newPasswordConfirmationInput = () => document.getElementById("new-password-confirmation"); - let submitButton = () => document.getElementById("submit"); - - let passCharCount = () => document.getElementById("password-char-count"); - let newPassCharCount = () => document.getElementById("new-password-char-count"); - let passMatches = () => document.getElementById("password-confirm-matches"); - - function doValidate(e) { - let password = passwordInput().value; - let newPassword = newPasswordInput().value; - let newPasswordConfirmation = newPasswordConfirmationInput().value; - let errors = false; - - if (password.length < 5) { - errors = true; - passCharCount().classList.add("invalid"); - } else { - passCharCount().classList.remove("invalid"); - } - - if (newPassword.length < 5) { - errors = true; - newPassCharCount().classList.add("invalid"); - } else { - newPassCharCount().classList.remove("invalid"); - } - - if (newPassword != newPasswordConfirmation || newPassword.length < 5) { - errors = true; - passMatches().classList.add("invalid"); - } else { - passMatches().classList.remove("invalid"); - } - - submitButton().disabled = errors; - } - - doValidate(); - passwordInput().addEventListener("input", doValidate); - newPasswordInput().addEventListener("input", doValidate); - newPasswordConfirmationInput().addEventListener("input", doValidate); -} diff --git a/client/src/pages/account/change_password.tsx b/client/src/pages/account/change_password.tsx new file mode 100644 index 0000000..4d89073 --- /dev/null +++ b/client/src/pages/account/change_password.tsx @@ -0,0 +1,116 @@ +import { ActionFunctionArgs, redirect } from "react-router-dom"; +import { createAccountPageURL } from "#lib/urls"; +import { PageSkeleton, createAccountPageLoader } from "#components/pages"; +import { FormRouter } from "#components/forms"; +import { fetchAccountChangePassword } from "#api/account"; + +export function AccountChangePasswordPage() { + const title = "Change password"; + const heading = "Change Password"; + + return ( + + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    +
    +
    + ); +} + +export const loader = createAccountPageLoader(); + +export async function action({ request }: ActionFunctionArgs) { + try { + if (request.method !== "POST") { + throw new Error(`Unknown method "${request.method}".`); + } + + const data = await request.formData(); + + const currentPassword = data.get("current-password") as string | null; + + if (!currentPassword) { + throw new Error("Password cannot be empty."); + } + + const newPassword = (data.get("new-password") as string | null)?.trim(); + + if (!newPassword) { + throw new Error("New password cannot be empty."); + } + + if (newPassword.length < 5) { + throw new Error("New password must have at least 5 characters."); + } + + const newPasswordConfirmation = ( + data.get("new-password-confirmation") as string | null + )?.trim(); + + if (newPassword !== newPasswordConfirmation) { + throw new Error("New password and confirmation do not match."); + } + + await fetchAccountChangePassword( + currentPassword, + newPassword, + newPasswordConfirmation + ); + + return redirect(String(createAccountPageURL())); + } catch (error) { + return error; + } +} diff --git a/client/src/pages/account/components/notification.html b/client/src/pages/account/components/notification.html deleted file mode 100644 index 81aaf48..0000000 --- a/client/src/pages/account/components/notification.html +++ /dev/null @@ -1,20 +0,0 @@ -{% from 'components/timestamp.html' import timestamp %} - -{% macro ACCOUNT_ROLE_CHANGE(extra_info) %} - Your role was changed from {{ extra_info.old_role }} to {{ extra_info.new_role }}. -{% endmacro %} - -{% set notification_types = { - 1: ACCOUNT_ROLE_CHANGE -} %} - -{% macro notification_item(notification) %} -
  • - - {{ timestamp(notification.created_at) }} - - - {{ notification_types[notification.type](notification.extra_info) }} - -
  • -{% endmacro %} diff --git a/client/src/pages/account/components/service_key.html b/client/src/pages/account/components/service_key.html deleted file mode 100644 index 0c0d04a..0000000 --- a/client/src/pages/account/components/service_key.html +++ /dev/null @@ -1,50 +0,0 @@ -{% from 'components/cards/base.html' import card, card_header, card_body, card_footer %} -{% from 'components/timestamp.html' import timestamp %} - -{% macro service_key_card(service_key, import_ids, class_name= none) %} - {% set paysite = g.paysites[service_key.service] %} - - {% call card(class_name= class_name) %} - {% call card_header() %} -

    - {{ paysite.title }} -

    - {% endcall %} - - {% call card_body() %} -
    -
    -
    Status:
    - {% if not service_key.dead %} -
    - Alive -
    - {% else %} -
    - Dead -
    - {% endif %} -
    -
    - {% if import_ids %} -
    Logs
    - - {% endif %} -
    -
    - {% endcall %} - - {% call card_footer() %} -
    -
    -
    Added:
    -
    {{ timestamp(service_key.added) }}
    -
    -
    - {% endcall %} - {% endcall %} -{% endmacro %} diff --git a/client/src/pages/account/favorites/legacy.tsx b/client/src/pages/account/favorites/legacy.tsx new file mode 100644 index 0000000..bc0193d --- /dev/null +++ b/client/src/pages/account/favorites/legacy.tsx @@ -0,0 +1,6 @@ +import { redirect } from "react-router-dom"; +import { createAccountFavoriteProfilesPageURL } from "#lib/urls"; + +export async function loader() { + return redirect(String(createAccountFavoriteProfilesPageURL())); +} diff --git a/client/src/pages/account/favorites/posts.module.scss b/client/src/pages/account/favorites/posts.module.scss new file mode 100644 index 0000000..df694a8 --- /dev/null +++ b/client/src/pages/account/favorites/posts.module.scss @@ -0,0 +1,14 @@ +.dropdowns { + display: grid; + grid-template-columns: max-content max-content; + grid-gap: 5px; + justify-content: center; +} + +.label { + text-align: right; + + ::after { + content: ":"; + } +} diff --git a/client/src/pages/account/favorites/posts.tsx b/client/src/pages/account/favorites/posts.tsx new file mode 100644 index 0000000..7744d53 --- /dev/null +++ b/client/src/pages/account/favorites/posts.tsx @@ -0,0 +1,184 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { + createAccountFavoritePostsPageURL, + createAccountFavoriteProfilesPageURL, +} from "#lib/urls"; +import { parseOffset } from "#lib/pagination"; +import { HeaderAd, SliderAd } from "#components/ads"; +import { Paginator } from "#components/pagination"; +import { CardList, PostCard } from "#components/cards"; +import { FormRouter } from "#components/forms"; +import { PageSkeleton, createAccountPageLoader } from "#components/pages"; +import { KemonoLink } from "#components/links"; +import { IFavouritePost, getAllFavouritePosts } from "#entities/account"; + +import styles from "./posts.module.scss"; + +type IProps = { + order?: IOrder; + count: number; + offset?: number; + sortBy?: ISortBy; + posts: IFavouritePost[]; +}; + +const sortByValues = ["faved_seq", "published"] as const; +const orderValues = ["asc", "desc"] as const; + +type ISortBy = (typeof sortByValues)[number]; +type IOrder = (typeof orderValues)[number]; + +function validateSortBy(input: unknown): asserts input is ISortBy { + if (!sortByValues.includes(input as ISortBy)) { + throw new Error(`Invalid sort by value "${input}".`); + } +} + +function validateOrder(input: unknown): asserts input is IOrder { + if (!orderValues.includes(input as IOrder)) { + throw new Error(`Invalid order value "${input}".`); + } +} + +/** + * TODO: split into separate pages + */ +export function FavoritePostsPage() { + const { sortBy, order, count, offset, posts } = useLoaderData() as IProps; + const title = "Favorite posts"; + const heading = "Favorite Posts"; + + return ( + + + + + <> + "Filter"} + > +

    + + Favorite Artists + +

    + {/* a filler div until proper form rewrite */} +
    + + + + + +
    + + <> +
    + + String(createAccountFavoritePostsPageURL(offset)) + } + /> +
    + + + {count === 0 ? ( + <> +

    Nobody here but us chickens!

    +

    There are no more posts.

    + + ) : ( + posts.map((post) => ( + + )) + )} +
    + +
    + + String(createAccountFavoritePostsPageURL(offset)) + } + /> +
    + + +
    + ); +} + +export const loader = createAccountPageLoader(async function loader({ + request, +}: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + + let sortBy: IProps["sortBy"] = undefined; + { + const inputValue = searchParams.get("sort_by")?.trim(); + + if (inputValue) { + validateSortBy(inputValue); + + sortBy = inputValue; + } + } + + let offset: IProps["offset"] = undefined; + { + const inputValue = searchParams.get("o")?.trim(); + + if (inputValue) { + offset = parseOffset(inputValue); + } + } + + let order: IProps["order"] = undefined; + { + const inputValue = searchParams.get("order")?.trim(); + + if (inputValue) { + validateOrder(inputValue); + + order = inputValue; + } + } + + const { count, posts } = await getAllFavouritePosts(); + + return { + count, + posts, + offset, + order, + sortBy, + }; +}); diff --git a/client/src/pages/account/favorites/profiles.module.scss b/client/src/pages/account/favorites/profiles.module.scss new file mode 100644 index 0000000..df694a8 --- /dev/null +++ b/client/src/pages/account/favorites/profiles.module.scss @@ -0,0 +1,14 @@ +.dropdowns { + display: grid; + grid-template-columns: max-content max-content; + grid-gap: 5px; + justify-content: center; +} + +.label { + text-align: right; + + ::after { + content: ":"; + } +} diff --git a/client/src/pages/account/favorites/profiles.tsx b/client/src/pages/account/favorites/profiles.tsx new file mode 100644 index 0000000..f84bda4 --- /dev/null +++ b/client/src/pages/account/favorites/profiles.tsx @@ -0,0 +1,189 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { + createAccountFavoritePostsPageURL, + createAccountFavoriteProfilesPageURL, +} from "#lib/urls"; +import { parseOffset } from "#lib/pagination"; +import { HeaderAd, SliderAd } from "#components/ads"; +import { Paginator } from "#components/pagination"; +import { CardList, ArtistCard } from "#components/cards"; +import { FormRouter } from "#components/forms"; +import { PageSkeleton, createAccountPageLoader } from "#components/pages"; +import { KemonoLink } from "#components/links"; +import { IFavouriteArtist, getAllFavouriteProfiles } from "#entities/account"; + +import styles from "./profiles.module.scss"; + +interface IProps { + order?: IOrder; + count: number; + offset?: number; + sortBy?: ISortBy; + profiles: IFavouriteArtist[]; +} + +const sortByValues = ["updated", "faved_seq", "last_imported"] as const; +const orderValues = ["asc", "desc"] as const; +type ISortBy = (typeof sortByValues)[number]; +type IOrder = (typeof orderValues)[number]; + +function validateSortBy(input: unknown): asserts input is ISortBy { + if (!sortByValues.includes(input as ISortBy)) { + throw new Error(`Invalid sort by value "${input}".`); + } +} + +function validateOrder(input: unknown): asserts input is IOrder { + if (!orderValues.includes(input as IOrder)) { + throw new Error(`Invalid order value "${input}".`); + } +} + +export function FavoriteProfilesPage() { + const { sortBy, order, count, offset, profiles } = useLoaderData() as IProps; + const title = "Favorite profiles"; + const heading = "Favorite Profiles"; + + return ( + + + + + "Filter"} + > +

    + + Favorite Posts + +

    + {/* a filler div until proper form rewrite */} +
    + + + + + +
    + +
    + + String(createAccountFavoriteProfilesPageURL(offset, sortBy, order)) + } + /> +
    + + + {count === 0 ? ( + <> +

    Nobody here but us chickens!

    +

    There are no profiles.

    + + ) : ( + profiles.map((profile) => ( + + )) + )} +
    + +
    + + String(createAccountFavoriteProfilesPageURL(offset, sortBy, order)) + } + /> +
    +
    + ); +} + +export const loader = createAccountPageLoader(async function loader({ + request, +}: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + + let sortBy: IProps["sortBy"] = undefined; + { + const inputValue = searchParams.get("sort_by")?.trim(); + + if (inputValue) { + validateSortBy(inputValue); + + sortBy = inputValue; + } + } + + let offset: IProps["offset"] = undefined; + { + const inputValue = searchParams.get("o")?.trim(); + + if (inputValue) { + offset = parseOffset(inputValue); + } + } + + let order: IProps["order"] = undefined; + { + const inputValue = searchParams.get("order")?.trim(); + + if (inputValue) { + validateOrder(inputValue); + + order = inputValue; + } + } + + const { count, profiles } = await getAllFavouriteProfiles( + offset, + order, + sortBy + ); + + return { + count, + profiles, + offset, + order, + sortBy, + }; +}); diff --git a/client/src/pages/account/home.html b/client/src/pages/account/home.html deleted file mode 100644 index ab65f36..0000000 --- a/client/src/pages/account/home.html +++ /dev/null @@ -1,80 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/image_link.html' import image_link %} - -{% from 'components/timestamp.html' import timestamp %} -{% from 'components/links.html' import kemono_link %} - -{% set role_links = { - "consumer": "/account", - "moderator": "/account/moderator", - "administrator": "/account/administrator" -} %} - -{% block title %} - {{ props.title }} -{% endblock title %} - -{% block content %} - -{% endblock content %} diff --git a/client/src/pages/account/home.tsx b/client/src/pages/account/home.tsx new file mode 100644 index 0000000..e2c1052 --- /dev/null +++ b/client/src/pages/account/home.tsx @@ -0,0 +1,130 @@ +import { useLoaderData } from "react-router-dom"; +import { + createAccountDMsReviewPageURL, + createAccountFavoritePostsPageURL, + createAccountFavoriteProfilesPageURL, + createAccountImportKeysPageURL, + createAccountNotificationsPageURL, + createAccountPageURL, + createAccountPasswordChangePageURL, + createAdministratorPageURL, + createModeratorPageURL, +} from "#lib/urls"; +import { fetchAccount } from "#api/account"; +import { IAccount } from "#entities/account"; +import { PageSkeleton, createAccountPageLoader } from "#components/pages"; +import { KemonoLink } from "#components/links"; + +interface IProps { + account: IAccount; + notificationsCount: number; +} + +export function AccountPage() { + const { account, notificationsCount } = useLoaderData() as IProps; + const { role, username, created_at } = account; + const title = "Your account details"; + const heading = "Your Account Details"; + const roleURL = String( + role === "administrator" + ? createAdministratorPageURL() + : role == "moderator" + ? createModeratorPageURL() + : createAccountPageURL() + ); + + return ( + +
    +
    + + Hello, + {username} + + +
    + Joined {created_at} | + + + {role} + + +
    +
    + +

    + Favorites: +

      +
    • + + Profiles + +
    • +
    • + + Posts + +
    • +
    +

    + +

    + Notifications: + + + {notificationsCount} + + +

    + +

    + + Keys + +

    + +

    + + Review DMs + +

    + +

    + + Change password + +

    +
    +
    + ); +} + +export const loader = createAccountPageLoader( + async function loader(): Promise { + const { props } = await fetchAccount(); + const { account, currentPage, notifications_count, title } = props; + + return { + account, + notificationsCount: notifications_count, + }; + } +); diff --git a/client/src/pages/account/keys.html b/client/src/pages/account/keys.html deleted file mode 100644 index 8879a90..0000000 --- a/client/src/pages/account/keys.html +++ /dev/null @@ -1,53 +0,0 @@ -{% extends 'components/shell.html' %} - -{% import 'components/site.html' as site %} -{% from 'components/forms/base.html' import form %} -{% from 'components/forms/submit_button.html' import submit_button %} -{% from 'components/card_list.html' import card_list %} -{% from 'components/cards/no_results.html' import no_results %} -{% from 'account/components/service_key.html' import service_key_card %} - -{% set page_title = 'Manage saved keys | ' ~ g.site_name %} - -{% block title %} - - {{ page_title }} - -{% endblock title %} - -{% block content %} -{% call site.section('account-keys', 'Stored service keys') %} - {% call card_list() %} - {% for key in props.service_keys %} -
    - -
    - {{ service_key_card(key, import_ids[loop.index0], class_name="key__card") }} - -
    -
    - {% else %} - {{ no_results() }} - {% endfor %} - {% endcall %} - {% if props.service_keys %} - {% call form( - id= 'revoke-service-keys', - action = '/account/keys', - method= 'POST' - ) %} - {{ submit_button('Revoke selected keys') }} - {% endcall %} - {% endif %} -{% endcall %} -{% endblock content %} diff --git a/client/src/pages/account/keys.tsx b/client/src/pages/account/keys.tsx new file mode 100644 index 0000000..eb0d743 --- /dev/null +++ b/client/src/pages/account/keys.tsx @@ -0,0 +1,113 @@ +import { ActionFunctionArgs, redirect, useLoaderData } from "react-router-dom"; +import { createAccountImportKeysPageURL } from "#lib/urls"; +import { + fetchAccountAutoImportKeys, + fetchRevokeAutoImportKeys, +} from "#api/account/auto-import-keys"; +import { IAutoImportKey } from "#entities/account"; +import { CardList, NoResults } from "#components/cards"; +import { FormRouter } from "#components/forms"; +import { + PageSkeleton, + createAccountPageLoader, + validateAccountPageAction, +} from "#components/pages"; +import { AutoImportKeyCard } from "#entities/account"; + +interface IProps { + autoImportKeys: IAutoImportKey[]; + importIDs: { import_id: string }[]; +} + +export function AccountAutoImportKeysPage() { + const { autoImportKeys, importIDs } = useLoaderData() as IProps; + const title = "Manage saved keys"; + const heading = "Stored service keys"; + const revokeFormID = "revoke-service-keys"; + + return ( + + + {autoImportKeys.length === 0 ? ( + + ) : ( + autoImportKeys.map((autoImportKey, index) => ( +
    + +
    + + +
    +
    + )) + )} +
    + + {autoImportKeys.length !== 0 && ( + <>Revoke selected keys} + /> + )} +
    + ); +} + +export const loader = createAccountPageLoader( + async function loader(): Promise { + const { props, import_ids } = await fetchAccountAutoImportKeys(); + const { service_keys } = props; + + return { + autoImportKeys: service_keys, + importIDs: import_ids, + }; + } +); + +export async function action(args: ActionFunctionArgs) { + try { + await validateAccountPageAction(args); + + const { request } = args; + + if (request.method !== "POST") { + throw new Error(`Unknown method "${request.method}".`); + } + + const data = await request.formData(); + + const idsForRevocation = (data.getAll("revoke") as string[]).map((id) => + parseInt(id, 10) + ); + + if (idsForRevocation.length === 0) { + throw new Error("At least one key must be selected for revocation."); + } + + await fetchRevokeAutoImportKeys(idsForRevocation); + + return redirect(String(createAccountImportKeysPageURL())); + } catch (error) { + return error; + } +} diff --git a/client/src/pages/account/login.html b/client/src/pages/account/login.html deleted file mode 100644 index 7208ba0..0000000 --- a/client/src/pages/account/login.html +++ /dev/null @@ -1,50 +0,0 @@ -{% extends 'components/shell.html' %} -{% block content %} - -{% endblock %} diff --git a/client/src/pages/account/login.tsx b/client/src/pages/account/login.tsx new file mode 100644 index 0000000..acf5846 --- /dev/null +++ b/client/src/pages/account/login.tsx @@ -0,0 +1,95 @@ +import { + ActionFunctionArgs, + redirect, + useSearchParams, +} from "react-router-dom"; +import { PageSkeleton } from "#components/pages"; +import { KemonoLink } from "#components/links"; +import { createArtistsPageURL, createRegistrationPageURL } from "#lib/urls"; +import { FormRouter, FormSection } from "#components/forms"; +import { loginAccount } from "#entities/account"; + +export function AccountLoginPage() { + const [searchParams] = useSearchParams(); + const title = "Login"; + const heading = "Login"; + const location = + searchParams.get("location")?.trim() ?? String(createArtistsPageURL()); + + return ( + +

    + Don't have an account?{" "} + + Register! + {" "} + Your favorites will automatically be saved. +

    + + "Login"} + > + + + + + + + + + + + + +
    + ); +} + +export async function action({ request }: ActionFunctionArgs) { + try { + if (request.method !== "POST") { + throw new Error(`Unknown method "${request.method}".`); + } + + const data = await request.formData(); + + const location = data.get("location") as string; + const username = data.get("username") as string | null; + { + if (!username) { + throw new Error(`Username is required.`); + } + } + + const password = data.get("password") as string | null; + { + if (!password) { + throw new Error(`Password is required.`); + } + } + + await loginAccount(username, password); + + return redirect(location); + } catch (error) { + return error; + } +} diff --git a/client/src/pages/account/moderator/_index.js b/client/src/pages/account/moderator/_index.js deleted file mode 100644 index 110ce34..0000000 --- a/client/src/pages/account/moderator/_index.js +++ /dev/null @@ -1,4 +0,0 @@ -/** - * @type {Map void} - */ -export const moderatorPageScripts = new Map(); diff --git a/client/src/pages/account/moderator/creator_links.html b/client/src/pages/account/moderator/creator_links.html deleted file mode 100644 index bba7bb0..0000000 --- a/client/src/pages/account/moderator/creator_links.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "components/shell.html" %} - -{% block content %} - -{% endblock content %} diff --git a/client/src/pages/account/moderator/creator_links.js b/client/src/pages/account/moderator/creator_links.js deleted file mode 100644 index 9523cc3..0000000 --- a/client/src/pages/account/moderator/creator_links.js +++ /dev/null @@ -1,22 +0,0 @@ -export function creatorLinksPage() { - Array.from(document.querySelectorAll(".link-request-card")).forEach(card => { - card.querySelector(".control > .approve").addEventListener("click", async (_e) => { - await approveLinkRequest(card.dataset["id"]); - card.remove(); - }); - card.querySelector(".control > .reject").addEventListener("click", async (_e) => { - await rejectLinkRequest(card.dataset["id"]); - card.remove(); - }); - }) -} - -async function approveLinkRequest(requestId) { - let resp = await fetch(`/creator_link_requests/${requestId}/approve`, { method: "POST" }); - let json = await resp.json(); -} - -async function rejectLinkRequest(requestId) { - let resp = await fetch(`/creator_link_requests/${requestId}/reject`, { method: "POST" }); - let json = await resp.json(); -} diff --git a/client/src/pages/account/moderator/dashboard.html b/client/src/pages/account/moderator/dashboard.html deleted file mode 100644 index 36a7af4..0000000 --- a/client/src/pages/account/moderator/dashboard.html +++ /dev/null @@ -1,18 +0,0 @@ -{% extends 'components/shell.html' %} - -{% block content %} -
    -
    -

    - Moderator room -

    -
    - -
    -{% endblock content %} diff --git a/client/src/pages/account/moderator/dashboard.tsx b/client/src/pages/account/moderator/dashboard.tsx new file mode 100644 index 0000000..de761d9 --- /dev/null +++ b/client/src/pages/account/moderator/dashboard.tsx @@ -0,0 +1,23 @@ +import { createProfileLinkRequestsPageURL } from "#lib/urls"; +import { PageSkeleton, createAccountPageLoader } from "#components/pages"; + +export function ModeratorDashboardPage() { + const title = "Moderator overview"; + const heading = "Moderator Overview"; + + return ( + + + + ); +} + +export const loader = createAccountPageLoader(); diff --git a/client/src/pages/account/moderator/files.html b/client/src/pages/account/moderator/files.html deleted file mode 100644 index a9baeea..0000000 --- a/client/src/pages/account/moderator/files.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'components/shell.html' %} - -{% block content %} -
    -
    -

    - Files for review -

    -
    -
    -{% endblock content %} diff --git a/client/src/pages/account/moderator/profile_links.tsx b/client/src/pages/account/moderator/profile_links.tsx new file mode 100644 index 0000000..1c4f557 --- /dev/null +++ b/client/src/pages/account/moderator/profile_links.tsx @@ -0,0 +1,140 @@ +import { + LoaderFunctionArgs, + useLoaderData, + useNavigate, +} from "react-router-dom"; +import { + createProfileLinkRequestsPageURL, + createProfilePageURL, +} from "#lib/urls"; +import { + fetchApproveLinkRequest, + fetchProfileLinkRequests, + fetchRejectLinkRequest, +} from "#api/account/moderator"; +import { PageSkeleton, createAccountPageLoader } from "#components/pages"; +import { IProfileLinkRequest } from "#entities/account"; +import { paysites } from "#entities/paysites"; + +interface IProps { + linkRequests: IProfileLinkRequest[]; +} + +export function ProfileLinkRequestsPage() { + const { linkRequests } = useLoaderData() as IProps; + const title = "Profile links requests"; + const heading = "Profile Links Requests"; + + return ( + +
    + {linkRequests.length === 0 ? ( +

    No pending requests. Yay!

    + ) : ( + linkRequests.map((linkRequest) => ( + + )) + )} +
    +
    + ); +} + +interface IProfileLinkRequestProps { + linkRequest: IProfileLinkRequest; +} + +function ProfileLinkRequest({ linkRequest }: IProfileLinkRequestProps) { + const navigate = useNavigate(); + const { id, requester, from_creator, to_creator, reason } = linkRequest; + const fromSite = paysites[from_creator.service]; + const toSite = paysites[to_creator.service]; + const fromURL = String( + createProfilePageURL({ + service: from_creator.service, + profileID: from_creator.id, + }) + ); + const toURL = String( + createProfilePageURL({ + service: to_creator.service, + profileID: to_creator.id, + }) + ); + + return ( + + ); +} + +export const loader = createAccountPageLoader( + async function loader({}: LoaderFunctionArgs): Promise { + const linkRequests = await fetchProfileLinkRequests(); + + return { linkRequests }; + } +); diff --git a/client/src/pages/account/notifications.html b/client/src/pages/account/notifications.html deleted file mode 100644 index bdab237..0000000 --- a/client/src/pages/account/notifications.html +++ /dev/null @@ -1,27 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/timestamp.html' import timestamp %} -{% from 'account/components/notification.html' import notification_item %} - -{% block title %} - Account notificatons -{% endblock title %} - -{% block content %} - -{% endblock content %} diff --git a/client/src/pages/account/notifications.tsx b/client/src/pages/account/notifications.tsx new file mode 100644 index 0000000..bdaa2cf --- /dev/null +++ b/client/src/pages/account/notifications.tsx @@ -0,0 +1,40 @@ +import { useLoaderData } from "react-router-dom"; +import { fetchAccountNotifications } from "#api/account"; +import { INotification } from "#entities/account"; +import { PageSkeleton, createAccountPageLoader } from "#components/pages"; +import { NotificationItem } from "#entities/account"; + +interface IProps { + notifications: INotification[]; +} + +export function AccountNotificationsPage() { + const { notifications } = useLoaderData() as IProps; + const title = "Account notificatons"; + const heading = "Notificatons"; + + return ( + +
      + {notifications.length === 0 ? ( +
    • There are no notifications.
    • + ) : ( + notifications.map((notification) => ( + + )) + )} +
    +
    + ); +} + +export const loader = createAccountPageLoader( + async function loader(): Promise { + const { notifications } = await fetchAccountNotifications(); + + return { notifications }; + } +); diff --git a/client/src/pages/account/register.html b/client/src/pages/account/register.html deleted file mode 100644 index 6b276ad..0000000 --- a/client/src/pages/account/register.html +++ /dev/null @@ -1,73 +0,0 @@ -{% extends 'components/shell.html' %} -{% block content %} - - -{% endblock %} diff --git a/client/src/pages/account/register.js b/client/src/pages/account/register.js deleted file mode 100644 index 0ee8c7c..0000000 --- a/client/src/pages/account/register.js +++ /dev/null @@ -1,96 +0,0 @@ -import "./register.scss"; - -/** - * @param {HTMLElement} section - */ -export function registerPage(section) { - populate_favorites(); - input_validation(); -} - -function populate_favorites() { - var input = document.getElementById("serialized-favorites"); - var favorites = localStorage.getItem("favorites"); - var to_serialize = []; - if (input && favorites) { - var artists = favorites.split(","); - artists.forEach(function (artist) { - var split = artist.split(":"); - if (split.length != 2) { - return; - } - var elem = { - service: split[0], - artist_id: split[1], - }; - to_serialize.push(elem); - }); - var serialized = JSON.stringify(to_serialize); - input.value = serialized; - } -} - - -function input_validation() { - const USERNAME_INPUT = () => document.getElementById("new-username"); - const PASSWORD_INPUT = () => document.getElementById("new-password"); - const PASSWORD_CONFIRM_INPUT = () => document.getElementById("password-confirm"); - const SUBMIT_BUTTON = () => document.getElementById("submit"); - - const USER_CHAR_COUNT = () => document.getElementById("username-char-count"); - const ALLOWED_CHARS = () => document.getElementById("username-allowed-characters"); - const PASS_CHAR_COUNT = () => document.getElementById("password-char-count"); - const PASSWORD_CONFIRM = () => document.getElementById("password-confirm-matches"); - - const USERNAME_PAT = new RegExp("^" + document.getElementById("register_form").dataset["pattern"] + "$"); - const NOT_ALLOWED_CHARS_PAT = new RegExp(`[^a-z0-9_@+.\-]`, 'g'); - - function validateInputs(e) { - let errors = false; - let username = USERNAME_INPUT().value; - let password = PASSWORD_INPUT().value; - let passwordConfirmation = PASSWORD_CONFIRM_INPUT().value; - - if (username.length < 3 || username.length > 15) { - errors = true; - USER_CHAR_COUNT().classList.add("invalid"); - } else { - USER_CHAR_COUNT().classList.remove("invalid"); - } - - if (!username.match(USERNAME_PAT)) { - errors = true; - ALLOWED_CHARS().classList.add("invalid"); - } else { - ALLOWED_CHARS().classList.remove("invalid"); - } - - if (password.length < 5) { - errors = true; - PASS_CHAR_COUNT().classList.add("invalid"); - } else { - PASS_CHAR_COUNT().classList.remove("invalid"); - } - - if (passwordConfirmation !== password || passwordConfirmation === "") { - errors = true; - PASSWORD_CONFIRM().classList.add("invalid"); - } else { - PASSWORD_CONFIRM().classList.remove("invalid"); - } - - SUBMIT_BUTTON().disabled = errors; - } - - window.addEventListener("load", (_event) => { - USERNAME_INPUT().textContent = USERNAME_INPUT().textContent.toLowerCase().replace(NOT_ALLOWED_CHARS_PAT, ""); - validateInputs(); - USERNAME_INPUT().addEventListener("input", validateInputs); - USERNAME_INPUT().addEventListener("input", (input) => { - input.target.value = input.target.value.toLowerCase().replace(NOT_ALLOWED_CHARS_PAT, ""); - }); - PASSWORD_INPUT().addEventListener("input", validateInputs); - PASSWORD_CONFIRM_INPUT().addEventListener("input", validateInputs); - }); - -} diff --git a/client/src/pages/account/register.tsx b/client/src/pages/account/register.tsx new file mode 100644 index 0000000..be916d7 --- /dev/null +++ b/client/src/pages/account/register.tsx @@ -0,0 +1,185 @@ +import { + ActionFunctionArgs, + redirect, + useSearchParams, +} from "react-router-dom"; +import { createArtistsPageURL, createLoginPageURL } from "#lib/urls"; +import { getLocalStorageItem } from "#storage/local"; +import { KemonoLink } from "#components/links"; +import { PageSkeleton } from "#components/pages"; +import { FormRouter, FormSection } from "#components/forms"; +import { registerAccount } from "#entities/account"; + +const USERNAME_REGEX = /^[a-z0-9_@+.-]{3,15}$/; +const NOT_ALLOWED_CHARS_REGEX = /[^a-z0-9_@+.\-]/g; + +export function RegisterPage() { + const [searchParams] = useSearchParams(); + const title = "Register account"; + const heading = "Register Account"; + const location = + searchParams.get("location") ?? String(createArtistsPageURL()); + + return ( + +
    + Already have an account?{" "} + + Log in! + +
    + + "Register"} + > + + + + + + + + + + + + + + + + + + + + + + +
    + ); +} + +export async function action({ request }: ActionFunctionArgs) { + try { + if (request.method !== "POST") { + throw new Error(`Unknown method "${request.method}".`); + } + + const data = await request.formData(); + + const location = data.get("location") as string; + const favorites = getLegacyFavoriteProfiles(); + + let userName: string | undefined = undefined; + { + const inputValue = (data.get("username") as string | null) + ?.toLowerCase() + .replace(NOT_ALLOWED_CHARS_REGEX, "") + .trim(); + + if (!inputValue) { + throw new Error("Username is required."); + } + + if (inputValue.length < 3 || inputValue.length > 15) { + throw new Error( + "Username must be at least 3 characters and no more than 15 characters long." + ); + } + + if (!inputValue.match(USERNAME_REGEX)) { + throw new Error(`Username doesn't match pattern "${USERNAME_REGEX}".`); + } + + userName = inputValue; + } + + let password: string | undefined; + { + const inputValue = (data.get("password") as string | null)?.trim(); + + if (!inputValue) { + throw new Error("Password is required."); + } + + if (inputValue.length < 5) { + throw new Error("Password must have at least 5 characters."); + } + + password = inputValue; + } + + const confirmPassword = ( + data.get("confirm_password") as string | null + )?.trim(); + { + if (confirmPassword !== password) { + throw new Error("Passwords don't match."); + } + } + + await registerAccount(userName, password, confirmPassword, favorites); + + return redirect(location); + } catch (error) { + return error; + } +} + +function getLegacyFavoriteProfiles(): string | undefined { + const value = getLocalStorageItem("favorites"); + + if (!value) { + return; + } + + const favorites: { service: string; artist_id: string }[] = []; + const artists = value.split(","); + + for (const artist of artists) { + const split = artist.split(":"); + + if (split.length != 2) { + continue; + } + + const fav = { + service: split[0], + artist_id: split[1], + }; + + favorites.push(fav); + } + + if (favorites.length === 0) { + return; + } + + return JSON.stringify(favorites); +} diff --git a/client/src/pages/all_dms.html b/client/src/pages/all_dms.html deleted file mode 100644 index a5ca44b..0000000 --- a/client/src/pages/all_dms.html +++ /dev/null @@ -1,50 +0,0 @@ -{% extends 'components/shell.html' %} - -{% import 'components/site.html' as site %} -{% from 'components/card_list.html' import card_list %} -{% from 'components/cards/dm.html' import dm_card %} -{% from 'components/ads.html' import slider_ad, header_ad %} - -{% block content %} -{% call site.section("all-dms", title="DMs") %} - {{ slider_ad() }} - {{ header_ad() }} -
    - {% include 'components/paginator.html' %} -
    - - -
    -
    - - {% call card_list("phone") %} - {% for dm in props.dms %} - {{ dm_card(dm, artist=dm|attr("artist") or {}, is_global=True) }} - {% else %} -
    -

    Nobody here but us chickens!

    -

    - There are no DMs. -

    -
    - {% endfor %} - {% endcall %} - -
    - {% include 'components/paginator.html' %} -
    -{% endcall %} -{% endblock %} diff --git a/client/src/pages/all_dms.tsx b/client/src/pages/all_dms.tsx new file mode 100644 index 0000000..102db8f --- /dev/null +++ b/client/src/pages/all_dms.tsx @@ -0,0 +1,103 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { createDMsPageURL } from "#lib/urls"; +import { parseOffset } from "#lib/pagination"; +import { fetchDMs } from "#api/dms"; +import { PageSkeleton } from "#components/pages"; +import { HeaderAd, SliderAd } from "#components/ads"; +import { Paginator } from "#components/pagination"; +import { CardList, DMCard } from "#components/cards"; +import { FormRouter } from "#components/forms"; +import { IApprovedDM } from "#entities/dms"; + +interface IProps { + query?: string; + count: number; + offset?: number; + dms: IApprovedDM[]; +} + +export function DMsPage() { + const { query, count, dms, offset } = useLoaderData() as IProps; + const title = "DMs"; + const heading = "DMs"; + + return ( + + + + +
    + String(createDMsPageURL(offset, query))} + /> + + + + + +
    + + + {count === 0 ? ( +
    +

    + Nobody here but us chickens! +

    +

    There are no DMs.

    +
    + ) : ( + dms.map((dm) => ( + + )) + )} +
    + +
    + String(createDMsPageURL(offset, query))} + /> +
    +
    + ); +} + +export async function loader({ request }: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + + let offset: number | undefined = undefined; + { + const inputOffset = searchParams.get("o")?.trim(); + if (inputOffset) { + offset = parseOffset(inputOffset); + } + } + + const query = searchParams.get("q")?.trim(); + + const { props } = await fetchDMs(offset, query); + const { count, dms } = props; + + return { + count, + offset, + query, + dms, + }; +} diff --git a/client/src/pages/artist/announcements.html b/client/src/pages/artist/announcements.html deleted file mode 100644 index 29a976f..0000000 --- a/client/src/pages/artist/announcements.html +++ /dev/null @@ -1,77 +0,0 @@ -{% extends "components/shell.html" %} - -{% from "components/headers.html" import user_header %} -{% from "components/card_list.html" import card_list %} -{% from "components/cards/dm.html" import dm_card %} - -{% set paysite = g.paysites[props.service] %} -{% set page_title = "Announcements of " ~ props.artist.name ~ " from " ~ paysite.title ~ " | " ~ g.site_name %} - -{% block title %} - {{ page_title }} -{% endblock %} - -{% block meta %} - - - - -{% endblock meta %} - -{% block opengraph %} - - - - - -{% endblock opengraph %} - -{% block content %} -
    - {{ user_header(request, props) }} -
    - {% include "components/tabs.html" %} -
    -{#
    #} -{# {% include "components/tabs.html" %}#} -{# {% include "components/paginator.html" %}#} -{##} -{#
    #} -{# #} -{# #} -{#
    #} - - {% call card_list("phone") %} - {% for announcement in props.announcements %} - {{ dm_card(announcement) }} - {% else %} -
    -

    Nobody here but us chickens!

    -

    - There are no Announcements for your query. -

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

    Nobody here but us chickens!

    -

    - There are no DMs for your query. -

    -
    - {% endfor %} - {% endcall %} -
    -{% endblock content %} - -{% block components %} - - {{ loading_icon() }} -{% endblock components %} diff --git a/client/src/pages/artist/fancards.html b/client/src/pages/artist/fancards.html deleted file mode 100644 index cd5f5fe..0000000 --- a/client/src/pages/artist/fancards.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/headers.html' import user_header %} - -{% set page_title = 'Fancards of ' ~ artist.name ~ ' | ' ~ g.site_name %} - -{% block title %} - {{ page_title }} -{% endblock title %} - -{% block meta %} - - - - -{% endblock meta %} - -{% block opengraph %} - - - - - -{% endblock opengraph %} - -{% block content %} -
    - {{ user_header(request, props) }} -
    - {% include 'components/tabs.html' %} -
    -
    - {% for fancard in fancards %} -
    - Added {{ (fancard.added|simple_date)[:7] }} - - - -
    - {% else %} -
    -

    Nobody here but us chickens!

    -

    - There are no uploads for your query. -

    -
    - {% endfor %} -
    -
    -{% endblock content %} - -{% block components %} - - {{ loading_icon() }} -{% endblock components %} \ No newline at end of file diff --git a/client/src/pages/artist/linked_accounts.html b/client/src/pages/artist/linked_accounts.html deleted file mode 100644 index 08e5aae..0000000 --- a/client/src/pages/artist/linked_accounts.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends "components/shell.html" %} - -{% from "components/card_list.html" import card_list %} -{% from "components/headers.html" import user_header %} -{% from "components/cards/user.html" import user_card %} - -{% set paysite = g.paysites[props.service] %} -{% set page_title = "Linked accounts for " ~ props.artist.name ~ " on " ~ paysite.title ~ " | " ~ g.site_name %} - -{% block title %} - {{ page_title }} -{% endblock %} - -{% block meta %} - - - - -{% endblock meta %} - -{% block opengraph %} - - - - - -{% endblock opengraph %} - -{% block content %} - -{% endblock %} - -{% block components %} - - {{ loading_icon() }} -{% endblock components %} \ No newline at end of file diff --git a/client/src/pages/artist/linked_accounts.js b/client/src/pages/artist/linked_accounts.js deleted file mode 100644 index 7cc1f31..0000000 --- a/client/src/pages/artist/linked_accounts.js +++ /dev/null @@ -1,32 +0,0 @@ -export function viewLinkedAccountsPage() { - if (localStorage.getItem("role") !== "administrator") return; - - window.addEventListener("DOMContentLoaded", (_e) => { - Array.from(document.querySelectorAll(".user-card")).forEach(card => { - let btn = document.createElement("button"); - btn.textContent = "✗"; - btn.classList = "remove-link"; - btn.addEventListener("click", async (e) => { - // apparently it only actually stops if you use both: - e.preventDefault(); - e.stopPropagation(); - - let id = card.dataset["id"]; - let service = card.dataset["service"]; - - if (confirm(`Delete the connection for user #${id} on ${service}?`)) { - if (await deleteLinkedAccount(service, id)) { - card.remove(); - } else { - alert("Error"); - } - } - }); - card.appendChild(btn); - }); - }); -} - -async function deleteLinkedAccount(service, id) { - return (await fetch(`/${service}/user/${id}/links`, { method: "DELETE" })).status == 204; -} diff --git a/client/src/pages/artist/new_linked_account.html b/client/src/pages/artist/new_linked_account.html deleted file mode 100644 index 6b1dfa0..0000000 --- a/client/src/pages/artist/new_linked_account.html +++ /dev/null @@ -1,84 +0,0 @@ -{% extends "components/shell.html" %} - -{% from "components/headers.html" import user_header %} - -{% set paysite = g.paysites[props.service] %} -{% set page_title = "Link a new account to " ~ props.artist.name ~ " on " ~ paysite.title ~ " | " ~ g.site_name %} - -{% block title %} - {{ page_title }} -{% endblock %} - -{% block meta %} - - - - -{% endblock meta %} - -{% block opengraph %} - - - - - -{% endblock opengraph %} - -{% block content %} - -{% endblock %} - -{% block components %} - - {{ loading_icon() }} -{% endblock components %} diff --git a/client/src/pages/artist/new_linked_account.js b/client/src/pages/artist/new_linked_account.js deleted file mode 100644 index e7b1830..0000000 --- a/client/src/pages/artist/new_linked_account.js +++ /dev/null @@ -1,111 +0,0 @@ -import { debounce } from "../artists.js"; -import { kemonoAPI } from "@wp/api"; -import { freesites, paysites } from "@wp/utils"; -import { BANNERS_PREPEND, ICONS_PREPEND } from "@wp/env/env-vars"; - -/** - * @type {Array} - */ -let ALL_CREATORS; - -export function newLinkedAccountPage() { - document.getElementById("service").addEventListener("input", debounce(testUser, 300)) - document.getElementById("creator_name").addEventListener("input", debounce(testUser, 300)) - document.getElementById("reason").addEventListener("input", debounce(validateReason, 300)) - testUser(); -} - -function validateReason() { - let reason = document.getElementById("reason"); - - if (reason.value.length > 140) { - reason.title = "Too long (140 max)"; - reason.classList.add("invalid"); - } else { - reason.title = ""; - reason.classList.remove("invalid"); - } -} - -async function testUser() { - if (ALL_CREATORS === undefined) { - ALL_CREATORS = await kemonoAPI.api.creators(); - } - - const CURRENT_ARTIST_ID = document.querySelector("meta[name='id']").content; - const CURRENT_ARTIST_SERVICE = document.querySelector("meta[name='service']").content; - let service = document.getElementById("service").value; - let creatorName = document.getElementById("creator_name").value.toLowerCase(); - let button = document.getElementById("submit"); - let resultBox = document.getElementById("lookup-result"); - - button.disabled = true; - - let items = ALL_CREATORS.filter(item => { - console.log(item); - return !(item.service === CURRENT_ARTIST_SERVICE && item.id === CURRENT_ARTIST_ID) - && (creatorName? item.name.toLowerCase().includes(creatorName) : true) - && (service !== "all"? item.service === service : true); - }).slice(0, 20).map(createCard); - console.log(items); - resultBox.replaceChildren.apply(resultBox, items); -} - -function createCard({id, name, service, indexed, updated, favorited}) { - // would like to use initUserCardFromScratch here, but it doesn't work. - // why would components be reuseable, after all? - - // so I'll just recreate the entire function here! - let updatedDate; - - if (updated || indexed) { - updatedDate = new Date((updated || indexed) * 1000).toISOString(); - } - - let profileIcon = freesites.kemono.user.icon(service, id); - let profileBanner = freesites.kemono.user.banner(service, id); - - profileIcon = ICONS_PREPEND + profileIcon; - profileBanner = BANNERS_PREPEND + profileBanner; - - let div = document.createElement("div"); - div.innerHTML = ` - -
    -
    - - - - - -
    -
    - -
    -
    - ${paysites[service].title} -
    - -
    - ${name} -
    - -
    - -
    -
    -
    - `; - div.addEventListener("click", (e) => { - e.preventDefault(); - - Array.from(document.querySelectorAll(".user-card.selected")).forEach(item => item.classList.remove("selected")); - - div.children[0].classList.add("selected"); - document.querySelector("#new_link_form #creator").value = `${service}/${id}`; - document.querySelector("#new_link_form #submit").disabled = false; - }); - return div; -} diff --git a/client/src/pages/artist/shares.html b/client/src/pages/artist/shares.html deleted file mode 100644 index 8079e93..0000000 --- a/client/src/pages/artist/shares.html +++ /dev/null @@ -1,56 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/cards/share.html' import share_card %} -{% from 'components/headers.html' import user_header %} -{% from 'components/card_list.html' import card_list %} - -{% set paysite = g.paysites[props.service] %} -{% set page_title = 'DMs of ' ~ props.artist.name ~ ' from ' ~ paysite.title ~ ' | ' ~ g.site_name %} - -{% block title %} - {{ page_title }} -{% endblock title %} - -{% block meta %} - - - - -{% endblock meta %} - -{% block opengraph %} - - - - - -{% endblock opengraph %} - -{% block content %} -
    - {{ user_header(request, props) }} -
    - {% include 'components/tabs.html' %} -
    - {% call card_list() %} - {% for dm in results %} - {{ share_card(dm) }} - {% else %} -
    -

    Nobody here but us chickens!

    -

    - There are no uploads for your query. -

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

    Nobody here but us chickens!

    -

    - There are no Announcements for your query. -

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

    - Displaying {{ props.display }} -

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

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

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

    - {{ title }} -

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

    -

    - {% endcall %} - - diff --git a/client/src/pages/components/shell.js b/client/src/pages/components/shell.js deleted file mode 100644 index 0db0df7..0000000 --- a/client/src/pages/components/shell.js +++ /dev/null @@ -1,106 +0,0 @@ -import { isLoggedIn } from "@wp/js/account"; - -window.addEventListener("load", () => { - document.body.classList.remove("transition-preload"); -}); - -/** - * @param {HTMLElement} sidebar - */ -export function initShell(sidebar) { - const burgor = document.getElementById("burgor"); - const header = burgor.parentElement; - const backdrop = document.querySelector(".backdrop"); - const contentWrapper = document.querySelector(".content-wrapper"); - const closeButton = sidebar.querySelector(".close-sidebar"); - const closeSidebar = (_, setState = true) => { - sidebar.classList.toggle("expanded"); - sidebar.classList.toggle("retracted"); - backdrop.classList.toggle("backdrop-hidden"); - contentWrapper.classList.toggle("shifted"); - const retracted = header.classList.toggle("sidebar-retracted"); - if (setState && window.innerWidth > 1020) localStorage.setItem("sidebar_state", retracted); - }; - if (typeof localStorage.getItem("sidebar_state") === "string") { - const sidebarState = localStorage.getItem("sidebar_state") === "true"; - if (window.innerWidth > 1020 && sidebarState) closeSidebar(); - } - window.addEventListener("resize", () => { - if (typeof localStorage.getItem("sidebar_state") !== "string") return; - const sidebarState = localStorage.getItem("sidebar_state") === "true"; - const realState = header.classList.contains("sidebar-retracted"); - const killAnimations = () => { - document.body.classList.add("transition-preload"); - requestAnimationFrame(() => setInterval(() => document.body.classList.remove("transition-preload"))); - }; - if (window.innerWidth <= 1020) { - if (sidebarState && realState) { - killAnimations(); - closeSidebar(null, false); - } - } else if (sidebarState && !realState) { - killAnimations(); - closeSidebar(); - } - }); - burgor.addEventListener("click", closeSidebar); - backdrop.addEventListener("click", closeSidebar); - closeButton.addEventListener("click", closeSidebar); - if (isLoggedIn) { - const accountList = sidebar.querySelector(".account"); - const login = accountList.querySelector(".login"); - const loginHeader = header.querySelector(".login"); - const register = accountList.querySelector(".register"); - const registerHeader = header.querySelector(".register"); - const favorites = accountList.querySelector(".favorites"); - const reviewDms = accountList.querySelector(".review_dms"); - login.classList.remove("login"); - loginHeader.classList.remove("login"); - loginHeader.classList.add("logout"); - register.classList.remove("register"); - registerHeader.classList.remove("register"); - favorites.classList.remove("hidden"); - reviewDms.classList.remove("hidden"); - login.lastChild.textContent = "Logout"; - login.firstElementChild.src = "/static/menu/logout.svg"; - login.href = "/account/logout"; - loginHeader.innerText = "Logout"; - loginHeader.href = "/account/logout"; - register.lastChild.textContent = "Keys"; - register.firstElementChild.src = "/static/menu/keys.svg"; - register.href = "/account/keys"; - registerHeader.innerText = "Favorites"; - registerHeader.href = "/favorites"; - const onLogout = (e) => { - e.preventDefault(); - localStorage.removeItem("logged_in"); - localStorage.removeItem("role"); - localStorage.removeItem("favs"); - localStorage.removeItem("post_favs"); - location.href = "/account/logout"; - }; - login.addEventListener("click", onLogout); - loginHeader.addEventListener("click", onLogout); - } else { - const accountHeader = sidebar.querySelector(".account-header"); - const newHeader = document.createElement("div"); - newHeader.className = "global-sidebar-entry-item header"; - newHeader.innerText = "Account"; - newHeader.prepend(accountHeader.firstElementChild); - accountHeader.parentElement.replaceChild(newHeader, accountHeader); - } - // questionable? close sidebar on tap of an item, - // delay loading of page until animation is done - // uncomment to close on tap - // uncomment the items commented with // to add a delay so it finishes animating - /* sidebar.querySelectorAll('.global-sidebar-entry-item').forEach(e => { - e.addEventListener('click', ev => { - //ev.preventDefault(); - sidebar.classList.remove('expanded'); - backdrop.classList.add('backdrop-hidden'); - // setTimeout(() => { - // location.href = e.href; - // }, 250); - }) - }) */ -} diff --git a/client/src/pages/components/site.html b/client/src/pages/components/site.html deleted file mode 100644 index 6811eb8..0000000 --- a/client/src/pages/components/site.html +++ /dev/null @@ -1,27 +0,0 @@ -{# call-only #} -{% macro section(name, title=none, class_name=none) %} -
    - {% if title %} - {% call header() %} - {{ heading(title) }} - {% endcall %} - {% endif %} - {{ caller() }} -
    -{% endmacro %} - -{% macro header(class_name=none) %} -
    - {{ caller() }} -
    -{% endmacro %} - -{% macro heading(title, class_name=none) %} -

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

    -{% endmacro %} diff --git a/client/src/pages/components/site_section.html b/client/src/pages/components/site_section.html deleted file mode 100644 index de100d0..0000000 --- a/client/src/pages/components/site_section.html +++ /dev/null @@ -1,13 +0,0 @@ -{% macro site_section(name) %} -
    - {{ caller() }} -
    -{% endmacro %} - -{% macro site_section_header(heading) %} -
    -

    - {{ heading }} -

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

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

    -{% endmacro %} diff --git a/client/src/pages/components/tooltip.js b/client/src/pages/components/tooltip.js deleted file mode 100644 index dd2eec2..0000000 --- a/client/src/pages/components/tooltip.js +++ /dev/null @@ -1,65 +0,0 @@ -import { createComponent } from "@wp/js/component-factory"; - -/** - * @type {HTMLDivElement} - */ -const tooltip = document.getElementById("flying-tooltip"); -/** - * @type {[HTMLButtonElement, HTMLSpanElement]} - */ -const [closeButton, messageContainer] = tooltip.children; - -closeButton.addEventListener("click", (event) => { - tooltip.classList.remove("tooltip--shown"); -}); - -/** - * @param {HTMLElement} element - * @param {HTMLParagraphElement} messageElement - */ -export function showTooltip(element, messageElement) { - const { left, bottom } = element.getBoundingClientRect(); - - tooltip.classList.remove("tooltip--shown"); - messageContainer.replaceWith(messageElement); - tooltip.style.setProperty("--local-x", `${left}px`); - tooltip.style.setProperty("--local-y", `${bottom}px`); - tooltip.classList.add("tooltip--shown"); -} - -/** - * TODO: init from `action_name` - * @param {HTMLElement} element - * @param {string} actionName - */ -export function registerMessage(element, actionName = "") { - /** - * @type {HTMLParagraphElement} - */ - const messageElement = element ? element : initFromScratch(actionName); - - return messageElement; -} - -/** - * @param {HTMLElement} element - */ -function initFromElement(element) {} - -/** - * @param {string} actionName - */ -function initFromScratch(actionName) { - /** - * @type {HTMLParagraphElement} - */ - const message = createComponent("tooltip__message tooltip__message--register"); - /** - * @type {HTMLSpanElement} - */ - const action = message.querySelector(".tooltip__action"); - - action.textContent = actionName; - - return message; -} diff --git a/client/src/pages/contact.tsx b/client/src/pages/contact.tsx new file mode 100644 index 0000000..dadb6ea --- /dev/null +++ b/client/src/pages/contact.tsx @@ -0,0 +1,20 @@ +import { PageSkeleton } from "#components/pages"; + +export function ContactPage() { + const title = "Contact Us"; + const heading = "Contact Us"; + + return ( + +

    + Questions: contact@kemono.su +

    +

    + Legal inquiries: legal@kemono.su +

    +

    + Advertising inquiries: ads@kemono.su +

    +
    + ); +} diff --git a/client/src/pages/development/_index.scss b/client/src/pages/development/_index.scss deleted file mode 100644 index a3235dd..0000000 --- a/client/src/pages/development/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@use "components"; -@use "design"; diff --git a/client/src/pages/development/closure.html b/client/src/pages/development/closure.html deleted file mode 100644 index cf7b73e..0000000 --- a/client/src/pages/development/closure.html +++ /dev/null @@ -1,26 +0,0 @@ -{# taken from https://gist.github.com/dah33/e18e71a81d1a0aaf59658269ada963b3 #} - -{% macro enclose(fn, env) %} - {% set closure = namespace(fn=fn, env=env) %} - {% do return(closure) %} -{% endmacro %} - -{% macro call1(closure, x1) %} - {% do return(closure.fn(x1, closure.env)) %} -{% endmacro %} - -{% macro call2(closure, x1, x2) %} - {% do return(closure.fn(x1, x2, closure.env)) %} -{% endmacro %} - -{# Example: #} - -{% macro power(x, kwargs) %} - {% do return(x**kwargs.exponent) %} -{% endmacro %} - -{# -{% set square = enclose(power, dict(exponent=2)) %} - -{{ call1(square, 8) }}{# = 8**2 = 64 #} -#} diff --git a/client/src/pages/development/components/_index.scss b/client/src/pages/development/components/_index.scss deleted file mode 100644 index af1b252..0000000 --- a/client/src/pages/development/components/_index.scss +++ /dev/null @@ -1,2 +0,0 @@ -@use "forms"; -@use "inputs"; diff --git a/client/src/pages/development/components/forms.html b/client/src/pages/development/components/forms.html deleted file mode 100644 index bd34b3f..0000000 --- a/client/src/pages/development/components/forms.html +++ /dev/null @@ -1,17 +0,0 @@ -{% from 'components/meta/attributes.html' import attributes %} - -{% macro form() %} -
    {{ caller() if caller }}
    -{% endmacro %} - -{% macro section() %} -
    {{ caller() if caller }}
    -{% endmacro %} - -{% macro label(text=none) %} - -{% endmacro %} - -{% macro input() %} - -{% endmacro %} diff --git a/client/src/pages/development/components/forms.scss b/client/src/pages/development/components/forms.scss deleted file mode 100644 index 501af59..0000000 --- a/client/src/pages/development/components/forms.scss +++ /dev/null @@ -1,31 +0,0 @@ -@use "../../../css/config/variables" as *; - -.dev-form { - display: grid; - grid-template-columns: 1fr; - grid-auto-rows: auto; - gap: $size-normal; - max-width: $width-mobile; - margin: 0 auto; - - &__section { - border: none; - padding: 0; - margin: 0; - - &--submit { - text-align: center; - } - } - - &__label { - display: inline-block; - } - - &__input { - min-width: 44px; - min-height: 44px; - width: 100%; - padding: $size-small; - } -} diff --git a/client/src/pages/development/components/inputs.html b/client/src/pages/development/components/inputs.html deleted file mode 100644 index 9935918..0000000 --- a/client/src/pages/development/components/inputs.html +++ /dev/null @@ -1,25 +0,0 @@ -{% from 'components/meta/attributes.html' import attributes %} - -{% import 'development/components/forms.html' as forms %} - -{% macro text(id, text) %} - {% call forms.section() %} - {{ forms.label(text, for=id) }} - {{ forms.input(id=id, - type="text", - **kwargs - ) }} - {% endcall %} -{% endmacro %} - -{% macro submit_button(text=none) %} - {% call forms.section(class='dev-form__section--submit') %} - - {% endcall %} -{% endmacro %} diff --git a/client/src/pages/development/components/inputs.scss b/client/src/pages/development/components/inputs.scss deleted file mode 100644 index 24d2370..0000000 --- a/client/src/pages/development/components/inputs.scss +++ /dev/null @@ -1,11 +0,0 @@ -@use "../../../css/config/variables" as *; - -.dev-form__input { -} - -.dev-form__submit { - min-width: 44px; - min-height: 44px; - width: auto; - padding: $size-small; -} diff --git a/client/src/pages/development/components/nav.html b/client/src/pages/development/components/nav.html deleted file mode 100644 index 524c5cd..0000000 --- a/client/src/pages/development/components/nav.html +++ /dev/null @@ -1,15 +0,0 @@ -{% from 'components/navigation/base.html' import navigation, nav_list, nav_item %} -{% from 'components/links.html' import kemono_link %} - -{# `nav_items` is a list of tuples `(url, title)` #} -{% macro dev_nav(nav_items, id=none) %} - {% call navigation(id) %} - {% call nav_list() %} - {% for url, title in nav_items %} - {% call nav_item() %} - {{ kemono_link(url, title, is_noop= false) }} - {% endcall %} - {% endfor %} - {% endcall %} - {% endcall %} -{% endmacro %} diff --git a/client/src/pages/development/config.html b/client/src/pages/development/config.html deleted file mode 100644 index 803abe3..0000000 --- a/client/src/pages/development/config.html +++ /dev/null @@ -1,45 +0,0 @@ -{% extends 'development/shell.html' %} - -{% import 'components/site.html' as site %} - -{% block content %} -{% call site.section('test-entries', 'Test Database') %} -
    -
    -

    Press "Activate" to:

    -
      -
    • Add test service keys
    • -
    -
    -
    - -
    -
    - -
    -
    -

    Press "Activate" to:

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

    Forms

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

    Form example

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

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

    -
    -
    - -
    -
    - -
    -
    -

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

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

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

    -
    -
    - -
    -
    - -
    -
    -

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

    -
    -
    - -
    -
    -{% endcall %} -{% endblock content %} diff --git a/client/src/pages/discord-channel.module.scss b/client/src/pages/discord-channel.module.scss new file mode 100644 index 0000000..13cf119 --- /dev/null +++ b/client/src/pages/discord-channel.module.scss @@ -0,0 +1,5 @@ +.main { + display: flex; + flex-direction: row; + gap: 1em; +} diff --git a/client/src/pages/discord-channel.tsx b/client/src/pages/discord-channel.tsx new file mode 100644 index 0000000..5ef7cd6 --- /dev/null +++ b/client/src/pages/discord-channel.tsx @@ -0,0 +1,77 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { parseOffset } from "#lib/pagination"; +import { fetchDiscordChannel, fetchDiscordServer } from "#api/profiles/discord"; +import { PageSkeleton } from "#components/pages"; +import { + DiscordMessages, + DiscordServer, + IDiscordChannelMessage, +} from "#entities/posts"; + +import styles from "./discord-channel.module.scss"; + +interface IProps { + serverID: string; + channelID: string; + channels: { id: string; name: string }[]; + messages: IDiscordChannelMessage[]; + offset?: number; +} + +export function DiscordChannelPage() { + const { serverID, channelID, channels, messages, offset } = + useLoaderData() as IProps; + const title = "Discord channel"; + const heading = "Discord Channel"; + + return ( + +
    + + +
    +
    + ); +} + +export async function loader({ + params, + request, +}: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + + const serverID = params.server_id?.trim(); + if (!serverID) { + throw new Error("Server ID is required."); + } + + const channelID = params.channel_id?.trim(); + if (!channelID) { + throw new Error("Channel ID is required."); + } + + let offset: number | undefined; + { + const inputOffset = searchParams.get("o"); + + if (inputOffset) { + offset = parseOffset(inputOffset, 150); + } + } + + const channels = await fetchDiscordServer(serverID); + const messages = await fetchDiscordChannel(channelID, offset); + + return { + serverID, + channelID, + channels, + messages, + offset, + }; +} diff --git a/client/src/pages/discord.html b/client/src/pages/discord.html deleted file mode 100644 index 328a52e..0000000 --- a/client/src/pages/discord.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - {{ g.site_name }} - - - - - - - -
    -
    -
    - -
    -
    - - - - \ No newline at end of file diff --git a/client/src/pages/discord.module.scss b/client/src/pages/discord.module.scss new file mode 100644 index 0000000..13cf119 --- /dev/null +++ b/client/src/pages/discord.module.scss @@ -0,0 +1,5 @@ +.main { + display: flex; + flex-direction: row; + gap: 1em; +} diff --git a/client/src/pages/discord.tsx b/client/src/pages/discord.tsx new file mode 100644 index 0000000..d442e4a --- /dev/null +++ b/client/src/pages/discord.tsx @@ -0,0 +1,39 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { fetchDiscordServer } from "#api/profiles/discord"; +import { PageSkeleton } from "#components/pages"; +import { DiscordServer } from "#entities/posts"; + +import styles from "./discord.module.scss"; + +interface IProps { + serverID: string; + channels: { id: string; name: string }[]; +} + +export function DiscordServerPage() { + const { serverID, channels } = useLoaderData() as IProps; + const title = "Discord server"; + const heading = "Discord Server"; + + return ( + +
    + +
    +
    + ); +} + +export async function loader({ params }: LoaderFunctionArgs): Promise { + const serverID = params.server_id?.trim(); + if (!serverID) { + throw new Error("Server ID is required."); + } + + const channels = await fetchDiscordServer(serverID); + + return { + serverID, + channels, + }; +} diff --git a/client/src/pages/dmca.tsx b/client/src/pages/dmca.tsx new file mode 100644 index 0000000..972053f --- /dev/null +++ b/client/src/pages/dmca.tsx @@ -0,0 +1,144 @@ +import { PageSkeleton } from "#components/pages"; + +export function DMCAPage() { + const title = "DMCA Notice"; + const heading = "DMCA Notice"; + + return ( + +

    + Please allow up to 3-5 working days for your request to be processed. + You may not receive a response unless further information is needed from + you. +

    + +

    + This information is required by the DMCA process. If any required + information is falsified or omitted, your request may be not be + processed. +

    + +

    + Notices may be shared to 3rd party's for due diligence and transparency + purposes +

    + +

    + Please be aware that information that is not protected by copyright may + not be removed from the site. This includes AI generated content, + creator tags, creator profiles, wiki pages, links to social media + profiles or other websites, links to works on other websites, and other + factual information not protected by copyright law. +

    + +

    + Please note that we will only honor content removal requests from the + following parties: +

    + +
      +
    • Legal copyright holder of the content
    • +
    • Legal copyright holder of the character(s)
    • +
    • The commissioner(s) of the content
    • +
    + +

    + Please consult the guide below for further information regarding when + it's acceptable to submit a takedown request and when it's not. +

    + +

    + You SHOULD submit a takedown request if you are one of + the following: +

    + +
      +
    • Creator wanting their own content removed.
    • +
    • + Character owner wanting content featuring their character removed. +
    • +
    • A commissioner who has paid for the content in question.
    • +
    • + Publisher wanting a publication or excerpts of a publication removed. +
    • +
    + +

    + You SHOULD NOT submit a takedown request if you are one + of the following: +

    + +
      +
    • + User wanting content removed because they believe it breaks a site + rule. You should instead use the site's Flag For Deletion tool (found + on every post's page) for each post that you wish to dispute. +
    • +
    • + Friend, relative, or fan of an creator, character owner, or + commissioner who wishes to act as a middlemen for the creator, + character owner, or commissioner. +
    • +
    • + Commissioner or character owner wanting to remove art that the creator + themselves has posted. In those cases please ask the creator to file a + takedown request for you. +
    • +
    + +

    Your email must include the following:

    + +
      +
    • + Your contact information, including your name, physical address, phone + number, and email address. +
    • +
    • + Identification of the material you wish to have removed, with enough + information to locate the material. For example, a list of links to + each post on Kemono you wish to have removed. Screenshots or simply + listing your creator tag, creator name, or social media accounts are + not sufficient to locate the exact material you wish to have removed. +
    • +
    • + Identification of the copyrighted work you claim is being infringed. + For example, for each Kemono post you claim infringes on your + copyright, a link to where the original work was posted on your Pixiv, + Twitter, or other social media accounts or personal websites. +
    • +
    • + A statement that you have a good faith belief that the use of the work + you believe is being infringed was not authorized by the copyright + owner, an agent of the owner, or the law. +
    • +
    • + A statement that everything contained in the takedown notice is + accurate and that, under penalty of perjury, you are the copyright + owner or have permission to act on the copyright owner’s behalf. +
    • +
    • + Your signature. This must be your full legal name, not a pseudonym or + creator handle. +
    • +
    + +

    + This notice can be sent via email to us on our Legal Inquiries address + found on the contact page. +

    + +

    + + Failure to follow these instructions, or emailing it to the wrong + department, or emailing all three departments at once will get your + notice discarded. + +

    + +

    + For further assistance, or for any questions regarding takedowns, please{" "} + contact us. +

    +
    + ); +} diff --git a/client/src/pages/documentation/api.module.scss b/client/src/pages/documentation/api.module.scss new file mode 100644 index 0000000..6abe3a2 --- /dev/null +++ b/client/src/pages/documentation/api.module.scss @@ -0,0 +1,4 @@ +.block { + background: #ffffff; + width: 100%; +} diff --git a/client/src/pages/documentation/api.tsx b/client/src/pages/documentation/api.tsx new file mode 100644 index 0000000..d4b65f0 --- /dev/null +++ b/client/src/pages/documentation/api.tsx @@ -0,0 +1,27 @@ +import { Helmet } from "react-helmet-async"; +import SwaggerUI from "swagger-ui-react"; +import { PageSkeleton } from "#components/pages"; + +// TODO: path alias it after moving out of server folder +import schema from "../../../../src/pages/api/schema.yaml"; + +import "swagger-ui-react/swagger-ui.css"; +import styles from "./api.module.scss"; + +export function Component() { + const title = "API documenation"; + const heading = "API Documenation"; + + return ( + + + + +
    + +
    +
    + ); +} + +Component.displayName = "APIDocumentationPage"; diff --git a/client/src/pages/error.html b/client/src/pages/error.html deleted file mode 100644 index d6a82e7..0000000 --- a/client/src/pages/error.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'components/shell.html' %} -{% block content %} -

    Error

    -

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

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

    Redirecting you back...

    - {% endif %} -{% endblock %} \ No newline at end of file diff --git a/client/src/pages/fanboximports.tsx b/client/src/pages/fanboximports.tsx new file mode 100644 index 0000000..0c0ab57 --- /dev/null +++ b/client/src/pages/fanboximports.tsx @@ -0,0 +1,38 @@ +import { PageSkeleton } from "#components/pages"; + +export function FanboxImportsPage() { + const title = "Fanbox Importer"; + const heading = "Fanbox Importer"; + + return ( + +

    + Auto-Import is disabled, please import the keys at your own discretion. +

    +

    + While we made sure to take steps to prevent detection and association of + the accounts with our importing, we can not make any guarantees. +

    +

    + This is a test run that is not optimized for performance but rather for + consistency and human emulation. +

    +

    + The system is accessible in this testing phase, if you submit your + fanbox key it should start importing most of your data with the improved + importer logic. +

    +

    + Once the testing is finished, we will scale up the services required for + more performant importing. +

    +

    + In case of you getting a notification from fanbox, do contact us via + Telegram and Chan that can be found in the bottom of the menu to the + left, or send an email to{" "} + contact@kemono.su +

    +

    Do include the content/screenshot of the notification from fanbox.

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

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

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

    Nobody here but us chickens!

    -

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

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

    Nobody here but us chickens!

    -

    - There are no more posts. -

    - {% endfor %} - {% endcall %} - -
    - {% include 'components/paginator.html' %} -
    - {% endif %} - {% endif %} - {{ footer_ad() }} -
    -{% endblock content %} diff --git a/client/src/pages/favorites.scss b/client/src/pages/favorites.scss deleted file mode 100644 index 8b279b6..0000000 --- a/client/src/pages/favorites.scss +++ /dev/null @@ -1,16 +0,0 @@ -.site-section--favorites { - div.dropdowns { - display: grid; - grid-template-columns: max-content max-content; - grid-gap: 5px; - justify-content: center; - } - - div.dropdowns > label { - text-align: right; - } - - div.dropdowns > label:after { - content: ":"; - } -} diff --git a/client/src/pages/favorites.tsx b/client/src/pages/favorites.tsx new file mode 100644 index 0000000..bc0193d --- /dev/null +++ b/client/src/pages/favorites.tsx @@ -0,0 +1,6 @@ +import { redirect } from "react-router-dom"; +import { createAccountFavoriteProfilesPageURL } from "#lib/urls"; + +export async function loader() { + return redirect(String(createAccountFavoriteProfilesPageURL())); +} diff --git a/client/src/pages/gumroad-and-co.tsx b/client/src/pages/gumroad-and-co.tsx new file mode 100644 index 0000000..780b755 --- /dev/null +++ b/client/src/pages/gumroad-and-co.tsx @@ -0,0 +1,54 @@ +import { PageSkeleton } from "#components/pages"; + +export function GumroadAndCoPage() { + const title = "Gumroad and Co"; + const heading = "Gumroad and Co"; + + return ( + +

    TL;DR (browse ») == archive password sharing and validation

    + +

    + To make a long story short, you are now able to list (not view) the + contents of the archives and submit passwords for archives that are + password protected. +
    + Upon submitting the correct password, the page will re-load and display + the valid password. +

    + +

    + + As for gumroad, they sure are deep-throating the payment processors, + shaft and balls. + {" "} + We'll see how it goes, but we do know where it'll end. +
    + Either way, we didn't expect this to happen this fast. The importer is + being worked on every day to scrape whatever is missing. +
    + And to you, who are submitting keys, please check if your key contains + "...", browsers are kind of shit and will abbreviate anything that is + "too long". +

    +

    + If you see something missing from your imports (contents, posts, reward + text, etc.), do contact us via Telegram and Chan, which can be found at + the bottom of the menu to the left, or send an email to{" "} + contact@kemono.su +

    + +

    + In regard to Account Linking. You do not need to cross-link (A{">"}B,B + {">"}A) the profiles, a single direction is more than enough: (A{">"}B,A + {">"}C,A{">"}D) or (A{">"}B,B{">"}C,C{">"}D). +

    + +

    + Also, I think it was never mentioned, but there exists a cookie + "thumbSize" for this site, which controls the size of the displayed + tiles. +

    +
    + ); +} diff --git a/client/src/pages/help/faq.html b/client/src/pages/help/faq.html deleted file mode 100644 index c7788c1..0000000 --- a/client/src/pages/help/faq.html +++ /dev/null @@ -1,48 +0,0 @@ -{% extends 'components/shell.html' %} - -{% import 'components/site.html' as site %} -{% from 'components/navigation/local.html' import local_nav, local_list, local_item %} -{% from 'components/lists/faq.html' import faq_list, faq_section, faq_question, faq_answer %} - -{% set page_title = 'Frequently Asked Questions | ' ~ g.site_name %} - -{% block title %} - - {{ page_title }} - -{% endblock title %} - -{% block content %} -{% call site.section('help-faq', 'Frequently Asked Questions') %} - {% call local_nav(id="faq-nav") %} -

    Table of contents

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

    FAQ

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

    Open Source

    -

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

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

    Posts

    -

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

    -

    Searching Posts

    -

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

    -

    Flagging

    -

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

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

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

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

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

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

    {{ announcement.title }}

    -
    {{ announcement.date }}
    -
    -

    - {{ announcement.content|safe }} -

    -
    - {% endfor %} - {% endcall %} -{% endblock %} diff --git a/client/src/pages/home.scss b/client/src/pages/home.scss index 448eb1c..14f4873 100644 --- a/client/src/pages/home.scss +++ b/client/src/pages/home.scss @@ -8,13 +8,12 @@ overflow-y: hidden; position: relative; display: flex; - flex-direction: row; + flex-flow: row nowrap; box-shadow: 0 1px 3px rgb(0 0 0 / 25%); - align-items: center; - justify-content: flex-end; min-height: 450px; background-color: rgba(0, 0, 0, 0.7); - margin: 0.5rem; + padding: $size-small; + @media (max-width: $width-tablet) { background-color: #3b3e44; } @@ -23,7 +22,6 @@ .jumbo-welcome-mascot { transform: translateZ(0); display: flex; - max-height: 450px; width: 100%; height: 100%; @media (max-width: $width-tablet) { diff --git a/client/src/pages/home.tsx b/client/src/pages/home.tsx new file mode 100644 index 0000000..2d62361 --- /dev/null +++ b/client/src/pages/home.tsx @@ -0,0 +1,102 @@ +import { + ARTISTS_OR_CREATORS, + BANNER_WELCOME, + HOME_BACKGROUND_IMAGE, + HOME_LOGO_PATH, + HOME_MASCOT_PATH, + HOME_WELCOME_CREDITS, + SITE_NAME, +} from "#env/env-vars"; +import { AVAILABLE_PAYSITE_LIST } from "#env/derived-vars"; +import { PageSkeleton } from "#components/pages"; +import { KemonoLink } from "#components/links"; + +export function HomePage() { + const title = "Welcome"; + + return ( + + {!BANNER_WELCOME ? undefined : ( +
    + )} + +
    +
    + + {!HOME_MASCOT_PATH ? undefined : ( +
    +
    + +
    +
    + )} + + +
    + + {BUNDLER_ENV_HOME_ANNOUNCEMENTS?.map((announcement) => ( +
    +
    +

    {announcement.title}

    +
    {announcement.date}
    +
    +

    +

    + ))} + + ); +} + +function Description() { + return ( +
    + {!HOME_LOGO_PATH ? undefined : ( +
    + +
    + )} + +

    + {SITE_NAME} is a public archiver for: +

    + +
      + {AVAILABLE_PAYSITE_LIST.map((paysite, index) => ( +
    • {paysite.title}
    • + ))} +
    + +

    + Contributors here upload content and share it here for easy searching + and organization. To get started viewing content, either search for + creators on the{" "} + + {ARTISTS_OR_CREATORS.toLowerCase()} page + + , or search for content on the{" "} + posts page. If you want to + contribute content, head over to the{" "} + import page. +

    + + {!HOME_WELCOME_CREDITS ? undefined : ( +
    + )} +
    + ); +} diff --git a/client/src/pages/importer_list.html b/client/src/pages/importer_list.html deleted file mode 100644 index d934496..0000000 --- a/client/src/pages/importer_list.html +++ /dev/null @@ -1,269 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/links.html' import email_link %} -{% from 'components/tooltip.html' import register_message %} - -{% block title %} - Import paywall posts/comments/DMs to {{ g.site_name }}. -{% endblock title %} - -{% block content %} -
    -

    Import from paysite

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

    Important information

    -

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

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

    Fantia

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

    Auto-import

    -

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

    -
    -{% endblock content %} - -{% block components %} - {{ register_message("DM import") }} -{% endblock components %} diff --git a/client/src/pages/importer_list.js b/client/src/pages/importer_list.js deleted file mode 100644 index 1d7f68f..0000000 --- a/client/src/pages/importer_list.js +++ /dev/null @@ -1,174 +0,0 @@ -import { registerMessage, showTooltip } from "@wp/components"; - -/** - * @param {HTMLElement} section - */ -export function importerPage(section) { - const isLoggedIn = localStorage.getItem("logged_in") === "yes"; - /** - * @type {HTMLFormElement} - */ - const form = document.forms["import-list"]; - const currentService = form.querySelector("#service").value; - - /** - * @type {Record} - */ - const noteLookup = { - fansly: form.querySelector(".fansly__notes"), - onlyfans: form.querySelector(".onlyfans__notes"), - fanbox: form.querySelector(".fanbox__notes"), - candfans: form.querySelector(".candfans__notes"), - other: form.querySelector(".other__notes"), - }; - switchKeyNotesToggle(currentService, noteLookup); - form.addEventListener( - "change", - processChangeForService((service) => switchKeyNotesToggle(service, noteLookup)), - ); - - /** - * @type {Record} - */ - const sectionLookup = { - discord: form.querySelector("#discord-section"), - onlyfans: form.querySelector("#onlyfans-section"), - }; - displayOnlyActiveInputSectionsFieldsRequired(currentService, sectionLookup); - form.addEventListener( - "change", - processChangeForService((service) => displayOnlyActiveInputSectionsFieldsRequired(service, sectionLookup)), - ); - - /** - * @type {Record} - */ - const DMLookup = { - patreon: true, - fansly: true, - }; - ActivateDMSection(currentService, DMLookup, form.querySelector("#dm-consent")); - form.addEventListener( - "change", - processChangeForService((service) => ActivateDMSection(service, DMLookup, form.querySelector("#dm-consent"))), - ); - ActivateFanboxTestConsentSection(currentService, form.querySelector("#fanbox-test-consent")); - form.addEventListener( - "change", - processChangeForService((service) => ActivateFanboxTestConsentSection(service, form.querySelector("#fanbox-test-consent"))), - ); - - form.addEventListener("submit", handleSubmit(isLoggedIn)); - document.getElementById("user-agent").value = navigator.userAgent; -} - -/** - * @param {function} procesingFunction - * @returns {(event: Event) => void} - */ -function processChangeForService(procesingFunction) { - return (event) => { - if (event.target.id === "service") { - event.stopPropagation(); - /** - * @type {String} - */ - const selectValue = event.target.value; - procesingFunction(selectValue); - } - }; -} - -/** - * @param {String} selectService - * @param {Record} sectionLookup - * @returns {void} - */ -function displayOnlyActiveInputSectionsFieldsRequired(selectService, sectionLookup) { - let activeSection = sectionLookup[selectService]; - Object.values(sectionLookup).forEach((section) => section.classList.add("form__section--hidden")); - Object.values(sectionLookup).forEach((section) => - section.querySelectorAll("input").forEach((input) => (input.required = false)), - ); - if (activeSection) { - activeSection.classList.remove("form__section--hidden"); - activeSection.querySelectorAll("input").forEach((input) => (input.required = true)); - } -} - -/** - * @param {String} selectService - * @param {Record} noteLookup - * @returns {void} - */ -function switchKeyNotesToggle(selectService, noteLookup) { - Object.values(noteLookup).forEach((notes) => (notes.hidden = true)); - if (noteLookup[selectService]) { - noteLookup[selectService].hidden = false; - } else { - noteLookup["other"].hidden = false; - } -} - -/** - * @param {String} selectService - * @param {Record} DMLookup - * @param {HTMLElement} dmSection - * @returns {void} - */ -function ActivateDMSection(selectService, DMLookup, dmSection) { - let isActive = DMLookup[selectService]; - if (isActive) { - dmSection.classList.remove("form__section--hidden"); - dmSection.querySelector("input").checked = true; - } else { - dmSection.classList.add("form__section--hidden"); - dmSection.querySelector("input").checked = false; - } -} - -/** - * @param {String} selectService - * @param {HTMLElement} fanboxTestConsentSection - * @returns {void} - */ -function ActivateFanboxTestConsentSection(selectService, fanboxTestConsentSection) { - let isActive = selectService === "fanbox"; - if (isActive) { - fanboxTestConsentSection.classList.remove("form__section--hidden"); - fanboxTestConsentSection.querySelector("input").checked = false; - } else { - fanboxTestConsentSection.classList.add("form__section--hidden"); - fanboxTestConsentSection.querySelector("input").checked = false; - } -} - -/** - * @param {boolean} isLoggedIn - * @returns {(event: Event) => void} - */ -function handleSubmit(isLoggedIn) { - return (event) => { - /** - * @type {HTMLFormElement} - */ - const form = event.target; - /** - * @type {HTMLInputElement} - */ - const dmConsent = form.elements["save-dms"]; - const fanboxTestConsent = form.elements["fanbox-test-consent"]; - const service = form.elements["service"]; - - if (service.value === "patreon" && dmConsent.checked && !isLoggedIn) { - event.preventDefault(); - showTooltip(dmConsent, registerMessage(null)); - } - console.log(!fanboxTestConsent.checked); - console.log(service.value); - if (service.value === "fanbox" && !fanboxTestConsent.checked) { - event.preventDefault(); - showTooltip(fanboxTestConsent, registerMessage("You need to agree.")); - } - }; -} diff --git a/client/src/pages/importer_list.tsx b/client/src/pages/importer_list.tsx new file mode 100644 index 0000000..d3c4b8e --- /dev/null +++ b/client/src/pages/importer_list.tsx @@ -0,0 +1,470 @@ +import clsx from "clsx"; +import { useState } from "react"; +import { ActionFunctionArgs, redirect } from "react-router-dom"; +import { PAYSITE_LIST, SITE_NAME } from "#env/env-vars"; +import { createImporterStatusPageURL } from "#lib/urls"; +import { fetchCreateImport } from "#api/imports"; +import { useClient } from "#hooks"; +import { PageSkeleton } from "#components/pages"; +import { FormRouter, FormSection } from "#components/forms"; +import { paysites } from "#entities/paysites"; +import { isRegisteredAccount } from "#entities/account"; + +const dmLookup = ["patreon", "fansly"]; + +/** + * TODO: split into separate pages per service + */ +export function ImporterPage() { + const isClient = useClient(); + const [selectedService, changeSelectedService] = useState(PAYSITE_LIST[0]); + const title = "Import paywall posts/comments/DMs"; + const heading = "Import from Paysite"; + + return ( + + "Submit key"} + > +
    + + +
    + + + + + + + Learn how to get your session key. + + + + + + + + + + + + {selectedService !== "onlyfans" ? undefined : ( +
    +
    + + + Your user ID. Can be found in Cookies -{">"} auth_id. + +
    + +
    + + + BC token. Can be found in Local Storage -{">"} bcTokenSha, or + the headers of an XHR request -{">"} x-bc. +
    + Paste this on the console{" "} + + localStorage.bcTokenSha + +
    +
    + +
    + + + + This needs to be set to the{" "} + + User-Agent + + of the last device that logged into your OnlyFans account; leave + it as the default value if you are on it right now. + +
    +
    + )} + +
    + + + comma separated, no spaces +
    + + + + + + + + +
    + +

    Important information

    +

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

    + + {!PAYSITE_LIST.includes("fantia") ? undefined : ( + <> +

    Fantia

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

    Auto-import

    +

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

    +
    + ); +} + +export async function action({ request }: ActionFunctionArgs) { + try { + if (request.method !== "POST") { + throw new Error(`Unknown method "${request.method}".`); + } + + const data = await request.formData(); + + const service = (data.get("service") as string | null)?.trim(); + { + if (!service) { + throw new Error("Service name is required."); + } + + if (!PAYSITE_LIST.includes(service)) { + throw new Error(`Unknown service "${service}".`); + } + } + + let isDMConsentChecked: boolean | undefined = undefined; + { + const inputValue = (data.get("save_dms") as string | null)?.trim(); + + if (inputValue === "1") { + isDMConsentChecked = true; + } + } + + if ( + service === "patreon" && + isDMConsentChecked && + !(await isRegisteredAccount()) + ) { + throw new Error("You must be registered to import DMs."); + } + + let isFanboxConsentChecked: boolean | undefined = undefined; + { + const inputValue = ( + data.get("fanbox-test-consent") as string | null + )?.trim(); + + if (inputValue === "1") { + isFanboxConsentChecked = true; + } + } + + if (service === "fanbox" && !isFanboxConsentChecked) { + throw new Error("You need to agree to fanbox test imports."); + } + + const sessionKey = (data.get("session_key") as string | null)?.trim(); + { + if (!sessionKey) { + throw new Error("Session key is required."); + } + } + + const saveSessionKey = ( + data.get("save_session_key") as string | null + )?.trim(); + + const autoImport = (data.get("auto_import") as string | null)?.trim(); + + const userAgent = (data.get("user_agent") as string | null)?.trim(); + + const onlyfansXBC = (data.get("x-bc") as string | null)?.trim(); + + const onlyfansAuthID = (data.get("auth_id") as string | null)?.trim(); + + const discordChannelIDs = ( + data.get("channel_ids") as string | null + )?.trim(); + + const { import_id } = await fetchCreateImport({ + service, + session_key: sessionKey, + save_session_key: saveSessionKey, + auto_import: autoImport, + save_dms: isDMConsentChecked, + user_agent: userAgent, + "x-bc": onlyfansXBC, + auth_id: onlyfansAuthID, + channel_ids: discordChannelIDs, + }); + + return redirect(String(createImporterStatusPageURL(import_id))); + } catch (error) { + return error; + } +} diff --git a/client/src/pages/importer_ok.html b/client/src/pages/importer_ok.html deleted file mode 100644 index 9453195..0000000 --- a/client/src/pages/importer_ok.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends 'components/shell.html' %} -{% block content %} -
    - {% include "components/importer_states.html" %} -

    Success

    -

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

    -
    -{% endblock %} \ No newline at end of file diff --git a/client/src/pages/importer_ok.tsx b/client/src/pages/importer_ok.tsx new file mode 100644 index 0000000..dae601b --- /dev/null +++ b/client/src/pages/importer_ok.tsx @@ -0,0 +1,14 @@ +import { PageSkeleton } from "#components/pages"; + +export function ImporterOKPage() { + return ( + +

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

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

    Importer logs for {{ props.import_id }}

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

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

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

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

    -
      -
    -
    -
    -{% endblock content %} - -{% block components %} - {{ log_item() }} -{% endblock components %} - -{% macro log_item() %} -
  • -{% endmacro %} diff --git a/client/src/pages/importer_status.js b/client/src/pages/importer_status.js deleted file mode 100644 index 8fa917e..0000000 --- a/client/src/pages/importer_status.js +++ /dev/null @@ -1,164 +0,0 @@ -import { kemonoAPI } from "@wp/api"; -import { createComponent } from "@wp/js/component-factory"; -import { waitAsync } from "@wp/utils"; -import { initPendingReviewDms } from "@wp/js/pending-review-dms"; - -/** - * @typedef Stats - * @property {string} importID - * @property {HTMLSpanElement} status - * @property {HTMLSpanElement} count - * @property {number} cooldown - * @property {number} retries - */ - -/** - * TODOs: - * - service heuristics - * - error handling - * @param {HTMLElement} section - */ -export async function importerStatusPage(section) { - /** - * @type {HTMLDivElement} - */ - const importInfo = section.querySelector(".import__info"); - /** - * @type {[HTMLDivElement, HTMLDivElement]} - */ - const [importStats, buttonPanel] = importInfo.children; - const [status, count] = importStats.children; - /** - * @type {Stats} - */ - const stats = { - importID: document.head.querySelector("meta[name='import_id']").content, - status: status.children[1], - count: count.children[1], - cooldown: 5000, - retries: 0, - }; - /** - * @type {HTMLParagraphElement} - */ - const loadingPlaceholder = section.querySelector(".loading-placeholder"); - /** - * @type {HTMLOListElement} - */ - const logList = section.querySelector(".log-list"); - - initButtons(buttonPanel, logList); - const logs = await kemonoAPI.api.logs(stats.importID); - - if (logs) { - populateLogList(logs, logList, loadingPlaceholder); - stats.status.textContent = "In Progress"; - stats.count.textContent = logs.length; - count.classList.remove("import__count--invisible"); - - initPendingReviewDms(true).then(() => {}) - await waitAsync(stats.cooldown); - await updateLogList(logs, logList, stats); - } else { - loadingPlaceholder.classList.add("loading-placeholder--complete"); - alert("Failed to fetch the logs, try reloading the page."); - } -} - -/** - * @param {HTMLDivElement} buttonPanel - * @param {HTMLOListElement} logList - */ -function initButtons(buttonPanel, logList) { - /** - * @type {HTMLButtonElement[]} - */ - const [reverseButton] = buttonPanel.children; - - reverseButton.addEventListener("click", reverseList(logList)); -} - -/** - * @param {HTMLOListElement} logList - * @returns {(event: MouseEvent) => void} - */ -function reverseList(logList) { - return (event) => { - logList.classList.toggle("log-list--reversed"); - }; -} - -/** - * @param {string[]} logs - * @param {HTMLOListElement} logList - * @param {HTMLParagraphElement} loadingItem - */ -function populateLogList(logs, logList, loadingItem) { - const fragment = document.createDocumentFragment(); - - logs.forEach((log) => { - const logItem = LogItem(log); - fragment.appendChild(logItem); - }); - - loadingItem.classList.add("loading-placeholder--complete"); - logList.appendChild(fragment); - logList.classList.add("log-list--loaded"); -} - -/** - * TODO: finishing condition. - * @param {string[]} logs - * @param {HTMLOListElement} logList - * @param {Stats} stats - */ -async function updateLogList(logs, logList, stats) { - let newLogs = await kemonoAPI.api.logs(stats.importID); - - if (!newLogs) { - if (stats.retries === 5) { - stats.status.textContent = "Fatal Error"; - return; - } - - await waitAsync(stats.cooldown); - stats.retries++; - return await updateLogList(logs, logList, stats); - } - - const diff = newLogs.length - logs.length; - - if (diff === 0) { - stats.cooldown = stats.cooldown * 2; - await waitAsync(stats.cooldown); - initPendingReviewDms(false, 1).then(() => {}) - return await updateLogList(logs, logList, stats); - } - - const diffLogs = newLogs.slice(newLogs.length - diff); - const fragment = document.createDocumentFragment(); - diffLogs.forEach((log) => { - const logItem = LogItem(log); - fragment.appendChild(logItem); - }); - logs.push(...diffLogs); - logList.appendChild(fragment); - stats.count.textContent = logs.length; - - await waitAsync(stats.cooldown); - return await updateLogList(logs, logList, stats); -} - -/** - * @param {string} message - */ -function LogItem(message) { - /** - * @type {HTMLLIElement} - */ - const item = createComponent("log-list__item"); - - item.textContent = message; - - return item; -} diff --git a/client/src/pages/importer_status.tsx b/client/src/pages/importer_status.tsx new file mode 100644 index 0000000..66e92ea --- /dev/null +++ b/client/src/pages/importer_status.tsx @@ -0,0 +1,167 @@ +import clsx from "clsx"; +import { useState } from "react"; +import { + LoaderFunctionArgs, + useLoaderData, + useNavigate, +} from "react-router-dom"; +import { createImporterStatusPageURL } from "#lib/urls"; +import { fetchHasPendingDMs } from "#api/dms"; +import { fetchImportLogs } from "#api/imports"; +import { getLocalStorageItem, setLocalStorageItem } from "#storage/local"; +import { useInterval } from "#hooks"; +import { PageSkeleton } from "#components/pages"; +import { LoadingIcon } from "#components/loading"; +import { Button } from "#components/buttons"; + +interface IProps { + importID: string; + isDMS?: boolean; + logs: string[]; +} + +export function ImporterStatusPage() { + const { importID, isDMS, logs } = useLoaderData() as IProps; + const navigate = useNavigate(); + const [isReversed, switchReversed] = useState(false); + const title = `Import ${importID}`; + const heading = `Importer logs for ${importID}`; + const status = logs.length === 0 ? "Fetching" : "In Progress"; + const cooldown = 120_000; + + useInterval(() => { + navigate(String(createImporterStatusPageURL(importID))); + }, cooldown); + + return ( + + {isDMS && ( +
    + Hey! +

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

    +
    + )} + +
    +
    +
    +
    + Status: + {status} +
    + +
    + Total: + {logs.length} +
    +
    + +
    + +
    +
    + +

    + Wait until logs load... +

    + +
      + {logs.length !== 0 && + logs.map((message, index) => ( +
    1. + {message} +
    2. + ))} +
    +
    +
    + ); +} + +async function initPendingReviewDms( + forceReload = false, + minutesForRecheck = 30 +) { + let hasPendingReviewDms = + getLocalStorageItem("has_pending_review_dms") === "true"; + const lastCheckedHasPendingReviewDms = parseInt( + getLocalStorageItem("last_checked_has_pending_review_dms") ?? "0", + 10 + ); + + if ( + forceReload || + !lastCheckedHasPendingReviewDms || + lastCheckedHasPendingReviewDms < Date.now() - minutesForRecheck * 60 * 1000 + ) { + /** + * @type {string} + */ + hasPendingReviewDms = await fetchHasPendingDMs(); + setLocalStorageItem("has_pending_review_dms", String(hasPendingReviewDms)); + localStorage.setItem( + "last_checked_has_pending_review_dms", + Date.now().toString() + ); + } +} + +export async function loader({ + params, + request, +}: LoaderFunctionArgs): Promise { + const searchparams = new URL(request.url).searchParams; + + const importID = params.import_id?.trim(); + if (!importID) { + throw new Error("Import ID is required."); + } + + let isDMS: boolean | undefined = undefined; + { + const inputValue = Boolean(searchparams.get("dms")?.trim()); + if (inputValue) { + isDMS = inputValue; + } + } + + const logs = await fetchImportLogs(importID); + + if (logs.length !== 0) { + initPendingReviewDms(true); + } + + return { + importID, + isDMS, + logs, + }; +} diff --git a/client/src/pages/importer_tutorial.html b/client/src/pages/importer_tutorial.html deleted file mode 100644 index f3873ee..0000000 --- a/client/src/pages/importer_tutorial.html +++ /dev/null @@ -1,76 +0,0 @@ -{% extends 'components/shell.html' %} -{% block content %} -{% include "components/importer_states.html" %} -
    -

    How to get your session key

    -

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

    -

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

    -

    Below are the respective cookies for the supported paysites.

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

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

    -

    Chrome

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

    Safari

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

    Firefox

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

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

    -

    Discord

    -

    Getting your token

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

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

    -

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

    -
    -{% endblock %} \ No newline at end of file diff --git a/client/src/pages/importer_tutorial.tsx b/client/src/pages/importer_tutorial.tsx new file mode 100644 index 0000000..d3c5384 --- /dev/null +++ b/client/src/pages/importer_tutorial.tsx @@ -0,0 +1,154 @@ +import { ARTISTS_OR_CREATORS, SITE_NAME } from "#env/env-vars"; +import { PageSkeleton } from "#components/pages"; + +export function ImporterTutorialPage() { + return ( + +

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

    +

    + {SITE_NAME} needs your session key in order to access posts from the{" "} + {ARTISTS_OR_CREATORS.toLowerCase()} you are subscribed to. +

    +

    Below are the respective cookies for the supported paysites.

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

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

    + +

    Chrome

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

    Safari

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

    Firefox

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

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

    +

    Discord

    +

    Getting your token

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

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

    +

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

    +
    + ); +} diff --git a/client/src/pages/importer_tutorial_fanbox.html b/client/src/pages/importer_tutorial_fanbox.html deleted file mode 100644 index 817756c..0000000 --- a/client/src/pages/importer_tutorial_fanbox.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends 'components/shell.html' %} -{% block content %} -{% include "components/importer_states.html" %} -
    -

    How to get your Fanbox session key

    -

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

    - -

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

    -

    Chrome

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

    Safari

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

    Firefox

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

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

    -
    -{% endblock %} \ No newline at end of file diff --git a/client/src/pages/importer_tutorial_fanbox.tsx b/client/src/pages/importer_tutorial_fanbox.tsx new file mode 100644 index 0000000..1dbbb4e --- /dev/null +++ b/client/src/pages/importer_tutorial_fanbox.tsx @@ -0,0 +1,79 @@ +import { ARTISTS_OR_CREATORS, SITE_NAME } from "#env/env-vars"; +import { PageSkeleton } from "#components/pages"; + +export function ImporterTutorialFanboxPage() { + return ( + +

    + {SITE_NAME} needs your session key in order to access posts from the{" "} + {ARTISTS_OR_CREATORS.toLowerCase()} you are subscribed to. +

    + +

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

    + +

    Chrome

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

    Safari

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

    Firefox

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

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

    +
    + ); +} diff --git a/client/src/pages/matrix.tsx b/client/src/pages/matrix.tsx new file mode 100644 index 0000000..52f16e1 --- /dev/null +++ b/client/src/pages/matrix.tsx @@ -0,0 +1,42 @@ +import { PageSkeleton } from "#components/pages"; + +export function MatrixPage() { + const title = "Matrix ecosystem"; + const heading = "Welcome to the Matrix ecosystem"; + + return ( + + + + ); +} diff --git a/client/src/pages/post-revision.tsx b/client/src/pages/post-revision.tsx new file mode 100644 index 0000000..b7e6f5d --- /dev/null +++ b/client/src/pages/post-revision.tsx @@ -0,0 +1,243 @@ +import { useEffect } from "react"; +import { + LoaderFunctionArgs, + useLoaderData, + defer, + Link, +} from "react-router-dom"; +import { Helmet } from "react-helmet-async"; +import { + ICONS_PREPEND, + IS_ARCHIVER_ENABLED, + KEMONO_SITE, + SITE_NAME, + VIDEO_AD, +} from "#env/env-vars"; +import { fetchArtistProfile } from "#api/profiles"; +import { fetchPostRevision, fetchPostComments } from "#api/posts"; +import { PageSkeleton } from "#components/pages"; +import { SliderAd } from "#components/ads"; +import { paysites, validatePaysite } from "#entities/paysites"; +import { + IComment, + IPost, + IPostAttachment, + IPostPreview, + IPostVideo, + PostOverview, +} from "#entities/posts"; +import { IArtistDetails } from "#entities/profiles"; + +interface IProps { + post: IPost; + profile: IArtistDetails; + revisions: Awaited< + ReturnType + >["props"]["revisions"]; + + /** + * TODO: wtf + */ + flagged?: 0; + videos?: IPostVideo[]; + attachments?: IPostAttachment[]; + previews?: IPostPreview[]; + archives_enabled?: boolean; + comments: Promise; +} + +export function PostRevisionPage() { + const { + post, + profile, + revisions, + flagged, + videos, + attachments, + previews, + archives_enabled, + comments, + } = useLoaderData() as IProps; + const paysite = paysites[post.service]; + const postTitle = post.title ?? "Untitled"; + const artistName = profile.name ?? post.user; + + const title = !profile + ? `Post "${postTitle}"` + : `Post "${postTitle}" by "${artistName}" from ${paysite.title}`; + + function handlePrevNextLinks(event: KeyboardEvent) { + switch (event.key) { + case "ArrowLeft": + document + .querySelector(".post__nav-link.prev") + ?.click(); + break; + case "ArrowRight": + document + .querySelector(".post__nav-link.next") + ?.click(); + break; + } + } + + useEffect(() => { + document.addEventListener("keydown", handlePrevNextLinks); + + import("fluid-player") + .then(({ default: fluidPlayer }) => { + Array.from(document.getElementsByTagName("video")).forEach((_, i) => { + fluidPlayer(`kemono-player${i}`, { + layoutControls: { + fillToContainer: false, + preload: "none", + }, + vastOptions: { + adList: JSON.parse(atob(VIDEO_AD)), + adTextPosition: "top left", + maxAllowedVastTagRedirects: 2, + vastAdvanced: { + vastLoadedCallback: function () {}, + noVastVideoCallback: function () {}, + vastVideoSkippedCallback: function () {}, + vastVideoEndedCallback: function () {}, + }, + }, + }); + }); + }) + .catch((error) => console.error(error)); + + return () => { + document.removeEventListener("keydown", handlePrevNextLinks); + }; + }, []); + + return ( + + + + + + {!post.published ? undefined : ( + + )} + + {/* */} + + + + + + {/* */} + + + + + + + + + ); +} + +export async function loader({ + params, +}: LoaderFunctionArgs): Promise> { + const service = params.service?.trim(); + { + if (!service) { + throw new Error("Service is required."); + } + + validatePaysite(service); + } + + const profileID = params.creator_id?.trim(); + { + if (!profileID) { + throw new Error("Profile ID is required."); + } + } + + const postID = params.post_id?.trim(); + { + if (!postID) { + throw new Error("Post ID is required."); + } + } + + const revisionID = params.revision_id?.trim(); + { + if (!revisionID) { + throw new Error("Revision ID is required."); + } + } + + const profile = await fetchArtistProfile(service, profileID); + const { post, result_attachments, result_previews, videos, props } = + await fetchPostRevision(service, profileID, postID, revisionID); + const { flagged, revisions } = props; + + const comments = fetchPostComments(service, profileID, postID); + + const pageProps = { + profile, + post, + revisions, + attachments: result_attachments, + previews: result_previews, + videos, + archives_enabled: IS_ARCHIVER_ENABLED, + flagged, + comments, + } satisfies IProps; + + return defer(pageProps); +} diff --git a/client/src/pages/post.html b/client/src/pages/post.html deleted file mode 100644 index 4918c42..0000000 --- a/client/src/pages/post.html +++ /dev/null @@ -1,420 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/timestamp.html' import timestamp %} -{% from 'components/links.html' import kemono_link, local_link %} -{% from 'components/fancy_image.html' import fancy_image, background_image %} -{% from 'components/image_link.html' import image_link %} -{% from 'components/ads.html' import middle_ad, slider_ad %} - -{% set paysite = g.paysites[props.service] %} -{% set post_title = post.title if post.title else 'Untitled' %} -{% set artist_name = props.artist.name if props.artist.name else props.user %} -{% set page_title = "\"" ~ post_title ~ "\" by " ~ props.artist.name ~ " from " ~ paysite.title ~ " | " ~ g.site_name if props.artist else post_title ~ " | " ~ g.site_name %} -{% set user_link = g.freesites.kemono.user.profile(post.service, post.user) %} -{% set user_icon = g.freesites.kemono.user.icon(post.service, post.user) %} -{% set user_banner = g.freesites.kemono.user.banner(post.service, post.user) %} -{% set post_link = g.freesites.kemono.post.link(post.service, post.user, post.id) %} - -{% block title %} - - {{ page_title }} - -{% endblock title %} - -{% block meta %} - - - - {% if post.published %} - - {% endif %} - -{% endblock meta %} - -{% block opengraph %} - - - - - -{% endblock opengraph %} - -{% block content %} -{{ slider_ad() }} -
    - {% if post %} - - -
    -
    - {{ background_image(user_banner) }} - {{ image_link( - url=user_link, - src=user_icon, - is_lazy=false, - class_name='post__user-profile' - ) }} - -
    - -
    - -
    - {{ middle_ad() }} - {{ post_view(post, result_attachments, result_previews) }} -
    - -
    -

    Comments

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

    Comment edits

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

    - {{ comment.content }} -

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

    No comments found for this post.

    - {% endfor %} -
    - -
    - - {% else %} -

    Nobody here but us chickens!

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

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

    - {% endif %} - - {% if videos %} -

    Videos

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

    Downloads

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

    Poll

    - -
    -
    -

    {{post.poll.title}}

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

    Content

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

    Files

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

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

    - {% if preview.description %} -

    - {{ preview.description }} -

    - {% endif %} -
    -
    - {% endif %} - {% endfor %} -
    - {% endif %} - -{% endmacro %} diff --git a/client/src/pages/post.js b/client/src/pages/post.js deleted file mode 100644 index ca08966..0000000 --- a/client/src/pages/post.js +++ /dev/null @@ -1,375 +0,0 @@ -import { kemonoAPI } from "@wp/api"; -import { addFavouritePost, findFavouritePost, removeFavouritePost } from "@wp/js/favorites"; -import { LoadingIcon, registerMessage, showTooltip } from "@wp/components"; -import { createComponent } from "@wp/js/component-factory"; -import { isLoggedIn } from "@wp/js/account"; -import MicroModal from "micromodal"; -import { diffChars } from "diff"; - -import "fluid-player/src/css/fluidplayer.css"; -import fluidPlayer from "fluid-player"; - -const meta = { - service: null, - user: null, - postID: null, -}; - -/** - * @param {HTMLElement} section - */ -export async function postPage(section) { - /** - * @type {HTMLElement} - */ - const buttonPanel = section.querySelector(".post__actions"); - - meta.service = document.head.querySelector("[name='service']").content; - meta.user = document.head.querySelector("[name='user']").content; - meta.postID = document.head.querySelector("[name='id']").content; - const postBody = section.querySelector(".post__body"); - - section.addEventListener("click", Expander); - - cleanupBody(postBody); - await initButtons(buttonPanel); - addRevisionHandler(); - - document.addEventListener("DOMContentLoaded", (d, ev) => {addShowTagsButton()}); - window.addEventListener("resize", (d, ev) => {addShowTagsButton()}); - - MicroModal.init(); - // diffComments(); - - Array.from(document.getElementsByTagName("video")).forEach((_, i) => { - fluidPlayer(`kemono-player${i}`, { - layoutControls: { - fillToContainer: false, - preload: "none", - }, - vastOptions: { - adList: window.videoAds, - adTextPosition: "top left", - maxAllowedVastTagRedirects: 2, - vastAdvanced: { - vastLoadedCallback: function () {}, - noVastVideoCallback: function () {}, - vastVideoSkippedCallback: function () {}, - vastVideoEndedCallback: function () {}, - }, - }, - }); - }); -} - -/** - * Apply some fixes to the content of the post. - * @param {HTMLElement} postBody - */ -function cleanupBody(postBody) { - const postContent = postBody.querySelector(".post__content"); - const isNoPostContent = !postContent || (!postContent.childElementCount && !postContent.childNodes.length); - - // content is empty - if (isNoPostContent) { - return; - } - - // pixiv post - if (meta.service === "fanbox") { - // its contents is a text node - if (!postContent.childElementCount && postContent.childNodes.length === 1) { - // wrap the text node into `
    `
    -      const [textNode] = Array.from(postContent.childNodes);
    -      const pre = document.createElement("pre");
    -      textNode.after(pre);
    -      pre.appendChild(textNode);
    -    }
    -
    -    // remove paragraphs with only `
    ` in them - const paragraphs = postContent.querySelectorAll("p"); - paragraphs.forEach((para) => { - if (para.childElementCount === 1 && para.firstElementChild.tagName === "BR") { - para.remove(); - } - }); - } - - Array.from(document.links).forEach((anchour) => { - // remove links to fanbox from the post - const hostname = anchour.hostname; - if (hostname.includes("downloads.fanbox.cc")) { - if (anchour.classList.contains("image-link")) { - anchour.remove(); - } else { - let el = document.createElement("span"); - el.textContent = anchour.textContent; - anchour.replaceWith(el); - } - } - else if (hostname.includes("fanbox.cc")){ - anchour.href = anchour.href.replace(/https?:\/\/(?:[a-zA-Z0-9-]*.)?fanbox\.cc\/(?:(?:manage\/)|(?:@[a-zA-Z\d]+\/)|)posts\/(\d+)/g, '/fanbox/post/$1'); - } - else if (hostname.includes("patreon.com")){ - anchour.href = anchour.href.replace( /https?:\/\/(?:[\w-]*.)?patreon\.com\/posts\/.*\b(\d+)\b(?:\?.*)?/g, '/patreon/post/$1'); - } - }); - - // Remove needless spaces and empty paragraphs. - /** - * @type {NodeListOf { - if (paragraph.nextElementSibling && paragraph.nextElementSibling.tagName === "BR") { - paragraph.nextElementSibling.remove(); - paragraph.remove(); - } else { - paragraph.remove(); - } - }); -} - -/** - * @param {HTMLElement} buttonPanel - */ -async function initButtons(buttonPanel) { - /** - * @type {HTMLButtonElement} - */ - const flagButton = buttonPanel.querySelector(".post__flag"); - /** - * @type {HTMLButtonElement} - */ - const favButton = createComponent("post__fav"); - const isFavorited = isLoggedIn && (await findFavouritePost(meta.service, meta.user, meta.postID)); - - if (isFavorited) { - const [icon, text] = favButton.children; - favButton.classList.add("post__fav--unfav"); - icon.textContent = "★"; - text.textContent = "Unfavorite"; - } - - if (!flagButton.classList.contains("post__flag--flagged")) { - flagButton.addEventListener("click", handleFlagging(meta.service, meta.user, meta.postID)); - } - - favButton.addEventListener("click", handleFavouriting(meta.service, meta.user, meta.postID)); - - buttonPanel.appendChild(favButton); - - document.addEventListener("keydown", (e) => { - switch (e.key) { - case "ArrowLeft": - document.querySelector(".post__nav-link.prev")?.click(); - break; - case "ArrowRight": - document.querySelector(".post__nav-link.next")?.click(); - break; - } - }); -} - -function addRevisionHandler() { - let selector = document.getElementById("post-revision-selection"); - if (selector) { - selector.addEventListener("change", (ev) => { - let revision = ev.target.selectedOptions[0].value; - if (revision) - location.pathname = `/${meta.service}/user/${meta.user}/post/${meta.postID}/revision/${revision}`; - else - location.pathname = `/${meta.service}/user/${meta.user}/post/${meta.postID}`; - }); - } -} - -/** - * @param {string} service - * @param {string} user - * @param {string} postID - * @returns {(event: MouseEvent) => Promise} - */ -function handleFlagging(service, user, postID) { - return async (event) => { - /** - * @type {HTMLButtonElement} - */ - const button = event.target; - const [icon, text] = button.children; - const loadingIcon = LoadingIcon(); - const isConfirmed = confirm( - "Are you sure you want to flag this post for reimport? Only do this if data in the post is broken/corrupted/incomplete.\nThis is not a deletion button.", - ); - - button.classList.add("post__flag--loading"); - button.disabled = true; - button.insertBefore(loadingIcon, text); - - try { - if (isConfirmed) { - const isFlagged = await kemonoAPI.posts.attemptFlag(service, user, postID); - - if (isFlagged) { - const parent = button.parentElement; - const newButton = createComponent("post__flag post__flag--flagged"); - - parent.insertBefore(newButton, button); - button.remove(); - } - } - } catch (error) { - console.error(error); - } finally { - loadingIcon.remove(); - button.disabled = false; - button.classList.remove("post__flag--loading"); - } - }; -} - -/** - * @param {string} service - * @param {string} user - * @param {string} postID - * @returns {(event: MouseEvent) => Promise} - */ -function handleFavouriting(service, user, postID) { - return async (event) => { - /** - * @type {HTMLButtonElement} - */ - const button = event.currentTarget; - const isLoggedIn = localStorage.getItem("logged_in") === "yes"; - - if (!isLoggedIn) { - showTooltip(button, registerMessage(null, "Favoriting")); - return; - } - - const [icon, text] = button.children; - const loadingIcon = LoadingIcon(); - - button.disabled = true; - button.classList.add("post__fav--loading"); - button.insertBefore(loadingIcon, text); - - try { - if (button.classList.contains("post__fav--unfav")) { - const isUnfavorited = await removeFavouritePost(service, user, postID); - - if (isUnfavorited) { - button.classList.remove("post__fav--unfav"); - icon.textContent = "☆"; - text.textContent = "Favorite"; - } - } else { - const isFavorited = await addFavouritePost(service, user, postID); - - if (isFavorited) { - button.classList.add("post__fav--unfav"); - icon.textContent = "★"; - text.textContent = "Unfavorite"; - } - } - } catch (error) { - console.error(error); - } finally { - loadingIcon.remove(); - button.disabled = false; - button.classList.remove("post__fav--loading"); - } - }; -} - -// expander.js -function Expand(c, t) { - if (!c.naturalWidth) { - return setTimeout(Expand, 10, c, t); - } - c.style.maxWidth = "100%"; - c.style.display = ""; - t.style.display = "none"; - t.style.opacity = ""; -} - -/** - * @param {MouseEvent} e - */ -function Expander(e) { - /** - * @type {HTMLElement} - */ - var t = e.target; - if (t.parentNode.classList.contains("fileThumb")) { - e.preventDefault(); - if (t.hasAttribute("data-src")) { - var c = document.createElement("img"); - c.setAttribute("src", t.parentNode.getAttribute("href")); - c.style.display = "none"; - t.parentNode.insertBefore(c, t.nextElementSibling); - t.style.opacity = "0.75"; - setTimeout(Expand, 10, c, t); - } else { - var a = t.parentNode; - a.firstChild.style.display = ""; - a.removeChild(t); - a.offsetTop < window.pageYOffset && a.scrollIntoView({ top: 0, behavior: "smooth" }); - } - } -} - -function addShowTagsButton() { - let div = document.querySelector("#post-tags > div"); - if (document.getElementById("show-tag-overflow-button")){ - document.getElementById("show-tag-overflow-button").remove(); - } - if (div && div.offsetWidth < div.scrollWidth) { - // tags overflow - let button = document.createElement("a"); - button.href = "javascript:void 0"; - button.id = "show-tag-overflow-button"; - button.textContent = "Show all »"; - button.onclick = (e) => { - if (div.classList.contains("show-overflow")) { - div.classList.remove("show-overflow"); - button.textContent = "Show all»"; - } else { - div.classList.add("show-overflow"); - button.textContent = "« Hide"; - } - } - div.parentElement.appendChild(button); - } -} - -function* pairwise(iterable) { - const iterator = iterable[Symbol.iterator](); - let a = iterator.next(); - if (a.done) return; - let b = iterator.next(); - while (!b.done) { - yield [a.value, b.value]; - a = b; - b = iterator.next(); - } -} - -function diffComments() { - let comments = Array.from(document.querySelectorAll(".comment-revisions-dialog .prose")); - let pairs = pairwise(comments); - for (let [old, new_] of pairs) { - let newSpan = document.createElement("span"); - newSpan.classList.add("prose"); - diffChars(old.textContent, new_.textContent) - .forEach(c => { - let span = document.createElement("span"); - if (c.added) { span.classList.add("added"); } - else if (c.removed) { span.classList.add("removed"); } - span.appendChild(document.createTextNode(c.value)); - newSpan.appendChild(span); - }); - - old.replaceWith(newSpan); - } -} diff --git a/client/src/pages/post.tsx b/client/src/pages/post.tsx new file mode 100644 index 0000000..5ac6868 --- /dev/null +++ b/client/src/pages/post.tsx @@ -0,0 +1,235 @@ +import { useEffect } from "react"; +import { + LoaderFunctionArgs, + useLoaderData, + defer, + Link, +} from "react-router-dom"; +import { Helmet } from "react-helmet-async"; +import { + ICONS_PREPEND, + IS_ARCHIVER_ENABLED, + KEMONO_SITE, + SITE_NAME, + VIDEO_AD, +} from "#env/env-vars"; +import { fetchArtistProfile } from "#api/profiles"; +import { fetchPost, fetchPostComments } from "#api/posts"; +import { PageSkeleton } from "#components/pages"; +import { SliderAd } from "#components/ads"; +import { paysites, validatePaysite } from "#entities/paysites"; +import { + IComment, + IPost, + IPostAttachment, + IPostPreview, + IPostVideo, + PostOverview, +} from "#entities/posts"; +import { IArtistDetails } from "#entities/profiles"; + +interface IProps { + post: IPost; + profile: IArtistDetails; + revisions: Awaited>["props"]["revisions"]; + /** + * TODO: wtf + */ + flagged?: 0; + videos?: IPostVideo[]; + attachments?: IPostAttachment[]; + previews?: IPostPreview[]; + archives_enabled?: boolean; + comments: Promise; +} + +export function PostPage() { + const { + post, + profile, + revisions, + flagged, + videos, + attachments, + previews, + archives_enabled, + comments, + } = useLoaderData() as IProps; + const paysite = paysites[post.service]; + const postTitle = post.title ?? "Untitled"; + const artistName = profile.name ?? post.user; + + const title = !profile + ? `Post "${postTitle}"` + : `Post "${postTitle}" by "${artistName}" from ${paysite.title}`; + + function handlePrevNextLinks(event: KeyboardEvent) { + switch (event.key) { + case "ArrowLeft": + document + .querySelector(".post__nav-link.prev") + ?.click(); + break; + case "ArrowRight": + document + .querySelector(".post__nav-link.next") + ?.click(); + break; + } + } + + useEffect(() => { + document.addEventListener("keydown", handlePrevNextLinks); + + import("fluid-player") + .then(({ default: fluidPlayer }) => { + Array.from(document.getElementsByTagName("video")).forEach((_, i) => { + fluidPlayer(`kemono-player${i}`, { + layoutControls: { + fillToContainer: false, + preload: "none", + }, + vastOptions: { + adList: JSON.parse(atob(VIDEO_AD)), + adTextPosition: "top left", + maxAllowedVastTagRedirects: 2, + vastAdvanced: { + vastLoadedCallback: function () {}, + noVastVideoCallback: function () {}, + vastVideoSkippedCallback: function () {}, + vastVideoEndedCallback: function () {}, + }, + }, + }); + }); + }) + .catch((error) => console.error(error)); + + return () => { + document.removeEventListener("keydown", handlePrevNextLinks); + }; + }, []); + + return ( + + + + + + {!post.published ? undefined : ( + + )} + + {/* */} + + + + + + {/* */} + + + + + + + + + ); +} + +export async function loader({ + params, +}: LoaderFunctionArgs): Promise> { + const service = params.service?.trim(); + { + if (!service) { + throw new Error("Service is required."); + } + + validatePaysite(service); + } + + const profileID = params.creator_id?.trim(); + { + if (!profileID) { + throw new Error("Profile ID is required."); + } + } + + const postID = params.post_id?.trim(); + { + if (!postID) { + throw new Error("Post ID is required."); + } + } + + const profile = await fetchArtistProfile(service, profileID); + const { post, attachments, previews, videos, props } = await fetchPost( + service, + profileID, + postID + ); + const { flagged, revisions } = props; + + const comments = fetchPostComments(service, profileID, postID); + + const pageProps = { + profile, + post, + revisions, + attachments, + previews, + videos, + archives_enabled: IS_ARCHIVER_ENABLED, + flagged, + comments, + } satisfies IProps; + + return defer(pageProps); +} diff --git a/client/src/pages/post/data.tsx b/client/src/pages/post/data.tsx new file mode 100644 index 0000000..57b67c5 --- /dev/null +++ b/client/src/pages/post/data.tsx @@ -0,0 +1,30 @@ +import { LoaderFunctionArgs, redirect } from "react-router-dom"; +import { fetchPostData } from "#api/posts"; +import { createPostURL } from "#lib/urls"; +import { HTTP_STATUS } from "#lib/http"; +import { validatePaysite } from "#entities/paysites"; + +export async function loader({ params }: LoaderFunctionArgs) { + const inputService = params.service?.trim(); + + if (!inputService) { + throw new Error("Service is required."); + } + + validatePaysite(inputService); + + const postID = params.post_id?.trim(); + + if (!postID) { + throw new Error("Post ID is required."); + } + + const { service, artist_id, post_id } = await fetchPostData( + inputService, + postID + ); + + const url = String(createPostURL(service, artist_id, post_id)); + + return redirect(url, HTTP_STATUS.SEE_OTHER); +} diff --git a/client/src/pages/posts.html b/client/src/pages/posts.html deleted file mode 100644 index ae6d223..0000000 --- a/client/src/pages/posts.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/card_list.html' import card_list %} -{% from 'components/cards/post.html' import post_card %} -{% from 'components/ads.html' import slider_ad, header_ad, footer_ad %} - -{% block content %} - {{ slider_ad() }} -
    -
    -

    Posts

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

    Nobody here but us chickens!

    -

    - There are no posts for your query. -

    -
    - {% endfor %} - {% endcall %} - - {{ footer_ad() }} - -
    - {% include 'components/paginator.html' %} -
    -
    -{% endblock %} - - diff --git a/client/src/pages/posts.js b/client/src/pages/posts.js deleted file mode 100644 index 7c10cd4..0000000 --- a/client/src/pages/posts.js +++ /dev/null @@ -1,35 +0,0 @@ -import { CardList, PostCard, registerPaginatorKeybinds } from "@wp/components"; -import { isLoggedIn } from "@wp/js/account"; -import { findFavouriteArtist, findFavouritePost } from "@wp/js/favorites"; - -/** - * @param {HTMLElement} section - */ -export function postsPage(section) { - const cardListElement = section.querySelector(".card-list"); - if (!cardListElement){ - return; - } - const { cardList, cardItems } = CardList(cardListElement); - - cardItems.forEach(async (card) => { - registerPaginatorKeybinds(); - - const { postID, userID, service } = PostCard(card); - const favPost = isLoggedIn && (await findFavouritePost(service, userID, postID)); - const favUser = isLoggedIn && (await findFavouriteArtist(userID, service)); - - if (favPost) { - card.classList.add("post-card--fav"); - } - - if (favUser) { - const postHeader = card.querySelector(".post-card__header"); - const postFooter = card.querySelector(".post-card__footer"); - - postHeader.classList.add("post-card__header--fav"); - postFooter.classList.add("post-card__footer--fav"); - /* userName.textContent = favUser.name; this is doing nothing does it */ - } - }); -} diff --git a/client/src/pages/posts.tsx b/client/src/pages/posts.tsx new file mode 100644 index 0000000..2478b14 --- /dev/null +++ b/client/src/pages/posts.tsx @@ -0,0 +1,110 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { parseOffset } from "#lib/pagination"; +import { createPostsPageURL } from "#lib/urls"; +import { fetchPosts } from "#api/posts"; +import { PageSkeleton } from "#components/pages"; +import { Paginator } from "#components/pagination"; +import { FooterAd, HeaderAd, SliderAd } from "#components/ads"; +import { CardList, PostCard } from "#components/cards"; +import { FormRouter, FormSection } from "#components/forms"; +import { IPost } from "#entities/posts"; + +interface IProps { + count: number; + trueCount: number; + offset?: number; + posts: IPost[]; + query?: string; + tags?: string[]; +} + +export function PostsPage() { + const { count, trueCount, offset, query, posts, tags } = + useLoaderData() as IProps; + const title = "Posts"; + const heading = "Posts"; + + return ( + +
    + + String(createPostsPageURL(offset, query, tags)) + } + /> + + + + + +
    + + + + + + {count === 0 ? ( +
    +

    Nobody here but us chickens!

    +

    There are no posts for your query.

    +
    + ) : ( + posts.map((post) => ( + + )) + )} +
    + + + +
    + + String(createPostsPageURL(offset, query, tags)) + } + /> +
    +
    + ); +} + +export async function loader({ request }: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + + let offset: number | undefined = undefined; + { + const parsedOffset = searchParams.get("o")?.trim(); + + if (parsedOffset) { + offset = parseOffset(parsedOffset); + } + } + + const query = searchParams.get("q")?.trim(); + const tags = searchParams.getAll("tag"); + const { count, true_count, posts } = await fetchPosts(offset, query, tags); + + return { + offset, + query, + tags, + count, + trueCount: true_count, + posts, + }; +} diff --git a/client/src/pages/posts/archive.html b/client/src/pages/posts/archive.html deleted file mode 100644 index 1edf998..0000000 --- a/client/src/pages/posts/archive.html +++ /dev/null @@ -1,53 +0,0 @@ -{% extends "components/shell.html" %} - -{% block title %} - Archived Files | {{ g.site_name }} -{% endblock %} - -{% block content %} -
    -
    -

    Archive Files

    -
    -
    - -
    -

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

    -
    - - {% for file_name in archive.file_list %} - {% if file_serving_enabled and archive.password %} - {{file_name}}
    - {% elif file_serving_enabled and archive.password == None %} - {{file_name}}
    - {% else %} - {{file_name}}
    - {% endif %} - {% else %} - {% if archive %} - Archive is empty or missing password. - {% else %} - File does not exist or is not an archive. - {% endif %} - {% endfor %} - - -{% endblock %} diff --git a/client/src/pages/posts/archive.tsx b/client/src/pages/posts/archive.tsx new file mode 100644 index 0000000..24b7edd --- /dev/null +++ b/client/src/pages/posts/archive.tsx @@ -0,0 +1,110 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { createArchiveFileURL } from "#lib/urls"; +import { fetchArchiveFile, fetchSetArchiveFilePassword } from "#api/files"; +import { PageSkeleton } from "#components/pages"; +import { IArchiveFile } from "#entities/files"; + +interface IProps { + archive: IArchiveFile; + isFileServingEnabled: boolean; +} + +export function ArchiveFilePage() { + const { archive, isFileServingEnabled } = useLoaderData() as IProps; + const { file, file_list, password } = archive; + const title = "Archive files"; + const heading = "Archive Files"; + + return ( + +
    + {archive.password ? ( + <>Archive password: {archive.password} + ) : archive.password === "" ? ( + <> + Archive needs password, but none was provided.{" "} + { + event.preventDefault(); + const password = prompt("input password"); + + if (!password) { + return; + } + + const encodedPassword = encodeURIComponent(password); + + try { + await fetchSetArchiveFilePassword(file.hash, encodedPassword); + + location.reload(); + } catch (error) { + console.error(error); + alert("Invalid password"); + } + }} + > + Click to input + + + ) : undefined} +
    + + {file_list.length === 0 ? ( + <>Archive is empty or missing password. + ) : ( + file_list.map((fileName) => + !isFileServingEnabled ? ( + <> + {fileName} +
    + + ) : password ? ( + <> + + {fileName} + +
    + + ) : ( + <> + + {fileName} + +
    + + ) + ) + )} +
    + ); +} + +export async function loader({ params }: LoaderFunctionArgs): Promise { + const fileHash = params.file_hash?.trim(); + if (!fileHash) { + throw new Error("File hash is required."); + } + + const { archive, file_serving_enabled } = await fetchArchiveFile(fileHash); + + if (!archive) { + throw new Error("Archive not found."); + } + + return { + archive, + isFileServingEnabled: file_serving_enabled, + }; +} diff --git a/client/src/pages/posts/popular.html b/client/src/pages/posts/popular.html deleted file mode 100644 index 3c4e918..0000000 --- a/client/src/pages/posts/popular.html +++ /dev/null @@ -1,114 +0,0 @@ -{% extends "components/shell.html" %} - -{% from "components/card_list.html" import card_list %} -{% from "components/cards/post.html" import post_fav_card %} -{% from "components/ads.html" import slider_ad, header_ad, footer_ad %} - -{% block title %} - Popular Posts | {{ g.site_name }} -{% endblock %} - -{% block content %} - {{ slider_ad() }} - - - -{% endblock %} diff --git a/client/src/pages/posts/popular.tsx b/client/src/pages/posts/popular.tsx new file mode 100644 index 0000000..4d8c4e5 --- /dev/null +++ b/client/src/pages/posts/popular.tsx @@ -0,0 +1,271 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { createPopularPostsPageURL } from "#lib/urls"; +import { parseOffset } from "#lib/pagination"; +import { fetchPopularPosts } from "#api/posts"; +import { PageSkeleton } from "#components/pages"; +import { Paginator } from "#components/pagination"; +import { FooterAd, HeaderAd, SliderAd } from "#components/ads"; +import { CardList, PostFavoriteCard } from "#components/cards"; +import { + IPopularPostsPeriod, + IPostWithFavorites, + validatePeriod, +} from "#entities/posts"; + +interface IProps { + minDate: string; + maxDate: string; + rangeDescription: string; + earliestDateForPopular: string; + navigationDates: Record; + scale?: IPopularPostsPeriod; + today: string; + count: number; + posts: IPostWithFavorites[]; + offset?: number; + date?: string; +} + +export function PopularPostsPage() { + const { + minDate, + maxDate, + rangeDescription, + navigationDates, + earliestDateForPopular, + scale, + today, + count, + offset, + date, + posts, + } = useLoaderData() as IProps; + const title = `Popular posts for ${rangeDescription}`; + + return ( + + Popular Posts for{" "} + {rangeDescription} + + } + > + + +
    +
    + + {navigationDates.day[0] < earliestDateForPopular ? ( + next » + ) : ( + + « prev + + )} + {" "} + + + {scale === "day" ? ( + Day + ) : ( + + Day + + )} + {" "} + + + {navigationDates.day[1] > today ? ( + next » + ) : ( + + next » + + )} + +
    + +
    + + {navigationDates.week[0] < earliestDateForPopular ? ( + next » + ) : ( + + « prev + + )} + {" "} + + + {scale === "week" ? ( + Week + ) : ( + + Week + + )} + {" "} + + + {navigationDates.week[1] > today ? ( + next » + ) : ( + + next » + + )} + +
    + +
    + + {navigationDates.month[0] < earliestDateForPopular ? ( + next » + ) : ( + + « prev + + )} + {" "} + + + {scale === "month" ? ( + Month + ) : ( + + Month + + )} + {" "} + + + + {navigationDates.month[1] > today ? ( + next » + ) : ( + + next » + + )} + + +
    + +
    + + String(createPopularPostsPageURL(date, scale, offset)) + } + /> + + + + + {count === 0 ? ( +
    +

    Nobody here but us chickens!

    +

    There are no posts for your query.

    +
    + ) : ( + posts.map((post) => ( + + )) + )} +
    + + + +
    + + String(createPopularPostsPageURL(date, scale, offset)) + } + /> +
    +
    +
    +
    + ); +} + +export async function loader({ request }: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + + const date = searchParams.get("date")?.trim(); + + const scale = searchParams.get("period")?.trim() ?? "recent"; + validatePeriod(scale); + + let offset: number | undefined; + { + const inputOffset = searchParams.get("o")?.trim(); + + if (inputOffset) { + offset = parseOffset(inputOffset); + } + } + + const { info, props, results } = await fetchPopularPosts(date, scale, offset); + const { count, earliest_date_for_popular, today } = props; + const { range_desc, min_date, max_date, navigation_dates } = info; + + return { + date, + count, + scale, + offset, + posts: results, + today, + earliestDateForPopular: earliest_date_for_popular, + rangeDescription: range_desc, + minDate: min_date, + maxDate: max_date, + navigationDates: navigation_dates, + }; +} diff --git a/client/src/pages/posts/random.tsx b/client/src/pages/posts/random.tsx new file mode 100644 index 0000000..2372c96 --- /dev/null +++ b/client/src/pages/posts/random.tsx @@ -0,0 +1,10 @@ +import { redirect } from "react-router-dom"; +import { createPostURL } from "#lib/urls"; +import { fetchRandomPost } from "#api/posts"; + +export async function loader() { + const { service, artist_id, post_id } = await fetchRandomPost(); + const url = String(createPostURL(service, artist_id, post_id)); + + return redirect(url); +} diff --git a/client/src/pages/user.scss b/client/src/pages/profile.scss similarity index 100% rename from client/src/pages/user.scss rename to client/src/pages/profile.scss diff --git a/client/src/pages/profile.tsx b/client/src/pages/profile.tsx new file mode 100644 index 0000000..ff1a173 --- /dev/null +++ b/client/src/pages/profile.tsx @@ -0,0 +1,225 @@ +import { LoaderFunctionArgs, redirect, useLoaderData } from "react-router-dom"; +import { createDiscordServerPageURL, createProfilePageURL } from "#lib/urls"; +import { parseOffset } from "#lib/pagination"; +import { ElementType } from "#lib/types"; +import { fetchProfilePosts } from "#api/profiles"; +import { FooterAd, SliderAd } from "#components/ads"; +import { Paginator } from "#components/pagination"; +import { CardList, PostCard } from "#components/cards"; +import { ProfilePageSkeleton } from "#components/pages"; +import { FormRouter } from "#components/forms"; +import { + ProfileHeader, + Tabs, + IArtistDetails, + getArtist, +} from "#entities/profiles"; +import { paysites, validatePaysite } from "#entities/paysites"; +import { IPost } from "#entities/posts"; +import { findFavouritePosts } from "#entities/account"; + +interface IProps { + profile: IArtistDetails; + postsData?: { + count: number; + offset?: number; + posts: (IPost & { isFavourite: boolean })[]; + }; + + query?: string; + tags?: string[]; + dmCount?: number; + hasLinks?: boolean; +} + +export function ProfilePage() { + const { profile, postsData, query, tags, dmCount, hasLinks } = + useLoaderData() as IProps; + const { service, id, name } = profile; + const paysite = paysites[service]; + const title = `Posts of "${name}" from "${paysite.title}"`; + + return ( + + + + + +
    + + + {!(postsData && (postsData?.count !== 0 || query)) ? undefined : ( + <> + + String( + createProfilePageURL({ + service, + profileID: id, + offset, + query, + tags, + }) + ) + } + /> + + + + {/* TODO: rewrite this into a proper form */} + + + + )} +
    + + {!postsData ? ( +
    +

    + Nobody here but us chickens! +

    +

    There are no posts for your query.

    +
    + ) : ( + <> + + {postsData.posts.map((post) => ( + + ))} + + + + +
    + + String( + createProfilePageURL({ + service, + profileID: id, + offset, + query, + tags, + }) + ) + } + /> +
    + + )} +
    + ); +} + +export async function loader({ + params, + request, +}: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + const service = params.service?.trim(); + { + if (!service) { + throw new Error("Service name is required."); + } + + validatePaysite(service); + } + + const profileID = params.creator_id?.trim(); + { + if (!profileID) { + throw new Error("Profile ID is required."); + } + } + + if (service === "discord") { + return redirect(String(createDiscordServerPageURL(profileID))); + } + + let offset: number | undefined; + { + const inputValue = searchParams.get("o")?.trim(); + + if (inputValue) { + offset = parseOffset(inputValue); + } + } + + let query: string | undefined = undefined; + { + const inputQuery = searchParams.get("q")?.trim(); + + if (inputQuery) { + query = inputQuery; + } + } + + const tags = searchParams.getAll("tag"); + + const profile = await getArtist(service, profileID); + const { props, results: posts } = await fetchProfilePosts( + service, + profileID, + offset, + query, + tags + ); + const { count, dm_count, has_links } = props; + const hasLinks = !has_links || has_links === "0" ? false : true; + const favPostData = await findFavouritePosts( + posts.map(({ service, user, id }) => { + return { + service, + user, + id, + }; + }) + ); + const finalPosts = posts.map< + ElementType["postsData"]["posts"]> + >((post) => { + const match = favPostData.find( + ({ service, user, id }) => + service === post.service && user === post.user && id === post.id + ); + + return { ...post, isFavourite: !match ? false : true }; + }); + + return { + profile, + query, + tags, + postsData: { + count, + offset, + posts: finalPosts, + }, + dmCount: dm_count, + hasLinks, + }; +} diff --git a/client/src/pages/artist/_index.scss b/client/src/pages/profile/_index.scss similarity index 80% rename from client/src/pages/artist/_index.scss rename to client/src/pages/profile/_index.scss index 7227f44..cdeb657 100644 --- a/client/src/pages/artist/_index.scss +++ b/client/src/pages/profile/_index.scss @@ -1,3 +1,4 @@ @use "dms"; @use "fancards"; @use "linked_accounts"; +@use "tags"; diff --git a/client/src/pages/profile/announcements.tsx b/client/src/pages/profile/announcements.tsx new file mode 100644 index 0000000..e89616b --- /dev/null +++ b/client/src/pages/profile/announcements.tsx @@ -0,0 +1,108 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { Helmet } from "react-helmet-async"; +import { ICONS_PREPEND, KEMONO_SITE, SITE_NAME } from "#env/env-vars"; +import { fetchAnnouncements } from "#api/posts"; +import { fetchArtistProfile } from "#api/profiles"; +import { PageSkeleton } from "#components/pages"; +import { CardList, DMCard } from "#components/cards"; +import { ProfileHeader, Tabs, IArtistDetails } from "#entities/profiles"; +import { paysites, validatePaysite } from "#entities/paysites"; +import { IAnnouncement } from "#entities/posts"; + +interface IProps { + service: string; + profile: IArtistDetails; + announcements: IAnnouncement[]; +} + +export function AnnouncementsPage() { + const { service, profile, announcements } = useLoaderData() as IProps; + const paysite = paysites[service]; + const title = `Announcements of "${profile.name}" from ${paysite.title}`; + const heading = "Announcements"; + + return ( + + + + + + {/* */} + + + + + + {/* */} + + + + +
    + +
    + + + {!announcements.length ? ( +
    +

    + Nobody here but us chickens! +

    +

    + There are no Announcements for your query. +

    +
    + ) : ( + announcements.map((announcement) => ( + + )) + )} +
    +
    + ); +} + +export async function loader({ params }: LoaderFunctionArgs): Promise { + const service = params.service?.trim(); + { + if (!service) { + throw new Error("Service name is required."); + } + + validatePaysite(service); + } + + const profileID = params.creator_id?.trim(); + { + if (!profileID) { + throw new Error("Artist ID is required."); + } + } + + const profile = await fetchArtistProfile(service, profileID); + const announcements = await fetchAnnouncements(service, profileID); + + return { + service, + profile, + announcements, + }; +} diff --git a/client/src/pages/artist/dms.scss b/client/src/pages/profile/dms.scss similarity index 100% rename from client/src/pages/artist/dms.scss rename to client/src/pages/profile/dms.scss diff --git a/client/src/pages/profile/dms.tsx b/client/src/pages/profile/dms.tsx new file mode 100644 index 0000000..6632325 --- /dev/null +++ b/client/src/pages/profile/dms.tsx @@ -0,0 +1,101 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { Helmet } from "react-helmet-async"; +import { ICONS_PREPEND, KEMONO_SITE, SITE_NAME } from "#env/env-vars"; +import { fetchProfileDMs } from "#api/dms"; +import { PageSkeleton } from "#components/pages"; +import { CardList, DMCard } from "#components/cards"; +import { ProfileHeader, Tabs, IArtist } from "#entities/profiles"; +import { paysites, validatePaysite } from "#entities/paysites"; +import { IApprovedDM } from "#entities/dms"; + +interface IProps { + profile: IArtist; + service: string; + dmCount: number; + dms: IApprovedDM[]; +} + +export function ProfileDMsPage() { + const { profile, service, dmCount, dms } = useLoaderData() as IProps; + const paysite = paysites[service]; + const title = `DMs of "${profile.name}" (${profile.id}) from ${paysite.title}`; + const heading = "DMs"; + + return ( + + + + + + {/* */} + + + + + + {/* */} + + + + +
    + +
    + + + {dmCount === 0 ? ( +
    +

    + Nobody here but us chickens! +

    +

    There are no DMs for your query.

    +
    + ) : ( + dms.map((dm) => ) + )} +
    +
    + ); +} + +export async function loader({ params }: LoaderFunctionArgs): Promise { + const service = params.service?.trim(); + { + if (!service) { + throw new Error("Service name is required."); + } + + validatePaysite(service); + } + + const profileID = params.creator_id?.trim(); + { + if (!profileID) { + throw new Error("Artist ID is required."); + } + } + + const { props } = await fetchProfileDMs(service, profileID); + const { artist, dm_count, dms } = props; + + return { + profile: artist, + service, + dmCount: dm_count, + dms, + }; +} diff --git a/client/src/pages/artist/fancards.scss b/client/src/pages/profile/fancards.scss similarity index 100% rename from client/src/pages/artist/fancards.scss rename to client/src/pages/profile/fancards.scss diff --git a/client/src/pages/profile/fancards.tsx b/client/src/pages/profile/fancards.tsx new file mode 100644 index 0000000..2b02245 --- /dev/null +++ b/client/src/pages/profile/fancards.tsx @@ -0,0 +1,123 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { Helmet } from "react-helmet-async"; +import { + ICONS_PREPEND, + KEMONO_SITE, + SITE_NAME, + THUMBNAILS_PREPEND, +} from "#env/env-vars"; +import { fetchFanboxProfileFancards } from "#api/profiles"; +import { PageSkeleton } from "#components/pages"; +import { validatePaysite } from "#entities/paysites"; +import { + ProfileHeader, + Tabs, + IArtistDetails, + getArtist, +} from "#entities/profiles"; +import { IFanCard } from "#entities/files"; + +interface IProps { + profile: IArtistDetails; + cards: IFanCard[]; +} + +export function FancardsPage() { + const { profile, cards } = useLoaderData() as IProps; + const title = "Fancards"; + const heading = "Fancards"; + + return ( + + + + + + {/* */} + + + + + + {/* */} + + + + +
    + +
    + {cards.length === 0 ? ( +
    +

    + Nobody here but us chickens! +

    +

    There are no uploads for your query.

    +
    + ) : ( + cards.map((card) => ( +
    + + Added {card.added.slice(0, 7)} + + + + +
    + )) + )} +
    +
    +
    + ); +} + +export async function loader({ params }: LoaderFunctionArgs): Promise { + const service = params.service?.trim(); + { + if (!service) { + throw new Error("Service name is required."); + } + + validatePaysite(service); + + if (service !== "fanbox") { + throw new Error(`Service must be "fanbox".`); + } + } + + const profileID = params.creator_id?.trim(); + { + if (!profileID) { + throw new Error("Artist ID is required."); + } + } + + const profile = await getArtist(service, profileID); + const cards = await fetchFanboxProfileFancards(profileID); + + return { + profile, + cards, + }; +} diff --git a/client/src/pages/artist/linked_accounts.scss b/client/src/pages/profile/linked_accounts.scss similarity index 100% rename from client/src/pages/artist/linked_accounts.scss rename to client/src/pages/profile/linked_accounts.scss diff --git a/client/src/pages/profile/linked_accounts.tsx b/client/src/pages/profile/linked_accounts.tsx new file mode 100644 index 0000000..9373755 --- /dev/null +++ b/client/src/pages/profile/linked_accounts.tsx @@ -0,0 +1,100 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { createProfileNewLinksPageURL } from "#lib/urls"; +import { fetchArtistProfile, fetchProfileLinks } from "#api/profiles"; +import { CardList, ArtistCard } from "#components/cards"; +import { ProfilePageSkeleton } from "#components/pages"; +import { ProfileHeader, Tabs, IArtistDetails } from "#entities/profiles"; +import { validatePaysite } from "#entities/paysites"; + +interface IProps { + service: string; + profile: IArtistDetails; + links: Awaited>; +} + +export function ProfileLinksPage() { + const { service, profile, links } = useLoaderData() as IProps; + const title = "Linked profiles"; + const heading = "Linked Profiles"; + + return ( + + + +
    + +
    + + + + + {links.length === 0 ? ( +

    + No linked accounts found. +

    + ) : ( + links.map((profile) => ( + + )) + )} +
    +
    + ); +} + +export async function loader({ params }: LoaderFunctionArgs): Promise { + const service = params.service?.trim(); + { + if (!service) { + throw new Error("Service name is required."); + } + + validatePaysite(service); + } + + const profileID = params.creator_id?.trim(); + { + if (!profileID) { + throw new Error("Artist ID is required."); + } + } + + const profile = await fetchArtistProfile(service, profileID); + const links = await fetchProfileLinks(service, profileID); + + return { + service, + profile, + links, + }; +} diff --git a/client/src/pages/profile/new_linked_account.tsx b/client/src/pages/profile/new_linked_account.tsx new file mode 100644 index 0000000..e61f110 --- /dev/null +++ b/client/src/pages/profile/new_linked_account.tsx @@ -0,0 +1,272 @@ +import { useEffect, useState } from "react"; +import { + ActionFunctionArgs, + LoaderFunctionArgs, + useActionData, + useLoaderData, +} from "react-router-dom"; +import { SITE_NAME } from "#env/env-vars"; +import { AVAILABLE_PAYSITE_LIST } from "#env/derived-vars"; +import { ElementType } from "#lib/types"; +import { fetchAddProfileLink } from "#api/account"; +import { + ProfilePageSkeleton, + createAccountPageLoader, +} from "#components/pages"; +import { FormRouter, FormSection, ButtonSubmit } from "#components/forms"; +import { InputHidden } from "#components/forms/inputs"; +import { ArtistCard } from "#components/cards"; +import { + ProfileHeader, + Tabs, + IArtistDetails, + getArtist, + getArtists, +} from "#entities/profiles"; +import { validatePaysite } from "#entities/paysites"; + +interface IProps { + profile: IArtistDetails; +} + +interface IAction { + message: string; +} + +export function NewProfileLinkPage() { + const { profile } = useLoaderData() as IProps; + const [currentService, changeCurrentService] = useState(); + const [currentQuery, changeCurrentQuery] = useState(); + const [profileSuggestions, changeProfileSuggestions] = + useState>>(); + const [selectedProfile, changeSelectedProfile] = + useState>["artists"]>>(); + const title = "Link new profile"; + const heading = "Link New Profile"; + + useEffect(() => { + (async () => { + const profiles = await getArtists({ + service: currentService, + query: currentQuery, + }); + + changeProfileSuggestions(profiles); + })(); + }, [currentService, currentQuery]); + + return ( + + + +
    + +
    + +

    + If you believe this profile has other profiles on {SITE_NAME}, you can + use this form to request they be linked. +

    + + + id="new_link_form" + className="form--wide" + method="POST" + successElement={({ message }) => ( +
      +
    • {message}
    • +
    + )} + > + + + + + + + + + { + const query = event.currentTarget.value.trim(); + + changeCurrentQuery(query.length === 0 ? undefined : query); + }} + /> + + + + + + + + + + + Request link + + + +
    +
    + {!profileSuggestions ? ( + <>Please select a creator. + ) : profileSuggestions.count === 0 ? ( + <>No results found. + ) : ( + profileSuggestions.artists.slice(0, 20).map((profile, index) => ( +
    { + event.preventDefault(); + event.stopPropagation(); + changeSelectedProfile(profile); + }} + > + +
    + )) + )} +
    +
    +
    + ); +} + +export const loader = createAccountPageLoader(async function loader({ + params, +}: LoaderFunctionArgs): Promise { + const service = params.service?.trim(); + { + if (!service) { + throw new Error("Service name is required."); + } + + validatePaysite(service); + } + + const profileID = params.creator_id?.trim(); + { + if (!profileID) { + throw new Error("Profile ID is required."); + } + } + + const profile = await getArtist(service, profileID); + + return { profile }; +}); + +export async function action({ + params, + request, +}: ActionFunctionArgs): Promise { + if (request.method !== "POST") { + throw new Error(`Unknown method "${request.method}".`); + } + + const service = params.service?.trim(); + { + if (!service) { + throw new Error("Service name is required."); + } + + validatePaysite(service); + } + + const profileID = params.creator_id?.trim(); + { + if (!profileID) { + throw new Error("Profile ID is required."); + } + } + + const formData = await request.formData(); + + const inputData = (formData.get("creator") as string | null) + ?.trim() + .split("/"); + + if (!inputData) { + throw new Error("Input data is required."); + } + + if (inputData.length !== 2) { + throw new Error("Invalid input data length."); + } + + const [suggestedService, suggestedProfileID] = inputData; + + const reason = (formData.get("reason") as string | null)?.trim(); + + const { message } = await fetchAddProfileLink( + service, + profileID, + suggestedService, + suggestedProfileID, + reason + ); + + return { message }; +} diff --git a/client/src/pages/tags.scss b/client/src/pages/profile/tags.scss similarity index 94% rename from client/src/pages/tags.scss rename to client/src/pages/profile/tags.scss index 73e6282..6b42120 100644 --- a/client/src/pages/tags.scss +++ b/client/src/pages/profile/tags.scss @@ -1,4 +1,4 @@ -@use "../css/config/variables" as *; +@use "../../css/config/variables" as *; h2#all-tags-header { margin-left: auto; diff --git a/client/src/pages/profile/tags.tsx b/client/src/pages/profile/tags.tsx new file mode 100644 index 0000000..d24c358 --- /dev/null +++ b/client/src/pages/profile/tags.tsx @@ -0,0 +1,73 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { createProfileTagURL } from "#lib/urls"; +import { PageSkeleton } from "#components/pages"; +import { getTags } from "#entities/tags"; +import { paysites, validatePaysite } from "#entities/paysites"; +import { ProfileHeader, Tabs } from "#entities/profiles"; + +interface IProps extends Awaited> {} + +export function ProfileTagsPage() { + const { service, artist, tags } = useLoaderData() as IProps; + const paysite = paysites[service]; + const title = `Tags of "${artist.name}" from ${paysite.title}`; + + return ( + + + +
    + +
    + +
    + {tags.length === 0 ? ( +
    +

    + Nobody here but us chickens! +

    +

    There are no tags for your query.

    +
    + ) : ( + tags.map((tag, index) => ( + + )) + )} +
    +
    + ); +} + +export async function loader({ params }: LoaderFunctionArgs): Promise { + const service = params.service?.trim(); + { + if (!service) { + throw new Error("Service name is required."); + } + + validatePaysite(service); + } + + const profileID = params.creator_id?.trim(); + { + if (!profileID) { + throw new Error("Artist ID is required."); + } + } + + const result = await getTags(service, profileID); + + return result; +} diff --git a/client/src/pages/profiles.module.scss b/client/src/pages/profiles.module.scss new file mode 100644 index 0000000..e913635 --- /dev/null +++ b/client/src/pages/profiles.module.scss @@ -0,0 +1,7 @@ +.loading { + text-align: center; +} + +.error { + text-align: center; +} diff --git a/client/src/pages/profiles.tsx b/client/src/pages/profiles.tsx new file mode 100644 index 0000000..1a24513 --- /dev/null +++ b/client/src/pages/profiles.tsx @@ -0,0 +1,333 @@ +import clsx from "clsx"; +import { Suspense } from "react"; +import { + useLoaderData, + LoaderFunctionArgs, + defer, + Await, + useAsyncError, +} from "react-router-dom"; +import { PAYSITE_LIST } from "#env/env-vars"; +import { + ARTISTS_OR_CREATORS_LOWERCASE, + AVAILABLE_PAYSITE_LIST, +} from "#env/derived-vars"; +import { createArtistsPageURL } from "#lib/urls"; +import { parseOffset } from "#lib/pagination"; +import { PageSkeleton } from "#components/pages"; +import { FooterAd, HeaderAd, SliderAd } from "#components/ads"; +import { Paginator } from "#components/pagination"; +import { CardList, ArtistCard } from "#components/cards"; +import { ButtonSubmit, FormRouter, FormSection } from "#components/forms"; +import { LoadingIcon } from "#components/loading"; +import { getArtists } from "#entities/profiles"; + +import styles from "./profiles.module.scss"; + +interface IProps { + results: ReturnType; + query?: string; + service?: string; + sort_by?: ISortField; + order?: "asc" | "desc"; + offset?: number; + true_count?: number; +} + +const sortFields = [ + "favorited", + "indexed", + "updated", + "name", + "service", +] as const; + +type ISortField = (typeof sortFields)[number]; + +function validateSortField(input: unknown): asserts input is ISortField { + if (!sortFields.includes(input as ISortField)) { + throw new Error(`Invalid sort field value "${input}".`); + } +} + +export function ArtistsPage() { + const { results, query, service, sort_by, order, offset } = + useLoaderData() as IProps; + const title = "Artists"; + const heading = "Artists"; + + return ( + + + + + +
    +

    + Displaying cached popular artists +

    +
    + +
    + }> + } resolve={results}> + {(resolvedResult: Awaited) => ( + { + const url = createArtistsPageURL( + offset, + query, + service, + sort_by, + order + ); + + return String(url); + }} + /> + )} + + +
    + + + + + + Loading creators... please wait! +

    + } + > + }> + {(resolvedResult: Awaited) => + resolvedResult.artists.length === 0 ? ( +

    + No {ARTISTS_OR_CREATORS_LOWERCASE} found for your query. +

    + ) : ( + resolvedResult.artists.map((artist) => ( + + )) + ) + } +
    +
    +
    + +
    + }> + } resolve={results}> + {(resolvedResult: Awaited) => ( + { + const url = createArtistsPageURL( + offset, + query, + service, + sort_by, + order + ); + + return String(url); + }} + /> + )} + + +
    + + +
    + ); +} + +interface ISearchFormProps + extends Pick {} + +function SearchForm({ query, service, sort_by, order }: ISearchFormProps) { + return ( + + {(state) => ( + <> + + + + + Leave blank to list all + + + + + + + + + + + {" "} + + + + +
    +
    + {state === "loading" + ? "Loading..." + : state === "submitting" + ? "Submitting..." + : "Ready for submit"} +
    +
    + + +
    + + Search + +
    + + )} +
    + ); +} + +function CollectionError() { + const error = useAsyncError(); + console.error(error); + + return ( +
    +

    Failed to load artists.

    +
    + Details + {/* @ts-expect-error vague type definition */} +

    {error?.statusText || error?.message}

    +
    +
    + ); +} + +export async function loader({ + request, +}: LoaderFunctionArgs): Promise> { + const searchParams = new URL(request.url).searchParams; + + let offset: IProps["offset"] | undefined = undefined; + { + const inputOffset = searchParams.get("o")?.trim(); + + if (inputOffset) { + offset = parseOffset(inputOffset); + } + } + + let query: IProps["query"] | undefined = searchParams.get("q")?.trim(); + + let sort_by: IProps["sort_by"] | undefined = undefined; + { + const inputValue = searchParams.get("sort_by")?.trim(); + + if (inputValue) { + validateSortField(inputValue); + sort_by = inputValue; + } + } + + let order_by: IProps["order"] | undefined = undefined; + { + const inputValue = searchParams.get("order")?.trim(); + + if (inputValue) { + if (inputValue !== "asc" && inputValue !== "desc") { + throw new Error(`Invalid order by field "${inputValue}".`); + } + + order_by = inputValue; + } + } + + let service: IProps["service"] = undefined; + { + const inputValue = searchParams.get("service")?.trim(); + + if (inputValue) { + if (!PAYSITE_LIST.includes(inputValue)) { + throw new Error(`Unknown service "${inputValue}".`); + } + } + + service = inputValue; + } + + const results = getArtists({ + offset, + order: order_by, + service, + sort_by, + query, + }); + + const pageProps = { + results, + sort_by, + order: order_by, + offset, + service, + query, + } satisfies IProps; + + return defer(pageProps); +} diff --git a/client/src/pages/profiles/random.tsx b/client/src/pages/profiles/random.tsx new file mode 100644 index 0000000..5774200 --- /dev/null +++ b/client/src/pages/profiles/random.tsx @@ -0,0 +1,10 @@ +import { redirect } from "react-router-dom"; +import { createProfilePageURL } from "#lib/urls"; +import { fetchRandomArtist } from "#api/profiles"; + +export async function loader() { + const { service, artist_id } = await fetchRandomArtist(); + const url = String(createProfilePageURL({ service, profileID: artist_id })); + + return redirect(url); +} diff --git a/client/src/pages/profiles/updated.tsx b/client/src/pages/profiles/updated.tsx new file mode 100644 index 0000000..0e26de4 --- /dev/null +++ b/client/src/pages/profiles/updated.tsx @@ -0,0 +1,97 @@ +import clsx from "clsx"; +import { useLoaderData, LoaderFunctionArgs } from "react-router-dom"; +import { ARTISTS_OR_CREATORS_LOWERCASE } from "#env/derived-vars"; +import { createArtistsUpdatedPageURL } from "#lib/urls"; +import { parseOffset } from "#lib/pagination"; +import { PageSkeleton } from "#components/pages"; +import { FooterAd, HeaderAd, SliderAd } from "#components/ads"; +import { Paginator } from "#components/pagination"; +import { CardList, ArtistCard } from "#components/cards"; +import { getArtists } from "#entities/profiles"; + +interface IProps { + results: Awaited>["artists"]; + offset: number; + count: number; +} + +export function ArtistsUpdatedPage() { + const { results, count, offset } = useLoaderData() as IProps; + const title = "Latest cached updated artists"; + const heading = "Latest Cached Updated Artists"; + + return ( + + + +
    + { + const url = createArtistsUpdatedPageURL(offset); + + return String(url); + }} + /> +
    + + + + + {results.length === 0 ? ( +

    + No {ARTISTS_OR_CREATORS_LOWERCASE} found for your query. +

    + ) : ( + results.map((artist) => ( + + )) + )} +
    + +
    + { + const url = createArtistsUpdatedPageURL(offset); + + return String(url); + }} + /> +
    + + +
    + ); +} + +export async function loader({ request }: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + const sort_by = "updated"; + + let offset: IProps["offset"] = 0; + { + const inputOffset = searchParams.get("o")?.trim(); + + if (inputOffset) { + offset = parseOffset(inputOffset); + } + } + + const { artists, count } = await getArtists({ offset, sort_by }); + + return { + results: artists, + count, + offset, + }; +} diff --git a/client/src/pages/review_dms/dms.js b/client/src/pages/review_dms/dms.js deleted file mode 100644 index eb87036..0000000 --- a/client/src/pages/review_dms/dms.js +++ /dev/null @@ -1,14 +0,0 @@ -import { initPendingReviewDms } from "@wp/js/pending-review-dms"; - -export async function reviewDMsPage() { - - const status_selector = document.getElementById("status"); - status_selector.addEventListener("change", async function (e) { - e.preventDefault(); - const currentUrl = new URL(window.location.href); - const urlParams = currentUrl.searchParams; - urlParams.set('status', status_selector.value); - window.location.href = currentUrl.toString(); - }); - await initPendingReviewDms(true); -} diff --git a/client/src/pages/review_dms/review_dms.html b/client/src/pages/review_dms/review_dms.html deleted file mode 100644 index 7c13def..0000000 --- a/client/src/pages/review_dms/review_dms.html +++ /dev/null @@ -1,81 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/cards/dm.html' import dm_card %} - -{% block title %} - Approve DMs for import to {{ g.site_name }}. -{% endblock title %} - -{% block meta %} - -{% endblock meta %} - -{% block content %} -
    -
    -

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

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

    - There are no DMs waiting for approval. -

    - {% endif %} -
    -{% endblock %} diff --git a/client/src/pages/review_dms/review_dms.module.scss b/client/src/pages/review_dms/review_dms.module.scss new file mode 100644 index 0000000..0e3a7bb --- /dev/null +++ b/client/src/pages/review_dms/review_dms.module.scss @@ -0,0 +1,3 @@ +.link { + text-align: center; +} diff --git a/client/src/pages/review_dms/review_dms.tsx b/client/src/pages/review_dms/review_dms.tsx new file mode 100644 index 0000000..04edeb5 --- /dev/null +++ b/client/src/pages/review_dms/review_dms.tsx @@ -0,0 +1,157 @@ +import clsx from "clsx"; +import { + ActionFunctionArgs, + LoaderFunctionArgs, + redirect, + useLoaderData, +} from "react-router-dom"; +import { createAccountDMsReviewPageURL } from "#lib/urls"; +import { fetchApproveDMs, fetchDMsForReview } from "#api/account/dms"; +import { PageSkeleton, createAccountPageLoader } from "#components/pages"; +import { DMCard } from "#components/cards"; +import { FormRouter } from "#components/forms"; +import { KemonoLink } from "#components/links"; +import { IUnapprovedDM } from "#entities/dms"; + +import styles from "./review_dms.module.scss"; + +interface IProps { + status: "pending" | "ignored"; + dms: IUnapprovedDM[]; +} + +export function DMsReviewPage() { + const { status, dms } = useLoaderData() as IProps; + const title = `Approve DMs`; + const heading = `DM Review`; + + return ( + +

    + + {status === "pending" ? <>Ignored DMs : <>Pending DMs} + +

    + + {!dms || dms.length === 0 ? ( +

    There are no DMs waiting for approval.

    + ) : ( + <> + + {dms.map((dm) => ( +
    + +
    + + +
    +
    + ))} + + {status === "ignored" && ( + + )} + +
    + +
    +
    + + )} +
    + ); +} + +export const loader = createAccountPageLoader(async function loader({ + request, +}: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + + let status: undefined | IProps["status"] = undefined; + { + const inputStatus = searchParams.get("status")?.trim() ?? "pending"; + + if (inputStatus !== "pending" && inputStatus !== "ignored") { + throw new Error(`Unknown status "${inputStatus}".`); + } + + status = inputStatus; + } + + const { dms } = await fetchDMsForReview(status); + + return { + status, + dms, + }; +}); + +export async function action({ request }: ActionFunctionArgs) { + try { + if (request.method !== "POST") { + throw new Error(`Unknown method ${request.method}.`); + } + + const formData = await request.formData(); + const approvedHashes = formData.getAll("approved_hashes") as string[]; + const deleteIgnored = + (formData.get("delete_ignored") as string | null)?.trim() === "true"; + + if (approvedHashes.length === 0) { + throw new Error("At least one DM must be provided for approval."); + } + + await fetchApproveDMs(approvedHashes, deleteIgnored); + + return redirect(String(createAccountDMsReviewPageURL())); + } catch (error) { + return error; + } +} diff --git a/client/src/pages/schema.html b/client/src/pages/schema.html deleted file mode 100644 index f7829da..0000000 --- a/client/src/pages/schema.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'components/shell.html' %} -{% block content %} - - -{% endblock %} \ No newline at end of file diff --git a/client/src/pages/search_hash.html b/client/src/pages/search_hash.html deleted file mode 100644 index 5b92c83..0000000 --- a/client/src/pages/search_hash.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "components/shell.html" %} -{% from "components/file_hash_search.html" import search_form %} - -{% block title %} - Search files | {{ g.site_name }} -{% endblock title %} - -{% block content %} - -{% endblock content %} diff --git a/client/src/pages/search_hash.js b/client/src/pages/search_hash.js deleted file mode 100644 index 404875d..0000000 --- a/client/src/pages/search_hash.js +++ /dev/null @@ -1,43 +0,0 @@ -import sha256 from "sha256-wasm"; - -export function searchHashPage() { - const FORM = document.getElementById("file-search"); - const FILE = document.getElementById("file"); - const HASH = document.getElementById("hash"); - - FORM.addEventListener("submit", async function (e) { - - e.preventDefault(); - let hash = undefined; - if (FILE.value !== "") { - hash = await getFileHash(FILE.files[0]); - } else if (HASH.value !== "") { - if (HASH.value.match(/[A-Fa-f0-9]{64}/)) { - hash = HASH.value; - } else { - alert("Invalid SHA256 hash"); - } - } else { - alert("Neither file or hash provided"); - } - - if (hash) { - window.location.search = "?hash=" + hash; - } - }); -} - -async function getFileHash(file) { - const fileSize = file.size; - const chunkSize = 1024 * 1024; // 1Mi - let offset = 0; - let hash = new sha256(); - - while (offset < fileSize) { - const arr = new Uint8Array(await file.slice(offset, chunkSize + offset).arrayBuffer()); - hash.update(arr); - offset += chunkSize; - } - - return hash.digest("hex"); -} diff --git a/client/src/pages/search_hash.tsx b/client/src/pages/search_hash.tsx new file mode 100644 index 0000000..2f4daab --- /dev/null +++ b/client/src/pages/search_hash.tsx @@ -0,0 +1,129 @@ +import { + ActionFunctionArgs, + LoaderFunctionArgs, + redirect, + useLoaderData, +} from "react-router-dom"; +// TODO: https://github.com/Daninet/hash-wasm probably +// since this one wasn't updated in 3 years +import sha256 from "sha256-wasm"; +import { + createDiscordChannelPageURL, + createFileSearchPageURL, +} from "#lib/urls"; +import { fetchSearchFileByHash } from "#api/files"; +import { PageSkeleton } from "#components/pages"; +import { CardList, PostCard } from "#components/cards"; +import { KemonoLink } from "#components/links"; +import { FileSearchForm } from "#entities/files"; + +interface IProps { + hash?: string; + result?: Awaited>; +} + +export function SearchFilesPage() { + const { hash, result } = useLoaderData() as IProps; + const title = "Search files"; + const heading = "Search Files"; + + return ( + + + + {!result?.posts.length ? ( +
    +

    + Nobody here but us chickens! +

    +

    There are no posts for your query.

    +
    + ) : ( + + {result.posts.map((post, index) => ( + + ))} + + )} + + {result?.discord_posts && result?.discord_posts.length !== 0 && ( + <> +

    Discord

    + {result.discord_posts.map((post, index) => ( +

    + + Server {post.server} channel {post.channel} + +

    + ))} + + )} +
    + ); +} + +export async function loader({ request }: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + + const hash = searchParams.get("hash")?.trim(); + + if (!hash) { + return {}; + } + + const result = await fetchSearchFileByHash(hash); + + return { hash, result }; +} + +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + throw new Error("Unknown method"); + } + + const data = await request.formData(); + + const file = data.get("file") as File | null; + let hash = data.get("hash") as string | null; + + if (!file && !hash) { + throw new Error("Neither file nor hash is provided"); + } + + if (file) { + hash = await getFileHash(file); + } + + if (hash && !hash.match(/[A-Fa-f0-9]{64}/)) { + throw new Error("Hash is not a valid SHA256 value."); + } + + await fetchSearchFileByHash(hash as string); + + return redirect(String(createFileSearchPageURL(hash as string))); +} + +async function getFileHash(file: File) { + const fileSize = file.size; + const chunkSize = 1024 * 1024; // 1Mi + let offset = 0; + let hash = new sha256(); + + while (offset < fileSize) { + const arr = new Uint8Array( + await file.slice(offset, chunkSize + offset).arrayBuffer() + ); + hash.update(arr); + offset += chunkSize; + } + + return hash.digest("hex"); +} diff --git a/client/src/pages/search_results.html b/client/src/pages/search_results.html deleted file mode 100644 index b2195bd..0000000 --- a/client/src/pages/search_results.html +++ /dev/null @@ -1,41 +0,0 @@ -{% extends "components/shell.html" %} - -{% from "components/card_list.html" import card_list %} -{% from "components/cards/post.html" import post_card %} -{% from "components/file_hash_search.html" import search_form %} - -{% block title %} - File search results | {{ g.site_name }} -{% endblock title %} - -{% block meta %} - -{% endblock meta %} - -{% block content %} - -{% endblock content %} diff --git a/client/src/pages/search_results.js b/client/src/pages/search_results.js deleted file mode 100644 index e69de29..0000000 diff --git a/client/src/pages/share.html b/client/src/pages/share.html deleted file mode 100644 index 7515bb0..0000000 --- a/client/src/pages/share.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/site_section.html' import site_section, site_section_header %} - -{% block scripts_extra %} - - -{% endblock scripts_extra %} - -{% block content %} -{% call site_section('upload') %} -
    - {{ site_section_header(share.name) }} -
    {{ share.description }}
    - {% for file in share_files %} -
  • - - Download {{ file['filename'] }} - -
  • - {% endfor %} -
    -{% endcall %} -{% endblock %} diff --git a/client/src/pages/share.tsx b/client/src/pages/share.tsx new file mode 100644 index 0000000..11f69a9 --- /dev/null +++ b/client/src/pages/share.tsx @@ -0,0 +1,54 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { createFileURL } from "#lib/urls"; +import { fetchShare } from "#api/shares"; +import { PageSkeleton } from "#components/pages"; +import { IShare, IShareFile } from "#entities/files"; + +interface IProps { + share: IShare; + files: IShareFile[]; +} + +export function SharePage() { + const { share, files } = useLoaderData() as IProps; + const title = `Share "${share.name}"`; + const heading = `Share "${share.name}"`; + + return ( + +
    +
    {share.description}
    + + {files.map((file) => ( +
  • + + Download {file.filename} + +
  • + ))} +
    +
    + ); +} + +export async function loader({ params }: LoaderFunctionArgs): Promise { + const shareID = params.share_id?.trim(); + + if (!shareID) { + throw new Error("Share ID is required."); + } + + const { share, share_files } = await fetchShare(shareID); + + return { + share, + files: share_files, + }; +} diff --git a/client/src/pages/shares-all.tsx b/client/src/pages/shares-all.tsx new file mode 100644 index 0000000..18362c1 --- /dev/null +++ b/client/src/pages/shares-all.tsx @@ -0,0 +1,75 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { createSharesPageURL } from "#lib/urls"; +import { parseOffset } from "#lib/pagination"; +import { fetchShares } from "#api/shares"; +import { PageSkeleton } from "#components/pages"; +import { Paginator } from "#components/pagination"; +import { CardList, ShareCard } from "#components/cards"; +import { IShare } from "#entities/files"; + +interface IProps { + count: number; + offset?: number; + shares: IShare[]; +} + +export function SharesPage() { + const { count, offset, shares } = useLoaderData() as IProps; + const title = "Filehaus"; + const heading = "Filehaus"; + + return ( + +
    + String(createSharesPageURL(offset))} + /> +
    + + + {count === 0 ? ( +
    +

    + Nobody here but us chickens! +

    +

    There are no uploads.

    +
    + ) : ( + shares.map((share) => ) + )} +
    + +
    + String(createSharesPageURL(offset))} + /> +
    +
    + ); +} + +export async function loader({ request }: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + + let offset: number | undefined = undefined; + { + const inputOffset = searchParams.get("o")?.trim(); + + if (inputOffset) { + offset = parseOffset(inputOffset); + } + } + + const { props } = await fetchShares(offset); + const { count, shares } = props; + + return { + offset, + count, + shares, + }; +} diff --git a/client/src/pages/shares.html b/client/src/pages/shares.html deleted file mode 100644 index d46faec..0000000 --- a/client/src/pages/shares.html +++ /dev/null @@ -1,49 +0,0 @@ -{% extends 'components/shell.html' %} - -{% import 'components/site.html' as site %} -{% from 'components/card_list.html' import card_list %} -{% from 'components/cards/share.html' import share_card %} - -{% block content %} -{% call site.section("all-dms", title="Filehaus") %} -
    - {% include 'components/paginator.html' %} - {# - - #} -
    - - {% call card_list("phone") %} - {% for dm in props.shares %} - {{ share_card(dm) }} - {% else %} -
    -

    Nobody here but us chickens!

    -

    - There are no uploads. -

    -
    - {% endfor %} - {% endcall %} - -
    - {% include 'components/paginator.html' %} -
    -{% endcall %} -{% endblock %} diff --git a/client/src/pages/shares.tsx b/client/src/pages/shares.tsx new file mode 100644 index 0000000..aa93f00 --- /dev/null +++ b/client/src/pages/shares.tsx @@ -0,0 +1,107 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { createProfilesSharesPageURL } from "#lib/urls"; +import { parseOffset } from "#lib/pagination"; +import { fetchProfileShares } from "#api/shares"; +import { PageSkeleton } from "#components/pages"; +import { Paginator } from "#components/pagination"; +import { CardList, ShareCard } from "#components/cards"; +import { validatePaysite } from "#entities/paysites"; +import { IShare } from "#entities/files"; + +interface IProps { + service: string; + profileID: string; + offset?: number; + count: number; + shares: IShare[]; +} + +export function ProfileSharesPage() { + const { service, profileID, count, offset, shares } = + useLoaderData() as IProps; + const title = "Filehaus"; + const heading = "Filehaus"; + + return ( + +
    + + String(createProfilesSharesPageURL(service, profileID, offset)) + } + /> +
    + + + {!count ? ( +
    +

    + Nobody here but us chickens! +

    +

    There are no uploads.

    +
    + ) : ( + shares.map((share) => ) + )} +
    + +
    + + String(createProfilesSharesPageURL(service, profileID, offset)) + } + /> +
    +
    + ); +} + +export async function loader({ + params, + request, +}: LoaderFunctionArgs): Promise { + const searchParams = new URL(request.url).searchParams; + const service = params.service?.trim(); + { + if (!service) { + throw new Error("Service name is required."); + } + + validatePaysite(service); + } + + const profileID = params.creator_id?.trim(); + { + if (!profileID) { + throw new Error("Artist ID is required."); + } + } + + let offset: number | undefined = undefined; + { + const inputOffset = searchParams.get("o")?.trim(); + + if (inputOffset) { + offset = parseOffset(inputOffset); + } + } + + const { results, props } = await fetchProfileShares( + service, + profileID, + offset + ); + const { share_count } = props; + + return { + service, + profileID, + shares: results, + count: share_count, + offset, + }; +} diff --git a/client/src/pages/success.html b/client/src/pages/success.html deleted file mode 100644 index eb5aa8a..0000000 --- a/client/src/pages/success.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends 'components/shell.html' %} - -{% block content %} -

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

    - {% if props.redirect %} - - {% endif %} -{% endblock %} diff --git a/client/src/pages/swagger_schema.html b/client/src/pages/swagger_schema.html deleted file mode 100644 index aa38187..0000000 --- a/client/src/pages/swagger_schema.html +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - SwaggerUI - - - -
    - - - - - \ No newline at end of file diff --git a/client/src/pages/tags-all.tsx b/client/src/pages/tags-all.tsx new file mode 100644 index 0000000..47518a3 --- /dev/null +++ b/client/src/pages/tags-all.tsx @@ -0,0 +1,36 @@ +import { LoaderFunctionArgs, useLoaderData } from "react-router-dom"; +import { PageSkeleton } from "#components/pages"; +import { ITag } from "#entities/tags"; +import { createTagPageURL } from "#lib/urls"; +import { fetchTags } from "#api/tags"; + +interface IProps { + tags: ITag[]; +} + +export function TagsPage() { + const { tags } = useLoaderData() as IProps; + const title = "All tags"; + const heading = "All Tags"; + + return ( + +
    + {tags.map((tag) => ( + + ))} +
    +
    + ); +} + +export async function loader({}: LoaderFunctionArgs): Promise { + const { tags } = await fetchTags(); + + return { tags }; +} diff --git a/client/src/pages/tags.html b/client/src/pages/tags.html deleted file mode 100644 index decf5b2..0000000 --- a/client/src/pages/tags.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "components/shell.html" %} - -{% from "components/headers.html" import user_header %} - -{% block title %} - Tags | {{g.site_name}} -{% endblock %} - -{% block content %} - -{% endblock %} - -{% block components %} - - {{ loading_icon() }} -{% endblock components %} diff --git a/client/src/pages/updated.html b/client/src/pages/updated.html deleted file mode 100644 index 09520f1..0000000 --- a/client/src/pages/updated.html +++ /dev/null @@ -1,28 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/card_list.html' import card_list %} -{% from 'components/cards/user.html' import user_card, user_card_header %} - -{% block content %} -
    - {% if results|length %} -
    - {% include 'components/paginator.html' %} -
    - {% endif %} - {% call card_list('phone') %} - {% for user in results %} - {{ user_card(user, is_date=true) }} - {% else %} -

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

    - {% endfor %} - {% endcall %} - {% if results|length %} -
    - {% include 'components/paginator.html' %} -
    - {% endif %} -
    -{% endblock %} diff --git a/client/src/pages/updated.js b/client/src/pages/updated.js deleted file mode 100644 index 4ae8559..0000000 --- a/client/src/pages/updated.js +++ /dev/null @@ -1,23 +0,0 @@ -import { CardList, registerPaginatorKeybinds } from "@wp/components"; -import { isLoggedIn } from "@wp/js/account"; -import { findFavouriteArtist } from "@wp/js/favorites"; - -/** - * @param {HTMLElement} section - */ -export async function updatedPage(section) { - registerPaginatorKeybinds(); - - const cardListElement = section.querySelector(".card-list"); - const { cardContainer } = CardList(cardListElement); - - for await (const userCard of cardContainer.children) { - const { id, service } = userCard.dataset; - - const isFaved = isLoggedIn && (await findFavouriteArtist(id, service)); - - if (isFaved) { - userCard.classList.add("user-card--fav"); - } - } -} diff --git a/client/src/pages/upload.html b/client/src/pages/upload.html deleted file mode 100644 index ea74e65..0000000 --- a/client/src/pages/upload.html +++ /dev/null @@ -1,87 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/site_section.html' import site_section, site_section_header %} - -{% block scripts_extra %} - - -{% endblock scripts_extra %} - -{% block content %} -{% call site_section('upload') %} -
    - {{ site_section_header('Upload file') }} -
    -
    - {% if request.args.get('service') and request.args.get('user') %} - - - {% else %} - {# #} - {% endif %} -
    -
    - - - - example, "February 2020 Rewards" - -
    -
    - - - - Specify what the file/archive is, where the original data can be found, include relevant keys/passwords, etc. - -
    -
      -
    -
    - Add files -
    -
    - -
    -
    -
    -
    -
    -{% endcall %} -{% endblock %} diff --git a/client/src/pages/upload.js b/client/src/pages/upload.js deleted file mode 100644 index a8a6975..0000000 --- a/client/src/pages/upload.js +++ /dev/null @@ -1,57 +0,0 @@ -import Dashboard from "@uppy/dashboard"; -import Form from "@uppy/form"; -import Uppy from "@uppy/core"; -import Tus from "@uppy/tus"; - -import "@uppy/dashboard/dist/style.min.css"; -import "@uppy/core/dist/style.min.css"; - -// import "@wp/js/resumable"; - -/** - * @param {HTMLElement} section - */ -export async function uploadPage(section) { - Array.from(document.getElementsByTagName("textarea")).forEach((tx) => { - function onTextareaInput() { - this.style.height = "auto"; - this.style.height = this.scrollHeight + "px"; - } - tx.setAttribute("style", "height:" + tx.scrollHeight + "px;overflow-y:hidden;"); - tx.addEventListener("input", onTextareaInput, false); - }); - - const uppy = new Uppy({ - restrictions: { - maxTotalFileSize: 2 * 1024 * 1024 * 1024, - maxNumberOfFiles: 10, - minNumberOfFiles: 1, - }, - }) - .use(Dashboard, { - note: "Up to 10 files permitted.", - fileManagerSelectionType: "both", - target: "#upload", - // inline: true, - inline: false, - trigger: "#upload-button", - theme: "dark", - }) - .use(Tus, { - // endpoint: 'https://tusd.tusdemo.net/files/', - endpoint: "http://localhost:1080/files/", - retryDelays: [0, 1000, 3000, 5000], - }) - .use(Form, { - resultName: "uppyResult", - target: "#upload-form", - submitOnSuccess: false, - }); - - uppy.on("complete", ({ successful }) => { - successful.forEach((file) => { - const fileList = document.getElementById("file-list"); - fileList.innerHTML += `
  • ${file.meta.name}
  • `; - }); - }); -} diff --git a/client/src/pages/upload.tsx b/client/src/pages/upload.tsx new file mode 100644 index 0000000..0e18861 --- /dev/null +++ b/client/src/pages/upload.tsx @@ -0,0 +1,180 @@ +import { useEffect, useState } from "react"; +import { + ActionFunctionArgs, + LoaderFunctionArgs, + useLoaderData, +} from "react-router-dom"; +import Uppy from "@uppy/core"; +import Dashboard from "@uppy/dashboard"; +import Form from "@uppy/form"; +import Tus from "@uppy/tus"; +import { FormRouter, FormSection } from "#components/forms"; +import { InputHidden } from "#components/forms/inputs"; +import { PageSkeleton, createAccountPageLoader } from "#components/pages"; + +import "@uppy/dashboard/dist/style.min.css"; +import "@uppy/core/dist/style.min.css"; + +interface IProps { + service?: string; + profileID?: string; +} + +export function Component() { + const { service, profileID } = useLoaderData() as IProps; + const [fileList, changeFileList] = useState(); + const title = "Upload file"; + const heading = "Upload File"; + + useEffect(() => { + (async () => { + try { + const uppy = new Uppy({ + restrictions: { + maxTotalFileSize: 2 * 1024 * 1024 * 1024, + maxNumberOfFiles: 10, + minNumberOfFiles: 1, + }, + }) + .use(Dashboard, { + note: "Up to 10 files permitted.", + fileManagerSelectionType: "both", + target: "#upload", + // inline: true, + inline: false, + trigger: "#upload-button", + theme: "dark", + }) + .use(Tus, { + // endpoint: 'https://tusd.tusdemo.net/files/', + endpoint: "http://localhost:1080/files/", + retryDelays: [0, 1000, 3000, 5000], + }) + .use(Form, { + resultName: "uppyResult", + target: "#upload-form", + submitOnSuccess: false, + }); + + uppy.on("complete", ({ successful }) => { + const files = successful.map( + // @ts-expect-error uppy types too generic + (file) => file.meta.name + ); + + changeFileList(files); + }); + } catch (error) { + console.error(error); + } + })(); + }, []); + + return ( + +
    + "Finish"} + > + + {Boolean(service && profileID) && ( + <> + + + + )} + + + + + + example, "February 2020 Rewards" + + + + + + + Specify what the file/archive is, where the original data can be + found, include relevant keys/passwords, etc. + + + +
      + {fileList?.map((name, index) => ( +
    • {name as string}
    • + ))} +
    + +
    + Add files +
    +
    + +
    +
    +
    + ); +} + +Component.displayName = "PostsUploadPage"; + +export const loader = createAccountPageLoader(async function loader({ + request, +}: LoaderFunctionArgs): Promise { + throw new Error("Not implemented"); + const searchParams = new URL(request.url).searchParams; + + const service = searchParams.get("service")?.trim(); + const profileID = searchParams.get("user")?.trim(); + + return { + service, + profileID, + }; +}); + +export async function action({ request }: ActionFunctionArgs) { + throw new Error("Not implemented"); + if (request.method !== "POST") { + throw new Error(`Unknown method "${request.method}".`); + } + + const formData = await request.formData(); + + const service = (formData.get("service") as string | null)?.trim(); + const profileID = (formData.get("user") as string | null)?.trim(); + const content = (formData.get("content") as string | null)?.trim(); +} diff --git a/client/src/pages/user.html b/client/src/pages/user.html deleted file mode 100644 index be968d5..0000000 --- a/client/src/pages/user.html +++ /dev/null @@ -1,91 +0,0 @@ -{% extends 'components/shell.html' %} - -{% from 'components/ads.html' import slider_ad, header_ad, footer_ad %} -{% from 'components/headers.html' import user_header %} -{% from "components/loading_icon.html" import loading_icon %} -{% from 'components/card_list.html' import card_list %} -{% from 'components/cards/post.html' import post_card %} - -{% set paysite = g.paysites[props.service] %} -{% set page_title = 'Posts of ' ~ props.name ~ ' from ' ~ paysite.title ~ ' | ' ~ g.site_name %} - -{% block title %} - {{ page_title }} -{% endblock title %} - -{% block meta %} - - - - -{% endblock meta %} - -{% block opengraph %} - - - - - -{% endblock opengraph %} - -{% block content %} -{{ slider_ad() }} -
    - {{ user_header(request, props) }} -
    - {% include 'components/tabs.html' %} - {% if results or request.args.get('q') %} - {% include 'components/paginator.html' %} -
    - - -
    - {% endif %} -
    - {% if results or request.args.get('q') %} - {{ header_ad() }} - - {% call card_list() %} - {% for post in results %} - {{ post_card(post) }} - {% endfor %} - {% endcall %} - - {{ footer_ad() }} - -
    - {% include 'components/paginator.html' %} -
    - {% endif %} - {% if not results %} -
    -

    Nobody here but us chickens!

    -

    - There are no posts for your query. -

    -
    - {% endif %} - -
    -{% endblock content %} - -{% block components %} - - {{ loading_icon() }} -{% endblock components %} diff --git a/client/src/pages/user.js b/client/src/pages/user.js deleted file mode 100644 index b27401e..0000000 --- a/client/src/pages/user.js +++ /dev/null @@ -1,122 +0,0 @@ -import { addFavouriteArtist, findFavouriteArtist, findFavouritePost, removeFavouriteArtist } from "@wp/js/favorites"; -import { CardList, PostCard, registerMessage, registerPaginatorKeybinds, showTooltip } from "@wp/components"; -import { createComponent } from "@wp/js/component-factory"; -import { isLoggedIn } from "@wp/js/account"; - -/** - * @param {HTMLElement} section - */ -export async function userPage(section) { - registerPaginatorKeybinds(); - - const artistID = document.head.querySelector("[name='id']")?.content; - const artistService = document.head.querySelector("[name='service']")?.content; - /** - * @type {HTMLElement} - */ - const buttonsPanel = section.querySelector(".user-header__actions"); - const cardListElement = section.querySelector(".card-list"); - - document.styleSheets[0].insertRule(".post-card__footer > div > img { display: none; }", 0); - await initButtons(buttonsPanel, artistID, artistService); - const urlPath = document.location.pathname; - if (urlPath.includes("fancards") ||urlPath.includes("tags")) return; - if (cardListElement) { - await initCardList(cardListElement); - } -} - -/** - * @param {HTMLElement} panelElement - * @param {string} artistID - * @param {string} artistService - */ -async function initButtons(panelElement, artistID, artistService) { - /** - * @type {HTMLButtonElement} - */ - const favButton = createComponent("user-header__favourite"); - if (!favButton) return; - const favItem = await findFavouriteArtist(artistID, artistService); - if (localStorage.getItem("logged_in") && favItem) { - favButton.classList.add("user-header__favourite--unfav"); - const [icon, text] = favButton.children; - icon.textContent = "★"; - text.textContent = "Unfavorite"; - } - - favButton.addEventListener("click", handleFavouriting(artistID, artistService)); - - panelElement?.appendChild(favButton); -} - -/** - * @param {HTMLElement} cardListElement - */ -async function initCardList(cardListElement) { - const { cardItems } = CardList(cardListElement); - - cardItems.forEach(async (card) => { - const { postID, userID, service } = PostCard(card); - const favPost = isLoggedIn && (await findFavouritePost(service, userID, postID)); - - if (favPost) { - card.classList.add("post-card--fav"); - } - }); -} - -/** - * @param {string} id - * @param {string} service - * @returns {(event: MouseEvent) => Promise} - */ -function handleFavouriting(id, service) { - return async (event) => { - /** - * @type {HTMLButtonElement} - */ - const button = event.target; - - if (!isLoggedIn) { - showTooltip(button, registerMessage(null, "Favoriting")); - return; - } - - const [icon, text] = button.children; - /** - * @type {HTMLElement} - */ - const loadingIcon = createComponent("loading-icon"); - - button.disabled = true; - button.classList.add("user-header__favourite--loading"); - button.insertBefore(loadingIcon, text); - - try { - if (button.classList.contains("user-header__favourite--unfav")) { - const isRemoved = await removeFavouriteArtist(id, service); - - if (isRemoved) { - button.classList.remove("user-header__favourite--unfav"); - icon.textContent = "☆"; - text.textContent = "Favorite"; - } - } else { - const isAdded = await addFavouriteArtist(id, service); - - if (isAdded) { - button.classList.add("user-header__favourite--unfav"); - icon.textContent = "★"; - text.textContent = "Unfavorite"; - } - } - } catch (error) { - console.error(error); - } finally { - loadingIcon.remove(); - button.disabled = false; - button.classList.remove("user-header__favourite--loading"); - } - }; -} diff --git a/client/src/router.tsx b/client/src/router.tsx new file mode 100644 index 0000000..b5aa56d --- /dev/null +++ b/client/src/router.tsx @@ -0,0 +1,417 @@ +import { createBrowserRouter } from "react-router-dom"; +import { Layout } from "#components/layout"; +import { ErrorPage } from "#components/pages"; +import { HomePage } from "#pages/home"; +import { ImporterTutorialPage } from "#pages/importer_tutorial"; +import { ImporterTutorialFanboxPage } from "#pages/importer_tutorial_fanbox"; +import { + ImporterPage, + action as importerPageAction, +} from "#pages/importer_list"; +import { + SearchFilesPage, + loader as searchFilesPageLoader, + action as searchFilesPageAction, +} from "#pages/search_hash"; +import { ImporterOKPage } from "#pages/importer_ok"; +import { + AdministratorDashboardPage, + loader as administratorDashboardPageLoader, +} from "#pages/account/administrator/dashboard"; +import { + AccountLoginPage, + action as accountLoginPageAction, +} from "#pages/account/login"; +import { ArtistsPage, loader as artistsPageLoader } from "#pages/profiles"; +import { + ArtistsUpdatedPage, + loader as artistsUpdatedPageLoader, +} from "#pages/profiles/updated"; +import { loader as artistRandomPageLoader } from "#pages/profiles/random"; +import { ProfilePage, loader as profilePageLoader } from "#pages/profile"; +import { + ProfileTagsPage, + loader as profileTagsPageLoader, +} from "#pages/profile/tags"; +import { + FancardsPage, + loader as fancardsLoader, +} from "#pages/profile/fancards"; +import { + ProfileSharesPage, + loader as profileSharesPageLoader, +} from "#pages/shares"; +import { + ProfileDMsPage, + loader as profileDMsPageLoader, +} from "#pages/profile/dms"; +import { + AnnouncementsPage, + loader as announcementsPageLoader, +} from "#pages/profile/announcements"; +import { + ProfileLinksPage, + loader as profileLinksPageLoader, +} from "#pages/profile/linked_accounts"; +import { + NewProfileLinkPage, + loader as newProfileLinkPageLoader, + action as newProfileLinkPageAction, +} from "#pages/profile/new_linked_account"; +import { DMsPage, loader as dmsPageLoader } from "#pages/all_dms"; +import { loader as accountFavoritesPageLoader } from "#pages/favorites"; +import { SharePage, loader as sharePageLoader } from "#pages/share"; +import { SharesPage, loader as sharesPageLoader } from "#pages/shares-all"; +import { PostPage, loader as postPageLoader } from "#pages/post"; +import { loader as postPageDataLoader } from "#pages/post/data"; +import { PostsPage, loader as postsPageLoader } from "#pages/posts"; +import { + PopularPostsPage, + loader as popularPostsPageLoader, +} from "#pages/posts/popular"; +import { TagsPage, loader as tagsPageLoader } from "#pages/tags-all"; +import { + DiscordServerPage, + loader as discordServerPageLoader, +} from "#pages/discord"; +import { + DiscordChannelPage, + loader as discordChannelPageLoader, +} from "#pages/discord-channel"; +import { + ArchiveFilePage, + loader as archiveFilePageLoader, +} from "#pages/posts/archive"; +import { loader as postRandomPageLoader } from "#pages/posts/random"; +import { + DMsReviewPage, + loader as dmsReviewPageLoader, + action as dmsReviewPageAction, +} from "#pages/review_dms/review_dms"; +import { + PostRevisionPage, + loader as postRevisionPageLoader, +} from "#pages/post-revision"; +import { + ImporterStatusPage, + loader as importerStatusPageLoader, +} from "#pages/importer_status"; +import { AccountPage, loader as accountPageLoader } from "#pages/account/home"; +import { + AccountNotificationsPage, + loader as accountNotificationsPageLoader, +} from "#pages/account/notifications"; +import { + AccountAutoImportKeysPage, + loader as accountAutoImportKeysPageLoader, + action as accountAutoImportKeysPageAction, +} from "#pages/account/keys"; +import { + AccountChangePasswordPage, + loader as accountChangePasswordPageLoader, +} from "#pages/account/change_password"; +import { + AdministratorAccountsPage, + loader as administratorAccountsPageLoader, +} from "#pages/account/administrator/accounts"; +import { + ModeratorDashboardPage, + loader as moderatorDashboardPageLoader, +} from "#pages/account/moderator/dashboard"; +import { + ProfileLinkRequestsPage, + loader as profileLinkRequestsPageLoader, +} from "#pages/account/moderator/profile_links"; +import { + RegisterPage, + action as registerPageAction, +} from "#pages/account/register"; +import { loader as accountLogoutPageLoader } from "#pages/authentication/logout"; +import { loader as favoritesLegacyPageLoader } from "#pages/account/favorites/legacy"; +import { + FavoriteProfilesPage, + loader as favoritesProfilesPageLoader, +} from "#pages/account/favorites/profiles"; +import { + FavoritePostsPage, + loader as favoritesPostsPageLoader, +} from "#pages/account/favorites/posts"; +import { Compliance2257Page } from "#pages/2257"; +import { ContactPage } from "#pages/contact"; +import { DMCAPage } from "#pages/dmca"; +import { FanboxImportsPage } from "#pages/fanboximports"; +import { GumroadAndCoPage } from "#pages/gumroad-and-co"; +import { MatrixPage } from "#pages/matrix"; + +export const router = createBrowserRouter([ + { + path: "/", + element: , + errorElement: , + children: [ + { + errorElement: , + children: [ + { index: true, element: }, + { + path: "/2257", + element: , + }, + { + path: "/contact", + element: , + }, + { + path: "/dmca", + element: , + }, + { + path: "/fanboximports", + element: , + }, + { + path: "/gumroad-and-co", + element: , + }, + { + path: "/matrix", + element: , + }, + { + path: "/importer", + element: , + action: importerPageAction, + }, + { + path: "/importer/ok", + element: , + }, + { + path: "/importer/tutorial", + element: , + }, + { + path: "/importer/tutorial_fanbox", + element: , + }, + { + path: "/importer/status/:import_id", + element: , + loader: importerStatusPageLoader, + }, + { + path: "/search_hash", + element: , + loader: searchFilesPageLoader, + action: searchFilesPageAction, + }, + { + path: "/artists", + element: , + loader: artistsPageLoader, + }, + { + path: "/artists/updated", + element: , + loader: artistsUpdatedPageLoader, + }, + { + path: "/artists/random", + loader: artistRandomPageLoader, + }, + { + path: "/posts", + element: , + loader: postsPageLoader, + }, + { + path: "/posts/popular", + element: , + loader: popularPostsPageLoader, + }, + { + path: "/posts/tags", + element: , + loader: tagsPageLoader, + }, + { + path: "/posts/archives/:file_hash", + element: , + loader: archiveFilePageLoader, + }, + { + path: "/posts/random", + loader: postRandomPageLoader, + }, + { + path: "/favorites", + loader: favoritesLegacyPageLoader, + }, + { + path: "/discord/server/:server_id", + element: , + loader: discordServerPageLoader, + }, + { + path: "/discord/server/:server_id/:channel_id", + element: , + loader: discordChannelPageLoader, + }, + { + path: "/:service/user/:creator_id", + element: , + loader: profilePageLoader, + }, + { + path: "/:service/user/:creator_id/tags", + element: , + loader: profileTagsPageLoader, + }, + { + path: "/:service/user/:creator_id/fancards", + element: , + loader: fancardsLoader, + }, + { + path: "/:service/user/:creator_id/shares", + element: , + loader: profileSharesPageLoader, + }, + { + path: "/:service/user/:creator_id/dms", + element: , + loader: profileDMsPageLoader, + }, + { + path: "/:service/user/:creator_id/announcements", + element: , + loader: announcementsPageLoader, + }, + { + path: "/:service/user/:creator_id/links", + element: , + loader: profileLinksPageLoader, + }, + { + path: "/:service/user/:creator_id/post/:post_id", + element: , + loader: postPageLoader, + }, + { + path: "/:service/user/:creator_id/post/:post_id/revision/:revision_id", + element: , + loader: postRevisionPageLoader, + }, + { + path: "/:service/post/:post_id", + loader: postPageDataLoader, + }, + { + path: "/dms", + element: , + loader: dmsPageLoader, + }, + { + path: "/shares", + element: , + loader: sharesPageLoader, + }, + { + path: "/share/:share_id", + element: , + loader: sharePageLoader, + }, + { + path: "/documentation/api", + lazy: () => import("#pages/documentation/api"), + }, + { + path: "/authentication/register", + element: , + action: registerPageAction, + }, + { + path: "/authentication/login", + element: , + action: accountLoginPageAction, + }, + { + path: "/authentication/logout", + loader: accountLogoutPageLoader, + }, + { + path: "/account", + element: , + loader: accountPageLoader, + }, + { + path: "/account/favorites", + loader: accountFavoritesPageLoader, + }, + { + path: "/account/favorites/artists", + element: , + loader: favoritesProfilesPageLoader, + }, + { + path: "/account/favorites/posts", + element: , + loader: favoritesPostsPageLoader, + }, + { + path: "/account/notifications", + element: , + loader: accountNotificationsPageLoader, + }, + { + path: "/account/keys", + element: , + loader: accountAutoImportKeysPageLoader, + action: accountAutoImportKeysPageAction, + }, + { + path: "/account/change_password", + element: , + loader: accountChangePasswordPageLoader, + }, + { + path: "/account/:service/user/:creator_id/links/new", + element: , + loader: newProfileLinkPageLoader, + action: newProfileLinkPageAction, + }, + { + path: "/account/posts/upload", + lazy: () => import("#pages/upload"), + }, + { + path: "/account/review_dms", + element: , + loader: dmsReviewPageLoader, + action: dmsReviewPageAction, + }, + { + path: "/account/moderator", + element: , + loader: moderatorDashboardPageLoader, + }, + { + path: "/account/moderator/tasks/creator_links", + element: , + loader: profileLinkRequestsPageLoader, + }, + { + path: "/account/administrator", + element: , + loader: administratorDashboardPageLoader, + }, + { + path: "/account/administrator/accounts", + element: , + loader: administratorAccountsPageLoader, + }, + ], + }, + ], + }, +]); diff --git a/client/src/templates/page.html b/client/src/templates/page.html deleted file mode 100644 index 66555e3..0000000 --- a/client/src/templates/page.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'components/shell.html' %} - -{% import 'components/site.html' as site %} - -{% set page_title = 'Heading | ' ~ g.site_name %} - -{% block title %} - - {{ page_title }} - -{% endblock title %} - -{% block content %} -{% call site.section('modifier', 'Heading') %} - -{% endcall %} -{% endblock content %} diff --git a/client/src/types/global.d.ts b/client/src/types/global.d.ts deleted file mode 100644 index b50a491..0000000 --- a/client/src/types/global.d.ts +++ /dev/null @@ -1,93 +0,0 @@ -interface KemonoAPI { - favorites: KemonoAPI.Favorites; - posts: KemonoAPI.Posts; - api: KemonoAPI.API; - dms: KemonoAPI.dms; -} - -namespace KemonoAPI { - interface Post { - id: string; - service: string; - title: string; - user: string; - added: string; - published: string; - attachments: string[]; - content: string; - edited: null; - embed: {}; - file: {}; - shared_file: boolean; - faved_seq?: number; - } - - interface User { - id: string; - name: string; - service: string; - indexed: string; - updated: string; - faved_seq?: number; - } - - interface Favorites { - retrieveFavoriteArtists: () => Promise; - favoriteArtist: (service: string, id: string) => Promise; - unfavoriteArtist: (service: string, id: string) => Promise; - retrieveFavoritePosts: () => Promise; - favoritePost: (service: string, user: string, post_id: string) => Promise; - unfavoritePost: (service: string, user: string, post_id: string) => Promise; - } - - namespace Favorites { - interface User extends KemonoAPI.User {} - - interface Post { - id: string; - service: string; - user: string; - } - } - - interface dms { - retrieveHasPendingDMs: () => Promise; - } - interface Posts { - attemptFlag: (service: string, user: string, post_id: string) => Promise; - } - - interface API { - bans: () => Promise; - bannedArtist: (id: string, service: string) => Promise; - creators: () => Promise; - logs: (importID: string) => Promise; - } - - namespace API { - interface BanItem { - id: string; - service: string; - } - - interface BannedArtist { - name: string; - } - - interface LogItem {} - } -} - -namespace Events { - interface Click { - (event: MouseEvent): void; - } - - interface NavClick { - (event: NavClickEvent): void; - } - - interface NavClickEvent extends MouseEvent { - target: HTMLButtonElement; - } -} diff --git a/client/src/utils/_index.js b/client/src/utils/_index.js deleted file mode 100644 index 903d953..0000000 --- a/client/src/utils/_index.js +++ /dev/null @@ -1,217 +0,0 @@ -export { KemonoError } from "./kemono-error"; - -const defaultDelay = parseInt(document.documentElement.style.getPropertyValue("--duration-global")); - -/** - * @param {string} name - * @param {string} url - * @returns - */ -function getParameterByName(name, url) { - if (!url) url = window.location.href; - name = name.replace(/[[]]/g, "\\$&"); - var regex = new RegExp("[?&]" + name + "(=([^&#]*)|&|#|$)"); - var results = regex.exec(url); - if (!results) return null; - if (!results[2]) return ""; - return decodeURIComponent(results[2].replace(/\+/g, " ")); -} - -/** - * @param {() => void} func - * @param {number} wait - * @param {boolean} immediate - * @returns {void} - */ -function debounce(func, wait, immediate) { - let timeout; - return function () { - var context = this; - var args = arguments; - var callNow = immediate && !timeout; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - if (callNow) func.apply(context, args); - - function later() { - timeout = null; - if (!immediate) func.apply(context, args); - } - }; -} - -/** - * @param {number} time - * @returns - */ -export function setTimeoutAsync(time = defaultDelay) { - const timeOut = new Promise((resolve) => { - setTimeout(resolve, time); - }); - return timeOut; -} - -/** - * Iterate over the list of images - * and add `image_link` class - * if they are a descendant of an `a` element - * and don't have that class already. - * @param {HTMLImageElement[] | HTMLCollectionOf} imageElements - */ -export function fixImageLinks(imageElements) { - const images = Array.from(imageElements); - - images.forEach((image) => { - const link = image.closest("a"); - - if ( - link && - // && !image.nextSibling - // && !image.previousSibling - // TODO: fix this later - !link.classList.contains("user-header__profile") && - !link.classList.contains("user-card") && - !link.classList.contains("image-link") && - !link.classList.contains("global-sidebar-entry-item") - ) { - link.classList.add("image-link"); - } - }); -} - -/** - * @type {{[paysite:string]: {title: string, color: string, user: { profile: (userID: string) => string }, post: {}}}} - */ -export const paysites = { - patreon: { - title: "Patreon", - color: "#fa5742", - user: { - profile: (userID) => `https://www.patreon.com/user?u=${userID}`, - }, - post: {}, - }, - fanbox: { - title: "Pixiv Fanbox", - color: "#2c333c", - user: { - profile: (userID) => `https://www.pixiv.net/fanbox/creator/${userID}`, - }, - post: {}, - }, - subscribestar: { - title: "SubscribeStar", - color: "#009688", - user: { - profile: (userID) => `https://subscribestar.adult/${userID}`, - }, - post: {}, - }, - gumroad: { - title: "Gumroad", - color: "#2b9fa4", - user: { - profile: (userID) => `https://gumroad.com/${userID}`, - }, - post: {}, - }, - discord: { - title: "Discord", - color: "#5165f6", - user: { - profile: (userID) => ``, - }, - post: {}, - }, - dlsite: { - title: "DLsite", - color: "#052a83", - user: { - profile: (userID) => `https://www.dlsite.com/eng/circle/profile/=/maker_id/${userID}`, - }, - post: {}, - }, - fantia: { - title: "Fantia", - color: "#e1097f", - user: { - profile: (userID) => `user_id: f"https://fantia.jp/fanclubs/${userID}`, - }, - post: {}, - }, - boosty: { - title: "Boosty", - color: "#fd6035", - user: { - profile: (userID) => `https://boosty.to/${userID}`, - }, - post: {}, - }, - afdian: { - title: "Afdian", - color: "#9169df", - user: { - profile: (userID) => ``, - }, - post: {}, - }, - fansly: { - title: "Fansly", - color: "#2399f7", - user: { - profile: (userID) => `https://fansly.com/${userID}`, - }, - post: {}, - }, - onlyfans: { - title: "OnlyFans", - color: "#008ccf", - user: { - profile: (userID) => `https://onlyfans.com/${userID}`, - }, - post: {}, - }, - candfans: { - title: "CandFans", - color: "#e8486c", - user: { - profile: (userID) => `https://candfans.jp/${userID}`, - }, - post: {}, - }, -}; - -export const freesites = { - kemono: { - title: "Kemono", - user: { - /** - * @param {string} service - * @param {string} artistID - */ - profile: (service, artistID) => `/${service}/${service === "discord" ? "server" : "user"}/${artistID}`, - /** - * @param {string} service - * @param {string} artistID - */ - icon: (service, artistID) => `/icons/${service}/${artistID}`, - banner: (service, artistID) => `/banners/${service}/${artistID}`, - }, - post: { - /** - * @param {string} service - * @param {string} userID - * @param {string} postID - * @returns - */ - link: (service, userID, postID) => `/${service}/user/${userID}/post/${postID}`, - }, - }, -}; - -/** - * @param {number} time - */ -export function waitAsync(time) { - return new Promise((resolve) => setTimeout(resolve, time)); -} diff --git a/client/src/utils/kemono-error.js b/client/src/utils/kemono-error.js deleted file mode 100644 index a54c25b..0000000 --- a/client/src/utils/kemono-error.js +++ /dev/null @@ -1,23 +0,0 @@ -const errorList = { - 0: "Could not connect to server.", - 1: "Could not favorite post.", - 2: "Could not unfavorite post.", - 3: "Could not favorite artist.", - 4: "Could not unfavorite artist.", - 5: "There might already be a flag here.", - 6: "Could not retrieve the list of bans.", - 7: "Could not retrieve banned artist.", - 8: "Could not retrieve artists.", - 9: "Could not retrieve import logs.", -}; - -export class KemonoError extends Error { - /** - * @param {number} code - */ - constructor(code) { - super(); - this.code = String(code).padStart(3, "0"); - this.message = `${this.code}: ${errorList[code]}`; - } -} diff --git a/client/static/boosty.svg b/client/static/boosty.svg new file mode 100644 index 0000000..68f7172 --- /dev/null +++ b/client/static/boosty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/static/small_icons/boosty.png b/client/static/small_icons/boosty.png new file mode 100644 index 0000000000000000000000000000000000000000..6573b946eab35937c413a9c56d94018153bf7fc3 GIT binary patch literal 154 zcmeAS@N?(olHy`uVBq!ia0vp^fVSC4{<&utH9#GpmyWqXYacoGCQ`W-!}aTw2Z;i)z4*}Q$iB} D1SmCD literal 0 HcmV?d00001 diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..20e1c4a --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "rootDir": ".", + "outDir": "./dist", + "sourceMap": true, + "lib": ["dom", "esnext"], + "allowJs": true, + "checkJs": true, + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true, + "target": "ES2017", + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "dist", "dev", "static"] +} diff --git a/client/webpack.config.js b/client/webpack.config.js index e5c0ee1..aa22d23 100644 --- a/client/webpack.config.js +++ b/client/webpack.config.js @@ -1,46 +1,107 @@ -const path = require("path"); +// @ts-check const { DefinePlugin } = require("webpack"); const CopyWebpackPlugin = require("copy-webpack-plugin"); -const { buildHTMLWebpackPluginsRecursive } = require("./configs/build-templates"); +const HTMLWebpackPlugin = require("html-webpack-plugin"); const { kemonoSite, - nodeEnv, + sentryDSN, + siteName, iconsPrepend, bannersPrepend, thumbnailsPrepend, creatorsLocation, + artistsOrCreators, + disableDMs, + disableFAQ, + disableFilehaus, + sidebarItems, + footerItems, + bannerGlobal, + bannerWelcome, + homeBackgroundImage, + homeMascotPath, + homeLogoPath, + paysiteList, + homeWelcomeCredits, + homeAnnouncements, + headerAd, + middleAd, + footerAd, + sliderAd, + videoAd, + isArchiveServerEnabled, + apiServerBaseURL, + apiServerPort, + analyticsEnabled, + analyticsCode, } = require("./configs/vars"); -const projectPath = path.resolve(__dirname, "src"); -const pagesPath = path.join(projectPath, "pages"); -const pagePlugins = buildHTMLWebpackPluginsRecursive(pagesPath, { - fileExtension: "html", - pluginOptions: { - inject: false, - minify: false, - }, -}); - /** * TODO: make separate entries for `admin` and `moderator` * @type import("webpack").Configuration */ const webpackConfig = { entry: { - global: path.join(projectPath, "js", "global.js"), - admin: path.join(projectPath, "js", "admin.js"), + index: "./src/index.tsx", + // global: path.join(projectPath, "js", "global.js"), + // admin: path.join(projectPath, "js", "admin.js"), // moderator: path.join(projectPath, "js", "moderator.js"), }, plugins: [ - ...pagePlugins, + // ...pagePlugins, + new HTMLWebpackPlugin({ + title: siteName, + filename: "index.html", + favicon: "./static/favicon.ico", + meta: { + "og:type": "website", + "og:site_name": siteName, + "og:title": siteName, + "og:image": `${kemonoSite}/static/kemono-logo.svg`, + "og:image:width": "150", + "og:image:height": "150", + }, + templateParameters: { + analytics: !analyticsEnabled + ? undefined + : !analyticsCode + ? undefined + : atob(analyticsCode), + }, + template: "./src/index.html", + }), + // https://webpack.js.org/plugins/define-plugin/ new DefinePlugin({ BUNDLER_ENV_KEMONO_SITE: JSON.stringify(kemonoSite), - BUNDLER_ENV_NODE_ENV: JSON.stringify(nodeEnv), + BUNDLER_ENV_SENTRY_DSN: JSON.stringify(sentryDSN), + BUNDLER_ENV_SITE_NAME: JSON.stringify(siteName), BUNDLER_ENV_ICONS_PREPEND: JSON.stringify(iconsPrepend), BUNDLER_ENV_BANNERS_PREPEND: JSON.stringify(bannersPrepend), BUNDLER_ENV_THUMBNAILS_PREPEND: JSON.stringify(thumbnailsPrepend), BUNDLER_ENV_CREATORS_LOCATION: JSON.stringify(creatorsLocation), + BUNDLER_ENV_ARTISTS_OR_CREATORS: JSON.stringify(artistsOrCreators), + BUNDLER_ENV_DISABLE_DMS: JSON.stringify(disableDMs), + BUNDLER_ENV_DISABLE_FAQ: JSON.stringify(disableFAQ), + BUNDLER_ENV_DISABLE_FILEHAUS: JSON.stringify(disableFilehaus), + BUNDLER_ENV_SIDEBAR_ITEMS: JSON.stringify(sidebarItems), + BUNDLER_ENV_FOOTER_ITEMS: JSON.stringify(footerItems), + BUNDLER_ENV_BANNER_GLOBAL: JSON.stringify(bannerGlobal), + BUNDLER_ENV_BANNER_WELCOME: JSON.stringify(bannerWelcome), + BUNDLER_ENV_HOME_BACKGROUND_IMAGE: JSON.stringify(homeBackgroundImage), + BUNDLER_ENV_HOME_MASCOT_PATH: JSON.stringify(homeMascotPath), + BUNDLER_ENV_HOME_LOGO_PATH: JSON.stringify(homeLogoPath), + BUNDLER_ENV_HOME_WELCOME_CREDITS: JSON.stringify(homeWelcomeCredits), + BUNDLER_ENV_HOME_ANNOUNCEMENTS: JSON.stringify(homeAnnouncements), + BUNDLER_ENV_PAYSITE_LIST: JSON.stringify(paysiteList), + BUNDLER_ENV_HEADER_AD: JSON.stringify(headerAd), + BUNDLER_ENV_MIDDLE_AD: JSON.stringify(middleAd), + BUNDLER_ENV_FOOTER_AD: JSON.stringify(footerAd), + BUNDLER_ENV_SLIDER_AD: JSON.stringify(sliderAd), + BUNDLER_ENV_VIDEO_AD: JSON.stringify(videoAd), + BUNDLER_ENV_IS_ARCHIVER_ENABLED: JSON.stringify(isArchiveServerEnabled), + BUNDLER_ENV_API_SERVER_BASE_URL: JSON.stringify(apiServerBaseURL), + BUNDLER_ENV_API_SERVER_PORT: JSON.stringify(apiServerPort), }), new CopyWebpackPlugin({ patterns: [ @@ -52,18 +113,7 @@ const webpackConfig = { }), ], resolve: { - extensions: [".js"], - alias: { - ["@wp/pages"]: path.join(projectPath, "pages", "_index.js"), - ["@wp/components"]: path.join(projectPath, "pages", "components", "_index.js"), - ["@wp/env"]: path.join(projectPath, "env"), - ["@wp/lib"]: path.join(projectPath, "lib"), - ["@wp/js"]: path.join(projectPath, "js"), - ["@wp/css"]: path.join(projectPath, "css"), - ["@wp/assets"]: path.join(projectPath, "assets"), - ["@wp/api"]: path.join(projectPath, "api", "_index.js"), - ["@wp/utils"]: path.join(projectPath, "utils", "_index.js"), - }, + extensions: [".tsx", ".ts", ".js"], fallback: { stream: false, }, diff --git a/client/webpack.dev.js b/client/webpack.dev.js index ab8884e..9ccbdf4 100644 --- a/client/webpack.dev.js +++ b/client/webpack.dev.js @@ -1,9 +1,10 @@ +// @ts-check const MiniCssExtractPlugin = require("mini-css-extract-plugin"); const path = require("path"); const { merge } = require("webpack-merge"); - +const yaml = require("yaml") +const { kemonoSite, apiServerBaseURL, apiServerPort } = require("./configs/vars"); const baseConfig = require("./webpack.config"); -const { kemonoSite } = require("./configs/vars"); const projectPath = path.resolve(__dirname, "src"); @@ -13,25 +14,23 @@ const projectPath = path.resolve(__dirname, "src"); const devServer = { host: "0.0.0.0", port: 3450, - devMiddleware: { - writeToDisk: true, - }, - watchFiles: { - options: { - poll: 500, - aggregateTimeout: 500, - }, + proxy: { + context: ['/api'], + target: `${apiServerBaseURL}:${apiServerPort}`, }, static: { directory: path.resolve(__dirname, "static"), watch: true, }, - hot: false, - liveReload: true, + hot: true, + // liveReload: true, client: { overlay: true, progress: true, }, + historyApiFallback: { + index: "/index.html", + }, }; /** @@ -39,11 +38,8 @@ const devServer = { */ const webpackConfigDev = { mode: "development", - devtool: "eval-source-map", + devtool: "inline-source-map", devServer: devServer, - entry: { - development: path.join(projectPath, "development", "entry.js"), - }, plugins: [ new MiniCssExtractPlugin({ filename: "static/bundle/css/[name].css", @@ -51,10 +47,19 @@ const webpackConfigDev = { }), ], module: { + parser: { + javascript: { + exportsPresence: "error", + }, + }, rules: [ + { + test: /\.tsx?$/, + use: "ts-loader", + exclude: /node_modules/, + }, { test: /\.s[ac]ss$/i, - exclude: /\.module.s[ac]ss$/i, use: [ MiniCssExtractPlugin.loader, { @@ -89,6 +94,13 @@ const webpackConfigDev = { test: /\.css$/, use: ["style-loader", "css-loader"], }, + { + test: /\.yaml$/i, + type: 'json', + parser: { + parse: yaml.parse, + }, + }, ], }, output: { diff --git a/client/webpack.prod.js b/client/webpack.prod.js index ff02923..6475d4d 100644 --- a/client/webpack.prod.js +++ b/client/webpack.prod.js @@ -1,7 +1,10 @@ -const path = require("path"); +// @ts-check +const path = require("path"); const MiniCSSExtractPlugin = require("mini-css-extract-plugin"); const { merge } = require("webpack-merge"); +const yaml = require("yaml"); +const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); const baseConfig = require("./webpack.config"); const { kemonoSite } = require("./configs/vars"); @@ -10,8 +13,9 @@ const { kemonoSite } = require("./configs/vars"); */ const webpackConfigProd = { mode: "production", - // devtool: "source-map", + devtool: "source-map", plugins: [ + new BundleAnalyzerPlugin({ analyzerMode: "static", openAnalyzer: false }), new MiniCSSExtractPlugin({ filename: "static/bundle/css/[name]-[contenthash].css", chunkFilename: "static/bundle/css/[id]-[contenthash].chunk.css", @@ -20,19 +24,25 @@ const webpackConfigProd = { module: { rules: [ { - test: /\.m?js$/i, - exclude: /node_modules/, - use: { - loader: "babel-loader", - options: { - presets: [["@babel/preset-env", { targets: "defaults" }]], - plugins: ["@babel/plugin-transform-runtime"], + test: /\.tsx?$/, + use: [ + { + loader: "babel-loader", + options: { + presets: [ + ["@babel/preset-env", { targets: "defaults" }], + ["@babel/preset-typescript"], + ["@babel/preset-react"], + ], + plugins: ["@babel/plugin-transform-runtime"], + }, }, - }, + "ts-loader", + ], + exclude: /node_modules/, }, { test: /\.s[ac]ss$/i, - exclude: /\.module\.s[ac]ss$/i, use: [ MiniCSSExtractPlugin.loader, { @@ -85,28 +95,26 @@ const webpackConfigProd = { test: /\.css$/, use: ["style-loader", "css-loader"], }, + { + test: /\.yaml$/i, + type: "json", + parser: { + parse: yaml.parse, + }, + }, ], }, output: { path: path.resolve(__dirname, "dist"), filename: "static/bundle/js/[name]-[contenthash].bundle.js", - assetModuleFilename: "static/bundle/assets/[name]-[contenthash][ext][query]", - // sourceMapFilename: "source-maps/[file].map[query]", + assetModuleFilename: + "static/bundle/assets/[name]-[contenthash][ext][query]", publicPath: "/", clean: true, }, optimization: { moduleIds: "deterministic", runtimeChunk: "single", - splitChunks: { - cacheGroups: { - vendor: { - test: /[\\/]node_modules[\\/]/, - name: "vendors", - chunks: "all", - }, - }, - }, }, }; diff --git a/config.example.json b/config.example.json index f2a558c..0a70c3c 100644 --- a/config.example.json +++ b/config.example.json @@ -1,16 +1,18 @@ { - "site": "http://localhost:5000", + "site": "http://localhost:3450", "development_mode": true, "automatic_migrations": true, "webserver": { "secret": "To SECRET name.", - "port": 80, + "base_url": "http://localhost", + "port": 3449, "ui": { "home": { "site_name": "Kemono" }, "config": { - "paysite_list": ["patreon", "fanbox", "afdian"] + "paysite_list": ["patreon", "fanbox", "afdian"], + "artists_or_creators": "Artists" } } }, diff --git a/db/migrations/20241110_00_DASAD-add-favorite-counts-table.py b/db/migrations/20241110_00_DASAD-add-favorite-counts-table.py new file mode 100644 index 0000000..19fe141 --- /dev/null +++ b/db/migrations/20241110_00_DASAD-add-favorite-counts-table.py @@ -0,0 +1,62 @@ +""" +Add a favorite_counts table to aleviate the query time. +""" + +from yoyo import step + +__depends__ = {"20240223_00_ASDAS-reset_relation_id_seq"} + +steps = [ + step( + """ + CREATE TABLE IF NOT EXISTS favorite_counts ( + service varchar NOT NULL, + artist_id varchar NOT NULL, + favorite_count INTEGER DEFAULT 0, + PRIMARY KEY (service, artist_id) + ); + """ + ), + step( + """ + CREATE OR REPLACE FUNCTION update_favorite_count() + RETURNS TRIGGER AS $$ + BEGIN + -- For INSERT event: increment the count + IF TG_OP = 'INSERT' THEN + INSERT INTO favorite_counts (service, artist_id, favorite_count) + VALUES (NEW.service, NEW.artist_id, 1) + ON CONFLICT (service, artist_id) + DO UPDATE SET favorite_count = favorite_counts.favorite_count + 1; + + -- For DELETE event: decrement the count + ELSIF TG_OP = 'DELETE' THEN + UPDATE favorite_counts + SET favorite_count = favorite_count - 1 + WHERE service = OLD.service AND artist_id = OLD.artist_id; + END IF; + + RETURN NULL; -- triggers on INSERT/DELETE return NULL + END; + $$ LANGUAGE plpgsql; + """ + ), + step( + """ + CREATE TRIGGER update_favorite_count_trigger + AFTER INSERT OR DELETE ON account_artist_favorite + FOR EACH ROW + EXECUTE FUNCTION update_favorite_count(); + """ + ), + step( + """ + INSERT INTO favorite_counts (service, artist_id, favorite_count) + SELECT service, artist_id, COUNT(*) AS favorite_count + FROM account_artist_favorite + GROUP BY service, artist_id + ON CONFLICT (service, artist_id) + DO UPDATE SET favorite_count = EXCLUDED.favorite_count; + """ + ), +] diff --git a/db/schema/public/accounts.sql b/db/schema/public/accounts.sql new file mode 100644 index 0000000..39bcbf5 --- /dev/null +++ b/db/schema/public/accounts.sql @@ -0,0 +1,52 @@ +-- accounts +CREATE TABLE account( + id serial PRIMARY KEY, + username varchar NOT NULL, + password_hash varchar NOT NULL, + created_at timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + role varchar DEFAULT 'consumer', + UNIQUE (username) +); + +CREATE TABLE account_artist_favorite( + id serial, + created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, + account_id int NOT NULL REFERENCES account(id), + service varchar(20) NOT NULL, + artist_id varchar(255) NOT NULL, + PRIMARY KEY (service, id), + UNIQUE (account_id, service, artist_id) +); + +CREATE INDEX ON account_artist_favorite(service, artist_id); + +CREATE TABLE account_post_favorite( + id serial, + created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, + account_id int NOT NULL REFERENCES account(id), + service varchar(20) NOT NULL, + artist_id varchar(255) NOT NULL, + post_id varchar(255) NOT NULL, + PRIMARY KEY (service, id), + UNIQUE (account_id, service, artist_id, post_id) +); + +CREATE TABLE notifications( + id bigserial PRIMARY KEY, + account_id int NOT NULL, + type SMALLINT NOT NULL, + extra_info jsonb, + created_at timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_seen boolean NOT NULL DEFAULT FALSE, + FOREIGN KEY (account_id) REFERENCES account(id) +); + +CREATE INDEX account_idx ON account USING BTREE(username, created_at, ROLE); + +CREATE INDEX ON account_post_favorite(service, artist_id, post_id); + +CREATE INDEX notifications_account_id_idx ON notifications USING BTREE("account_id"); + +CREATE INDEX notifications_created_at_idx ON notifications USING BTREE("created_at"); + +CREATE INDEX notifications_type_idx ON notifications USING BTREE("type"); diff --git a/db/schema/public/artists.sql b/db/schema/public/artists.sql new file mode 100644 index 0000000..3438305 --- /dev/null +++ b/db/schema/public/artists.sql @@ -0,0 +1,107 @@ +-- Lookup (aka artists) +CREATE TABLE lookup( + "id" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "indexed" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + public_id text, + relation_id integer, + PRIMARY KEY (id, service) +); + +CREATE TABLE creators( + creator_id text NOT NULL, + service text NOT NULL, + creator_name text NOT NULL, + creator_slug text, + creator_internal_id text, + short_description text NOT NULL, + description text NOT NULL, + icon text, + banner text, + is_nsfw boolean, + deleted_at timestamp without time zone, + stopped_at timestamp without time zone, + paused_at timestamp without time zone, + post_count integer, + media_count integer, + tiers jsonb[], + access_groups jsonb[], + published_at timestamp without time zone, + added_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone, + public_posts_refreshed_at timestamp without time zone, + public_posts_full_refreshed_at timestamp without time zone, + CONSTRAINT creators_pkey PRIMARY KEY (creator_id, service) +); + +CREATE TABLE creators_revisions( + revision_id serial NOT NULL PRIMARY KEY, + creator_id text NOT NULL, + service text NOT NULL, + creator_name text NOT NULL, + creator_slug text, + creator_internal_id text, + short_description text NOT NULL, + description text NOT NULL, + icon text, + banner text, + is_nsfw boolean, + deleted_at timestamp without time zone, + stopped_at timestamp without time zone, + paused_at timestamp without time zone, + post_count integer, + media_count integer, + tiers jsonb[], + access_groups jsonb[], + published_at timestamp without time zone, + added_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp without time zone, + public_posts_refreshed_at timestamp without time zone, + public_posts_full_refreshed_at timestamp without time zone +); + +CREATE TYPE unapproved_link_status AS ENUM( + 'pending', + 'approved', + 'rejected' +); + +CREATE TABLE unapproved_link_requests( + id int PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + from_service text NOT NULL, + from_id text NOT NULL, + to_service text NOT NULL, + to_id text NOT NULL, + reason text, + requester_id int NOT NULL REFERENCES account(id), + status unapproved_link_status NOT NULL DEFAULT 'pending', + FOREIGN KEY (from_service, from_id) REFERENCES lookup(service, id), + FOREIGN KEY (to_service, to_id) REFERENCES lookup(service, id), + UNIQUE (from_service, from_id, to_service, to_id) +); + +CREATE INDEX name_idx ON lookup USING btree("name"); + +CREATE INDEX lookup_id_idx ON lookup USING btree("id"); + +CREATE INDEX lookup_service_idx ON lookup USING btree("service"); + +CREATE INDEX lookup_indexed_idx ON lookup USING btree("indexed"); + +CREATE SEQUENCE lookup_relation_id_seq; + +CREATE INDEX lookup_relation_id_index ON lookup USING btree(relation_id); + +CREATE INDEX lookup_public_id_idx ON lookup(public_id); + +CREATE INDEX lookup_relation_id_idx ON lookup(relation_id); + +-- the migrations refer to `updated_idx` index +-- but it wasn't declared prior being changed +CREATE INDEX updated_idx ON lookup USING btree("updated"); + +CREATE INDEX creators_revisions_creator_id_service_idx ON creators_revisions USING btree(creator_id, service); + +CREATE INDEX unapproved_link_requests_status_id_idx ON unapproved_link_requests(status, id); diff --git a/db/schema/public/dms.sql b/db/schema/public/dms.sql new file mode 100644 index 0000000..4d9fe21 --- /dev/null +++ b/db/schema/public/dms.sql @@ -0,0 +1,64 @@ +-- DMs +CREATE TABLE dms_temp_old( + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "content" text NOT NULL DEFAULT '', + "embed" jsonb NOT NULL DEFAULT '{}', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "file" jsonb NOT NULL, + PRIMARY KEY (id, service) +); + +CREATE TABLE unapproved_dms_temp_old( + "import_id" varchar(255) NOT NULL, + contributor_id varchar(255), + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "content" text NOT NULL DEFAULT '', + "embed" jsonb NOT NULL DEFAULT '{}', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "file" jsonb NOT NULL, + PRIMARY KEY (id, service) +); + +-- the old tables weren't dropped, apparently + +CREATE TABLE dms( + "hash" varchar NOT NULL, + "user" varchar(255) NOT NULL, + service varchar(20) NOT NULL, + "content" text NOT NULL DEFAULT ''::text, + embed jsonb NOT NULL DEFAULT '{}' ::jsonb, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + published timestamp NULL, + file jsonb NOT NULL, + CONSTRAINT dms_pkey PRIMARY KEY ("hash", "user", service) +); + +CREATE TABLE unapproved_dms( + "hash" varchar NOT NULL, + "user" varchar(255) NOT NULL, + service varchar(20) NOT NULL, + contributor_id varchar(255) NOT NULL, + "content" text NOT NULL DEFAULT ''::text, + embed jsonb NOT NULL DEFAULT '{}' ::jsonb, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + published timestamp NULL, + file jsonb NOT NULL, + import_id varchar(255) NOT NULL, + remote_user_id_hash varchar NULL, + deleted_at timestamp NULL, + CONSTRAINT unapproved_dms_pkey PRIMARY KEY ("hash", "user", service, contributor_id) +); + +CREATE INDEX dm_idx ON dms_temp_old USING btree("user"); + +CREATE INDEX dms_user_idx ON dms_temp_old("user"); + +CREATE INDEX unapproved_dm_idx ON unapproved_dms USING btree("import_id"); + +CREATE INDEX unapproved_dms_contributor_id_user_idx ON unapproved_dms(contributor_id, "user"); diff --git a/db/schema/public/extensions.sql b/db/schema/public/extensions.sql new file mode 100644 index 0000000..6e98b00 --- /dev/null +++ b/db/schema/public/extensions.sql @@ -0,0 +1,5 @@ +CREATE EXTENSION pgroonga; + +CREATE EXTENSION pgcrypto; + +CREATE EXTENSION citext; diff --git a/db/schema/public/files.sql b/db/schema/public/files.sql new file mode 100644 index 0000000..1b31a89 --- /dev/null +++ b/db/schema/public/files.sql @@ -0,0 +1,67 @@ +-- files +CREATE TABLE files( + id serial PRIMARY KEY, + hash varchar NOT NULL, + mtime timestamp NOT NULL, + ctime timestamp NOT NULL, + mime varchar, + ext varchar, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (hash) +); + +CREATE TABLE file_post_relationships( + file_id int NOT NULL REFERENCES files(id), + filename varchar NOT NULL, + service varchar NOT NULL, + "user" varchar NOT NULL, + post varchar NOT NULL, + contributor_id int REFERENCES account(id), + inline boolean NOT NULL DEFAULT FALSE, + PRIMARY KEY (file_id, service, "user", post) +); + +CREATE TABLE file_discord_message_relationships( + file_id int NOT NULL REFERENCES files(id), + filename varchar NOT NULL, + server varchar NOT NULL, + channel varchar NOT NULL, + id varchar NOT NULL, + contributor_id int REFERENCES account(id), + PRIMARY KEY (file_id, SERVER, channel, id) +); + +CREATE TABLE file_server_relationships( + file_id int NOT NULL REFERENCES files(id), + remote_path varchar NOT NULL +); + +CREATE TABLE archive_files( + file_id int NOT NULL REFERENCES files(id), + -- this for sure won't have problems down the line + files text[] NOT NULL, + password TEXT, + CONSTRAINT archive_files_pk PRIMARY KEY (file_id) +); + +CREATE INDEX file_id_idx ON file_post_relationships USING btree("file_id"); + +CREATE INDEX file_post_service_idx ON file_post_relationships USING btree("service"); + +CREATE INDEX file_post_user_idx ON file_post_relationships USING btree("user"); + +CREATE INDEX file_post_id_idx ON file_post_relationships USING btree("post"); + +CREATE INDEX file_post_contributor_id_idx ON file_post_relationships USING btree("contributor_id"); + +CREATE INDEX file_discord_id_idx ON file_discord_message_relationships USING btree("file_id"); + +CREATE INDEX file_discord_message_server_idx ON file_discord_message_relationships USING btree("server"); + +CREATE INDEX file_discord_message_channel_idx ON file_discord_message_relationships USING btree("channel"); + +CREATE INDEX file_discord_message_id_idx ON file_discord_message_relationships USING btree("id"); + +CREATE INDEX file_discord_message_contributor_id_idx ON file_discord_message_relationships USING btree("contributor_id"); + +CREATE INDEX file_server_relationships_remote_path_idx ON file_server_relationships USING btree(remote_path); diff --git a/db/schema/public/posts/comments.sql b/db/schema/public/posts/comments.sql new file mode 100644 index 0000000..33a7f12 --- /dev/null +++ b/db/schema/public/posts/comments.sql @@ -0,0 +1,34 @@ +CREATE TABLE comments( + "id" varchar(255) NOT NULL, + "post_id" varchar(255) NOT NULL, + "parent_id" varchar(255), + "commenter" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "content" text NOT NULL DEFAULT '', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + deleted_at timestamp, + commenter_name text, + PRIMARY KEY (id, service) +); + +CREATE TABLE comments_revisions( + revision_id serial4 NOT NULL, + id varchar(255) NOT NULL, + post_id varchar(255) NOT NULL, + parent_id varchar(255) NULL, + commenter varchar(255) NOT NULL, + service varchar(20) NOT NULL, + "content" text NOT NULL DEFAULT ''::text, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + published timestamp NULL, + deleted_at timestamp, + commenter_name text, + CONSTRAINT comments_revisions_pkey PRIMARY KEY (revision_id) +); + +CREATE INDEX comments_revisions_post_id_idx ON comments_revisions USING btree(post_id); + +CREATE INDEX comments_revisions_id_idx ON comments_revisions USING btree(id); + +CREATE INDEX comment_idx ON comments USING btree("post_id"); diff --git a/db/schema/public/posts/discord.sql b/db/schema/public/posts/discord.sql new file mode 100644 index 0000000..9eb232a --- /dev/null +++ b/db/schema/public/posts/discord.sql @@ -0,0 +1,59 @@ +-- Posts (Discord) +CREATE TABLE discord_channels( + channel_id text NOT NULL, + server_id text NOT NULL, + name text NOT NULL, + parent_channel_id text NULL, + topic text NULL, + theme_color text NULL, + is_nsfw bool NOT NULL, + position int NOT NULL DEFAULT 0, + icon_emoji text NULL, + type int NOT NULL DEFAULT 0, + CONSTRAINT discord_channels_pkey PRIMARY KEY (channel_id) +); + +CREATE TABLE discord_posts( + "id" varchar(255) NOT NULL, + "author" jsonb NOT NULL, + "server" varchar(255) NOT NULL, + "channel" varchar(255) NOT NULL, + "content" text NOT NULL DEFAULT '', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "edited" timestamp, + "embeds" jsonb[] NOT NULL, + "mentions" jsonb[] NOT NULL, + "attachments" jsonb[] NOT NULL, + PRIMARY KEY (id, SERVER, channel) +); + +CREATE TABLE discord_posts_revisions( + revision_id serial4 NOT NULL, + id varchar(255) NOT NULL, + author jsonb NOT NULL, + "server" varchar(255) NOT NULL, + channel varchar(255) NOT NULL, + "content" text NOT NULL DEFAULT ''::text, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + published timestamp NULL, + edited timestamp NULL, + embeds _jsonb NOT NULL, + mentions _jsonb NOT NULL, + attachments _jsonb NOT NULL, + CONSTRAINT discord_posts_revisions_pkey PRIMARY KEY (revision_id) +); + +CREATE INDEX discord_channels_server_id_idx ON discord_channels USING btree(server_id); + +CREATE INDEX discord_channels_parent_channel_id_idx ON discord_channels USING btree(parent_channel_id); + +CREATE INDEX discord_id_idx ON discord_posts USING HASH ("id"); + +CREATE INDEX server_idx ON discord_posts USING HASH ("server"); + +CREATE INDEX discord_posts_server_channel_idx ON discord_posts USING btree(SERVER, channel); + +CREATE INDEX discord_posts_channel_published_idx ON discord_posts USING btree(channel, published); + +CREATE INDEX discord_posts_revisions_id_idx ON public.discord_posts_revisions USING btree(id); diff --git a/db/schema/public/posts/fanbox.sql b/db/schema/public/posts/fanbox.sql new file mode 100644 index 0000000..dd0ea3e --- /dev/null +++ b/db/schema/public/posts/fanbox.sql @@ -0,0 +1,62 @@ +CREATE TABLE fanbox_newsletters_temp_old( + id varchar NOT NULL, + user_id varchar NOT NULL, + content text NOT NULL, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + published timestamp, + PRIMARY KEY (id) +); + +CREATE TABLE fanbox_newsletters( + user_id varchar NOT NULL, + hash varchar NOT NULL, + "content" varchar NOT NULL, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + published timestamp NOT NULL, + CONSTRAINT fanbox_newsletters_pkey PRIMARY KEY (user_id, hash) +); + +CREATE TABLE fanbox_embeds( + id varchar NOT NULL, + user_id varchar NOT NULL, + post_id varchar NOT NULL, + type varchar NOT NULL, + json varchar NOT NULL, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + processed varchar, + "iframely_key" varchar(255) NULL, + "iframely_data" jsonb NULL, + "iframely_url" text NULL, + PRIMARY KEY (id) +); + +CREATE TABLE fanbox_fancards( + id serial PRIMARY KEY, + user_id varchar NOT NULL, + file_id int REFERENCES files(id), + last_checked_at timestamp DEFAULT CURRENT_TIMESTAMP, + price text NOT NULL DEFAULT '', + CONSTRAINT fanbox_fancards_user_id_file_id_price_unique_idx UNIQUE (user_id, file_id, price) +); + +CREATE INDEX fanbox_newsletters_temp_old_user_id_idx ON fantia_newsletters USING btree(user_id); + +CREATE INDEX fanbox_newsletters_temp_old_added_idx ON fantia_newsletters USING btree(added); + +CREATE INDEX fanbox_newsletters_temp_old_published_idx ON fantia_newsletters USING btree(published); + +CREATE INDEX fanbox_newsletters_user_id_published_idx ON public.fanbox_newsletters_temp_new USING btree(user_id, published); + +CREATE INDEX fanbox_embeds_user_id_idx ON fanbox_embeds USING btree(user_id); + +CREATE INDEX fanbox_embeds_post_id_idx ON fanbox_embeds USING btree(post_id); + +CREATE INDEX fanbox_embeds_added_idx ON fanbox_embeds USING btree(added); + +CREATE INDEX fanbox_embeds_type_idx ON fanbox_embeds USING btree(type); + +CREATE INDEX fanbox_fancards_user_id_idx ON fanbox_fancards USING btree(user_id); + +CREATE UNIQUE INDEX fanbox_fancards_null_file_id_user_id_price_unique_idx ON fanbox_fancards(user_id, price) +WHERE + file_id IS NULL; diff --git a/db/schema/public/posts/posts.sql b/db/schema/public/posts/posts.sql new file mode 100644 index 0000000..02e18e4 --- /dev/null +++ b/db/schema/public/posts/posts.sql @@ -0,0 +1,115 @@ +-- Posts +CREATE TABLE posts( + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "title" text NOT NULL DEFAULT '', + "content" text NOT NULL DEFAULT '', + "embed" jsonb NOT NULL DEFAULT '{}', + "shared_file" boolean NOT NULL DEFAULT '0', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "edited" timestamp, + "file" jsonb NOT NULL, + "attachments" jsonb[] NOT NULL, + poll jsonb, + captions jsonb, + -- wtf is this type? + tags _CITEXT, + PRIMARY KEY (id, service) +); + +CREATE TABLE revisions( + "revision_id" serial PRIMARY KEY, + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "title" text NOT NULL DEFAULT '', + "content" text NOT NULL DEFAULT '', + "embed" jsonb NOT NULL DEFAULT '{}', + "shared_file" boolean NOT NULL DEFAULT '0', + "added" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "published" timestamp, + "edited" timestamp, + "file" jsonb NOT NULL, + "attachments" jsonb[] NOT NULL, + poll jsonb, + -- wtf is this type? + tags _CITEXT, + captions jsonb +); + +CREATE TABLE introductory_messages( + service varchar NOT NULL, + user_id varchar NOT NULL, + hash varchar NOT NULL, + content varchar NOT NULL, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (service, user_id, hash) +); + +CREATE TABLE posts_incomplete_rewards( + id varchar(255) NOT NULL, + service varchar(20) NOT NULL, + last_checked_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + incomplete_attachments_info jsonb NOT NULL DEFAULT '{}' ::jsonb, + "user" varchar(255) NULL, + CONSTRAINT posts_incomplete_rewards_pkey PRIMARY KEY (id, service) +); + +CREATE TABLE posts_added_max( + "user" varchar NOT NULL, + service varchar NOT NULL, + added timestamp NOT NULL, + CONSTRAINT posts_added_max_pkey PRIMARY KEY ("user", service) +); + +CREATE TRIGGER posts_added_max + AFTER INSERT OR UPDATE ON posts + FOR EACH ROW + EXECUTE FUNCTION posts_added_max(); + +CREATE TABLE public_posts( + post_id text NOT NULL, + creator_id text NOT NULL, + service text NOT NULL, + title text NOT NULL, + body text NOT NULL, + tier_price_required text, + tier_required text[], + published_at timestamp without time zone, + edited_at timestamp without time zone, + deleted_at timestamp without time zone, + tags text[], + like_count integer, + comment_count integer, + is_public boolean, + is_nsfw boolean, + refreshed_at timestamp without time zone, + buy_price text, + CONSTRAINT public_posts_pkey PRIMARY KEY (post_id, service) +); + +CREATE INDEX id_idx ON posts USING HASH ("id"); + +CREATE INDEX service_idx ON posts USING btree("service"); + +CREATE INDEX added_idx ON posts USING btree("added"); + +CREATE INDEX published_idx ON posts USING btree("published"); + +CREATE INDEX user_idx ON posts USING btree("user"); + +CREATE INDEX updated_idx ON posts USING btree("user", "service", "added"); + +CREATE INDEX posts_tags_idx ON public.posts USING gin(tags); + +CREATE INDEX posts_user_published_id_idx ON posts USING btree("user", published, id); + +CREATE INDEX posts_incomplete_rewards_service_user_idx ON posts_incomplete_rewards USING btree(service, "user"); + +CREATE INDEX revisions_id_idx ON revisions USING HASH (id); + +CREATE INDEX introductory_messages_user_id_added_idx ON introductory_messages USING btree(user_id, added); + +CREATE INDEX public_posts_creator_id_service_idx ON public_posts USING btree(service, creator_id); diff --git a/db/schema/public/posts/triggers.sql b/db/schema/public/posts/triggers.sql new file mode 100644 index 0000000..dbcfac7 --- /dev/null +++ b/db/schema/public/posts/triggers.sql @@ -0,0 +1,27 @@ +CREATE FUNCTION posts_added_max() + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ +BEGIN + INSERT INTO posts_added_max AS pam("user", service, added) + SELECT + "user", + service, + max(added) AS added + FROM + posts + WHERE + posts.service = NEW.service + AND posts."user" = NEW."user" + GROUP BY + "user", + service + ON CONFLICT(service, + "user") + DO UPDATE SET + added = EXCLUDED.added + WHERE + EXCLUDED.added > pam.added; + RETURN NULL; +END; +$$; diff --git a/db/schema/public/schema.sql b/db/schema/public/schema.sql new file mode 100644 index 0000000..8414400 --- /dev/null +++ b/db/schema/public/schema.sql @@ -0,0 +1,157 @@ +-- Booru bans +CREATE TABLE dnp( + "id" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + "import" boolean NOT NULL DEFAULT TRUE +); + +-- Flags +CREATE TABLE booru_flags( + "id" varchar(255) NOT NULL, + "user" varchar(255) NOT NULL, + "service" varchar(20) NOT NULL, + PRIMARY KEY (id, "user", service) +); + +-- Board +CREATE TABLE board_replies( + "reply" integer NOT NULL, + "in" integer NOT NULL +); + +-- Requests +CREATE TYPE request_status AS ENUM( + 'open', + 'fulfilled', + 'closed' +); + +CREATE TABLE requests( + "id" serial PRIMARY KEY, + "service" varchar(20) NOT NULL, + "user" varchar(255) NOT NULL, + "post_id" varchar(255), + "title" text NOT NULL, + "description" text NOT NULL DEFAULT '', + "created" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "image" text, + "price" numeric NOT NULL, + "votes" integer NOT NULL DEFAULT 1, + "ips" text[] NOT NULL, + "status" request_status NOT NULL DEFAULT 'open' +); + +CREATE request_title_idx ON requests +USING btree( + "title" +); + +CREATE request_service_idx ON requests +USING btree( + "service" +); + +CREATE request_votes_idx ON requests +USING btree( + "votes" +); + +CREATE request_created_idx ON requests +USING btree( + "created" +); + +CREATE request_price_idx ON requests +USING btree( + "price" +); + +CREATE request_status_idx ON requests +USING btree( + "status" +); + +-- Request Subscriptions +CREATE TABLE request_subscriptions( + "request_id" numeric NOT NULL, + "endpoint" text NOT NULL, + "expirationTime" numeric, + "keys" jsonb NOT NULL +); + +CREATE INDEX request_id_idx ON request_subscriptions USING btree("request_id"); + +CREATE TABLE saved_session_keys( + id serial PRIMARY KEY, + service varchar NOT NULL, + discord_channel_ids varchar, + encrypted_key varchar NOT NULL, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + dead boolean NOT NULL DEFAULT FALSE, + contributor_id int REFERENCES account(id), + UNIQUE (service, encrypted_key) +); + +CREATE TABLE saved_session_keys_with_hashes( + id serial PRIMARY KEY, + service varchar NOT NULL, + discord_channel_ids varchar, + encrypted_key varchar NOT NULL, + hash varchar NOT NULL, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + dead boolean NOT NULL DEFAULT FALSE, + dead_at timestamp, + contributor_id int REFERENCES account(id), + remote_user_id_hash varchar, + UNIQUE (service, hash) +); + +CREATE TABLE saved_session_key_import_ids( + key_id int NOT NULL, + import_id varchar NOT NULL, + UNIQUE (key_id, import_id) +); + +CREATE TABLE complete_imports( + user_id varchar(255) NOT NULL, + service varchar(20) NOT NULL, + subscription varchar(255) NOT NULL, + last_successful_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + info jsonb NOT NULL DEFAULT '{}' ::jsonb, + CONSTRAINT complete_imports_pkey PRIMARY KEY (user_id, service, subscription) +); + +CREATE INDEX saved_session_keys_contributor_idx ON saved_session_keys USING btree("contributor_id"); + +CREATE INDEX saved_session_keys_with_hashes_contributor_idx ON saved_session_keys_with_hashes USING btree("contributor_id"); + +CREATE INDEX saved_session_keys_with_hashes_dead_idx ON saved_session_keys_with_hashes USING btree("dead"); + +CREATE INDEX saved_session_keys_dead_idx ON saved_session_keys USING btree("dead"); + +CREATE TABLE jobs( + job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamp DEFAULT CURRENT_TIMESTAMP, + queue_name text NOT NULL, + priority integer NOT NULL, + consumer_id text, + pids integer[], + started_at timestamp, + last_heartbeat_at timestamp, + job_input jsonb NOT NULL, + job_status jsonb DEFAULT '{}' ::jsonb, + finished_at timestamp, + resuming_at timestamp, + error text +); + +CREATE INDEX jobs_finished_at_queue_name_job_input_key_idx ON jobs(finished_at, queue_name,(job_input ->> 'key')); + +CREATE TABLE posts_forced_reimports( + creator_id text NOT NULL, + service text NOT NULL, + post_id text NOT NULL, + reason text NULL, + created_at timestamp DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT posts_forced_reimports_pkey PRIMARY KEY (creator_id, service, post_id) +); diff --git a/db/schema/public/shares.sql b/db/schema/public/shares.sql new file mode 100644 index 0000000..8038fdd --- /dev/null +++ b/db/schema/public/shares.sql @@ -0,0 +1,33 @@ +CREATE TABLE shares( + id serial4 NOT NULL, + "name" varchar NOT NULL, + description varchar NOT NULL, + uploader int4 NULL, + added timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT shares_pkey PRIMARY KEY (id), + CONSTRAINT shares_uploader_fkey FOREIGN KEY (uploader) REFERENCES account(id) +); + +CREATE INDEX shares_added_idx ON shares USING btree(added); + +CREATE INDEX shares_uploader_idx ON shares USING btree(uploader); + +CREATE TABLE lookup_share_relationships( + share_id int4 NOT NULL, + service varchar NOT NULL, + user_id varchar NOT NULL, + CONSTRAINT lookup_share_relationships_pkey PRIMARY KEY (share_id, service, user_id), + CONSTRAINT lookup_share_relationships_service_user_id_fkey FOREIGN KEY (service, user_id) REFERENCES lookup(service, id), + CONSTRAINT lookup_share_relationships_share_id_fkey FOREIGN KEY (share_id) REFERENCES shares(id) +); + +CREATE TABLE file_share_relationships( + share_id int4 NOT NULL, + upload_url varchar NOT NULL, + upload_id varchar NOT NULL, + file_id int4 NULL, + filename varchar NOT NULL, + CONSTRAINT file_share_relationships_pkey PRIMARY KEY (share_id, upload_id), + CONSTRAINT file_share_relationships_file_id_fkey FOREIGN KEY (file_id) REFERENCES files(id), + CONSTRAINT file_share_relationships_share_id_fkey FOREIGN KEY (share_id) REFERENCES shares(id) +); diff --git a/docker-compose.yml b/docker-compose.yml index cdbca08..99bbf4e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: '3' services: postgres: - image: groonga/pgroonga:3.1.6-alpine-16-slim + image: groonga/pgroonga:latest-debian-16 restart: unless-stopped environment: - POSTGRES_DB=kemono diff --git a/docs/FAQ.md b/docs/FAQ.md index 109074b..ce4c37a 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -1,13 +1,9 @@ # Frequently Asked Questions -
    - -### My dump doesn't migrate. +## My dump doesn't migrate. _This assumes a running setup._ -
    - 1. Enter into database container: ```sh @@ -18,8 +14,6 @@ _This assumes a running setup._ kemonodb ``` -
    - 2. Check the contents of the  `posts`  table. ```sql @@ -28,16 +22,12 @@ _This assumes a running setup._ _Most likely it has  `0`  rows._ -
    - 3. Move contents of  `booru_posts`  ➞  `posts` ```sql INSERT INTO posts SELECT * FROM booru_posts ON CONFLICT DO NOTHING; ``` -
    - 4. Restart the archiver. ```sh @@ -47,8 +37,6 @@ _This assumes a running setup._ If you see a bunch of log entries from  `kemono-db` ,
    then this indicates that the archiver is doing it's job. -
    - 5. In case the frontend still doesn't show
    the artists / posts, clear the redis cache. @@ -59,50 +47,10 @@ _This assumes a running setup._ FLUSHALL ``` -
    -
    - -### How do I git modules? - -_This assumes you haven't cloned the repository recursively._ - -
    - -1. Initiate the submodules - - ```sh - git submodule init - git submodule update \ - --recursive \ - --init - ``` - -
    - -2. Switch to the archiver folder and
    - add your fork to the remotes list. - - ```sh - cd archiver - git remote add - ``` - -
    - -3. Now you can interact with Kitsune repo the same
    - way you do as if it was outside of project folder. - -
    -
    - -### How do I import from db dump? - -
    +## How do I import from db dump? 1. Retrieve a database dump. -
    - 2. Run the following in the folder of said dump. ```sh @@ -113,30 +61,19 @@ _This assumes you haven't cloned the repository recursively._ --username=nano kemonodb ``` -
    - 3. Restart the archiver to trigger migrations. ```sh docker restart kemono-archiver ``` -
    - If that didn't start the migrations, refer
    - to  [`My Dump Doesn't Migrate`]  section. + to  [My Dump Doesn't Migrate](#my-dump-doesnt-migrate)  section. -
    -
    - -### How do I put files into nginx container? - -
    +## How do I put files into nginx container? 1. Retrieve the files in required folder structure. -
    - 2. Copy them into nginx image. ```sh @@ -144,8 +81,6 @@ _This assumes you haven't cloned the repository recursively._ cp ./ kemono-nginx:/storage ``` -
    - 3. Add required permissions to that folder. ```sh @@ -155,8 +90,27 @@ _This assumes you haven't cloned the repository recursively._ nginx /storage ``` -
    +## How Do I Install Python 3.12 on Ubuntu 22? +Through a PPA (Personal Package Archives). - +1. Install required tooling and add the PPA: -[`My Dump Doesn't Migrate`]: #my-dump-doesnt-migrate + ```sh + sudo apt install --assume-yes software-properties-common + sudo add-apt-repository ppa:deadsnakes/ppa + ``` + +2. Update local `apt` listing and install required python dependencies: + + ```sh + sudo apt update + sudo apt install \ + python3.12 \ + python3.12-dev \ + python3.12-distutils + ``` + +3. Confirm python 3.12 is installed + ```sh + which python3.12 + ``` diff --git a/docs/code-style.md b/docs/code-style.md new file mode 100644 index 0000000..f5a27c7 --- /dev/null +++ b/docs/code-style.md @@ -0,0 +1,15 @@ +# Code Style + +## General + +- Prepend all control flow keywords with an empty line. + +## SQL + +- Always declare aliases with `AS` construct. +- No star selects allowed. + +## Python + +- Prefix all `TypedDict` declarations with `TD`.
    + Otherwise it's easy to mix them up wtih actual classes and then do `isinstance()` accidentally. diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..6a40317 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,37 @@ +# Database + +## Schema reference + +The folder `/db/schema/` holds the information about relevant symbols for the codebase in ``.
    +This information is plain SQL files with only symbol declarations without any data-modifying queries.
    + +The main usecase is to be able to look into database schema without reving up the docker stack/setting up database.
    +The other perk coming from this is schema reference data is bound to commits, so it allows to diff schema changes
    +before trying to make sense of migrations. + +**From this point on (2024-07-03T00:00:0.000Z) any commit which includes schema-changing migrations
    +must also include changes in the schema reference.** + +## File structure + +There is no particular structure beyond having files in a specific schema folder.
    +But in general small enough declarations without any category go into `schema.sql`,
    +otherwise they go into separate files. If a separate file becomes too big, then it goes into
    +a separate folder split into separate files. + +## Migrations already include this information +They do, but as of time of writing (2024-07-03T00:00:0.000Z) there 75 migration files with a ton of data-changing boilerplate. + +## Why like this? +It's quick, located alongside migrations and (eventually) queries and doesn't require to learn some goofy DSL
    +which will eventually fall behind the new features of Postgresql. + +The alternative considered was relying on [`pg_dump` with `--schema-only` argument](https://www.postgresql.org/docs/current/app-pgdump.html)
    +but it has pretty big drawbacks: +- it requires a functioning and running database to work, which isn't going to always happen during development. +- The output SQL is not a clean one, i.e. it includes system-specific data, which is irrelevant to the repo code.
    + That means it has to be parsed and transformed which is not gonna happen. +- Included symbols make sense in context of the command, but not in the context of repo. I.e. the repo doesn't need to know the user ownership of symbols. +That means the repo has to store all symbol names somewhere in the first place. And that list has to be updated manually anyway. +- The output SQL is written in a desugarized form, which is a dealbreaker since migrations are written in sugar
    +and this disparity only makes it harder to understand them and the whole schema. diff --git a/docs/develop.md b/docs/develop.md index 18f547c..e3619c1 100644 --- a/docs/develop.md +++ b/docs/develop.md @@ -1,51 +1,55 @@ # Develop For now Docker is a primary way of working on the repo. +However dependencies are still needed to installed locally for +the IDE setup. + +## Requirements: + +Python: 3.12+ +NodeJS: 18+ ## Installation -1. Install `virtualenv` package if it's not installed. +1. Check if python 3.12 is installed in the system: + + ```sh + which python 3.12 + ``` + + If no path returned, follow [installation instructions](./FAQ.md#how-do-i-install-python-312-on-ubuntu-22) + +2. Install `virtualenv` package if it's not installed. ```sh pip install --user virtualenv ``` -2. Create a virtual environment: +3. Create a virtual environment: ```sh - virtualenv venv + virtualenv python=3.12 venv ``` -2. Activate the virtual environment. +4. Activate the virtual environment. ```sh # Windows ➞ venv\Scripts\activate source venv/bin/activate ``` -3. Install python packages. +5. Install python packages. ```sh pip install --requirement requirements.txt ``` -4. Install  `pre-commit`  hooks. +6. Install  `pre-commit`  hooks. ```sh pre-commit install --install-hooks ``` -### Database - -1. Register an account. - -2. Visit  [`http://localhost:5000/development`] - -3. Click either seeded or random generation. - - _This will start a mock import process,_
    - _which will also populate the database._ - ### Build ```sh @@ -57,29 +61,16 @@ In a browser, visit  [`http://localhost:8000/`] ## Manual -> **TODO** : Write installation and setup instructions +1. Run the API dev server: -This assumes you have  `Python 3.8+`  &  `Node 12+`  installed
    -as well as a running **PostgreSQL** server with **Pgroonga**. + ```sh + pytnon -m src web + ``` +2. Run frontend dev server: -```sh -# Make sure your database is initialized -# cd to kemono directory - -pip install virtualenv -virtualenv venv - -# Windows ➞ venv\Scripts\activate -source venv/bin/activate - -pip install \ - --requirement requirements.txt - -cd client \ - && npm install \ - && npm run build \ - && cd .. -``` + ```sh + python -m src webpack + ``` ## Git diff --git a/pyproject.toml b/pyproject.toml index 1d9ae3c..e19b7cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,4 +6,5 @@ target-version = ["py312"] line_length = 120 [tool.mypy] +python_version = "3.12" exclude = "storage" diff --git a/requirements.txt b/requirements.txt index 03f3786..c067f94 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ bcrypt==4.0.1 beautifulsoup4==4.12.2 nh3==0.2.15 cloudscraper==1.2.71 -dill==0.3.7 Flask==2.3.2 humanize==4.8.0 murmurhash2==0.2.10 @@ -16,9 +15,8 @@ requests==2.31.0 httpx[http2,socks]==0.25.2 retry==0.9.2 sentry-sdk[flask]==1.29.2 -orjson==3.9.9 -#uWSGI==2.0.22 -git+https://github.com/unbit/uwsgi.git@0486062811be6f4bbed28e61bcb0d33dfeb2045c#uWSGI +orjson==3.9.15 +uWSGI==2.0.24; platform_system != "Windows" pyyaml==6.0.1 yoyo-migrations==8.2.0 zstandard==0.21.0 diff --git a/src/cmd/__init__.py b/src/cmd/__init__.py index 871fc1c..2e72003 100644 --- a/src/cmd/__init__.py +++ b/src/cmd/__init__.py @@ -38,4 +38,4 @@ def main(args: list[str]): __try(run_daemon()) case _: - print(f"usage: python -m kemono [web|webpack|daemon]") + print(f"usage: python -m src [run|web|webpack|daemon]") diff --git a/src/cmd/web.py b/src/cmd/web.py index f36e6ef..6f77b92 100644 --- a/src/cmd/web.py +++ b/src/cmd/web.py @@ -39,6 +39,7 @@ def run_web(): backend.apply_migrations(backend.to_apply(migrations)) """ Initialize Pgroonga if needed. """ with database.pool.getconn() as conn: + # TODO: make it an actual migration file try: with conn.cursor() as db: db.execute("CREATE EXTENSION IF NOT EXISTS pgroonga") @@ -47,8 +48,11 @@ def run_web(): ) db.execute("CREATE INDEX IF NOT EXISTS pgroonga_dms_idx ON dms USING pgroonga (content)") conn.commit() - except Exception: - pass + except Exception as error: + if (Configuration().development_mode): + raise Exception("Failed to install PGRoonga.") from error + else: + pass if Configuration().development_mode: with conn.cursor() as db: db.execute(open("db/seed.sql", "r").read()) @@ -58,4 +62,4 @@ def run_web(): database.close_pool() from src import server - server.app.run("0.0.0.0", port=80) + server.app.run("0.0.0.0", port=Configuration().webserver['port']) diff --git a/src/config.py b/src/config.py index 2ad457f..51b9703 100644 --- a/src/config.py +++ b/src/config.py @@ -41,6 +41,7 @@ class BuildConfiguration: config = merge_dict(config, override_config) self.sentry_dsn = config.get("sentry_dsn", None) + self.sentry_dsn_js = config.get("sentry_dsn_js", None) self.open_telemetry_endpoint = config.get("open_telemetry_endpoint", None) self.development_mode = config.get("development_mode", True) self.automatic_migrations = config.get("automatic_migrations", True) diff --git a/src/internals/database/database.py b/src/internals/database/database.py index f9f308c..61e9353 100644 --- a/src/internals/database/database.py +++ b/src/internals/database/database.py @@ -8,6 +8,7 @@ from flask import g from psycopg.abc import Query from psycopg.connection import Connection from psycopg.cursor import Cursor +from psycopg.client_cursor import ClientCursor from psycopg.errors import QueryCanceled from psycopg.rows import dict_row from psycopg.types.string import TextLoader @@ -78,7 +79,7 @@ def get_cursor() -> Cursor: return g.cursor -def get_client_binding_cursor() -> Cursor: +def get_client_binding_cursor() -> ClientCursor: if "client_binding_cursor" not in g: if "connection" not in g: g.connection = get_pool().getconn() @@ -124,7 +125,7 @@ def get_from_cache(redis: Any, cache_key: str, cache_store_method: str): def cached_query( query: str, cache_key: str, - params: tuple = (), + params: tuple | dict = (), serialize_fn=safe_dumper, deserialize_fn=safe_loader, reload: bool = False, diff --git a/src/lib/account.py b/src/lib/account.py index 3f5d784..1badd03 100644 --- a/src/lib/account.py +++ b/src/lib/account.py @@ -1,6 +1,6 @@ import base64 import hashlib -from typing import Optional +from typing import Optional, TypedDict import bcrypt from flask import current_app, flash, session @@ -31,15 +31,23 @@ def load_account(account_id: Optional[str] = None, reload: bool = False): account_dict = cached_query(query, key, (account_id,), serialize_account, deserialize_account, reload, True) return Account.init_from_dict(account_dict) +class TDSessionKeyImportID(TypedDict): + key_id: int + import_id: str -def get_saved_key_import_ids(key_id, reload=False): +def get_saved_key_import_ids(key_id, reload=False) -> list[TDSessionKeyImportID]: key = f"saved_key_import_ids:{key_id}" + params = dict(key_id=key_id) query = """ - SELECT * - FROM saved_session_key_import_ids - WHERE key_id = %s + SELECT + key_id, + import_id + FROM + saved_session_key_import_ids + WHERE + key_id = %(key_id)s """ - return cached_query(query, key, (key_id,), reload=reload) + return cached_query(query, key, params, reload=reload) def get_saved_keys(account_id: int, reload: bool = False): @@ -82,8 +90,21 @@ def revoke_saved_keys(key_ids: list[int], account_id: int): redis.delete(key) -def get_login_info_for_username(username): - query = "SELECT id, password_hash FROM account WHERE username = %s" +class TDLoginInfo(TypedDict): + id: str + password_hash: str + + +def get_login_info_for_username(username) -> TDLoginInfo | None: + query = """ + SELECT + id, + password_hash + FROM + account + WHERE + username = %s + """ return query_one_db(query, (username,)) @@ -152,28 +173,27 @@ def change_password(user_id: int, current_password: str, new_password: str) -> b return True -def attempt_login(username: str, password: str) -> Optional[Account]: +def attempt_login(username: str, password: str) -> tuple[Account, None] | tuple[None, str]: if not username or not password: - return None + return (None, "Username and password must be provided.") account_info = get_login_info_for_username(username) + if account_info is None: - flash("Username or password is incorrect") - return None + return (None, "Username or password is incorrect.") if current_app.config.get("ENABLE_LOGIN_RATE_LIMITING") and is_login_rate_limited(str(account_info["id"])): - flash("You're doing that too much. Try again in a little bit.") - return None + return (None, "You're doing that too much. Try again in a little bit.") if bcrypt.checkpw(get_base_password_hash(password), account_info["password_hash"].encode("utf-8")): if account := load_account(account_info["id"], True): session["account_id"] = account.id - return account + + return (account, None) else: raise Exception("Error loading account") - flash("Username or password is incorrect") - return None + return (None, "Username or password is incorrect") def get_base_password_hash(password: str): diff --git a/src/lib/announcements.py b/src/lib/announcements.py index 756461a..8ee5b7c 100644 --- a/src/lib/announcements.py +++ b/src/lib/announcements.py @@ -1,11 +1,19 @@ -from typing import List +from typing import TypedDict, Optional from src.internals.database.database import cached_count, cached_query, get_cursor +class TDAnnouncement(TypedDict): + service: str + user_id: str + hash: str + content: str + added: str + published: Optional[str] + def get_artist_announcements( service: str, artist_id: str, query: str | None = None, reload: bool = False -) -> List[dict]: +) -> list[TDAnnouncement]: key = f"announcements:{service}:{artist_id}:{hash(query) if query else ""}" params: tuple[str, ...] @@ -17,18 +25,38 @@ def get_artist_announcements( if service == "fanbox": query = f""" - SELECT *, 'fanbox' AS service - FROM fanbox_newsletters - WHERE user_id = %s {ts_query} - ORDER BY published DESC + SELECT + user_id, + hash, + content, + added, + published, + 'fanbox' AS service + FROM + fanbox_newsletters + WHERE + user_id = %s {ts_query} + ORDER BY + published DESC """ params = (artist_id,) else: query = f""" - SELECT * - FROM introductory_messages - WHERE service = %s AND user_id = %s {ts_query} - ORDER BY added DESC + SELECT + user_id, + hash, + content, + added, + NULL as published, + service + FROM + introductory_messages + WHERE + service = %s + AND + user_id = %s {ts_query} + ORDER BY + added DESC """ params = (service, artist_id) diff --git a/src/lib/api.py b/src/lib/api.py new file mode 100644 index 0000000..9011bca --- /dev/null +++ b/src/lib/api.py @@ -0,0 +1,18 @@ +from flask import make_response, jsonify + + +def create_client_error_response(message: str, status_code=400): + + if status_code < 400 or status_code > 499: + message = 'The value of status code "{status_code}" is not within range of 400...499.' + raise ValueError(message) + + response = make_response(jsonify(error=message), status_code) + + return response + + +def create_not_found_error_response(message: str = "Not Found"): + response = create_client_error_response(message, 404) + + return response diff --git a/src/lib/artist.py b/src/lib/artist.py index 186e5c4..11f6f6b 100644 --- a/src/lib/artist.py +++ b/src/lib/artist.py @@ -1,5 +1,5 @@ import datetime -from typing import Optional +from typing import Optional, TypedDict, Literal, Dict from src.config import Configuration from src.internals.cache.redis import get_conn @@ -13,25 +13,59 @@ from src.internals.serializers.artist import ( ) from src.utils.utils import clear_web_cache_for_creator_links +class TDArtist(TypedDict): + id: str + name: str + service: str + indexed: str + updated: str + public_id: str + relation_id: int -def get_top_artists_by_faves(offset, count, reload=False): + +class TDArtistWithFavs(TDArtist): + count: int + + +def get_top_artists_by_faves(offset, count, reload=False) -> list[TDArtistWithFavs]: key = f"top_artists:{offset}:{count}" + params = dict(offset=offset, limit=count) query = """ - SELECT l.*, count(*) - FROM lookup l - INNER JOIN account_artist_favorite aaf - ON l.id = aaf.artist_id AND l.service = aaf.service + SELECT + artists.id, + artists.name, + artists.service, + artists.indexed, + artists.updated, + artists.public_id, + artists.relation_id, + fc.favorite_count AS count + FROM + lookup AS artists + INNER JOIN + favorite_counts AS fc + ON + artists.id = fc.artist_id + AND + artists.service = fc.service WHERE - (l.id, l.service) NOT IN (SELECT id, service from dnp) - GROUP BY (l.id, l.service) - ORDER BY count(*) DESC - OFFSET %s - LIMIT %s + (artists.id, artists.service) NOT IN ( + SELECT + id, + service + FROM + dnp + ) + ORDER BY + count DESC + OFFSET %(offset)s + LIMIT %(limit)s """ + return cached_query( query, key, - (offset, count), + params, serialize_artists, deserialize_artists, reload, @@ -46,21 +80,34 @@ def get_random_artist_keys(count, reload=False): return cached_query(query, key, (count,), unsafe_dumper, unsafe_loader, reload, lock_enabled=True) -def get_artist(service: str, artist_id: str, reload: bool = False) -> dict: +def get_artist(service: str, artist_id: str, reload: bool = False) -> TDArtist: key = f"artist:{service}:{artist_id}" - if service == "onlyfans": - id_filter = "(id = %s or public_id = %s)" - params = (artist_id, artist_id, service) - else: - id_filter = "id = %s" - params = (artist_id, service) + params = dict(artist_id=artist_id, service=service) + id_filter = ( + "(id = %(artist_id)s or public_id = %(artist_id)s)" + if service == ("onlyfans", "fansly", "candfans", "patreon", "fanbox", "boosty") + else "id = %(artist_id)s" + ) query = f""" - SELECT * - FROM lookup + SELECT + id, + name, + service, + indexed, + updated, + public_id, + relation_id + FROM + lookup WHERE {id_filter} - AND service = %s - AND (id, service) NOT IN (SELECT id, service from dnp); + AND service = %(service)s + AND (id, service) NOT IN ( + SELECT + id, + service + FROM dnp + ); """ return cached_query( query, @@ -75,18 +122,35 @@ def get_artist(service: str, artist_id: str, reload: bool = False) -> dict: ) -def get_artists_by_update_time(offset, limit, reload=False): +def get_artists_by_update_time(offset, limit, reload=False) -> list[TDArtist]: key = f"artists_by_update_time:{offset}:{limit}" + params = dict(offset=offset, limit=limit) query = """ - SELECT * - FROM lookup + SELECT + id, + name, + service, + indexed, + updated, + public_id, + relation_id + FROM + lookup WHERE - (id, service) NOT IN (SELECT id, service from dnp) - ORDER BY updated desc - OFFSET %s - LIMIT %s + (id, service) NOT IN ( + SELECT + id, + service + FROM + dnp + ) + ORDER BY + updated DESC + OFFSET %(offset)s + LIMIT %(limit)s """ - return cached_query(query, key, (offset, limit), serialize_artists, deserialize_artists, reload) + + return cached_query(query, key, params, serialize_artists, deserialize_artists, reload) def get_fancards_by_artist(artist_id, reload=False): @@ -102,22 +166,61 @@ def create_unapproved_link_request(from_artist, to_artist, user_id, reason: Opti ON CONFLICT DO NOTHING """ cur = get_cursor() - cur.execute(query, (from_artist["service"], from_artist["id"], to_artist["service"], to_artist["id"], user_id, reason or None)) + cur.execute( + query, + (from_artist["service"], from_artist["id"], to_artist["service"], to_artist["id"], user_id, reason or None), + ) -def get_unapproved_links_with_artists(): +class TDUnapprovedLink(TypedDict): + id: int + from_service: str + from_id: str + to_service: str + to_id: str + reason: str + requester_id: int + status: Literal["pending", "approved", "rejected"] + from_creator: Dict + to_creator: Dict + requester: Dict + + +def get_unapproved_links_with_artists() -> list[TDUnapprovedLink]: query = """ SELECT - unapproved_link_requests.* - , row_to_json(from_creator.*) as from_creator - , row_to_json(to_creator.*) as to_creator - , row_to_json(requester.*) as requester - FROM unapproved_link_requests - JOIN lookup from_creator ON from_service = from_creator.service AND from_id = from_creator.id - JOIN lookup to_creator ON to_service = to_creator.service AND to_id = to_creator.id - JOIN account requester ON requester_id = requester.id - WHERE status = 'pending' - ORDER BY unapproved_link_requests.id ASC + unapproved_link_requests.* , + row_to_json( + from_creator.* + ) AS from_creator, + row_to_json( + to_creator.* + ) AS to_creator, + row_to_json( + requester.* + ) AS requester + FROM + unapproved_link_requests + JOIN + lookup AS from_creator + ON + from_service = from_creator.service + AND + from_id = from_creator.id + JOIN + lookup AS to_creator + ON + to_service = to_creator.service + AND + to_id = to_creator.id + JOIN + account AS requester + ON + requester_id = requester.id + WHERE + status = 'pending' + ORDER BY + unapproved_link_requests.id ASC """ cur = get_cursor() cur.execute(query) @@ -176,8 +279,8 @@ def approve_unapproved_link_request(request_id: int): redis.delete(f"linked_accounts:{update_result['to_service']}:{update_result['to_id']}") redis.delete(f"artist:{update_result['from_service']}:{update_result['from_id']}") redis.delete(f"artist:{update_result['to_service']}:{update_result['to_id']}") - clear_web_cache_for_creator_links(update_result['from_service'], update_result['from_id']) - clear_web_cache_for_creator_links(update_result['to_service'], update_result['to_id']) + clear_web_cache_for_creator_links(update_result["from_service"], update_result["from_id"]) + clear_web_cache_for_creator_links(update_result["to_service"], update_result["to_id"]) def delete_creator_link(service: str, creator_id: str): diff --git a/src/lib/favorites.py b/src/lib/favorites.py index 09c344e..03134c8 100644 --- a/src/lib/favorites.py +++ b/src/lib/favorites.py @@ -1,4 +1,5 @@ import logging +from typing import TypedDict from src.internals.cache.redis import get_conn from src.internals.database.database import cached_query, query_rowcount_db @@ -10,10 +11,21 @@ from src.lib.post import get_post_multiple def get_favorite_artists(account_id, reload=False): key = f"favorite_artists:{account_id}" query = """ - SELECT aaf.id, aaf.service, aaf.artist_id, pam.added as updated - FROM account_artist_favorite aaf - LEFT JOIN posts_added_max pam on pam.service = aaf.service and pam."user" = aaf.artist_id - WHERE account_id = %s + SELECT + aaf.id, + aaf.service, + aaf.artist_id, + pam.added AS updated + FROM + account_artist_favorite AS aaf + LEFT JOIN + posts_added_max AS pam + ON + pam.service = aaf.service + AND + pam."user" = aaf.artist_id + WHERE + account_id = %s """ user_favorite_artists = { (fav["service"], fav["artist_id"]): fav @@ -29,49 +41,97 @@ def get_favorite_artists(account_id, reload=False): # mget artists to prevent n+1, todo better mget with query integrated like posts keys = [f"artist:{service}:{artist_id}" for service, artist_id in user_favorite_artists.keys()] redis = get_conn() + if keys: cache_result = ( - deserialize_artist(artist) for artist in (artist_str for artist_str in redis.mget(keys) if artist_str) + deserialize_artist(artist) + for artist + in (artist_str + for artist_str + in redis.mget(keys) + if artist_str + ) ) else: cache_result = [] + in_cache = {(artist["service"], artist["id"]): artist for artist in cache_result if artist} artists = [] + for favorite_artist in user_favorite_artists.values(): artist = in_cache.get((favorite_artist["service"], favorite_artist["artist_id"])) + if not artist: artist = get_artist(favorite_artist["service"], favorite_artist["artist_id"]) + if artist: artist["faved_seq"] = favorite_artist["id"] artist["last_imported"] = artist["updated"] artist["updated"] = favorite_artist["updated"] artists.append(artist) + return artists +class TDFavoritePostData(TypedDict): + id: str + service: str + artist_id: str + post_id: str + + def get_favorite_posts(account_id, reload=False): key = f"favorite_posts:{account_id}" - query = "select id, service, artist_id, post_id from account_post_favorite where account_id = %s" - favorites = cached_query(query, key, (account_id,), reload=reload) + query = """ + SELECT + id, + service, + artist_id, + post_id + FROM + account_post_favorite + WHERE + account_id = %s + """ + favorites: list[TDFavoritePostData]= cached_query(query, key, (account_id,), reload=reload) posts_to_loads = {(f["service"], f["artist_id"], f["post_id"]): f for f in favorites} return_value = get_post_multiple(posts_to_loads) log_flag = False + for post in return_value: if not post: log_flag = True continue + post["faved_seq"] = posts_to_loads[(post["service"], post["user"], post["id"])]["id"] + if log_flag: logging.exception("Fav post not found for account faves", extra={"account_id": account_id}) + return return_value def add_favorite_artist(account_id, service, artist_id): query_rowcount_db( - "insert into account_artist_favorite (account_id, service, artist_id) values (%s, %s, %s) ON CONFLICT (account_id, service, artist_id) DO NOTHING", + """ + INSERT INTO account_artist_favorite + ( + account_id, + service, + artist_id + ) + VALUES + ( + %s, + %s, + %s + ) + ON CONFLICT + (account_id, service, artist_id) DO NOTHING + """, (account_id, service, artist_id), ) # g.connection.commit() we needed this before because we were in a transaction diff --git a/src/lib/filehaus.py b/src/lib/filehaus.py index 22b474f..bad45ea 100644 --- a/src/lib/filehaus.py +++ b/src/lib/filehaus.py @@ -1,43 +1,80 @@ +from typing import TypedDict from src.internals.database.database import cached_count, cached_query -def get_share(share_id: int, reload=False): +class TDShare(TypedDict): + id: int + name: str + description: str + uploader: int + added: str + + +def get_share(share_id: int, reload=False) -> TDShare: key = f"share:{share_id}" + params = dict(share_id=share_id) query = """ - SELECT * - FROM shares - WHERE id = %s + SELECT + id, + name, + description, + uploader, + added + FROM + shares + WHERE + id = %(share_id)s """ - return cached_query(query, key, (share_id,), reload=reload, single=True) + return cached_query(query, key, params, reload=reload, single=True) -def get_shares(offset: int, limit: int = 50, reload=False): +def get_shares(offset: int, limit: int = 50, reload=False) -> list[TDShare]: key = f"all_shares:{limit}:{offset}:" + params = dict(offset=offset, limit=limit) query = """ - SELECT * - FROM shares - ORDER BY id DESC - OFFSET %s - LIMIT %s + SELECT + id, + name, + description, + uploader, + added + FROM + shares + ORDER BY + id DESC + OFFSET %(offset)s + LIMIT %(limit)s """ - return cached_query(query, key, (offset, limit), reload=reload, lock_enabled=True) + return cached_query(query, key, params, reload=reload, lock_enabled=True) def get_all_shares_count(reload: bool = False) -> int: return cached_count("SELECT COUNT(*) FROM shares", "all_shares_count", lock_enabled=True) -def get_artist_shares(artist_id, service, reload=False): +def get_artist_shares(artist_id, service, reload=False) -> list[TDShare]: key = f"artist_shares:{service}:{artist_id}" + params = dict(artist_id=artist_id, service=service) query = """ - SELECT * - FROM shares s - INNER JOIN lookup_share_relationships lsr on s.id = lsr.share_id - WHERE lsr.user_id = %s AND lsr.service = %s - ORDER BY s.id DESC + SELECT + shares.id, + shares.name, + shares.description, + shares.uploader, + shares.added + FROM + shares + INNER JOIN + lookup_share_relationships AS lsr + ON + shares.id = lsr.share_id + WHERE + lsr.user_id = %(artist_id)s AND lsr.service = %(service)s + ORDER BY + shares.id DESC """ # todo CONSTRAINT lookup_share_relationships_pkey PRIMARY KEY (share_id, service, user_id) should be user_id, service, share_id or we have other index - return cached_query(query, key, (artist_id, service), reload=reload) + return cached_query(query, key, params, reload=reload) def get_artist_share_count(service: str, artist_id: str, reload=False): @@ -51,14 +88,46 @@ def get_artist_share_count(service: str, artist_id: str, reload=False): return cached_count(query, key, (service, artist_id), reload) -def get_files_for_share(share_id: int, reload=False): +class TDShareFile(TypedDict): + share_id: int + upload_url: str + upload_id: str + file_id: int + filename: str + id: int + hash: str + mtime: str + ctime: str + mime: str + ext: str + added: str + + +def get_files_for_share(share_id: int, reload=False) -> list[TDShareFile]: key = f"share_files:{share_id}" query = """ - SELECT * - FROM file_share_relationships fsr - LEFT JOIN files f - ON fsr.file_id = f.id - WHERE share_id = %s - ORDER frs.file_id DESC + SELECT + fsr.share_id, + fsr.upload_url, + fsr.upload_id, + fsr.file_id, + fsr.filename, + files.id, + files.hash, + files.mtime, + files.ctime, + files.mime, + files.ext, + files.added + FROM + file_share_relationships AS fsr + LEFT JOIN + files + ON + fsr.file_id = files.id + WHERE + share_id = %s + ORDER + frs.file_id DESC """ return cached_query(query, key, (share_id,), reload=reload) diff --git a/src/lib/files.py b/src/lib/files.py index dc31db7..f8e5018 100644 --- a/src/lib/files.py +++ b/src/lib/files.py @@ -3,7 +3,7 @@ import logging import re from dataclasses import dataclass from datetime import datetime -from typing import Optional +from typing import Optional, TypedDict import requests @@ -36,8 +36,33 @@ class File: size: int ihash: Optional[str] +class TDPost(TypedDict): + file_id: int + id: str + user: str + service: str + title: str + content: str + published: str + file: dict + attachments: list -def get_file_relationships(file_hash: str, reload: bool = False): +class TDDiscordPost(TypedDict): + file_id: int + id: str + server: str + channel: str + content: str + published: str + embeds: list + mentions: list + attachments: list + +class TDFileRelationships(TypedDict): + posts: list[TDPost] + discord_posts: list[TDDiscordPost] + +def get_file_relationships(file_hash: str, reload: bool = False) -> TDFileRelationships | None: key = f"files:by_hash:{file_hash}" query = """ SELECT @@ -55,9 +80,18 @@ def get_file_relationships(file_hash: str, reload: bool = False): posts.published, posts.file, posts.attachments - FROM file_post_relationships post_files - LEFT JOIN posts ON post_files.post = posts.id AND post_files.service = posts.service - WHERE files.id = post_files.file_id AND posts.id is not NULL + FROM + file_post_relationships AS post_files + LEFT JOIN + posts + ON + post_files.post = posts.id + AND + post_files.service = posts.service + WHERE + files.id = post_files.file_id + AND + posts.id is not NULL LIMIT 1000 ) AS posts ) AS posts, @@ -74,9 +108,14 @@ def get_file_relationships(file_hash: str, reload: bool = False): discord_posts.embeds, discord_posts.mentions, discord_posts.attachments - FROM file_discord_message_relationships discord_files - LEFT JOIN discord_posts ON discord_files.id = discord_posts.id - WHERE files.id = discord_files.file_id + FROM + file_discord_message_relationships AS discord_files + LEFT JOIN + discord_posts + ON + discord_files.id = discord_posts.id + WHERE + files.id = discord_files.file_id LIMIT 1000 ) AS discord_posts ) AS discord_posts @@ -119,7 +158,18 @@ def get_archive_files(file_hash: str) -> Optional[ArchiveInfo]: return None file, ext = arc_data key = f"archive_files:{file.hash}" - query = "SELECT * FROM archive_files LEFT JOIN files ON archive_files.file_id = files.id WHERE files.hash = %s" + query = """ + SELECT + * + FROM + archive_files + LEFT JOIN + files + ON + archive_files.file_id = files.id + WHERE + files.hash = %s + """ result = cached_query(query, key, (file.hash,), single=True) if result: return ArchiveInfo( @@ -168,11 +218,18 @@ def try_set_password(file_hash: str, passwords: list[str]) -> bool: if password: update_result = query_one_db( """ - UPDATE archive_files af - SET password = %s - FROM files - WHERE files.hash = %s AND af.file_id = files.id - RETURNING af.files + UPDATE + archive_files AS af + SET + password = %s + FROM + files + WHERE + files.hash = %s + AND + af.file_id = files.id + RETURNING + af.files """, (password, file_hash), ) diff --git a/src/lib/post.py b/src/lib/post.py index 0a0e896..2b41bd6 100644 --- a/src/lib/post.py +++ b/src/lib/post.py @@ -1,6 +1,8 @@ import itertools import logging import random +import re +from typing import TypedDict, Union, Sequence, Any, Literal, Optional from murmurhash2 import murmurhash2 @@ -17,6 +19,7 @@ from src.internals.serializers.post import ( serialize_post_list, serialize_posts_incomplete_rewards, ) +from src.lib.posts import Post from src.utils.utils import fixed_size_batches, images_pattern @@ -46,14 +49,51 @@ def get_random_post_key(table_fraction_percentage: float): def get_post(service, artist_id, post_id, reload=False): key = f"post:{service}:{artist_id}:{post_id}" + params = ( + service, + artist_id, + post_id, + service, + artist_id, + post_id, + service, + artist_id, + post_id, + ) query = """ WITH main_post AS ( - SELECT * - FROM posts - WHERE service = %s - AND "user" = %s - AND id = %s - AND ("user", service) NOT IN (SELECT id, service FROM dnp) + SELECT + id, + "user", + service, + title, + content, + embed, + shared_file, + added, + published, + edited, + file, + attachments, + poll, + captions, + tags + FROM + posts + WHERE + service = %s + AND + "user" = %s + AND + id = %s + AND + ("user", service) NOT IN ( + SELECT + id, + service + FROM + dnp + ) ) SELECT main_post.*, @@ -82,17 +122,7 @@ def get_post(service, artist_id, post_id, reload=False): return cached_query( query, key, - ( - service, - artist_id, - post_id, - service, - artist_id, - post_id, - service, - artist_id, - post_id, - ), + params, serialize_post, deserialize_post, reload, @@ -100,25 +130,42 @@ def get_post(service, artist_id, post_id, reload=False): ) -def get_post_multiple(input_, reload=False): +class TDPostData(TypedDict): + service: str + artist_id: str + post_id: str + +def get_post_multiple(input_: dict[tuple[str, str, str], TDPostData], reload=False): if not input_: return [] key = "post:{service}:{artist_id}:{post_id}" redis = get_conn() keys = [ - key.format(service=service, artist_id=artist_id, post_id=post_id) for (service, artist_id, post_id) in input_ + key.format(service=service, artist_id=artist_id, post_id=post_id) + for (service, artist_id, post_id) + in input_ ] cache_results = redis.mget(keys) - missing_in_cache = [] + missing_in_cache: list[tuple[str, str, str]] = [] for input_el, cache_result in zip(input_, cache_results): if cache_result is None: missing_in_cache.append(input_el) if not missing_in_cache: - all_posts = [deserialize_post(el) for el in cache_results] - return [el for el in all_posts if el] + all_posts = [ + deserialize_post(el) + for el + in cache_results + ] + + return [ + el + for el + in all_posts + if el + ] query = """ WITH input_values (service, "user", id) AS ( @@ -212,10 +259,26 @@ def get_post_multiple(input_, reload=False): return full_result -def get_post_by_id(post_id, service, reload=True): +def get_post_by_id(post_id:str, service:str, reload=True): key = f"post_by_id:{service}:{post_id}" - query = 'select id, service, "user" from posts where id = %s and service = %s' - return cached_query(query, key, (post_id, service), reload=reload, single=True) + params = dict( + post_id=post_id, + service=service + ) + query = """ + SELECT + id, + service, + "user" + FROM + posts + WHERE + id = %(post_id)s + AND + service = %(service)s + """ + + return cached_query(query, key, params, reload=reload, single=True) def get_posts_incomplete_rewards(post_id, artist_id, service, reload=False): @@ -236,12 +299,24 @@ def get_posts_incomplete_rewards(post_id, artist_id, service, reload=False): ) -def get_post_comments(post_id, service, reload=False): - if service not in ("fanbox", "patreon"): +class TDComment(TypedDict): + id: str + post_id: str + parent_id: Optional[str] + commenter: str + service: str + content: str + added: str + published: str + deleted_at: str + commenter_name: str + +def get_post_comments(post_id, service, reload=False) -> list[TDComment]: + if service not in ("fanbox", "patreon", "boosty"): return [] key = f"comments:{service}:{post_id}" # we select only used fields to save memory and be faster - if service in ("fanbox", "patreon"): + if service in ("fanbox", "patreon", "boosty"): revisions_select = """COALESCE(json_agg( json_build_object( 'id', comments_revisions.revision_id @@ -338,19 +413,72 @@ def get_artist_post_count(service, artist_id, reload=False): return cached_count(query, key, (artist_id, service), reload, lock_enabled=True) -def is_post_flagged(service, artist_id, post_id, reload=False): +def is_post_flagged(service, artist_id, post_id, reload=False) -> int: key = f"is_post_flagged:{service}:{artist_id}:{post_id}" query = 'SELECT COUNT(*) FROM booru_flags WHERE id = %s AND "user" = %s AND service = %s' return cached_count(query, key, (post_id, artist_id, service), reload) +class TDPostRevision(TypedDict): + revision_id: int + id: str + user: str + service: str + title: str + content: str + embed: dict + shared_file: bool | Literal["0"] + added: str + published: str + edited: str + file: Any + attachments: list[Any] + size: int + ihash: str + poll: Any + tags: list[str] + captions: Any -def get_post_revisions(service, artist_id, post_id, reload=False): + +def get_post_revisions(service: str, artist_id: str, post_id: str, reload=False) -> list[TDPostRevision]: key = f"post_revisions:{service}:{artist_id}:{post_id}" - query = 'SELECT * FROM revisions WHERE service = %s AND "user" = %s AND id = %s order by revision_id desc' + params = dict( + service=service, + artist_id=artist_id, + post_id=post_id + ) + query = """ + SELECT + revision_id, + id, + "user", + service, + title, + content, + embed, + shared_file, + added, + published, + edited, + file, + attachments, + poll, + tags, + captions + FROM + revisions + WHERE + service = %(service)s + AND + "user" = %(artist_id)s + AND + id = %(post_id)s + ORDER BY + revision_id DESC + """ return cached_query( query, key, - (service, artist_id, post_id), + params, serialize_post_list, deserialize_post_list, reload, @@ -380,15 +508,33 @@ def get_fileserver_for_value(value: str) -> str: return name return "" +type TDPreview = Union[TDPreviewThumbnail, TDPreviewEmbed] -def get_render_data_for_posts(posts): +class TDPreviewThumbnail(TypedDict): + type: "thumbnail" + server: str + name: str + path: str + +class TDPreviewEmbed(TypedDict): + type: "embed" + url: str + subject: str + description: str + +class TDAttachament(TypedDict): + server: str + name: str + path:str + +def get_render_data_for_posts(posts: Sequence[Post]) -> tuple[list[TDPreview], list[TDAttachament], list[bool]]: result_previews = [] result_attachments = [] result_is_image = [] for post in posts: - previews = [] - attachments = [] + previews: list[TDPreview] = [] + attachments: list[TDAttachament] = [] if "path" in post["file"]: if images_pattern.search(post["file"]["path"]): result_is_image.append(True) @@ -444,3 +590,21 @@ def get_render_data_for_posts(posts): result_attachments.append(attachments) return result_previews, result_attachments, result_is_image + + +img_replace_patterns = re.compile(pattern=r']*?src="([^"]+)"[^>]*>') + + +def patch_inline_img(content): + return img_replace_patterns.sub(replace_img_tags, content) + + +def replace_img_tags(match): + src = match.group(1) + if not src.startswith("/data"): + src = "/data" + src + from src.config import Configuration + new_src = Configuration().webserver["ui"]["files_url_prepend"]["thumbnails_base_url"] + "/thumbnail" + src + server = get_fileserver_for_value(src.split("?")[0]) + # return match.group(0).replace(src, new_src) + return f'' diff --git a/src/lib/posts.py b/src/lib/posts.py index 9358ed5..5556be9 100644 --- a/src/lib/posts.py +++ b/src/lib/posts.py @@ -2,7 +2,7 @@ import base64 import itertools from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Optional, TypedDict +from typing import Optional, TypedDict,Any from src.config import Configuration from src.internals.cache.redis import get_conn @@ -26,6 +26,10 @@ class Post(TypedDict): edited: datetime file: dict attachments: list[dict] + poll: dict + captions: Any + tags: list[str] + incomplete_rewards: Optional[str] class PostWithFavCount(Post): @@ -80,10 +84,23 @@ def get_all_posts_summary(offset: int, limit=50, reload=False, cache_ttl=None): # we need this version to reduce redis size and bandwidth in half key = f"all_posts:summary:{limit}:{offset}" query = """ - SELECT id, "user", service, title, substring("content", 1, 50), published, file, attachments - FROM posts - WHERE ("user", service) NOT IN (SELECT id, service from dnp) - ORDER BY added DESC + SELECT + id, + "user", + service, + title, + substring("content", 1, 50), + published, + file, + attachments + FROM + posts + WHERE + ("user", service) NOT IN ( + SELECT id, service from dnp + ) + ORDER BY + added DESC OFFSET %s LIMIT %s """ @@ -118,14 +135,26 @@ def get_all_posts_for_query(q: str, offset: int, limit=50, reload=False): query = """ SET random_page_cost = 0.0001; SET LOCAL statement_timeout = 10000; - SELECT id, "user", service, title, substring("content", 1, 50), published, file, attachments - FROM posts - WHERE (title || ' ' || content) &@~ %s - AND ("user", service) NOT IN ( - SELECT id, service - FROM dnp - ) - ORDER BY added DESC + SELECT + id, + "user", + service, + title, + substring("content", 1, 50), + published, + file, + attachments + FROM + posts + WHERE + (title || ' ' || content) &@~ %s + AND + ("user", service) NOT IN ( + SELECT id, service + FROM dnp + ) + ORDER BY + added DESC LIMIT %s OFFSET %s; """ diff --git a/src/pages/account/__init__.py b/src/pages/account/__init__.py deleted file mode 100644 index 681decc..0000000 --- a/src/pages/account/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .blueprint import account_bp diff --git a/src/pages/account/administrator/__init__.py b/src/pages/account/administrator/__init__.py deleted file mode 100644 index 1ab7734..0000000 --- a/src/pages/account/administrator/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .blueprint import administrator diff --git a/src/pages/account/administrator/blueprint.py b/src/pages/account/administrator/blueprint.py deleted file mode 100644 index cb6aa3f..0000000 --- a/src/pages/account/administrator/blueprint.py +++ /dev/null @@ -1,157 +0,0 @@ -from flask import Blueprint, abort, g, make_response, render_template, request - -from src.lib.administrator import change_account_role, get_accounts -from src.lib.pagination import Pagination -from src.types.account import Account, AccountRoleChange, visible_roles - -from .types import Accounts, Dashboard, Role_Change - -# from datetime import datetime - -administrator = Blueprint( - "admin", - __name__, -) - - -@administrator.before_request -def check_credentials(): - account: Account = g.get("account") - if account.role != "administrator": - return abort(404) - - -@administrator.get("/administrator") -def get_admin(): - props = Dashboard() - - response = make_response( - render_template( - "account/administrator/dashboard.html", - props=props, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@administrator.get("/administrator/accounts") -def get_accounts_list(): - queries = request.args.to_dict() - queries["name"] = queries["name"] if queries.get("name") else None - - # transform `role` query into a list for db query - if queries.get("role") and queries["role"] != "all": - queries["role"] = [queries["role"]] - else: - queries["role"] = visible_roles - - pagination = Pagination(request) - accounts = get_accounts(pagination, queries) - props = Accounts( - accounts=accounts, - role_list=visible_roles, - pagination=pagination, - ) - - response = make_response( - render_template( - "account/administrator/accounts.html", - props=props, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@administrator.post("/administrator/accounts") -def change_account_roles(): - form_dict = request.form.to_dict(flat=False) - # convert ids to `int` - candidates = dict( - moderator=[int(id) for id in form_dict.get("moderator")] if form_dict.get("moderator") else [], - consumer=[int(id) for id in form_dict.get("consumer")] if form_dict.get("consumer") else [], - ) - - if candidates["moderator"]: - change_account_role( - candidates["moderator"], - AccountRoleChange( - old_role="consumer", - new_role="moderator", - ), - ) - if candidates["consumer"]: - change_account_role( - candidates["consumer"], - AccountRoleChange( - old_role="moderator", - new_role="consumer", - ), - ) - - props = Role_Change() - - response = make_response(render_template("success.html", props=props), 200) - response.headers["Cache-Control"] = "max-age=0, private, must-revalidate" - - return response - - -# @admin.route('/admin/accounts/', methods= ['GET']) -# def get_account_info(account_id: str): -# """ -# Detailed account page. -# """ -# account = get_account(account_id) -# props = admin_props.Account( -# account= account -# ) - -# response = make_response(render_template( -# 'admin/account_info.html', -# props = props, -# ), 200) -# response.headers['Cache-Control'] = 's-maxage=60' -# return response - -# @admin.route('/admin/accounts/', methods= ['POST']) -# def change_account(): -# pass - -# @admin.route('/admin/accounts//files') -# def get_account_files(account_id: str): -# """ -# The lists of approved/rejected/queued files for the given account. -# """ -# files = [] -# account = {} - -# props = admin_props.Account_Files( -# account= account, -# files= files -# ) -# response = make_response(render_template( -# 'admin/account_files.html', -# props = props, -# ), 200) -# response.headers['Cache-Control'] = 's-maxage=60' -# return response - -# @admin.route('/admin/mods/actions', methods= ['GET']) -# def get_moderators_audits(): -# """ -# The list of moderator actions. -# """ -# actions = [] -# props = admin_props.ModeratorActions( -# actions= actions -# ) -# response = make_response(render_template( -# 'admin/mods_actions.html', -# props = props, -# ), 200) -# response.headers['Cache-Control'] = 's-maxage=60' -# return response diff --git a/src/pages/account/administrator/types.py b/src/pages/account/administrator/types.py deleted file mode 100644 index 214d68f..0000000 --- a/src/pages/account/administrator/types.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from src.internals.internal_types import PageProps -from src.lib.pagination import Pagination -from src.types.account import Account - - -@dataclass -class Dashboard(PageProps): - currentPage: str = "admin" - - -@dataclass -class Accounts(PageProps): - accounts: List[Account] - role_list: List[str] - pagination: Pagination - currentPage: str = "admin" - - -@dataclass -class Role_Change(PageProps): - redirect: str = "/account/administrator/accounts" - currentPage: str = "admin" - - -# @dataclass -# class Account_Props(PageProps): -# account: Account -# currentPage: str = 'admin' - -# @dataclass -# class Account_Files: -# account: Account -# files: List[Dict] -# currentPage: str = 'admin' - -# @dataclass -# class ModeratorsActions(): -# actions: List[Dict] -# currentPage: str = 'admin' diff --git a/src/pages/account/blueprint.py b/src/pages/account/blueprint.py deleted file mode 100644 index 19b4a15..0000000 --- a/src/pages/account/blueprint.py +++ /dev/null @@ -1,270 +0,0 @@ -import re -from json import JSONDecodeError - -import orjson -from flask import Blueprint, current_app, flash, g, make_response, redirect, render_template, request, session, url_for - -from src.config import Configuration -from src.lib.account import ( - attempt_login, - create_account, - get_saved_key_import_ids, - get_saved_keys, - revoke_saved_keys, - change_password as db_change_password, -) -from src.lib.notification import count_account_notifications, get_account_notifications, set_notifications_as_seen -from src.lib.security import is_password_compromised -from src.types.account import Account -from src.types.props import SuccessProps -from src.utils.utils import set_query_parameter -from src.utils.decorators import require_login - -from .administrator import administrator -from .moderator import moderator -from .types import AccountPageProps, NotificationsProps, ServiceKeysProps - -USERNAME_REGEX = re.compile(r"^[a-z0-9_@+.\-]{3,15}$") -account_bp = Blueprint("account", __name__) - - -@account_bp.get("/account") -def get_account(): - account: Account = g.get("account") - if not account: - return redirect(url_for("account.get_login")) - - if Configuration().enable_notifications: - notifications_count = count_account_notifications(account.id) - else: - notifications_count = 0 - props = AccountPageProps(account=account, notifications_count=notifications_count) - - return make_response(render_template("account/home.html", props=props), 200) - - -@account_bp.get("/account/notifications") -def get_notifications(): - account: Account = g.get("account") - if not account: - return redirect(url_for("account.get_login")) - - if Configuration().enable_notifications: - notifications = get_account_notifications(account.id) - else: - notifications = [] - props = NotificationsProps(notifications=notifications) - - seen_notif_ids = [notification.id for notification in notifications if not notification.is_seen] - set_notifications_as_seen(seen_notif_ids) - - return make_response(render_template("account/notifications.html", props=props), 200) - - -@account_bp.get("/account/keys") -def get_account_keys(): - account: Account = g.get("account") - if not account: - return redirect(url_for("account.get_login")) - - saved_keys = get_saved_keys(account.id) - props = ServiceKeysProps(service_keys=saved_keys) - - saved_session_key_import_ids = [] - for key in saved_keys: - saved_session_key_import_ids.append(get_saved_key_import_ids(key.id)) - - response = make_response( - render_template("account/keys.html", props=props, import_ids=saved_session_key_import_ids), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@account_bp.post("/account/keys") -def revoke_service_keys(): - account: Account = g.get("account") - if not account: - return redirect(url_for("account.get_login")) - - keys_dict = request.form.to_dict(flat=False) - keys_for_revocation = [int(key) for key in keys_dict["revoke"]] if keys_dict.get("revoke") else [] - - revoke_saved_keys(keys_for_revocation, account.id) - - props = SuccessProps(currentPage="account", redirect="/account/keys") - - response = make_response(render_template("success.html", props=props), 200) - return response - - -@account_bp.get("/account/login") -def get_login(): - props = {"currentPage": "login"} - location = request.form.get("location", request.args.get("location", url_for("artists.list"))) - - if account := g.get("account"): - return redirect(set_query_parameter(location, {"logged_in": "yes", "role": account.role})) - - response = make_response( - render_template( - "account/login.html", - location=location, - props=props, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@account_bp.post("/account/login") -def post_login(): - location = request.form.get("location", request.args.get("location", url_for("artists.list"))) - - if account := g.get("account"): - return redirect(set_query_parameter(location, {"logged_in": "yes", "role": account.role})) - - username = request.form.get("username", "").replace("\x00", "") - password = request.form.get("password", "") - - if account := attempt_login(username, password): - return redirect(set_query_parameter(location, {"logged_in": "yes", "role": account.role})) - - return redirect(url_for("account.get_login", location=location)) - - - -@account_bp.route("/account/logout") -def logout(): - if "account_id" in session: - session.pop("account_id") - return redirect(url_for("artists.list")) - - -@account_bp.get("/account/register") -def get_register(): - props = { - "currentPage": "login", - "username_regex": USERNAME_REGEX.pattern, - } - location = request.form.get("location", request.args.get("location", url_for("artists.list"))) - - if g.get("account"): - return redirect(location) - - return make_response(render_template("account/register.html", props=props, location=location), 200) - - -@account_bp.post("/account/register") -def post_register(): - location = request.form.get("location", request.args.get("location", url_for("artists.list"))) - - username = request.form.get("username", "").replace("\x00", "").strip() - password = request.form.get("password", "").strip() - favorites_json = request.form.get("favorites", "[]") - confirm_password = request.form.get("confirm_password", "").strip() - - favorites = [] - if favorites_json != "": - try: - favorites = orjson.loads(favorites_json) - except JSONDecodeError: - pass - - errors = False - if username == "": - flash("Username cannot be empty") - errors = True - - if not USERNAME_REGEX.match(username): - flash("Invalid username") - errors = True - - if password == "": - flash("Password cannot be empty") - errors = True - - if password != confirm_password: - flash("Passwords do not match") - errors = True - - if current_app.config.get("ENABLE_PASSWORD_VALIDATOR") and is_password_compromised(password): - flash( - "We've detected that password was compromised in a data breach on another site. Please choose a different password." - ) - errors = True - - if not errors: - success = create_account(username, password, favorites) - if not success: - flash("Username already taken") - errors = True - - if not errors: - account = attempt_login(username, password) - if account is None: - current_app.logger.warning("Error logging into account immediately after creation") - flash("Account created successfully.") - return redirect(set_query_parameter(location, {"logged_in": "yes"})) - else: - flash("Account created successfully.") - return redirect(set_query_parameter(location, {"logged_in": "yes", "role": account.role})) - - return redirect(url_for("account.get_register", location=location)) - - -@account_bp.get("/account/change_password") -@require_login -def change_password(user: Account): - props = {"currentPage": "changepassword"} - - tmpl = render_template("account/change_password.html", props=props) - response = make_response(tmpl, 200) - response.headers["Cache-Control"] = "s-maxage=3600" - return response - - -@account_bp.post("/account/change_password") -@require_login -def post_change_password(user: Account): - current_password = request.form.get("current-password", "").strip() - new_password = request.form.get("new-password", "").strip() - new_password_conf = request.form.get("new-password-confirmation", "").strip() - - errors = False - - if not new_password: - flash("Password cannot be empty") - errors = True - - if new_password != new_password_conf: - flash("Passwords do not match") - errors = True - - if current_app.config.get("ENABLE_PASSWORD_VALIDATOR") and is_password_compromised(new_password): - flash( - "We've detected that password was compromised in a data breach on another site. Please choose a different password." - ) - errors = True - - if not errors: - if db_change_password(user.id, current_password, new_password): - flash("Password changed") - return redirect(url_for("account.get_account")) - else: - flash("Current password is invalid") - - return redirect(url_for("account.change_password")) - - -@account_bp.get("/.well-known/change-password") -def well_known_change_password(): - response = redirect(url_for("account.change_password")) - response.headers["Cache-Control"] = "s-maxage=604800" - return response - - -account_bp.register_blueprint(administrator, url_prefix="/account") -account_bp.register_blueprint(moderator, url_prefix="/account") diff --git a/src/pages/account/moderator/__init__.py b/src/pages/account/moderator/__init__.py deleted file mode 100644 index 1fe7f06..0000000 --- a/src/pages/account/moderator/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .blueprint import moderator diff --git a/src/pages/account/moderator/blueprint.py b/src/pages/account/moderator/blueprint.py deleted file mode 100644 index f037c65..0000000 --- a/src/pages/account/moderator/blueprint.py +++ /dev/null @@ -1,59 +0,0 @@ -from flask import Blueprint, abort, make_response, render_template, g - -from src.lib.artist import get_unapproved_links_with_artists - -from .types import mod_props - -moderator = Blueprint("mod", __name__) - - -@moderator.before_request -def check_credentials(): - account = g.get("account") - if not account or (account.role != "moderator" and account.role != "administrator"): - return abort(code=404) - - -@moderator.get("/moderator") -def get_dashboard(): - props = mod_props.Dashboard() - - response = make_response( - render_template( - "account/moderator/dashboard.html", - props=props, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@moderator.route("/moderator/tasks/creator_links") -def get_creator_links(): - links = get_unapproved_links_with_artists() - props = {"currentPage": "mod"} - - response = make_response( - render_template( - "account/moderator/creator_links.html", - links=links, - props=props, - ), - 200 - ) - return response - - -# @moderator.route("/mod/tasks/files") -# def get_files(): -# files = [] -# props = mod_props.Files( -# files= files -# ) -# response = make_response(render_template( -# 'moderator_files.html', -# props = props, -# ), 200) -# response.headers['Cache-Control'] = 's-maxage=60' -# return response diff --git a/src/pages/account/moderator/types.py b/src/pages/account/moderator/types.py deleted file mode 100644 index fd2c47f..0000000 --- a/src/pages/account/moderator/types.py +++ /dev/null @@ -1,21 +0,0 @@ -from typing import Dict, List - - -class Dashboard: - def __init__(self) -> None: - self.current_page = "mod" - - -class Files: - def __init__(self, files: List[Dict]) -> None: - self.current_page = "mod" - self.files = files - - -class Moderator: - def __init__(self) -> None: - self.Dashboard = Dashboard - self.Files = Files - - -mod_props = Moderator() diff --git a/src/pages/api/__init__.py b/src/pages/api/__init__.py index 46e9682..0b40361 100644 --- a/src/pages/api/__init__.py +++ b/src/pages/api/__init__.py @@ -1,9 +1,6 @@ -import pathlib +from flask import Blueprint, Response -import orjson -import yaml -from flask import Blueprint, make_response, render_template, send_from_directory -from yaml import CLoader as Loader +from src.config import Configuration from src.pages.api.v1 import v1api_bp @@ -11,37 +8,12 @@ api_bp = Blueprint("api", __name__, url_prefix="/api") api_bp.register_blueprint(v1api_bp) +# set up cors handler for development +if (Configuration().development_mode): + @api_bp.after_request + def aug_api_response(response: Response): + response.headers["Access-Control-Allow-Origin"] = Configuration().webserver["site"] + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + response.headers["Access-Control-Allow-Credentials"] = "true" -@api_bp.get("/swagger_schema") -def swagger_schema(): - response = make_response( - render_template( - "swagger_schema.html", - props=dict( - json_spec=orjson.dumps( - yaml.load(open(pathlib.Path(__file__).parent / "schema.yaml", "r"), Loader=Loader) - ).decode() - ), - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@api_bp.get("/swagger_schema.yaml") -def swagger_schema_yaml(): - return send_from_directory(pathlib.Path(__file__).parent, "schema.yaml") - - -@api_bp.get("/schema") -def api_schema(): - response = make_response( - render_template( - "schema.html", - props=dict(), - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response + return response diff --git a/src/pages/api/schema.yaml b/src/pages/api/schema.yaml index a2692ab..5a26b76 100644 --- a/src/pages/api/schema.yaml +++ b/src/pages/api/schema.yaml @@ -1,9 +1,9 @@ -openapi: 3.0.1 +openapi: 3.1.0 info: title: Kemono API - version: 1.0.0 -contact: - email: contact@kemono.party + version: 1.1.0 + contact: + email: contact@kemono.party servers: - url: https://kemono.su/api/v1 - url: https://coomer.su/api/v1 @@ -78,55 +78,69 @@ paths: description: Result offset, stepping of 50 is enforced schema: type: integer + - name: tag + in: query + description: A list of tags to filter by + schema: + type: array + items: + type: string responses: '200': description: List of recently added posts content: application/json: schema: - type: array - items: - type: object - properties: - id: - type: string - user: - type: string - service: - type: string - title: - type: string - content: - type: string - embed: - type: object - shared_file: - type: boolean - added: - type: string - format: date-time - published: - type: string - format: date-time - edited: - type: string - format: date-time - file: + type: object + properties: + count: + type: integer + true_count: + type: integer + posts: + type: array + items: type: object properties: - name: + id: type: string - path: + user: type: string - attachments: - type: array - items: - type: object - properties: - name: - type: string - path: - type: string + service: + type: string + title: + type: string + content: + type: string + embed: + type: object + shared_file: + type: boolean + added: + type: string + format: date-time + published: + type: string + format: date-time + edited: + type: string + format: date-time + file: + type: object + properties: + name: + type: string + path: + type: string + attachments: + type: array + items: + type: object + properties: + name: + type: string + path: + type: string example: - id: '1836570' user: '6570768' @@ -160,6 +174,217 @@ paths: path: /d7/4d/d74d1727f2c3fcf7a7cc2d244d677d93b4cc562a56904765e4e708523b34fb4c.png - name: ab0e17d7-52e5-42c2-925b-5cfdb451df0c.png path: /1b/67/1b677a8c0525e386bf2b2f013e36e29e4033feb2308798e4e5e3780da6c0e815.png + /posts/random: + get: + description: Get a random post + responses: + '200': + description: A random post. + content: + application/json: + schema: + type: object + properties: + service: + type: string + artist_id: + type: string + post_id: + type: string + '404': + description: Not random psot found. + content: + application/json: + schema: + $ref: "#/components/schemas/error" + /posts/popular: + get: + description: Get popular posts + parameters: + - name: date + in: query + description: Base date of the list + required: true + schema: + type: string + - name: period + in: query + description: Period scale of the list + required: true + schema: + enum: + - recent + - day + - week + - month + - $ref: "#/components/parameters/query-o" + + responses: + '200': + description: A list of popular posts. + content: + application/json: + schema: + type: object + properties: + info: + type: object + properties: + date: + type: string + min_date: + type: string + max_date: + type: string + navigation_dates: + type: object + propertyNames: + enum: + - recent + - day + - week + - month + additionalProperties: + type: array + prefixItems: + - type: string + - type: string + - type: string + range_desc: + type: string + scale: + enum: + - recent + - day + - week + - month + props: + type: object + properties: + currentPage: + const: popular_posts + today: + type: string + earliest_date_for_popular: + type: string + limit: + type: integer + count: + type: integer + results: + type: array + items: + $ref: "#/components/schemas/post-with-fav-count" + base: + type: object + additionalProperties: + type: string + result_previews: + type: array + items: + anyOf: + - type: object + properties: + type: + const: thumbnail + server: + type: string + name: + type: string + path: + type: string + - type: object + properties: + type: + const: embed + url: + type: string + subject: + type: string + description: + type: string + result_attachments: + type: array + items: + type: object + properties: + server: + type: string + name: + type: string + path: + type: string + result_is_image: + type: array + items: + type: boolean + /posts/tags: + get: + description: Get tags + responses: + '200': + description: A list of post tags. + content: + application/json: + schema: + type: object + properties: + props: + type: object + properties: + currentpage: + const: "tags" + tags: + type: array + items: + $ref: "#/components/schemas/tag" + /posts/archives/{file_hash}: + get: + description: Get archive file contents + parameters: + - name: file_hash + in: path + required: true + schema: + type: string + responses: + '200': + description: Archive file contents. + content: + application/json: + schema: + type: object + properties: + archive: + $ref: "#/components/schemas/archive-info" + file_serving_enabled: + type: boolean + /{service}/post/{post_id}: + get: + description: Get a post by ID + parameters: + - $ref: "#/components/parameters/path-service" + - $ref: "#/components/parameters/path-post-id" + responses: + '200': + description: Post data. + content: + application/json: + schema: + type: object + properties: + service: + type: string + artist_id: + type: string + post_id: + type: string + '404': + description: No post found + content: + application/json: + schema: + $ref: "#/components/schemas/error" /{service}/user/{creator_id}/profile: get: summary: Get a creator @@ -190,8 +415,9 @@ paths: type: string description: The ID of the creator public_id: - type: string - nullable: true + type: + - string + - null description: The public ID of the creator service: type: string @@ -444,7 +670,11 @@ paths: type: integer ihash: type: string - example: + path: + type: string + server: + type: string + example: - id: 108058645 user_id: '3316400' file_id: 108058645 @@ -501,8 +731,9 @@ paths: type: string description: The ID of the creator public_id: - type: string - nullable: true + type: + - string + - null description: The public ID of the creator service: type: string @@ -529,6 +760,344 @@ paths: type: string description: The error message enum: ["Creator not found."] + delete: + description: Remove artist from linked accounts. Requires admin privilegies. + parameters: + - $ref: "#/components/parameters/path-service" + - $ref: "#/components/parameters/path-creator-id" + responses: + '204': + description: Artist's link was successfuly removed. + content: + plain/text: + schema: + const: "" + '404': + description: Insufficient privilegies. + content: + plain/text: + schema: + const: "" + /{service}/user/{creator_id}/links/new: + get: + description: Add links to the artist + parameters: + - $ref: "#/components/parameters/path-service" + - $ref: "#/components/parameters/path-creator-id" + responses: + '200': + description: The data for the new link. + content: + application/json: + schema: + type: object + properties: + props: + type: object + properties: + id: + type: string + service: + type: string + artist: + $ref: "#/components/schemas/artist" + share_count: + $ref: "#/components/schemas/non-negative-integer" + dm_count: + $ref: "#/components/schemas/non-negative-integer" + has_links: + enum: + - ✔️ + - "0" + display_data: + type: object + properties: + service: + type: string + href: + type: string + base: + type: object + properties: + service: + type: string + artist_id: + type: string + post: + description: Add links to the artist + parameters: + - $ref: "#/components/parameters/path-service" + - $ref: "#/components/parameters/path-creator-id" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - service + - artist_id + properties: + service: + type: string + artist_id: + type: string + reason: + type: string + maxLength: 140 + responses: + '200': + description: The link request added to moderation queue. + content: + application/json: + schema: + type: object + properties: + message: + type: string + props: + type: object + properties: + id: + type: string + service: + type: string + artist: + $ref: "#/components/schemas/artist" + share_count: + $ref: "#/components/schemas/non-negative-integer" + has_links: + enum: + - ✔️ + - "0" + display_data: + type: object + properties: + service: + type: string + href: + type: string + '400': + description: Failed to added the new link due to input errors. + content: + application/json: + schema: + type: object + properties: + error: + type: text + /{service}/user/{creator_id}/tags: + get: + description: Tags of profile + tags: + - Creators + parameters: + - $ref: "#/components/parameters/path-service" + - $ref: "#/components/parameters/path-creator-id" + responses: + '200': + description: Found the tags for the profile + content: + application/json: + schema: + type: object + properties: + props: + display_data: + type: object + properties: + service: string + href: string + artist: + $ref: "#/components/schemas/artist" + service: + type: string + id: + type: string + share_count: + type: integer + dm_count: + type: integer + has_links: + # gr8 API design + enum: + - ✔️ + - "0" + tags: + type: array + items: + $ref: "#/components/schemas/tag" + service: + type: string + artist: + $ref: "#/components/schemas/artist" + /{service}/user/{creator_id}/shares: + get: + description: Shares of the artist + parameters: + - $ref: "#/components/parameters/path-service" + - $ref: "#/components/parameters/path-creator-id" + - $ref: "#/components/parameters/query-o" + responses: + '200': + description: Found the shares for the artist + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: "#/components/schemas/share" + props: + display_data: + type: object + properties: + service: + type: string + href: + type: string + service: + type: string + artist: + $ref: "#/components/schemas/artist" + id: + type: string + dm_count: + type: integer + share_count: + type: integer + has_links: + enum: + - ✔️ + - "0" + base: + type: object + properties: + service: + type: string + artist_id: + type: string + /{service}/user/{creator_id}/dms: + get: + description: Direct messages of profile + parameters: + - $ref: "#/components/parameters/path-service" + - $ref: "#/components/parameters/path-creator-id" + responses: + '200': + description: Found direct messages for the profile + content: + application/json: + schema: + type: object + properties: + props: + id: + type: string + service: + type: string + artist: + $ref: "#/components/schemas/artist" + display_data: + type: object + properties: + service: + type: string + href: + type: string + share_count: + type: integer + dm_count: + type: integer + dms: + type: array + items: + $ref: "#/components/schemas/approved-dm" + has_links: + enum: + - ✔️ + - "0" + /{service}/user/{creator_id}/posts-legacy: + get: + description: A duct-tape endpoint which also returns count for pagination component. + parameters: + - name: service + in: path + required: true + description: The service name + schema: + type: string + - name: creator_id + in: path + required: true + description: The profiles's ID + schema: + type: string + - name: tag + in: query + description: A list of post tags + schema: + type: array + responses: + '200': + description: Found posts of the profile + content: + application/json: + schema: + type: object + properties: + props: + type: object + properties: + currentPage: + const: posts + id: + type: string + service: + type: string + name: + type: string + count: + type: integer + limit: + type: integer + artist: + $ref: "#/components/schemas/artist" + display_data: + type: object + properties: + service: + type: string + href: + type: string + dm_count: + type: integer + share_count: + type: integer + has_links: + type: string + base: + type: object + results: + type: array + items: + $ref: "#/components/schemas/post" + result_previews: + type: array + items: + type: object + result_attachments: + type: array + items: + type: object + result_is_image: + type: array + items: + type: boolean + disable_service_icons: + const: true /{service}/user/{creator_id}/post/{post_id}: get: summary: Get a specific post @@ -561,68 +1130,145 @@ paths: schema: type: object properties: - id: - type: string - user: - type: string - service: - type: string - title: - type: string - content: - type: string - embed: - type: object - shared_file: - type: boolean - added: - type: string - format: date-time - published: - type: string - format: date-time - edited: - type: string - format: date-time - file: + post: type: object properties: - name: + id: type: string - path: + user: + type: string + service: + type: string + title: + type: string + content: + type: string + embed: + type: object + shared_file: + type: boolean + added: + type: string + format: date-time + published: + type: string + format: date-time + edited: + type: string + format: date-time + file: + type: object + properties: + name: + type: string + path: + type: string + attachments: + type: array + items: + type: object + properties: + name: + type: string + path: + type: string + next: + type: string + prev: type: string attachments: type: array - items: - type: object - properties: - name: - type: string - path: - type: string - next: - type: string - prev: - type: string + previews: + type: array + videos: + type: array + props: + type: object + properties: + service: + type: string + flagged: + type: integer + revisions: + type: array + items: + $ref: "#/components/schemas/post-revision" example: - id: '1836570' - user: '6570768' - service: fanbox - title: 今日はFANBOXを始まりました! - content:

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

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

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

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


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

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

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


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

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

    - embed: { } - shared_file: false - added: '2021-03-30T18:00:05.973913' - published: '2021-01-24T17:54:38' - edited: '2021-01-24T18:46:15' - file: - name: a99d9674-5490-400e-acca-4bed99590699.jpg - path: /5c/98/5c984d1f62f0990a0891d8fa359aecdff6ac1e26ac165ba7bb7f31cc99e7a674.jpg - attachments: [ ] - next: null - prev: '1836649' + post: + id: '1836570' + user: '6570768' + service: fanbox + title: 今日はFANBOXを始まりました! + content:

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

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

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

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


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

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

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


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

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

    + embed: { } + shared_file: false + added: '2021-03-30T18:00:05.973913' + published: '2021-01-24T17:54:38' + edited: '2021-01-24T18:46:15' + file: + name: a99d9674-5490-400e-acca-4bed99590699.jpg + path: /5c/98/5c984d1f62f0990a0891d8fa359aecdff6ac1e26ac165ba7bb7f31cc99e7a674.jpg + attachments: [ ] + next: null + prev: '1836649' '404': description: Post not found + /{service}/user/{creator_id}/post/{post_id}/revision/{revision_id}: + get: + description: Get revision of a post + parameters: + - $ref: "#/components/parameters/path-service" + - $ref: "#/components/parameters/path-creator-id" + - $ref: "#/components/parameters/path-post-id" + - name: revision_id + in: path + description: ID of the revision + required: true + schema: + type: string + responses: + '200': + description: A revision of the post. + content: + application/json: + schema: + type: object + properties: + props: + type: object + properties: + currentPage: + const: revisions + service: + type: string + artist: + $ref: "#/components/schemas/artist" + flagged: + $ref: "#/components/schemas/non-negative-integer" + revisions: + type: array + items: + $ref: "#/components/schemas/post-revision" + post: + $ref: "#/components/schemas/post-revision" + comments: + type: array + items: + $ref: "#/components/schemas/comment" + result_previews: + type: array + result_attachments: + type: array + videos: + type: array + archives_enabled: + type: boolean + '404': + description: Failed to find the revision of the post. + content: + application/json: + schema: + $ref: "#/components/schemas/error" /discord/channel/{channel_id}: get: tags: @@ -765,6 +1411,215 @@ paths: name: nyarla-lewds '404': description: Discord server not found + /authentication/register: + post: + description: Register an account + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + confirm_password: + type: string + favorites_json: + type: string + responses: + '200': + description: Successfully registered. + content: + application/json: + schema: + const: true + '400': + description: Failed to register due to user errors. + content: + application/json: + schema: + $ref: "#/components/schemas/error" + /authentication/login: + post: + description: Sign in to account + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + responses: + '200': + description: Succefully logged in. + content: + application/json: + schema: + $ref: "#/components/schemas/account" + '400': + description: Failed to log in due to user errors. + content: + application/json: + schema: + $ref: "#/components/schemas/error" + '409': + description: Already logged in. + content: + application/json: + schema: + $ref: "#/components/schemas/error" + /authentication/logout: + post: + description: Logout from account + responses: + '200': + description: Succefuuly logged out from account. + content: + application/json: + schema: + const: true + /account: + get: + description: Get account data + security: + - cookieAuth: [ ] + responses: + '200': + description: Account data. + content: + application/json: + schema: + type: object + properties: + props: + type: object + properties: + currentPage: + const: account + title: + const: Your account page + account: + $ref: "#/components/schemas/account" + notifications_count: + $ref: "#/components/schemas/non-negative-integer" + /account/change_password: + post: + description: Change account password + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - current-password + - new-password + - new-password-confirmation + properties: + current-password: + type: string + new-password: + type: string + new-password-confirmation: + type: string + responses: + '200': + description: Successfully changed account password. + content: + application/json: + schema: + const: true + /account/notifications: + get: + description: Get account notifications + security: + - cookieAuth: [ ] + responses: + '200': + description: A list of account notifications. + content: + application/json: + schema: + type: object + properties: + currentPage: + const: account + notifications: + type: array + items: + $ref: "#/components/schemas/notification" + /account/keys: + get: + description: Get account autoimport keys + security: + - cookieAuth: [ ] + responses: + '200': + description: A list of account keys. + content: + application/json: + schema: + type: object + properties: + props: + type: object + properties: + currentPage: + const: account + title: + const: Your service keys + service_keys: + type: array + items: + $ref: "#/components/schemas/service-key" + import_ids: + type: array + items: + type: object + properties: + key_id: + type: string + import_id: + type: string + post: + security: + - cookieAuth: [ ] + description: Revoke account autoimport keys + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + revoke: + type: array + items: + $ref: "#/components/schemas/positive-integer" + responses: + '200': + description: Account import keys revoked. + content: + application/json: + schema: + type: object + properties: + props: + type: object + properties: + currentPage: + const: account + redirect: + const: /account/keys + message: + const: "Success!" /account/favorites: get: tags: @@ -815,6 +1670,200 @@ paths: description: Timestamp when the creator was last updated '401': $ref: '#/components/schemas/401' + /account/posts/upload: + get: + description: Upload posts. + security: + - cookieAuth: [ ] + responses: + '200': + description: Upload posts maybe??? + content: + application/json: + schema: + type: object + properties: + currentPage: + const: posts + /account/review_dms: + get: + description: Get DMs for review. + security: + - cookieAuth: [ ] + parameters: + - name: status + in: query + description: Status of the DM. + schema: + enum: + - ignored + - pending + responses: + '200': + description: A list of unapproved DMs. + content: + application/json: + schema: + type: object + properties: + currentPage: + const: import + account_id: + $ref: "#/components/schemas/positive-integer" + dms: + type: array + items: + $ref: "#/components/schemas/unapproved-dm" + status: + enum: + - ignored + - pending + post: + description: Approve DMs. + security: + - cookieAuth: [ ] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + approved_hashes: + type: array + items: + type: string + delete_ignored: + type: boolean + responses: + '200': + description: Approved DMs. + content: + application/json: + schema: + const: true + /account/administrator/accounts: + get: + security: + - cookieAuth: [ ] + description: Get a list of accounts + parameters: + - name: name + in: query + description: Filter by name + schema: + type: string + - name: role + in: query + description: Filter by role + schema: + type: string + - name: page + in: query + description: Page of the list + schema: + $ref: "#/components/schemas/positive-integer" + - name: limit + in: query + description: A limit per page + schema: + $ref: "#/components/schemas/positive-integer" + responses: + '200': + description: A list of accounts. + content: + application/json: + schema: + type: object + properties: + accounts: + type: array + items: + $ref: "#/components/schemas/account" + role_list: + type: array + items: + type: string + pagination: + $ref: "#/components/schemas/pagination" + currentPage: + const: admin + post: + security: + - cookieAuth: [ ] + description: Change the roles of accounts + requestBody: + required: true + content: + application/json: + schema: + type: object + minProperties: 1 + properties: + moderator: + type: array + items: + $ref: "#/components/schemas/positive-integer" + consumer: + type: array + items: + $ref: "#/components/schemas/positive-integer" + responses: + '200': + description: Successfully changed account roles. + content: + application/json: + schema: + type: object + properties: + currentPage: + type: string + redirect: + type: string + /account/moderator/tasks/creator_links: + get: + security: + - cookieAuth: [ ] + description: Get a list of pending artist link requests + responses: + '200': + description: A list of pending artist link requests. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/unapproved-link" + /account/moderator/creator_link_requests/{request_id}/approve: + post: + security: + - cookieAuth: [ ] + description: Approve a new artist link. + responses: + '200': + description: Successfully approved a new artist link. + content: + application/json: + schema: + type: object + properties: + response: + const: approved + /account/moderator/creator_link_requests/{request_id}/reject: + post: + security: + - cookieAuth: [ ] + description: Reject a new artist link. + responses: + '200': + description: Successfully rejected a new artist link. + content: + application/json: + schema: + type: object + properties: + response: + const: rejected /favorites/post/{service}/{creator_id}/{post_id}: post: tags: @@ -1124,10 +2173,16 @@ paths: responses: '201': description: Flagged successfully - content: { } + content: + application/json: + schema: + const: true '409': description: Already flagged - content: { } + content: + application/json: + schema: + const: true get: tags: - Post Flagging @@ -1315,7 +2370,8 @@ paths: type: string parent_id: type: string - nullable: true + - string + - null commenter: type: string content: @@ -1347,7 +2403,133 @@ paths: added: "2023-11-14T03:09:12.275975" '404': description: No comments found. - + /artists/random: + get: + description: Get a random artist + responses: + '200': + description: A random artist. + content: + application/json: + schema: + type: object + properties: + service: + type: string + artist_id: + type: string + '404': + description: No random artst exists. + content: + application/json: + schema: + $ref: "#/components/schemas/error" + /shares: + get: + description: Get a list of shares + parameters: + - name: o + in: query + description: List's offset + schema: + $ref: "#/components/schemas/non-negative-integer" + responses: + '200': + description: A list of shares. + content: + application/json: + schema: + type: object + properties: + props: + type: object + properties: + currentPage: + const: shares + count: + $ref: "#/components/schemas/non-negative-integer" + shares: + type: array + items: + $ref: "#/components/schemas/share" + limit: + $ref: "#/components/schemas/non-negative-integer" + base: + type: object + /share/{share_id}: + get: + description: Get details of the share. + parameters: + - name: share_id + in: path + description: ID of the share. + required: true + schema: + type: string + responses: + '200': + description: Details of the share. + content: + application/json: + schema: + type: object + properties: + share_files: + type: array + share: + $ref: "#/components/schemas/share" + base: + type: object + /dms: + get: + description: Get a list of DMs. + parameters: + - name: o + in: query + description: List's offset + schema: + $ref: "#/components/schemas/non-negative-integer" + - name: q + in: query + description: Search query + schema: + type: string + responses: + '200': + description: A list of DMs. + content: + application/json: + schema: + type: object + properties: + props: + type: object + properties: + currentPage: + const: artists + count: + $ref: "#/components/schemas/non-negative-integer" + limit: + $ref: "#/components/schemas/non-negative-integer" + dms: + type: array + items: + $ref: "#/components/schemas/approved-dm" + base: + type: object + properties: + q: + type: string + /has_pending_dms: + get: + description: Check if there are pending DMs. + responses: + '200': + description: + content: + application/json: + schema: + type: boolean /app_version: get: tags: @@ -1364,8 +2546,89 @@ paths: format: hex minLength: 40 maxLength: 40 - example: 3b9cd5fab1d35316436968fe85c90ff2de0cdca0 + examples: + - 3b9cd5fab1d35316436968fe85c90ff2de0cdca0 + /importer/submit: + post: + description: Create a site import + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + session_key: + type: string + auto_import: + type: string + save_session_key: + type: string + save_dms: + type: string + channel_ids: + type: string + x-bc: + type: string + auth_id: + type: string + user_agent: + type: string + responses: + '200': + description: Succesfully added new import + content: + application/json: + schema: + type: object + properties: + import_id: + type: string + /importer/logs/{import_id}: + get: + responses: + '200': + description: Get import logs + content: + application/json: + schema: + type: array components: + parameters: + path-service: + name: service + in: path + description: The service where the creator is located + required: true + schema: + type: string + path-creator-id: + name: creator_id + in: path + description: ID of the creator + required: true + schema: + type: string + path-post-id: + name: post_id + in: path + description: ID of the post + required: true + schema: + type: string + query-q: + name: q + in: query + description: Search query + schema: + type: string + minLength: 3 + query-o: + name: o + in: query + description: Result offset, stepping of 50 is enforced + schema: + type: integer securitySchemes: cookieAuth: description: Session key that can be found in cookies after a successful login @@ -1373,6 +2636,380 @@ components: in: cookie name: session schemas: + error: + title: Error + description: Error message + type: object + properties: + error: + type: string '401': title: Unauthorized description: Unauthorized Access + non-negative-integer: + title: NonNegativeInteger + description: Integer which cannot be below zero. + type: integer + minimum: 0 + positive-integer: + title: PositiveInteger + description: Integer which is always above zero. + type: integer + minimum: 1 + artist: + title: Artist + type: object + properties: + id: + type: string + name: + type: string + service: + type: string + indexed: + type: string + updated: + type: string + public_id: + type: string + relation_id: + type: integer + tag: + title: Tag + type: object + properties: + tag: + type: string + post_count: + type: integer + share: + title: Share + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + uploader: + type: integer + added: + type: string + approved-dm: + title: ApprovedDM + description: The public visible DM. + type: object + required: + - hash + - user + - service + - content + - embed + - file + - added + - published + properties: + hash: + type: string + user: + type: string + service: + type: string + content: + type: string + embed: + type: object + file: + type: object + added: + type: string + published: + type: string + artist: + $ref: "#/components/schemas/artist" + unapproved-dm: + title: UnapprovedDM + description: The DM which is shown to the importing user. + type: object + properties: + hash: + type: string + user: + type: string + artist: + type: object + import_id: + type: string + contributor_id: + type: string + service: + type: string + content: + type: string + embed: + type: object + file: + type: object + added: + type: string + published: + type: string + post: + title: Post + type: object + properties: + id: + type: string + user: + type: string + service: + type: string + title: + type: string + content: + type: string + embed: + type: object + shared_file: + type: boolean + added: + type: string + published: + type: string + edited: + type: string + file: + type: object + attachments: + type: array + items: + type: object + post-with-fav-count: + title: Post + type: object + properties: + id: + type: string + user: + type: string + service: + type: string + title: + type: string + content: + type: string + embed: + type: object + shared_file: + type: boolean + added: + type: string + published: + type: string + edited: + type: string + file: + type: object + attachments: + type: array + items: + type: object + fav_count: + type: integer + post-revision: + title: PostRevision + type: object + properties: + revision_id: + $ref: "#/components/schemas/positive-integer" + id: + type: string + user: + type: string + service: + type: string + title: + type: string + content: + type: string + embed: + type: object + shared_file: + anyOf: + - type: boolean + - const: "0" + added: + type: string + published: + type: string + edited: + type: string + file: + type: object + attachments: + type: array + items: + type: object + size: + $ref: "#/components/schemas/non-negative-integer" + ihash: + type: string + poll: + type: object + tags: + type: array + items: + type: string + captions: + type: object + comment: + type: object + properties: + id: + type: string + post_id: + type: string + parent_id: + type: string + commenter: + type: string + service: + type: string + content: + type: string + added: + type: string + published: + type: string + deleted_at: + type: string + commenter_name: + type: string + file: + title: File + type: object + properties: + id: + type: integer + hash: + type: string + mtime: + type: string + ctime: + type: string + mime: + type: string + ext: + type: string + added: + type: string + size: + type: integer + ihash: + type: string + archive-info: + title: ArchiveInfo + type: object + required: + - file + - file_list + properties: + file: + $ref: "#/components/schemas/file" + file_list: + type: array + items: + type: string + password: + type: string + account: + type: object + properties: + id: + $ref: "#/components/schemas/positive-integer" + username: + type: string + created_at: + type: string + role: + enum: + - consumer + - moderator + - administrator + notification: + type: object + properties: + id: + $ref: "#/components/schemas/positive-integer" + created_at: + type: string + account_id: + $ref: "#/components/schemas/positive-integer" + is_seen: + type: boolean + type: + type: string + extra_info: + type: object + service-key: + type: object + properties: + id: + $ref: "#/components/schemas/positive-integer" + service: + type: string + added: + type: string + dead: + type: boolean + contributor_id: + $ref: "#/components/schemas/positive-integer" + encrypted_key: + type: string + discord_channel_ids: + type: string + pagination: + title: Pagination + description: Pagination info of a collection. + type: object + properties: + current_page: + $ref: "#/components/schemas/positive-integer" + limit: + $ref: "#/components/schemas/positive-integer" + base: + type: object + offset: + $ref: "#/components/schemas/non-negative-integer" + count: + $ref: "#/components/schemas/non-negative-integer" + current_count: + $ref: "#/components/schemas/non-negative-integer" + total_pages: + $ref: "#/components/schemas/positive-integer" + unapproved-link: + type: object + properties: + id: + $ref: "#/components/schemas/positive-integer" + from_service: + type: string + from_id: + type: string + to_service: + type: string + to_id: + type: string + reason: + type: string + requester_id: + $ref: "#/components/schemas/positive-integer" + status: + const: pending + from_creator: + type: object + to_creator: + type: object + requester: + type: object diff --git a/src/pages/api/v1/account.py b/src/pages/api/v1/account.py new file mode 100644 index 0000000..ad3c3e2 --- /dev/null +++ b/src/pages/api/v1/account.py @@ -0,0 +1,228 @@ +from typing import TypedDict, List, Literal, Optional + +from flask import Blueprint, g, make_response, jsonify, abort, request, current_app + +from src.config import Configuration +from src.lib.account import ( + is_logged_in, + load_account, + get_saved_key_import_ids, + get_saved_keys, + revoke_saved_keys, + change_password as db_change_password, +) +from src.lib.notification import count_account_notifications, get_account_notifications, set_notifications_as_seen +from src.lib.security import is_password_compromised +from src.lib.api import create_client_error_response +from src.lib.dms import approve_dms, cleanup_unapproved_dms, get_unapproved_dms, clean_dms_already_approved +from src.pages.account.types import AccountPageProps, NotificationsProps, ServiceKeysProps +from src.types.props import SuccessProps +from src.types.account.account import Account +from src.types.kemono import Unapproved_DM + + +from . import v1api_bp +from .administrator import administrator_bp +from .moderator import moderator_bp + +account_bp = Blueprint("account", __name__) + + +# check credentials for all requests for this blueprint +# so the subsequent handlers wouldn't need to check it again +@account_bp.before_request +def check_auth(): + if not is_logged_in(): + abort(401) + + account = load_account() + + if not account: + abort(401) + + +@account_bp.get("/account") +def get_account_info(): + account: Account = g.get("account") + + if Configuration().enable_notifications: + notifications_count = count_account_notifications(account.id) + else: + notifications_count = 0 + props = AccountPageProps(account=account, notifications_count=notifications_count) + + return make_response(jsonify(props=props), 200) + + +TDChangePasswordBody = TypedDict( + "TDChangePasswordBody", {"current-password": str, "new-password": str, "new-password-confirmation": str} +) + + +@account_bp.post("/account/change_password") +def post_change_password(): + body: TDChangePasswordBody = request.get_json() + current_password = body.get("current-password", "") + new_password = body.get("new-password", "").strip() + new_password_conf = body.get("new-password-confirmation", "").strip() + account: Account = load_account() + + if not new_password: + return create_client_error_response("New password cannot be empty.") + + if len(new_password) < 5: + return create_client_error_response("New password must have at least 5 characters.") + + if new_password != new_password_conf: + return create_client_error_response("New password and confirmation do not match.") + + if current_app.config.get("ENABLE_PASSWORD_VALIDATOR") and is_password_compromised(new_password): + response = create_client_error_response( + "We've detected that password was compromised in a data breach on another site. Please choose a different password." + ) + + return response + + if not db_change_password(account.id, current_password, new_password): + return create_client_error_response("Current password is invalid.") + + response = make_response(jsonify(True), 200) + + return response + + +@account_bp.get("/account/notifications") +def get_notifications(): + account: Account = g.get("account") + + if Configuration().enable_notifications: + notifications = get_account_notifications(account.id) + else: + notifications = [] + props = NotificationsProps(notifications=notifications) + + seen_notif_ids = [notification.id for notification in notifications if not notification.is_seen] + set_notifications_as_seen(seen_notif_ids) + + return make_response(jsonify(props=props), 200) + + +@account_bp.get("/account/keys") +def get_account_keys(): + account: Account = g.get("account") + + saved_keys = get_saved_keys(account.id) + props = ServiceKeysProps(service_keys=saved_keys) + + saved_session_key_import_ids = [] + for key in saved_keys: + saved_session_key_import_ids.append(get_saved_key_import_ids(key.id)) + + response = make_response( + jsonify(props=props, import_ids=saved_session_key_import_ids), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + + return response + + +class TDKeyRevokeBody(TypedDict): + revoke: list[int] + + +@account_bp.post("/account/keys") +def revoke_service_keys(): + account: Account = g.get("account") + body: TDKeyRevokeBody = request.get_json() + keys_for_revocation = body["revoke"] + + revoke_saved_keys(keys_for_revocation, account.id) + + props = SuccessProps(currentPage="account", redirect="/account/keys") + response = make_response(jsonify(props=props), 200) + + return response + + +@account_bp.get("/account/posts/upload") +def upload_post(): + account: Account = g.get("account") + + required_roles = Configuration().filehaus["required_roles"] + if len(required_roles) and account.role not in required_roles: + return create_client_error_response( + "Filehaus uploading requires elevated permissions. Please contact the administrator to change your role." + ) + + props = {"currentPage": "posts"} + response = make_response(jsonify(props), 200) + response.headers["Cache-Control"] = "s-maxage=60" + + return response + + +class TDDMPageProps(TypedDict): + account_id: int + dms: List[Unapproved_DM] + status: str + currentPage: Literal["import"] + + +@account_bp.get("/account/review_dms") +def importer_dms(): + account: Account = g.get("account") + + status = "ignored" if request.args.get("status") == "ignored" else "pending" + dms = get_unapproved_dms(account.id, request.args.get("status") == "ignored") + + props = TDDMPageProps(currentPage="import", account_id=account.id, dms=dms, status=status) + + response = make_response( + jsonify( + props, + ), + 200, + ) + response.headers["Cache-Control"] = "max-age=0, private, must-revalidate" + + return response + + +class TDApproveDMsBody(TypedDict): + approved_hashes: list[str] + delete_ignored: Optional[bool] + + +@account_bp.post("/account/review_dms") +def approve_importer_dms(): + account: Account = g.get("account") + body: TDApproveDMsBody = request.get_json() + approved_hashes = body.get("approved_hashes", []) + delete_ignored = bool(body.get("delete_ignored", False)) + approve_dms(int(account.id), approved_hashes) + clean_dms_already_approved(int(account.id)) + cleanup_unapproved_dms(int(account.id)) + + if delete_ignored: + cleanup_unapproved_dms(int(account.id), delete=True) + + response = make_response(jsonify(True), 200) + response.headers["Cache-Control"] = "max-age=0, private, must-revalidate" + + return response + + +@account_bp.errorhandler(401) +def not_authorized_error(error): + return (jsonify(error="Not Authorized"), 401) + + +# going this roundabout way because the structure expects +# a flat module list in the folder +# and I am not going to change it +account_bp.register_blueprint(administrator_bp, url_prefix="/account") +account_bp.register_blueprint(moderator_bp, url_prefix="/account") +# not sure about resolution order +# so load it in the end +v1api_bp.register_blueprint(account_bp) diff --git a/src/pages/api/v1/administrator.py b/src/pages/api/v1/administrator.py new file mode 100644 index 0000000..92636f5 --- /dev/null +++ b/src/pages/api/v1/administrator.py @@ -0,0 +1,92 @@ +from typing import TypedDict, Literal + +from flask import Blueprint, g, make_response, jsonify, abort, request + +from src.lib.pagination import Pagination +from src.lib.administrator import get_accounts, change_account_role +from src.types.account import Account, visible_roles, AccountRoleChange + +administrator_bp = Blueprint("api_administrator", __name__) + + +@administrator_bp.before_request +def check_credentials(): + account: Account = g.get("account") + + if account.role != "administrator": + return abort(404) + + +class TDAccountsProps(TypedDict): + accounts: list[Account] + role_list: list[str] + pagination: Pagination + currentPage: Literal["admin"] + + +@administrator_bp.get("/administrator/accounts") +def get_accounts_list(): + queries = request.args.to_dict() + queries["name"] = queries["name"] if queries.get("name") else None + + # transform `role` query into a list for db query + if queries.get("role") and queries["role"] != "all": + queries["role"] = [queries["role"]] + else: + queries["role"] = visible_roles + + pagination = Pagination(request) + accounts = get_accounts(pagination, queries) + props = TDAccountsProps(accounts=accounts, role_list=visible_roles, pagination=pagination, currentPage="admin") + + response = make_response( + jsonify(props), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + + return response + + +class TDAccountChangeBody(TypedDict): + moderator: list[int] + consumer: list[int] + + +class TDAccountRoleChangeProps(TypedDict): + redirect: Literal["/account/administrator/accounts"] + currentPage: Literal["admin"] + + +@administrator_bp.post("/administrator/accounts") +def change_account_roles(): + body: TDAccountChangeBody = request.get_json() + # convert ids to `int` + candidates = dict( + moderator=[int(id) for id in body.get("moderator")] if body.get("moderator") else [], + consumer=[int(id) for id in body.get("consumer")] if body.get("consumer") else [], + ) + + if candidates["moderator"]: + change_account_role( + candidates["moderator"], + AccountRoleChange( + old_role="consumer", + new_role="moderator", + ), + ) + if candidates["consumer"]: + change_account_role( + candidates["consumer"], + AccountRoleChange( + old_role="moderator", + new_role="consumer", + ), + ) + + props = TDAccountRoleChangeProps(currentPage="admin", redirect="/account/administrator/accounts") + + response = make_response(jsonify(props), 200) + response.headers["Cache-Control"] = "max-age=0, private, must-revalidate" + + return response diff --git a/src/pages/api/v1/authentication.py b/src/pages/api/v1/authentication.py new file mode 100644 index 0000000..e0c375b --- /dev/null +++ b/src/pages/api/v1/authentication.py @@ -0,0 +1,122 @@ +import re +from json import JSONDecodeError +from typing import TypedDict + +import orjson +from flask import Blueprint, request, current_app, make_response, jsonify, g, session + +from src.lib.account import attempt_login, create_account +from src.lib.api import create_client_error_response +from src.lib.security import is_password_compromised +from src.types.account import Account + +from . import v1api_bp + +# it is in a separate file because +# all account routes require auth + +authentication_bp = Blueprint("authentication", __name__) + + +class TDRegistrationBody(TypedDict): + location: str + username: str + password: str + confirm_password: str + favorites_json: str + + +USERNAME_REGEX = re.compile(r"^[a-z0-9_@+.\-]{3,15}$") + + +@authentication_bp.post("/authentication/register") +def post_register(): + body: TDRegistrationBody = request.get_json() + username = body.get("username", "").replace("\x00", "").strip() + password = body.get("password", "").strip() + confirm_password = body.get("confirm_password", "").strip() + favorites_json = body.get("favorites", "[]") + + favorites = [] + if favorites_json != "": + try: + favorites = orjson.loads(favorites_json) + except JSONDecodeError: + pass + + if username == "": + return create_client_error_response("Username cannot be empty") + + if not USERNAME_REGEX.match(username): + return create_client_error_response("Invalid username") + + if password == "": + return create_client_error_response("Password cannot be empty") + + if len(password) < 5: + return create_client_error_response("Password must have at least 5 characters.") + + if password != confirm_password: + return create_client_error_response("Passwords do not match") + + if current_app.config.get("ENABLE_PASSWORD_VALIDATOR") and is_password_compromised(password): + return create_client_error_response( + "We've detected that password was compromised in a data breach on another site. Please choose a different password." + ) + + success = create_account(username, password, favorites) + + if not success: + return create_client_error_response("Username already taken") + + response = make_response(jsonify(True), 200) + + return response + + +class TDLoginBody(TypedDict): + username: str + password: str + + +@authentication_bp.post("/authentication/login") +def post_login(): + body: TDLoginBody = request.get_json() + + account: Account | None = g.get("account") + + if account: + return create_client_error_response("Already logged in", 409) + + username = body.get("username", "").replace("\x00", "") + password = body.get("password", "") + + if not username: + return create_client_error_response("Username is required.") + + if not password: + return create_client_error_response("Password is required.") + + (account, error_message) = attempt_login(username, password) + + if error_message: + return create_client_error_response(error_message) + + if not account: + return create_client_error_response("Account doesn't exist") + + response = make_response(jsonify(account), 200) + + return response + + +@authentication_bp.post("/authentication/logout") +def logout(): + if "account_id" in session: + session.pop("account_id") + response = make_response(jsonify(True), 200) + + return response + + +v1api_bp.register_blueprint(authentication_bp) diff --git a/src/pages/api/v1/comments.py b/src/pages/api/v1/comments.py index 3577308..c40bb57 100644 --- a/src/pages/api/v1/comments.py +++ b/src/pages/api/v1/comments.py @@ -1,6 +1,7 @@ from flask import jsonify, make_response from src.lib.post import get_post_comments +from src.lib.api import create_not_found_error_response from src.pages.api.v1 import v1api_bp @@ -9,10 +10,12 @@ def get_comments(service: str, creator_id: str, post_id: str): comments = get_post_comments(post_id, service) if not comments: - response = make_response(jsonify({"error": "Not found"}), 404) + response = create_not_found_error_response("No comments found.") response.headers["Cache-Control"] = "s-maxage=600" + return response response = make_response(jsonify(comments), 200) response.headers["Cache-Control"] = "s-maxage=600" + return response diff --git a/src/pages/api/v1/creators.py b/src/pages/api/v1/creators.py index e86f19a..02739e6 100644 --- a/src/pages/api/v1/creators.py +++ b/src/pages/api/v1/creators.py @@ -1,11 +1,74 @@ -from flask import jsonify, make_response +import random +from typing import TypedDict, Optional, Literal +from flask import jsonify, make_response, redirect, url_for, request, session, abort from src.internals.database.database import query_db from src.lib.announcements import get_artist_announcements -from src.lib.artist import get_artist, get_fancards_by_artist, get_linked_creators +from src.lib.artist import ( + get_artist, + get_fancards_by_artist, + get_linked_creators, + get_artists_by_update_time, + delete_creator_link, + create_unapproved_link_request, + get_random_artist_keys, + TDArtist +) +from src.lib.filehaus import get_artist_share_count, get_artist_shares +from src.lib.post import get_artist_post_count, get_artist_posts_summary, get_all_posts_by_artist, get_render_data_for_posts, get_fileserver_for_value +from src.lib.posts import get_all_tags, count_all_posts_for_tag, get_tagged_posts +from src.lib.dms import count_user_dms, get_artist_dms +from src.lib.api import create_client_error_response, create_not_found_error_response +from src.utils.utils import parse_int, positive_or_none, step_int, take, sort_dict_list_by, offset_list +from src.utils.decorators import require_login +from src.pages.artists_types import ArtistShareProps, ArtistDisplayData, ArtistDMsProps, LinkedAccountsProps +from src.types.account.account import Account +from src.types.paysites import Paysite, Paysites + from src.pages.api.v1 import v1api_bp +@v1api_bp.get("/artists/updated") +def updated(): + base = dict(commit=True, sort_by="updated") + limit = 50 + + results = get_artists_by_update_time(offset=0, limit=limit) + props = dict( + currentPage="artists", + display="cached updated artists", + count=len(results), + limit=limit, + ) + + response = make_response(jsonify(props=props, results=results, base=base), 200) + response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" + + return response + + +@v1api_bp.get("/artists/random") +def random_artist(): + """todo decide after random posts with redis list if its worth""" + artist = get_random_artist() + + if artist is None: + response = make_response(jsonify(error="No artist found"), 404) + + return response + + response = make_response(jsonify(service=artist["service"], artist_id=artist["id"]), 200) + + return response + + +def get_random_artist(): + artists = get_random_artist_keys(1000) + if len(artists) == 0: + return None + return random.choice(artists) + + @v1api_bp.get("/creators") def all_creators(): """this view must be cached at nginx/cdn level""" @@ -16,15 +79,11 @@ def all_creators(): l.service, EXTRACT(epoch from l.indexed)::int AS indexed, EXTRACT(epoch from l.updated)::int AS updated, - COALESCE(aaf.favorited, 0) AS favorited + COALESCE(aaf.favorite_count, 0) AS favorited FROM lookup l LEFT JOIN ( - SELECT - artist_id, - service, - COUNT(*) AS favorited - FROM account_artist_favorite - GROUP BY artist_id, service + SELECT * + FROM favorite_counts ) aaf ON l.id = aaf.artist_id AND l.service = aaf.service @@ -49,6 +108,107 @@ def get_creator(service, creator_id): return response +class TDProfileDisplayData(TypedDict): + service: str + href: str + + +class TDProfilePostsProps(TypedDict): + currentPage: Literal["posts"] + id: str + service: str + name: str + count: int + limit: int + artist: TDArtist + display_data: TDProfileDisplayData + dm_count: int + share_count: int + has_links: str + + +@v1api_bp.get("//user//posts-legacy") +def get_profile_posts_legacy(service: str, creator_id: str): + if not service: + return create_client_error_response("Service name is required.") + + if not creator_id: + return create_client_error_response("Profile ID is required.") + + if service == "discord": + return create_client_error_response("Discord servers not allowed.") + + artist = get_artist(service, creator_id) + + if not artist: + return create_not_found_error_response() + + if artist["public_id"] == creator_id and artist["id"] != creator_id: + return create_client_error_response("Something something profile ID mismatch.") + + query = request.args.get("q", default="").strip() + tags = sorted(request.args.getlist("tag")) + limit = 50 + offset = positive_or_none(step_int(parse_int(request.args.get("o"), 0), limit)) + + if offset is None: + return create_client_error_response(f"Offset is not a multiple of {limit}.") + + if tags: + posts = get_tagged_posts(tags, offset, limit, service, creator_id) + total_count = count_all_posts_for_tag(tags, service, creator_id) + elif not query or len(query) < 2: + total_count = get_artist_post_count(service, creator_id) + + if offset > total_count: + return create_client_error_response(f"Offset {offset} is bigger than total count {total_count}.") + else: + posts = get_artist_posts_summary(creator_id, service, offset, limit, "published DESC NULLS LAST") + else: + (posts, total_count) = do_artist_post_search(creator_id, service, query, offset, limit) + + ( + result_previews, + result_attachments, + result_is_image, + ) = get_render_data_for_posts(posts) + + base = request.args.to_dict() + base.pop("o", None) + base["service"] = service + base["artist_id"] = creator_id + + props = TDProfilePostsProps( + currentPage="posts", + id=creator_id, + service=service, + name=artist["name"], + count=total_count, + limit=limit, + artist=artist, + display_data=make_artist_display_data(artist), + dm_count=count_user_dms(service, creator_id), + share_count=get_artist_share_count(service, creator_id), + has_links="✔️" if artist["relation_id"] else "0", + ) + + response = make_response( + jsonify( + props=props, + base=base, + results=posts, + result_previews=result_previews, + result_attachments=result_attachments, + result_is_image=result_is_image, + disable_service_icons=True, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + + return response + + @v1api_bp.get("//user//announcements") def get_announcements(service, creator_id): artist = get_artist(service, creator_id) @@ -65,13 +225,24 @@ def get_announcements(service, creator_id): @v1api_bp.get("//user//fancards") def get_fancards(service, creator_id): artist = get_artist(service, creator_id) + if not artist: response = make_response(jsonify({"error": "Artist not found."}), 404) response.headers["Cache-Control"] = "s-maxage=600" + return response + fancards = get_fancards_by_artist(creator_id, reload=True) + + for fancard in fancards: + fhash = fancard["hash"] + ext = fancard["ext"] + fancard["path"] = f"/data/{fhash[0:2]}/{fhash[2:4]}/{fhash}{ext}" + fancard["server"] = get_fileserver_for_value(fancard["path"]) + response = make_response(jsonify(fancards), 200) response.headers["Cache-Control"] = "s-maxage=600" + return response @@ -82,3 +253,260 @@ def get_linked_accounts(service, creator_id): response = make_response(jsonify(links), 200) response.headers["Cache-Control"] = "s-maxage=600" return response + + +@v1api_bp.delete("//user//links") +@require_login +def delete_linked_account(service: str, creator_id: str, user: Account): + if user.role != "administrator": + abort(404) + else: + delete_creator_link(service, creator_id) + return "", 204 + + +@v1api_bp.get("//user//links/new") +@require_login +def get_new_link_page(service: str, artist_id: str, user: Account): + artist = get_artist(service, artist_id) + if not artist: + response = make_response(jsonify({"error": "Artist not found."}), 404) + response.headers["Cache-Control"] = "s-maxage=600" + + return response + elif artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for(".get_new_link_page", service=service, artist_id=artist["id"]), code=301) + + base = dict(service=service, artist_id=artist_id) + props = LinkedAccountsProps( + id=artist_id, + service=service, + artist=artist, + share_count=get_artist_share_count(service, artist_id), + dm_count=count_user_dms(service, artist_id), + has_links="✔️" if artist["relation_id"] else "0", + display_data=make_artist_display_data(artist), + ) + + response = make_response( + jsonify( + props=props, + base=base, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=600" + + return response + + +class TDArtistLinkRequest(TypedDict): + service: str + artist_id: str + reason: Optional[str] + + +@v1api_bp.post("//user//links/new") +@require_login +def post_new_link_page(service: str, artist_id: str, user: Account): + body: TDArtistLinkRequest = request.get_json() + + dest_service = body["service"] + dest_artist_id = body["artist_id"] + reason = body["reason"] or "" + + from_artist = get_artist(service, artist_id) + to_artist = get_artist(dest_service, dest_artist_id) + + if not from_artist: + response = make_response(jsonify({"error": "Artist not found."}), 404) + response.headers["Cache-Control"] = "s-maxage=600" + + return response + elif from_artist["public_id"] == artist_id and from_artist["id"] != artist_id: + return redirect(url_for(".post_new_link_page", service=service, artist_id=from_artist["id"]), code=301) + + base = dict(service=service, artist_id=artist_id) + props = LinkedAccountsProps( + id=artist_id, + service=service, + artist=from_artist, + share_count=get_artist_share_count(service, artist_id), + dm_count=count_user_dms(service, artist_id), + has_links="✔️" if from_artist["relation_id"] else "0", + display_data=make_artist_display_data(from_artist), + ) + + if not to_artist: + message = f"Invalid creator (svc: {dest_service}, id: {dest_artist_id})" + response = make_response(jsonify(error=message), 400) + + return response + + if len(reason) > 140: + message = "Reason is too long" + response = make_response(jsonify(error=message), 400) + + return response + + if dest_service == service and dest_artist_id == artist_id: + message = "Can't link an artist to themself" + response = make_response(jsonify(error=message), 400) + + return response + + if from_artist["relation_id"] == to_artist["relation_id"] and from_artist["relation_id"] is not None: + message = "Already linked" + response = make_response(jsonify(error=message), 400) + + return response + + create_unapproved_link_request(from_artist, to_artist, user.id, reason) + + response = make_response( + jsonify( + message="Request created. It will be shown here when approved.", + props=props, + base=base, + ), + 200, + ) + + return response + + +@v1api_bp.get("//user//tags") +def get_tags(service: str, artist_id: str): + artist = get_artist(service, artist_id) + if not artist: + response = make_response(jsonify({"error": "Artist not found."}), 404) + response.headers["Cache-Control"] = "s-maxage=600" + + return response + elif artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for(".get_tags", service=service, artist_id=artist["id"]), code=301) + + tags = get_all_tags(service, artist_id) + props = dict( + display_data=make_artist_display_data(artist), + artist=artist, + service=service, + id=artist["id"], + share_count=get_artist_share_count(service, artist_id), + dm_count=count_user_dms(service, artist_id), + has_links="✔️" if artist["relation_id"] else "0", + ) + response = make_response( + jsonify( + props=props, + tags=tags, + service=service, + artist=artist, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=600" + + return response + + +@v1api_bp.route("//user//shares") +def get_shares(service: str, artist_id: str): + base = request.args.to_dict() + base.pop("o", None) + base["service"] = service + base["artist_id"] = artist_id + + dm_count = count_user_dms(service, artist_id) + shares = get_artist_shares(artist_id, service) + + artist = get_artist(service, artist_id) + if artist is None: + response = make_response(jsonify({"error": "Artist not found."}), 404) + response.headers["Cache-Control"] = "s-maxage=600" + + return response + elif artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for(".get_shares", service=service, artist_id=artist["id"]), code=301) + + props = ArtistShareProps( + display_data=make_artist_display_data(artist), + service=service, + artist=artist, + id=artist_id, + dm_count=dm_count, + share_count=len(shares), + has_links="✔️" if artist["relation_id"] else "0", + ) + + response = make_response( + jsonify(results=shares, props=props, base=base), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + + return response + + +@v1api_bp.route("//user//dms") +def get_dms(service: str, artist_id: str): + + artist = get_artist(service, artist_id) + + if artist is None: + response = make_response(jsonify({"error": "Artist not found."}), 404) + response.headers["Cache-Control"] = "s-maxage=600" + + return response + elif artist["public_id"] == artist_id and artist["id"] != artist_id: + return redirect(url_for(".get_dms", service=service, artist_id=artist["id"]), code=301) + + dms = get_artist_dms(service, artist_id) + + props = ArtistDMsProps( + id=artist_id, + service=service, + artist=artist, + display_data=make_artist_display_data(artist), + share_count=get_artist_share_count(service, artist_id), + dm_count=len(dms), + dms=dms, + has_links="✔️" if artist["relation_id"] else "0", + ) + + response = make_response( + jsonify( + props=props, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + + return response + + +def make_artist_display_data(artist: dict) -> ArtistDisplayData: + service_name: str = artist["service"] + pay_site: Paysite | None = getattr(Paysites, service_name, None) + + if pay_site: + return ArtistDisplayData(service=pay_site.title, href=pay_site.user.profile(artist)) + raise Exception("Service not found in Paysites") + + +def do_artist_post_search(artist_id: str, service: str, search: str, o: int, limit: int): + posts = get_all_posts_by_artist(artist_id, service) + search = search.lower() + + matches = [] + for post in posts: + if ( + search in post["content"].lower() + or search in post["title"].lower() + or search in " ".join(post["tags"] or []).lower() + ): + matches.append(post) + + matches = sort_dict_list_by(matches, "published", True) + + return take(limit, offset_list(o, matches)), len(matches) diff --git a/src/pages/api/v1/dms.py b/src/pages/api/v1/dms.py index cf3c876..c7ff59d 100644 --- a/src/pages/api/v1/dms.py +++ b/src/pages/api/v1/dms.py @@ -1,9 +1,72 @@ -from flask import jsonify, make_response, session +from typing import TypedDict, Literal, List + +from flask import request, jsonify, make_response, session + +from src.config import Configuration +from src.lib.dms import ( + has_unapproved_dms, + get_all_dms, + get_all_dms_by_query, + get_all_dms_by_query_count, + get_all_dms_count, +) +from src.lib.api import create_client_error_response +from src.utils.utils import get_query_parameters_dict, parse_int, positive_or_none, step_int +from src.types.kemono import Approved_DM -from src.lib.dms import has_unapproved_dms from src.pages.api.v1 import v1api_bp +class TDDMsProps(TypedDict): + currentPage: Literal["artists"] + count: int + limit: int + dms: List[Approved_DM] + + +@v1api_bp.get("/dms") +def get_api_dms(): + base = get_query_parameters_dict(request, on_errors="ignore", clean_query_string=True) + + limit = 50 + max_offset = limit * 1000 # only load 1000 pages of any result + offset = positive_or_none(step_int(parse_int(base.pop("o", 0), 0), limit)) + + if offset is None or offset > max_offset: + return create_client_error_response("Offset is bigger than maximum offset.") + + query = base.get("q", "").strip()[: Configuration().webserver["max_full_text_search_input_len"]] + + if not query or len(query) < 3: + total_count = get_all_dms_count() + + if offset > total_count: + return create_client_error_response("Offset is bigger than the total count.") + + dms = get_all_dms(offset, limit) + + else: + total_count = get_all_dms_by_query_count(query) + + if offset > total_count: + return create_client_error_response("Offset is bigger than the total count.") + + dms = get_all_dms_by_query(query, offset, limit) + + props = TDDMsProps(currentPage="artists", count=total_count, limit=limit, dms=dms) + + response = make_response( + jsonify( + props=props, + base=base, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + + return response + + @v1api_bp.get("/has_pending_dms") def get_has_pending_dms(): has_pending_dms = False diff --git a/src/pages/api/v1/favorites.py b/src/pages/api/v1/favorites.py index 9631860..4dec1c2 100644 --- a/src/pages/api/v1/favorites.py +++ b/src/pages/api/v1/favorites.py @@ -31,25 +31,33 @@ def list_account_favorites(user: Account): @require_login def post_favorite_post(service, creator_id, post_id, user: Account): add_favorite_post(user.id, service, creator_id, post_id) - return "", 204 + response = make_response(jsonify(True), 200) + + return response @v1api_bp.route("/favorites/creator//", methods=["POST"]) @require_login def post_favorite_artist(service, creator_id, user: Account): add_favorite_artist(user.id, service, creator_id) - return "", 204 + response = make_response(jsonify(True), 200) + + return response @v1api_bp.route("/favorites/post///", methods=["DELETE"]) @require_login def delete_favorite_post(service, creator_id, post_id, user: Account): remove_favorite_post(user.id, service, creator_id, post_id) - return "", 204 + response = make_response(jsonify(True), 200) + + return response @v1api_bp.route("/favorites/creator//", methods=["DELETE"]) @require_login def delete_favorite_artist(service, creator_id, user: Account): remove_favorite_artist(user.id, service, creator_id) - return "", 204 + response = make_response(jsonify(True), 200) + + return response diff --git a/src/pages/api/v1/files.py b/src/pages/api/v1/files.py index 620d6aa..d30cf68 100644 --- a/src/pages/api/v1/files.py +++ b/src/pages/api/v1/files.py @@ -2,22 +2,28 @@ from flask import jsonify, make_response, request from src.config import Configuration from src.lib.files import get_file_relationships, try_set_password +from src.utils.utils import get_query_parameters_dict, parse_int, positive_or_none, step_int +from src.lib.filehaus import get_all_shares_count, get_files_for_share, get_share, get_shares +from src.lib.api import create_not_found_error_response, create_client_error_response from src.pages.api.v1 import v1api_bp -from src.utils.utils import get_query_parameters_dict @v1api_bp.get("/search_hash/") def lookup_file(file_hash): if not (len(file_hash) == 64 and all(c in "0123456789abcdefABCDEF" for c in file_hash)): - response = make_response(jsonify({"error": "Invalid SHA256 hash"}), 400) - return response - if not (file := get_file_relationships(file_hash)): - response = make_response("{}", 404) + return create_client_error_response("Invalid SHA256 hash") + + file = get_file_relationships(file_hash) + + if not (file): + response = create_not_found_error_response() response.headers["Cache-Control"] = "s-maxage=600" + return response response = make_response(jsonify(file), 200) response.headers["Cache-Control"] = "s-maxage=600" + return response @@ -31,3 +37,61 @@ def set_password(): if not file_hash or not passwords or not try_set_password(file_hash, passwords): return "false" return "true" + + +@v1api_bp.route("/shares") +def get_shares_data(): + base = request.args.to_dict() + base.pop("o", None) + + limit = 50 + offset = positive_or_none(step_int(parse_int(request.args.get("o"), 0), limit)) + # query = request.args.get('q') + + shares = None + total_count = None + (shares, total_count) = get_share_page(offset, limit) + + props = dict(currentPage="shares", count=total_count, shares=shares, limit=limit) + + response = make_response( + jsonify( + props=props, + base=base, + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + + return response + + +def get_share_page(offset: int, limit: int): + posts = get_shares(offset, limit) + total_count = get_all_shares_count() + + return posts, total_count + + +@v1api_bp.get("/share/") +def get_share_handler(share_id: str): + base = request.args.to_dict() + base.pop("o", None) + + if (not share_id.isdigit()): + return create_client_error_response("Invalid share ID.") + + share = get_share(int(share_id)) + + if share is None: + return create_not_found_error_response("Share not found.") + + share_files = get_files_for_share(share["id"]) + + response = make_response( + jsonify(share_files=share_files, share=share, base=base), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=60" + + return response diff --git a/src/pages/api/v1/flags.py b/src/pages/api/v1/flags.py index df045f2..3fe5ba0 100644 --- a/src/pages/api/v1/flags.py +++ b/src/pages/api/v1/flags.py @@ -1,3 +1,5 @@ +from flask import make_response, jsonify + from src.config import Configuration from src.internals.cache.redis import get_conn from src.internals.database.database import query_db @@ -21,10 +23,16 @@ def flag_post_api(service, creator_id, post): get_conn().set( f"is_post_flagged:{service}:{creator_id}:{post}", len(rows_returned), ex=Configuration().redis["default_ttl"] ) + status_code = 201 if len(rows_returned) else 409 + response = make_response(jsonify(True), status_code) - return "", (201 if len(rows_returned) else 409) + return response @v1api_bp.route("/user//post//flag", methods=["GET"]) def flag_api(service, creator_id, post): - return "", 200 if is_post_flagged(service, creator_id, post) else 404 + is_flagged = is_post_flagged(service, creator_id, post) + status_code = 200 if is_flagged else 404 + response = make_response(jsonify(True), status_code) + + return response diff --git a/src/pages/api/v1/importer.py b/src/pages/api/v1/importer.py index 8cce42a..031671f 100644 --- a/src/pages/api/v1/importer.py +++ b/src/pages/api/v1/importer.py @@ -1,63 +1,128 @@ import base64 import logging import re +from typing import TypedDict, Union, Literal, NotRequired import orjson -from flask import current_app, make_response, render_template, request, session +from flask import make_response, request, session, jsonify from src.config import Configuration from src.internals.cache.redis import get_conn from src.internals.database.database import query_db, query_one_db from src.lib.imports import validate_import_key +from src.lib.api import create_client_error_response from src.pages.api.v1 import v1api_bp -from src.types.props import SuccessProps + +TDOnlyFansImportCreateBody = TypedDict( + "TDOnlyFansImportCreateBody", + { + "service": Literal["onlyfans"], + "session_key": str, + "auto_import": str | int | None, + "save_session_key": str | int | None, + "x-bc": str, + "auth_id": str, + "user_agent": str, + }, +) + + +class TDPatreonImportCreateBody(TypedDict): + service: Literal["patreon"] + session_key: str + auto_import: str | int | None + save_session_key: str | int | None + save_dms: NotRequired[bool] + + +class TDDiscordImportCreateBody(TypedDict): + service: Literal["discord"] + session_key: str + auto_import: str | int | None + save_session_key: str | int | None + channel_ids: str @v1api_bp.post("/importer/submit") def importer_submit(): - if not session.get("account_id") and request.form.get("save_dms") and request.form.get("service") == "patreon": - return "You must be logged in to import direct messages.", 401 - - if not request.form.get("session_key"): - return "Session key missing.", 401 - - key = request.form.get("session_key").strip().strip("\" \t'") - if request.form.get("service") == "onlyfans": - key = base64.b64encode( - orjson.dumps( - { - "sess": key, - "x-bc": request.form.get("x-bc").strip().strip("\" \t'"), - "auth_id": request.form.get("auth_id").strip().strip("\" \t'"), - "auth_uid_": "None", - "user_agent": request.form.get("user_agent").strip().strip("\" \t'"), - } - ) - ).decode() - result = validate_import_key(key, request.form.get("service")) + """ + TODO: split into per-service endpoints + """ + body: Union[TDOnlyFansImportCreateBody, TDPatreonImportCreateBody, TDDiscordImportCreateBody] = request.get_json() + account_id = session.get("account_id") + session_key = body.get("session_key") + auto_import = body.get("auto_import") + save_session_key = body.get("save_session_key") + country = request.headers.get(Configuration().webserver["country_header_key"]) + user_agent = request.headers.get("User-Agent") + save_dms = None + key = session_key.strip().strip("\" \t'") discord_channels = None - if (input_channels := request.form.get("channel_ids")) and request.form.get("service") == "discord": + result = None + + if not session_key: + return create_client_error_response("Session key missing.", 401) + + if not body.get("service"): + return create_client_error_response("Service is required.", 400) + + # per service validation + if body["service"] == "patreon": + save_dms = body.get("save_dms") + + if not account_id and save_dms: + return create_client_error_response("You must be logged in to import direct messages.", 401) + + elif body["service"] == "onlyfans": + xBC = body["x-bc"].strip().strip("\" \t'") + auth_id = body["auth_id"].strip().strip("\" \t'") + of_user_agent = body["user_agent"].strip().strip("\" \t'") + key_dict = { + "sess": key, + "x-bc": xBC, + "auth_id": auth_id, + "auth_uid_": "None", + "user_agent": of_user_agent, + } + key = base64.b64encode(orjson.dumps(key_dict)).decode() + + elif body["service"] == "discord": + channel_ids = body["channel_ids"] regex = r"https://discord\.com/channels/\d+/(?P\d+)" - input_channels = [ - re.match(regex, item).group("ch") if re.match(regex, item) else item for item in input_channels.split(",") + + if not channel_ids: + return create_client_error_response("Channel IDs is required.") + + temp_input_channels = [ + re.match(regex, item).group("ch") if re.match(regex, item) else item for item in channel_ids.split(",") ] - discord_channels = list(s.strip() for s in re.split(r"[\s,.、。/']", ",".join(input_channels)) if s.strip()) + + discord_channels = list( + s.strip() for s in re.split(r"[\s,.、。/']", ",".join(temp_input_channels)) if s.strip() + ) + if any(not s.isdigit() for s in discord_channels): msg = "Discord channel ids are numbers, the last number of the url (notice the / between the 2 numbers)" - logging.exception(msg, extra=dict(input_channels=input_channels, discord_channels=discord_channels)) - return msg, 422 + logging.exception(msg, extra=dict(input_channels=channel_ids, discord_channels=discord_channels)) + + return create_client_error_response(msg, 422) + if not discord_channels: msg = "Discord submit requires channels" - logging.exception(msg, extra=dict(input_channels=input_channels, discord_channels=discord_channels)) - return msg, 422 + logging.exception(msg, extra=dict(input_channels=channel_ids, discord_channels=discord_channels)) + + return create_client_error_response(msg, 422) + discord_channels = ",".join(discord_channels) + result = validate_import_key(key, body["service"]) + if not result.is_valid: return "\n".join(result.errors), 422 formatted_key = result.modified_result if result.modified_result else key - service = request.form.get("service") + service = body["service"] queue_name = f"import:{service}" existing_imports = query_db( @@ -68,44 +133,40 @@ def importer_submit(): AND queue_name = %s AND job_input ->> 'key' = %s """, - (queue_name, formatted_key) + (queue_name, formatted_key), ) if existing_imports: existing_import = existing_imports[0]["job_id"] - props = SuccessProps( - message="This key is already being used for an import. Redirecting to logs...", - currentPage="import", - redirect=f"/importer/status/{existing_import}{"?dms=1" if request.form.get("save_dms") else ""}", - ) + response = make_response(jsonify(import_id=existing_import), 200) - return make_response(render_template("success.html", props=props), 200) + return response data = dict( key=formatted_key, service=service, channel_ids=discord_channels, - auto_import=request.form.get("auto_import"), - save_session_key=request.form.get("save_session_key"), - save_dms=request.form.get("save_dms"), - contributor_id=session.get("account_id"), + auto_import=auto_import, + save_session_key=save_session_key, + save_dms=save_dms, + contributor_id=account_id, priority=1, - country=request.headers.get(Configuration().webserver["country_header_key"]), - user_agent=request.headers.get("User-Agent"), + country=country, + user_agent=user_agent, ) query = b""" - INSERT INTO jobs (queue_name, priority, job_input) - VALUES (%s, %s, %s) - RETURNING job_id; + INSERT INTO jobs + (queue_name, priority, job_input) + VALUES + (%s, %s, %s) + RETURNING + job_id; """ import_id = query_one_db(query, (queue_name, 1, orjson.dumps(data).decode()))["job_id"] - props = SuccessProps( - currentPage="import", - redirect=f"/importer/status/{import_id}{"?dms=1" if request.form.get("save_dms") else ""}", - ) + response = make_response(jsonify(import_id=import_id), 200) - return make_response(render_template("success.html", props=props), 200) + return response @v1api_bp.route("/importer/logs/") diff --git a/src/pages/api/v1/moderator.py b/src/pages/api/v1/moderator.py new file mode 100644 index 0000000..7f67e3e --- /dev/null +++ b/src/pages/api/v1/moderator.py @@ -0,0 +1,40 @@ +from flask import Blueprint, g, make_response, jsonify, abort, request + +from src.lib.artist import get_unapproved_links_with_artists +from src.lib.artist import approve_unapproved_link_request, reject_unapproved_link_request +from src.types.account import Account + +moderator_bp = Blueprint("administrator", __name__) + + +@moderator_bp.before_request +def check_credentials(): + account: Account = g.get("account") + + if account.role != "moderator" and account.role != "administrator": + return abort(code=404) + + +@moderator_bp.get("/moderator/tasks/creator_links") +def get_creator_links(): + links = get_unapproved_links_with_artists() + + response = make_response(jsonify(links), 200) + + return response + + +@moderator_bp.post("/moderator/creator_link_requests//approve") +def approve_request(request_id: int): + approve_unapproved_link_request(request_id) + response = make_response(jsonify({"response": "approved"}), 200) + + return response + + +@moderator_bp.post("/moderator/creator_link_requests//reject") +def reject_request(request_id: int): + reject_unapproved_link_request(request_id) + response = make_response(jsonify({"response": "rejected"}), 200) + + return response diff --git a/src/pages/api/v1/posts.py b/src/pages/api/v1/posts.py index 7af06cc..d3a9ec2 100644 --- a/src/pages/api/v1/posts.py +++ b/src/pages/api/v1/posts.py @@ -1,11 +1,59 @@ -from flask import jsonify, make_response, request +import datetime +import json +import re +import logging +from typing import cast, get_args, TypedDict +from pathlib import PurePath + +import dateutil.parser +from flask import jsonify, make_response, request, url_for, redirect from src.config import Configuration -from src.lib.post import get_artist_posts_full, get_post, get_post_revisions -from src.lib.posts import get_all_posts_for_query, get_all_posts_summary -from src.pages.api.v1 import v1api_bp +from src.lib.artist import get_artist +from src.lib.post import ( + get_artist_posts_full, + get_post, + get_post_revisions, + get_random_post_key, + get_post_by_id, + get_render_data_for_posts, + get_fileserver_for_value, + get_posts_incomplete_rewards, + is_post_flagged, + patch_inline_img, + TDPostRevision +) +from src.lib.posts import get_all_posts_for_query, get_all_posts_summary, get_popular_posts_for_date_range, get_all_tags, count_all_posts, Post, count_all_posts_for_query, get_tagged_posts, count_all_posts_for_tag +from src.lib.files import get_archive_files +from src.lib.api import create_not_found_error_response, create_client_error_response from src.pages.artists import do_artist_post_search -from src.utils.utils import get_query_parameters_dict, parse_int, positive_or_none, step_int +from src.pages.post import ready_post_props +from src.utils.datetime_ import PeriodScale, parse_scale_string +from src.utils.utils import get_query_parameters_dict, parse_int, positive_or_none, step_int, set_query_parameter, images_pattern, sanitize_html, limit_int + +from src.pages.api.v1 import v1api_bp + +@v1api_bp.get("//post/") +def get_by_id(service, post_id): + post = get_post_by_id(post_id, service) + + if not post: + message = "No post found" + response = make_response(jsonify(error=message), 404) + + return response + + response = make_response( + jsonify( + service=post["service"], + artist_id=post["user"], + post_id=post["id"], + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=86400" + + return response @v1api_bp.get("//user//posts") @@ -18,33 +66,84 @@ def list_posts_api(service, creator_id): return response query = request.args.get("q", default="").strip() + if not query or len(query) < 2: posts = get_artist_posts_full(creator_id, service, offset, limit, "published DESC NULLS LAST") else: (posts, total_count) = do_artist_post_search(creator_id, service, query, offset, limit) response = make_response(jsonify(posts), 200) + return response +video_extensions = Configuration().webserver["ui"]["video_extensions"] @v1api_bp.get("//user//post/") def get_post_api(service, creator_id, post_id): post = get_post(service, creator_id, post_id) + if not post or post["user"] != creator_id: - response = make_response(jsonify({"error": "Not Found"}), 404) - return response - response = make_response(jsonify(post), 200) + return create_not_found_error_response() + + attachments, previews, videos, props = ready_post_props_light(post) + response = make_response( + jsonify( + post=post, + attachments=attachments, + previews=previews, + videos=videos, + props=props + ), + 200 + ) + response.headers["Cache-Control"] = "s-maxage=60" + return response + + @v1api_bp.get("//user//post//revisions") def list_post_revision_api(service, creator_id, post_id): revisions = get_post_revisions(service, creator_id, post_id) response = make_response(jsonify(revisions), 200 if revisions else 404) response.headers["Cache-Control"] = "max-age=600" + + return response + +@v1api_bp.route("//user//post//revision/") +def get_post_revision(service: str, artist_id: str, post_id: str, revision_id: str): + revisions = get_post_revisions(service, artist_id, post_id) if revision_id.isdigit() else [] + revision = next((rev for rev in revisions if rev["revision_id"] == int(revision_id)), None) + + if not revision or not ( + service == revision["service"] and artist_id == revision["user"] and post_id == revision["id"] + ): + message = "No post revision found" + response = make_response(jsonify(error=message), 404) + + return response + + attachments, comments, previews, videos, props = ready_post_props(revision) + props["currentPage"] = "revisions" + + response = make_response( + jsonify( + props=props, + post=revision, + comments=comments, + result_previews=previews, + result_attachments=attachments, + videos=videos, + archives_enabled=Configuration().archive_server["enabled"], + ), + 200, + ) + response.headers["Cache-Control"] = "s-maxage=600" + return response -@v1api_bp.route("/posts") +@v1api_bp.get("/posts") def recent(): limit = 50 query_params = get_query_parameters_dict(request, on_errors="ignore", clean_query_string=True) @@ -52,17 +151,31 @@ def recent(): extra_pages = Configuration().webserver["extra_pages_to_load_on_posts"] max_offset = limit * 1000 # only load 1000 pages of any result query = query_params.get("q", "").strip()[: Configuration().webserver["max_full_text_search_input_len"]] + tags = request.args.getlist("tag") o = query_params.pop("o", 0) offset = positive_or_none(step_int(parse_int(o, 0), limit)) + if offset is None or offset > max_offset: - response = make_response(jsonify({"error": "offset not multiple of 150 or too large"}), 400) - return response + return create_client_error_response("offset not multiple of 150 or too large") + extra_offset = positive_or_none(step_int(parse_int(o, 0), limit * extra_pages)) slice_offset = offset - extra_offset - if not query or len(query) < 2: - extra_results = get_all_posts_summary(extra_offset, limit * extra_pages, cache_ttl=Configuration().cache_ttl_for_recent_posts)[slice_offset : limit + slice_offset] - # true_count = count_all_posts() - # count = limit_int(count_all_posts(), max_offset) + count = 0 + true_count = 0 + + if tags: + extra_results = get_tagged_posts(tags, extra_offset, limit * extra_pages) + total_count = count_all_posts_for_tag(tags) + true_count = total_count + count = limit_int(total_count, max_offset) + + elif not query or len(query) < 2: + extra_results = get_all_posts_summary( + extra_offset, limit * extra_pages, cache_ttl=Configuration().cache_ttl_for_recent_posts + )[slice_offset : limit + slice_offset] + true_count = count_all_posts() + count = limit_int(true_count, max_offset) + else: try: extra_results = get_all_posts_for_query(query, extra_offset, limit * extra_pages) @@ -73,23 +186,355 @@ def recent(): query_params["q"] = query extra_results = get_all_posts_for_query(query, extra_offset, limit * extra_pages) - # count not used - # if not offset and len(extra_results) < limit: - # true_count = 0 - # count = len(extra_results) - # else: - # try: - # true_count = count_all_posts_for_query(query) - # count = limit_int(props["true_count"], max_offset) - # except Exception as count_error: # catch timeouts, set count as max offset - # logging.exception( - # "Caught error in count_all_posts_for_query", - # extra={"e": count_error}, - # ) - # true_count = 0 - # count = max_offset + if not offset and len(extra_results) < limit: + true_count = 0 + count = len(extra_results) + else: + try: + true_count = count_all_posts_for_query(query) + count = limit_int(true_count, max_offset) + except Exception as count_error: # catch timeouts, set count as max offset + logging.exception( + "Caught error in count_all_posts_for_query", + extra={"e": count_error}, + ) + true_count = 0 + count = max_offset results = extra_results[slice_offset : limit + slice_offset] - response = make_response(jsonify(results), 200) + response = make_response( + jsonify( + count=count, + true_count=true_count, + posts=results + ), + 200 + ) response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" + return response + + +@v1api_bp.get("/posts/random") +def random_post(): + post = get_random_post_key(Configuration().webserver.get("table_sample_bernoulli_sample_size")) + if post is None: + message = "No post found" + response = make_response(jsonify(error=message), 404) + + return response + + response = make_response( + jsonify( + service=post["service"], + artist_id=post["user"], + post_id=post["id"], + ), + 200, + ) + + return response + + +@v1api_bp.get("/posts/popular") +def popular_posts(): + query_params = get_query_parameters_dict(request) + earliest_date_for_popular = Configuration().webserver.get("earliest_date_for_popular") + # checked below but doesn't typecheck without a cast + scale: PeriodScale = cast(PeriodScale, query_params.get("period", "recent")) + + if scale not in get_args(PeriodScale): + scale = "recent" + + info, valid_date = parse_scale_string(query_params.get("date"), scale) + does_not_match_step_date = scale != "recent" and info.date.date() != info.navigation_dates[scale][2] + if ( + not valid_date + or does_not_match_step_date + or info.date.date() > datetime.date.today() + or info.date.date() < earliest_date_for_popular + ): + correct_date = info.navigation_dates[scale][2].isoformat() + if info.date.date() > datetime.date.today(): + correct_date = datetime.date.today() + scale = "day" + elif info.date.date() < earliest_date_for_popular: + correct_date = earliest_date_for_popular + scale = "day" + new_url = set_query_parameter(url_for("posts.popular_posts"), {"date": correct_date, "period": scale}) + response = redirect(new_url) + cache_seconds = int(datetime.timedelta(days=7).total_seconds()) + if info.date.date() > datetime.date.today(): + cache_seconds = int(datetime.timedelta(hours=3).total_seconds()) + response.headers["Cache-Control"] = f"max-age={cache_seconds}" + return response + expiry = int(datetime.timedelta(days=30).total_seconds()) + if scale == "recent": + expiry = int(datetime.timedelta(minutes=30 + 1).total_seconds()) + elif info.max_date > datetime.datetime.utcnow(): + if scale == "day": + expiry = int(datetime.timedelta(hours=3).total_seconds()) + elif scale == "week": + expiry = int(datetime.timedelta(days=1).total_seconds()) + elif scale == "month": + if datetime.date.today().day < 7: + expiry = int(datetime.timedelta(days=1).total_seconds()) + else: + expiry = int(datetime.timedelta(days=5).total_seconds()) + + pages = Configuration().webserver.get("pages_in_popular") + per_page = 50 + offset = positive_or_none(step_int(parse_int(query_params.pop("o", 0), 0), per_page)) + if offset is None: + response = redirect(url_for("posts.popular_posts")) + response.headers["Cache-Control"] = f"max-age={int(datetime.timedelta(days=7).total_seconds())}" + return response + posts = get_popular_posts_for_date_range( + info.min_date, info.max_date, scale, offset // per_page, per_page, pages, expiry + ) + (previews, attachments, is_image) = get_render_data_for_posts(posts) + props = dict( + currentPage="popular_posts", + today=datetime.date.today(), + earliest_date_for_popular=Configuration().webserver.get("earliest_date_for_popular"), + limit=per_page, + count=pages * per_page, + ) + + response = make_response( + jsonify( + info=info, + props=props, + results=posts, + base=query_params, + result_previews=previews, + result_attachments=attachments, + result_is_image=is_image, + ), + 200, + ) + response.headers["Cache-Control"] = f"max-age={int(expiry)}" + return response + + +@v1api_bp.get("/posts/tags") +def list_tags(): + props = dict(currentPage="tags") + response = make_response( + jsonify( + props=props, + tags=get_all_tags(), + ), + ) + response.headers["Cache-Control"] = "s-maxage=3600" + + return response + +@v1api_bp.route("/posts/archives/") +def list_archive(file_hash: str): + archive = get_archive_files(file_hash) + response = make_response(jsonify( + archive=archive, + file_serving_enabled=Configuration().archive_server["file_serving_enabled"], + ), 200) + response.headers["Cache-Control"] = "s-maxage=600" + + return response + +DOWNLOAD_URL_FANBOX_REGEX = re.compile(r"") + +class TDPostProps(TypedDict): + flagged: int + revisions: list[tuple[int, TDPostRevision]] + + +def ready_post_props_light(post: Post): + service = post["service"] + artist_id = post["user"] + post_id = post["id"] + + if service in ("patreon",): + + if post["file"] and post["attachments"] and post["file"] == post["attachments"][0]: + post["attachments"] = post["attachments"][1:] + + if service in ("fansly", "onlyfans"): + posts_incomplete_rewards = get_posts_incomplete_rewards(post_id, artist_id, service) + + if posts_incomplete_rewards: + post["incomplete_rewards"] = "This post is missing paid rewards from a higher tier or payment." + + if post["service"] == "onlyfans": + try: + rewards_info_text = ( + f"{posts_incomplete_rewards["incomplete_attachments_info"]["media_count"]} media, " + f"{posts_incomplete_rewards["incomplete_attachments_info"]["photo_count"]} photos, " + f"{posts_incomplete_rewards["incomplete_attachments_info"]["video_count"]} videos, " + f"for {posts_incomplete_rewards["incomplete_attachments_info"]["price"]}$." + ) + post["incomplete_rewards"] += "\n" + rewards_info_text + except Exception: + pass + + elif post["service"] == "fansly": + try: + rewards_info_text = ( + f"Downloaded:{posts_incomplete_rewards["incomplete_attachments_info"]["complete"]} " + f"Missing:{posts_incomplete_rewards["incomplete_attachments_info"]["incomplete"]}" + ) + post["incomplete_rewards"] += "\n" + rewards_info_text + except Exception: + pass + previews = [] + attachments = [] + videos = [] + + if "path" in post["file"]: + + if images_pattern.search(post["file"]["path"]): + previews.append( + { + "type": "thumbnail", + "server": get_fileserver_for_value(f"/data{post["file"]["path"]}"), + "name": post["file"].get("name"), + "path": post["file"]["path"], + } + ) + else: + file_extension = PurePath(post["file"]["path"]).suffix + name_extension = PurePath(post["file"].get("name") or "").suffix + # filename without extension + stem = PurePath(post["file"]["path"]).stem + attachments.append( + { + "server": get_fileserver_for_value(f"/data{post["file"]["path"]}"), + "name": post["file"].get("name"), + "extension": file_extension, + "name_extension": name_extension, + "stem": stem, + "path": post["file"]["path"], + } + ) + + if len(post.get("embed") or []): + previews.append( + { + "type": "embed", + "url": post["embed"]["url"], + "subject": post["embed"]["subject"], + "description": post["embed"]["description"], + } + ) + + for attachment in post["attachments"]: + + if images_pattern.search(attachment["path"]): + previews.append( + { + "type": "thumbnail", + "server": get_fileserver_for_value(f"/data{attachment["path"]}"), + "name": attachment["name"], + "path": attachment["path"], + } + ) + else: + file_extension = PurePath(attachment["path"]).suffix + name_extension = PurePath(attachment.get("name") or "").suffix + # filename without extension + stem = PurePath(attachment["path"]).stem + attachments.append( + { + "server": get_fileserver_for_value(f"/data{attachment["path"]}"), + "name": attachment.get("name"), + "extension": file_extension, + "name_extension": name_extension, + "stem": stem, + "path": attachment["path"], + } + ) + + for i, attachment in enumerate(attachments): + if attachment["extension"] in video_extensions: + videos.append( + { + "index": i, + "path": attachment["path"], + "name": attachment.get("name"), + "extension": attachment["extension"], + "name_extension": attachment["name_extension"], + "server": get_fileserver_for_value(f"/data{attachment["path"]}"), + } + ) + + if post.get("poll") is not None: + post["poll"]["total_votes"] = sum(choice["votes"] for choice in post["poll"]["choices"]) + post["poll"]["created_at"] = datetime.datetime.fromisoformat(post["poll"]["created_at"]) + if post["poll"]["closes_at"]: + post["poll"]["closes_at"] = datetime.datetime.fromisoformat(post["poll"]["closes_at"]) + + if (captions := post.get("captions")) is not None: + for file_hash, caption_data in captions.items(): + for preview_data in [preview for preview in previews if preview.get("path") == file_hash]: + if isinstance(caption_data, dict): + preview_data["caption"] = caption_data.get("text") or "" + elif isinstance(caption_data, list): + preview_data["caption"] = " ".join(each.get("text") or "" for each in caption_data) + for video in [video for video in videos if video["path"] == file_hash]: + if isinstance(caption_data, dict): + video["caption"] = caption_data.get("text") or "" + elif isinstance(caption_data, list): + video["caption"] = " ".join(each.get("text") or "" for each in caption_data) + + props = TDPostProps( + flagged=is_post_flagged(service, artist_id, post_id), + revisions=get_post_revisions(service, artist_id, post_id), + ) + real_post = post if not post.get("revision_id") else get_post(service, artist_id, post_id) + all_revisions = [real_post] + props["revisions"] + for set_prev_next_revision in (post, *props["revisions"]): + set_prev_next_revision["prev"] = real_post["prev"] + set_prev_next_revision["next"] = real_post["next"] + + if props["revisions"]: + last_date = real_post["added"] + for i, rev in enumerate(all_revisions[:-1]): + rev["added"] = all_revisions[i + 1]["added"] + props["revisions"][-1]["added"] = last_date + + if real_post["service"] == "fanbox": + top_rev_stripped = all_revisions[0].copy() + top_rev_stripped.pop("file") + top_rev_stripped.pop("added") + top_rev_stripped.pop("revision_id", None) + for fanbox_attachment in top_rev_stripped["attachments"]: + if 41 >= len(fanbox_attachment["name"]) >= 39: + fanbox_attachment.pop("name", None) + for duplicated_check_rev in all_revisions[1:]: + duplicated_check_rev_file_stripped = duplicated_check_rev.copy() + duplicated_check_rev_file_stripped.pop("file") + duplicated_check_rev_file_stripped.pop("added") + duplicated_check_rev_file_stripped.pop("revision_id", None) + for fanbox_attachment in duplicated_check_rev_file_stripped["attachments"]: + if 41 >= len(fanbox_attachment["name"]) >= 39: + fanbox_attachment.pop("name", None) + if duplicated_check_rev_file_stripped == top_rev_stripped: + all_revisions.remove(duplicated_check_rev) + else: + top_rev_stripped = duplicated_check_rev_file_stripped + + if isinstance(post["tags"], str): + post["tags"] = [tag.strip('"') for tag in post["tags"][1:-1].split(",")] + + transformed_revisions = list(reversed([ + (i, rev) + for i, rev + in enumerate(reversed(all_revisions)) + ])) + props["revisions"] = transformed_revisions + if post["service"] == "fanbox": + post["content"] = DOWNLOAD_URL_FANBOX_REGEX.sub("", post["content"]) + post["content"] = sanitize_html(post["content"], allow_iframe=post["service"] == "fanbox") + if post["service"] == "boosty": + post["content"] = patch_inline_img(post["content"]) + + return attachments, previews, videos, props diff --git a/src/pages/artists.py b/src/pages/artists.py index 8d65f4b..c696107 100644 --- a/src/pages/artists.py +++ b/src/pages/artists.py @@ -1,466 +1,11 @@ -from flask import Blueprint, Response, abort, flash, g, make_response, redirect, render_template, request, session, url_for - -from src.lib.announcements import get_artist_announcements -from src.lib.artist import ( - delete_creator_link, - get_artist, - get_artists_by_update_time, - get_fancards_by_artist, - get_top_artists_by_faves, - create_unapproved_link_request, - get_linked_creators, -) -from src.lib.dms import count_user_dms, get_artist_dms -from src.lib.filehaus import get_artist_share_count, get_artist_shares from src.lib.post import ( get_all_posts_by_artist, - get_artist_post_count, - get_artist_posts_summary, - get_fileserver_for_value, - get_render_data_for_posts, ) -from src.lib.posts import count_all_posts_for_tag, get_all_tags, get_tagged_posts from src.pages.artists_types import ( - ArtistAnnouncementsProps, ArtistDisplayData, - ArtistDMsProps, - ArtistFancardsProps, - ArtistPageProps, - ArtistShareProps, - LinkedAccountsProps, ) from src.types.paysites import Paysite, Paysites -from src.utils.utils import offset_list, parse_int, positive_or_none, sort_dict_list_by, step_int, take -from src.types.account.account import Account -from src.utils.decorators import require_login - -artists_bp = Blueprint("artists", __name__) - - -@artists_bp.route("/artists") -def list(): - base = dict() - limit = 50 - - results = get_top_artists_by_faves(0, limit) - props = dict( - currentPage="artists", - display="cached popular artists", - count=len(results), - limit=limit, - ) - - response = make_response(render_template("artists.html", props=props, results=results, base=base), 200) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@artists_bp.route("/artists/updated") -def updated(): - base = dict(commit=True, sort_by="updated") - limit = 50 - - results = get_artists_by_update_time(offset=0, limit=limit) - props = dict( - currentPage="artists", - display="cached updated artists", - count=len(results), - limit=limit, - ) - - response = make_response(render_template("artists.html", props=props, results=results, base=base), 200) - response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" - return response - - -@artists_bp.route("//user/") -def get(service: str, artist_id: str): - if service == "discord": - response = redirect(f"/discord/server/{artist_id}", 308) - response.headers["Cache-Control"] = "s-maxage=86400" - return response - - base = request.args.to_dict() - base.pop("o", None) - base["service"] = service - base["artist_id"] = artist_id - - artist = get_artist(service, artist_id) - if artist is None: - return redirect(url_for("artists.list")) - elif artist["public_id"] == artist_id and artist["id"] != artist_id: - return redirect(url_for("artists.get", service=service, artist_id=artist["id"])) - - query = request.args.get("q", default="").strip() - tags = sorted(request.args.getlist("tag")) - limit = 50 - offset = positive_or_none(step_int(parse_int(request.args.get("o"), 0), limit)) - if offset is None: - return redirect(url_for("artists.list")) - - if tags: - posts = get_tagged_posts(tags, offset, limit, service, artist_id) - total_count = count_all_posts_for_tag(tags, service, artist_id) - elif not query or len(query) < 2: - total_count = get_artist_post_count(service, artist_id) - if offset > total_count: - return redirect(url_for("artists.get", service=service, artist_id=artist_id)) - else: - posts = get_artist_posts_summary(artist_id, service, offset, limit, "published DESC NULLS LAST") - else: - (posts, total_count) = do_artist_post_search(artist_id, service, query, offset, limit) - - ( - result_previews, - result_attachments, - result_is_image, - ) = get_render_data_for_posts(posts) - - props = ArtistPageProps( - id=artist_id, - service=service, - session=session, - name=artist["name"], - count=total_count, - limit=limit, - artist=artist, - display_data=make_artist_display_data(artist), - dm_count=count_user_dms(service, artist_id), - share_count=get_artist_share_count(service, artist_id), - has_links="✔️" if artist["relation_id"] else "0", - ) - - response = make_response( - render_template( - "user.html", - props=props, - base=base, - results=posts, - result_previews=result_previews, - result_attachments=result_attachments, - result_is_image=result_is_image, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@artists_bp.get("//user//tags") -def get_tags(service: str, artist_id: str): - artist = get_artist(service, artist_id) - if not artist: - response = redirect(url_for("artists.list"), code=301) - response.headers["Cache-Control"] = "s-maxage=60" - return response - elif artist["public_id"] == artist_id and artist["id"] != artist_id: - return redirect(url_for("artists.get_tags", service=service, artist_id=artist["id"]), code=301) - - tags = get_all_tags(service, artist_id) - - response = make_response( - render_template( - "artist/tags.html", - props={ - "display_data": make_artist_display_data(artist), - "artist": artist, - "service": service, - "id": artist["id"], - "share_count": get_artist_share_count(service, artist_id), - "dm_count": count_user_dms(service, artist_id), - "has_links": "✔️" if artist["relation_id"] else "0", - }, - tags=tags, - service=service, - artist=artist, - ) - ) - response.headers["Cache-Control"] = "s-maxage=600" - return response - - -@artists_bp.route("/fanbox/user//fancards") -def get_fancards(artist_id: str): - service = "fanbox" - artist = get_artist(service, artist_id) - if not artist: - response = redirect(url_for("artists.list"), code=301) - response.headers["Cache-Control"] = "s-maxage=60" - return response - elif artist["public_id"] == artist_id: - return redirect(url_for("artists.get_fancards", artist_id=artist["id"]), code=301) - - fancards = get_fancards_by_artist(artist_id) - for fancard in fancards: - fhash = fancard["hash"] - ext = fancard["ext"] - fancard["path"] = f"/data/{fhash[0:2]}/{fhash[2:4]}/{fhash}{ext}" - fancard["server"] = get_fileserver_for_value(fancard["path"]) - - props = ArtistFancardsProps( - id=artist_id, - session=session, - artist=artist, - display_data=make_artist_display_data(artist), - fancards=fancards, - share_count=get_artist_share_count(artist_id=artist_id, service=service), - dm_count=count_user_dms(service, artist_id), - has_links="✔️" if artist["relation_id"] else "0", - ) - - response = make_response( - render_template( - "artist/fancards.html", - artist=artist, - fancards=fancards, - props=props, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@artists_bp.route("//user//shares") -def get_shares(service: str, artist_id: str): - base = request.args.to_dict() - base.pop("o", None) - base["service"] = service - base["artist_id"] = artist_id - - dm_count = count_user_dms(service, artist_id) - shares = get_artist_shares(artist_id, service) - - artist = get_artist(service, artist_id) - if artist is None: - return redirect(url_for("artists.list")) - elif artist["public_id"] == artist_id and artist["id"] != artist_id: - return redirect(url_for("artists.get_shares", service=service, artist_id=artist["id"]), code=301) - - props = ArtistShareProps( - display_data=make_artist_display_data(artist), - service=service, - session=session, - artist=artist, - id=artist_id, - dm_count=dm_count, - share_count=len(shares), - has_links="✔️" if artist["relation_id"] else "0", - ) - - response = make_response( - render_template("artist/shares.html", results=shares, props=props, base=base), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@artists_bp.route("//user//dms") -def get_dms(service: str, artist_id: str): - # pagination might be added at some point if we need it, but considering how few dms most artists end up having, we probably won't - # base = request.args.to_dict() - # base.pop('o', None) - # base["service"] = service - # base["artist_id"] = artist_id - - # offset = int(request.args.get('o') or 0) - # query = request.args.get('q') - # limit = limit_int(int(request.args.get('limit') or 25), 50) - - artist = get_artist(service, artist_id) - if artist is None: - return redirect(url_for("artists.list")) - elif artist["public_id"] == artist_id and artist["id"] != artist_id: - return redirect(url_for("artists.get_dms", service=service, artist_id=artist["id"]), code=301) - - dms = get_artist_dms(service, artist_id) - - props = ArtistDMsProps( - id=artist_id, - service=service, - session=session, - artist=artist, - display_data=make_artist_display_data(artist), - share_count=get_artist_share_count(service, artist_id), - dm_count=len(dms), - dms=dms, - has_links="✔️" if artist["relation_id"] else "0", - ) - - response = make_response( - render_template( - "artist/dms.html", - props=props, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@artists_bp.route("//user//announcements") -def get_announcements(service: str, artist_id: str) -> Response: - # offset = int(request.args.get("o") or 0) - query = request.args.get("q", "") - - artist = get_artist(service, artist_id) - if artist is None: - return redirect(url_for("artists.list")) - elif artist["public_id"] == artist_id and artist["id"] != artist_id: - return redirect(url_for("artists.get_announcements", service=service, artist_id=artist["id"]), code=301) - - announcements = get_artist_announcements(service, artist_id, query=query, reload=True) - # total_announcement_count = get_announcement_count(service=service, artist_id=artist_id, query=query, reload=True) - - props = ArtistAnnouncementsProps( - id=artist_id, - service=service, - artist=artist, - announcements=announcements, - # count=total_announcement_count, - share_count=get_artist_share_count(service, artist_id), - dm_count=count_user_dms(service, artist_id), - has_links="✔️" if artist["relation_id"] else "0", - session=session, - display_data=make_artist_display_data(artist), - ) - response: Response = make_response( - render_template( - "artist/announcements.html", - props=props, - base={"service": service, "artist_id": artist_id}, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@artists_bp.get("//user//links") -def get_linked_accounts(service: str, artist_id: str): - artist = get_artist(service, artist_id) - if not artist: - return redirect(url_for("artists.list")) - elif artist["public_id"] == artist_id and artist["id"] != artist_id: - return redirect(url_for("artists.get_linked_accounts", service=service, artist_id=artist["id"]), code=301) - links = get_linked_creators(service, artist_id) - - props = LinkedAccountsProps( - id=artist_id, - service=service, - artist=artist, - share_count=get_artist_share_count(service, artist_id), - dm_count=count_user_dms(service, artist_id), - has_links="✔️" if artist["relation_id"] else "0", - display_data=make_artist_display_data(artist), - ) - - response = make_response( - render_template( - "artist/linked_accounts.html", - props=props, - links=links, - base={"service": service, "artist_id": artist_id}, - ), - 200 - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@artists_bp.delete("//user//links") -@require_login -def delete_linked_account(service: str, creator_id: str, user: Account): - if user.role != "administrator": - abort(404) - else: - delete_creator_link(service, creator_id) - return "", 204 - - -@artists_bp.get("//user//links/new") -@require_login -def get_new_link_page(service: str, artist_id: str, user: Account): - artist = get_artist(service, artist_id) - if not artist: - return redirect(url_for("artists.list")) - elif artist["public_id"] == artist_id and artist["id"] != artist_id: - return redirect(url_for("artists.get_new_link_page", service=service, artist_id=artist["id"]), code=301) - - props = LinkedAccountsProps( - id=artist_id, - service=service, - artist=artist, - share_count=get_artist_share_count(service, artist_id), - dm_count=count_user_dms(service, artist_id), - has_links="✔️" if artist["relation_id"] else "0", - display_data=make_artist_display_data(artist), - ) - - response = make_response( - render_template( - "artist/new_linked_account.html", - props=props, - base={"service": service, "artist_id": artist_id}, - ), - 200 - ) - response.headers["Cache-Control"] = "s-maxage=600" - return response - - -@artists_bp.post("//user//links/new") -@require_login -def post_new_link_page(service: str, artist_id: str, user: Account): - dest_service, dest_artist_id = request.form.get("creator", "/").split("/") - reason = request.form.get("reason", "") - - from_artist = get_artist(service, artist_id) - to_artist = get_artist(dest_service, dest_artist_id) - - if not from_artist: - return redirect(url_for("artists.list")) - elif from_artist["public_id"] == artist_id and from_artist["id"] != artist_id: - return redirect(url_for("artists.post_new_link_page", service=service, artist_id=from_artist["id"]), code=301) - - props = LinkedAccountsProps( - id=artist_id, - service=service, - artist=from_artist, - share_count=get_artist_share_count(service, artist_id), - dm_count=count_user_dms(service, artist_id), - has_links="✔️" if from_artist["relation_id"] else "0", - display_data=make_artist_display_data(from_artist), - ) - - tmpl = render_template( - "artist/new_linked_account.html", - props=props, - base={"service": service, "artist_id": artist_id}, - ) - if not to_artist: - flash(f"Invalid creator (svc: {dest_service}, id: {dest_artist_id})") - response = make_response(tmpl, 404) - return response - - if len(reason) > 140: - flash("Reason is too long") - return tmpl, 422 - - if dest_service == service and dest_artist_id == artist_id: - flash("Can't link an artist to themself") - response = make_response(tmpl, 422) - return response - - if from_artist["relation_id"] == to_artist["relation_id"] and from_artist["relation_id"] is not None: - flash("Already linked") - response = make_response(tmpl, 422) - return response - - create_unapproved_link_request(from_artist, to_artist, user.id, reason) - flash("Request created. It will be shown here when approved.") - return redirect(url_for("artists.get_linked_accounts", service=service, artist_id=artist_id)) +from src.utils.utils import offset_list, sort_dict_list_by, take def do_artist_post_search(artist_id, service, search, o, limit): diff --git a/src/pages/artists_types.py b/src/pages/artists_types.py index 9cea111..709becf 100644 --- a/src/pages/artists_types.py +++ b/src/pages/artists_types.py @@ -1,8 +1,6 @@ from dataclasses import dataclass from typing import Any, Dict, List -from flask.sessions import SessionMixin - from src.internals.internal_types import PageProps from src.types.kemono import Approved_DM @@ -18,7 +16,6 @@ class ArtistPageProps(PageProps): currentPage = "posts" id: str service: str - session: SessionMixin name: str count: int limit: int @@ -34,7 +31,6 @@ class ArtistShareProps(PageProps): currentPage = "shares" id: str service: str - session: SessionMixin artist: Dict display_data: ArtistDisplayData dm_count: int @@ -47,7 +43,6 @@ class ArtistDMsProps(PageProps): currentPage = "dms" id: str service: str - session: SessionMixin artist: Dict display_data: ArtistDisplayData dm_count: int @@ -59,7 +54,6 @@ class ArtistDMsProps(PageProps): @dataclass class ArtistFancardsProps(PageProps): id: str - session: SessionMixin artist: Dict display_data: ArtistDisplayData fancards: List[Any] # todo remove any @@ -80,7 +74,6 @@ class ArtistAnnouncementsProps(PageProps): share_count: int dm_count: int has_links: str - session: SessionMixin display_data: ArtistDisplayData currentPage: str = "announcements" limit: int = 50 diff --git a/src/pages/creator_link_requests.py b/src/pages/creator_link_requests.py deleted file mode 100644 index 3ed6533..0000000 --- a/src/pages/creator_link_requests.py +++ /dev/null @@ -1,26 +0,0 @@ -from flask import Blueprint, abort, jsonify - -from src.lib.artist import approve_unapproved_link_request, reject_unapproved_link_request -from src.utils.decorators import require_login -from src.types.account.account import Account - - -bp = Blueprint("creator_link_requests", __name__) - - -@bp.post("/creator_link_requests//approve") -@require_login -def approve_request(request_id: int, user: Account): - if user.role not in ["moderator", "administrator"]: - return abort(404) - approve_unapproved_link_request(request_id) - return jsonify({"response": "approved"}) - - -@bp.post("/creator_link_requests//reject") -@require_login -def reject_request(request_id: int, user: Account): - if user.role not in ["moderator", "administrator"]: - return abort(404) - reject_unapproved_link_request(request_id) - return jsonify({"response": "rejected"}) diff --git a/src/pages/dms.py b/src/pages/dms.py deleted file mode 100644 index 13fcb04..0000000 --- a/src/pages/dms.py +++ /dev/null @@ -1,58 +0,0 @@ -from dataclasses import dataclass -from typing import Dict, List - -from flask import Blueprint, make_response, redirect, render_template, request, url_for - -from src.config import Configuration -from src.internals.internal_types import PageProps -from src.lib.artist import get_artist -from src.lib.dms import get_all_dms, get_all_dms_by_query, get_all_dms_by_query_count, get_all_dms_count -from src.types.kemono import Approved_DM -from src.utils.utils import get_query_parameters_dict, parse_int, positive_or_none, step_int - - -@dataclass -class DMsProps(PageProps): - currentPage = "artists" - count: int - limit: int - dms: List[Approved_DM] - - -dms_bp = Blueprint("dms", __name__) - - -@dms_bp.route("/dms") -def get_dms(): - base = get_query_parameters_dict(request, on_errors="ignore", clean_query_string=True) - - limit = 50 - max_offset = limit * 1000 # only load 1000 pages of any result - offset = positive_or_none(step_int(parse_int(base.pop("o", 0), 0), limit)) - if offset is None or offset > max_offset: - return redirect(url_for("dms.get_dms")) - query = base.get("q", "").strip()[: Configuration().webserver["max_full_text_search_input_len"]] - - if not query or len(query) < 3: - total_count = get_all_dms_count() - if offset > total_count: - return redirect(url_for("dms.get_dms")) - dms = get_all_dms(offset, limit) - else: - total_count = get_all_dms_by_query_count(query) - if offset > total_count: - return redirect(url_for("dms.get_dms")) - dms = get_all_dms_by_query(query, offset, limit) - - props = DMsProps(count=total_count, limit=limit, dms=dms) - - response = make_response( - render_template( - "all_dms.html", - props=props, - base=base, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response diff --git a/src/pages/favorites.py b/src/pages/favorites.py deleted file mode 100644 index ab41025..0000000 --- a/src/pages/favorites.py +++ /dev/null @@ -1,60 +0,0 @@ -from flask import Blueprint, make_response, redirect, render_template, request, url_for - -from src.lib.favorites import get_favorite_artists, get_favorite_posts -from src.types.account.account import Account -from src.utils.decorators import require_login -from src.utils.utils import offset_list, parse_int, positive_or_none, restrict_value, sort_dict_list_by, step_int, take - -favorites_bp = Blueprint("favorites", __name__) - - -@favorites_bp.route("/favorites", methods=["GET"]) -@require_login -def list_favorites(user: Account): - props = {"currentPage": "favorites"} - base = request.args.to_dict() - base.pop("o", None) - - fave_type = request.args.get("type", "artist") - if fave_type == "post": - favorites = get_favorite_posts(user.id) - sort_field = restrict_value(request.args.get("sort"), ["faved_seq", "published"], "faved_seq") - else: - fave_type = "artist" - favorites = get_favorite_artists(user.id) - sort_field = restrict_value( - request.args.get("sort"), - ["faved_seq", "updated", "last_imported"], - "updated", - ) - - limit = 50 - offset = positive_or_none(step_int(parse_int(request.args.get("o"), 0), limit)) - if offset is None: - return redirect(url_for("favorites.list_favorites")) - sort_asc = request.args.get("order") == "asc" - results = sort_and_filter_favorites(favorites, offset, sort_field, sort_asc) - - props["fave_type"] = fave_type - props["sort_field"] = sort_field - props["sort_asc"] = sort_asc - props["count"] = len(favorites) - props["limit"] = limit - - response = make_response( - render_template( - "favorites.html", - props=props, - base=base, - source="account", - results=results, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -def sort_and_filter_favorites(favorites, o, field, asc): - favorites = sort_dict_list_by(favorites, field, not asc) - return take(50, offset_list(o, favorites)) diff --git a/src/pages/filehaus.py b/src/pages/filehaus.py deleted file mode 100644 index cf715b3..0000000 --- a/src/pages/filehaus.py +++ /dev/null @@ -1,79 +0,0 @@ -from flask import Blueprint, flash, make_response, redirect, render_template, request, url_for, g - -from src.config import Configuration -from src.lib.filehaus import get_all_shares_count, get_files_for_share, get_share, get_shares -from src.utils.utils import parse_int, positive_or_none, step_int - -filehaus_bp = Blueprint("filehaus", __name__) - - -@filehaus_bp.route("/share/") -def get_share_handler(share_id: str): - base = request.args.to_dict() - base.pop("o", None) - - props = dict(currentPage="shares") - share = get_share(int(share_id)) if share_id.isdigit() else None - if share is None: - response = redirect(url_for("filehaus.get_shares_page")) - return response - - share_files = get_files_for_share(share["id"]) - - response = make_response( - render_template("share.html", share_files=share_files, share=share, props=props, base=base), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@filehaus_bp.route("/shares") -def get_shares_page(): - base = request.args.to_dict() - base.pop("o", None) - - limit = 50 - offset = positive_or_none(step_int(parse_int(request.args.get("o"), 0), limit)) - # query = request.args.get('q') - - shares = None - total_count = None - (shares, total_count) = get_share_page(offset, limit) - - props = dict(currentPage="shares", count=total_count, shares=shares, limit=limit) - - response = make_response( - render_template( - "shares.html", - props=props, - base=base, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -def get_share_page(offset: int, limit: int): - posts = get_shares(offset, limit) - total_count = get_all_shares_count() - return posts, total_count - - -@filehaus_bp.route("/posts/upload") -def upload_post(): - account = g.get("account") - if Configuration().filehaus["requires_account"] and account is None: - flash("Filehaus uploading requires an account.") - return redirect(url_for("account.get_login")) - required_roles = Configuration().filehaus["required_roles"] - if len(required_roles) and account.role not in required_roles: - flash( - "Filehaus uploading requires elevated permissions. " "Please contact the administrator to change your role." - ) - return redirect(url_for("account.get_account")) - props = {"currentPage": "posts"} - response = make_response(render_template("upload.html", props=props), 200) - response.headers["Cache-Control"] = "s-maxage=60" - return response diff --git a/src/pages/files.py b/src/pages/files.py deleted file mode 100644 index af3f793..0000000 --- a/src/pages/files.py +++ /dev/null @@ -1,43 +0,0 @@ -from flask import Blueprint, make_response, redirect, render_template, request, url_for - -from src.internals.database.database import get_cursor -from src.lib.files import get_file_relationships - -files_bp = Blueprint("files", __name__) - - -@files_bp.route("/search_hash", methods=["GET", "POST"]) -def search_hash(): - file_hash = request.args.get("hash") - if file_hash: - if not (len(file_hash) == 64 and all(c in "0123456789abcdefABCDEF" for c in file_hash)): - return redirect(url_for("files.search_hash")) - file_data: dict | None = get_file_relationships(file_hash) - for discord_post in (file_data["discord_posts"] or []) if file_data else []: - cursor = get_cursor() - cursor.execute( - "SELECT * FROM discord_channels WHERE channel_id = %s", - (discord_post["channel"],), - ) - lookup_result = cursor.fetchall() - discord_post["channel_name"] = lookup_result[0]["name"] if len(lookup_result) else "" - response = make_response( - render_template( - "search_results.html", - hash=file_hash, - file_data=file_data, - props={"currentPage": "search_hash"}, - ) - ) - response.headers["Cache-Control"] = "s-maxage=60" - else: - response = make_response( - render_template( - "search_hash.html", - props={"currentPage": "search_hash"}, - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - - return response diff --git a/src/pages/help.py b/src/pages/help.py deleted file mode 100644 index 9191e2f..0000000 --- a/src/pages/help.py +++ /dev/null @@ -1,16 +0,0 @@ -from flask import Blueprint, make_response, redirect, render_template, url_for - -help_app_bp = Blueprint("help_app", __name__) - - -@help_app_bp.route("/") -def help(): - return redirect(url_for("help_app.faq"), 302) - - -@help_app_bp.get("/faq") -def faq(): - props = dict(currentPage="help") - response = make_response(render_template("help/faq.html", props=props), 200) - response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" - return response diff --git a/src/pages/home.py b/src/pages/home.py deleted file mode 100644 index 3140220..0000000 --- a/src/pages/home.py +++ /dev/null @@ -1,13 +0,0 @@ -from flask import Blueprint, make_response, render_template, request - -home_bp = Blueprint("pages", __name__) - - -@home_bp.get("/") -def get_home(): - props = {} - base = request.args.to_dict() - base.pop("o", None) - response = make_response(render_template("home.html", props=props, base=base), 200) - response.headers["Cache-Control"] = "s-maxage=60" - return response diff --git a/src/pages/imports/__init__.py b/src/pages/imports/__init__.py deleted file mode 100644 index c02a770..0000000 --- a/src/pages/imports/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .blueprint import importer_page_bp diff --git a/src/pages/imports/blueprint.py b/src/pages/imports/blueprint.py deleted file mode 100644 index b5df5d8..0000000 --- a/src/pages/imports/blueprint.py +++ /dev/null @@ -1,55 +0,0 @@ -from flask import Blueprint, make_response, redirect, render_template, request, session, url_for - -from src.lib.dms import approve_dms, cleanup_unapproved_dms, get_unapproved_dms -from src.types.props import SuccessProps - -from .types import DMPageProps, ImportProps, StatusPageProps - -importer_page_bp = Blueprint("importer_page", __name__) - - -@importer_page_bp.get("/importer") -def importer(): - props = ImportProps() - - response = make_response(render_template("importer_list.html", props=props), 200) - response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" - return response - - -@importer_page_bp.get("/importer/tutorial") -def importer_tutorial(): - props = ImportProps() - - response = make_response(render_template("importer_tutorial.html", props=props), 200) - response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" - return response - - -@importer_page_bp.get("/importer/tutorial_fanbox") -def importer_tutorial_fanbox(): - props = ImportProps() - - response = make_response(render_template("importer_tutorial_fanbox.html", props=props), 200) - response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" - return response - - -@importer_page_bp.get("/importer/ok") -def importer_ok(): - props = ImportProps() - - response = make_response(render_template("importer_ok.html", props=props), 200) - response.headers["Cache-Control"] = "max-age=60, public, stale-while-revalidate=2592000" - return response - - -@importer_page_bp.get("/importer/status/") -def importer_status(import_id): - is_dms = bool(request.args.get("dms")) - - props = StatusPageProps(import_id=import_id, is_dms=is_dms) - response = make_response(render_template("importer_status.html", props=props), 200) - - response.headers["Cache-Control"] = "max-age=0, private, must-revalidate" - return response diff --git a/src/pages/imports/types.py b/src/pages/imports/types.py deleted file mode 100644 index d9bbdd3..0000000 --- a/src/pages/imports/types.py +++ /dev/null @@ -1,23 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from src.internals.internal_types import PageProps -from src.types.kemono import Unapproved_DM - - -@dataclass -class ImportProps(PageProps): - currentPage = "import" - - -@dataclass -class StatusPageProps(ImportProps): - import_id: str - is_dms: bool - - -@dataclass -class DMPageProps(ImportProps): - account_id: int - dms: List[Unapproved_DM] - status: str diff --git a/src/pages/post.py b/src/pages/post.py index 2258cba..5b1650e 100644 --- a/src/pages/post.py +++ b/src/pages/post.py @@ -1,91 +1,40 @@ import datetime -import json import re from pathlib import PurePath -import dateutil.parser -from flask import Blueprint, make_response, redirect, render_template, url_for - from src.config import Configuration from src.lib.artist import get_artist from src.lib.post import ( get_fileserver_for_value, get_post, - get_post_by_id, get_post_comments, get_post_revisions, get_posts_incomplete_rewards, is_post_flagged, + patch_inline_img, ) +from src.lib.posts import Post from src.utils.utils import images_pattern, sanitize_html -post_bp = Blueprint("post", __name__) video_extensions = Configuration().webserver["ui"]["video_extensions"] -@post_bp.route("//post/") -def get_by_id(service, post_id): - post = get_post_by_id(post_id, service) - - if post: - response = redirect( - url_for( - "post.get", - service=post["service"], - artist_id=post["user"], - post_id=post["id"], - ) - ) - response.headers["Cache-Control"] = "s-maxage=86400" - else: - response = redirect(url_for("artists.list"), code=301) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -@post_bp.route("//user//post/") -def get(service, artist_id, post_id): - artist = get_artist(service, artist_id) - if artist and artist["public_id"] == artist_id and artist["id"] != artist_id: - return redirect(url_for("post.get", service=service, artist_id=artist["id"], post_id=post_id), code=301) - - post: dict = get_post(service, artist_id, post_id) - if not post: - response = redirect(url_for("artists.get", service=service, artist_id=artist_id)) - return response - - attachments, comments, previews, videos, props = ready_post_props(post) - props["currentPage"] = "posts" - - response = make_response( - render_template( - "post.html", - props=props, - post=post, - comments=comments, - result_previews=previews, - result_attachments=attachments, - videos=videos, - archives_enabled=Configuration().archive_server["enabled"], - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=60" - return response - - -def ready_post_props(post): +def ready_post_props(post: Post): service = post["service"] artist_id = post["user"] post_id = post["id"] if service in ("patreon",): + if post["file"] and post["attachments"] and post["file"] == post["attachments"][0]: post["attachments"] = post["attachments"][1:] + if service in ("fansly", "onlyfans"): posts_incomplete_rewards = get_posts_incomplete_rewards(post_id, artist_id, service) + if posts_incomplete_rewards: post["incomplete_rewards"] = "This post is missing paid rewards from a higher tier or payment." + if post["service"] == "onlyfans": try: rewards_info_text = ( @@ -97,6 +46,7 @@ def ready_post_props(post): post["incomplete_rewards"] += "\n" + rewards_info_text except Exception: pass + elif post["service"] == "fansly": try: rewards_info_text = ( @@ -109,7 +59,9 @@ def ready_post_props(post): previews = [] attachments = [] videos = [] + if "path" in post["file"]: + if images_pattern.search(post["file"]["path"]): previews.append( { @@ -134,6 +86,7 @@ def ready_post_props(post): "path": post["file"]["path"], } ) + if len(post.get("embed") or []): previews.append( { @@ -143,7 +96,9 @@ def ready_post_props(post): "description": post["embed"]["description"], } ) + for attachment in post["attachments"]: + if images_pattern.search(attachment["path"]): previews.append( { @@ -168,6 +123,7 @@ def ready_post_props(post): "path": attachment["path"], } ) + for i, attachment in enumerate(attachments): if attachment["extension"] in video_extensions: videos.append( @@ -247,6 +203,8 @@ def ready_post_props(post): if post["service"] == "fanbox": post["content"] = DOWNLOAD_URL_FANBOX_REGEX.sub("", post["content"]) post["content"] = sanitize_html(post["content"], allow_iframe=post["service"] == "fanbox") + if post["service"] == "boosty": + post["content"] = patch_inline_img(post["content"]) return attachments, comments, previews, videos, props diff --git a/src/pages/posts.py b/src/pages/posts.py deleted file mode 100644 index c98dc1c..0000000 --- a/src/pages/posts.py +++ /dev/null @@ -1,228 +0,0 @@ -import datetime -import logging -from typing import cast, get_args - -from flask import Blueprint, Response, make_response, redirect, render_template, request, url_for -from psycopg.errors import QueryCanceled -from src.lib.files import get_archive_files - -from src.config import Configuration -from src.lib.post import get_render_data_for_posts -from src.lib.posts import ( - count_all_posts, - count_all_posts_for_query, - count_all_posts_for_tag, - get_all_posts_for_query, - get_all_posts_summary, - get_all_tags, - get_popular_posts_for_date_range, - get_tagged_posts, -) -from src.utils.datetime_ import PeriodScale, parse_scale_string -from src.utils.utils import ( - get_query_parameters_dict, - limit_int, - parse_int, - parse_offset, - positive_or_none, - set_query_parameter, - step_int, -) - -posts_bp = Blueprint("posts", __name__) - - -@posts_bp.route("/posts") -def get_posts(): - props = { - "currentPage": "posts", - "limit": 50, - } - - query_params = get_query_parameters_dict(request, on_errors="ignore", clean_query_string=True) - - extra_pages = Configuration().webserver["extra_pages_to_load_on_posts"] - max_offset = props["limit"] * 1000 # only load 1000 pages of any result - query = query_params.get("q", "").strip()[: Configuration().webserver["max_full_text_search_input_len"]] - tags = request.args.getlist("tag") - o = query_params.pop("o", 0) - offset = positive_or_none(step_int(parse_int(o, 0), props["limit"])) - if offset is None or offset > max_offset: - return redirect(url_for("posts.get_posts")) - extra_offset = positive_or_none(step_int(parse_int(o, 0), props["limit"] * extra_pages)) - slice_offset = offset - extra_offset - # todo this true_count and count have no real meaning seeing it is displayed as {{props.true_count or props.count }} - if tags: - extra_results = get_tagged_posts(tags, extra_offset, props["limit"] * extra_pages) - total_count = count_all_posts_for_tag(tags) - props["true_count"] = total_count - props["count"] = limit_int(total_count, max_offset) - elif not query or len(query) < 2: - extra_results = get_all_posts_summary(extra_offset, props["limit"] * extra_pages, cache_ttl=Configuration().cache_ttl_for_recent_posts)[ - slice_offset : props["limit"] + slice_offset - ] - props["true_count"] = count_all_posts() - props["count"] = limit_int(count_all_posts(), max_offset) - else: - try: - extra_results = get_all_posts_for_query(query, extra_offset, props["limit"] * extra_pages) - except QueryCanceled: - return make_response("Query Timeout. Please fix your query text or try again later.", 408) - except Exception as error: - if "failed to parse expression" not in str(error): - raise - query = "Failed to parse query." - query_params["q"] = query - extra_results = get_all_posts_for_query(query, extra_offset, props["limit"] * extra_pages) - - if not offset and len(extra_results) < props["limit"]: - props["true_count"] = 0 - props["count"] = len(extra_results) - else: - try: - props["true_count"] = count_all_posts_for_query(query) - props["count"] = limit_int(props["true_count"], max_offset) - except Exception as count_error: # catch timeouts, set count as max offset - logging.exception( - "Caught error in count_all_posts_for_query", - extra={"e": count_error}, - ) - props["true_count"] = 0 - props["count"] = max_offset - - results = extra_results[slice_offset : props["limit"] + slice_offset] - - ( - result_previews, - result_attachments, - result_is_image, - ) = get_render_data_for_posts(results) - - response = make_response( - render_template( - "posts.html", - props=props, - results=results, - base=query_params, - result_previews=result_previews, - result_attachments=result_attachments, - result_is_image=result_is_image, - ), - 200, - ) - # response.headers["Cache-Control"] = "no-store, max-age=0" - return response - - -@posts_bp.route("/posts/popular") -def popular_posts() -> Response: - query_params = get_query_parameters_dict(request) - earliest_date_for_popular = Configuration().webserver.get("earliest_date_for_popular") - # checked below but doesn't typecheck without a cast - scale: PeriodScale = cast(PeriodScale, query_params.get("period", "recent")) - - if scale not in get_args(PeriodScale): - scale = "recent" - - info, valid_date = parse_scale_string(query_params.get("date"), scale) - does_not_match_step_date = scale != "recent" and info.date.date() != info.navigation_dates[scale][2] - if ( - not valid_date - or does_not_match_step_date - or info.date.date() > datetime.date.today() - or info.date.date() < earliest_date_for_popular - ): - correct_date = info.navigation_dates[scale][2].isoformat() - if info.date.date() > datetime.date.today(): - correct_date = datetime.date.today() - scale = "day" - elif info.date.date() < earliest_date_for_popular: - correct_date = earliest_date_for_popular - scale = "day" - new_url = set_query_parameter(url_for("posts.popular_posts"), {"date": correct_date, "period": scale}) - response = redirect(new_url) - cache_seconds = int(datetime.timedelta(days=7).total_seconds()) - if info.date.date() > datetime.date.today(): - cache_seconds = int(datetime.timedelta(hours=3).total_seconds()) - response.headers["Cache-Control"] = f"max-age={cache_seconds}" - return response - expiry = int(datetime.timedelta(days=30).total_seconds()) - if scale == "recent": - expiry = int(datetime.timedelta(minutes=30 + 1).total_seconds()) - elif info.max_date > datetime.datetime.utcnow(): - if scale == "day": - expiry = int(datetime.timedelta(hours=3).total_seconds()) - elif scale == "week": - expiry = int(datetime.timedelta(days=1).total_seconds()) - elif scale == "month": - if datetime.date.today().day < 7: - expiry = int(datetime.timedelta(days=1).total_seconds()) - else: - expiry = int(datetime.timedelta(days=5).total_seconds()) - - pages = Configuration().webserver.get("pages_in_popular") - per_page = 50 - offset = positive_or_none(step_int(parse_int(query_params.pop("o", 0), 0), per_page)) - if offset is None: - response = redirect(url_for("posts.popular_posts")) - response.headers["Cache-Control"] = f"max-age={int(datetime.timedelta(days=7).total_seconds())}" - return response - posts = get_popular_posts_for_date_range( - info.min_date, info.max_date, scale, offset // per_page, per_page, pages, expiry - ) - (previews, attachments, is_image) = get_render_data_for_posts(posts) - - response = make_response( - render_template( - "posts/popular.html", - info=info, - props={ - "currentPage": "popular_posts", - "today": datetime.date.today(), - "earliest_date_for_popular": Configuration().webserver.get("earliest_date_for_popular"), - "limit": per_page, - "count": pages * per_page, - }, - results=posts, - base=query_params, - result_previews=previews, - result_attachments=attachments, - result_is_image=is_image, - ), - 200, - ) - response.headers["Cache-Control"] = f"max-age={int(expiry)}" - return response - - -@posts_bp.get("/posts/tags") -def list_tags(): - response = make_response( - render_template( - "tags.html", - props={"currentPage": "tags"}, - tags=get_all_tags(), - ), - ) - response.headers["Cache-Control"] = "s-maxage=3600" - return response - - -@posts_bp.route("/discord/server/") -def discord_server(server_id): - response = make_response(render_template("discord.html"), 200) - response.headers["Cache-Control"] = "s-maxage=600" - return response - - -@posts_bp.route("/posts/archives/") -def list_archive(file_hash: str): - archive = get_archive_files(file_hash) - response = make_response(render_template( - "posts/archive.html", - props={}, - archive=archive, - file_serving_enabled=Configuration().archive_server["file_serving_enabled"], - ), 200) - response.headers["Cache-Control"] = "s-maxage=600" - return response diff --git a/src/pages/random_.py b/src/pages/random_.py deleted file mode 100644 index 3c74a69..0000000 --- a/src/pages/random_.py +++ /dev/null @@ -1,45 +0,0 @@ -import random - -from flask import Blueprint, redirect, url_for - -from src.config import Configuration -from src.lib.artist import get_random_artist_keys -from src.lib.post import get_random_post_key - -random_bp = Blueprint("random", __name__) - - -@random_bp.route("/posts/random") -def random_post(): - post = get_random_post_key(Configuration().webserver.get("table_sample_bernoulli_sample_size")) - if post is None: - return redirect(url_for("posts.get_posts")) - - return redirect( - url_for( - "post.get", - service=post["service"], - artist_id=post["user"], - post_id=post["id"], - ) - ) - - -@random_bp.route("/artists/random") -def random_artist(): - """todo decide after random posts with redis list if its worth""" - artist = get_random_artist() - if artist is None: - return redirect(url_for("artists.list")) - - # currently we don't get random discord artists but anyway... - if artist["service"] == "discord": - return redirect(url_for("posts.discord_server", server_id=artist["id"])) - return redirect(url_for("artists.get", service=artist["service"], artist_id=artist["id"])) - - -def get_random_artist(): - artists = get_random_artist_keys(1000) - if len(artists) == 0: - return None - return random.choice(artists) diff --git a/src/pages/review_dms.py b/src/pages/review_dms.py deleted file mode 100644 index 98571a6..0000000 --- a/src/pages/review_dms.py +++ /dev/null @@ -1,47 +0,0 @@ -from flask import Blueprint, make_response, redirect, render_template, request, url_for - -from src.lib.dms import approve_dms, cleanup_unapproved_dms, get_unapproved_dms, clean_dms_already_approved -from src.pages.imports.types import DMPageProps -from src.types.account.account import Account -from src.types.props import SuccessProps -from src.utils.decorators import require_login - -review_dms_bp = Blueprint("review_dms", __name__) - - -@review_dms_bp.get("/account/review_dms") -@require_login -def importer_dms(user: Account): - account_id_int = int(user.id) - status = "ignored" if request.args.get("status") == "ignored" else "pending" - dms = get_unapproved_dms(account_id_int, request.args.get("status") == "ignored") - - props = DMPageProps(account_id=account_id_int, dms=dms, status=status) - - response = make_response( - render_template( - "review_dms/review_dms.html", - props=props, - ), - 200, - ) - - response.headers["Cache-Control"] = "max-age=0, private, must-revalidate" - return response - - -@review_dms_bp.post("/account/review_dms") -@require_login -def approve_importer_dms(user: Account): - props = SuccessProps(currentPage="import", redirect="/account/review_dms") - approved_hashes = request.form.getlist("approved_hashes") - delete_ignored = bool(request.form.get("delete_ignored", default=False)) - approve_dms(int(user.id), approved_hashes) - clean_dms_already_approved(int(user.id)) - cleanup_unapproved_dms(int(user.id)) - if delete_ignored: - cleanup_unapproved_dms(int(user.id), delete=True) - - response = make_response(render_template("success.html", props=props), 200) - response.headers["Cache-Control"] = "max-age=0, private, must-revalidate" - return response diff --git a/src/pages/revisions.py b/src/pages/revisions.py deleted file mode 100644 index 37c3c31..0000000 --- a/src/pages/revisions.py +++ /dev/null @@ -1,37 +0,0 @@ -from flask import Blueprint, make_response, redirect, render_template, url_for - -from src.config import Configuration -from src.lib.post import get_post_revisions -from src.pages.post import ready_post_props - -revisions_bp = Blueprint("revisions", __name__) - - -@revisions_bp.route("//user//post//revision/") -def get(service: str, artist_id: str, post_id: str, revision_id: str): - revisions = get_post_revisions(service, artist_id, post_id) if revision_id.isdigit() else [] - revision = next((rev for rev in revisions if rev["revision_id"] == int(revision_id)), None) - if not revision or not ( - service == revision["service"] and artist_id == revision["user"] and post_id == revision["id"] - ): - response = redirect(url_for("post.get", service=service, artist_id=artist_id, post_id=get)) - return response - - attachments, comments, previews, videos, props = ready_post_props(revision) - props["currentPage"] = "revisions" - - response = make_response( - render_template( - "post.html", - props=props, - post=revision, - comments=comments, - result_previews=previews, - result_attachments=attachments, - videos=videos, - archives_enabled=Configuration().archive_server["enabled"], - ), - 200, - ) - response.headers["Cache-Control"] = "s-maxage=600" - return response diff --git a/src/server.py b/src/server.py index 5e43bf1..69de826 100644 --- a/src/server.py +++ b/src/server.py @@ -3,7 +3,7 @@ import os import pathlib import jinja2 -from flask import Flask, g, make_response, render_template, request, send_from_directory, session +from flask import Flask, g, make_response, render_template, request, send_from_directory, session, jsonify from flask.json.provider import JSONProvider from src.config import Configuration @@ -39,22 +39,7 @@ import orjson from src.lib.account import is_logged_in, load_account from src.lib.notification import count_new_notifications from src.lib.post import get_fileserver_for_value -from src.pages.account import account_bp from src.pages.api import api_bp -from src.pages.artists import artists_bp -from src.pages.dms import dms_bp -from src.pages.favorites import favorites_bp -from src.pages.filehaus import filehaus_bp -from src.pages.files import files_bp -from src.pages.help import help_app_bp -from src.pages.home import home_bp -from src.pages.imports import importer_page_bp -from src.pages.post import post_bp -from src.pages.posts import posts_bp -from src.pages.random_ import random_bp -from src.pages.revisions import revisions_bp -from src.pages.review_dms import review_dms_bp -from src.pages.creator_link_requests import bp as link_request_bp from src.types.account import Account from src.utils.utils import ( freesites, @@ -68,21 +53,6 @@ from src.utils.utils import ( app.url_map.strict_slashes = False app.register_blueprint(api_bp) -app.register_blueprint(home_bp) -app.register_blueprint(artists_bp) -app.register_blueprint(random_bp) -app.register_blueprint(post_bp) -app.register_blueprint(posts_bp) -app.register_blueprint(revisions_bp) -app.register_blueprint(account_bp) -app.register_blueprint(favorites_bp) -app.register_blueprint(filehaus_bp) -app.register_blueprint(files_bp) -app.register_blueprint(importer_page_bp) -app.register_blueprint(dms_bp) -app.register_blueprint(review_dms_bp) -app.register_blueprint(help_app_bp, url_prefix="/help") -app.register_blueprint(link_request_bp) app.config.update( @@ -279,6 +249,23 @@ def do_finish_stuff(response): return response +# adding the handlers there because +# flask doesn't allow handling 404/405 in blueprints +# https://flask.palletsprojects.com/en/2.3.x/errorhandling/#blueprint-error-handlers +@app.errorhandler(404) +def route_not_found(error): + if request.path.startswith('/api/v1'): + return jsonify(error="Not Found"), 404 + + return error, 404 + +@app.errorhandler(405) +def method_not_allowed(error): + if request.path.startswith('/api/v1'): + return jsonify(error="Method Not Allowed"), 405 + + return error, 405 + @app.errorhandler(413) def upload_exceeded(error): props = {"redirect": request.headers.get("Referer") if request.headers.get("Referer") else "/"} diff --git a/src/types/paysites/__init__.py b/src/types/paysites/__init__.py index e650c9f..d4f6936 100644 --- a/src/types/paysites/__init__.py +++ b/src/types/paysites/__init__.py @@ -16,6 +16,12 @@ from .subscribestar import Subscribestar # duplicated in /client/src/utils/_index.js +# nothing can be done about that (for now) +# short of refactoring into JSON file and URL templates +# but those have their own issues and sometimes can be way less readable +# than string interpolations for respective languages +# not to mention different libs are going to parse templates +# with all the subtle interop issues class Paysites: afdian = Afdian() diff --git a/src/types/paysites/boosty.py b/src/types/paysites/boosty.py index 062d4d4..5b1a2b2 100644 --- a/src/types/paysites/boosty.py +++ b/src/types/paysites/boosty.py @@ -6,13 +6,13 @@ from .base import Paysite, Service_Post, Service_User @dataclass class User(Service_User): def profile(self, artist: dict) -> str: - return "" + return f"https://boosty.to/{(artist or {}).get('id')}" @dataclass class Post(Service_Post): def link(self, post_id: str, user_id: str) -> str: - return "" + return f"https://boosty.to/{user_id}/posts/{post_id}" @dataclass