├── .dockerignore ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── discussion---request-for-commentary--rfc-.md │ └── feature_request.md └── workflows │ ├── build.yml │ ├── frontend-lint.yml │ └── golangci-lint.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── docker ├── build │ └── x86_64 │ │ ├── Dockerfile │ │ ├── db │ │ └── initdb.sh │ │ └── docker-compose.yml ├── ci │ └── x86_64 │ │ ├── Dockerfile │ │ └── docker_push.sh └── production │ ├── docker-compose.yml │ └── postgres │ └── Dockerfile ├── frontend ├── .env.development ├── .env.development.local.shadow ├── .gitattributes ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .stylelintrc ├── README.md ├── apollo.config.js ├── codegen.yml ├── eslint.config.mjs ├── index.html ├── package.json ├── pnpm-lock.yaml ├── src │ ├── App.scss │ ├── App.tsx │ ├── Login.tsx │ ├── Main.tsx │ ├── components │ │ ├── changeRow │ │ │ ├── ChangeRow.tsx │ │ │ └── index.ts │ │ ├── checkboxSelect │ │ │ ├── CheckboxSelect.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── deleteButton │ │ │ ├── DeleteButton.tsx │ │ │ └── index.ts │ │ ├── editCard │ │ │ ├── AddComment.tsx │ │ │ ├── EditCard.tsx │ │ │ ├── EditComment.tsx │ │ │ ├── EditExpiration.tsx │ │ │ ├── EditHeader.tsx │ │ │ ├── EditStatus.tsx │ │ │ ├── ModifyEdit.tsx │ │ │ ├── VoteBar.tsx │ │ │ ├── Votes.tsx │ │ │ ├── index.ts │ │ │ ├── renderEntity.tsx │ │ │ └── styles.scss │ │ ├── editImages │ │ │ ├── editImages.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── form │ │ │ ├── BodyModification.tsx │ │ │ ├── EditNote.tsx │ │ │ ├── Image.tsx │ │ │ ├── NavButtons.tsx │ │ │ ├── NoteInput.tsx │ │ │ ├── SubmitButtons.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── fragments │ │ │ ├── ErrorMessage.tsx │ │ │ ├── Favorite.tsx │ │ │ ├── GenderIcon.tsx │ │ │ ├── Help.tsx │ │ │ ├── Icon.tsx │ │ │ ├── LoadingIndicator.tsx │ │ │ ├── PerformerName.tsx │ │ │ ├── SearchHint.tsx │ │ │ ├── SiteLink.tsx │ │ │ ├── TagLink.tsx │ │ │ ├── Thumbnail.tsx │ │ │ ├── Tooltip.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── image │ │ │ ├── Image.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── imageCarousel │ │ │ ├── ImageCarousel.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── imageChangeRow │ │ │ ├── ImageChangeRow.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── linkedChangeRow │ │ │ ├── LinkedChangeRow.tsx │ │ │ └── index.ts │ │ ├── list │ │ │ ├── EditList.tsx │ │ │ ├── List.tsx │ │ │ ├── SceneList.tsx │ │ │ ├── TagList.tsx │ │ │ ├── URLList.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── listChangeRow │ │ │ ├── ListChangeRow.tsx │ │ │ └── index.ts │ │ ├── modal │ │ │ ├── Modal.tsx │ │ │ └── index.ts │ │ ├── multiSelect │ │ │ ├── MultiSelect.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── pagination │ │ │ ├── Pagination.tsx │ │ │ └── index.ts │ │ ├── performerCard │ │ │ ├── PerformerCard.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── performerSelect │ │ │ ├── PerformerSelect.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── sceneCard │ │ │ ├── SceneCard.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── searchField │ │ │ ├── SearchField.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── studioSelect │ │ │ ├── StudioSelect.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── tagFilter │ │ │ ├── TagFilter.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── tagSelect │ │ │ ├── TagSelect.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── title │ │ │ ├── Title.tsx │ │ │ └── index.ts │ │ ├── urlChangeRow │ │ │ ├── URLChangeRow.tsx │ │ │ └── index.ts │ │ └── urlInput │ │ │ ├── index.ts │ │ │ ├── styles.scss │ │ │ └── urlInput.tsx │ ├── constants │ │ ├── enums.ts │ │ ├── index.ts │ │ └── route.ts │ ├── context.tsx │ ├── graphql │ │ ├── fragments │ │ │ ├── CommentFragment.gql │ │ │ ├── EditFragment.gql │ │ │ ├── FingerprintFragment.gql │ │ │ ├── ImageFragment.gql │ │ │ ├── PerformerFragment.gql │ │ │ ├── QuerySceneFragment.gql │ │ │ ├── SceneFragment.gql │ │ │ ├── ScenePerformerFragment.gql │ │ │ ├── SearchPerformerFragment.gql │ │ │ ├── StudioFragment.gql │ │ │ ├── TagFragment.gql │ │ │ └── URLFragment.gql │ │ ├── index.ts │ │ ├── mutations │ │ │ ├── ActivateNewUser.gql │ │ │ ├── AddImage.gql │ │ │ ├── AddScene.gql │ │ │ ├── AddSite.gql │ │ │ ├── AddStudio.gql │ │ │ ├── AddTagCategory.gql │ │ │ ├── AddUser.gql │ │ │ ├── ApplyEdit.gql │ │ │ ├── CancelEdit.gql │ │ │ ├── ChangePassword.gql │ │ │ ├── ConfirmChangeEmail.gql │ │ │ ├── DeleteDraft.gql │ │ │ ├── DeleteScene.gql │ │ │ ├── DeleteSite.gql │ │ │ ├── DeleteStudio.gql │ │ │ ├── DeleteTagCategory.gql │ │ │ ├── DeleteUser.gql │ │ │ ├── EditComment.gql │ │ │ ├── FavoritePerformer.gql │ │ │ ├── FavoriteStudio.gql │ │ │ ├── GenerateInviteCode.gql │ │ │ ├── GrantInvite.gql │ │ │ ├── MarkNotificationRead.gql │ │ │ ├── MarkNotificationsRead.gql │ │ │ ├── NewUser.gql │ │ │ ├── PerformerEdit.gql │ │ │ ├── PerformerEditUpdate.gql │ │ │ ├── RegenerateAPIKey.gql │ │ │ ├── RequestChangeEmail.gql │ │ │ ├── RescindInviteCode.gql │ │ │ ├── ResetPassword.gql │ │ │ ├── RevokeInvite.gql │ │ │ ├── SceneEdit.gql │ │ │ ├── SceneEditUpdate.gql │ │ │ ├── StudioEdit.gql │ │ │ ├── StudioEditUpdate.gql │ │ │ ├── TagEdit.gql │ │ │ ├── TagEditUpdate.gql │ │ │ ├── UnmatchFingerprint.gql │ │ │ ├── UpdateNotificationSubscriptions.gql │ │ │ ├── UpdateScene.gql │ │ │ ├── UpdateSite.gql │ │ │ ├── UpdateStudio.gql │ │ │ ├── UpdateTagCategory.gql │ │ │ ├── UpdateUser.gql │ │ │ ├── ValidateChangeEmail.gql │ │ │ ├── Vote.gql │ │ │ └── index.ts │ │ ├── queries │ │ │ ├── Categories.gql │ │ │ ├── Category.gql │ │ │ ├── Config.gql │ │ │ ├── Draft.gql │ │ │ ├── Drafts.gql │ │ │ ├── Edit.gql │ │ │ ├── EditUpdate.gql │ │ │ ├── Edits.gql │ │ │ ├── FullPerformer.gql │ │ │ ├── Me.gql │ │ │ ├── PendingEditsCount.gql │ │ │ ├── Performer.gql │ │ │ ├── Performers.gql │ │ │ ├── PublicUser.gql │ │ │ ├── QueryExistingPerformer.gql │ │ │ ├── QueryExistingScene.gql │ │ │ ├── QueryNotifications.gql │ │ │ ├── Scene.gql │ │ │ ├── ScenePairings.gql │ │ │ ├── Scenes.gql │ │ │ ├── ScenesWithFingerprints.gql │ │ │ ├── ScenesWithoutCount.gql │ │ │ ├── SearchAll.gql │ │ │ ├── SearchPerformers.gql │ │ │ ├── SearchTags.gql │ │ │ ├── Site.gql │ │ │ ├── Sites.gql │ │ │ ├── Studio.gql │ │ │ ├── StudioPerformers.gql │ │ │ ├── Studios.gql │ │ │ ├── Tag.gql │ │ │ ├── Tags.gql │ │ │ ├── UnreadNotificationCount.gql │ │ │ ├── User.gql │ │ │ ├── Users.gql │ │ │ ├── Version.gql │ │ │ └── index.ts │ │ ├── scalars.d.ts │ │ └── types.ts │ ├── hooks │ │ ├── index.ts │ │ ├── toast.scss │ │ ├── useAuth.tsx │ │ ├── useBeforeUnload.ts │ │ ├── useCurrentUser.tsx │ │ ├── useEditFilter.tsx │ │ ├── usePagination.ts │ │ ├── useQueryParams.ts │ │ └── useToast.tsx │ ├── index.tsx │ ├── modules.d.ts │ ├── pages │ │ ├── activateUser │ │ │ ├── ActivateUser.tsx │ │ │ └── index.ts │ │ ├── categories │ │ │ ├── Categories.tsx │ │ │ ├── Category.tsx │ │ │ ├── CategoryAdd.tsx │ │ │ ├── CategoryEdit.tsx │ │ │ ├── categoryForm │ │ │ │ ├── CategoryForm.tsx │ │ │ │ └── index.ts │ │ │ └── index.tsx │ │ ├── drafts │ │ │ ├── Draft.tsx │ │ │ ├── Drafts.tsx │ │ │ ├── PerformerDraft.tsx │ │ │ ├── SceneDraft.tsx │ │ │ ├── index.tsx │ │ │ └── parse.ts │ │ ├── edits │ │ │ ├── Edit.tsx │ │ │ ├── EditUpdate.tsx │ │ │ ├── Edits.tsx │ │ │ ├── components │ │ │ │ └── UpdateCount.tsx │ │ │ └── index.tsx │ │ ├── forgotPassword │ │ │ ├── ForgotPassword.tsx │ │ │ └── index.ts │ │ ├── home │ │ │ ├── Home.tsx │ │ │ ├── index.ts │ │ │ └── styles.scss │ │ ├── index.tsx │ │ ├── notifications │ │ │ ├── CommentNotification.tsx │ │ │ ├── EditNotification.tsx │ │ │ ├── Notification.tsx │ │ │ ├── Notifications.tsx │ │ │ ├── index.ts │ │ │ ├── sceneNotification.tsx │ │ │ ├── styles.scss │ │ │ └── types.ts │ │ ├── performers │ │ │ ├── Performer.tsx │ │ │ ├── PerformerAdd.tsx │ │ │ ├── PerformerDelete.tsx │ │ │ ├── PerformerEdit.tsx │ │ │ ├── PerformerEditUpdate.tsx │ │ │ ├── PerformerMerge.tsx │ │ │ ├── Performers.tsx │ │ │ ├── components │ │ │ │ ├── index.ts │ │ │ │ ├── performerInfo.tsx │ │ │ │ └── scenePairings.tsx │ │ │ ├── index.tsx │ │ │ ├── performerForm │ │ │ │ ├── ExistingPerformerAlert.tsx │ │ │ │ ├── PerformerForm.tsx │ │ │ │ ├── diff.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── types.ts │ │ │ └── styles.scss │ │ ├── registerUser │ │ │ ├── Register.tsx │ │ │ └── index.ts │ │ ├── resetPassword │ │ │ ├── ResetPassword.tsx │ │ │ └── index.ts │ │ ├── scenes │ │ │ ├── Scene.tsx │ │ │ ├── SceneAdd.tsx │ │ │ ├── SceneDelete.tsx │ │ │ ├── SceneEdit.tsx │ │ │ ├── SceneEditUpdate.tsx │ │ │ ├── Scenes.tsx │ │ │ ├── components │ │ │ │ └── fingerprints.tsx │ │ │ ├── index.tsx │ │ │ ├── sceneForm │ │ │ │ ├── ExistingSceneAlert.tsx │ │ │ │ ├── SceneForm.tsx │ │ │ │ ├── diff.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── styles.scss │ │ │ │ └── types.ts │ │ │ └── styles.scss │ │ ├── search │ │ │ ├── Search.tsx │ │ │ ├── index.ts │ │ │ └── search.scss │ │ ├── sites │ │ │ ├── Site.tsx │ │ │ ├── SiteAdd.tsx │ │ │ ├── SiteEdit.tsx │ │ │ ├── Sites.tsx │ │ │ ├── index.tsx │ │ │ └── siteForm │ │ │ │ ├── SiteForm.tsx │ │ │ │ └── index.ts │ │ ├── studios │ │ │ ├── Studio.tsx │ │ │ ├── StudioAdd.tsx │ │ │ ├── StudioDelete.tsx │ │ │ ├── StudioEdit.tsx │ │ │ ├── StudioEditUpdate.tsx │ │ │ ├── Studios.tsx │ │ │ ├── components │ │ │ │ ├── index.ts │ │ │ │ └── studioPerformers.tsx │ │ │ ├── index.tsx │ │ │ ├── studioForm │ │ │ │ ├── StudioForm.tsx │ │ │ │ ├── diff.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── types.ts │ │ │ └── styles.scss │ │ ├── tags │ │ │ ├── Tag.tsx │ │ │ ├── TagAdd.tsx │ │ │ ├── TagDelete.tsx │ │ │ ├── TagEdit.tsx │ │ │ ├── TagEditUpdate.tsx │ │ │ ├── TagMerge.tsx │ │ │ ├── Tags.tsx │ │ │ ├── index.tsx │ │ │ └── tagForm │ │ │ │ ├── TagForm.tsx │ │ │ │ ├── diff.ts │ │ │ │ ├── index.ts │ │ │ │ ├── schema.ts │ │ │ │ └── types.ts │ │ ├── users │ │ │ ├── GenerateInviteKeyModal.tsx │ │ │ ├── User.tsx │ │ │ ├── UserAdd.tsx │ │ │ ├── UserConfirmChangeEmail.tsx │ │ │ ├── UserEdit.tsx │ │ │ ├── UserEditForm.tsx │ │ │ ├── UserEdits.tsx │ │ │ ├── UserFingerprints.tsx │ │ │ ├── UserFingerprintsList │ │ │ │ ├── UserFingerprint.tsx │ │ │ │ ├── UserFingerprintsList.tsx │ │ │ │ ├── UserSceneLine.tsx │ │ │ │ └── index.ts │ │ │ ├── UserForm.tsx │ │ │ ├── UserNotificationPreferences.tsx │ │ │ ├── UserPassword.tsx │ │ │ ├── UserPasswordForm.tsx │ │ │ ├── UserValidateChangeEmail.tsx │ │ │ ├── Users.tsx │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ └── version │ │ │ ├── Version.tsx │ │ │ └── index.ts │ ├── styles │ │ └── theme.scss │ └── utils │ │ ├── country.ts │ │ ├── createClient.ts │ │ ├── data.ts │ │ ├── date.ts │ │ ├── diff.ts │ │ ├── edit.ts │ │ ├── enum.ts │ │ ├── general.ts │ │ ├── index.ts │ │ ├── intl.ts │ │ ├── markdown.tsx │ │ ├── route.ts │ │ ├── transforms.ts │ │ ├── url.ts │ │ └── user.ts ├── tsconfig.json └── vite.config.mjs ├── go.mod ├── go.sum ├── gqlgen.yml ├── graphql └── schema │ ├── schema.graphql │ └── types │ ├── config.graphql │ ├── draft.graphql │ ├── edit.graphql │ ├── filter.graphql │ ├── image.graphql │ ├── misc.graphql │ ├── notifications.graphql │ ├── performer.graphql │ ├── scene.graphql │ ├── site.graphql │ ├── studio.graphql │ ├── tag.graphql │ ├── user.graphql │ └── version.graphql ├── main.go ├── pkg ├── api │ ├── authorization.go │ ├── context_keys.go │ ├── directives.go │ ├── edit_integration_test.go │ ├── factory.go │ ├── field_test.go │ ├── graphql_client_test.go │ ├── integration_test.go │ ├── performer_edit_integration_test.go │ ├── performer_integration_test.go │ ├── resolver.go │ ├── resolver_model_draft.go │ ├── resolver_model_edit.go │ ├── resolver_model_edit_comment.go │ ├── resolver_model_edit_vote.go │ ├── resolver_model_image.go │ ├── resolver_model_notification.go │ ├── resolver_model_performer.go │ ├── resolver_model_performer_draft.go │ ├── resolver_model_performer_edit.go │ ├── resolver_model_scene.go │ ├── resolver_model_scene_draft.go │ ├── resolver_model_scene_edit.go │ ├── resolver_model_site.go │ ├── resolver_model_studio.go │ ├── resolver_model_studio_edit.go │ ├── resolver_model_tag.go │ ├── resolver_model_tag_category.go │ ├── resolver_model_tag_edit.go │ ├── resolver_model_url.go │ ├── resolver_model_user.go │ ├── resolver_mutation_draft.go │ ├── resolver_mutation_edit.go │ ├── resolver_mutation_image.go │ ├── resolver_mutation_notifications.go │ ├── resolver_mutation_performer.go │ ├── resolver_mutation_scene.go │ ├── resolver_mutation_site.go │ ├── resolver_mutation_studio.go │ ├── resolver_mutation_tag.go │ ├── resolver_mutation_tag_category.go │ ├── resolver_mutation_user.go │ ├── resolver_query_find_draft.go │ ├── resolver_query_find_edit.go │ ├── resolver_query_find_notifications.go │ ├── resolver_query_find_performer.go │ ├── resolver_query_find_scene.go │ ├── resolver_query_find_site.go │ ├── resolver_query_find_studio.go │ ├── resolver_query_find_tag.go │ ├── resolver_query_find_tag_category.go │ ├── resolver_query_find_user.go │ ├── resolver_query_notifications.go │ ├── resolver_query_search.go │ ├── routes_image.go │ ├── routes_root.go │ ├── scene_edit_integration_test.go │ ├── scene_integration_test.go │ ├── search_integration_test.go │ ├── server.go │ ├── session.go │ ├── sql_resolver.go │ ├── studio_edit_integration_test.go │ ├── studio_integration_test.go │ ├── tag_category_integration_test.go │ ├── tag_edit_integration_test.go │ ├── tag_integration_test.go │ └── user_integration_test.go ├── database │ ├── database.go │ ├── databasetest │ │ └── database_test_utils.go │ ├── migrations │ │ └── postgres │ │ │ ├── 10_tag_categories.up.sql │ │ │ ├── 11_image_constraints.up.sql │ │ │ ├── 12_fix_performer_trigger.up.sql │ │ │ ├── 13_sort_indexes.up.sql │ │ │ ├── 14_phash_distance_search.up.sql │ │ │ ├── 15_scene_fingerprint_submissions.up.sql │ │ │ ├── 16_fix_scene_update_trigger.up.sql │ │ │ ├── 17_edit_votes.up.sql │ │ │ ├── 18_fingerprint_user.up.sql │ │ │ ├── 19_scene_created_index.up.sql │ │ │ ├── 1_initial.down.sql │ │ │ ├── 1_initial.up.sql │ │ │ ├── 20_edit_constraints.up.sql │ │ │ ├── 21_site_urls.up.sql │ │ │ ├── 22_performer_search_indexes.up.sql │ │ │ ├── 23_favorites.up.sql │ │ │ ├── 24_drafts.up.sql │ │ │ ├── 25_scene_codes.up.sql │ │ │ ├── 26_scene_partial_date.down.sql │ │ │ ├── 26_scene_partial_date.up.sql │ │ │ ├── 27_edit_closed_at.up.sql │ │ │ ├── 28_studio_favorite_index.up.sql │ │ │ ├── 29_scene_edit_fingerprint_index.up.sql │ │ │ ├── 2_create_search.down.sql │ │ │ ├── 2_create_search.up.sql │ │ │ ├── 30_edit_bot.up.sql │ │ │ ├── 31_scenes_deleted_idx.up.sql │ │ │ ├── 32_edit_indexes.up.sql │ │ │ ├── 33_invite_key_uses.up.sql │ │ │ ├── 34_fingerprints.up.sql │ │ │ ├── 35_websearch.up.sql │ │ │ ├── 36_drop_unique_invite.up.sql │ │ │ ├── 37_tokens.up.sql │ │ │ ├── 38_scenes_studio_id_index.up.sql │ │ │ ├── 39_edits_updates.up.sql │ │ │ ├── 3_misc.up.sql │ │ │ ├── 40_fingerprint_vote.up.sql │ │ │ ├── 41_notifications.up.sql │ │ │ ├── 42_date_columns.up.sql │ │ │ ├── 43_studio_aliases.up.sql │ │ │ ├── 44_performer_death_date.up.sql │ │ │ ├── 45_scene_production_date.up.sql │ │ │ ├── 46_update_default_notifications.up.sql │ │ │ ├── 47_favorite_unique.up.sql │ │ │ ├── 48_fingerprinted_scene_edit_notification.up.sql │ │ │ ├── 49_entity_search_lower_idx.up.sql │ │ │ ├── 4_image_tables.up.sql │ │ │ ├── 5_edits.up.sql │ │ │ ├── 6_deletion_and_redirects.up.sql │ │ │ ├── 7_optimization_indexes.up.sql │ │ │ ├── 8_user_invite.up.sql │ │ │ └── 9_image_data.up.sql │ └── postgres.go ├── dataloader │ ├── bodymodificationsloader_gen.go │ ├── boolsloader_gen.go │ ├── editcommentloader_gen.go │ ├── editloader_gen.go │ ├── fingerprintsloader_gen.go │ ├── imageloader_gen.go │ ├── loaders.go │ ├── performerloader_gen.go │ ├── sceneappearancesloader_gen.go │ ├── sceneloader_gen.go │ ├── siteloader_gen.go │ ├── stringsloader_gen.go │ ├── studioloader_gen.go │ ├── submittedfingerprintsloader_gen.go │ ├── tagcategoryloader_gen.go │ ├── tagloader_gen.go │ ├── urlloader_gen.go │ └── uuidsloader_gen.go ├── draft │ └── draft.go ├── edit │ └── slice_merge.go ├── email │ └── email.go ├── image │ ├── cache.go │ ├── favicon.go │ ├── file.go │ ├── image_backend.go │ ├── image_service.go │ ├── resize_unix.go │ ├── resize_windows.go │ ├── s3.go │ ├── service.go │ └── utils.go ├── logger │ ├── logger.go │ ├── otel.go │ └── progress_formatter.go ├── manager │ ├── config │ │ └── config.go │ ├── cron │ │ └── cron.go │ ├── edit │ │ ├── edit.go │ │ ├── performer.go │ │ ├── scene.go │ │ ├── studio.go │ │ ├── tag.go │ │ └── validate.go │ ├── manager.go │ ├── notifications │ │ └── notifications.go │ └── paths │ │ └── paths.go ├── models │ ├── activation.go │ ├── draft.go │ ├── edit.go │ ├── errors.go │ ├── extension_criterion_input.go │ ├── extension_edit_details.go │ ├── extension_edit_details_test.go │ ├── extension_role_enum.go │ ├── factory.go │ ├── generated_exec.go │ ├── generated_models.go │ ├── image.go │ ├── invite_key.go │ ├── joins.go │ ├── json_time.go │ ├── model_draft.go │ ├── model_edit.go │ ├── model_image.go │ ├── model_invite_key.go │ ├── model_joins.go │ ├── model_notification.go │ ├── model_performer.go │ ├── model_performer_test.go │ ├── model_scene.go │ ├── model_site.go │ ├── model_studio.go │ ├── model_tag.go │ ├── model_tag_category.go │ ├── model_tag_test.go │ ├── model_user.go │ ├── model_user_tokens.go │ ├── notification.go │ ├── performer.go │ ├── scalars.go │ ├── scene.go │ ├── site.go │ ├── sql_translate.go │ ├── sql_translate_test.go │ ├── sqlite_date.go │ ├── studio.go │ ├── tag.go │ ├── tag_category.go │ ├── translate.go │ └── user.go ├── scene │ └── scene.go ├── sqlx │ ├── dbi.go │ ├── factory.go │ ├── fingerprints.go │ ├── null.go │ ├── querybuilder_draft.go │ ├── querybuilder_edit.go │ ├── querybuilder_image.go │ ├── querybuilder_invite_key.go │ ├── querybuilder_joins.go │ ├── querybuilder_notifications.go │ ├── querybuilder_performer.go │ ├── querybuilder_scene.go │ ├── querybuilder_site.go │ ├── querybuilder_sql.go │ ├── querybuilder_studio.go │ ├── querybuilder_tag.go │ ├── querybuilder_tag_category.go │ ├── querybuilder_user.go │ ├── querybuilder_user_token.go │ ├── sql.go │ ├── table.go │ └── transaction.go ├── txn │ └── state.go ├── user │ ├── activation.go │ ├── apikey.go │ ├── authorization.go │ ├── email.go │ ├── invite.go │ ├── invite_test.go │ ├── templates │ │ ├── email.html │ │ └── email.txt │ ├── user.go │ └── user_test.go └── utils │ ├── arguments.go │ ├── crypto.go │ ├── date.go │ ├── enum.go │ ├── error.go │ ├── file.go │ ├── json.go │ ├── map.go │ ├── map_test.go │ ├── password_blacklist.go │ ├── slice_compare.go │ ├── slice_compare_test.go │ └── string.go ├── scripts └── getDate.go └── tools.go /.dockerignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | #### 15 | # Visual Studio Code 16 | #### 17 | .vscode 18 | 19 | #### 20 | # Jetbrains 21 | #### 22 | 23 | # User-specific stuff 24 | .idea/**/workspace.xml 25 | .idea/**/tasks.xml 26 | .idea/**/usage.statistics.xml 27 | .idea/**/dictionaries 28 | .idea/**/shelf 29 | 30 | # Generated files 31 | .idea/**/contentModel.xml 32 | 33 | # Sensitive or high-churn files 34 | .idea/**/dataSources/ 35 | .idea/**/dataSources.ids 36 | .idea/**/dataSources.local.xml 37 | .idea/**/sqlDataSources.xml 38 | .idea/**/dynamic.xml 39 | .idea/**/uiDesigner.xml 40 | .idea/**/dbnavigator.xml 41 | 42 | # Vim 43 | *.swp 44 | 45 | #### 46 | # Random 47 | #### 48 | 49 | frontend/node_modules/* 50 | frontend/build/* 51 | 52 | *.db 53 | 54 | stashdb 55 | stash-box 56 | dist 57 | 58 | pkg/models/generated_*.go 59 | # TODO - we'll add this in later 60 | vendor 61 | docker 62 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | go.mod text eol=lf 2 | go.sum text eol=lf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[Bug Report] Short Form Subject (50 Chars or less)" 5 | labels: help wanted 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem please ensure that your screenshots are SFW or at least appropriately censored. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/discussion---request-for-commentary--rfc-.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Discussion / Request for Commentary [RFC] 3 | about: This is for issues that will be discussed and won't necessarily result directly 4 | in commits or pull requests. 5 | title: "[RFC] Short Form Title" 6 | labels: help wanted 7 | assignees: '' 8 | 9 | --- 10 | 11 | 16 | 17 | ## Long Form 18 | 19 | 20 | ## Examples 21 | 22 | 23 | ## Reference Reading 24 | 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[Feature] Short Form Title (50 chars or less.)" 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/frontend-lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint (frontend) 2 | on: 3 | push: 4 | pull_request: 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-24.04 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Install Node 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '22' 21 | 22 | - name: Install PNPM 23 | uses: pnpm/action-setup@v4 24 | with: 25 | version: 9 26 | 27 | - name: Cache node packages 28 | uses: actions/cache@v4 29 | env: 30 | cache-name: cache-node_modules 31 | with: 32 | path: frontend/node_modules 33 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('frontend/pnpm-lock.yaml') }} 34 | 35 | - name: Install node packages 36 | run: make pre-ui 37 | 38 | - name: Validate UI 39 | run: make ui-validate 40 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint (golangci-lint) 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | golangci: 8 | name: lint 9 | runs-on: ubuntu-24.04 10 | 11 | steps: 12 | - name: Install vips 13 | run: sudo apt install -y libvips-dev 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-go@v5 16 | with: 17 | go-version: 1.23.x 18 | - run: mkdir frontend/build && touch frontend/build/dummy 19 | - name: Run golangci-lint 20 | uses: golangci/golangci-lint-action@v6 21 | with: 22 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 23 | version: latest 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | #### 15 | # Visual Studio Code 16 | #### 17 | .vscode 18 | 19 | #### 20 | # Jetbrains 21 | #### 22 | 23 | # User-specific stuff 24 | .idea/**/workspace.xml 25 | .idea/**/tasks.xml 26 | .idea/**/usage.statistics.xml 27 | .idea/**/dictionaries 28 | .idea/**/shelf 29 | 30 | # Generated files 31 | .idea/**/contentModel.xml 32 | 33 | # Sensitive or high-churn files 34 | .idea/**/dataSources/ 35 | .idea/**/dataSources.ids 36 | .idea/**/dataSources.local.xml 37 | .idea/**/sqlDataSources.xml 38 | .idea/**/dynamic.xml 39 | .idea/**/uiDesigner.xml 40 | .idea/**/dbnavigator.xml 41 | 42 | # Vim 43 | *.swp 44 | 45 | # macOS 46 | .DS_Store 47 | 48 | #### 49 | # Random 50 | #### 51 | 52 | node_modules 53 | 54 | *.db 55 | .go-cache/ 56 | 57 | stashdb 58 | stash-box 59 | dist 60 | 61 | stash-box-config.yml 62 | 63 | # TODO - we'll add this in later 64 | vendor 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 stashapp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docker/build/x86_64/db/initdb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" -c 'CREATE DATABASE "stash-box";' && \ 5 | psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "stash-box" -c 'CREATE EXTENSION pg_trgm; CREATE EXTENSION pgcrypto;' 6 | -------------------------------------------------------------------------------- /docker/build/x86_64/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # APPNICENAME=Stash-box 2 | # APPDESCRIPTION=Stash App's own OpenSource video indexing and Perceptual Hashing MetaData API for porn 3 | version: '3.4' 4 | services: 5 | db: 6 | image: postgres:12.3 7 | restart: always 8 | environment: 9 | POSTGRES_PASSWORD: stash-box-db 10 | volumes: 11 | - ./stash-box-data/data:/var/lib/postgresql/data 12 | - ./db:/docker-entrypoint-initdb.d 13 | stash-box: 14 | image: stash-box/build:latest 15 | restart: always 16 | depends_on: 17 | - "db" 18 | environment: 19 | STASH_BOX_DATABASE: postgres:stash-box-db@db/stash-box?sslmode=disable 20 | ports: 21 | - 9998:9998 22 | volumes: 23 | - /etc/localtime:/etc/localtime:ro 24 | ## Adjust below paths (the left part) to your liking. 25 | ## E.g. you can change ./config:/root/.stash to ./stash:/root/.stash 26 | 27 | ## Keep configs here. 28 | - ./config:/root/.stash-box 29 | -------------------------------------------------------------------------------- /docker/ci/x86_64/Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | # must be built from /dist directory 3 | 4 | FROM ubuntu:24.04 as app 5 | LABEL MAINTAINER="https://discord.gg/Uz29ny" 6 | 7 | RUN apt-get update && apt-get install -y libvips 8 | 9 | COPY stash-box-linux /usr/bin/stash-box 10 | 11 | EXPOSE 9998 12 | CMD ["stash-box", "--config_file", "/root/.stash-box/stash-box-config.yml"] 13 | -------------------------------------------------------------------------------- /docker/ci/x86_64/docker_push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DOCKER_TAG=$1 4 | 5 | # must build the image from dist directory 6 | echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin 7 | docker build -t stashapp/stash-box:$DOCKER_TAG -f ./docker/ci/x86_64/Dockerfile ./dist 8 | 9 | docker push stashapp/stash-box:$DOCKER_TAG 10 | -------------------------------------------------------------------------------- /docker/production/postgres/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:14.2 2 | 3 | RUN buildDeps='git make gcc postgresql-server-dev-14' \ 4 | && apt update && apt install -y $buildDeps --no-install-recommends --reinstall ca-certificates \ 5 | && git clone https://github.com/fake-name/pg-spgist_hamming.git \ 6 | && make -C pg-spgist_hamming/bktree \ 7 | && make -C pg-spgist_hamming/bktree install \ 8 | && rm -rf pg-spgist_hamming \ 9 | && apt purge -y --auto-remove $buildDeps 10 | 11 | EXPOSE 5432 12 | CMD docker-entrypoint.sh postgres 13 | -------------------------------------------------------------------------------- /frontend/.env.development: -------------------------------------------------------------------------------- 1 | PORT=3001 2 | -------------------------------------------------------------------------------- /frontend/.env.development.local.shadow: -------------------------------------------------------------------------------- 1 | VITE_APIKEY= 2 | #VITE_SERVER_URL=https://stash server.example 3 | #VITE_SERVER_PORT=443 4 | #PORT=3001 5 | -------------------------------------------------------------------------------- /frontend/.gitattributes: -------------------------------------------------------------------------------- 1 | src/**/*.ts* text eol=lf 2 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | dist/ 3 | node_modules/ 4 | src/**/*.jsx 5 | tests/__coverage__/ 6 | tests/**/*.jsx 7 | .eslintcache 8 | .env*.local 9 | build 10 | *.swp 11 | -------------------------------------------------------------------------------- /frontend/.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | generated-graphql/ 2 | definitions/ -------------------------------------------------------------------------------- /frontend/.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "stylelint-scss" 4 | ], 5 | "extends": [ 6 | "stylelint-config-standard", 7 | "stylelint-config-standard-scss" 8 | ], 9 | "rules": { 10 | "selector-class-pattern": null, 11 | "property-no-vendor-prefix": null 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /frontend/apollo.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const globule = require("globule"); 3 | 4 | const schemaPath = path.resolve(__dirname, "../graphql/schema"); 5 | 6 | /** @type {import("apollo").ApolloConfig} */ 7 | module.exports = { 8 | client: { 9 | service: { 10 | name: "stash-box", 11 | localSchemaFile: [ 12 | ...globule.find({ 13 | src: "**/*.graphql", 14 | cwd: schemaPath, 15 | prefixBase: true, 16 | ignore: "schema.graphql", 17 | }), 18 | path.join(schemaPath, "./schema.graphql"), 19 | ], 20 | }, 21 | excludes: [ 22 | "**/queries/**/_*", 23 | "**/mutations/**/_*", 24 | "**/__tests__/**/*", 25 | "**/node_modules", 26 | ], 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /frontend/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: "../graphql/schema/**/*.graphql" 3 | documents: "src/graphql/**/*.gql" 4 | generates: 5 | src/graphql/types.ts: 6 | plugins: 7 | - typescript 8 | - typescript-operations 9 | - typed-document-node 10 | config: 11 | dedupeOperationSuffix: true 12 | scalars: 13 | Date: string 14 | DateTime: string 15 | Time: string 16 | Upload: File 17 | namingConvention: 18 | enumValues: change-case-all#upperCase 19 | nonOptionalTypename: true 20 | hooks: 21 | afterAllFileWrite: 22 | - prettier --write 23 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | {{.}} 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { ApolloProvider } from "@apollo/client"; 3 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 4 | 5 | import Main from "src/Main"; 6 | import createClient from "src/utils/createClient"; 7 | import Pages from "src/pages"; 8 | import Title from "src/components/title"; 9 | import { ToastProvider } from "src/hooks/useToast"; 10 | 11 | import "./App.scss"; 12 | 13 | const client = createClient(); 14 | 15 | const App: FC = () => ( 16 | 17 | 18 | 19 | 20 | 24 | 25 | <Pages /> 26 | </Main> 27 | } 28 | /> 29 | </Routes> 30 | </ToastProvider> 31 | </BrowserRouter> 32 | </ApolloProvider> 33 | ); 34 | 35 | export default App; 36 | -------------------------------------------------------------------------------- /frontend/src/components/changeRow/ChangeRow.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Col, Row } from "react-bootstrap"; 3 | import cx from "classnames"; 4 | 5 | export interface ChangeRowProps { 6 | name?: string; 7 | newValue?: string | number | null; 8 | oldValue?: string | number | null; 9 | showDiff?: boolean; 10 | } 11 | 12 | const ChangeRow: FC<ChangeRowProps> = ({ 13 | name, 14 | newValue, 15 | oldValue, 16 | showDiff = false, 17 | }) => 18 | name && (newValue || oldValue) ? ( 19 | <Row className="mb-2"> 20 | <b className="col-2 text-end pt-1">{name}</b> 21 | {showDiff && ( 22 | <Col xs={5}> 23 | <div className="EditDiff bg-danger">{oldValue}</div> 24 | </Col> 25 | )} 26 | <Col xs={showDiff ? 5 : 10}> 27 | <div className={cx("EditDiff", { "bg-success": showDiff })}> 28 | {newValue} 29 | </div> 30 | </Col> 31 | </Row> 32 | ) : ( 33 | <></> 34 | ); 35 | 36 | export default ChangeRow; 37 | -------------------------------------------------------------------------------- /frontend/src/components/changeRow/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ChangeRow"; 2 | -------------------------------------------------------------------------------- /frontend/src/components/checkboxSelect/index.ts: -------------------------------------------------------------------------------- 1 | import CheckboxSelect from "./CheckboxSelect"; 2 | 3 | export default CheckboxSelect; 4 | -------------------------------------------------------------------------------- /frontend/src/components/checkboxSelect/styles.scss: -------------------------------------------------------------------------------- 1 | .CheckboxSelect { 2 | width: 20rem; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/components/deleteButton/index.ts: -------------------------------------------------------------------------------- 1 | import DeleteButton from "./DeleteButton"; 2 | 3 | export default DeleteButton; 4 | -------------------------------------------------------------------------------- /frontend/src/components/editCard/EditComment.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Card } from "react-bootstrap"; 3 | import { Link } from "react-router-dom"; 4 | 5 | import { formatDateTime, userHref, Markdown } from "src/utils"; 6 | 7 | const CLASSNAME = "EditComment"; 8 | 9 | interface Props { 10 | id: string; 11 | comment: string; 12 | date: string; 13 | user?: { name: string; id: string } | null; 14 | } 15 | 16 | const EditComment: FC<Props> = ({ id, comment, date, user }) => ( 17 | <Card className={CLASSNAME}> 18 | <Card.Body className="pb-0"> 19 | <Markdown text={comment} unique={id} /> 20 | </Card.Body> 21 | <Card.Footer className="text-end"> 22 | {user ? ( 23 | <Link to={userHref(user)}>{user.name}</Link> 24 | ) : ( 25 | <span>Deleted User</span> 26 | )} 27 | <span className="mx-1">•</span> 28 | <span>{formatDateTime(date, false)}</span> 29 | </Card.Footer> 30 | </Card> 31 | ); 32 | 33 | export default EditComment; 34 | -------------------------------------------------------------------------------- /frontend/src/components/editCard/index.ts: -------------------------------------------------------------------------------- 1 | import EditCard from "./EditCard"; 2 | 3 | export default EditCard; 4 | -------------------------------------------------------------------------------- /frontend/src/components/editImages/index.ts: -------------------------------------------------------------------------------- 1 | import EditImages from "./editImages"; 2 | 3 | export default EditImages; 4 | -------------------------------------------------------------------------------- /frontend/src/components/form/EditNote.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Form } from "react-bootstrap"; 3 | import cx from "classnames"; 4 | import { FieldError, UseFormRegister } from "react-hook-form"; 5 | 6 | import NoteInput from "./NoteInput"; 7 | 8 | interface Props { 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | register: UseFormRegister<any>; 11 | error?: FieldError; 12 | } 13 | 14 | const EditNote: FC<Props> = ({ register, error }) => ( 15 | <div className="mb-3"> 16 | <Form.Label>Edit Note</Form.Label> 17 | <NoteInput 18 | className={cx({ "is-invalid": error })} 19 | register={register} 20 | hasError={!!error?.message} 21 | /> 22 | <Form.Text> 23 | Please add any relevant sources or other supporting information for your 24 | edit. 25 | </Form.Text> 26 | <Form.Control.Feedback type="invalid"> 27 | {error?.message} 28 | </Form.Control.Feedback> 29 | </div> 30 | ); 31 | 32 | export default EditNote; 33 | -------------------------------------------------------------------------------- /frontend/src/components/form/Image.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Button } from "react-bootstrap"; 3 | import { faXmark } from "@fortawesome/free-solid-svg-icons"; 4 | 5 | import { Icon } from "src/components/fragments"; 6 | import Image from "src/components/image"; 7 | import { ImageFragment } from "src/graphql"; 8 | 9 | interface ImageProps { 10 | image: Pick<ImageFragment, "id" | "url" | "width" | "height">; 11 | onRemove: () => void; 12 | } 13 | 14 | const CLASSNAME = "ImageInput"; 15 | const CLASSNAME_IMAGE = `${CLASSNAME}-image`; 16 | const CLASSNAME_REMOVE = `${CLASSNAME}-remove`; 17 | 18 | const ImageInput: FC<ImageProps> = ({ image, onRemove }) => ( 19 | <div className={CLASSNAME}> 20 | <Button 21 | variant="danger" 22 | className={CLASSNAME_REMOVE} 23 | onClick={() => onRemove()} 24 | > 25 | <Icon icon={faXmark} /> 26 | </Button> 27 | <Image images={image} className={CLASSNAME_IMAGE} size="full" /> 28 | </div> 29 | ); 30 | 31 | export default ImageInput; 32 | -------------------------------------------------------------------------------- /frontend/src/components/form/NavButtons.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Button } from "react-bootstrap"; 3 | import { useNavigate } from "react-router-dom"; 4 | 5 | interface Props { 6 | onNext: () => void; 7 | disabled?: boolean; 8 | } 9 | 10 | export const NavButtons: FC<Props> = ({ onNext, disabled = false }) => { 11 | const navigate = useNavigate(); 12 | return ( 13 | <div className="d-flex mt-2"> 14 | <Button 15 | variant="danger" 16 | className="ms-auto me-2" 17 | onClick={() => navigate(-1)} 18 | > 19 | Cancel 20 | </Button> 21 | <Button className="me-1" onClick={onNext} disabled={disabled}> 22 | Next 23 | </Button> 24 | </div> 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/components/form/SubmitButtons.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Button } from "react-bootstrap"; 3 | import { useNavigate } from "react-router-dom"; 4 | 5 | interface Props { 6 | disabled?: boolean; 7 | } 8 | 9 | export const SubmitButtons: FC<Props> = ({ disabled = false }) => { 10 | const navigate = useNavigate(); 11 | return ( 12 | <div className="d-flex mt-2"> 13 | <Button 14 | variant="danger" 15 | className="ms-auto me-2" 16 | onClick={() => navigate(-1)} 17 | > 18 | Cancel 19 | </Button> 20 | <Button type="submit" disabled className="d-none" aria-hidden="true" /> 21 | <Button type="submit" disabled={disabled}> 22 | Submit Edit 23 | </Button> 24 | </div> 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /frontend/src/components/form/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BodyModification } from "./BodyModification"; 2 | export { default as Image } from "./Image"; 3 | export { default as EditNote } from "./EditNote"; 4 | export { default as NoteInput } from "./NoteInput"; 5 | export * from "./NavButtons"; 6 | export * from "./SubmitButtons"; 7 | -------------------------------------------------------------------------------- /frontend/src/components/form/styles.scss: -------------------------------------------------------------------------------- 1 | .BodyModification { 2 | &-remove { 3 | z-index: 2; 4 | background: transparent; 5 | border: none; 6 | color: rgba(0 0 0 / 50%); 7 | width: 10%; 8 | 9 | &:hover { 10 | color: rgba(0 0 0 / 80%); 11 | } 12 | } 13 | 14 | &-select { 15 | width: 100%; 16 | } 17 | 18 | &-label { 19 | width: 90%; 20 | } 21 | 22 | &-location { 23 | display: inline-block; 24 | margin-right: 5%; 25 | overflow: hidden; 26 | text-overflow: ellipsis; 27 | vertical-align: middle; 28 | white-space: nowrap; 29 | width: 30%; 30 | } 31 | 32 | .form-control { 33 | display: inline; 34 | width: 65%; 35 | } 36 | 37 | h6 { 38 | text-transform: capitalize; 39 | } 40 | } 41 | 42 | .ImageInput { 43 | margin: 0.5rem; 44 | position: relative; 45 | 46 | &-image { 47 | border-radius: 3px; 48 | height: 200px; 49 | } 50 | 51 | &-remove { 52 | z-index: 2; 53 | left: 5px; 54 | opacity: 0.6; 55 | padding: 0 5px; 56 | position: absolute; 57 | top: 5px; 58 | } 59 | 60 | &:hover &-remove { 61 | opacity: 1; 62 | } 63 | } 64 | 65 | .NoteInput { 66 | .tab-content { 67 | padding-bottom: 0; 68 | } 69 | 70 | .EditComment { 71 | margin-bottom: 0; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from "react"; 2 | 3 | interface IProps { 4 | error: string | ReactNode; 5 | } 6 | 7 | const ErrorMessage: FC<IProps> = ({ error }) => ( 8 | <div className="row ErrorMessage"> 9 | <h2 className="ErrorMessage-content">Error: {error}</h2> 10 | </div> 11 | ); 12 | 13 | export default ErrorMessage; 14 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/GenderIcon.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { 3 | faVenus, 4 | faTransgender, 5 | faMars, 6 | faVenusMars, 7 | } from "@fortawesome/free-solid-svg-icons"; 8 | import Icon from "./Icon"; 9 | import { GenderEnum } from "src/graphql"; 10 | import { GenderTypes } from "src/constants"; 11 | 12 | interface IconProps { 13 | gender?: GenderEnum | null; 14 | } 15 | 16 | const GenderIcon: FC<IconProps> = ({ gender }) => { 17 | if (gender) { 18 | const icon = 19 | gender.toLowerCase() === "male" 20 | ? faMars 21 | : gender.toLowerCase() === "female" 22 | ? faVenus 23 | : faTransgender; 24 | return <Icon icon={icon} title={GenderTypes[gender]} />; 25 | } 26 | return <Icon icon={faVenusMars} />; 27 | }; 28 | 29 | export default GenderIcon; 30 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/Help.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Button, OverlayTrigger, Popover } from "react-bootstrap"; 3 | import { Icon } from "src/components/fragments"; 4 | import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; 5 | 6 | interface Props { 7 | message: string; 8 | } 9 | 10 | const Help: FC<Props> = ({ message }) => { 11 | const renderContent = () => ( 12 | <Popover id="help"> 13 | <Popover.Body>{message}</Popover.Body> 14 | </Popover> 15 | ); 16 | 17 | return ( 18 | <OverlayTrigger 19 | overlay={renderContent()} 20 | placement="bottom" 21 | trigger="hover" 22 | > 23 | <Button variant="link" className="minimal"> 24 | <Icon icon={faQuestionCircle} /> 25 | </Button> 26 | </OverlayTrigger> 27 | ); 28 | }; 29 | 30 | export default Help; 31 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import cx from "classnames"; 3 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 4 | import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; 5 | 6 | interface Props { 7 | icon: IconDefinition; 8 | className?: string; 9 | color?: string; 10 | title?: string; 11 | variant?: "danger" | "success" | "info" | "warning"; 12 | } 13 | 14 | const Icon: FC<Props> = ({ icon, className, color, title, variant }) => ( 15 | <FontAwesomeIcon 16 | title={title} 17 | icon={icon} 18 | className={cx("fa-icon", className, { [`text-${variant}`]: variant })} 19 | color={color} 20 | /> 21 | ); 22 | 23 | export default Icon; 24 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from "react"; 2 | import { Spinner } from "react-bootstrap"; 3 | import cx from "classnames"; 4 | 5 | interface LoadingProps { 6 | message?: string; 7 | delay?: number; 8 | } 9 | 10 | const CLASSNAME = "LoadingIndicator"; 11 | const CLASSNAME_MESSAGE = `${CLASSNAME}-message`; 12 | const CLASSNAME_DELAYED = `${CLASSNAME}-delayed`; 13 | 14 | const LoadingIndicator: FC<LoadingProps> = ({ message, delay = 100 }) => { 15 | const [delayed, setDelayed] = useState(delay > 0); 16 | useEffect(() => { 17 | if (!delayed || delay === 0) return; 18 | const timeout = setTimeout(() => setDelayed(false), delay); 19 | return () => clearTimeout(timeout); 20 | }, [delayed, delay]); 21 | 22 | return ( 23 | <div className={cx(CLASSNAME, { [CLASSNAME_DELAYED]: delayed })}> 24 | <Spinner animation="border" role="status"> 25 | <span className="visually-hidden">Loading...</span> 26 | </Spinner> 27 | <h4 className={CLASSNAME_MESSAGE}>{message ?? "Loading..."}</h4> 28 | </div> 29 | ); 30 | }; 31 | 32 | export default LoadingIndicator; 33 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/PerformerName.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { PerformerFragment } from "src/graphql"; 3 | 4 | interface PerformerNameProps { 5 | performer: Pick<PerformerFragment, "name" | "disambiguation" | "deleted">; 6 | as?: string | null; 7 | } 8 | 9 | const PerformerName: FC<PerformerNameProps> = ({ performer, as }) => { 10 | if (!as) 11 | return ( 12 | <> 13 | {performer.deleted ? ( 14 | <del>{performer.name}</del> 15 | ) : ( 16 | <span>{performer.name}</span> 17 | )} 18 | {performer.disambiguation && ( 19 | <small className="ms-1 text-small text-muted"> 20 | ({performer.disambiguation}) 21 | </small> 22 | )} 23 | </> 24 | ); 25 | return ( 26 | <> 27 | <span>{as}</span> 28 | <small className="ms-1 text-small text-muted"> 29 | ({performer.name}) 30 | {performer.disambiguation && ( 31 | <small className="ms-1 text-small text-muted"> 32 | ({performer.disambiguation}) 33 | </small> 34 | )} 35 | </small> 36 | </> 37 | ); 38 | }; 39 | 40 | export default PerformerName; 41 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/SearchHint.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { faCircleQuestion } from "@fortawesome/free-regular-svg-icons"; 3 | import { Icon, Tooltip } from "src/components/fragments"; 4 | 5 | export const SearchHint: React.FC = () => ( 6 | <Tooltip text='Add " to the end to include all words, or paste in a Stash ID'> 7 | <div className="SearchHint"> 8 | <Icon icon={faCircleQuestion} color="black" /> 9 | </div> 10 | </Tooltip> 11 | ); 12 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/SiteLink.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import cx from "classnames"; 4 | import { siteHref } from "src/utils/route"; 5 | 6 | const CLASSNAME = "SiteLink"; 7 | const CLASSNAME_ICON = `${CLASSNAME}-icon`; 8 | const CLASSNAME_NAME = `${CLASSNAME}-name`; 9 | const CLASSNAME_NO_MARGIN = `${CLASSNAME}-no-margin`; 10 | 11 | interface Props { 12 | site: { 13 | id: string; 14 | name: string; 15 | icon: string; 16 | } | null; 17 | hideName?: boolean; 18 | noMargin?: boolean; 19 | } 20 | 21 | const SiteLink: FC<Props> = ({ site, hideName = false, noMargin = false }) => 22 | site ? ( 23 | <Link to={siteHref(site)} className={CLASSNAME}> 24 | <img className={CLASSNAME_ICON} src={site.icon} alt="" /> 25 | {!hideName && ( 26 | <span 27 | className={cx(CLASSNAME_NAME, { [CLASSNAME_NO_MARGIN]: noMargin })} 28 | > 29 | {site.name} 30 | </span> 31 | )} 32 | </Link> 33 | ) : ( 34 | <></> 35 | ); 36 | 37 | export default SiteLink; 38 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/TagLink.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Badge, Button } from "react-bootstrap"; 3 | import { Link } from "react-router-dom"; 4 | import { Icon } from "src/components/fragments"; 5 | import { faXmark } from "@fortawesome/free-solid-svg-icons"; 6 | import cx from "classnames"; 7 | 8 | interface IProps { 9 | title: string; 10 | link?: string; 11 | description?: string | null; 12 | className?: string; 13 | onRemove?: () => void; 14 | disabled?: boolean; 15 | } 16 | 17 | const TagLink: FC<IProps> = ({ 18 | title, 19 | link, 20 | description, 21 | className, 22 | onRemove, 23 | disabled = false, 24 | }) => ( 25 | <Badge className={cx("tag-item", className)} bg="none"> 26 | <abbr title={description || undefined}> 27 | {link && !disabled ? <Link to={link}>{title}</Link> : title} 28 | </abbr> 29 | {onRemove && ( 30 | <Button onClick={onRemove}> 31 | <Icon icon={faXmark} /> 32 | </Button> 33 | )} 34 | </Badge> 35 | ); 36 | 37 | export default TagLink; 38 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/Thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import cx from "classnames"; 3 | import { faXmark } from "@fortawesome/free-solid-svg-icons"; 4 | import Icon from "./Icon"; 5 | 6 | interface Props { 7 | image?: string; 8 | size?: 600 | 300; 9 | alt?: string | null; 10 | className?: string; 11 | orientation?: "portrait" | "landscape"; 12 | } 13 | 14 | const doubleSize = { 15 | 300: 600, 16 | 600: 1280, 17 | }; 18 | 19 | export const Thumbnail: FC<Props> = ({ 20 | image, 21 | size, 22 | alt, 23 | className, 24 | orientation = "landscape", 25 | }) => 26 | image ? ( 27 | <img 28 | alt={alt ?? ""} 29 | className={className} 30 | src={image + (size ? `?size=${size}` : "")} 31 | srcSet={ 32 | size ? `${image}?size=${doubleSize[size]} ${doubleSize[size]}w` : "" 33 | } 34 | /> 35 | ) : ( 36 | <div 37 | className={cx(className, "Thumbnail-empty")} 38 | style={{ aspectRatio: orientation === "landscape" ? "16/9" : "2/3" }} 39 | > 40 | <Icon icon={faXmark} /> 41 | </div> 42 | ); 43 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactElement } from "react"; 2 | import { 3 | OverlayTrigger, 4 | Tooltip as BSTooltip, 5 | PopoverProps, 6 | } from "react-bootstrap"; 7 | 8 | interface Props { 9 | text: string | ReactElement; 10 | placement?: PopoverProps["placement"]; 11 | children: ReactElement; 12 | delay?: number; 13 | } 14 | 15 | const Tooltip: FC<Props> = ({ 16 | children, 17 | text, 18 | delay = 200, 19 | placement = "bottom-end", 20 | }) => ( 21 | <OverlayTrigger 22 | delay={{ show: delay, hide: 0 }} 23 | overlay={ 24 | <BSTooltip className="Tooltip" id="tooltip"> 25 | {text} 26 | </BSTooltip> 27 | } 28 | show={text ? undefined : false} 29 | placement={placement} 30 | trigger={["hover", "focus"]} 31 | > 32 | {children} 33 | </OverlayTrigger> 34 | ); 35 | 36 | export default Tooltip; 37 | -------------------------------------------------------------------------------- /frontend/src/components/fragments/index.ts: -------------------------------------------------------------------------------- 1 | export { default as GenderIcon } from "./GenderIcon"; 2 | export { default as LoadingIndicator } from "./LoadingIndicator"; 3 | export { default as Icon } from "./Icon"; 4 | export { default as TagLink } from "./TagLink"; 5 | export { default as SiteLink } from "./SiteLink"; 6 | export { default as PerformerName } from "./PerformerName"; 7 | export { default as ErrorMessage } from "./ErrorMessage"; 8 | export { default as Help } from "./Help"; 9 | export { default as Tooltip } from "./Tooltip"; 10 | export { FavoriteStar } from "./Favorite"; 11 | export { Thumbnail } from "./Thumbnail"; 12 | export { SearchHint } from "./SearchHint"; 13 | -------------------------------------------------------------------------------- /frontend/src/components/image/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Image"; 2 | -------------------------------------------------------------------------------- /frontend/src/components/image/styles.scss: -------------------------------------------------------------------------------- 1 | .Image { 2 | position: relative; 3 | display: flex; 4 | max-height: 100%; 5 | max-width: 100%; 6 | 7 | &-image { 8 | max-width: 100%; 9 | max-height: 100%; 10 | z-index: 1; 11 | } 12 | 13 | .LoadingIndicator { 14 | position: absolute; 15 | background-color: $gray-600; 16 | border-radius: 4px; 17 | height: 100%; 18 | width: 100%; 19 | text-align: center; 20 | } 21 | 22 | &-error, 23 | &-missing { 24 | position: absolute; 25 | background-color: $secondary; 26 | width: 100%; 27 | height: 100%; 28 | align-content: center; 29 | text-align: center; 30 | font-size: 24px; 31 | 32 | .fa-icon { 33 | border-radius: 4px; 34 | width: 70px; 35 | height: 70px; 36 | max-width: 100%; 37 | max-height: 100%; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/components/imageCarousel/index.ts: -------------------------------------------------------------------------------- 1 | import ImageCarousel from "./ImageCarousel"; 2 | 3 | export default ImageCarousel; 4 | -------------------------------------------------------------------------------- /frontend/src/components/imageChangeRow/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ImageChangeRow"; 2 | -------------------------------------------------------------------------------- /frontend/src/components/imageChangeRow/styles.scss: -------------------------------------------------------------------------------- 1 | .ImageChangeRow { 2 | display: flex; 3 | flex-wrap: wrap; 4 | 5 | .Image { 6 | height: 150px; 7 | margin: 5px; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/components/linkedChangeRow/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./LinkedChangeRow"; 2 | -------------------------------------------------------------------------------- /frontend/src/components/list/URLList.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { SiteLink } from "src/components/fragments"; 3 | 4 | interface URLListProps { 5 | urls: { 6 | url: string; 7 | site: { 8 | id: string; 9 | name: string; 10 | icon: string; 11 | } | null; 12 | }[]; 13 | } 14 | 15 | const URLList: FC<URLListProps> = ({ urls }) => ( 16 | <ul className="URLList"> 17 | {urls.map((u) => ( 18 | <li key={u.url}> 19 | <SiteLink site={u.site} /> 20 | <a href={u.url} target="_blank" rel="noreferrer noopener"> 21 | {u.url} 22 | </a> 23 | </li> 24 | ))} 25 | </ul> 26 | ); 27 | 28 | export default URLList; 29 | -------------------------------------------------------------------------------- /frontend/src/components/list/index.ts: -------------------------------------------------------------------------------- 1 | export { default as List } from "./List"; 2 | export { default as SceneList } from "./SceneList"; 3 | export { default as TagList } from "./TagList"; 4 | export { default as EditList } from "./EditList"; 5 | export { default as URLList } from "./URLList"; 6 | -------------------------------------------------------------------------------- /frontend/src/components/list/styles.scss: -------------------------------------------------------------------------------- 1 | .URLList { 2 | list-style-type: none; 3 | padding: 0; 4 | 5 | img { 6 | width: 16px; 7 | } 8 | } 9 | 10 | .FavoriteFilter { 11 | width: 240px; 12 | } 13 | 14 | .BotFilter { 15 | width: 120px; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/components/listChangeRow/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ListChangeRow"; 2 | -------------------------------------------------------------------------------- /frontend/src/components/modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, FC } from "react"; 2 | import { Modal, Button } from "react-bootstrap"; 3 | 4 | interface ModalProps { 5 | callback: (status: boolean) => void; 6 | cancelTerm?: string; 7 | acceptTerm?: string; 8 | } 9 | 10 | interface MessageProps { 11 | message: string; 12 | children?: never; 13 | } 14 | interface ElementProps { 15 | children: ReactNode; 16 | message?: never; 17 | } 18 | 19 | const ModalComponent: FC<ModalProps & (MessageProps | ElementProps)> = ({ 20 | message, 21 | children, 22 | callback, 23 | cancelTerm = "Cancel", 24 | acceptTerm = "Delete", 25 | }) => { 26 | const handleCancel = () => callback(false); 27 | const handleAccept = () => callback(true); 28 | 29 | const content = message || children; 30 | 31 | return ( 32 | <Modal show onHide={handleCancel}> 33 | <Modal.Header closeButton> 34 | <b>Warning</b> 35 | </Modal.Header> 36 | <Modal.Body>{content}</Modal.Body> 37 | <Modal.Footer> 38 | <Button variant="danger" onClick={handleAccept}> 39 | {acceptTerm} 40 | </Button> 41 | <Button variant="primary" onClick={handleCancel}> 42 | {cancelTerm} 43 | </Button> 44 | </Modal.Footer> 45 | </Modal> 46 | ); 47 | }; 48 | 49 | export default ModalComponent; 50 | -------------------------------------------------------------------------------- /frontend/src/components/modal/index.ts: -------------------------------------------------------------------------------- 1 | import Modal from "./Modal"; 2 | 3 | export default Modal; 4 | -------------------------------------------------------------------------------- /frontend/src/components/multiSelect/index.ts: -------------------------------------------------------------------------------- 1 | import MultiSelect from "./MultiSelect"; 2 | 3 | export default MultiSelect; 4 | -------------------------------------------------------------------------------- /frontend/src/components/multiSelect/styles.scss: -------------------------------------------------------------------------------- 1 | .TagSelect { 2 | margin-top: 0.5rem; 3 | 4 | &-list { 5 | margin-bottom: 1rem; 6 | } 7 | 8 | &-container { 9 | display: flex; 10 | } 11 | 12 | &-select { 13 | display: inline-block; 14 | margin-left: auto; 15 | width: 25rem; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/components/pagination/index.ts: -------------------------------------------------------------------------------- 1 | import Pagination from "./Pagination"; 2 | 3 | export default Pagination; 4 | -------------------------------------------------------------------------------- /frontend/src/components/performerCard/index.ts: -------------------------------------------------------------------------------- 1 | import PerformerCard from "./PerformerCard"; 2 | 3 | export default PerformerCard; 4 | -------------------------------------------------------------------------------- /frontend/src/components/performerCard/styles.scss: -------------------------------------------------------------------------------- 1 | .PerformerCard { 2 | &-image { 3 | align-items: center; 4 | display: flex; 5 | aspect-ratio: 2 / 3; 6 | 7 | img { 8 | height: 100%; 9 | image-rendering: smooth; 10 | object-fit: cover; 11 | object-position: top; 12 | width: 100%; 13 | 14 | &[src=""] { 15 | display: none; 16 | } 17 | } 18 | } 19 | 20 | &-star { 21 | position: absolute; 22 | top: 0.5rem; 23 | right: 0.5rem; 24 | z-index: 1; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/performerSelect/index.ts: -------------------------------------------------------------------------------- 1 | import PerformerSelect from "./PerformerSelect"; 2 | 3 | export default PerformerSelect; 4 | -------------------------------------------------------------------------------- /frontend/src/components/performerSelect/styles.scss: -------------------------------------------------------------------------------- 1 | .PerformerSelect { 2 | margin-top: 0.5rem; 3 | 4 | &-list { 5 | margin: 0.5rem 0; 6 | } 7 | 8 | &-container { 9 | display: flex; 10 | } 11 | 12 | &-select { 13 | display: inline-block; 14 | margin-left: auto; 15 | width: 25rem; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/components/sceneCard/index.ts: -------------------------------------------------------------------------------- 1 | import SceneCard from "./SceneCard"; 2 | 3 | export default SceneCard; 4 | -------------------------------------------------------------------------------- /frontend/src/components/sceneCard/styles.scss: -------------------------------------------------------------------------------- 1 | .SceneCard { 2 | &.card { 3 | box-shadow: none; 4 | border-radius: 0; 5 | background-color: transparent; 6 | } 7 | 8 | &-body { 9 | min-height: 150px; 10 | padding: 0; 11 | } 12 | 13 | &-image { 14 | align-items: center; 15 | aspect-ratio: 16/9; 16 | display: flex; 17 | height: 100%; 18 | justify-content: center; 19 | width: 100%; 20 | 21 | img { 22 | height: 100%; 23 | image-rendering: smooth; 24 | object-fit: cover; 25 | object-position: center; 26 | width: 100%; 27 | } 28 | 29 | .vertical-img { 30 | background-color: rgba(0 0 0 / 50%); 31 | object-fit: scale-down; 32 | } 33 | } 34 | 35 | .card-footer { 36 | font-size: 0.8rem; 37 | padding: 0.75rem 0; 38 | } 39 | 40 | &-studio-name { 41 | max-width: 65%; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/src/components/searchField/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./SearchField"; 2 | export * from "./SearchField"; 3 | -------------------------------------------------------------------------------- /frontend/src/components/searchField/styles.scss: -------------------------------------------------------------------------------- 1 | .SearchField { 2 | width: 400px; 3 | z-index: 5; 4 | 5 | .search-value { 6 | font-size: 14px; 7 | font-weight: 500; 8 | } 9 | 10 | .search-subvalue { 11 | font-size: 12px; 12 | } 13 | 14 | &-thumb { 15 | aspect-ratio: 2/3; 16 | height: 4rem; 17 | width: auto; 18 | object-fit: cover; 19 | margin-right: 0.5rem; 20 | } 21 | 22 | .react-select__menu-list { 23 | max-height: 600px; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/components/studioSelect/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./StudioSelect"; 2 | -------------------------------------------------------------------------------- /frontend/src/components/studioSelect/styles.scss: -------------------------------------------------------------------------------- 1 | .StudioSelect { 2 | .parent-studio { 3 | color: $text-muted; 4 | } 5 | 6 | .react-select__value-container { 7 | .parent-studio { 8 | color: rgba($black, 0.5); 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/components/tagFilter/index.ts: -------------------------------------------------------------------------------- 1 | import TagFilter from "./TagFilter"; 2 | 3 | export default TagFilter; 4 | -------------------------------------------------------------------------------- /frontend/src/components/tagFilter/styles.scss: -------------------------------------------------------------------------------- 1 | .TagFilter { 2 | &-select { 3 | display: inline-block; 4 | margin-right: 0.5rem; 5 | width: 14rem; 6 | 7 | .react-select__menu { 8 | width: 400px; 9 | } 10 | 11 | &-value { 12 | font-size: 14px; 13 | font-weight: 500; 14 | } 15 | 16 | &-subvalue { 17 | font-size: 12px; 18 | color: $text-muted; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/components/tagSelect/index.ts: -------------------------------------------------------------------------------- 1 | import TagSelect from "./TagSelect"; 2 | 3 | export default TagSelect; 4 | -------------------------------------------------------------------------------- /frontend/src/components/tagSelect/styles.scss: -------------------------------------------------------------------------------- 1 | .TagSelect { 2 | margin-top: 0.5rem; 3 | 4 | &-list { 5 | margin-bottom: 1rem; 6 | } 7 | 8 | &-container { 9 | display: flex; 10 | } 11 | 12 | &-select { 13 | display: inline-block; 14 | margin-left: auto; 15 | width: 25rem; 16 | 17 | &-value { 18 | font-size: 14px; 19 | font-weight: 500; 20 | } 21 | 22 | &-subvalue { 23 | font-size: 12px; 24 | color: $text-muted; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/components/title/Title.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Helmet } from "react-helmet"; 3 | 4 | // Title is only injected in production, so default to Stash-Box in dev 5 | const INSTANCE_TITLE = 6 | document.title === "{{.}}" ? "Stash-Box" : document.title; 7 | 8 | interface Props { 9 | page?: string; 10 | } 11 | 12 | const Title: FC<Props> = ({ page }) => ( 13 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 14 | /* @ts-ignore */ 15 | <Helmet> 16 | <title>{page ? `${page} | ${INSTANCE_TITLE}` : INSTANCE_TITLE} 17 | 18 | ); 19 | 20 | export default Title; 21 | -------------------------------------------------------------------------------- /frontend/src/components/title/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Title"; 2 | -------------------------------------------------------------------------------- /frontend/src/components/urlChangeRow/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./URLChangeRow"; 2 | export type { URL } from "./URLChangeRow"; 3 | -------------------------------------------------------------------------------- /frontend/src/components/urlInput/index.ts: -------------------------------------------------------------------------------- 1 | import URLInput from "./urlInput"; 2 | 3 | export default URLInput; 4 | -------------------------------------------------------------------------------- /frontend/src/components/urlInput/styles.scss: -------------------------------------------------------------------------------- 1 | .URLInput { 2 | width: 100%; 3 | 4 | ul { 5 | list-style-type: none; 6 | padding-left: 0; 7 | } 8 | 9 | li { 10 | margin-bottom: 0.5rem; 11 | } 12 | 13 | .input-group { 14 | flex-wrap: nowrap; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./enums"; 2 | export * from "./route"; 3 | -------------------------------------------------------------------------------- /frontend/src/context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | 3 | import { RoleEnum } from "src/graphql"; 4 | 5 | export interface User { 6 | id: string; 7 | name: string; 8 | roles?: RoleEnum[] | null; 9 | } 10 | 11 | export type ContextType = { 12 | authenticated: boolean; 13 | user?: User; 14 | }; 15 | 16 | const AuthContext = createContext({ 17 | authenticated: false, 18 | }); 19 | 20 | export const useAuthContext = () => useContext(AuthContext); 21 | 22 | export default AuthContext; 23 | -------------------------------------------------------------------------------- /frontend/src/graphql/fragments/CommentFragment.gql: -------------------------------------------------------------------------------- 1 | fragment CommentFragment on EditComment { 2 | id 3 | user { 4 | id 5 | name 6 | } 7 | date 8 | comment 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/graphql/fragments/FingerprintFragment.gql: -------------------------------------------------------------------------------- 1 | fragment FingerprintFragment on Fingerprint { 2 | hash 3 | algorithm 4 | duration 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/graphql/fragments/ImageFragment.gql: -------------------------------------------------------------------------------- 1 | fragment ImageFragment on Image { 2 | id 3 | url 4 | width 5 | height 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/fragments/PerformerFragment.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ImageFragment.gql" 2 | #import "../fragments/URLFragment.gql" 3 | fragment PerformerFragment on Performer { 4 | id 5 | name 6 | disambiguation 7 | deleted 8 | merged_into_id 9 | aliases 10 | gender 11 | birth_date 12 | death_date 13 | age 14 | height 15 | hair_color 16 | eye_color 17 | ethnicity 18 | country 19 | career_end_year 20 | career_start_year 21 | breast_type 22 | waist_size 23 | hip_size 24 | band_size 25 | cup_size 26 | tattoos { 27 | location 28 | description 29 | } 30 | piercings { 31 | location 32 | description 33 | } 34 | urls { 35 | ...URLFragment 36 | } 37 | images { 38 | ...ImageFragment 39 | } 40 | is_favorite 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/graphql/fragments/QuerySceneFragment.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/URLFragment.gql" 2 | #import "../fragments/ImageFragment.gql" 3 | #import "../fragments/ScenePerformerFragment.gql" 4 | fragment QuerySceneFragment on Scene { 5 | id 6 | release_date 7 | title 8 | duration 9 | urls { 10 | ...URLFragment 11 | } 12 | images { 13 | ...ImageFragment 14 | } 15 | studio { 16 | id 17 | name 18 | } 19 | performers { 20 | as 21 | performer { 22 | ...ScenePerformerFragment 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/graphql/fragments/SceneFragment.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ImageFragment.gql" 2 | #import "../fragments/ScenePerformerFragment.gql" 3 | #import "../fragments/URLFragment.gql" 4 | fragment SceneFragment on Scene { 5 | id 6 | release_date 7 | production_date 8 | title 9 | deleted 10 | details 11 | director 12 | code 13 | duration 14 | urls { 15 | ...URLFragment 16 | } 17 | images { 18 | ...ImageFragment 19 | } 20 | studio { 21 | id 22 | name 23 | parent { 24 | id 25 | name 26 | } 27 | } 28 | performers { 29 | as 30 | performer { 31 | ...ScenePerformerFragment 32 | } 33 | } 34 | fingerprints { 35 | hash 36 | algorithm 37 | duration 38 | submissions 39 | reports 40 | user_submitted 41 | user_reported 42 | created 43 | updated 44 | } 45 | tags { 46 | id 47 | name 48 | description 49 | aliases 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/graphql/fragments/ScenePerformerFragment.gql: -------------------------------------------------------------------------------- 1 | fragment ScenePerformerFragment on Performer { 2 | id 3 | name 4 | disambiguation 5 | deleted 6 | gender 7 | aliases 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/graphql/fragments/SearchPerformerFragment.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ImageFragment.gql" 2 | #import "../fragments/URLFragment.gql" 3 | fragment SearchPerformerFragment on Performer { 4 | id 5 | name 6 | disambiguation 7 | deleted 8 | gender 9 | aliases 10 | country 11 | career_start_year 12 | career_end_year 13 | scene_count 14 | birth_date 15 | urls { 16 | ...URLFragment 17 | } 18 | images { 19 | ...ImageFragment 20 | } 21 | is_favorite 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/graphql/fragments/StudioFragment.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/URLFragment.gql" 2 | fragment StudioFragment on Studio { 3 | id 4 | name 5 | aliases 6 | child_studios { 7 | id 8 | name 9 | } 10 | parent { 11 | id 12 | name 13 | } 14 | urls { 15 | ...URLFragment 16 | } 17 | images { 18 | id 19 | url 20 | height 21 | width 22 | } 23 | deleted 24 | is_favorite 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/graphql/fragments/TagFragment.gql: -------------------------------------------------------------------------------- 1 | fragment TagFragment on Tag { 2 | id 3 | name 4 | description 5 | deleted 6 | category { 7 | id 8 | name 9 | } 10 | aliases 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/graphql/fragments/URLFragment.gql: -------------------------------------------------------------------------------- 1 | fragment URLFragment on URL { 2 | url 3 | site { 4 | id 5 | name 6 | icon 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/graphql/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./queries"; 2 | export * from "./mutations"; 3 | export * from "./types"; 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/ActivateNewUser.gql: -------------------------------------------------------------------------------- 1 | mutation ActivateNewUser($input: ActivateNewUserInput!) { 2 | activateNewUser(input: $input) { 3 | id 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/AddImage.gql: -------------------------------------------------------------------------------- 1 | mutation AddImage($imageData: ImageCreateInput!) { 2 | imageCreate(input: $imageData) { 3 | id 4 | url 5 | width 6 | height 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/AddScene.gql: -------------------------------------------------------------------------------- 1 | mutation AddScene($sceneData: SceneCreateInput!) { 2 | sceneCreate(input: $sceneData) { 3 | id 4 | release_date 5 | production_date 6 | title 7 | code 8 | details 9 | director 10 | urls { 11 | url 12 | site { 13 | id 14 | name 15 | } 16 | } 17 | studio { 18 | id 19 | name 20 | } 21 | performers { 22 | performer { 23 | name 24 | id 25 | gender 26 | aliases 27 | } 28 | } 29 | fingerprints { 30 | hash 31 | algorithm 32 | duration 33 | } 34 | tags { 35 | id 36 | name 37 | description 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/AddSite.gql: -------------------------------------------------------------------------------- 1 | mutation AddSite($siteData: SiteCreateInput!) { 2 | siteCreate(input: $siteData) { 3 | id 4 | name 5 | description 6 | url 7 | regex 8 | valid_types 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/AddStudio.gql: -------------------------------------------------------------------------------- 1 | mutation AddStudio($studioData: StudioCreateInput!) { 2 | studioCreate(input: $studioData) { 3 | id 4 | name 5 | aliases 6 | urls { 7 | url 8 | site { 9 | id 10 | name 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/AddTagCategory.gql: -------------------------------------------------------------------------------- 1 | mutation AddTagCategory($categoryData: TagCategoryCreateInput!) { 2 | tagCategoryCreate(input: $categoryData) { 3 | id 4 | name 5 | description 6 | group 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/AddUser.gql: -------------------------------------------------------------------------------- 1 | mutation AddUser($userData: UserCreateInput!) { 2 | userCreate(input: $userData) { 3 | id 4 | name 5 | email 6 | roles 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/ApplyEdit.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | mutation ApplyEdit($input: ApplyEditInput!) { 3 | applyEdit(input: $input) { 4 | ...EditFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/CancelEdit.gql: -------------------------------------------------------------------------------- 1 | mutation CancelEdit($input: CancelEditInput!) { 2 | cancelEdit(input: $input) { 3 | id 4 | target_type 5 | operation 6 | status 7 | applied 8 | created 9 | user { 10 | id 11 | name 12 | } 13 | target { 14 | ... on Tag { 15 | id 16 | name 17 | description 18 | deleted 19 | } 20 | } 21 | details { 22 | ... on TagEdit { 23 | name 24 | description 25 | added_aliases 26 | removed_aliases 27 | } 28 | } 29 | merge_sources { 30 | ... on Tag { 31 | id 32 | name 33 | description 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/ChangePassword.gql: -------------------------------------------------------------------------------- 1 | mutation ChangePassword($userData: UserChangePasswordInput!) { 2 | changePassword(input: $userData) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/ConfirmChangeEmail.gql: -------------------------------------------------------------------------------- 1 | mutation ConfirmChangeEmail($token: ID!) { 2 | confirmChangeEmail(token: $token) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/DeleteDraft.gql: -------------------------------------------------------------------------------- 1 | mutation DeleteDraft($id: ID!) { 2 | destroyDraft(id: $id) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/DeleteScene.gql: -------------------------------------------------------------------------------- 1 | mutation DeleteScene($input: SceneDestroyInput!) { 2 | sceneDestroy(input: $input) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/DeleteSite.gql: -------------------------------------------------------------------------------- 1 | mutation DeleteSite($input: SiteDestroyInput!) { 2 | siteDestroy(input: $input) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/DeleteStudio.gql: -------------------------------------------------------------------------------- 1 | mutation DeleteStudio($input: StudioDestroyInput!) { 2 | studioDestroy(input: $input) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/DeleteTagCategory.gql: -------------------------------------------------------------------------------- 1 | mutation DeleteTagCategory($input: TagCategoryDestroyInput!) { 2 | tagCategoryDestroy(input: $input) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/DeleteUser.gql: -------------------------------------------------------------------------------- 1 | mutation DeleteUser($input: UserDestroyInput!) { 2 | userDestroy(input: $input) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/EditComment.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/CommentFragment.gql" 2 | mutation EditComment($input: EditCommentInput!) { 3 | editComment(input: $input) { 4 | id 5 | comments { 6 | ...CommentFragment 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/FavoritePerformer.gql: -------------------------------------------------------------------------------- 1 | mutation FavoritePerformer($id: ID!, $favorite: Boolean!) { 2 | favoritePerformer(id: $id, favorite: $favorite) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/FavoriteStudio.gql: -------------------------------------------------------------------------------- 1 | mutation FavoriteStudio($id: ID!, $favorite: Boolean!) { 2 | favoriteStudio(id: $id, favorite: $favorite) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/GenerateInviteCode.gql: -------------------------------------------------------------------------------- 1 | mutation GenerateInviteCodes($input: GenerateInviteCodeInput) { 2 | generateInviteCodes(input: $input) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/GrantInvite.gql: -------------------------------------------------------------------------------- 1 | mutation GrantInvite($input: GrantInviteInput!) { 2 | grantInvite(input: $input) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/MarkNotificationRead.gql: -------------------------------------------------------------------------------- 1 | mutation MarkNotificationRead($notification: MarkNotificationReadInput!) { 2 | markNotificationsRead(notification: $notification) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/MarkNotificationsRead.gql: -------------------------------------------------------------------------------- 1 | mutation MarkNotificationsRead { 2 | markNotificationsRead 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/NewUser.gql: -------------------------------------------------------------------------------- 1 | mutation NewUser($input: NewUserInput!) { 2 | newUser(input: $input) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/PerformerEdit.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | mutation PerformerEdit($performerData: PerformerEditInput!) { 3 | performerEdit(input: $performerData) { 4 | ...EditFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/PerformerEditUpdate.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | mutation PerformerEditUpdate($id: ID!, $performerData: PerformerEditInput!) { 3 | performerEditUpdate(id: $id, input: $performerData) { 4 | ...EditFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/RegenerateAPIKey.gql: -------------------------------------------------------------------------------- 1 | mutation RegenerateAPIKey($user_id: ID) { 2 | regenerateAPIKey(userID: $user_id) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/RequestChangeEmail.gql: -------------------------------------------------------------------------------- 1 | mutation RequestChangeEmail { 2 | requestChangeEmail 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/RescindInviteCode.gql: -------------------------------------------------------------------------------- 1 | mutation RescindInviteCode($code: ID!) { 2 | rescindInviteCode(code: $code) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/ResetPassword.gql: -------------------------------------------------------------------------------- 1 | mutation ResetPassword($input: ResetPasswordInput!) { 2 | resetPassword(input: $input) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/RevokeInvite.gql: -------------------------------------------------------------------------------- 1 | mutation RevokeInvite($input: RevokeInviteInput!) { 2 | revokeInvite(input: $input) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/SceneEdit.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | mutation SceneEdit($sceneData: SceneEditInput!) { 3 | sceneEdit(input: $sceneData) { 4 | ...EditFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/SceneEditUpdate.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | mutation SceneEditUpdate($id: ID!, $sceneData: SceneEditInput!) { 3 | sceneEditUpdate(id: $id, input: $sceneData) { 4 | ...EditFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/StudioEdit.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | mutation StudioEdit($studioData: StudioEditInput!) { 3 | studioEdit(input: $studioData) { 4 | ...EditFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/StudioEditUpdate.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | mutation StudioEditUpdate($id: ID!, $studioData: StudioEditInput!) { 3 | studioEditUpdate(id: $id, input: $studioData) { 4 | ...EditFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/TagEdit.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | mutation TagEdit($tagData: TagEditInput!) { 3 | tagEdit(input: $tagData) { 4 | ...EditFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/TagEditUpdate.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | mutation TagEditUpdate($id: ID!, $tagData: TagEditInput!) { 3 | tagEditUpdate(id: $id, input: $tagData) { 4 | ...EditFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/UnmatchFingerprint.gql: -------------------------------------------------------------------------------- 1 | mutation UnmatchFingerprint( 2 | $scene_id: ID! 3 | $algorithm: FingerprintAlgorithm! 4 | $hash: String! 5 | $duration: Int! 6 | ) { 7 | unmatchFingerprint: submitFingerprint( 8 | input: { 9 | vote: REMOVE 10 | scene_id: $scene_id 11 | fingerprint: { hash: $hash, algorithm: $algorithm, duration: $duration } 12 | } 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/UpdateNotificationSubscriptions.gql: -------------------------------------------------------------------------------- 1 | mutation UpdateNotificationSubscriptions($subscriptions: [NotificationEnum!]!) { 2 | updateNotificationSubscriptions(subscriptions: $subscriptions) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/UpdateScene.gql: -------------------------------------------------------------------------------- 1 | mutation UpdateScene($updateData: SceneUpdateInput!) { 2 | sceneUpdate(input: $updateData) { 3 | id 4 | release_date 5 | production_date 6 | details 7 | director 8 | code 9 | duration 10 | title 11 | urls { 12 | url 13 | site { 14 | id 15 | name 16 | } 17 | } 18 | studio { 19 | id 20 | name 21 | } 22 | performers { 23 | performer { 24 | name 25 | id 26 | gender 27 | aliases 28 | } 29 | } 30 | fingerprints { 31 | hash 32 | algorithm 33 | duration 34 | } 35 | tags { 36 | id 37 | name 38 | description 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/UpdateSite.gql: -------------------------------------------------------------------------------- 1 | mutation UpdateSite($siteData: SiteUpdateInput!) { 2 | siteUpdate(input: $siteData) { 3 | id 4 | name 5 | description 6 | url 7 | regex 8 | valid_types 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/UpdateStudio.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/StudioFragment.gql" 2 | mutation UpdateStudio($input: StudioUpdateInput!) { 3 | studioUpdate(input: $input) { 4 | ...StudioFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/UpdateTagCategory.gql: -------------------------------------------------------------------------------- 1 | mutation UpdateTagCategory($categoryData: TagCategoryUpdateInput!) { 2 | tagCategoryUpdate(input: $categoryData) { 3 | id 4 | name 5 | description 6 | group 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/UpdateUser.gql: -------------------------------------------------------------------------------- 1 | mutation UpdateUser($userData: UserUpdateInput!) { 2 | userUpdate(input: $userData) { 3 | id 4 | name 5 | email 6 | roles 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/ValidateChangeEmail.gql: -------------------------------------------------------------------------------- 1 | mutation ValidateChangeEmail($token: ID!, $email: String!) { 2 | validateChangeEmail(token: $token, email: $email) 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/mutations/Vote.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | mutation Vote($input: EditVoteInput!) { 3 | editVote(input: $input) { 4 | ...EditFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Categories.gql: -------------------------------------------------------------------------------- 1 | query Categories { 2 | queryTagCategories { 3 | count 4 | tag_categories { 5 | id 6 | name 7 | description 8 | group 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Category.gql: -------------------------------------------------------------------------------- 1 | query Category($id: ID!) { 2 | findTagCategory(id: $id) { 3 | id 4 | name 5 | description 6 | group 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Config.gql: -------------------------------------------------------------------------------- 1 | query Config { 2 | getConfig { 3 | edit_update_limit 4 | host_url 5 | require_invite 6 | require_activation 7 | vote_promotion_threshold 8 | vote_application_threshold 9 | voting_period 10 | min_destructive_voting_period 11 | vote_cron_interval 12 | guidelines_url 13 | require_scene_draft 14 | require_tag_role 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Drafts.gql: -------------------------------------------------------------------------------- 1 | query Drafts { 2 | findDrafts { 3 | id 4 | created 5 | expires 6 | data { 7 | ... on PerformerDraft { 8 | id 9 | name 10 | } 11 | ... on SceneDraft { 12 | id 13 | title 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Edit.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | query Edit($id: ID!) { 3 | findEdit(id: $id) { 4 | ...EditFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Edits.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | query Edits($input: EditQueryInput!) { 3 | queryEdits(input: $input) { 4 | count 5 | edits { 6 | ...EditFragment 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/FullPerformer.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/PerformerFragment.gql" 2 | query FullPerformer($id: ID!) { 3 | findPerformer(id: $id) { 4 | ...PerformerFragment 5 | studios { 6 | scene_count 7 | studio { 8 | id 9 | name 10 | parent { 11 | id 12 | name 13 | } 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Me.gql: -------------------------------------------------------------------------------- 1 | query Me { 2 | me { 3 | id 4 | name 5 | roles 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/PendingEditsCount.gql: -------------------------------------------------------------------------------- 1 | query PendingEditsCount($type: TargetTypeEnum!, $id: ID!) { 2 | queryEdits( 3 | input: { target_type: $type, target_id: $id, status: PENDING, per_page: 1 } 4 | ) { 5 | count 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Performer.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/PerformerFragment.gql" 2 | query Performer($id: ID!) { 3 | findPerformer(id: $id) { 4 | ...PerformerFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Performers.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/URLFragment.gql" 2 | #import "../fragments/ImageFragment.gql" 3 | query Performers($input: PerformerQueryInput!) { 4 | queryPerformers(input: $input) { 5 | count 6 | performers { 7 | id 8 | name 9 | disambiguation 10 | deleted 11 | aliases 12 | gender 13 | birth_date 14 | age 15 | height 16 | hair_color 17 | eye_color 18 | ethnicity 19 | country 20 | career_end_year 21 | career_start_year 22 | breast_type 23 | waist_size 24 | hip_size 25 | band_size 26 | cup_size 27 | tattoos { 28 | location 29 | description 30 | } 31 | piercings { 32 | location 33 | description 34 | } 35 | urls { 36 | ...URLFragment 37 | } 38 | images { 39 | ...ImageFragment 40 | } 41 | is_favorite 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/PublicUser.gql: -------------------------------------------------------------------------------- 1 | query PublicUser($name: String!) { 2 | findUser(username: $name) { 3 | id 4 | name 5 | vote_count { 6 | accept 7 | reject 8 | immediate_accept 9 | immediate_reject 10 | abstain 11 | } 12 | edit_count { 13 | immediate_accepted 14 | immediate_rejected 15 | accepted 16 | rejected 17 | failed 18 | canceled 19 | pending 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/QueryExistingPerformer.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | #import "../fragments/PerformerFragment.gql" 3 | query QueryExistingPerformer($input: QueryExistingPerformerInput!) { 4 | queryExistingPerformer(input: $input) { 5 | performers { 6 | ...PerformerFragment 7 | } 8 | edits { 9 | ...EditFragment 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/QueryExistingScene.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/EditFragment.gql" 2 | #import "../fragments/SceneFragment.gql" 3 | query QueryExistingScene($input: QueryExistingSceneInput!) { 4 | queryExistingScene(input: $input) { 5 | scenes { 6 | ...SceneFragment 7 | } 8 | edits { 9 | ...EditFragment 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Scene.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/SceneFragment.gql" 2 | query Scene($id: ID!) { 3 | findScene(id: $id) { 4 | ...SceneFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/ScenePairings.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ImageFragment.gql" 2 | query ScenePairings( 3 | $performerId: ID! 4 | $names: String 5 | $gender: GenderFilterEnum 6 | $favorite: Boolean 7 | $page: Int! = 1 8 | $per_page: Int! = 25 9 | $direction: SortDirectionEnum! 10 | $sort: PerformerSortEnum! 11 | $fetchScenes: Boolean! 12 | ) { 13 | queryPerformers( 14 | input: { 15 | performed_with: $performerId 16 | names: $names 17 | gender: $gender 18 | is_favorite: $favorite 19 | page: $page 20 | per_page: $per_page 21 | direction: $direction 22 | sort: $sort 23 | } 24 | ) { 25 | count 26 | performers { 27 | id 28 | name 29 | disambiguation 30 | deleted 31 | aliases 32 | gender 33 | birth_date 34 | is_favorite 35 | images { 36 | ...ImageFragment 37 | } 38 | scenes(input: { performed_with: $performerId }) 39 | @include(if: $fetchScenes) { 40 | id 41 | title 42 | date 43 | duration 44 | release_date 45 | studio { 46 | id 47 | name 48 | } 49 | images { 50 | ...ImageFragment 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Scenes.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/QuerySceneFragment.gql" 2 | query Scenes($input: SceneQueryInput!) { 3 | queryScenes(input: $input) { 4 | count 5 | scenes { 6 | ...QuerySceneFragment 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/ScenesWithFingerprints.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/QuerySceneFragment.gql" 2 | query ScenesWithFingerprints($input: SceneQueryInput!, $submitted: Boolean!) { 3 | queryScenes(input: $input) { 4 | count 5 | scenes { 6 | ...QuerySceneFragment 7 | fingerprints(is_submitted: $submitted) { 8 | hash 9 | algorithm 10 | duration 11 | submissions 12 | user_submitted 13 | created 14 | updated 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/ScenesWithoutCount.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/QuerySceneFragment.gql" 2 | query ScenesWithoutCount($input: SceneQueryInput!) { 3 | queryScenes(input: $input) { 4 | scenes { 5 | ...QuerySceneFragment 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/SearchAll.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ImageFragment.gql" 2 | #import "../fragments/SearchPerformerFragment.gql" 3 | #import "../fragments/URLFragment.gql" 4 | query SearchAll($term: String!, $limit: Int = 5) { 5 | searchPerformer(term: $term, limit: $limit) { 6 | ...SearchPerformerFragment 7 | } 8 | searchScene(term: $term, limit: $limit) { 9 | id 10 | release_date 11 | title 12 | deleted 13 | duration 14 | code 15 | urls { 16 | ...URLFragment 17 | } 18 | images { 19 | ...ImageFragment 20 | } 21 | studio { 22 | id 23 | name 24 | } 25 | performers { 26 | as 27 | performer { 28 | id 29 | name 30 | disambiguation 31 | gender 32 | aliases 33 | deleted 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/SearchPerformers.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/SearchPerformerFragment.gql" 2 | query SearchPerformers($term: String!, $limit: Int = 5) { 3 | searchPerformer(term: $term, limit: $limit) { 4 | ...SearchPerformerFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/SearchTags.gql: -------------------------------------------------------------------------------- 1 | fragment SearchTagFragment on Tag { 2 | deleted 3 | id 4 | name 5 | description 6 | aliases 7 | } 8 | 9 | query SearchTags($term: String!, $limit: Int = 5) { 10 | exact: findTagOrAlias(name: $term) { 11 | ...SearchTagFragment 12 | } 13 | query: searchTag(term: $term, limit: $limit) { 14 | ...SearchTagFragment 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Site.gql: -------------------------------------------------------------------------------- 1 | query Site($id: ID!) { 2 | findSite(id: $id) { 3 | id 4 | name 5 | description 6 | url 7 | regex 8 | valid_types 9 | icon 10 | created 11 | updated 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Sites.gql: -------------------------------------------------------------------------------- 1 | query Sites { 2 | querySites { 3 | sites { 4 | id 5 | name 6 | description 7 | url 8 | regex 9 | valid_types 10 | icon 11 | created 12 | updated 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Studio.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/StudioFragment.gql" 2 | query Studio($id: ID!) { 3 | findStudio(id: $id) { 4 | ...StudioFragment 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/StudioPerformers.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/ImageFragment.gql" 2 | query StudioPerformers( 3 | $studioId: ID! 4 | $gender: GenderFilterEnum 5 | $favorite: Boolean 6 | $names: String 7 | $page: Int! = 1 8 | $per_page: Int! = 25 9 | $direction: SortDirectionEnum! 10 | $sort: PerformerSortEnum! 11 | ) { 12 | queryPerformers( 13 | input: { 14 | studio_id: $studioId 15 | gender: $gender 16 | is_favorite: $favorite 17 | names: $names 18 | page: $page 19 | per_page: $per_page 20 | direction: $direction 21 | sort: $sort 22 | } 23 | ) { 24 | count 25 | performers { 26 | id 27 | name 28 | disambiguation 29 | deleted 30 | aliases 31 | gender 32 | birth_date 33 | is_favorite 34 | images { 35 | ...ImageFragment 36 | } 37 | scenes(input: { studio_id: $studioId }) { 38 | id 39 | title 40 | duration 41 | release_date 42 | production_date 43 | studio { 44 | id 45 | name 46 | } 47 | images { 48 | ...ImageFragment 49 | } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Studios.gql: -------------------------------------------------------------------------------- 1 | #import "../fragments/URLFragment.gql" 2 | #import "../fragments/ImageFragment.gql" 3 | query Studios($input: StudioQueryInput!) { 4 | queryStudios(input: $input) { 5 | count 6 | studios { 7 | id 8 | name 9 | aliases 10 | deleted 11 | parent { 12 | id 13 | name 14 | } 15 | urls { 16 | ...URLFragment 17 | } 18 | images { 19 | ...ImageFragment 20 | } 21 | is_favorite 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Tag.gql: -------------------------------------------------------------------------------- 1 | query Tag($name: String, $id: ID) { 2 | findTag(name: $name, id: $id) { 3 | id 4 | name 5 | description 6 | aliases 7 | deleted 8 | category { 9 | id 10 | name 11 | group 12 | description 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Tags.gql: -------------------------------------------------------------------------------- 1 | query Tags($input: TagQueryInput!) { 2 | queryTags(input: $input) { 3 | count 4 | tags { 5 | id 6 | name 7 | description 8 | aliases 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/UnreadNotificationCount.gql: -------------------------------------------------------------------------------- 1 | query UnreadNotificationCount { 2 | getUnreadNotificationCount 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/User.gql: -------------------------------------------------------------------------------- 1 | query User($name: String!) { 2 | findUser(username: $name) { 3 | id 4 | name 5 | email 6 | roles 7 | api_key 8 | api_calls 9 | invited_by { 10 | id 11 | name 12 | } 13 | invite_tokens 14 | invite_codes { 15 | id 16 | uses 17 | expires 18 | } 19 | vote_count { 20 | accept 21 | reject 22 | immediate_accept 23 | immediate_reject 24 | abstain 25 | } 26 | edit_count { 27 | immediate_accepted 28 | immediate_rejected 29 | accepted 30 | rejected 31 | failed 32 | canceled 33 | pending 34 | } 35 | notification_subscriptions 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Users.gql: -------------------------------------------------------------------------------- 1 | query Users($input: UserQueryInput!) { 2 | queryUsers(input: $input) { 3 | count 4 | users { 5 | id 6 | name 7 | email 8 | roles 9 | api_key 10 | api_calls 11 | invited_by { 12 | id 13 | name 14 | } 15 | invite_tokens 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /frontend/src/graphql/queries/Version.gql: -------------------------------------------------------------------------------- 1 | query Version { 2 | version { 3 | hash 4 | version 5 | build_time 6 | build_type 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/graphql/scalars.d.ts: -------------------------------------------------------------------------------- 1 | type GQLDate = string; 2 | type GQLTime = string; 3 | type GQLUpload = File; 4 | -------------------------------------------------------------------------------- /frontend/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as usePagination } from "./usePagination"; 2 | export { default as useEditFilter } from "./useEditFilter"; 3 | export { default as useAuth } from "./useAuth"; 4 | export { useToast } from "./useToast"; 5 | export { useQueryParams } from "./useQueryParams"; 6 | export { useCurrentUser } from "./useCurrentUser"; 7 | -------------------------------------------------------------------------------- /frontend/src/hooks/toast.scss: -------------------------------------------------------------------------------- 1 | .ToastContainer { 2 | position: fixed; 3 | bottom: 30px; 4 | right: 40px; 5 | z-index: 10; 6 | } 7 | 8 | .toast-header { 9 | float: right; 10 | background-color: transparent; 11 | padding: 0.75rem; 12 | } 13 | 14 | .toast.fade { 15 | transition: opacity 0.6s ease-in-out; 16 | } 17 | -------------------------------------------------------------------------------- /frontend/src/hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useMe } from "src/graphql"; 2 | import { getCachedUser, setCachedUser } from "src/utils"; 3 | import { User } from "../context"; 4 | 5 | interface AuthResult { 6 | loading: boolean; 7 | user: User | undefined; 8 | } 9 | 10 | const useAuth = (): AuthResult => { 11 | const { loading, data } = useMe({ 12 | fetchPolicy: "network-only", 13 | onCompleted: (res) => setCachedUser(res.me), 14 | onError: () => setCachedUser(), 15 | }); 16 | 17 | return { loading, user: loading ? getCachedUser() : (data?.me ?? undefined) }; 18 | }; 19 | 20 | export default useAuth; 21 | -------------------------------------------------------------------------------- /frontend/src/hooks/useBeforeUnload.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export const useBeforeUnload = () => { 4 | const unloadListener = (event: BeforeUnloadEvent) => { 5 | event.preventDefault(); 6 | event.returnValue = true; 7 | }; 8 | useEffect(() => { 9 | window.addEventListener("beforeunload", unloadListener); 10 | return () => window.removeEventListener("beforeunload", unloadListener); 11 | }, []); 12 | }; 13 | -------------------------------------------------------------------------------- /frontend/src/hooks/usePagination.ts: -------------------------------------------------------------------------------- 1 | import { useQueryParams } from "src/hooks"; 2 | 3 | const usePagination = () => { 4 | const [{ page }, setParams] = useQueryParams({ 5 | page: { name: "page", type: "number", default: 1 }, 6 | }); 7 | 8 | const setPage = (pageNumber: number) => setParams("page", pageNumber); 9 | 10 | return { page, setPage }; 11 | }; 12 | 13 | export default usePagination; 14 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from "react-dom/client"; 2 | import App from "./App"; 3 | const container = document.getElementById("root"); 4 | if (container) { 5 | const root = createRoot(container); 6 | root.render(); 7 | } else { 8 | throw Error("Root not found"); 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/modules.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.gql" { 2 | import { DocumentNode } from "graphql"; 3 | 4 | const value: DocumentNode; 5 | export default value; 6 | } 7 | 8 | interface ImportMetaEnv extends Readonly> { 9 | readonly VITE_APIKEY?: string; 10 | readonly VITE_SERVER_PORT?: string; 11 | } 12 | 13 | interface ImportMeta { 14 | readonly env: ImportMetaEnv; 15 | } 16 | -------------------------------------------------------------------------------- /frontend/src/pages/activateUser/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ActivateUser"; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/categories/CategoryAdd.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | import { useAddCategory, TagCategoryCreateInput } from "src/graphql"; 5 | import { categoryHref } from "src/utils"; 6 | import CategoryForm from "./categoryForm"; 7 | 8 | const AddCategory: FC = () => { 9 | const navigate = useNavigate(); 10 | const [createCategory] = useAddCategory({ 11 | onCompleted: (data) => { 12 | if (data?.tagCategoryCreate?.id) 13 | navigate(categoryHref(data.tagCategoryCreate)); 14 | }, 15 | }); 16 | 17 | const doInsert = (insertData: TagCategoryCreateInput) => { 18 | createCategory({ 19 | variables: { 20 | categoryData: insertData, 21 | }, 22 | }); 23 | }; 24 | 25 | return ( 26 |
27 |

Add new tag category

28 |
29 | 30 |
31 | ); 32 | }; 33 | 34 | export default AddCategory; 35 | -------------------------------------------------------------------------------- /frontend/src/pages/categories/CategoryEdit.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | import { 5 | useUpdateCategory, 6 | TagCategoryCreateInput, 7 | CategoryQuery, 8 | } from "src/graphql"; 9 | import { categoryHref } from "src/utils"; 10 | import CategoryForm from "./categoryForm"; 11 | 12 | type Category = NonNullable; 13 | 14 | interface Props { 15 | category: Category; 16 | } 17 | 18 | const UpdateCategory: FC = ({ category }) => { 19 | const navigate = useNavigate(); 20 | const [updateCategory] = useUpdateCategory({ 21 | onCompleted: (result) => { 22 | if (result?.tagCategoryUpdate?.id) 23 | navigate(categoryHref(result.tagCategoryUpdate)); 24 | }, 25 | }); 26 | 27 | const doUpdate = (insertData: TagCategoryCreateInput) => { 28 | updateCategory({ 29 | variables: { 30 | categoryData: { 31 | id: category.id, 32 | ...insertData, 33 | }, 34 | }, 35 | }); 36 | }; 37 | 38 | return ( 39 |
40 |

41 | Update {category.name} 42 |

43 |
44 | 45 |
46 | ); 47 | }; 48 | 49 | export default UpdateCategory; 50 | -------------------------------------------------------------------------------- /frontend/src/pages/categories/categoryForm/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./CategoryForm"; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/drafts/Draft.tsx: -------------------------------------------------------------------------------- 1 | import { DraftQuery } from "src/graphql"; 2 | import SceneDraft from "./SceneDraft"; 3 | import PerformerDraft from "./PerformerDraft"; 4 | 5 | type Draft = NonNullable; 6 | 7 | const DraftComponent: React.FC<{ draft: Draft }> = ({ draft }) => { 8 | if (draft.data.__typename === "SceneDraft") 9 | return ; 10 | else return ; 11 | }; 12 | 13 | export default DraftComponent; 14 | -------------------------------------------------------------------------------- /frontend/src/pages/edits/Edits.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | 3 | import { VoteStatusEnum, UserVotedFilterEnum } from "src/graphql"; 4 | import { EditList } from "src/components/list"; 5 | import Title from "src/components/title"; 6 | 7 | const EditsComponent: FC = () => ( 8 | <> 9 | 10 | <h3>Edits</h3> 11 | <EditList 12 | defaultVoteStatus={VoteStatusEnum.PENDING} 13 | defaultVoted={UserVotedFilterEnum.NOT_VOTED} 14 | defaultBot="exclude" 15 | defaultUserSubmitted={true} 16 | /> 17 | </> 18 | ); 19 | 20 | export default EditsComponent; 21 | -------------------------------------------------------------------------------- /frontend/src/pages/edits/components/UpdateCount.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useConfig } from "src/graphql"; 3 | 4 | interface Props { 5 | updatable: boolean; 6 | updateCount: number; 7 | } 8 | 9 | export const UpdateCount: FC<Props> = ({ updatable, updateCount }) => { 10 | const { data: config } = useConfig(); 11 | 12 | const updateLimit = config?.getConfig.edit_update_limit; 13 | if (!updatable || !updateLimit) return null; 14 | 15 | const updates = updateLimit - updateCount; 16 | return ( 17 | <small className="text-muted align-content-center me-3"> 18 | Edit can be updated{" "} 19 | {updates === 1 ? "one more time" : `${updates} more times`} 20 | </small> 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/src/pages/edits/index.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Route, Routes } from "react-router-dom"; 3 | 4 | import Edit from "./Edit"; 5 | import Edits from "./Edits"; 6 | import EditUpdate from "./EditUpdate"; 7 | 8 | const SceneRoutes: FC = () => ( 9 | <Routes> 10 | <Route path="/" element={<Edits />} /> 11 | <Route path="/:id/update" element={<EditUpdate />} /> 12 | <Route path="/:id/*" element={<Edit />} /> 13 | </Routes> 14 | ); 15 | 16 | export default SceneRoutes; 17 | -------------------------------------------------------------------------------- /frontend/src/pages/forgotPassword/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ForgotPassword"; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/home/index.ts: -------------------------------------------------------------------------------- 1 | import Home from "./Home"; 2 | 3 | export default Home; 4 | -------------------------------------------------------------------------------- /frontend/src/pages/home/styles.scss: -------------------------------------------------------------------------------- 1 | .HomePage { 2 | &-scenes { 3 | display: grid; 4 | grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); 5 | grid-template-rows: auto auto; 6 | grid-auto-rows: 0; 7 | overflow: hidden; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/pages/notifications/CommentNotification.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import type { CommentNotificationType } from "./types"; 3 | import EditComment from "src/components/editCard/EditComment"; 4 | 5 | interface Props { 6 | notification: CommentNotificationType; 7 | } 8 | 9 | export const CommentNotification: FC<Props> = ({ notification }) => ( 10 | <EditComment {...notification.data.comment} /> 11 | ); 12 | -------------------------------------------------------------------------------- /frontend/src/pages/notifications/EditNotification.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import EditCard from "src/components/editCard"; 3 | import type { EditNotificationType } from "./types"; 4 | 5 | interface Props { 6 | notification: EditNotificationType; 7 | } 8 | 9 | export const EditNotification: FC<Props> = ({ notification }) => { 10 | return ( 11 | <EditCard 12 | edit={notification.data.edit} 13 | showVotes 14 | hideDiff 15 | showVoteBar={false} 16 | /> 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/src/pages/notifications/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Notifications"; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/notifications/sceneNotification.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import SceneCard from "src/components/sceneCard"; 3 | import type { SceneNotificationType } from "./types"; 4 | 5 | interface Props { 6 | notification: SceneNotificationType; 7 | } 8 | 9 | export const SceneNotification: FC<Props> = ({ notification }) => { 10 | return <SceneCard scene={notification.data.scene} />; 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/src/pages/notifications/styles.scss: -------------------------------------------------------------------------------- 1 | .Notification { 2 | .SceneCard { 3 | width: 300px; 4 | } 5 | 6 | &-read-state { 7 | width: 20px; 8 | height: 20px; 9 | display: flex; 10 | align-self: center; 11 | align-items: center; 12 | justify-content: center; 13 | 14 | .btn { 15 | border: none; 16 | border-bottom: 2px solid transparent; 17 | border-radius: 0; 18 | padding: 4px 0; 19 | 20 | .fa-envelope { 21 | display: block; 22 | } 23 | 24 | .fa-envelope-open { 25 | display: none; 26 | } 27 | 28 | &:hover { 29 | border-color: white; 30 | 31 | .fa-envelope { 32 | display: none; 33 | } 34 | 35 | .fa-envelope-open { 36 | display: block; 37 | color: white !important; 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /frontend/src/pages/notifications/types.ts: -------------------------------------------------------------------------------- 1 | import { NotificationsQuery } from "src/graphql"; 2 | 3 | export type NotificationType = 4 | NotificationsQuery["queryNotifications"]["notifications"][number]; 5 | 6 | type CommentData = Extract<NotificationType["data"], { comment: unknown }>; 7 | export type CommentNotificationType = NotificationType & { data: CommentData }; 8 | export const isCommentNotification = ( 9 | notification: NotificationType, 10 | ): notification is CommentNotificationType => 11 | (notification.data as CommentData).comment !== undefined; 12 | 13 | type EditData = Extract<NotificationType["data"], { edit: unknown }>; 14 | export type EditNotificationType = NotificationType & { data: EditData }; 15 | export const isEditNotification = ( 16 | notification: NotificationType, 17 | ): notification is EditNotificationType => 18 | (notification.data as EditData).edit !== undefined; 19 | 20 | type SceneData = Extract<NotificationType["data"], { scene: unknown }>; 21 | export type SceneNotificationType = NotificationType & { data: SceneData }; 22 | export const isSceneNotification = ( 23 | notification: NotificationType, 24 | ): notification is SceneNotificationType => 25 | (notification.data as SceneData).scene !== undefined; 26 | -------------------------------------------------------------------------------- /frontend/src/pages/performers/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./performerInfo"; 2 | export * from "./scenePairings"; 3 | -------------------------------------------------------------------------------- /frontend/src/pages/performers/performerForm/index.ts: -------------------------------------------------------------------------------- 1 | import PerformerForm from "./PerformerForm"; 2 | export * from "./types"; 3 | 4 | export default PerformerForm; 5 | -------------------------------------------------------------------------------- /frontend/src/pages/performers/performerForm/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GenderEnum, 3 | HairColorEnum, 4 | EyeColorEnum, 5 | EthnicityEnum, 6 | BreastTypeEnum, 7 | } from "src/graphql"; 8 | 9 | export type InitialPerformer = { 10 | name?: string | null; 11 | disambiguation?: string | null; 12 | gender?: GenderEnum | null; 13 | birthdate?: string | null; 14 | deathdate?: string | null; 15 | height?: number | null; 16 | hair_color?: HairColorEnum | null; 17 | eye_color?: EyeColorEnum | null; 18 | ethnicity?: EthnicityEnum | null; 19 | breast_type?: BreastTypeEnum | null; 20 | country?: string | null; 21 | career_start_year?: number | null; 22 | career_end_year?: number | null; 23 | urls?: { 24 | url: string; 25 | site: { 26 | id: string; 27 | name: string; 28 | }; 29 | }[]; 30 | aliases?: string[]; 31 | waist_size?: number | null; 32 | hip_size?: number | null; 33 | band_size?: number | null; 34 | cup_size?: string | null; 35 | images?: { 36 | id: string; 37 | url: string; 38 | width: number; 39 | height: number; 40 | }[]; 41 | tattoos?: { 42 | location: string; 43 | description?: string | null; 44 | }[]; 45 | piercings?: { 46 | location: string; 47 | description?: string | null; 48 | }[]; 49 | }; 50 | -------------------------------------------------------------------------------- /frontend/src/pages/performers/styles.scss: -------------------------------------------------------------------------------- 1 | .PerformerMerge { 2 | .PerformerCard { 3 | &-image { 4 | height: 8rem; 5 | aspect-ratio: 1 / 1; 6 | } 7 | 8 | .card-footer { 9 | display: none; 10 | } 11 | } 12 | 13 | .TargetCard { 14 | .PerformerCard-image { 15 | border: 2px solid white; 16 | } 17 | } 18 | } 19 | 20 | .performer-photo { 21 | height: 470px; 22 | margin-top: 0.75rem; 23 | } 24 | 25 | .performer-filter { 26 | width: 175px; 27 | } 28 | 29 | .performer-sort { 30 | width: 200px; 31 | 32 | select { 33 | -webkit-appearance: none; 34 | } 35 | } 36 | 37 | .performers-list { 38 | .col-auto { 39 | width: 20%; 40 | } 41 | } 42 | 43 | .PerformerScenes { 44 | .CheckboxSelect { 45 | float: left; 46 | margin-right: 0.5rem; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/pages/registerUser/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Register"; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/resetPassword/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ResetPassword"; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/scenes/sceneForm/index.ts: -------------------------------------------------------------------------------- 1 | import SceneForm from "./SceneForm"; 2 | 3 | export default SceneForm; 4 | export * from "./types"; 5 | -------------------------------------------------------------------------------- /frontend/src/pages/scenes/sceneForm/types.ts: -------------------------------------------------------------------------------- 1 | import { GenderEnum } from "src/graphql"; 2 | 3 | export type InitialScene = { 4 | title?: string | null; 5 | details?: string | null; 6 | duration?: number | null; 7 | director?: string | null; 8 | date?: string | null; 9 | production_date?: string | null; 10 | code?: string | null; 11 | urls?: { 12 | url: string; 13 | site: { 14 | id: string; 15 | name: string; 16 | }; 17 | }[]; 18 | images?: { 19 | id: string; 20 | width: number; 21 | height: number; 22 | url: string; 23 | }[]; 24 | studio?: { 25 | id: string; 26 | name: string; 27 | } | null; 28 | tags?: { 29 | id: string; 30 | name: string; 31 | aliases: string[]; 32 | }[]; 33 | performers?: 34 | | { 35 | as?: string | null; 36 | performer: { 37 | id: string; 38 | name: string; 39 | aliases?: string[] | null; 40 | disambiguation?: string | null; 41 | gender?: GenderEnum | null; 42 | deleted: boolean; 43 | }; 44 | }[] 45 | | null; 46 | }; 47 | -------------------------------------------------------------------------------- /frontend/src/pages/scenes/styles.scss: -------------------------------------------------------------------------------- 1 | .fa-mars, 2 | .fa-venus, 3 | .fa-transgender { 4 | margin-right: 0.25rem; 5 | } 6 | 7 | .fa-mars { 8 | color: #89cff0; 9 | } 10 | 11 | .fa-venus { 12 | color: #f38cac; 13 | } 14 | 15 | .fa-transgender { 16 | color: #c8a2c8; 17 | } 18 | 19 | .ScenePhoto { 20 | padding: 0; 21 | min-height: 450px; 22 | display: flex; 23 | flex-direction: column; 24 | } 25 | 26 | .scene-performer { 27 | display: inline-block; 28 | margin-right: 0.5rem; 29 | } 30 | 31 | .scene-description { 32 | white-space: pre-wrap; 33 | } 34 | 35 | .scene-tags { 36 | margin-top: 1rem; 37 | } 38 | 39 | .scene-tag-list { 40 | list-style-type: none; 41 | margin: 0; 42 | padding: 0; 43 | 44 | li { 45 | display: inline; 46 | margin: 0; 47 | } 48 | } 49 | 50 | .user-submitted { 51 | color: $success; 52 | margin-left: 0.5rem; 53 | padding: 0; 54 | vertical-align: baseline; 55 | 56 | .fa-circle-xmark { 57 | display: none; 58 | } 59 | 60 | &:hover { 61 | cursor: pointer; 62 | color: $danger; 63 | 64 | .fa-circle-xmark { 65 | display: inline; 66 | } 67 | 68 | .fa-circle-check { 69 | display: none; 70 | } 71 | } 72 | 73 | .fa-spin { 74 | animation-duration: 1.5s; 75 | cursor: wait; 76 | color: $warning; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /frontend/src/pages/search/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Search"; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/sites/SiteAdd.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | import { useAddSite, SiteCreateInput } from "src/graphql"; 5 | import { siteHref } from "src/utils"; 6 | import SiteForm from "./siteForm"; 7 | 8 | const AddSite: FC = () => { 9 | const navigate = useNavigate(); 10 | const [createSite] = useAddSite({ 11 | onCompleted: (data) => { 12 | if (data?.siteCreate?.id) navigate(siteHref(data.siteCreate)); 13 | }, 14 | }); 15 | 16 | const doInsert = (insertData: SiteCreateInput) => { 17 | createSite({ 18 | variables: { 19 | siteData: insertData, 20 | }, 21 | }); 22 | }; 23 | 24 | return ( 25 | <div> 26 | <h3>Add new site</h3> 27 | <hr /> 28 | <SiteForm callback={doInsert} /> 29 | </div> 30 | ); 31 | }; 32 | 33 | export default AddSite; 34 | -------------------------------------------------------------------------------- /frontend/src/pages/sites/SiteEdit.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | import { useUpdateSite, SiteCreateInput, SiteQuery } from "src/graphql"; 5 | import { siteHref } from "src/utils"; 6 | import SiteForm from "./siteForm"; 7 | 8 | type Site = NonNullable<SiteQuery["findSite"]>; 9 | 10 | interface Props { 11 | site: Site; 12 | } 13 | 14 | const UpdateSite: FC<Props> = ({ site }) => { 15 | const navigate = useNavigate(); 16 | const [updateSite] = useUpdateSite({ 17 | onCompleted: (result) => { 18 | if (result?.siteUpdate?.id) navigate(siteHref(result.siteUpdate)); 19 | }, 20 | }); 21 | 22 | const doUpdate = (insertData: SiteCreateInput) => { 23 | updateSite({ 24 | variables: { 25 | siteData: { 26 | id: site.id, 27 | ...insertData, 28 | }, 29 | }, 30 | }); 31 | }; 32 | 33 | return ( 34 | <div> 35 | <h3> 36 | Update <em>{site.name}</em> 37 | </h3> 38 | <hr /> 39 | <SiteForm callback={doUpdate} site={site} /> 40 | </div> 41 | ); 42 | }; 43 | 44 | export default UpdateSite; 45 | -------------------------------------------------------------------------------- /frontend/src/pages/sites/siteForm/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./SiteForm"; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/studios/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./studioPerformers"; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/studios/studioForm/index.ts: -------------------------------------------------------------------------------- 1 | import StudioForm from "./StudioForm"; 2 | 3 | export default StudioForm; 4 | -------------------------------------------------------------------------------- /frontend/src/pages/studios/studioForm/schema.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | export const StudioSchema = yup.object({ 4 | name: yup.string().trim().required("Name is required"), 5 | aliases: yup.array().of(yup.string().trim().ensure()).ensure().default([]), 6 | urls: yup 7 | .array() 8 | .of( 9 | yup.object({ 10 | url: yup.string().url("Invalid URL").required(), 11 | site: yup 12 | .object({ 13 | id: yup.string().required(), 14 | name: yup.string().required(), 15 | icon: yup.string().required(), 16 | }) 17 | .required(), 18 | }), 19 | ) 20 | .ensure(), 21 | images: yup 22 | .array() 23 | .of( 24 | yup.object({ 25 | id: yup.string().required(), 26 | url: yup.string().required(), 27 | width: yup.number().required(), 28 | height: yup.number().required(), 29 | }), 30 | ) 31 | .required(), 32 | parent: yup 33 | .object({ 34 | id: yup.string().required(), 35 | name: yup.string().required(), 36 | }) 37 | .nullable() 38 | .default(null), 39 | note: yup.string().required("Edit note is required"), 40 | }); 41 | 42 | export type StudioFormData = yup.Asserts<typeof StudioSchema>; 43 | -------------------------------------------------------------------------------- /frontend/src/pages/studios/studioForm/types.ts: -------------------------------------------------------------------------------- 1 | export type InitialStudio = { 2 | name?: string | null; 3 | aliases?: string[]; 4 | parent?: { 5 | id: string; 6 | name: string; 7 | } | null; 8 | images?: { 9 | id: string; 10 | height: number; 11 | width: number; 12 | url: string; 13 | }[]; 14 | urls?: { 15 | url: string; 16 | site: { 17 | id: string; 18 | name: string; 19 | }; 20 | }[]; 21 | }; 22 | -------------------------------------------------------------------------------- /frontend/src/pages/studios/styles.scss: -------------------------------------------------------------------------------- 1 | .studio-photo { 2 | flex-grow: 1; 3 | margin-right: 1rem; 4 | max-height: 100px; 5 | text-align: right; 6 | 7 | img { 8 | max-height: 100%; 9 | max-width: 32rem; 10 | } 11 | } 12 | 13 | .sub-studio-list { 14 | margin-bottom: 1rem; 15 | max-height: 15rem; 16 | overflow-y: auto; 17 | 18 | ul { 19 | column-count: 3; 20 | margin-bottom: 0; 21 | } 22 | } 23 | 24 | .StudioForm { 25 | .EditImages { 26 | &-images, 27 | &-input { 28 | flex: unset; 29 | width: unset; 30 | } 31 | 32 | &-drop { 33 | height: 200px; 34 | padding: 1rem; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/pages/tags/Tags.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Button } from "react-bootstrap"; 3 | import { Link } from "react-router-dom"; 4 | 5 | import { TagList } from "src/components/list"; 6 | import { createHref } from "src/utils"; 7 | import { ROUTE_TAG_ADD } from "src/constants/route"; 8 | import { useCurrentUser } from "src/hooks"; 9 | 10 | const Tags: FC = () => { 11 | const { isTagEditor } = useCurrentUser(); 12 | return ( 13 | <> 14 | <div className="d-flex"> 15 | <h3>Tags</h3> 16 | {isTagEditor && ( 17 | <Link to={createHref(ROUTE_TAG_ADD)} className="ms-auto"> 18 | <Button className="ms-auto">Create</Button> 19 | </Link> 20 | )} 21 | </div> 22 | <TagList tagFilter={{}} showCategoryLink /> 23 | </> 24 | ); 25 | }; 26 | 27 | export default Tags; 28 | -------------------------------------------------------------------------------- /frontend/src/pages/tags/tagForm/index.ts: -------------------------------------------------------------------------------- 1 | import TagForm from "./TagForm"; 2 | 3 | export default TagForm; 4 | -------------------------------------------------------------------------------- /frontend/src/pages/tags/tagForm/schema.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | export const TagSchema = yup.object({ 4 | name: yup.string().trim().required("Name is required"), 5 | description: yup.string().trim(), 6 | aliases: yup.array().of(yup.string().trim().ensure()).ensure().default([]), 7 | category: yup 8 | .object({ 9 | id: yup.string().required(), 10 | name: yup.string().required(), 11 | }) 12 | .nullable() 13 | .default(null), 14 | note: yup.string().required("Edit note is required"), 15 | }); 16 | 17 | export type TagFormData = yup.Asserts<typeof TagSchema>; 18 | -------------------------------------------------------------------------------- /frontend/src/pages/tags/tagForm/types.ts: -------------------------------------------------------------------------------- 1 | export type InitialTag = { 2 | name?: string | null; 3 | description?: string | null; 4 | aliases?: string[]; 5 | category?: { 6 | id: string; 7 | name: string; 8 | } | null; 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/pages/users/UserEdits.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Link } from "react-router-dom"; 3 | 4 | import { ROUTE_USER } from "src/constants/route"; 5 | import { createHref } from "src/utils"; 6 | import { EditList } from "src/components/list"; 7 | import { VoteStatusEnum } from "src/graphql"; 8 | import { useCurrentUser } from "src/hooks"; 9 | 10 | interface Props { 11 | user: { 12 | id: string; 13 | name: string; 14 | }; 15 | } 16 | 17 | const UserEditsComponent: FC<Props> = ({ user }) => { 18 | const { isSelf } = useCurrentUser(); 19 | return ( 20 | <> 21 | <h3> 22 | Edits by <Link to={createHref(ROUTE_USER, user)}>{user.name}</Link> 23 | </h3> 24 | <EditList 25 | userId={user.id} 26 | defaultVoteStatus={VoteStatusEnum.PENDING} 27 | showVotedFilter={!isSelf(user)} 28 | userSubmitted={true} 29 | /> 30 | </> 31 | ); 32 | }; 33 | 34 | export default UserEditsComponent; 35 | -------------------------------------------------------------------------------- /frontend/src/pages/users/UserFingerprints.tsx: -------------------------------------------------------------------------------- 1 | import { UserFingerprintsList } from "./UserFingerprintsList"; 2 | 3 | const UserFingerprintsComponent = () => { 4 | const filter = { 5 | has_fingerprint_submissions: true, 6 | }; 7 | 8 | return ( 9 | <> 10 | <h3>My fingerprints</h3> 11 | <UserFingerprintsList filter={filter} /> 12 | </> 13 | ); 14 | }; 15 | 16 | export default UserFingerprintsComponent; 17 | -------------------------------------------------------------------------------- /frontend/src/pages/users/UserFingerprintsList/UserFingerprint.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Button } from "react-bootstrap"; 3 | import { FingerprintAlgorithm } from "src/graphql"; 4 | import { Icon } from "src/components/fragments"; 5 | import { formatDuration } from "src/utils"; 6 | import { faTimesCircle } from "@fortawesome/free-solid-svg-icons"; 7 | 8 | interface Props { 9 | fingerprint: { 10 | hash: string; 11 | duration: number; 12 | algorithm: FingerprintAlgorithm; 13 | }; 14 | deleteFingerprint: () => void; 15 | } 16 | 17 | export const UserFingerprint: FC<Props> = ({ 18 | fingerprint, 19 | deleteFingerprint, 20 | }) => ( 21 | <li> 22 | <div key={fingerprint.hash}> 23 | <b className="me-2">{fingerprint.algorithm}</b> 24 | {fingerprint.hash} ({formatDuration(fingerprint.duration)}) 25 | <Button 26 | className="text-danger ms-2" 27 | title="Submitted by you - click to remove submission" 28 | onClick={deleteFingerprint} 29 | variant="link" 30 | > 31 | <Icon icon={faTimesCircle} /> 32 | </Button> 33 | </div> 34 | </li> 35 | ); 36 | -------------------------------------------------------------------------------- /frontend/src/pages/users/UserFingerprintsList/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./UserFingerprintsList"; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/users/styles.scss: -------------------------------------------------------------------------------- 1 | .users-table { 2 | table-layout: fixed; 3 | 4 | .apikey { 5 | width: 50%; 6 | word-break: break-all; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/pages/version/Version.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useVersion } from "src/graphql"; 3 | 4 | const Version: FC = () => { 5 | const { loading, data } = useVersion(); 6 | 7 | if (loading || !data) return null; 8 | 9 | let link = ""; 10 | switch (data.version.build_type) { 11 | case "OFFICIAL": 12 | link = `https://github.com/stashapp/stash-box/releases/tag/${data.version.version}`; 13 | break; 14 | case "DEVELOPMENT": 15 | case "PR": 16 | link = `https://github.com/stashapp/stash-box/commit/${data.version.hash}`; 17 | break; 18 | } 19 | 20 | return ( 21 | <dl> 22 | <dt>Version</dt> 23 | <dd> 24 | {link ? ( 25 | <a href={link}>{data.version.version}</a> 26 | ) : ( 27 | <span>{data.version.version}</span> 28 | )} 29 | </dd> 30 | <dt>Build Type</dt> 31 | <dd>{data.version.build_type}</dd> 32 | <dt>Build Hash</dt> 33 | <dd>{data.version.hash}</dd> 34 | <dt>Build Time</dt> 35 | <dd>{data.version.build_time}</dd> 36 | </dl> 37 | ); 38 | }; 39 | 40 | export default Version; 41 | -------------------------------------------------------------------------------- /frontend/src/pages/version/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Version"; 2 | -------------------------------------------------------------------------------- /frontend/src/utils/country.ts: -------------------------------------------------------------------------------- 1 | import Countries from "i18n-iso-countries"; 2 | import english from "i18n-iso-countries/langs/en.json"; 3 | 4 | Countries.registerLocale(english); 5 | 6 | const fuzzyDict: Record<string, string> = { 7 | USA: "US", 8 | "United States": "US", 9 | America: "US", 10 | American: "US", 11 | Czechia: "CZ", 12 | England: "GB", 13 | "United Kingdom": "GB", 14 | Russia: "RU", 15 | "Slovak Republic": "SK", 16 | }; 17 | 18 | export const getISOCountry = (country: string | null | undefined) => { 19 | if (!country) return null; 20 | 21 | const code = fuzzyDict[country] ?? Countries.getAlpha2Code(country, "en"); 22 | if (!code) return null; 23 | 24 | return { 25 | code, 26 | name: Countries.getName(code, "en"), 27 | }; 28 | }; 29 | 30 | export const getCountryByISO = (iso?: string | null) => { 31 | if (!iso) return null; 32 | const country = Countries.getName(iso, "en", { select: "alias" }); 33 | return country ?? null; 34 | }; 35 | -------------------------------------------------------------------------------- /frontend/src/utils/data.ts: -------------------------------------------------------------------------------- 1 | export const filterData = <T>(data?: (T | null | undefined)[] | null) => 2 | data ? (data.filter((item) => item) as T[]) : []; 3 | 4 | export const compareByName = <T extends { name: string }>(a: T, b: T) => 5 | a.name > b.name ? 1 : a.name < b.name ? -1 : 0; 6 | -------------------------------------------------------------------------------- /frontend/src/utils/general.ts: -------------------------------------------------------------------------------- 1 | export const isUUID = (term: string): boolean => 2 | /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(term); 3 | -------------------------------------------------------------------------------- /frontend/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./country"; 2 | export * from "./date"; 3 | export * from "./edit"; 4 | export * from "./general"; 5 | export * from "./transforms"; 6 | export * from "./route"; 7 | export * from "./markdown"; 8 | export * from "./enum"; 9 | export * from "./user"; 10 | export * from "./diff"; 11 | export * from "./data"; 12 | export * from "./intl"; 13 | export * from "./url"; 14 | -------------------------------------------------------------------------------- /frontend/src/utils/intl.ts: -------------------------------------------------------------------------------- 1 | const enOrdinalRules = new Intl.PluralRules("en-US", { type: "ordinal" }); 2 | 3 | const suffixes = new Map([ 4 | ["one", "st"], 5 | ["two", "nd"], 6 | ["few", "rd"], 7 | ["other", "th"], 8 | ]); 9 | 10 | export const formatOrdinals = (num: number) => { 11 | const rule = enOrdinalRules.select(num); 12 | const suffix = suffixes.get(rule); 13 | return `${num}${suffix}`; 14 | }; 15 | -------------------------------------------------------------------------------- /frontend/src/utils/markdown.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import ReactMarkdown from "react-markdown"; 3 | import RemarkGFM from "remark-gfm"; 4 | import RemarkBreaks from "remark-breaks"; 5 | import RehypeExternalLinks from "rehype-external-links"; 6 | 7 | interface Props { 8 | text: string | null | undefined; 9 | unique?: string; 10 | } 11 | 12 | export const Markdown: FC<Props> = ({ text, unique }) => 13 | text ? ( 14 | <ReactMarkdown 15 | remarkPlugins={[RemarkGFM, RemarkBreaks]} 16 | rehypePlugins={[ 17 | [RehypeExternalLinks, { rel: ["nofollow", "noopener", "noreferrer"] }], 18 | ]} 19 | remarkRehypeOptions={{ 20 | clobberPrefix: unique ? `${unique}-` : undefined, 21 | }} 22 | components={{ 23 | input: (props) => ( 24 | <input 25 | className={props.type === "checkbox" ? "form-check-input" : ""} 26 | {...props} 27 | /> 28 | ), 29 | }} 30 | > 31 | {text} 32 | </ReactMarkdown> 33 | ) : null; 34 | -------------------------------------------------------------------------------- /frontend/src/utils/url.ts: -------------------------------------------------------------------------------- 1 | export const cleanURL = ( 2 | regexStr: string | undefined | null, 3 | url: string, 4 | ): string | undefined => { 5 | if (!regexStr) return; 6 | 7 | const regex = new RegExp(regexStr); 8 | const match = regex.exec(url); 9 | 10 | if (match == null || match.length < 2) { 11 | return match?.[1]; 12 | } else { 13 | match.shift(); 14 | return match.join(""); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/utils/user.ts: -------------------------------------------------------------------------------- 1 | import type { User } from "../context"; 2 | import { UserQuery, PublicUserQuery, RoleEnum } from "src/graphql"; 3 | 4 | type PrivateUser = NonNullable<UserQuery["findUser"]>; 5 | type PublicUser = NonNullable<PublicUserQuery["findUser"]>; 6 | 7 | const USER_STORAGE = "stash_box_user"; 8 | const cache = localStorage.getItem(USER_STORAGE); 9 | const cachedUser = cache ? (JSON.parse(cache) as User) : undefined; 10 | 11 | export const getCachedUser = () => cachedUser; 12 | export const setCachedUser = (user?: User | null) => { 13 | if (user) localStorage.setItem(USER_STORAGE, JSON.stringify(user)); 14 | else localStorage.removeItem(USER_STORAGE); 15 | }; 16 | 17 | export const isPrivateUser = ( 18 | user: PublicUser | PrivateUser, 19 | ): user is PrivateUser => !!(user as PrivateUser).email; 20 | 21 | export const isAdmin = (user?: User) => 22 | (user?.roles ?? []).includes(RoleEnum.ADMIN); 23 | 24 | export const canEdit = (user?: User) => 25 | (user?.roles ?? []).includes(RoleEnum.EDIT) || isAdmin(user); 26 | 27 | export const canTagEdit = (user?: User) => 28 | (user?.roles ?? []).includes(RoleEnum.EDIT_TAGS) || isAdmin(user); 29 | 30 | export const canVote = (user?: User) => 31 | (user?.roles ?? []).includes(RoleEnum.VOTE) || isAdmin(user); 32 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "allowSyntheticDefaultImports": true, 6 | "esModuleInterop": true, 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ESNext", 11 | "jsx": "react-jsx", 12 | "baseUrl": ".", 13 | "lib": [ 14 | "dom", 15 | "dom.iterable", 16 | "esnext" 17 | ], 18 | "allowJs": true, 19 | "skipLibCheck": true, 20 | "strict": true, 21 | "strictFunctionTypes": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "isolatedModules": true, 24 | "noEmit": true, 25 | "noFallthroughCasesInSwitch": true 26 | }, 27 | "include": [ 28 | "src/**/*" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /frontend/vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, loadEnv } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | import graphqlPlugin from "@rollup/plugin-graphql"; 5 | import analyzePlugin from "rollup-plugin-analyzer"; 6 | 7 | export default defineConfig(({ mode }) => { 8 | const env = { 9 | ...process.env, 10 | ...loadEnv(mode, process.cwd(), ""), 11 | }; 12 | 13 | /** @type {import("vite").UserConfig} */ 14 | const config = { 15 | build: { 16 | outDir: "build", 17 | assetsDir: "assets", 18 | sourcemap: mode === "production", 19 | }, 20 | optimizeDeps: { 21 | entries: "src/index.tsx", 22 | }, 23 | server: { 24 | port: Number(env.PORT) || undefined, 25 | }, 26 | plugins: [ 27 | react(), 28 | tsconfigPaths(), 29 | graphqlPlugin(), 30 | ], 31 | esbuild: { 32 | logOverride: { 'this-is-undefined-in-esm': 'silent' } 33 | } 34 | }; 35 | 36 | if (process.env.analyze) { 37 | config.plugins.push( 38 | analyzePlugin({ summaryOnly: true, limit: 30 }) 39 | ); 40 | } 41 | 42 | return config; 43 | }); 44 | -------------------------------------------------------------------------------- /gqlgen.yml: -------------------------------------------------------------------------------- 1 | # Refer to https://gqlgen.com/config/ for detailed .gqlgen.yml documentation. 2 | 3 | schema: 4 | - "graphql/schema/types/*.graphql" 5 | - "graphql/schema/*.graphql" 6 | exec: 7 | filename: pkg/models/generated_exec.go 8 | model: 9 | filename: pkg/models/generated_models.go 10 | 11 | struct_tag: gqlgen 12 | 13 | autobind: 14 | - "github.com/stashapp/stash-box/pkg/models" 15 | 16 | models: 17 | ID: 18 | model: github.com/stashapp/stash-box/pkg/models.ID 19 | #model: github.com/gofrs/uuid.UUID 20 | Image: 21 | model: github.com/stashapp/stash-box/pkg/models.Image 22 | fields: 23 | url: 24 | resolver: true 25 | QueryPerformersResultType: 26 | model: github.com/stashapp/stash-box/pkg/models.PerformerQuery 27 | QueryEditsResultType: 28 | model: github.com/stashapp/stash-box/pkg/models.EditQuery 29 | QueryScenesResultType: 30 | model: github.com/stashapp/stash-box/pkg/models.SceneQuery 31 | -------------------------------------------------------------------------------- /graphql/schema/types/config.graphql: -------------------------------------------------------------------------------- 1 | type StashBoxConfig { 2 | host_url: String! 3 | require_invite: Boolean! 4 | require_activation: Boolean! 5 | vote_promotion_threshold: Int 6 | vote_application_threshold: Int! 7 | voting_period: Int! 8 | min_destructive_voting_period: Int! 9 | vote_cron_interval: String! 10 | guidelines_url: String! 11 | require_scene_draft: Boolean! 12 | edit_update_limit: Int! 13 | require_tag_role: Boolean! 14 | } 15 | -------------------------------------------------------------------------------- /graphql/schema/types/draft.graphql: -------------------------------------------------------------------------------- 1 | type DraftSubmissionStatus { 2 | id: ID 3 | } 4 | 5 | type DraftEntity { 6 | name: String! 7 | id: ID 8 | } 9 | 10 | input DraftEntityInput { 11 | name: String! 12 | id: ID 13 | } 14 | 15 | type Draft { 16 | id: ID! 17 | created: Time! 18 | expires: Time! 19 | data: DraftData! 20 | } 21 | 22 | union DraftData = SceneDraft | PerformerDraft 23 | -------------------------------------------------------------------------------- /graphql/schema/types/filter.graphql: -------------------------------------------------------------------------------- 1 | input MultiIDCriterionInput { 2 | value: [ID!] 3 | modifier: CriterionModifier! 4 | } 5 | 6 | input IDCriterionInput { 7 | value: [ID!]! 8 | modifier: CriterionModifier! 9 | } 10 | 11 | input StringCriterionInput { 12 | value: String! 13 | modifier: CriterionModifier! 14 | } 15 | 16 | input MultiStringCriterionInput { 17 | value: [String!]! 18 | modifier: CriterionModifier! 19 | } 20 | 21 | input IntCriterionInput { 22 | value: Int! 23 | modifier: CriterionModifier! 24 | } 25 | 26 | input DateCriterionInput { 27 | value: Date! 28 | modifier: CriterionModifier! 29 | } 30 | 31 | enum CriterionModifier { 32 | """=""" 33 | EQUALS, 34 | """!=""" 35 | NOT_EQUALS, 36 | """>""" 37 | GREATER_THAN, 38 | """<""" 39 | LESS_THAN, 40 | """IS NULL""" 41 | IS_NULL, 42 | """IS NOT NULL""" 43 | NOT_NULL, 44 | """INCLUDES ALL""" 45 | INCLUDES_ALL, 46 | INCLUDES, 47 | EXCLUDES, 48 | } -------------------------------------------------------------------------------- /graphql/schema/types/image.graphql: -------------------------------------------------------------------------------- 1 | scalar Upload 2 | 3 | type Image { 4 | id: ID! 5 | url: String! 6 | width: Int! 7 | height: Int! 8 | } 9 | 10 | input ImageCreateInput { 11 | url: String 12 | file: Upload 13 | } 14 | 15 | input ImageUpdateInput { 16 | id: ID! 17 | url: String 18 | } 19 | 20 | input ImageDestroyInput { 21 | id: ID! 22 | } 23 | -------------------------------------------------------------------------------- /graphql/schema/types/misc.graphql: -------------------------------------------------------------------------------- 1 | scalar Date 2 | scalar DateTime 3 | scalar Time 4 | 5 | enum DateAccuracyEnum { 6 | YEAR 7 | MONTH 8 | DAY 9 | } 10 | 11 | type FuzzyDate { 12 | date: Date! 13 | accuracy: DateAccuracyEnum! 14 | } 15 | 16 | enum SortDirectionEnum { 17 | ASC 18 | DESC 19 | } 20 | 21 | type URL { 22 | url: String! 23 | type: String! @deprecated(reason: "Use the site field instead") 24 | site: Site! 25 | } 26 | 27 | input URLInput { 28 | url: String! 29 | site_id: ID! 30 | } 31 | -------------------------------------------------------------------------------- /graphql/schema/types/site.graphql: -------------------------------------------------------------------------------- 1 | type Site { 2 | id: ID! 3 | name: String! 4 | description: String 5 | url: String 6 | regex: String 7 | valid_types: [ValidSiteTypeEnum!]! 8 | icon: String! 9 | created: Time! 10 | updated: Time! 11 | } 12 | 13 | input SiteCreateInput { 14 | name: String! 15 | description: String 16 | url: String 17 | regex: String 18 | valid_types: [ValidSiteTypeEnum!]! 19 | } 20 | 21 | input SiteUpdateInput { 22 | id: ID! 23 | name: String! 24 | description: String 25 | url: String 26 | regex: String 27 | valid_types: [ValidSiteTypeEnum!]! 28 | } 29 | 30 | input SiteDestroyInput { 31 | id: ID! 32 | } 33 | 34 | type QuerySitesResultType { 35 | count: Int! 36 | sites: [Site!]! 37 | } 38 | 39 | enum ValidSiteTypeEnum { 40 | PERFORMER 41 | SCENE 42 | STUDIO 43 | } 44 | -------------------------------------------------------------------------------- /graphql/schema/types/version.graphql: -------------------------------------------------------------------------------- 1 | type Version { 2 | hash: String! 3 | build_time: String! 4 | build_type: String! 5 | version: String! 6 | } 7 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | //go:generate go run github.com/99designs/gqlgen 2 | package main 3 | 4 | import ( 5 | "context" 6 | "embed" 7 | 8 | "github.com/stashapp/stash-box/pkg/api" 9 | "github.com/stashapp/stash-box/pkg/database" 10 | "github.com/stashapp/stash-box/pkg/image" 11 | "github.com/stashapp/stash-box/pkg/logger" 12 | "github.com/stashapp/stash-box/pkg/manager" 13 | "github.com/stashapp/stash-box/pkg/manager/config" 14 | "github.com/stashapp/stash-box/pkg/manager/cron" 15 | "github.com/stashapp/stash-box/pkg/manager/notifications" 16 | "github.com/stashapp/stash-box/pkg/sqlx" 17 | "github.com/stashapp/stash-box/pkg/user" 18 | ) 19 | 20 | //go:embed frontend/build 21 | var ui embed.FS 22 | 23 | func main() { 24 | manager.Initialize() 25 | 26 | cleanup := logger.InitTracer() 27 | //nolint:errcheck 28 | defer cleanup(context.Background()) 29 | 30 | api.InitializeSession() 31 | 32 | const databaseProvider = "postgres" 33 | db := database.Initialize(databaseProvider, config.GetDatabasePath()) 34 | txnMgr := sqlx.NewTxnMgr(db) 35 | user.CreateSystemUsers(txnMgr.Repo(context.Background())) 36 | api.Start(txnMgr, ui) 37 | cron.Init(txnMgr) 38 | notifications.Init(txnMgr) 39 | 40 | image.InitResizer() 41 | 42 | blockForever() 43 | } 44 | 45 | func blockForever() { 46 | c := make(chan struct{}) 47 | <-c 48 | } 49 | -------------------------------------------------------------------------------- /pkg/api/authorization.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gofrs/uuid" 7 | 8 | "github.com/stashapp/stash-box/pkg/models" 9 | "github.com/stashapp/stash-box/pkg/user" 10 | ) 11 | 12 | func getCurrentUser(ctx context.Context) *models.User { 13 | return user.GetCurrentUser(ctx) 14 | } 15 | 16 | func validateVote(ctx context.Context) error { 17 | return user.ValidateRole(ctx, models.RoleEnumVote) 18 | } 19 | 20 | func validateInvite(ctx context.Context) error { 21 | return user.ValidateRole(ctx, models.RoleEnumInvite) 22 | } 23 | 24 | func validateManageInvites(ctx context.Context) error { 25 | return user.ValidateRole(ctx, models.RoleEnumManageInvites) 26 | } 27 | 28 | func validateAdmin(ctx context.Context) error { 29 | return user.ValidateRole(ctx, models.RoleEnumAdmin) 30 | } 31 | 32 | func validateBot(ctx context.Context) error { 33 | return user.ValidateRole(ctx, models.RoleEnumBot) 34 | } 35 | 36 | func validateUser(ctx context.Context, userID uuid.UUID) error { 37 | return user.ValidateOwner(ctx, userID) 38 | } 39 | 40 | func validateUserOrAdmin(ctx context.Context, userID uuid.UUID) error { 41 | if err := user.ValidateOwner(ctx, userID); err == nil { 42 | return nil 43 | } 44 | return user.ValidateRole(ctx, models.RoleEnumAdmin) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/api/context_keys.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | // https://stackoverflow.com/questions/40891345/fix-should-not-use-basic-type-string-as-key-in-context-withvalue-golint 4 | 5 | type key int 6 | 7 | const ( 8 | ContextRepo key = iota 9 | ) 10 | -------------------------------------------------------------------------------- /pkg/api/directives.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/99designs/gqlgen/graphql" 7 | "github.com/stashapp/stash-box/pkg/models" 8 | "github.com/stashapp/stash-box/pkg/user" 9 | ) 10 | 11 | func IsUserOwnerDirective(ctx context.Context, obj interface{}, next graphql.Resolver) (interface{}, error) { 12 | if err := validateUserOrAdmin(ctx, obj.(*models.User).ID); err != nil { 13 | return nil, err 14 | } 15 | 16 | return next(ctx) 17 | } 18 | 19 | func HasRoleDirective(ctx context.Context, obj interface{}, next graphql.Resolver, role models.RoleEnum) (interface{}, error) { 20 | if err := user.ValidateRole(ctx, role); err != nil { 21 | return nil, err 22 | } 23 | 24 | return next(ctx) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/api/factory.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/stashapp/stash-box/pkg/models" 8 | ) 9 | 10 | type RepoProvider interface { 11 | // IMPORTANT: the returned Repo object MUST NOT be shared between goroutines. 12 | // that is: call Repo for each new request/goroutine 13 | Repo(ctx context.Context) models.Repo 14 | } 15 | 16 | // creates a new Repo (with its own transaction boundary) for each incoming request 17 | func repoMiddleware(provider RepoProvider) func(http.Handler) http.Handler { 18 | return func(next http.Handler) http.Handler { 19 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 20 | ctx := r.Context() 21 | r = r.WithContext(context.WithValue(ctx, ContextRepo, provider.Repo(ctx))) 22 | 23 | next.ServeHTTP(w, r) 24 | }) 25 | } 26 | } 27 | 28 | func getRepo(ctx context.Context) models.Repo { 29 | return ctx.Value(ContextRepo).(models.Repo) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/api/resolver_model_draft.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/stashapp/stash-box/pkg/manager/config" 9 | "github.com/stashapp/stash-box/pkg/models" 10 | ) 11 | 12 | type draftResolver struct{ *Resolver } 13 | 14 | func (r *draftResolver) Created(ctx context.Context, obj *models.Draft) (*time.Time, error) { 15 | return &obj.CreatedAt, nil 16 | } 17 | 18 | func (r *draftResolver) Expires(ctx context.Context, obj *models.Draft) (*time.Time, error) { 19 | duration := time.Second * time.Duration(config.GetDraftTimeLimit()) 20 | expiration := obj.CreatedAt.Add(duration) 21 | return &expiration, nil 22 | } 23 | 24 | func (r *draftResolver) Data(ctx context.Context, obj *models.Draft) (models.DraftData, error) { 25 | switch obj.Type { 26 | case "SCENE": 27 | return obj.GetSceneData() 28 | case "PERFORMER": 29 | return obj.GetPerformerData() 30 | default: 31 | return nil, fmt.Errorf("Unsupported type: %s", obj.Type) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/api/resolver_model_edit_comment.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/stashapp/stash-box/pkg/models" 8 | ) 9 | 10 | type editCommentResolver struct{ *Resolver } 11 | 12 | func (r *editCommentResolver) ID(ctx context.Context, obj *models.EditComment) (string, error) { 13 | return obj.ID.String(), nil 14 | } 15 | 16 | func (r *editCommentResolver) Comment(ctx context.Context, obj *models.EditComment) (string, error) { 17 | return obj.Text, nil 18 | } 19 | 20 | func (r *editCommentResolver) Date(ctx context.Context, obj *models.EditComment) (*time.Time, error) { 21 | return &obj.CreatedAt, nil 22 | } 23 | 24 | func (r *editCommentResolver) User(ctx context.Context, obj *models.EditComment) (*models.User, error) { 25 | fac := r.getRepoFactory(ctx) 26 | qb := fac.User() 27 | 28 | if obj.UserID.UUID.IsNil() { 29 | return nil, nil 30 | } 31 | 32 | user, err := qb.Find(obj.UserID.UUID) 33 | 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return user, nil 39 | } 40 | 41 | func (r *editCommentResolver) Edit(ctx context.Context, obj *models.EditComment) (*models.Edit, error) { 42 | fac := r.getRepoFactory(ctx) 43 | qb := fac.Edit() 44 | 45 | return qb.Find(obj.EditID) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/api/resolver_model_edit_vote.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/stashapp/stash-box/pkg/models" 8 | "github.com/stashapp/stash-box/pkg/utils" 9 | ) 10 | 11 | type editVoteResolver struct{ *Resolver } 12 | 13 | func (r *editVoteResolver) Vote(ctx context.Context, obj *models.EditVote) (models.VoteTypeEnum, error) { 14 | var ret models.VoteTypeEnum 15 | if !utils.ResolveEnumString(obj.Vote, &ret) { 16 | return "", nil 17 | } 18 | return ret, nil 19 | } 20 | 21 | func (r *editVoteResolver) Date(ctx context.Context, obj *models.EditVote) (*time.Time, error) { 22 | return &obj.CreatedAt, nil 23 | } 24 | 25 | func (r *editVoteResolver) User(ctx context.Context, obj *models.EditVote) (*models.User, error) { 26 | // User votes only available to users with vote permission 27 | if err := validateVote(ctx); err != nil { 28 | return nil, nil 29 | } 30 | 31 | fac := r.getRepoFactory(ctx) 32 | qb := fac.User() 33 | 34 | if obj.UserID.UUID.IsNil() { 35 | return nil, nil 36 | } 37 | 38 | user, err := qb.Find(obj.UserID.UUID) 39 | 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | return user, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/api/resolver_model_image.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gofrs/uuid" 7 | "github.com/stashapp/stash-box/pkg/dataloader" 8 | "github.com/stashapp/stash-box/pkg/models" 9 | ) 10 | 11 | type imageResolver struct{ *Resolver } 12 | 13 | func (r *imageResolver) ID(ctx context.Context, obj *models.Image) (string, error) { 14 | return obj.ID.String(), nil 15 | } 16 | func (r *imageResolver) URL(ctx context.Context, obj *models.Image) (string, error) { 17 | baseURL := ctx.Value(BaseURLCtxKey).(string) 18 | id := obj.ID.String() 19 | return baseURL + "/images/" + id, nil 20 | } 21 | 22 | func imageList(ctx context.Context, imageIDs []uuid.UUID) ([]*models.Image, error) { 23 | if len(imageIDs) == 0 { 24 | return nil, nil 25 | } 26 | 27 | images, errors := dataloader.For(ctx).ImageByID.LoadAll(imageIDs) 28 | for _, err := range errors { 29 | if err != nil { 30 | return nil, err 31 | } 32 | } 33 | return images, nil 34 | } 35 | -------------------------------------------------------------------------------- /pkg/api/resolver_model_performer_draft.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stashapp/stash-box/pkg/models" 7 | ) 8 | 9 | type performerDraftResolver struct{ *Resolver } 10 | 11 | func (r *performerDraftResolver) ID(ctx context.Context, obj *models.PerformerDraft) (*string, error) { 12 | if obj.ID != nil { 13 | val := obj.ID.String() 14 | return &val, nil 15 | } 16 | return nil, nil 17 | } 18 | 19 | func (r *performerDraftResolver) Image(ctx context.Context, obj *models.PerformerDraft) (*models.Image, error) { 20 | if obj.Image == nil { 21 | return nil, nil 22 | } 23 | 24 | fac := r.getRepoFactory(ctx) 25 | qb := fac.Image() 26 | return qb.Find(*obj.Image) 27 | } 28 | -------------------------------------------------------------------------------- /pkg/api/resolver_model_tag_category.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stashapp/stash-box/pkg/models" 7 | "github.com/stashapp/stash-box/pkg/utils" 8 | ) 9 | 10 | type tagCategoryResolver struct{ *Resolver } 11 | 12 | func (r *tagCategoryResolver) ID(ctx context.Context, obj *models.TagCategory) (string, error) { 13 | return obj.ID.String(), nil 14 | } 15 | func (r *tagCategoryResolver) Name(ctx context.Context, obj *models.TagCategory) (string, error) { 16 | return obj.Name, nil 17 | } 18 | func (r *tagCategoryResolver) Description(ctx context.Context, obj *models.TagCategory) (*string, error) { 19 | return resolveNullString(obj.Description), nil 20 | } 21 | func (r *tagCategoryResolver) Group(ctx context.Context, obj *models.TagCategory) (models.TagGroupEnum, error) { 22 | var ret models.TagGroupEnum 23 | if !utils.ResolveEnumString(obj.Group, &ret) { 24 | return "", nil 25 | } 26 | 27 | return ret, nil 28 | } 29 | -------------------------------------------------------------------------------- /pkg/api/resolver_model_tag_edit.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/stashapp/stash-box/pkg/models" 8 | "github.com/stashapp/stash-box/pkg/sqlx" 9 | ) 10 | 11 | type tagEditResolver struct{ *Resolver } 12 | 13 | func (r *tagEditResolver) Category(ctx context.Context, obj *models.TagEdit) (*models.TagCategory, error) { 14 | if obj.CategoryID == nil { 15 | return nil, nil 16 | } 17 | 18 | qb := r.getRepoFactory(ctx).TagCategory() 19 | return qb.Find(*obj.CategoryID) 20 | } 21 | 22 | func (r *tagEditResolver) Aliases(ctx context.Context, obj *models.TagEdit) ([]string, error) { 23 | fac := r.getRepoFactory(ctx) 24 | id, err := fac.Edit().FindTagID(obj.EditID) 25 | if err != nil && !errors.Is(err, sqlx.ErrEditTargetIDNotFound) { 26 | return nil, err 27 | } 28 | 29 | return fac.Tag().GetEditAliases(id, obj) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/api/resolver_model_url.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/stashapp/stash-box/pkg/dataloader" 8 | "github.com/stashapp/stash-box/pkg/models" 9 | ) 10 | 11 | type urlResolver struct{ *Resolver } 12 | 13 | func (r *urlResolver) URL(ctx context.Context, obj *models.URL) (string, error) { 14 | return obj.URL, nil 15 | } 16 | 17 | func (r *urlResolver) Site(ctx context.Context, obj *models.URL) (*models.Site, error) { 18 | return dataloader.For(ctx).SiteByID.Load(obj.SiteID) 19 | } 20 | 21 | func (r *urlResolver) Type(ctx context.Context, obj *models.URL) (string, error) { 22 | site, err := dataloader.For(ctx).SiteByID.Load(obj.SiteID) 23 | if err != nil { 24 | return "", err 25 | } 26 | return strings.ToUpper(site.Name), err 27 | } 28 | -------------------------------------------------------------------------------- /pkg/api/resolver_mutation_image.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stashapp/stash-box/pkg/image" 7 | "github.com/stashapp/stash-box/pkg/models" 8 | ) 9 | 10 | func (r *mutationResolver) ImageCreate(ctx context.Context, input models.ImageCreateInput) (*models.Image, error) { 11 | fac := r.getRepoFactory(ctx) 12 | 13 | var ret *models.Image 14 | err := fac.WithTxn(func() error { 15 | qb := fac.Image() 16 | imageService := image.GetService(qb) 17 | var txnErr error 18 | ret, txnErr = imageService.Create(input) 19 | 20 | return txnErr 21 | }) 22 | 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return ret, nil 28 | } 29 | 30 | func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageDestroyInput) (bool, error) { 31 | fac := r.getRepoFactory(ctx) 32 | 33 | err := fac.WithTxn(func() error { 34 | qb := fac.Image() 35 | imageService := image.GetService(qb) 36 | return imageService.Destroy(input) 37 | }) 38 | 39 | if err != nil { 40 | return false, err 41 | } 42 | 43 | return true, nil 44 | } 45 | -------------------------------------------------------------------------------- /pkg/api/resolver_mutation_notifications.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stashapp/stash-box/pkg/models" 7 | ) 8 | 9 | func (r *mutationResolver) MarkNotificationsRead(ctx context.Context, notification *models.MarkNotificationReadInput) (bool, error) { 10 | user := getCurrentUser(ctx) 11 | fac := r.getRepoFactory(ctx) 12 | err := fac.WithTxn(func() error { 13 | qb := fac.Notification() 14 | 15 | if notification == nil { 16 | return qb.MarkAllRead(user.ID) 17 | } 18 | 19 | return qb.MarkRead(user.ID, notification.Type, notification.ID) 20 | }) 21 | return err == nil, err 22 | } 23 | 24 | func (r *mutationResolver) UpdateNotificationSubscriptions(ctx context.Context, subscriptions []models.NotificationEnum) (bool, error) { 25 | user := getCurrentUser(ctx) 26 | fac := r.getRepoFactory(ctx) 27 | err := fac.WithTxn(func() error { 28 | qb := fac.Joins() 29 | var userNotifications []*models.UserNotification 30 | for _, s := range subscriptions { 31 | userNotification := models.UserNotification{UserID: user.ID, Type: s} 32 | userNotifications = append(userNotifications, &userNotification) 33 | } 34 | return qb.UpdateUserNotifications(user.ID, userNotifications) 35 | }) 36 | 37 | return err == nil, err 38 | } 39 | -------------------------------------------------------------------------------- /pkg/api/resolver_query_find_draft.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/gofrs/uuid" 8 | "github.com/stashapp/stash-box/pkg/models" 9 | ) 10 | 11 | func (r *queryResolver) FindDrafts(ctx context.Context) ([]*models.Draft, error) { 12 | fac := r.getRepoFactory(ctx) 13 | 14 | user := getCurrentUser(ctx) 15 | return fac.Draft().FindByUser(user.ID) 16 | } 17 | 18 | func (r *queryResolver) FindDraft(ctx context.Context, id uuid.UUID) (*models.Draft, error) { 19 | fac := r.getRepoFactory(ctx) 20 | 21 | user := getCurrentUser(ctx) 22 | draft, err := fac.Draft().Find(id) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | if draft == nil || user.ID != draft.UserID { 28 | return nil, fmt.Errorf("draft not found: %s", id) 29 | } 30 | 31 | return draft, nil 32 | } 33 | -------------------------------------------------------------------------------- /pkg/api/resolver_query_find_edit.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gofrs/uuid" 7 | "github.com/stashapp/stash-box/pkg/models" 8 | "github.com/stashapp/stash-box/pkg/user" 9 | ) 10 | 11 | func (r *queryResolver) FindEdit(ctx context.Context, id uuid.UUID) (*models.Edit, error) { 12 | fac := r.getRepoFactory(ctx) 13 | qb := fac.Edit() 14 | 15 | return qb.Find(id) 16 | } 17 | 18 | func (r *queryResolver) QueryEdits(ctx context.Context, input models.EditQueryInput) (*models.EditQuery, error) { 19 | return &models.EditQuery{ 20 | Filter: input, 21 | }, nil 22 | } 23 | 24 | type queryEditResolver struct{ *Resolver } 25 | 26 | func (r *queryEditResolver) Count(ctx context.Context, obj *models.EditQuery) (int, error) { 27 | fac := r.getRepoFactory(ctx) 28 | qb := fac.Edit() 29 | u := user.GetCurrentUser(ctx) 30 | return qb.QueryCount(obj.Filter, u.ID) 31 | } 32 | 33 | func (r *queryEditResolver) Edits(ctx context.Context, obj *models.EditQuery) ([]*models.Edit, error) { 34 | fac := r.getRepoFactory(ctx) 35 | qb := fac.Edit() 36 | u := user.GetCurrentUser(ctx) 37 | return qb.QueryEdits(obj.Filter, u.ID) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/api/resolver_query_find_notifications.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stashapp/stash-box/pkg/models" 7 | ) 8 | 9 | func (r *queryResolver) QueryNotifications(ctx context.Context, input models.QueryNotificationsInput) (*models.QueryNotificationsResult, error) { 10 | return &models.QueryNotificationsResult{ 11 | Input: input, 12 | }, nil 13 | } 14 | 15 | type queryNotificationsResolver struct{ *Resolver } 16 | 17 | func (r *queryNotificationsResolver) Count(ctx context.Context, query *models.QueryNotificationsResult) (int, error) { 18 | fac := r.getRepoFactory(ctx) 19 | qb := fac.Notification() 20 | currentUser := getCurrentUser(ctx) 21 | 22 | return qb.GetNotificationsCount(currentUser.ID, query.Input) 23 | } 24 | 25 | func (r *queryNotificationsResolver) Notifications(ctx context.Context, query *models.QueryNotificationsResult) ([]*models.Notification, error) { 26 | fac := r.getRepoFactory(ctx) 27 | qb := fac.Notification() 28 | currentUser := getCurrentUser(ctx) 29 | return qb.GetNotifications(currentUser.ID, query.Input) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/api/resolver_query_find_site.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gofrs/uuid" 7 | 8 | "github.com/stashapp/stash-box/pkg/models" 9 | ) 10 | 11 | func (r *queryResolver) FindSite(ctx context.Context, id uuid.UUID) (*models.Site, error) { 12 | fac := r.getRepoFactory(ctx) 13 | qb := fac.Site() 14 | 15 | return qb.Find(id) 16 | } 17 | 18 | func (r *queryResolver) QuerySites(ctx context.Context) (*models.QuerySitesResultType, error) { 19 | fac := r.getRepoFactory(ctx) 20 | qb := fac.Site() 21 | 22 | sites, count, err := qb.Query() 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return &models.QuerySitesResultType{ 28 | Sites: sites, 29 | Count: count, 30 | }, nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/api/resolver_query_find_studio.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gofrs/uuid" 7 | 8 | "github.com/stashapp/stash-box/pkg/models" 9 | "github.com/stashapp/stash-box/pkg/user" 10 | ) 11 | 12 | func (r *queryResolver) FindStudio(ctx context.Context, id *uuid.UUID, name *string) (*models.Studio, error) { 13 | fac := r.getRepoFactory(ctx) 14 | qb := fac.Studio() 15 | 16 | if id != nil { 17 | return qb.Find(*id) 18 | } else if name != nil { 19 | return qb.FindByName(*name) 20 | } 21 | 22 | return nil, nil 23 | } 24 | 25 | func (r *queryResolver) QueryStudios(ctx context.Context, input models.StudioQueryInput) (*models.QueryStudiosResultType, error) { 26 | fac := r.getRepoFactory(ctx) 27 | qb := fac.Studio() 28 | user := user.GetCurrentUser(ctx) 29 | 30 | studios, count, err := qb.Query(input, user.ID) 31 | return &models.QueryStudiosResultType{ 32 | Studios: studios, 33 | Count: count, 34 | }, err 35 | } 36 | -------------------------------------------------------------------------------- /pkg/api/resolver_query_find_tag_category.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gofrs/uuid" 7 | 8 | "github.com/stashapp/stash-box/pkg/models" 9 | ) 10 | 11 | func (r *queryResolver) FindTagCategory(ctx context.Context, id uuid.UUID) (*models.TagCategory, error) { 12 | fac := r.getRepoFactory(ctx) 13 | qb := fac.TagCategory() 14 | 15 | return qb.Find(id) 16 | } 17 | 18 | func (r *queryResolver) QueryTagCategories(ctx context.Context) (*models.QueryTagCategoriesResultType, error) { 19 | fac := r.getRepoFactory(ctx) 20 | qb := fac.TagCategory() 21 | 22 | categories, count, err := qb.Query() 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | return &models.QueryTagCategoriesResultType{ 28 | TagCategories: categories, 29 | Count: count, 30 | }, nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/api/resolver_query_find_user.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gofrs/uuid" 7 | 8 | "github.com/stashapp/stash-box/pkg/models" 9 | "github.com/stashapp/stash-box/pkg/user" 10 | ) 11 | 12 | func (r *queryResolver) FindUser(ctx context.Context, id *uuid.UUID, username *string) (*models.User, error) { 13 | fac := r.getRepoFactory(ctx) 14 | qb := fac.User() 15 | 16 | var ret *models.User 17 | var err error 18 | if id != nil { 19 | ret, err = qb.Find(*id) 20 | } else if username != nil { 21 | ret, err = qb.FindByName(*username) 22 | } 23 | 24 | return ret, err 25 | } 26 | 27 | func (r *queryResolver) QueryUsers(ctx context.Context, input models.UserQueryInput) (*models.QueryUsersResultType, error) { 28 | fac := r.getRepoFactory(ctx) 29 | qb := fac.User() 30 | 31 | users, count, err := qb.Query(input) 32 | return &models.QueryUsersResultType{ 33 | Users: users, 34 | Count: count, 35 | }, err 36 | } 37 | 38 | func (r *queryResolver) Me(ctx context.Context) (*models.User, error) { 39 | currentUser := getCurrentUser(ctx) 40 | if currentUser == nil { 41 | return nil, user.ErrUnauthorized 42 | } 43 | 44 | return currentUser, nil 45 | } 46 | -------------------------------------------------------------------------------- /pkg/api/resolver_query_notifications.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/stashapp/stash-box/pkg/models" 7 | ) 8 | 9 | func (r *queryResolver) GetUnreadNotificationCount(ctx context.Context) (int, error) { 10 | fac := r.getRepoFactory(ctx) 11 | qb := fac.Notification() 12 | currentUser := getCurrentUser(ctx) 13 | unread := true 14 | return qb.GetNotificationsCount(currentUser.ID, models.QueryNotificationsInput{UnreadOnly: &unread}) 15 | } 16 | -------------------------------------------------------------------------------- /pkg/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/jmoiron/sqlx" 5 | ) 6 | 7 | var appSchemaVersion uint = 49 8 | 9 | var databaseProviders map[string]databaseProvider 10 | 11 | type databaseProvider interface { 12 | Open(path string) *sqlx.DB 13 | } 14 | 15 | func Initialize(provider string, databasePath string) *sqlx.DB { 16 | p := databaseProviders[provider] 17 | 18 | if p == nil { 19 | panic("No database provider found for " + provider) 20 | } 21 | 22 | db := p.Open(databasePath) 23 | return db 24 | } 25 | 26 | func registerProvider(name string, provider databaseProvider) { 27 | if databaseProviders == nil { 28 | databaseProviders = make(map[string]databaseProvider) 29 | } 30 | databaseProviders[name] = provider 31 | } 32 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/10_tag_categories.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE tag_categories ( 2 | "id" uuid not null primary key, 3 | "group" text not null, 4 | "name" text not null, 5 | "description" text, 6 | "created_at" timestamp not null, 7 | "updated_at" timestamp not null, 8 | unique ("name") 9 | ); 10 | 11 | ALTER TABLE tags 12 | ADD COLUMN "category_id" uuid, 13 | ADD FOREIGN KEY ("category_id") REFERENCES "tag_categories"("id"); 14 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/11_image_constraints.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "scene_images" 2 | DROP CONSTRAINT "scene_images_image_id_fkey", 3 | DROP CONSTRAINT "scene_images_scene_id_fkey", 4 | ADD CONSTRAINT "scene_images_image_id_fkey" 5 | FOREIGN KEY ("image_id") REFERENCES "images"("id") ON DELETE CASCADE, 6 | ADD CONSTRAINT "scene_images_scene_id_fkey" 7 | FOREIGN KEY ("scene_id") REFERENCES "scenes"("id") ON DELETE CASCADE; 8 | 9 | ALTER TABLE "performer_images" 10 | DROP CONSTRAINT "performer_images_image_id_fkey", 11 | DROP CONSTRAINT "performer_images_performer_id_fkey", 12 | ADD CONSTRAINT "performer_images_image_id_fkey" 13 | FOREIGN KEY ("image_id") REFERENCES "images"("id") ON DELETE CASCADE, 14 | ADD CONSTRAINT "performer_images_performer_id_fkey" 15 | FOREIGN KEY ("performer_id") REFERENCES "performers"("id") ON DELETE CASCADE; 16 | 17 | ALTER TABLE "studio_images" 18 | DROP CONSTRAINT "studio_images_image_id_fkey", 19 | DROP CONSTRAINT "studio_images_studio_id_fkey", 20 | ADD CONSTRAINT "studio_images_image_id_fkey" 21 | FOREIGN KEY ("image_id") REFERENCES "images"("id") ON DELETE CASCADE, 22 | ADD CONSTRAINT "studio_images_studio_id_fkey" 23 | FOREIGN KEY ("studio_id") REFERENCES "studios"("id") ON DELETE CASCADE; 24 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/13_sort_indexes.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX "scene_search_scene_id_idx" ON "scene_search" ("scene_id"); 2 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/14_phash_distance_search.up.sql: -------------------------------------------------------------------------------- 1 | -- Enable bktree extension if available and user is superuser 2 | DO $$ 3 | DECLARE 4 | extension pg_available_extensions%rowtype; 5 | BEGIN 6 | 7 | SELECT * 8 | INTO extension 9 | FROM pg_available_extensions 10 | WHERE name='bktree'; 11 | 12 | IF found and current_setting('is_superuser') = 'on' THEN 13 | CREATE EXTENSION IF NOT EXISTS bktree; 14 | END IF; 15 | 16 | END$$; 17 | 18 | -- Create phash index if bktree is available 19 | DO $$ 20 | DECLARE 21 | extension pg_extension%rowtype; 22 | BEGIN 23 | 24 | SELECT * 25 | INTO extension 26 | FROM pg_extension 27 | WHERE extname='bktree'; 28 | 29 | IF found THEN 30 | CREATE INDEX scene_fingerprints_phash_index 31 | ON scene_fingerprints 32 | USING spgist ((('x' || hash)::bit(64)::bigint) bktree_ops) 33 | WHERE algorithm = 'PHASH'; 34 | END IF; 35 | 36 | END$$; 37 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/15_scene_fingerprint_submissions.up.sql: -------------------------------------------------------------------------------- 1 | -- Unused index 2 | DROP INDEX "index_scene_fingerprints_on_hash"; 3 | 4 | ALTER TABLE "scene_fingerprints" ADD CONSTRAINT "scene_hash_unique" UNIQUE ("scene_id", "hash"); 5 | ALTER TABLE "scene_fingerprints" DROP CONSTRAINT "scene_fingerprints_scene_id_algorithm_hash_key"; 6 | 7 | ALTER TABLE "scene_fingerprints" ADD COLUMN "submissions" INTEGER NOT NULL DEFAULT 1; 8 | ALTER TABLE "scene_fingerprints" ADD COLUMN "created_at" TIMESTAMP NOT NULL DEFAULT NOW(); 9 | ALTER TABLE "scene_fingerprints" ADD COLUMN "updated_at" TIMESTAMP NOT NULL DEFAULT NOW(); 10 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/19_scene_created_index.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX scenes_created_idx ON scenes(created_at DESC NULLS LAST); 2 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/1_initial.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS "scene_fingerprints"; 2 | DROP TABLE IF EXISTS "scene_performers"; 3 | DROP TABLE IF EXISTS "scene_tags"; 4 | DROP TABLE IF EXISTS "studio_urls"; 5 | DROP TABLE IF EXISTS "tag_aliases"; 6 | DROP TABLE IF EXISTS "performer_aliases"; 7 | DROP TABLE IF EXISTS "performer_urls"; 8 | DROP TABLE IF EXISTS "performer_piercings"; 9 | DROP TABLE IF EXISTS "performer_tattoos"; 10 | DROP TABLE IF EXISTS "scenes"; 11 | DROP TABLE IF EXISTS "scene_urls"; 12 | DROP TABLE IF EXISTS "tags"; 13 | DROP TABLE IF EXISTS "performers"; 14 | DROP TABLE IF EXISTS "studios"; 15 | 16 | 17 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/22_performer_search_indexes.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX disambiguation_trgm_idx ON "performers" USING GIN ("disambiguation" gin_trgm_ops); 2 | CREATE INDEX performer_alias_trgm_idx ON "performer_aliases" USING GIN ("alias" gin_trgm_ops); 3 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/23_favorites.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE performer_favorites ( 2 | performer_id uuid REFERENCES performers(id) ON DELETE CASCADE NOT NULL, 3 | user_id uuid REFERENCES users(id) ON DELETE CASCADE NOT NULL 4 | ); 5 | 6 | CREATE TABLE studio_favorites ( 7 | studio_id uuid REFERENCES studios(id) ON DELETE CASCADE NOT NULL, 8 | user_id uuid REFERENCES users(id) ON DELETE CASCADE NOT NULL 9 | ); 10 | 11 | CREATE INDEX scene_edit_performers_added_idx ON edits USING GIN 12 | (jsonb_path_query_array(data, '$.new_data.added_performers[*].performer_id')) 13 | WHERE target_type = 'SCENE'; 14 | 15 | CREATE INDEX scene_edit_performers_removed_idx ON edits USING GIN 16 | (jsonb_path_query_array(data, '$.new_data.removed_performers[*].performer_id')) 17 | WHERE target_type = 'SCENE'; 18 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/24_drafts.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "drafts" ( 2 | "id" UUID PRIMARY KEY, 3 | "user_id" UUID NOT NULL, 4 | "type" TEXT CHECK ("type" in ('SCENE', 'PERFORMER', 'STUDIO')) NOT NULL, 5 | "data" JSONB NOT NULL, 6 | "created_at" TIMESTAMP NOT NULL, 7 | FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE 8 | ); 9 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/26_scene_partial_date.down.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "scenes" 2 | DROP COLUMN "date_accuracy"; 3 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/26_scene_partial_date.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "scenes" 2 | ADD COLUMN "date_accuracy" varchar(10); 3 | 4 | UPDATE "scenes" 5 | SET "date_accuracy" = 'DAY'; 6 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/27_edit_closed_at.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "edits" 2 | ADD COLUMN "closed_at" timestamp; 3 | 4 | ALTER TABLE "edits" 5 | ALTER COLUMN "updated_at" DROP NOT NULL; 6 | 7 | UPDATE "edits" 8 | SET "closed_at" = "updated_at" 9 | WHERE "updated_at" > "created_at" 10 | AND "status" != 'PENDING'; 11 | 12 | UPDATE "edits" 13 | SET "updated_at" = NULL; 14 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/28_studio_favorite_index.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX scene_edit_studio_added_idx ON edits 2 | ((data->'old_data'->>'studio_id')) 3 | WHERE target_type = 'SCENE'; 4 | 5 | CREATE INDEX scene_edit_studio_removed_idx ON edits 6 | ((data->'new_data'->>'studio_id')) 7 | WHERE target_type = 'SCENE'; 8 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/29_scene_edit_fingerprint_index.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX scene_edit_fingerprint_added_idx ON edits USING GIN 2 | (jsonb_path_query_array(data, '$.new_data.added_fingerprints[*].hash')) 3 | WHERE target_type = 'SCENE'; 4 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/2_create_search.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE scene_search; 2 | DROP INDEX name_trgm_idx; 3 | 4 | DROP FUNCTION update_performers CASCADE; 5 | 6 | DROP FUNCTION update_scene CASCADE; 7 | 8 | DROP FUNCTION insert_scene CASCADE; 9 | 10 | DROP FUNCTION update_studio CASCADE; 11 | 12 | DROP FUNCTION update_scene_performers CASCADE; 13 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/30_edit_bot.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "edits" 2 | ADD COLUMN "bot" BOOLEAN NOT NULL DEFAULT False; 3 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/31_scenes_deleted_idx.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX "scenes_deleted_idx" ON "scenes"("deleted"); 2 | CREATE INDEX "scenes_id_deleted_idx" ON "scenes"("id", "deleted"); 3 | CREATE INDEX "studio_favorites_idx" ON "studio_favorites"("studio_id", "user_id"); 4 | CREATE INDEX "performer_favorites_idx" ON "performer_favorites"("performer_id", "user_id"); 5 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/32_edit_indexes.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX "edit_votes_user_edit_idx" ON "edit_votes" ("user_id", "edit_id"); 2 | CREATE INDEX "edit_status_idx" ON edits ("status"); 3 | CREATE INDEX "edit_comments_edit_idx" ON "edit_comments" ("edit_id"); 4 | CREATE INDEX "scene_edits_scene_idx" ON "scene_edits" ("scene_id"); 5 | CREATE INDEX "scene_edits_edit_idx" ON "scene_edits" ("edit_id"); 6 | CREATE INDEX "performer_edits_edit_idx" ON "performer_edits" ("edit_id"); 7 | CREATE INDEX "tag_edits_edit_idx" ON "tag_edits" ("edit_id"); 8 | CREATE INDEX "studio_edits_edit_idx" ON "studio_edits" ("edit_id"); 9 | CREATE INDEX "studio_deleted_parent_idx" ON "studios" ("deleted", "parent_studio_id"); 10 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/33_invite_key_uses.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "invite_keys" ADD COLUMN "uses" integer; 2 | ALTER TABLE "invite_keys" ADD COLUMN "expire_time" timestamp; 3 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/36_drop_unique_invite.up.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS "pending_activation_invite_key_idx"; 2 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/37_tokens.up.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE "pending_activations"; 2 | 3 | CREATE TABLE "user_tokens" ( 4 | "id" UUID NOT NULL, 5 | "data" JSONB, 6 | "type" TEXT NOT NULL, 7 | "created_at" TIMESTAMP NOT NULL DEFAULT now(), 8 | "expires_at" TIMESTAMP NOT NULL 9 | ); 10 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/38_scenes_studio_id_index.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX "scenes_studio_id_idx" ON "scenes" ("studio_id"); 2 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/39_edits_updates.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "edits" 2 | ADD COLUMN "update_count" integer NOT NULL DEFAULT 0; 3 | 4 | UPDATE "edits" 5 | SET "update_count" = 1 6 | WHERE "updated_at" IS NOT NULL; 7 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/3_misc.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "scenes" 2 | ADD COLUMN duration integer, 3 | ADD COLUMN director TEXT; 4 | 5 | ALTER TABLE "scene_fingerprints" 6 | ADD COLUMN duration int; 7 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/40_fingerprint_vote.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "scene_fingerprints" 2 | ADD COLUMN "vote" SMALLINT NOT NULL DEFAULT 1 CHECK (vote = -1 OR vote = 1); 3 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/41_notifications.up.sql: -------------------------------------------------------------------------------- 1 | DROP TYPE IF EXISTS notification_type; 2 | CREATE TYPE notification_type AS ENUM ( 3 | 'FAVORITE_PERFORMER_SCENE', 4 | 'FAVORITE_PERFORMER_EDIT', 5 | 'FAVORITE_STUDIO_SCENE', 6 | 'FAVORITE_STUDIO_EDIT', 7 | 'COMMENT_OWN_EDIT', 8 | 'DOWNVOTE_OWN_EDIT', 9 | 'FAILED_OWN_EDIT', 10 | 'COMMENT_COMMENTED_EDIT', 11 | 'COMMENT_VOTED_EDIT', 12 | 'UPDATED_EDIT' 13 | ); 14 | 15 | CREATE TABLE notifications ( 16 | user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL, 17 | type notification_type NOT NULL, 18 | id UUID NOT NULL, 19 | created_at TIMESTAMP NOT NULL DEFAULT NOW(), 20 | read_at TIMESTAMP 21 | ); 22 | CREATE INDEX notifications_user_read_idx ON notifications (user_id, read_at); 23 | 24 | CREATE TABLE user_notifications ( 25 | user_id UUID REFERENCES users(id) ON DELETE CASCADE NOT NULL, 26 | type notification_type NOT NULL 27 | ); 28 | CREATE INDEX user_notifications_user_id_idx ON user_notifications (user_id); 29 | CREATE INDEX user_notifications_type_idx ON user_notifications (type); 30 | 31 | INSERT INTO user_notifications 32 | SELECT id, type FROM unnest(enum_range(NULL::notification_type)) AS type CROSS JOIN users; 33 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/42_date_columns.up.sql: -------------------------------------------------------------------------------- 1 | -- Transform scene date columns to single string column 2 | ALTER TABLE "scenes" RENAME COLUMN "date" TO "date_old"; 3 | ALTER TABLE "scenes" ADD COLUMN "date" TEXT; 4 | 5 | UPDATE "scenes" SET "date" = ( 6 | CASE 7 | WHEN "date_accuracy" = 'DAY' THEN TO_CHAR("date_old", 'YYYY-MM-DD') 8 | WHEN "date_accuracy" = 'MONTH' THEN TO_CHAR("date_old", 'YYYY-MM') 9 | WHEN "date_accuracy" = 'YEAR' THEN TO_CHAR("date_old", 'YYYY') 10 | END 11 | ); 12 | 13 | ALTER TABLE "scenes" DROP COLUMN "date_old"; 14 | ALTER TABLE "scenes" DROP COLUMN "date_accuracy"; 15 | 16 | -- Transform performers birthdate columns to single string column 17 | ALTER TABLE "performers" RENAME COLUMN "birthdate" TO "birthdate_old"; 18 | ALTER TABLE "performers" ADD COLUMN "birthdate" TEXT; 19 | 20 | UPDATE "performers" SET "birthdate" = ( 21 | CASE 22 | WHEN "birthdate_accuracy" = 'DAY' THEN TO_CHAR("birthdate_old", 'YYYY-MM-DD') 23 | WHEN "birthdate_accuracy" = 'MONTH' THEN TO_CHAR("birthdate_old", 'YYYY-MM') 24 | WHEN "birthdate_accuracy" = 'YEAR' THEN TO_CHAR("birthdate_old", 'YYYY') 25 | END 26 | ); 27 | 28 | ALTER TABLE "performers" DROP COLUMN "birthdate_old"; 29 | ALTER TABLE "performers" DROP COLUMN "birthdate_accuracy"; 30 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/43_studio_aliases.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "studio_aliases" ( 2 | "studio_id" uuid not null, 3 | "alias" varchar(255) not null, 4 | foreign key("studio_id") references "studios"("id") ON DELETE CASCADE, 5 | unique ("alias") 6 | ); -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/44_performer_death_date.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "performers" ADD COLUMN "deathdate" TEXT; 2 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/45_scene_production_date.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "scenes" ADD COLUMN "production_date" TEXT; 2 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/46_update_default_notifications.up.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM "user_notifications" 2 | WHERE type IN ( 3 | 'FAVORITE_PERFORMER_EDIT', 4 | 'FAVORITE_STUDIO_EDIT', 5 | 'FAVORITE_STUDIO_SCENE', 6 | 'FAVORITE_PERFORMER_SCENE' 7 | ); 8 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/47_favorite_unique.up.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM "performer_favorites" PF 2 | WHERE EXISTS ( 3 | SELECT FROM "performer_favorites" 4 | WHERE performer_id = PF.performer_id 5 | AND user_id = PF.user_id 6 | AND ctid < PF.ctid 7 | ); 8 | 9 | CREATE UNIQUE INDEX "performer_favorites_unique_idx" ON "performer_favorites" (performer_id, user_id); 10 | 11 | DROP INDEX performer_favorites_idx; 12 | 13 | DELETE FROM "studio_favorites" SF 14 | WHERE EXISTS ( 15 | SELECT FROM "studio_favorites" 16 | WHERE studio_id = SF.studio_id 17 | AND user_id = SF.user_id 18 | AND ctid < SF.ctid 19 | ); 20 | 21 | CREATE UNIQUE INDEX "studio_favorites_unique_idx" ON "studio_favorites" (studio_id, user_id); 22 | 23 | DROP INDEX studio_favorites_idx; 24 | 25 | ALTER TABLE "performer_favorites" ADD COLUMN "created_at" TIMESTAMP; 26 | ALTER TABLE "performer_favorites" ALTER "created_at" SET DEFAULT NOW(); 27 | ALTER TABLE "studio_favorites" ADD COLUMN "created_at" TIMESTAMP; 28 | ALTER TABLE "studio_favorites" ALTER "created_at" SET DEFAULT NOW(); 29 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/48_fingerprinted_scene_edit_notification.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TYPE notification_type ADD VALUE 'FINGERPRINTED_SCENE_EDIT'; 2 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/49_entity_search_lower_idx.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX performer_urls_url_lower_idx ON performer_urls (LOWER(url)); 2 | CREATE INDEX scene_urls_url_lower_idx ON scene_urls (LOWER(url)); 3 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/4_image_tables.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE images ( 2 | id uuid PRIMARY KEY, 3 | url VARCHAR NOT NULL, 4 | width INT, 5 | height INT 6 | ); 7 | 8 | CREATE TABLE scene_images ( 9 | scene_id uuid REFERENCES scenes(id) NOT NULL, 10 | image_id uuid REFERENCES images(id) NOT NULL 11 | ); 12 | 13 | CREATE TABLE performer_images ( 14 | performer_id uuid REFERENCES performers(id) NOT NULL, 15 | image_id uuid REFERENCES images(id) NOT NULL 16 | ); 17 | 18 | CREATE TABLE studio_images ( 19 | studio_id uuid REFERENCES studios(id) NOT NULL, 20 | image_id uuid REFERENCES images(id) NOT NULL 21 | ); 22 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/7_optimization_indexes.up.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX performer_images_performer_id_idx ON performer_images (performer_id); 2 | CREATE INDEX scenes_date_idx ON scenes (date DESC NULLS LAST); 3 | CREATE INDEX scene_images_scene_id_idx ON scene_images (scene_id); 4 | CREATE INDEX scene_performers_performer_idx ON scene_performers (performer_id); 5 | CREATE INDEX scene_tags_tag_id_idx ON scene_tags (tag_id); 6 | CREATE INDEX scene_fingerprints_hash_idx ON scene_fingerprints (hash); 7 | CREATE INDEX studio_images_studio_id_idx ON studio_images (studio_id); 8 | CREATE INDEX tag_aliases_tag_id_idx ON tag_aliases (tag_id); 9 | CREATE INDEX tags_name_idx ON tags (name); 10 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/8_user_invite.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "users" 2 | ADD COLUMN "invited_by" uuid, 3 | ADD COLUMN "invite_tokens" integer not null default 0, 4 | ADD foreign key("invited_by") references "users"("id") ON DELETE SET NULL; 5 | 6 | CREATE INDEX "user_invited_by_idx" ON "users" ("invited_by"); 7 | 8 | CREATE TABLE "invite_keys" ( 9 | "id" uuid not null primary key, 10 | "generated_by" uuid not null, 11 | "generated_at" timestamp not null, 12 | foreign key("generated_by") references "users"("id") on delete cascade 13 | ); 14 | 15 | CREATE INDEX "invite_keys_generated_by_idx" ON "invite_keys" ("generated_by"); 16 | 17 | CREATE TABLE "pending_activations" ( 18 | "id" uuid not null primary key, 19 | "email" varchar(255) not null, 20 | "invite_key" uuid, 21 | "type" varchar(255) not null, 22 | "time" timestamp not null, 23 | foreign key("invite_key") references "invite_keys"("id") 24 | ); 25 | 26 | CREATE UNIQUE INDEX "pending_activation_email_idx" on "pending_activations" ("email"); 27 | CREATE UNIQUE INDEX "pending_activation_invite_key_idx" on "pending_activations" ("invite_key"); 28 | -------------------------------------------------------------------------------- /pkg/database/migrations/postgres/9_image_data.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE "images" ADD COLUMN "checksum" varchar(255) NOT NULL; 2 | ALTER TABLE "images" ALTER COLUMN "url" DROP NOT NULL; 3 | ALTER TABLE "images" ALTER COLUMN "width" SET NOT NULL; 4 | ALTER TABLE "images" ALTER COLUMN "height" SET NOT NULL; 5 | 6 | CREATE UNIQUE INDEX "images_checksum_idx" ON "images" ("checksum"); 7 | -------------------------------------------------------------------------------- /pkg/draft/draft.go: -------------------------------------------------------------------------------- 1 | package draft 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gofrs/uuid" 7 | "github.com/stashapp/stash-box/pkg/image" 8 | "github.com/stashapp/stash-box/pkg/models" 9 | ) 10 | 11 | func Destroy(fac models.Repo, id uuid.UUID) error { 12 | dqb := fac.Draft() 13 | draft, err := dqb.Find(id) 14 | if err != nil { 15 | return err 16 | } 17 | if draft == nil { 18 | return fmt.Errorf("Draft not found: %v", id) 19 | } 20 | 21 | var imageID *uuid.UUID 22 | switch draft.Type { 23 | case "SCENE": 24 | data, err := draft.GetSceneData() 25 | if err != nil { 26 | return err 27 | } 28 | imageID = data.Image 29 | case "PERFORMER": 30 | data, err := draft.GetPerformerData() 31 | if err != nil { 32 | return err 33 | } 34 | imageID = data.Image 35 | default: 36 | return fmt.Errorf("Unsupported type: %s", draft.Type) 37 | } 38 | 39 | if err = dqb.Destroy(id); err != nil { 40 | return err 41 | } 42 | 43 | if imageID != nil { 44 | imageService := image.GetService(fac.Image()) 45 | if err := imageService.DestroyUnusedImage(*imageID); err != nil { 46 | return err 47 | } 48 | } 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /pkg/image/image_backend.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/stashapp/stash-box/pkg/models" 7 | ) 8 | 9 | type Backend interface { 10 | WriteFile(file []byte, image *models.Image) error 11 | DestroyFile(image *models.Image) error 12 | ReadFile(image models.Image) (io.ReadCloser, int64, error) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/image/image_service.go: -------------------------------------------------------------------------------- 1 | package image 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/gofrs/uuid" 7 | "github.com/stashapp/stash-box/pkg/manager/config" 8 | "github.com/stashapp/stash-box/pkg/models" 9 | ) 10 | 11 | type BackendService interface { 12 | Create(input models.ImageCreateInput) (*models.Image, error) 13 | Destroy(input models.ImageDestroyInput) error 14 | DestroyUnusedImages() error 15 | DestroyUnusedImage(imageID uuid.UUID) error 16 | Read(image models.Image) (io.ReadCloser, int64, error) 17 | } 18 | 19 | func GetService(repo models.ImageRepo) BackendService { 20 | imageBackend := config.GetImageBackend() 21 | 22 | var backend Backend 23 | if imageBackend == config.FileBackend { 24 | backend = &FileBackend{} 25 | } else if imageBackend == config.S3Backend { 26 | backend = &S3Backend{} 27 | } 28 | 29 | return &Service{ 30 | Repository: repo, 31 | Backend: backend, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/image/resize_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows || darwin 2 | 3 | package image 4 | 5 | import ( 6 | "io" 7 | 8 | "github.com/stashapp/stash-box/pkg/models" 9 | ) 10 | 11 | func Resize(reader io.Reader, max int, dbimage *models.Image, fileSize int64) ([]byte, error) { 12 | return resizeImage(reader, int64(max)) 13 | } 14 | 15 | func InitResizer() {} 16 | -------------------------------------------------------------------------------- /pkg/logger/progress_formatter.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | ) 6 | 7 | type ProgressFormatter struct{} 8 | 9 | func (f *ProgressFormatter) Format(entry *logrus.Entry) ([]byte, error) { 10 | msg := []byte("Processing --> " + entry.Message + "\r") 11 | return msg, nil 12 | } 13 | -------------------------------------------------------------------------------- /pkg/manager/paths/paths.go: -------------------------------------------------------------------------------- 1 | package paths 2 | 3 | import ( 4 | "path/filepath" 5 | ) 6 | 7 | func GetConfigDirectory() string { 8 | return "." 9 | } 10 | 11 | func GetDefaultDatabaseFilePath() string { 12 | return "postgres@localhost/stash-box?sslmode=disable" 13 | } 14 | 15 | func GetConfigName() string { 16 | return "stash-box-config" 17 | } 18 | 19 | func GetDefaultConfigFilePath() string { 20 | return filepath.Join(GetConfigDirectory(), GetConfigName()+".yml") 21 | } 22 | 23 | func GetSSLKey() string { 24 | return filepath.Join(GetConfigDirectory(), "stash-box.key") 25 | } 26 | 27 | func GetSSLCert() string { 28 | return filepath.Join(GetConfigDirectory(), "stash-box.crt") 29 | } 30 | -------------------------------------------------------------------------------- /pkg/models/activation.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/gofrs/uuid" 5 | ) 6 | 7 | type UserTokenRepo interface { 8 | UserTokenFinder 9 | UserTokenCreator 10 | 11 | Destroy(id uuid.UUID) error 12 | DestroyExpired() error 13 | Count() (int, error) 14 | } 15 | 16 | type UserTokenFinder interface { 17 | Find(id uuid.UUID) (*UserToken, error) 18 | FindByInviteKey(key uuid.UUID) ([]*UserToken, error) 19 | } 20 | 21 | type UserTokenCreator interface { 22 | Create(newActivation UserToken) (*UserToken, error) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/models/draft.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/gofrs/uuid" 5 | ) 6 | 7 | type DraftRepo interface { 8 | Create(newEdit Draft) (*Draft, error) 9 | Destroy(id uuid.UUID) error 10 | Find(id uuid.UUID) (*Draft, error) 11 | FindByUser(userID uuid.UUID) ([]*Draft, error) 12 | FindExpired(timeLimit int) ([]*Draft, error) 13 | } 14 | -------------------------------------------------------------------------------- /pkg/models/errors.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gofrs/uuid" 7 | ) 8 | 9 | // NotFoundError indicates that an object with the given id was not found. 10 | type NotFoundError uuid.UUID 11 | 12 | func (e NotFoundError) Error() string { 13 | return fmt.Sprintf("object with id %s not found", uuid.UUID(e).String()) 14 | } 15 | -------------------------------------------------------------------------------- /pkg/models/extension_criterion_input.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | type MultiCriterionInput interface { 4 | Count() int 5 | GetValues() []interface{} 6 | GetModifier() CriterionModifier 7 | } 8 | 9 | func (i MultiIDCriterionInput) Count() int { 10 | return len(i.Value) 11 | } 12 | 13 | func (i MultiIDCriterionInput) GetValues() []interface{} { 14 | args := make([]interface{}, len(i.Value)) 15 | for index := range i.Value { 16 | args[index] = i.Value[index] 17 | } 18 | return args 19 | } 20 | 21 | func (i MultiIDCriterionInput) GetModifier() CriterionModifier { 22 | return i.Modifier 23 | } 24 | 25 | func (i MultiStringCriterionInput) Count() int { 26 | return len(i.Value) 27 | } 28 | 29 | func (i MultiStringCriterionInput) GetModifier() CriterionModifier { 30 | return i.Modifier 31 | } 32 | 33 | func (i MultiStringCriterionInput) GetValues() []interface{} { 34 | args := make([]interface{}, len(i.Value)) 35 | for index := range i.Value { 36 | args[index] = i.Value[index] 37 | } 38 | return args 39 | } 40 | -------------------------------------------------------------------------------- /pkg/models/extension_role_enum.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | func (r RoleEnum) Implies(other RoleEnum) bool { 4 | // admin has all roles 5 | if r == RoleEnumAdmin { 6 | return true 7 | } 8 | 9 | // MANAGE_INVITES implies INVITE 10 | if r == RoleEnumManageInvites && other == RoleEnumInvite { 11 | return true 12 | } 13 | 14 | // until we add a NONE value, all values imply read 15 | if r.IsValid() && other == RoleEnumRead { 16 | return true 17 | } 18 | 19 | // all others only imply themselves 20 | return r == other 21 | } 22 | -------------------------------------------------------------------------------- /pkg/models/factory.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/stashapp/stash-box/pkg/txn" 4 | 5 | type Repo interface { 6 | txn.State 7 | 8 | Image() ImageRepo 9 | 10 | Performer() PerformerRepo 11 | Scene() SceneRepo 12 | Studio() StudioRepo 13 | 14 | TagCategory() TagCategoryRepo 15 | Tag() TagRepo 16 | 17 | Edit() EditRepo 18 | 19 | Joins() JoinsRepo 20 | 21 | UserToken() UserTokenRepo 22 | Invite() InviteKeyRepo 23 | User() UserRepo 24 | Site() SiteRepo 25 | Draft() DraftRepo 26 | 27 | Notification() NotificationRepo 28 | } 29 | -------------------------------------------------------------------------------- /pkg/models/image.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/gofrs/uuid" 5 | ) 6 | 7 | type ImageRepo interface { 8 | ImageCreator 9 | ImageDestroyer 10 | ImageFinder 11 | 12 | FindByIds(ids []uuid.UUID) ([]*Image, []error) 13 | FindIdsBySceneIds(ids []uuid.UUID) ([][]uuid.UUID, []error) 14 | FindIdsByPerformerIds(ids []uuid.UUID) ([][]uuid.UUID, []error) 15 | FindBySceneID(sceneID uuid.UUID) ([]*Image, error) 16 | FindByPerformerID(performerID uuid.UUID) (Images, error) 17 | FindByStudioID(studioID uuid.UUID) ([]*Image, error) 18 | FindIdsByStudioIds(ids []uuid.UUID) ([][]uuid.UUID, []error) 19 | } 20 | 21 | type ImageCreator interface { 22 | Create(newImage Image) (*Image, error) 23 | } 24 | 25 | type ImageFinder interface { 26 | Find(id uuid.UUID) (*Image, error) 27 | FindByChecksum(checksum string) (*Image, error) 28 | FindByPerformerID(performerID uuid.UUID) (Images, error) 29 | FindUnused() ([]*Image, error) 30 | IsUnused(imageID uuid.UUID) (bool, error) 31 | } 32 | 33 | type ImageDestroyer interface { 34 | Destroy(id uuid.UUID) error 35 | } 36 | -------------------------------------------------------------------------------- /pkg/models/invite_key.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gofrs/uuid" 7 | ) 8 | 9 | type InviteKeyRepo interface { 10 | InviteKeyFinder 11 | InviteKeyCreator 12 | InviteKeyDestroyer 13 | InviteKeyUser 14 | } 15 | 16 | type InviteKeyCreator interface { 17 | Create(newKey InviteKey) (*InviteKey, error) 18 | } 19 | 20 | type InviteKeyFinder interface { 21 | Find(id uuid.UUID) (*InviteKey, error) 22 | FindActiveKeysForUser(userID uuid.UUID, expireTime time.Time) (InviteKeys, error) 23 | } 24 | 25 | type InviteKeyDestroyer interface { 26 | InviteKeyFinder 27 | Destroy(id uuid.UUID) error 28 | DestroyExpired() error 29 | } 30 | 31 | type InviteKeyUser interface { 32 | KeyUsed(id uuid.UUID) (*int, error) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/models/json_time.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stashapp/stash-box/pkg/utils" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | type JSONTime struct { 11 | time.Time 12 | } 13 | 14 | func (jt *JSONTime) UnmarshalJSON(b []byte) (err error) { 15 | s := strings.Trim(string(b), "\"") 16 | if s == "null" { 17 | jt.Time = time.Time{} 18 | return 19 | } 20 | 21 | jt.Time, err = utils.ParseDateStringAsTime(s) 22 | return 23 | } 24 | 25 | func (jt *JSONTime) MarshalJSON() ([]byte, error) { 26 | if jt.Time.IsZero() { 27 | return []byte("null"), nil 28 | } 29 | return []byte(fmt.Sprintf("\"%s\"", jt.Time.Format(time.RFC3339))), nil 30 | } 31 | -------------------------------------------------------------------------------- /pkg/models/model_invite_key.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/gofrs/uuid" 8 | ) 9 | 10 | type InviteKey struct { 11 | ID uuid.UUID `json:"id"` 12 | Uses *int `json:"uses"` 13 | GeneratedBy uuid.UUID `json:"generated_by"` 14 | GeneratedAt time.Time `json:"generated_at"` 15 | Expires *time.Time `json:"expires"` 16 | } 17 | 18 | func (p InviteKey) GetID() uuid.UUID { 19 | return p.ID 20 | } 21 | 22 | func (p InviteKey) String() string { 23 | uses := "unlimited" 24 | expires := "never" 25 | 26 | if p.Uses != nil { 27 | uses = fmt.Sprintf("%d", *p.Uses) 28 | } 29 | if p.Expires != nil { 30 | expires = p.Expires.Format(time.RFC3339) 31 | } 32 | 33 | return fmt.Sprintf("%s: [%s] expires %s", p.ID, uses, expires) 34 | } 35 | 36 | type InviteKeys []*InviteKey 37 | 38 | func (p InviteKeys) Each(fn func(interface{})) { 39 | for _, v := range p { 40 | fn(*v) 41 | } 42 | } 43 | 44 | func (p *InviteKeys) Add(o interface{}) { 45 | *p = append(*p, o.(*InviteKey)) 46 | } 47 | -------------------------------------------------------------------------------- /pkg/models/model_notification.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | "github.com/gofrs/uuid" 8 | ) 9 | 10 | type Notification struct { 11 | UserID uuid.UUID `db:"user_id" json:"user_id"` 12 | Type NotificationEnum `db:"type" json:"type"` 13 | TargetID uuid.UUID `db:"id" json:"id"` 14 | CreatedAt time.Time `db:"created_at" json:"created_at"` 15 | ReadAt sql.NullTime `db:"read_at" json:"read_at"` 16 | } 17 | 18 | type Notifications []*Notification 19 | 20 | func (s Notifications) Each(fn func(interface{})) { 21 | for _, v := range s { 22 | fn(v) 23 | } 24 | } 25 | 26 | func (s *Notifications) Add(o interface{}) { 27 | *s = append(*s, o.(*Notification)) 28 | } 29 | 30 | type QueryNotificationsResult struct { 31 | Input QueryNotificationsInput 32 | } 33 | -------------------------------------------------------------------------------- /pkg/models/model_tag_category.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | 7 | "github.com/gofrs/uuid" 8 | ) 9 | 10 | type TagCategory struct { 11 | ID uuid.UUID `db:"id" json:"id"` 12 | Name string `db:"name" json:"name"` 13 | Group string `db:"group" json:"group"` 14 | Description sql.NullString `db:"description" json:"description"` 15 | CreatedAt time.Time `db:"created_at" json:"created_at"` 16 | UpdatedAt time.Time `db:"updated_at" json:"updated_at"` 17 | } 18 | 19 | func (p TagCategory) GetID() uuid.UUID { 20 | return p.ID 21 | } 22 | 23 | type TagCategories []*TagCategory 24 | 25 | func (p TagCategories) Each(fn func(interface{})) { 26 | for _, v := range p { 27 | fn(v) 28 | } 29 | } 30 | 31 | func (p *TagCategories) Add(o interface{}) { 32 | *p = append(*p, o.(*TagCategory)) 33 | } 34 | 35 | func (p *TagCategory) CopyFromCreateInput(input TagCategoryCreateInput) { 36 | CopyFull(p, input) 37 | } 38 | 39 | func (p *TagCategory) CopyFromUpdateInput(input TagCategoryUpdateInput) { 40 | CopyFull(p, input) 41 | } 42 | -------------------------------------------------------------------------------- /pkg/models/model_tag_test.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "testing" 6 | 7 | "github.com/gofrs/uuid" 8 | "gotest.tools/v3/assert" 9 | ) 10 | 11 | func TestCopyFromTagEdit(t *testing.T) { 12 | input := TagEdit{ 13 | Name: &bName, 14 | Description: &bDescription, 15 | CategoryID: &bCategoryID, 16 | } 17 | 18 | old := TagEdit{ 19 | Name: &aName, 20 | Description: &aDescription, 21 | CategoryID: &aCategoryID, 22 | } 23 | 24 | orig := Tag{ 25 | Name: aName, 26 | Description: sql.NullString{String: aDescription, Valid: true}, 27 | CategoryID: uuid.NullUUID{UUID: aCategoryID, Valid: true}, 28 | } 29 | 30 | origCopy := orig 31 | origCopy.CopyFromTagEdit(input, &old) 32 | 33 | assert.DeepEqual(t, Tag{ 34 | Name: bName, 35 | Description: sql.NullString{String: bDescription, Valid: true}, 36 | CategoryID: uuid.NullUUID{UUID: bCategoryID, Valid: true}, 37 | }, origCopy) 38 | 39 | origCopy = orig 40 | origCopy.CopyFromTagEdit(TagEdit{}, &TagEdit{}) 41 | 42 | assert.DeepEqual(t, orig, origCopy) 43 | } 44 | -------------------------------------------------------------------------------- /pkg/models/notification.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/gofrs/uuid" 4 | 5 | type NotificationRepo interface { 6 | DestroyExpired() error 7 | GetNotifications(userID uuid.UUID, filter QueryNotificationsInput) ([]*Notification, error) 8 | GetNotificationsCount(userID uuid.UUID, filter QueryNotificationsInput) (int, error) 9 | MarkRead(userID uuid.UUID, notificationType NotificationEnum, id uuid.UUID) error 10 | MarkAllRead(userID uuid.UUID) error 11 | 12 | TriggerSceneCreationNotifications(sceneID uuid.UUID) error 13 | TriggerPerformerEditNotifications(editID uuid.UUID) error 14 | TriggerStudioEditNotifications(editID uuid.UUID) error 15 | TriggerSceneEditNotifications(editID uuid.UUID) error 16 | TriggerEditCommentNotifications(editID uuid.UUID) error 17 | TriggerDownvoteEditNotifications(editID uuid.UUID) error 18 | TriggerFailedEditNotifications(editID uuid.UUID) error 19 | TriggerUpdatedEditNotifications(editID uuid.UUID) error 20 | } 21 | -------------------------------------------------------------------------------- /pkg/models/scalars.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "strings" 7 | 8 | "github.com/99designs/gqlgen/graphql" 9 | "github.com/gofrs/uuid" 10 | ) 11 | 12 | type ID uuid.UUID 13 | 14 | // Creates a marshaller which converts a uuid to a string 15 | func MarshalID(id uuid.UUID) graphql.Marshaler { 16 | return graphql.WriterFunc(func(w io.Writer) { 17 | _, e := io.WriteString(w, fmt.Sprintf("%s%s%s", "\"", id.String(), "\"")) 18 | if e != nil { 19 | panic(e) 20 | } 21 | }) 22 | } 23 | 24 | // Unmarshalls a string to a uuid 25 | func UnmarshalID(v interface{}) (uuid.UUID, error) { 26 | str, ok := v.(string) 27 | if !ok { 28 | return uuid.UUID{}, fmt.Errorf("ids must be strings") 29 | } 30 | withoutQuotes := strings.ReplaceAll(str, "\"", "") 31 | i, err := uuid.FromString(withoutQuotes) 32 | return i, err 33 | } 34 | -------------------------------------------------------------------------------- /pkg/models/site.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/gofrs/uuid" 4 | 5 | type SiteRepo interface { 6 | Create(newSite Site) (*Site, error) 7 | Update(updatedSite Site) (*Site, error) 8 | Destroy(id uuid.UUID) error 9 | Find(id uuid.UUID) (*Site, error) 10 | FindByIds(ids []uuid.UUID) ([]*Site, []error) 11 | Query() ([]*Site, int, error) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/models/sqlite_date.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql/driver" 5 | "time" 6 | 7 | "github.com/stashapp/stash-box/pkg/logger" 8 | "github.com/stashapp/stash-box/pkg/utils" 9 | ) 10 | 11 | type SQLDate struct { 12 | String string 13 | Valid bool 14 | } 15 | 16 | // Scan implements the Scanner interface. 17 | func (t *SQLDate) Scan(value interface{}) error { 18 | dateTime, ok := value.(time.Time) 19 | if !ok { 20 | t.String = "" 21 | t.Valid = false 22 | return nil 23 | } 24 | 25 | t.String = dateTime.Format("2006-01-02") 26 | if t.String != "" && t.String != "0001-01-01" { 27 | t.Valid = true 28 | } else { 29 | t.Valid = false 30 | } 31 | return nil 32 | } 33 | 34 | // Value implements the driver Valuer interface. 35 | func (t SQLDate) Value() (driver.Value, error) { 36 | if !t.Valid || t.String == "" { 37 | return nil, nil 38 | } 39 | 40 | result, err := utils.ParseDateStringAsFormat(t.String, "2006-01-02") 41 | if err != nil { 42 | logger.Debugf("date conversion error: %s", err.Error()) 43 | } 44 | if result == "" { 45 | return nil, nil 46 | } 47 | return result, nil 48 | } 49 | 50 | func (t SQLDate) IsValid() bool { 51 | return t.Valid 52 | } 53 | -------------------------------------------------------------------------------- /pkg/models/tag.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/gofrs/uuid" 4 | 5 | type TagRepo interface { 6 | Create(newTag Tag) (*Tag, error) 7 | Update(updatedTag Tag) (*Tag, error) 8 | UpdatePartial(updatedTag Tag) (*Tag, error) 9 | Destroy(id uuid.UUID) error 10 | CreateAliases(newJoins TagAliases) error 11 | UpdateAliases(tagID uuid.UUID, updatedJoins TagAliases) error 12 | Find(id uuid.UUID) (*Tag, error) 13 | FindBySceneID(sceneID uuid.UUID) ([]*Tag, error) 14 | FindIdsBySceneIds(ids []uuid.UUID) ([][]uuid.UUID, []error) 15 | FindByIds(ids []uuid.UUID) ([]*Tag, []error) 16 | FindByName(name string) (*Tag, error) 17 | FindByNameOrAlias(name string) (*Tag, error) 18 | FindWithRedirect(id uuid.UUID) (*Tag, error) 19 | Count() (int, error) 20 | Query(input TagQueryInput) ([]*Tag, int, error) 21 | GetAliases(id uuid.UUID) ([]string, error) 22 | ApplyEdit(edit Edit, operation OperationEnum, tag *Tag) (*Tag, error) 23 | GetEditAliases(id *uuid.UUID, data *TagEdit) ([]string, error) 24 | SearchTags(term string, limit int) ([]*Tag, error) 25 | } 26 | -------------------------------------------------------------------------------- /pkg/models/tag_category.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/gofrs/uuid" 4 | 5 | type TagCategoryRepo interface { 6 | Create(newCategory TagCategory) (*TagCategory, error) 7 | Update(updatedCategory TagCategory) (*TagCategory, error) 8 | Destroy(id uuid.UUID) error 9 | Find(id uuid.UUID) (*TagCategory, error) 10 | FindByIds(ids []uuid.UUID) ([]*TagCategory, []error) 11 | Query() ([]*TagCategory, int, error) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/gofrs/uuid" 5 | ) 6 | 7 | type UserRepo interface { 8 | UserFinder 9 | 10 | Create(newUser User) (*User, error) 11 | Update(updatedUser User) (*User, error) 12 | UpdateFull(updatedUser User) (*User, error) 13 | Destroy(id uuid.UUID) error 14 | CreateRoles(newJoins UserRoles) error 15 | UpdateRoles(userID uuid.UUID, updatedJoins UserRoles) error 16 | 17 | Count() (int, error) 18 | Query(filter UserQueryInput) (Users, int, error) 19 | GetRoles(id uuid.UUID) (UserRoles, error) 20 | CountVotesByType(id uuid.UUID) (*UserVoteCount, error) 21 | CountEditsByStatus(id uuid.UUID) (*UserEditCount, error) 22 | } 23 | 24 | // UserFinder is an interface to find and update User objects. 25 | type UserFinder interface { 26 | Find(id uuid.UUID) (*User, error) 27 | FindByName(name string) (*User, error) 28 | FindByEmail(email string) (*User, error) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/sqlx/fingerprints.go: -------------------------------------------------------------------------------- 1 | package sqlx 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/gofrs/uuid" 7 | ) 8 | 9 | type dbSceneFingerprint struct { 10 | SceneID uuid.UUID `db:"scene_id" json:"scene_id"` 11 | UserID uuid.UUID `db:"user_id" json:"user_id"` 12 | FingerprintID int `db:"fingerprint_id" json:"fingerprint_id"` 13 | Duration int `db:"duration" json:"duration"` 14 | CreatedAt time.Time `db:"created_at" json:"created_at"` 15 | Vote int `db:"vote" json:"vote"` 16 | } 17 | 18 | type dbSceneFingerprints []*dbSceneFingerprint 19 | 20 | func (f dbSceneFingerprints) Each(fn func(interface{})) { 21 | for _, v := range f { 22 | fn(*v) 23 | } 24 | } 25 | 26 | func (f dbSceneFingerprints) EachPtr(fn func(interface{})) { 27 | for _, v := range f { 28 | fn(v) 29 | } 30 | } 31 | 32 | func (f *dbSceneFingerprints) Add(o interface{}) { 33 | *f = append(*f, o.(*dbSceneFingerprint)) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/sqlx/null.go: -------------------------------------------------------------------------------- 1 | package sqlx 2 | 3 | import "database/sql" 4 | 5 | func intPtrFromNullInt(n sql.NullInt64) *int { 6 | if n.Valid { 7 | i := int(n.Int64) 8 | return &i 9 | } 10 | return nil 11 | } 12 | -------------------------------------------------------------------------------- /pkg/txn/state.go: -------------------------------------------------------------------------------- 1 | package txn 2 | 3 | // Mgr manages the initialisation of transaction state objects. 4 | // Mgr instances may exist in multiple goroutines. 5 | type Mgr interface { 6 | // New creates a new State object, for the purposes of executing 7 | // queries within a single context. 8 | New() State 9 | } 10 | 11 | // State represents the transaction state for a single request. 12 | // A State object instance should exist only within a single goroutine. 13 | // It MUST NOT be shared between goroutines. 14 | type State interface { 15 | WithTxn(fn func() error) error 16 | InTxn() bool 17 | ResetTxn() error 18 | } 19 | 20 | func MustBeIn(m State) { 21 | if !m.InTxn() { 22 | panic("not in transaction") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/user/templates/email.txt: -------------------------------------------------------------------------------- 1 | ************ 2 | {{ .Greeting }} 3 | ************ 4 | 5 | {{ .Content }} 6 | 7 | {{ .ActionURL }} 8 | 9 | - {{ .SiteName }} 10 | -------------------------------------------------------------------------------- /pkg/utils/crypto.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/md5" 5 | "crypto/rand" 6 | "encoding/ascii85" 7 | "fmt" 8 | "io" 9 | "os" 10 | ) 11 | 12 | func MD5FromBytes(data []byte) string { 13 | result := md5.Sum(data) 14 | return fmt.Sprintf("%x", result) 15 | } 16 | 17 | func MD5FromString(str string) string { 18 | data := []byte(str) 19 | return MD5FromBytes(data) 20 | } 21 | 22 | func MD5FromFilePath(filePath string) (string, error) { 23 | f, err := os.Open(filePath) 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | h := md5.New() 29 | if _, err := io.Copy(h, f); err != nil { 30 | return "", err 31 | } 32 | checksum := h.Sum(nil) 33 | 34 | _ = f.Close() 35 | return fmt.Sprintf("%x", checksum), nil 36 | } 37 | 38 | func GenerateRandomPassword(l int) (string, error) { 39 | b := make([]byte, l) 40 | if _, err := rand.Read(b); err != nil { 41 | return "", err 42 | } 43 | 44 | output := make([]byte, ascii85.MaxEncodedLen(l)) 45 | n := ascii85.Encode(output, b) 46 | output = output[0:n] 47 | return string(output), nil 48 | } 49 | 50 | func GenerateRandomKey(l int) (string, error) { 51 | b := make([]byte, l) 52 | if _, err := rand.Read(b); err != nil { 53 | return "", err 54 | } 55 | return fmt.Sprintf("%x", b), nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/utils/date.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | const railsTimeLayout = "2006-01-02 15:04:05 MST" 9 | 10 | func GetYMDFromDatabaseDate(dateString string) string { 11 | result, _ := ParseDateStringAsFormat(dateString, "2006-01-02") 12 | return result 13 | } 14 | 15 | func ParseDateStringAsFormat(dateString string, format string) (string, error) { 16 | t, e := ParseDateStringAsTime(dateString) 17 | if e == nil { 18 | return t.Format(format), e 19 | } 20 | return "", fmt.Errorf("ParseDateStringAsFormat failed: dateString <%s>, format <%s>", dateString, format) 21 | } 22 | 23 | func ParseDateStringAsTime(dateString string) (time.Time, error) { 24 | // https://stackoverflow.com/a/20234207 WTF? 25 | 26 | t, e := time.Parse(time.RFC3339, dateString) 27 | if e == nil { 28 | return t, nil 29 | } 30 | 31 | t, e = time.Parse("2006-01-02", dateString) 32 | if e == nil { 33 | return t, nil 34 | } 35 | 36 | t, e = time.Parse("2006-01-02 15:04:05", dateString) 37 | if e == nil { 38 | return t, nil 39 | } 40 | 41 | t, e = time.Parse(railsTimeLayout, dateString) 42 | if e == nil { 43 | return t, nil 44 | } 45 | 46 | return time.Time{}, fmt.Errorf("ParseDateStringAsTime failed: dateString <%s>", dateString) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/utils/enum.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "database/sql" 5 | "reflect" 6 | ) 7 | 8 | type validator interface { 9 | IsValid() bool 10 | } 11 | 12 | func validateEnum(value interface{}) bool { 13 | v, ok := value.(validator) 14 | if !ok { 15 | // shouldn't happen 16 | return false 17 | } 18 | 19 | return v.IsValid() 20 | } 21 | 22 | func ResolveEnum(value sql.NullString, out interface{}) bool { 23 | if !value.Valid { 24 | return false 25 | } 26 | 27 | outValue := reflect.ValueOf(out).Elem() 28 | outValue.SetString(value.String) 29 | 30 | return validateEnum(out) 31 | } 32 | 33 | func ResolveEnumString(value string, out interface{}) bool { 34 | if value == "" { 35 | return false 36 | } 37 | 38 | outValue := reflect.ValueOf(out).Elem() 39 | outValue.SetString(value) 40 | 41 | return validateEnum(out) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/utils/error.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func DuplicateError(err error, size int) []error { 4 | errors := make([]error, size) 5 | for i := range errors { 6 | errors[i] = err 7 | } 8 | return errors 9 | } 10 | -------------------------------------------------------------------------------- /pkg/utils/file.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | // FileExists returns true if the given path exists 8 | func FileExists(path string) (bool, error) { 9 | _, err := os.Stat(path) 10 | switch { 11 | case err == nil: 12 | return true, nil 13 | case os.IsNotExist(err): 14 | return false, err 15 | default: 16 | panic(err) 17 | } 18 | } 19 | 20 | // Touch creates an empty file at the given path if it doesn't already exist 21 | func Touch(path string) error { 22 | var _, err = os.Stat(path) 23 | if os.IsNotExist(err) { 24 | var file, err = os.Create(path) 25 | if err != nil { 26 | return err 27 | } 28 | return file.Close() 29 | } 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/utils/json.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | 7 | "github.com/jmoiron/sqlx/types" 8 | ) 9 | 10 | func ToJSON(data interface{}) (types.JSONText, error) { 11 | buffer := &bytes.Buffer{} 12 | encoder := json.NewEncoder(buffer) 13 | encoder.SetEscapeHTML(false) 14 | encoder.SetIndent("", " ") 15 | if err := encoder.Encode(data); err != nil { 16 | return nil, err 17 | } 18 | return buffer.Bytes(), nil 19 | } 20 | 21 | func FromJSON(data types.JSONText, obj interface{}) error { 22 | return json.Unmarshal(data, obj) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/utils/map.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "strings" 4 | 5 | // FindField traverses a json map, searching for the field matching the 6 | // qualified field string provided. 7 | // 8 | // For example: a qualifiedField of "foo" returns value of the "foo" key in the 9 | // provided map. A qualifiedField of "foo.bar" will find the "foo" value, and 10 | // if it is a map[string]interface{}, will return the "bar" value of that map. 11 | // Returns the value and true if the value was found. Returns nil and false if 12 | // the value was not found, or if one of the intermediate values was not a 13 | // map[string]interface{} 14 | func FindField(m map[string]interface{}, qualifiedField string) (interface{}, bool) { 15 | const delimiter = "." 16 | fields := strings.Split(qualifiedField, delimiter) 17 | 18 | var current interface{} 19 | current = m 20 | for _, field := range fields { 21 | asMap, ok := current.(map[string]interface{}) 22 | if !ok { 23 | return nil, false 24 | } 25 | 26 | current, ok = asMap[field] 27 | if !ok { 28 | return nil, false 29 | } 30 | } 31 | 32 | return current, true 33 | } 34 | -------------------------------------------------------------------------------- /pkg/utils/map_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | type findFieldTest struct { 9 | subjectJSON string 10 | qualifiedField string 11 | expected interface{} 12 | expectedBool bool 13 | } 14 | 15 | var findFieldTests = []findFieldTest{ 16 | {`{"foo": "bar"}`, "foo", "bar", true}, 17 | {`{"foo": "bar"}`, "bar", nil, false}, 18 | {`{"foo": "bar"}`, "foo.bar", nil, false}, 19 | {`{"foo": null}`, "foo", nil, true}, 20 | {`{"foo": {"bar": "baz"}}`, "foo.bar", "baz", true}, 21 | {`{"foo": {"bar": "baz"}}`, "foo.baz", nil, false}, 22 | } 23 | 24 | func TestFindField(t *testing.T) { 25 | for _, test := range findFieldTests { 26 | var m map[string]interface{} 27 | err := json.Unmarshal([]byte(test.subjectJSON), &m) 28 | if err != nil { 29 | t.Errorf("(%v) Unexpected error: %s", test, err.Error()) 30 | continue 31 | } 32 | 33 | v, found := FindField(m, test.qualifiedField) 34 | 35 | if test.expectedBool != found { 36 | t.Errorf("found(%v,%v) = %v; want %v", test.subjectJSON, test.qualifiedField, found, test.expectedBool) 37 | } 38 | 39 | if test.expected != v { 40 | t.Errorf("value(%v,%v) = %v; want %v", test.subjectJSON, test.qualifiedField, v, test.expected) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkg/utils/slice_compare.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func Includes[T comparable](arr []T, against T) bool { 4 | for _, v := range arr { 5 | if v == against { 6 | return true 7 | } 8 | } 9 | return false 10 | } 11 | 12 | func SliceCompare[T comparable](subject []T, against []T) (added []T, missing []T) { 13 | for _, v := range subject { 14 | if !Includes(against, v) && !Includes(added, v) { 15 | added = append(added, v) 16 | } 17 | } 18 | 19 | for _, v := range against { 20 | if !Includes(subject, v) && !Includes(missing, v) { 21 | missing = append(missing, v) 22 | } 23 | } 24 | 25 | return 26 | } 27 | 28 | func ProcessSlice[T comparable](current []T, added []T, removed []T) []T { 29 | for _, v := range removed { 30 | for i, k := range current { 31 | if v == k { 32 | current[i] = current[len(current)-1] 33 | current = current[:len(current)-1] 34 | break 35 | } 36 | } 37 | } 38 | 39 | for i := range added { 40 | if !Includes(current, added[i]) { 41 | current = append(current, added[i]) 42 | } 43 | } 44 | 45 | return current 46 | } 47 | -------------------------------------------------------------------------------- /pkg/utils/string.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func StrPtrToString(val *string) string { 4 | if val == nil { 5 | return "" 6 | } 7 | return *val 8 | } 9 | 10 | func StringToStrPtr(val string) *string { 11 | if val == "" { 12 | return nil 13 | } 14 | return &val 15 | } 16 | -------------------------------------------------------------------------------- /scripts/getDate.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | 3 | package main 4 | 5 | import "fmt" 6 | import "time" 7 | 8 | func main() { 9 | now := time.Now().Format("2006-01-02 15:04:05") 10 | 11 | fmt.Printf("%s", now) 12 | } 13 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | import ( 7 | _ "github.com/99designs/gqlgen" 8 | _ "github.com/99designs/gqlgen/graphql/introspection" 9 | _ "github.com/vektah/dataloaden" 10 | ) 11 | --------------------------------------------------------------------------------