1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2026-02-06 23:32:58 +00:00

113 Commits
0.4.1 ... 0.4.5

Author SHA1 Message Date
6098a11115 Bumped version to 0.4.5
Woops, forgot to bump the version.
2025-06-03 13:48:49 +04:00
a87d8b94b8 Merge pull request #125 from koloml/release/0.4.5
Release: 0.4.5
2025-06-03 13:46:57 +04:00
c283b96285 Updating dependencies (#126)
* Updated `sass` from 1.86.3 to 1.89.1

* Updated `svelte` from 5.25.6 to 5.33.14

* Updated `svelte-check` from 4.1.5 to 4.2.1

* Updated `@sveltejs/kit` from 2.20.3 to 2.21.1

* Removed `@sveltejs/adapter-auto`

Looks like it was left over from initial commit. We're using static
adapter.

* Updated `vite` from 6.2.5 to 6.3.5

* Updated `vitest` from 3.1.1 to 3.2.0

* Updated `@vitest/coverage-v8` from 3.1.1 to 3.2.0

* Updated `typescript` from 5.8.2 to 5.8.3

* Updated `jsdom` from 26.0.0 to 26.1.0

* Updated `@types/node` from 22.14.0 to 22.15.29

* Updated `@types/chrome` from 0.0.313 to 0.0.326
2025-06-03 13:45:17 +04:00
02478f0bf0 Merge pull request #124 from koloml/bugfix/popup-header-z-index
Fixed header in popup having wrong z-index
2025-06-03 02:41:23 +04:00
59c15f27eb Merge pull request #123 from koloml/feature/quick-query-untagged-implications
Tag Page: Added link for quick search of untagged implications
2025-06-03 02:41:01 +04:00
134e96bc4c Added link for a quick search of untagged implications 2025-06-03 02:14:00 +04:00
1c05159ddf Fixed popup's header z-index position appearing behind some elements 2025-04-15 23:50:02 +04:00
bb14492578 Merge pull request #116 from koloml/release/0.4.4
Release: 0.4.4
2025-04-04 14:38:37 +04:00
30320e7283 Bumped version to 0.4.4 2025-04-04 14:37:20 +04:00
8839373292 Updating dependencies (#118)
* Updated `vite` to 6.2.5

* Updated `sass` to 1.86.3

* Updated `svelte` to 5.25.6

* Updated `@sveltejs/kit` to 2.20.3

* Updated `vitest` to 3.1.1

* Updated `@vitest/coverage-v8` to 3.1.1

* Updated `@sveltejs/adapter-auto` to 6.0.0

* Updated `@types/chrome` to 0.0.313

* Updated `@types/node` to 22.14.0
2025-04-04 14:35:54 +04:00
0e35d1d0ba Merge pull request #117 from koloml/feature/name-events-as-constants
Changing the naming of custom events to be different from the usual variables
2025-04-04 14:15:45 +04:00
bca21da6d1 Merge remote-tracking branch 'origin/release/0.4.4' into feature/name-events-as-constants
# Conflicts:
#	src/lib/components/TagsListBlock.ts
2025-04-04 14:15:02 +04:00
60491f57d4 Merge pull request #115 from koloml/feature/grouping-button
Tag Groups: Added button to the tags list component to quckly toggle the sepeartion on and off
2025-03-26 21:06:25 +04:00
c26c4bcf62 Merge pull request #114 from koloml/bugfix/missing-re-initialization-for-tags-list
Tags List: Fixed re-initialization of the component after tags were submitted
2025-03-26 21:06:15 +04:00
1b4b646024 Merge pull request #113 from koloml/bugfix/last-mediabox-position
Fixed last media box on the page showing its popup outside of the viewport
2025-03-26 21:05:29 +04:00
928fe5ddb0 Removed unnecessary import 2025-03-26 20:46:51 +04:00
6586141134 Fixed missed re-initialization of tags list after tag form was submitted 2025-03-26 20:43:19 +04:00
d587bd2453 Added button to the tags list to toggle separation of groups 2025-03-26 20:39:30 +04:00
e2eb8a0ca7 Fixed last media box on the page not being marked as the last in a row 2025-03-26 20:03:04 +04:00
0876e5f001 Changed naming for event name constants to differentiate them with variables 2025-03-26 19:01:18 +04:00
d5ad66d902 Merge pull request #112 from koloml/release/0.4.3.1
Release: 0.4.3.1
2025-03-12 19:34:05 +04:00
cb6b5f4f9d Bumped version to 0.4.3.1 2025-03-12 19:32:27 +04:00
193941b0ac Merge pull request #111 from koloml/hotfix/tag-groups-not-applying-for-tag-editor
Fixed tag group colors & grouping not applying in Firefox
2025-03-12 19:31:52 +04:00
562274b3d8 Fixed Firefox not applying tag groups due to invalid scripts order 2025-03-12 19:27:24 +04:00
6faf5c8582 Merge pull request #105 from koloml/release/0.4.3
Release: 0.4.3
2025-03-12 18:55:40 +04:00
e591751406 Bumped version to 0.4.3 2025-03-12 18:48:50 +04:00
c9347c375d Merge pull request #110 from koloml/bugfix/group-editor-back-link
Tag Groups: Fixed incorrect path on the "Back" link for group editor view
2025-03-12 18:46:50 +04:00
68e134f2e4 Updated dependencies (#109)
* Updated `@sveltejs/kit` to 2.19.0

* Updated `svelte` to 5.23.0

* Updated `typescript` to 5.8.2

* Updated `svelte-check` to 4.1.5

* Updated `sass` to 1.85.1

* Updated `@types/chrome` to 0.0.309

* Updated `vite` to 6.1.1

* Updated `vitest` to 3.0.8

* Updated `@vitest/coverage-v8` to 3.0.8

* Updated `@types/node` to 22.13.10
2025-03-12 18:45:21 +04:00
338eb2bbb1 Fixed incorrect path on the "Back" link for group editor 2025-03-12 18:43:41 +04:00
2933cd379e Merge pull request #108 from koloml/feature/option-to-display-groups-separately
Tag Groups: Added option to display the tags captured by the group in the separate list
2025-03-10 23:58:56 +04:00
8fe2d718ff Default global group separation to turned on 2025-03-10 06:49:41 +04:00
b1ca67fc5b Implemented grouping of tags marked for separation in settings 2025-03-10 06:48:56 +04:00
37095a2f22 Fixed instances not resolving on different content scripts 2025-03-10 06:08:08 +04:00
c1ed23dee5 Added event for resolved categories, coloring from the tag wrapper 2025-03-10 06:07:36 +04:00
8c51d2d482 Store references to the tag group instead of category name 2025-03-10 03:39:43 +04:00
16b72300a9 Added option to separate the specific group of choice in tags 2025-03-10 03:24:23 +04:00
11af0f6484 Fixed category missing in the export for groups 2025-03-10 03:22:55 +04:00
4f302faf45 Added option to turn on/off separation of tags by custom category 2025-03-10 03:18:03 +04:00
bedb18a6aa Merge pull request #107 from koloml/feature/groups-suffix
Tag Groups: Support matching custom categories by suffix
2025-03-09 03:58:19 +04:00
ea791838bf Display stars in the tag editor for prefixes/suffixes 2025-03-02 18:55:51 +04:00
ff16c62e26 Added support for suffix-matching for groups 2025-03-02 18:44:53 +04:00
45cc5b0eb3 Merge pull request #106 from koloml/feature/workaronud-for-opening-in-new-tab
Fixed popup links being unusable when opened in new tab
2025-02-28 03:57:38 +04:00
a2d884c969 Added tests for the link replacement logic 2025-02-28 03:50:08 +04:00
74f987b5c9 Merge remote-tracking branch 'refs/remotes/origin/release/0.4.3' into feature/workaronud-for-opening-in-new-tab 2025-02-28 03:28:13 +04:00
f687389516 Implemented routing to be more compatible for extension popup 2025-02-28 03:18:18 +04:00
92854f4d6b Renamed hooks to TS 2025-02-28 02:40:19 +04:00
4ca9ff029b Merge pull request #104 from koloml/feature/testing-configuration-controller
Added tests for ConfigurationController class
2025-02-28 02:01:00 +04:00
70e573ddc8 Merge pull request #103 from koloml/bugfix/fixing-type-errors
Fixed type errors reported by the TypeScript
2025-02-28 02:00:30 +04:00
8e843c2b19 Fixed element types not being set up for queries 2025-02-27 00:54:00 +04:00
76e7bf1542 Fixed missing empty checks for required components 2025-02-27 00:53:44 +04:00
d5ed86fb40 Exposing timer return type globally 2025-02-27 00:50:05 +04:00
dc0a9f0aa8 Imported utils function for random string 2025-02-25 03:39:30 +04:00
09edc44af8 Added tests for configuration controller 2025-02-25 03:38:49 +04:00
a9d53afdbe Mocked storage change events for mocked storage area 2025-02-25 03:20:43 +04:00
ed263d2da4 Installed types for NodeJS for testing 2025-02-25 03:19:49 +04:00
9586d121e4 Moved storage definition to constructor for testability 2025-02-25 03:19:22 +04:00
92afd10b81 Merge pull request #92 from koloml/release/0.4.2
Release: 0.4.2
2025-02-22 19:48:40 -05:00
cf8be2589d Bumped version to 0.4.2 2025-02-23 04:30:41 +04:00
dbe164b444 Merge pull request #101 from koloml/bugfix/detect-and-init-tags-and-tags-form
Fixed custom tags categories not being reapplied to the tags form
2025-02-22 19:26:16 -05:00
5613c6fdca Wait for new element to be inserted and notify tags dropdown about it 2025-02-23 04:22:29 +04:00
dfab625999 Added event returned from booru on form submissions 2025-02-23 03:38:06 +04:00
b25758c294 Merge pull request #100 from koloml/feature/converting-content-components-to-ts
Converting all content scripts components to TypeScript, adding minor value checks and bug fixes found during conversion
2025-02-22 10:40:59 -05:00
45a8c436be Added tests for the base component class 2025-02-21 22:03:35 +04:00
7aabb683cf Making component not abstract to run tests on it 2025-02-21 22:03:20 +04:00
c4a904c046 Converting content script components to TS 2025-02-21 20:54:56 +04:00
3a010f9303 Renaming all content scripts to TS 2025-02-21 03:04:11 +04:00
878f3eb878 Merge pull request #99 from koloml/bugfix/dedupe-test-runs-on-release-prs
Fixed tests running twice on release PRs
2025-02-20 15:58:31 -05:00
b9165302e7 Removed double-testing for release PRs 2025-02-21 00:55:19 +04:00
6c02c14f5c Merge pull request #96 from koloml/feature/vitest
Added Vitest for testing the codebase
2025-02-20 15:52:23 -05:00
8b2e0722f0 Building the extension first since testing depends on tsconfig provided by SvelteKit 2025-02-21 00:49:58 +04:00
840202396e Merge branch 'bugfix/storage-helper-falsy-values' into feature/vitest 2025-02-21 00:36:27 +04:00
f6504eeecf Merge pull request #97 from koloml/bugfix/storage-helper-falsy-values
StorageHelper: Fixed helper treating falsy values as unset values
2025-02-20 15:31:59 -05:00
faaef1305a Fixed storage helper treating falsy values as non-existing values 2025-02-21 00:28:39 +04:00
91c44adbd3 Added workflow for GitHub CI 2025-02-21 00:25:31 +04:00
68b68d3efd Added initial implementation of mocks for chrome StorageArea, adding first tests for storage helper 2025-02-20 23:52:26 +04:00
769a63ccff Added alias $tests for test directory 2025-02-20 23:49:42 +04:00
9d097436a5 Added chrome types to autoload 2025-02-20 23:49:26 +04:00
6a34822f6a Incorrect include for tests, only test lib directory, import vitest globals 2025-02-20 22:27:04 +04:00
ab5a6daa07 Installing vitest with jsdom, setting up configuration 2025-02-20 22:16:28 +04:00
0bc4379491 Merge pull request #95 from koloml/feature/booru-api-ts
Converted classes related to parsing/submitting tags to TypeScript
2025-02-20 12:58:35 -05:00
3f245bb621 Converting classes to TS 2025-02-20 21:47:09 +04:00
a858888252 Renamed classes to TS 2025-02-20 21:38:10 +04:00
898c82daf5 Merge pull request #94 from koloml/bugfix/fullscreen-viewer-quality-selector-bg
Fullscreen Viewer: Fixed size selector not having backgronud color making it difficult to read with certain images
2025-02-18 15:57:53 -05:00
fbecc5ccd5 Fixed background color of image size selector to not be transparent 2025-02-18 20:41:52 +04:00
48767d47f9 Merge pull request #93 from koloml/feature/migrate-svelte-to-v5-syntax
Migration to Svelte 5 syntax
2025-02-17 18:25:54 -05:00
c859c5b038 Refactored tag editor element 2025-02-18 02:09:41 +04:00
12e9054f59 Refactoring form components 2025-02-18 02:00:48 +04:00
23638b50ae Refactored profiles related routes & components 2025-02-18 01:39:11 +04:00
b31541ddf0 Fixed error being displayed incorrectly 2025-02-18 01:29:35 +04:00
62857d44c2 Making sure checkbox input is clickable without following the link 2025-02-18 01:17:43 +04:00
dff77391b0 Making sure radio input is clickable without following the link 2025-02-18 01:14:44 +04:00
5a07fa3d60 Refactored menu & menu items
Once again, breaking change for a lot of components, would be tackled separately
2025-02-17 04:51:15 +04:00
49986ec497 Refactored groups-related routes and components 2025-02-17 04:30:45 +04:00
9f08eb2171 Refactored MenuItem
This change will break a lot of menu items with `on:click`. These cases would be resolved separately.
2025-02-17 03:46:29 +04:00
927fa21d95 Fixed the typo in the back link 2025-02-17 03:44:48 +04:00
620af3e622 Refactoring storage viewer and its route 2025-02-17 03:06:39 +04:00
efdd9487ad Reformatting all svelte templates to use new rules 2025-02-17 02:07:55 +04:00
762652f795 Added specific rules for the svelte components formatting 2025-02-17 02:07:40 +04:00
67d41ecf03 Migration to Svelte 5 (using the migration script) 2025-02-16 18:02:03 +04:00
5975584905 Merge pull request #91 from koloml/feature/ts-storages
Converting storages to TypeScript, minor restructuring of the storage folder
2025-02-16 08:38:18 -05:00
566211d046 Merge pull request #90 from koloml/bugfix/storage-inspector-null
Debugging: Fixed null value being displayed incorrectly in storage inspector
2025-02-16 08:33:48 -05:00
16f60ef9b5 Moving and renaming entities storages to entities dir 2025-02-16 16:19:25 +04:00
09e912ffff Moving all preferences-related stores to preferences dir 2025-02-16 16:12:48 +04:00
8a3ef6b049 Converting tag groups store to TS 2025-02-16 16:09:16 +04:00
5392a17db5 Renaming tag groups store to TS 2025-02-16 16:08:34 +04:00
0b4ff96fc1 Adding type for suggestion position, converting store to TS 2025-02-16 16:07:52 +04:00
2104922951 Renaming search preferences store to TS 2025-02-16 16:05:02 +04:00
f27157a0c5 Converting misc preferences store to TS 2025-02-16 16:04:14 +04:00
729d0281ed Converting profiles store to TS 2025-02-16 16:03:24 +04:00
461fce5c05 Renaming profiles store to TS 2025-02-16 16:01:31 +04:00
d1a69437d1 Converting debug store to TS 2025-02-16 15:59:13 +04:00
062b04ca8a Renamed debug storage to TS 2025-02-16 15:50:37 +04:00
4d3023a641 Display nulls and undefined values in the storage inspector properly 2025-02-16 15:47:41 +04:00
109 changed files with 5965 additions and 2153 deletions

View File

@@ -403,6 +403,365 @@ ij_typescript_while_brace_force = never
ij_typescript_while_on_new_line = false
ij_typescript_wrap_comments = false
[*.svelte]
indent_size = 2
tab_width = 2
ij_continuation_indent_size = 2
ij_javascript_align_imports = false
ij_javascript_align_multiline_array_initializer_expression = false
ij_javascript_align_multiline_binary_operation = false
ij_javascript_align_multiline_chained_methods = false
ij_javascript_align_multiline_extends_list = false
ij_javascript_align_multiline_for = true
ij_javascript_align_multiline_parameters = true
ij_javascript_align_multiline_parameters_in_calls = false
ij_javascript_align_multiline_ternary_operation = false
ij_javascript_align_object_properties = 0
ij_javascript_align_union_types = false
ij_javascript_align_var_statements = 0
ij_javascript_array_initializer_new_line_after_left_brace = false
ij_javascript_array_initializer_right_brace_on_new_line = false
ij_javascript_array_initializer_wrap = off
ij_javascript_assignment_wrap = off
ij_javascript_binary_operation_sign_on_next_line = false
ij_javascript_binary_operation_wrap = off
ij_javascript_blacklist_imports = rxjs/Rx, node_modules/**, **/node_modules/**, @angular/material, @angular/material/typings/**
ij_javascript_blank_lines_after_imports = 1
ij_javascript_blank_lines_around_class = 1
ij_javascript_blank_lines_around_field = 0
ij_javascript_blank_lines_around_function = 1
ij_javascript_blank_lines_around_method = 1
ij_javascript_block_brace_style = end_of_line
ij_javascript_block_comment_add_space = false
ij_javascript_block_comment_at_first_column = true
ij_javascript_call_parameters_new_line_after_left_paren = false
ij_javascript_call_parameters_right_paren_on_new_line = false
ij_javascript_call_parameters_wrap = off
ij_javascript_catch_on_new_line = false
ij_javascript_chained_call_dot_on_new_line = true
ij_javascript_class_brace_style = end_of_line
ij_javascript_comma_on_new_line = false
ij_javascript_do_while_brace_force = never
ij_javascript_else_on_new_line = false
ij_javascript_enforce_trailing_comma = keep
ij_javascript_extends_keyword_wrap = off
ij_javascript_extends_list_wrap = off
ij_javascript_field_prefix = _
ij_javascript_file_name_style = relaxed
ij_javascript_finally_on_new_line = false
ij_javascript_for_brace_force = never
ij_javascript_for_statement_new_line_after_left_paren = false
ij_javascript_for_statement_right_paren_on_new_line = false
ij_javascript_for_statement_wrap = off
ij_javascript_force_quote_style = false
ij_javascript_force_semicolon_style = false
ij_javascript_function_expression_brace_style = end_of_line
ij_javascript_if_brace_force = never
ij_javascript_import_merge_members = global
ij_javascript_import_prefer_absolute_path = global
ij_javascript_import_sort_members = true
ij_javascript_import_sort_module_name = false
ij_javascript_import_use_node_resolution = true
ij_javascript_imports_wrap = on_every_item
ij_javascript_indent_case_from_switch = true
ij_javascript_indent_chained_calls = true
ij_javascript_indent_package_children = 0
ij_javascript_jsx_attribute_value = braces
ij_javascript_keep_blank_lines_in_code = 2
ij_javascript_keep_first_column_comment = true
ij_javascript_keep_indents_on_empty_lines = false
ij_javascript_keep_line_breaks = true
ij_javascript_keep_simple_blocks_in_one_line = false
ij_javascript_keep_simple_methods_in_one_line = false
ij_javascript_line_comment_add_space = true
ij_javascript_line_comment_at_first_column = false
ij_javascript_method_brace_style = end_of_line
ij_javascript_method_call_chain_wrap = off
ij_javascript_method_parameters_new_line_after_left_paren = false
ij_javascript_method_parameters_right_paren_on_new_line = false
ij_javascript_method_parameters_wrap = off
ij_javascript_object_literal_wrap = on_every_item
ij_javascript_object_types_wrap = on_every_item
ij_javascript_parentheses_expression_new_line_after_left_paren = false
ij_javascript_parentheses_expression_right_paren_on_new_line = false
ij_javascript_place_assignment_sign_on_next_line = false
ij_javascript_prefer_as_type_cast = false
ij_javascript_prefer_explicit_types_function_expression_returns = false
ij_javascript_prefer_explicit_types_function_returns = false
ij_javascript_prefer_explicit_types_vars_fields = false
ij_javascript_prefer_parameters_wrap = false
ij_javascript_property_prefix =
ij_javascript_reformat_c_style_comments = false
ij_javascript_space_after_colon = true
ij_javascript_space_after_comma = true
ij_javascript_space_after_dots_in_rest_parameter = false
ij_javascript_space_after_generator_mult = true
ij_javascript_space_after_property_colon = true
ij_javascript_space_after_quest = true
ij_javascript_space_after_type_colon = true
ij_javascript_space_after_unary_not = false
ij_javascript_space_before_async_arrow_lparen = true
ij_javascript_space_before_catch_keyword = true
ij_javascript_space_before_catch_left_brace = true
ij_javascript_space_before_catch_parentheses = true
ij_javascript_space_before_class_lbrace = true
ij_javascript_space_before_class_left_brace = true
ij_javascript_space_before_colon = true
ij_javascript_space_before_comma = false
ij_javascript_space_before_do_left_brace = true
ij_javascript_space_before_else_keyword = true
ij_javascript_space_before_else_left_brace = true
ij_javascript_space_before_finally_keyword = true
ij_javascript_space_before_finally_left_brace = true
ij_javascript_space_before_for_left_brace = true
ij_javascript_space_before_for_parentheses = true
ij_javascript_space_before_for_semicolon = false
ij_javascript_space_before_function_left_parenth = true
ij_javascript_space_before_generator_mult = false
ij_javascript_space_before_if_left_brace = true
ij_javascript_space_before_if_parentheses = true
ij_javascript_space_before_method_call_parentheses = false
ij_javascript_space_before_method_left_brace = true
ij_javascript_space_before_method_parentheses = false
ij_javascript_space_before_property_colon = false
ij_javascript_space_before_quest = true
ij_javascript_space_before_switch_left_brace = true
ij_javascript_space_before_switch_parentheses = true
ij_javascript_space_before_try_left_brace = true
ij_javascript_space_before_type_colon = false
ij_javascript_space_before_unary_not = false
ij_javascript_space_before_while_keyword = true
ij_javascript_space_before_while_left_brace = true
ij_javascript_space_before_while_parentheses = true
ij_javascript_spaces_around_additive_operators = true
ij_javascript_spaces_around_arrow_function_operator = true
ij_javascript_spaces_around_assignment_operators = true
ij_javascript_spaces_around_bitwise_operators = true
ij_javascript_spaces_around_equality_operators = true
ij_javascript_spaces_around_logical_operators = true
ij_javascript_spaces_around_multiplicative_operators = true
ij_javascript_spaces_around_relational_operators = true
ij_javascript_spaces_around_shift_operators = true
ij_javascript_spaces_around_unary_operator = false
ij_javascript_spaces_within_array_initializer_brackets = false
ij_javascript_spaces_within_brackets = false
ij_javascript_spaces_within_catch_parentheses = false
ij_javascript_spaces_within_for_parentheses = false
ij_javascript_spaces_within_if_parentheses = false
ij_javascript_spaces_within_imports = true
ij_javascript_spaces_within_interpolation_expressions = false
ij_javascript_spaces_within_method_call_parentheses = false
ij_javascript_spaces_within_method_parentheses = false
ij_javascript_spaces_within_object_literal_braces = true
ij_javascript_spaces_within_object_type_braces = true
ij_javascript_spaces_within_parentheses = false
ij_javascript_spaces_within_switch_parentheses = false
ij_javascript_spaces_within_type_assertion = false
ij_javascript_spaces_within_union_types = true
ij_javascript_spaces_within_while_parentheses = false
ij_javascript_special_else_if_treatment = true
ij_javascript_ternary_operation_signs_on_next_line = false
ij_javascript_ternary_operation_wrap = off
ij_javascript_union_types_wrap = on_every_item
ij_javascript_use_chained_calls_group_indents = false
ij_javascript_use_double_quotes = true
ij_javascript_use_explicit_js_extension = never
ij_javascript_use_import_type = auto
ij_javascript_use_path_mapping = always
ij_javascript_use_public_modifier = false
ij_javascript_use_semicolon_after_statement = true
ij_javascript_var_declaration_wrap = normal
ij_javascript_while_brace_force = never
ij_javascript_while_on_new_line = false
ij_javascript_wrap_comments = false
ij_scss_align_closing_brace_with_properties = false
ij_scss_blank_lines_around_nested_selector = 1
ij_scss_blank_lines_between_blocks = 1
ij_scss_block_comment_add_space = false
ij_scss_brace_placement = 0
ij_scss_enforce_quotes_on_format = false
ij_scss_hex_color_long_format = false
ij_scss_hex_color_lower_case = false
ij_scss_hex_color_short_format = false
ij_scss_hex_color_upper_case = false
ij_scss_keep_blank_lines_in_code = 2
ij_scss_keep_indents_on_empty_lines = false
ij_scss_keep_single_line_blocks = false
ij_scss_line_comment_add_space = false
ij_scss_line_comment_at_first_column = false
ij_scss_properties_order = font, font-family, font-size, font-weight, font-style, font-variant, font-size-adjust, font-stretch, line-height, position, z-index, top, right, bottom, left, display, visibility, float, clear, overflow, overflow-x, overflow-y, clip, zoom, align-content, align-items, align-self, flex, flex-flow, flex-basis, flex-direction, flex-grow, flex-shrink, flex-wrap, justify-content, order, box-sizing, width, min-width, max-width, height, min-height, max-height, margin, margin-top, margin-right, margin-bottom, margin-left, padding, padding-top, padding-right, padding-bottom, padding-left, table-layout, empty-cells, caption-side, border-spacing, border-collapse, list-style, list-style-position, list-style-type, list-style-image, content, quotes, counter-reset, counter-increment, resize, cursor, user-select, nav-index, nav-up, nav-right, nav-down, nav-left, transition, transition-delay, transition-timing-function, transition-duration, transition-property, transform, transform-origin, animation, animation-name, animation-duration, animation-play-state, animation-timing-function, animation-delay, animation-iteration-count, animation-direction, text-align, text-align-last, vertical-align, white-space, text-decoration, text-emphasis, text-emphasis-color, text-emphasis-style, text-emphasis-position, text-indent, text-justify, letter-spacing, word-spacing, text-outline, text-transform, text-wrap, text-overflow, text-overflow-ellipsis, text-overflow-mode, word-wrap, word-break, tab-size, hyphens, pointer-events, opacity, color, border, border-width, border-style, border-color, border-top, border-top-width, border-top-style, border-top-color, border-right, border-right-width, border-right-style, border-right-color, border-bottom, border-bottom-width, border-bottom-style, border-bottom-color, border-left, border-left-width, border-left-style, border-left-color, border-radius, border-top-left-radius, border-top-right-radius, border-bottom-right-radius, border-bottom-left-radius, border-image, border-image-source, border-image-slice, border-image-width, border-image-outset, border-image-repeat, outline, outline-width, outline-style, outline-color, outline-offset, background, background-color, background-image, background-repeat, background-attachment, background-position, background-position-x, background-position-y, background-clip, background-origin, background-size, box-decoration-break, box-shadow, text-shadow
ij_scss_space_after_colon = true
ij_scss_space_before_opening_brace = true
ij_scss_use_double_quotes = true
ij_scss_value_alignment = 0
ij_typescript_align_imports = false
ij_typescript_align_multiline_array_initializer_expression = false
ij_typescript_align_multiline_binary_operation = false
ij_typescript_align_multiline_chained_methods = false
ij_typescript_align_multiline_extends_list = false
ij_typescript_align_multiline_for = true
ij_typescript_align_multiline_parameters = true
ij_typescript_align_multiline_parameters_in_calls = false
ij_typescript_align_multiline_ternary_operation = false
ij_typescript_align_object_properties = 0
ij_typescript_align_union_types = false
ij_typescript_align_var_statements = 0
ij_typescript_array_initializer_new_line_after_left_brace = false
ij_typescript_array_initializer_right_brace_on_new_line = false
ij_typescript_array_initializer_wrap = off
ij_typescript_assignment_wrap = off
ij_typescript_binary_operation_sign_on_next_line = false
ij_typescript_binary_operation_wrap = off
ij_typescript_blacklist_imports = rxjs/Rx, node_modules/**, **/node_modules/**, @angular/material, @angular/material/typings/**
ij_typescript_blank_lines_after_imports = 1
ij_typescript_blank_lines_around_class = 1
ij_typescript_blank_lines_around_field = 0
ij_typescript_blank_lines_around_function = 1
ij_typescript_blank_lines_around_method = 1
ij_typescript_block_brace_style = end_of_line
ij_typescript_block_comment_add_space = false
ij_typescript_block_comment_at_first_column = true
ij_typescript_call_parameters_new_line_after_left_paren = false
ij_typescript_call_parameters_right_paren_on_new_line = false
ij_typescript_call_parameters_wrap = off
ij_typescript_catch_on_new_line = false
ij_typescript_chained_call_dot_on_new_line = true
ij_typescript_class_brace_style = end_of_line
ij_typescript_comma_on_new_line = false
ij_typescript_do_while_brace_force = never
ij_typescript_else_on_new_line = false
ij_typescript_enforce_trailing_comma = keep
ij_typescript_extends_keyword_wrap = off
ij_typescript_extends_list_wrap = off
ij_typescript_field_prefix = _
ij_typescript_file_name_style = relaxed
ij_typescript_finally_on_new_line = false
ij_typescript_for_brace_force = never
ij_typescript_for_statement_new_line_after_left_paren = false
ij_typescript_for_statement_right_paren_on_new_line = false
ij_typescript_for_statement_wrap = off
ij_typescript_force_quote_style = false
ij_typescript_force_semicolon_style = false
ij_typescript_function_expression_brace_style = end_of_line
ij_typescript_if_brace_force = never
ij_typescript_import_merge_members = global
ij_typescript_import_prefer_absolute_path = global
ij_typescript_import_sort_members = true
ij_typescript_import_sort_module_name = false
ij_typescript_import_use_node_resolution = true
ij_typescript_imports_wrap = on_every_item
ij_typescript_indent_case_from_switch = true
ij_typescript_indent_chained_calls = true
ij_typescript_indent_package_children = 0
ij_typescript_jsx_attribute_value = braces
ij_typescript_keep_blank_lines_in_code = 2
ij_typescript_keep_first_column_comment = true
ij_typescript_keep_indents_on_empty_lines = false
ij_typescript_keep_line_breaks = true
ij_typescript_keep_simple_blocks_in_one_line = false
ij_typescript_keep_simple_methods_in_one_line = false
ij_typescript_line_comment_add_space = true
ij_typescript_line_comment_at_first_column = false
ij_typescript_method_brace_style = end_of_line
ij_typescript_method_call_chain_wrap = off
ij_typescript_method_parameters_new_line_after_left_paren = false
ij_typescript_method_parameters_right_paren_on_new_line = false
ij_typescript_method_parameters_wrap = off
ij_typescript_object_literal_wrap = on_every_item
ij_typescript_object_types_wrap = on_every_item
ij_typescript_parentheses_expression_new_line_after_left_paren = false
ij_typescript_parentheses_expression_right_paren_on_new_line = false
ij_typescript_place_assignment_sign_on_next_line = false
ij_typescript_prefer_as_type_cast = false
ij_typescript_prefer_explicit_types_function_expression_returns = false
ij_typescript_prefer_explicit_types_function_returns = false
ij_typescript_prefer_explicit_types_vars_fields = false
ij_typescript_prefer_parameters_wrap = false
ij_typescript_property_prefix =
ij_typescript_reformat_c_style_comments = false
ij_typescript_space_after_colon = true
ij_typescript_space_after_comma = true
ij_typescript_space_after_dots_in_rest_parameter = false
ij_typescript_space_after_generator_mult = true
ij_typescript_space_after_property_colon = true
ij_typescript_space_after_quest = true
ij_typescript_space_after_type_colon = true
ij_typescript_space_after_unary_not = false
ij_typescript_space_before_async_arrow_lparen = true
ij_typescript_space_before_catch_keyword = true
ij_typescript_space_before_catch_left_brace = true
ij_typescript_space_before_catch_parentheses = true
ij_typescript_space_before_class_lbrace = true
ij_typescript_space_before_class_left_brace = true
ij_typescript_space_before_colon = true
ij_typescript_space_before_comma = false
ij_typescript_space_before_do_left_brace = true
ij_typescript_space_before_else_keyword = true
ij_typescript_space_before_else_left_brace = true
ij_typescript_space_before_finally_keyword = true
ij_typescript_space_before_finally_left_brace = true
ij_typescript_space_before_for_left_brace = true
ij_typescript_space_before_for_parentheses = true
ij_typescript_space_before_for_semicolon = false
ij_typescript_space_before_function_left_parenth = true
ij_typescript_space_before_generator_mult = false
ij_typescript_space_before_if_left_brace = true
ij_typescript_space_before_if_parentheses = true
ij_typescript_space_before_method_call_parentheses = false
ij_typescript_space_before_method_left_brace = true
ij_typescript_space_before_method_parentheses = false
ij_typescript_space_before_property_colon = false
ij_typescript_space_before_quest = true
ij_typescript_space_before_switch_left_brace = true
ij_typescript_space_before_switch_parentheses = true
ij_typescript_space_before_try_left_brace = true
ij_typescript_space_before_type_colon = false
ij_typescript_space_before_unary_not = false
ij_typescript_space_before_while_keyword = true
ij_typescript_space_before_while_left_brace = true
ij_typescript_space_before_while_parentheses = true
ij_typescript_spaces_around_additive_operators = true
ij_typescript_spaces_around_arrow_function_operator = true
ij_typescript_spaces_around_assignment_operators = true
ij_typescript_spaces_around_bitwise_operators = true
ij_typescript_spaces_around_equality_operators = true
ij_typescript_spaces_around_logical_operators = true
ij_typescript_spaces_around_multiplicative_operators = true
ij_typescript_spaces_around_relational_operators = true
ij_typescript_spaces_around_shift_operators = true
ij_typescript_spaces_around_unary_operator = false
ij_typescript_spaces_within_array_initializer_brackets = false
ij_typescript_spaces_within_brackets = false
ij_typescript_spaces_within_catch_parentheses = false
ij_typescript_spaces_within_for_parentheses = false
ij_typescript_spaces_within_if_parentheses = false
ij_typescript_spaces_within_imports = true
ij_typescript_spaces_within_interpolation_expressions = false
ij_typescript_spaces_within_method_call_parentheses = false
ij_typescript_spaces_within_method_parentheses = false
ij_typescript_spaces_within_object_literal_braces = true
ij_typescript_spaces_within_object_type_braces = true
ij_typescript_spaces_within_parentheses = false
ij_typescript_spaces_within_switch_parentheses = false
ij_typescript_spaces_within_type_assertion = false
ij_typescript_spaces_within_union_types = true
ij_typescript_spaces_within_while_parentheses = false
ij_typescript_special_else_if_treatment = true
ij_typescript_ternary_operation_signs_on_next_line = false
ij_typescript_ternary_operation_wrap = off
ij_typescript_union_types_wrap = on_every_item
ij_typescript_use_chained_calls_group_indents = false
ij_typescript_use_double_quotes = true
ij_typescript_use_explicit_js_extension = never
ij_typescript_use_import_type = auto
ij_typescript_use_path_mapping = always
ij_typescript_use_public_modifier = false
ij_typescript_use_semicolon_after_statement = true
ij_typescript_var_declaration_wrap = normal
ij_typescript_while_brace_force = never
ij_typescript_while_on_new_line = false
ij_typescript_wrap_comments = false
[{*.htm,*.html,*.sht,*.shtm,*.shtml}]
ij_html_add_new_line_before_tags = body, div, p, form, h1, h2, h3
ij_html_align_attributes = true

31
.github/workflows/build-and-tests.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: Testing
on:
push:
branches:
- master
pull_request:
branches:
- master
- 'release/**'
jobs:
run-tests:
name: 'Run Unit Tests'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install npm dependencies
run: npm ci
- name: Building the extension
run: npm run build
- name: Running unit tests
run: npm run test

3
.gitignore vendored
View File

@@ -2,10 +2,11 @@
.DS_Store
node_modules
/build
/coverage
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
vite.config.ts.timestamp-*

View File

@@ -1,7 +1,7 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
"version": "0.4.1",
"version": "0.4.5",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city"
@@ -27,7 +27,7 @@
"*://*.furbooru.org/galleries/*"
],
"js": [
"src/content/listing.js"
"src/content/listing.ts"
],
"css": [
"src/styles/content/listing.scss"
@@ -38,12 +38,20 @@
"*://*.furbooru.org/*"
],
"js": [
"src/content/header.js"
"src/content/header.ts"
],
"css": [
"src/styles/content/header.scss"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.ts"
]
},
{
"matches": [
"*://*.furbooru.org/images?*",
@@ -59,15 +67,7 @@
"*://*.furbooru.org/filters/*"
],
"js": [
"src/content/tags.js"
]
},
{
"matches": [
"*://*.furbooru.org/images/*"
],
"js": [
"src/content/tags-editor.js"
"src/content/tags.ts"
]
}
],

2497
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,31 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.4.1",
"version": "0.4.5",
"private": true,
"scripts": {
"build": "npm run build:popup && npm run build:extension",
"build:popup": "vite build",
"build:extension": "node build-extension.js",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run --coverage",
"test:watch": "vitest watch --coverage"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.2",
"@sveltejs/kit": "^2.21.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.304",
"@types/chrome": "^0.0.326",
"@types/node": "^22.15.29",
"@vitest/coverage-v8": "^3.2.0",
"cheerio": "^1.0.0",
"sass": "^1.85.0",
"svelte": "^5.20.1",
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"vite": "^6.1.0"
"jsdom": "^26.1.0",
"sass": "^1.89.1",
"svelte": "^5.33.14",
"svelte-check": "^4.2.1",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vitest": "^3.2.0"
},
"type": "module",
"dependencies": {

3
src/app.d.ts vendored
View File

@@ -4,6 +4,9 @@ import MaintenanceProfile from "$entities/MaintenanceProfile";
import type TagGroup from "$entities/TagGroup";
declare global {
// Helper type to not deal with differences between the setTimeout of @types/node and usual web browser's type.
type Timeout = ReturnType<typeof setTimeout>;
namespace App {
// interface Error {}
// interface Locals {}

View File

@@ -1,93 +1,123 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { storagesCollection } from "$stores/debug";
import { goto } from "$app/navigation";
import { findDeepObject } from "$lib/utils";
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { storagesCollection } from "$stores/debug";
import { goto } from "$app/navigation";
import { findDeepObject } from "$lib/utils";
/** @type {string} */
export let storage;
interface StorageViewerProps {
storage: string;
path: string[];
}
/** @type {string[]} */
export let path;
type BreadcrumbsArray = [string, string][];
/** @type {Object|null} */
let targetStorage = null;
/** @type {[string, string][]} */
let breadcrumbs = [];
/** @type {Object<string, any>|null} */
let targetObject = null;
let targetPathString = '';
let { storage, path }: StorageViewerProps = $props();
$: {
/** @type {[string, string][]} */
const builtBreadcrumbs = [];
let breadcrumbs = $derived.by<BreadcrumbsArray>(() => {
return path.reduce<BreadcrumbsArray>((resultCrumbs, entry) => {
let entryPath = entry;
breadcrumbs = path.reduce((resultCrumbs, entry) => {
let entryPath = entry;
if (resultCrumbs.length) {
entryPath = resultCrumbs[resultCrumbs.length - 1][1] + "/" + entryPath;
}
if (resultCrumbs.length) {
entryPath = resultCrumbs[resultCrumbs.length - 1][1] + "/" + entryPath;
}
resultCrumbs.push([entry, entryPath]);
resultCrumbs.push([entry, entryPath]);
return resultCrumbs;
}, [])
});
return resultCrumbs;
}, builtBreadcrumbs);
let targetStorage = $derived.by<object|null>(() => {
return $storagesCollection[storage];
});
targetPathString = path.join("/");
let targetObject = $derived.by<Record<string, any> | null>(() => {
return targetStorage
? findDeepObject(targetStorage, path)
: null;
});
if (targetPathString.length) {
targetPathString += "/";
}
let targetPathString = $derived.by<string>(() => {
let pathString = path.join("/");
if (pathString.length) {
pathString += "/";
}
$: {
targetStorage = $storagesCollection[storage];
return pathString;
});
if (!targetStorage) {
goto("/preferences/debug/storage");
}
$effect(() => {
if (!targetStorage) {
goto("/preferences/debug/storage");
}
});
/**
* Helper function to resolve type, including the null.
* @param value Value to resolve type from.
* @return Type of the value, including "null" for null.
*/
function resolveType(value: unknown): string {
let typeName: string = typeof value;
if (typeName === 'object' && value === null) {
typeName = 'null';
}
$: {
targetObject = targetStorage
? findDeepObject(targetStorage, path)
: null;
return typeName;
}
/**
* Helper function to resolve value, including values like null or undefined.
* @param value Value to resolve.
* @return String representation of the value.
*/
function resolveValue(value: unknown): string {
if (value === null) {
return "null";
}
if (value === undefined) {
return "undefined";
}
return value?.toString() ?? '';
}
</script>
<Menu>
<MenuItem href="/preferences/debug/storage" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/preferences/debug/storage" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<p class="path">
<span>/ <a href="/preferences/debug/storage/{storage}">{storage}</a></span>
{#each breadcrumbs as [name, entryPath]}
<span>/ <a href="/preferences/debug/storage/{storage}/{entryPath}/">{name}</a></span>
{/each}
<span>/ <a href="/preferences/debug/storage/{storage}">{storage}</a></span>
{#each breadcrumbs as [name, entryPath]}
<span>/ <a href="/preferences/debug/storage/{storage}/{entryPath}/">{name}</a></span>
{/each}
</p>
{#if targetObject}
<Menu>
<hr>
{#each Object.entries(targetObject) as [key, value]}
{#if targetObject[key] && typeof targetObject[key] === 'object'}
<MenuItem href="/preferences/debug/storage/{storage}/{targetPathString}{key}">
{key}: Object
</MenuItem>
{:else}
<MenuItem>
{key}: {typeof targetObject[key]} = {targetObject[key]}
</MenuItem>
{/if}
{/each}
</Menu>
<Menu>
<hr>
{#each Object.entries(targetObject) as [key, _]}
{#if targetObject[key] && typeof targetObject[key] === 'object'}
<MenuItem href="/preferences/debug/storage/{storage}/{targetPathString}{key}">
{key}: Object
</MenuItem>
{:else}
<MenuItem>
{key}: {resolveType(targetObject[key])} = {resolveValue(targetObject[key])}
</MenuItem>
{/if}
{/each}
</Menu>
{/if}
<style lang="scss">
.path {
display: flex;
flex-wrap: wrap;
column-gap: .5em;
}
.path {
display: flex;
flex-wrap: wrap;
column-gap: .5em;
}
</style>

View File

@@ -1,59 +1,73 @@
<script>
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
<script lang="ts">
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
import type TagGroup from "$entities/TagGroup";
/**
* @type {import('$entities/TagGroup').default}
*/
export let group;
interface GroupViewProps {
group: TagGroup;
}
let sortedTagsList, sortedPrefixes;
let { group }: GroupViewProps = $props();
let sortedTagsList = $derived<string[]>(group.settings.tags.sort((a, b) => a.localeCompare(b))),
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b))),
sortedSuffixes = $derived<string[]>(group.settings.suffixes.sort((a, b) => a.localeCompare(b)));
$: sortedTagsList = group.settings.tags.sort((a, b) => a.localeCompare(b));
$: sortedPrefixes = group.settings.prefixes.sort((a, b) => a.localeCompare(b));
</script>
<div class="block">
<strong>Group Name:</strong>
<div>{group.settings.name}</div>
<strong>Group Name:</strong>
<div>{group.settings.name}</div>
</div>
{#if sortedTagsList.length}
<div class="block">
<strong>Tags:</strong>
<TagsColorContainer targetCategory="{group.settings.category}">
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
</TagsColorContainer>
</div>
<div class="block">
<strong>Tags:</strong>
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
</TagsColorContainer>
</div>
{/if}
{#if sortedPrefixes.length}
<div class="block">
<strong>Prefixes:</strong>
<TagsColorContainer targetCategory="{group.settings.category}">
<div class="tags-list">
{#each sortedPrefixes as prefixName}
<span class="tag">{prefixName}*</span>
{/each}
</div>
</TagsColorContainer>
</div>
<div class="block">
<strong>Prefixes:</strong>
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedPrefixes as prefixName}
<span class="tag">{prefixName}*</span>
{/each}
</div>
</TagsColorContainer>
</div>
{/if}
{#if sortedSuffixes.length}
<div class="block">
<strong>Suffixes:</strong>
<TagsColorContainer targetCategory={group.settings.category}>
<div class="tags-list">
{#each sortedSuffixes as suffixName}
<span class="tag">*{suffixName}</span>
{/each}
</div>
</TagsColorContainer>
</div>
{/if}
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
strong {
display: block;
margin-bottom: .25em;
}
}
</style>

View File

@@ -1,36 +1,41 @@
<script>
/** @type {import('$entities/MaintenanceProfile').default} */
export let profile;
<script lang="ts">
import type MaintenanceProfile from "$entities/MaintenanceProfile";
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
interface ProfileViewProps {
profile: MaintenanceProfile;
}
let { profile }: ProfileViewProps = $props();
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
</script>
<div class="block">
<strong>Profile:</strong>
<div>{profile.settings.name}</div>
<strong>Profile:</strong>
<div>{profile.settings.name}</div>
</div>
<div class="block">
<strong>Tags:</strong>
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
<strong>Tags:</strong>
<div class="tags-list">
{#each sortedTagsList as tagName}
<span class="tag">{tagName}</span>
{/each}
</div>
</div>
<style lang="scss">
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.block + .block {
margin-top: .5em;
.block + .block {
margin-top: .5em;
strong {
display: block;
margin-bottom: .25em;
}
strong {
display: block;
margin-bottom: .25em;
}
}
</style>

View File

@@ -1,32 +1,32 @@
<script>
import { version } from "$app/environment";
import { version } from "$app/environment";
</script>
<footer>
<a href="https://github.com/koloml/furbooru-tagging-assistant/releases/tag/{version}" target="_blank">
v{version}
</a>
<span>, made with ♥ by KoloMl.</span>
<a href="https://github.com/koloml/furbooru-tagging-assistant/releases/tag/{version}" target="_blank">
v{version}
</a>
<span>, made with ♥ by KoloMl.</span>
</footer>
<style lang="scss">
@use '$styles/colors';
@use '$styles/colors';
footer {
display: flex;
width: 100%;
background: colors.$footer;
color: colors.$footer-text;
padding: 0 24px;
font-size: 12px;
line-height: 36px;
footer {
display: flex;
width: 100%;
background: colors.$footer;
color: colors.$footer-text;
padding: 0 24px;
font-size: 12px;
line-height: 36px;
a {
color: inherit;
a {
color: inherit;
&:hover {
text-decoration: underline;
}
}
&:hover {
text-decoration: underline;
}
}
}
</style>

View File

@@ -1,28 +1,29 @@
<header>
<a href="/">Furbooru Tagging Assistant</a>
<a href="/">Furbooru Tagging Assistant</a>
</header>
<style lang="scss">
@use "$styles/colors";
@use "$styles/colors";
header {
background: colors.$header;
padding: 0 24px;
display: flex;
position: sticky;
top: 0;
left: 0;
right: 0;
header {
background: colors.$header;
padding: 0 24px;
display: flex;
position: sticky;
top: 0;
left: 0;
right: 0;
z-index: 10;
a {
color: colors.$text;
line-height: 36px;
padding: 0 12px;
margin-left: -12px;
a {
color: colors.$text;
line-height: 36px;
padding: 0 12px;
margin-left: -12px;
&:hover {
background: colors.$header-hover-background;
}
}
&:hover {
background: colors.$header-hover-background;
}
}
}
</style>

View File

@@ -1,62 +1,68 @@
<script>
/** @type {string} */
export let targetCategory = '';
<script lang="ts">
import type { Snippet } from "svelte";
interface TagColorContainerProps {
targetCategory?: string;
children?: Snippet;
}
let { targetCategory = '', children }: TagColorContainerProps = $props();
</script>
<div class="tag-color-container tag-color-container--{targetCategory || 'default'}">
<slot></slot>
{@render children?.()}
</div>
<style lang="scss">
@use '$styles/colors';
@use '$styles/colors';
.tag-color-container:is(.tag-color-container--rating) :global(.tag) {
background-color: colors.$tag-rating-background;
color: colors.$tag-rating-text;
}
.tag-color-container:is(:global(.tag-color-container--rating)) :global(.tag) {
background-color: colors.$tag-rating-background;
color: colors.$tag-rating-text;
}
.tag-color-container:is(.tag-color-container--spoiler) :global(.tag) {
background-color: colors.$tag-spoiler-background;
color: colors.$tag-spoiler-text;
}
.tag-color-container:is(:global(.tag-color-container--spoiler)) :global(.tag) {
background-color: colors.$tag-spoiler-background;
color: colors.$tag-spoiler-text;
}
.tag-color-container:is(.tag-color-container--origin) :global(.tag) {
background-color: colors.$tag-origin-background;
color: colors.$tag-origin-text;
}
.tag-color-container:is(:global(.tag-color-container--origin)) :global(.tag) {
background-color: colors.$tag-origin-background;
color: colors.$tag-origin-text;
}
.tag-color-container:is(.tag-color-container--oc) :global(.tag) {
background-color: colors.$tag-oc-background;
color: colors.$tag-oc-text;
}
.tag-color-container:is(:global(.tag-color-container--oc)) :global(.tag) {
background-color: colors.$tag-oc-background;
color: colors.$tag-oc-text;
}
.tag-color-container:is(.tag-color-container--error) :global(.tag) {
background-color: colors.$tag-error-background;
color: colors.$tag-error-text;
}
.tag-color-container:is(:global(.tag-color-container--error)) :global(.tag) {
background-color: colors.$tag-error-background;
color: colors.$tag-error-text;
}
.tag-color-container:is(.tag-color-container--character) :global(.tag) {
background-color: colors.$tag-character-background;
color: colors.$tag-character-text;
}
.tag-color-container:is(:global(.tag-color-container--character)) :global(.tag) {
background-color: colors.$tag-character-background;
color: colors.$tag-character-text;
}
.tag-color-container:is(.tag-color-container--content-official) :global(.tag) {
background-color: colors.$tag-content-official-background;
color: colors.$tag-content-official-text;
}
.tag-color-container:is(:global(.tag-color-container--content-official)) :global(.tag) {
background-color: colors.$tag-content-official-background;
color: colors.$tag-content-official-text;
}
.tag-color-container:is(.tag-color-container--content-fanmade) :global(.tag) {
background-color: colors.$tag-content-fanmade-background;
color: colors.$tag-content-fanmade-text;
}
.tag-color-container:is(:global(.tag-color-container--content-fanmade)) :global(.tag) {
background-color: colors.$tag-content-fanmade-background;
color: colors.$tag-content-fanmade-text;
}
.tag-color-container:is(.tag-color-container--species) :global(.tag) {
background-color: colors.$tag-species-background;
color: colors.$tag-species-text;
}
.tag-color-container:is(:global(.tag-color-container--species)) :global(.tag) {
background-color: colors.$tag-species-background;
color: colors.$tag-species-text;
}
.tag-color-container:is(.tag-color-container--body-type) :global(.tag) {
background-color: colors.$tag-body-type-background;
color: colors.$tag-body-type-text;
}
.tag-color-container:is(:global(.tag-color-container--body-type)) :global(.tag) {
background-color: colors.$tag-body-type-background;
color: colors.$tag-body-type-text;
}
</style>

View File

@@ -1,106 +1,115 @@
<script>
/**
* List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
* @type {string[]}
*/
export let tags = [];
<script lang="ts">
import type { EventHandler } from "svelte/elements";
/** @type {Set<string>} */
let uniqueTags = new Set();
interface TagEditorProps {
// List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
tags?: string[];
mapTagNames?: (tagName: string) => string;
}
$: uniqueTags = new Set(tags);
let {
tags = $bindable([]),
mapTagNames,
}: TagEditorProps = $props();
/** @type {string} */
let addedTagName = '';
let uniqueTags = $state<Set<string>>(new Set());
/**
* Create a callback function to pass into both mouse & keyboard events for tag removal.
* @param {string} tagName
* @return {function(Event)} Callback to pass as event listener.
*/
function createTagRemoveHandler(tagName) {
return event => {
if (event.type === 'click') {
removeTag(tagName);
}
$effect.pre(() => {
uniqueTags = new Set(tags);
});
if (event instanceof KeyboardEvent && (event.code === 'Enter' || event.code === 'Space')) {
// To be more comfortable, automatically focus next available tag's remove button in the list.
if (event.currentTarget instanceof HTMLElement) {
const currenTagElement = event.currentTarget.closest('.tag');
const nextTagElement = currenTagElement?.previousElementSibling ?? currenTagElement?.parentElement?.firstElementChild;
const nextRemoveButton = nextTagElement?.querySelector('.remove');
let addedTagName = $state<string>('');
if (nextRemoveButton instanceof HTMLElement) {
nextRemoveButton.focus();
}
}
/**
* Create a callback function to pass into both mouse & keyboard events for tag removal.
* @param tagName Name to remove when clicked.
* @return Callback to pass as event listener.
*/
function createTagRemoveHandler(tagName: string): EventHandler<Event, HTMLElement> {
return event => {
if (event.type === 'click') {
removeTag(tagName);
}
removeTag(tagName);
}
}
}
if (event instanceof KeyboardEvent && (event.code === 'Enter' || event.code === 'Space')) {
// To be more comfortable, automatically focus next available tag's remove button in the list.
if (event.currentTarget instanceof HTMLElement) {
const currenTagElement = event.currentTarget.closest('.tag');
const nextTagElement = currenTagElement?.previousElementSibling ?? currenTagElement?.parentElement?.firstElementChild;
const nextRemoveButton = nextTagElement?.querySelector('.remove');
/**
* @param {string} tagName
*/
function removeTag(tagName) {
uniqueTags.delete(tagName);
tags = Array.from(uniqueTags);
}
/**
* @param {string} tagName
*/
function addTag(tagName) {
uniqueTags.add(tagName);
tags = Array.from(uniqueTags);
}
/**
* Handle adding new tags to the list or removing them when backspace is pressed.
*
* Additional note: For some reason, mobile Chrome breaks the usual behaviour inside extension. `code` is becoming
* empty, while usually it should contain proper button code.
*
* @param {KeyboardEvent} event
*/
function handleKeyPresses(event) {
if ((event.code === 'Enter' || event.key === 'Enter') && addedTagName.length) {
addTag(addedTagName)
addedTagName = '';
if (nextRemoveButton instanceof HTMLElement) {
nextRemoveButton.focus();
}
}
if ((event.code === 'Backspace' || event.key === 'Backspace') && !addedTagName.length && tags?.length) {
removeTag(tags[tags.length - 1]);
}
removeTag(tagName);
}
}
}
/**
* Remove the tag from the set.
* @param tagName Name of the tag to remove.
*/
function removeTag(tagName: string) {
uniqueTags.delete(tagName);
tags = Array.from(uniqueTags);
}
/**
* Add the tag to the set.
* @param tagName Name of the tag to add.
*/
function addTag(tagName: string) {
uniqueTags.add(tagName);
tags = Array.from(uniqueTags);
}
/**
* Handle adding new tags to the list or removing them when backspace is pressed.
*
* Additional note: For some reason, mobile Chrome breaks the usual behaviour inside extension. `code` is becoming
* empty, while usually it should contain proper button code.
*
* @param event
*/
function handleKeyPresses(event: KeyboardEvent) {
if ((event.code === 'Enter' || event.key === 'Enter') && addedTagName.length) {
addTag(addedTagName)
addedTagName = '';
}
if ((event.code === 'Backspace' || event.key === 'Backspace') && !addedTagName.length && tags?.length) {
removeTag(tags[tags.length - 1]);
}
}
</script>
<div class="tags-editor">
{#each uniqueTags.values() as tagName}
<div class="tag">
{tagName}
<span class="remove" on:click={createTagRemoveHandler(tagName)}
on:keydown={createTagRemoveHandler(tagName)}
role="button" tabindex="0">x</span>
</div>
{/each}
<input type="text"
bind:value={addedTagName}
on:keydown={handleKeyPresses}
autocomplete="off"
autocapitalize="none"/>
{#each uniqueTags.values() as tagName}
<div class="tag">
{mapTagNames?.(tagName) ?? tagName}
<span class="remove" onclick={createTagRemoveHandler(tagName)}
onkeydown={createTagRemoveHandler(tagName)}
role="button" tabindex="0">x</span>
</div>
{/each}
<input autocapitalize="none"
autocomplete="off"
bind:value={addedTagName}
onkeydown={handleKeyPresses}
type="text"/>
</div>
<style lang="scss">
.tags-editor {
display: flex;
flex-wrap: wrap;
gap: 6px;
.tags-editor {
display: flex;
flex-wrap: wrap;
gap: 6px;
input {
width: 100%;
}
input {
width: 100%;
}
}
</style>

View File

@@ -1,12 +1,20 @@
<script>
/** @type {string|undefined} */
export let name = undefined;
<script lang="ts">
import type { Snippet } from "svelte";
/** @type {boolean} */
export let checked;
interface CheckboxFieldProps {
name?: string;
checked: boolean;
children?: Snippet;
}
let {
name = undefined,
checked = $bindable(),
children
}: CheckboxFieldProps = $props();
</script>
<input type="checkbox" {name} bind:checked={checked}>
<input bind:checked={checked} {name} type="checkbox">
<span>
<slot></slot>
{@render children?.()}
</span>

View File

@@ -1,11 +1,21 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface FormContainerProps {
children?: Snippet;
}
let { children }: FormContainerProps = $props();
</script>
<form>
<slot></slot>
{@render children?.()}
</form>
<style lang="scss">
form {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
form {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
</style>

View File

@@ -1,27 +1,35 @@
<script>
<script lang="ts">
import type { Snippet } from "svelte";
/** @type {string|undefined} */
export let label = undefined;
interface FormControlProps {
label?: string;
children?: Snippet;
}
let {
label = undefined,
children
}: FormControlProps = $props();
</script>
<label class="control">
{#if label}
<div class="label">{label}</div>
{/if}
<slot></slot>
{#if label}
<div class="label">{label}</div>
{/if}
{@render children?.()}
</label>
<style lang="scss">
.label {
margin-bottom: .5em;
}
.label {
margin-bottom: .5em;
}
.control {
padding: 5px 0;
.control {
padding: 5px 0;
:global(textarea) {
width: 100%;
resize: vertical;
}
:global(textarea) {
width: 100%;
resize: vertical;
}
}
</style>

View File

@@ -1,40 +1,45 @@
<script>
/**
* @type {string[]|Record<string, string>}
*/
export let options = [];
<script lang="ts">
type SelectFieldOptionsObject = Record<string, string>;
/** @type {string|undefined} */
export let name = undefined;
interface SelectFieldProps {
options?: string[] | SelectFieldOptionsObject;
name?: string;
id?: string;
value?: string;
}
/** @type {string|undefined} */
export let id = undefined;
let {
options = [],
name = undefined,
id = undefined,
value = $bindable(undefined)
}: SelectFieldProps = $props();
/** @type {string|undefined} */
export let value = undefined;
/** @type {Record<string, string>} */
const optionPairs = {};
const optionPairs = $derived.by<SelectFieldOptionsObject>(() => {
const resultPairs: SelectFieldOptionsObject = {};
if (Array.isArray(options)) {
for (let option of options) {
optionPairs[option] = option;
}
for (let optionName of options) {
resultPairs[optionName] = optionName;
}
} else if (options && typeof options === 'object') {
Object.keys(options).forEach((key) => {
optionPairs[key] = options[key];
})
Object.keys(options).forEach(optionKey => {
resultPairs[optionKey] = options[optionKey];
})
}
return resultPairs;
});
</script>
<select {name} {id} bind:value={value}>
{#each Object.entries(optionPairs) as [value, label]}
<option {value}>{label}</option>
{/each}
<select bind:value={value} {id} {name}>
{#each Object.entries(optionPairs) as [value, label]}
<option {value}>{label}</option>
{/each}
</select>
<style lang="scss">
select {
width: 100%;
}
select {
width: 100%;
}
</style>

View File

@@ -1,80 +1,84 @@
<script>
import SelectField from "$components/ui/forms/SelectField.svelte";
import { categories } from "$lib/booru/tag-categories";
<script lang="ts">
import SelectField from "$components/ui/forms/SelectField.svelte";
import { categories } from "$lib/booru/tag-categories";
/** @type {string} */
export let value = '';
interface TagCategorySelectFieldProps {
value?: string;
}
/** @type {Record<string, string>} */
let tagCategoriesOptions = {
'': 'Default'
};
let {
value = $bindable('')
}: TagCategorySelectFieldProps = $props();
tagCategoriesOptions = categories.reduce((options, category) => {
options[category] = category
.replace('-', ' ')
.replace(/(?<=\s|^)\w/g, (matchedCharacter) => matchedCharacter.toUpperCase());
let tagCategoriesOptions = $derived.by<Record<string, string>>(() => {
return categories.reduce<Record<string, string>>((options, category) => {
options[category] = category
.replace('-', ' ')
.replace(/(?<=\s|^)\w/g, (matchedCharacter) => matchedCharacter.toUpperCase());
return options;
}, tagCategoriesOptions);
return options;
}, {
'': 'Default'
})
});
</script>
<SelectField bind:value={value} options={tagCategoriesOptions} name="tag_color"/>
<SelectField bind:value={value} name="tag_color" options={tagCategoriesOptions}/>
<style lang="scss">
@use '$styles/colors';
@use '$styles/colors';
:global(select[name=tag_color]) {
:global(option) {
&:is(:global([value=rating])) {
background-color: colors.$tag-rating-background;
color: colors.$tag-rating-text;
}
:global(select[name=tag_color]) {
:global(option) {
&:is(:global([value=rating])) {
background-color: colors.$tag-rating-background;
color: colors.$tag-rating-text;
}
&:is(:global([value=spoiler])) {
background-color: colors.$tag-spoiler-background;
color: colors.$tag-spoiler-text;
}
&:is(:global([value=spoiler])) {
background-color: colors.$tag-spoiler-background;
color: colors.$tag-spoiler-text;
}
&:is(:global([value=origin])) {
background-color: colors.$tag-origin-background;
color: colors.$tag-origin-text;
}
&:is(:global([value=origin])) {
background-color: colors.$tag-origin-background;
color: colors.$tag-origin-text;
}
&:is(:global([value=oc])) {
background-color: colors.$tag-oc-background;
color: colors.$tag-oc-text;
}
&:is(:global([value=oc])) {
background-color: colors.$tag-oc-background;
color: colors.$tag-oc-text;
}
&:is(:global([value=error])) {
background-color: colors.$tag-error-background;
color: colors.$tag-error-text;
}
&:is(:global([value=error])) {
background-color: colors.$tag-error-background;
color: colors.$tag-error-text;
}
&:is(:global([value=character])) {
background-color: colors.$tag-character-background;
color: colors.$tag-character-text;
}
&:is(:global([value=character])) {
background-color: colors.$tag-character-background;
color: colors.$tag-character-text;
}
&:is(:global([value=content-official])) {
background-color: colors.$tag-content-official-background;
color: colors.$tag-content-official-text;
}
&:is(:global([value=content-official])) {
background-color: colors.$tag-content-official-background;
color: colors.$tag-content-official-text;
}
&:is(:global([value=content-fanmade])) {
background-color: colors.$tag-content-fanmade-background;
color: colors.$tag-content-fanmade-text;
}
&:is(:global([value=content-fanmade])) {
background-color: colors.$tag-content-fanmade-background;
color: colors.$tag-content-fanmade-text;
}
&:is(:global([value=species])) {
background-color: colors.$tag-species-background;
color: colors.$tag-species-text;
}
&:is(:global([value=species])) {
background-color: colors.$tag-species-background;
color: colors.$tag-species-text;
}
&:is(:global([value=body-type])) {
background-color: colors.$tag-body-type-background;
color: colors.$tag-body-type-text;
}
}
&:is(:global([value=body-type])) {
background-color: colors.$tag-body-type-background;
color: colors.$tag-body-type-text;
}
}
}
</style>

View File

@@ -1,18 +1,21 @@
<script>
/** @type {string|undefined} */
export let name = undefined;
<script lang="ts">
interface TextFieldProps {
name?: string;
placeholder?: string;
value?: string;
}
/** @type {string|undefined} */
export let placeholder = undefined;
/** @type {string} */
export let value = '';
let {
name = undefined,
placeholder = undefined,
value = $bindable('')
}: TextFieldProps = $props();
</script>
<input type="text" {name} {placeholder} bind:value={value}>
<input bind:value={value} {name} {placeholder} type="text">
<style lang="scss">
:global(.control) input {
width: 100%;
}
:global(.control) input {
width: 100%;
}
</style>

View File

@@ -1,38 +1,46 @@
<script lang="ts">
interface MenuProps {
children?: import('svelte').Snippet;
}
let { children }: MenuProps = $props();
</script>
<nav>
<slot></slot>
{@render children?.()}
</nav>
<style lang="scss">
@use '$styles/colors';
@use '$styles/colors';
nav {
display: flex;
flex-direction: column;
nav {
display: flex;
flex-direction: column;
& > :global(.menu-item) {
padding: 5px 24px;
}
:global(.menu-item) {
color: colors.$text;
&:hover {
background: colors.$header-mobile-link-hover;
}
}
:global(hr) {
background: colors.$block-border;
margin: .5em 24px;
border: 0;
height: 1px;
}
:global(main) > & {
margin: {
left: -24px;
right: -24px;
}
}
& > :global(.menu-item) {
padding: 5px 24px;
}
:global(.menu-item) {
color: colors.$text;
&:hover {
background: colors.$header-mobile-link-hover;
}
}
:global(hr) {
background: colors.$block-border;
margin: .5em 24px;
border: 0;
height: 1px;
}
:global(main) > & {
margin: {
left: -24px;
right: -24px;
}
}
}
</style>

View File

@@ -1,37 +1,44 @@
<script>
import MenuLink from "$components/ui/menu/MenuItem.svelte";
<script lang="ts">
import MenuLink from "$components/ui/menu/MenuItem.svelte";
import type { Snippet } from "svelte";
import type { FormEventHandler, MouseEventHandler } from "svelte/elements";
/**
* @type {boolean}
*/
export let checked;
interface MenuCheckboxItemProps {
checked: boolean;
name?: string;
value?: string;
href?: string;
children?: Snippet;
onclick?: MouseEventHandler<HTMLInputElement>;
oninput?: FormEventHandler<HTMLInputElement>;
}
/**
* @type {string|undefined}
*/
export let name = undefined;
let {
checked = $bindable(),
name = undefined,
value = undefined,
href = undefined,
children,
onclick,
oninput,
}: MenuCheckboxItemProps = $props();
/**
* @type {string|undefined}
*/
export let value = undefined;
/**
* @type {string|null}
*/
export let href = null;
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
originalEvent.stopPropagation();
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
}
</script>
<MenuLink {href}>
<input type="checkbox" {name} {value} bind:checked={checked} on:input on:click|stopPropagation>
<slot></slot>
<input bind:checked={checked} {name} onclick={stopPropagationAndPassCallback} {oninput} type="checkbox" {value}>
{@render children?.()}
</MenuLink>
<style lang="scss">
:global(.menu-item) input {
width: 16px;
height: 16px;
margin-right: 6px;
flex-shrink: 0;
}
:global(.menu-item) input {
width: 16px;
height: 16px;
margin-right: 6px;
flex-shrink: 0;
}
</style>

View File

@@ -1,41 +1,45 @@
<script>
/**
* @type {string|null}
*/
export let href = null;
<script lang="ts">
import type { Snippet } from "svelte";
import type { MouseEventHandler } from "svelte/elements";
/**
* @type {App.IconName|null}
*/
export let icon = null;
interface MenuItemProps {
href?: string | null;
icon?: App.IconName | null;
target?: App.LinkTarget | undefined;
children?: Snippet;
onclick?: MouseEventHandler<HTMLAnchorElement | HTMLSpanElement>;
}
/**
* @type {App.LinkTarget|undefined}
*/
export let target = undefined;
let {
href = null,
icon = null,
target = undefined,
children,
onclick
}: MenuItemProps = $props();
</script>
<svelte:element this="{href ? 'a': 'span'}" class="menu-item" {href} {target} on:click role="link" tabindex="0">
{#if icon}
<i class="icon icon-{icon}"></i>
{/if}
<slot></slot>
<svelte:element class="menu-item" {href} {onclick} role="link" tabindex="0" {target} this="{href ? 'a': 'span'}">
{#if icon}
<i class="icon icon-{icon}"></i>
{/if}
{@render children?.()}
</svelte:element>
<style lang="scss">
@use '$styles/colors';
@use '$styles/colors';
.menu-item {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
.menu-item {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
i {
width: 16px;
height: 16px;
background: colors.$text;
margin-right: 6px;
}
i {
width: 16px;
height: 16px;
background: colors.$text;
margin-right: 6px;
}
}
</style>

View File

@@ -1,37 +1,44 @@
<script>
import MenuLink from "$components/ui/menu/MenuItem.svelte";
<script lang="ts">
import MenuLink from "$components/ui/menu/MenuItem.svelte";
import type { Snippet } from "svelte";
import type { FormEventHandler, MouseEventHandler } from "svelte/elements";
/**
* @type {boolean}
*/
export let checked;
interface MenuRadioItemProps {
checked: boolean;
name: string;
value: string;
href?: string | null;
children?: Snippet;
onclick?: MouseEventHandler<HTMLInputElement>;
oninput?: FormEventHandler<HTMLInputElement>;
}
/**
* @type {string}
*/
export let name;
let {
checked,
name,
value,
href = null,
children,
onclick,
oninput,
}: MenuRadioItemProps = $props();
/**
* @type {string}
*/
export let value;
/**
* @type {string|null}
*/
export let href = null;
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
originalEvent.stopPropagation();
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
}
</script>
<MenuLink {href}>
<input type="radio" {name} {value} {checked} on:input on:click|stopPropagation>
<slot></slot>
<input {checked} {name} onclick={stopPropagationAndPassCallback} {oninput} type="radio" {value}>
{@render children?.()}
</MenuLink>
<style lang="scss">
:global(.menu-item) input {
width: 16px;
height: 16px;
margin-right: 6px;
flex-shrink: 0;
}
:global(.menu-item) input {
width: 16px;
height: 16px;
margin-right: 6px;
flex-shrink: 0;
}
</style>

View File

@@ -1,6 +1,6 @@
import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper";
const siteHeader = document.querySelector('.header');
const siteHeader = document.querySelector<HTMLElement>('.header');
if (siteHeader) {
initializeSiteHeader(siteHeader);

View File

@@ -3,9 +3,10 @@ import { createMediaBoxTools } from "$lib/components/MediaBoxTools";
import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/components/MediaBoxWrapper";
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
import { initializeImageListContainer } from "$lib/components/listing/ImageListContainer";
/** @type {NodeListOf<HTMLElement>} */
const mediaBoxes = document.querySelectorAll('.media-box');
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
mediaBoxes.forEach(mediaBoxElement => {
initializeMediaBox(mediaBoxElement, [
@@ -23,3 +24,7 @@ mediaBoxes.forEach(mediaBoxElement => {
});
calculateMediaBoxesPositions(mediaBoxes);
if (imageListContainer) {
initializeImageListContainer(imageListContainer);
}

View File

@@ -1,3 +0,0 @@
import { TagsForm } from "$lib/components/TagsForm";
TagsForm.watchForEditors();

View File

@@ -0,0 +1,6 @@
import { TagsForm } from "$lib/components/TagsForm";
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/components/TagsListBlock";
initializeAllTagsLists();
watchForUpdatedTagLists();
TagsForm.watchForEditors();

View File

@@ -1,6 +1,6 @@
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$lib/components/TagDropdownWrapper";
for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) {
for (let tagDropdownElement of document.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}

View File

@@ -1,8 +1,13 @@
/** @type {import('@sveltejs/kit').Reroute} */
export function reroute({url}) {
import type { Reroute } from "@sveltejs/kit";
export const reroute: Reroute = ({url}) => {
// Reroute index.html as just / for the root.
// Browser extension starts from with the index.html file in the pathname which is not correct for the router.
if (url.pathname === '/index.html') {
if (url.searchParams.has('path')) {
return url.searchParams.get('path')!;
}
return "/";
}
}
};

View File

@@ -1,19 +1,25 @@
import PostParser from "$lib/booru/scraped/parsing/PostParser";
type UpdaterFunction = (tags: Set<string>) => Set<string>;
export default class ScrapedAPI {
/**
* Update the tags of the image using callback.
* @param {number} imageId ID of the image.
* @param {function(Set<string>): Set<string>} callback Callback to call to change the content.
* @return {Promise<Map<string,string>|null>} Updated tags and aliases list for updating internal cached state.
* @param imageId ID of the image.
* @param callback Callback to call to change the content.
* @return Updated tags and aliases list for updating internal cached state.
*/
async updateImageTags(imageId, callback) {
async updateImageTags(imageId: number, callback: UpdaterFunction): Promise<Map<string, string> | null> {
const postParser = new PostParser(imageId);
const formData = await postParser.resolveTagEditorFormData();
const tagsFieldValue = formData.get(PostParser.tagsInputName);
if (typeof tagsFieldValue !== 'string') {
throw new Error('Missing tags field!');
}
const tagsList = new Set(
formData
.get(PostParser.tagsInputName)
tagsFieldValue
.split(',')
.map(tagName => tagName.trim())
);

View File

@@ -1,17 +1,12 @@
export default class PageParser {
/** @type {string} */
#url;
/** @type {DocumentFragment|null} */
#fragment = null;
readonly #url: string;
#fragment: DocumentFragment | null = null;
constructor(url) {
constructor(url: string) {
this.#url = url;
}
/**
* @return {Promise<DocumentFragment>}
*/
async resolveFragment() {
async resolveFragment(): Promise<DocumentFragment> {
if (this.#fragment) {
return this.#fragment;
}
@@ -34,12 +29,12 @@ export default class PageParser {
/**
* Create a document fragment from the following response.
*
* @param {Response} response Response to create a fragment from. Note, that this response will be used. If you need
* to use the same response somewhere else, then you need to pass a cloned version of the response.
* @param response Response to create a fragment from. Note, that this response will be used. If you need to use the
* same response somewhere else, then you need to pass a cloned version of the response.
*
* @return {Promise<DocumentFragment>} Resulting document fragment ready for processing.
* @return Resulting document fragment ready for processing.
*/
static async resolveFragmentFromResponse(response) {
static async resolveFragmentFromResponse(response: Response): Promise<DocumentFragment> {
const documentFragment = document.createDocumentFragment();
const template = document.createElement('template');
template.innerHTML = await response.text();

View File

@@ -2,23 +2,19 @@ import PageParser from "$lib/booru/scraped/parsing/PageParser";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
export default class PostParser extends PageParser {
/** @type {HTMLFormElement} */
#tagEditorForm;
#tagEditorForm: HTMLFormElement | null = null;
constructor(imageId) {
constructor(imageId: number) {
super(`/images/${imageId}`);
}
/**
* @return {Promise<HTMLFormElement>}
*/
async resolveTagEditorForm() {
async resolveTagEditorForm(): Promise<HTMLFormElement> {
if (this.#tagEditorForm) {
return this.#tagEditorForm;
}
const documentFragment = await this.resolveFragment();
const tagsFormElement = documentFragment.querySelector("#tags-form");
const tagsFormElement = documentFragment.querySelector<HTMLFormElement>("#tags-form");
if (!tagsFormElement) {
throw new Error("Failed to find the tag editor form");
@@ -37,10 +33,8 @@ export default class PostParser extends PageParser {
/**
* Resolve the tags and aliases mapping from the post page.
*
* @return {Promise<Map<string, string>|null>}
*/
async resolveTagsAndAliases() {
async resolveTagsAndAliases(): Promise<Map<string, string> | null> {
return PostParser.resolveTagsAndAliasesFromPost(
await this.resolveFragment()
);
@@ -49,25 +43,32 @@ export default class PostParser extends PageParser {
/**
* Resolve the list of tags and aliases from the post content.
*
* @param {DocumentFragment} documentFragment Real content to parse the data from.
* @param documentFragment Real content to parse the data from.
*
* @return {Map<string, string>|null} Tags and aliases or null if failed to parse.
* @return Tags and aliases or null if failed to parse.
*/
static resolveTagsAndAliasesFromPost(documentFragment) {
const imageShowContainer = documentFragment.querySelector('.image-show-container');
const tagsForm = documentFragment.querySelector('#tags-form');
static resolveTagsAndAliasesFromPost(documentFragment: DocumentFragment): Map<string, string> | null {
const imageShowContainer = documentFragment.querySelector<HTMLElement>('.image-show-container');
const tagsForm = documentFragment.querySelector<HTMLFormElement>('#tags-form');
if (!imageShowContainer || !tagsForm) {
return null;
}
const tagsFormData = new FormData(tagsForm);
const tagsAndAliasesValue = imageShowContainer.dataset.imageTagAliases;
const tagsValue = tagsFormData.get(this.tagsInputName);
const tagsAndAliasesList = imageShowContainer.dataset.imageTagAliases
if (!tagsAndAliasesValue || !tagsValue || typeof tagsValue !== 'string') {
console.warn('Failed to locate tags & aliases!');
return null;
}
const tagsAndAliasesList = tagsAndAliasesValue
.split(',')
.map(tagName => tagName.trim());
const actualTagsList = tagsFormData.get(this.tagsInputName)
const actualTagsList = tagsValue
.split(',')
.map(tagName => tagName.trim());

View File

@@ -22,7 +22,7 @@ export default class StorageHelper {
* @return The JSON object or the default value if the entry does not exist.
*/
async read<Type = any, DefaultType = any>(key: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
return (await this.#storageArea.get(key))?.[key] || defaultValue;
return (await this.#storageArea.get(key))?.[key] ?? defaultValue;
}
/**

View File

@@ -1,30 +1,22 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
import { emit, on } from "$lib/components/events/comms";
import { EVENT_SIZE_LOADED } from "$lib/components/events/fullscreen-viewer-events";
export class FullscreenViewer extends BaseComponent {
/** @type {HTMLVideoElement} */
#videoElement = document.createElement('video');
/** @type {HTMLImageElement} */
#imageElement = document.createElement('img');
#spinnerElement = document.createElement('i');
#sizeSelectorElement = document.createElement('select');
#closeButtonElement = document.createElement('i');
/** @type {number|null} */
#touchId = null;
/** @type {number|null} */
#startX = null;
/** @type {number|null} */
#startY = null;
/** @type {boolean|null} */
#isClosingSwipeStarted = null;
#isSizeFetched = false;
/** @type {App.ImageURIs|null} */
#currentURIs = null;
#videoElement: HTMLVideoElement = document.createElement('video');
#imageElement: HTMLImageElement = document.createElement('img');
#spinnerElement: HTMLElement = document.createElement('i');
#sizeSelectorElement: HTMLSelectElement = document.createElement('select');
#closeButtonElement: HTMLElement = document.createElement('i');
#touchId: number | null = null;
#startX: number | null = null;
#startY: number | null = null;
#isClosingSwipeStarted: boolean | null = null;
#isSizeFetched: boolean = false;
#currentURIs: App.ImageURIs | null = null;
/**
* @protected
*/
build() {
protected build() {
this.container.classList.add('fullscreen-viewer');
this.container.append(
@@ -71,10 +63,7 @@ export class FullscreenViewer extends BaseComponent {
this.container.classList.remove('loading');
}
/**
* @param {TouchEvent} event
*/
#onTouchStart(event) {
#onTouchStart(event: TouchEvent) {
if (this.#touchId !== null) {
return;
}
@@ -88,14 +77,12 @@ export class FullscreenViewer extends BaseComponent {
this.#touchId = firstTouch.identifier;
this.#startX = firstTouch.clientX;
this.#startY = firstTouch.clientY;
this.container.classList.add(FullscreenViewer.#swipeState);
}
/**
* @param {TouchEvent} event
*/
#onTouchEnd(event) {
if (this.#touchId === null) {
#onTouchEnd(event: TouchEvent) {
if (this.#touchId === null || this.#startY === null) {
return;
}
@@ -126,11 +113,8 @@ export class FullscreenViewer extends BaseComponent {
});
}
/**
* @param {TouchEvent} event
*/
#onTouchMove(event) {
if (this.#touchId === null) {
#onTouchMove(event: TouchEvent) {
if (this.#touchId === null || this.#startY === null || this.#startX === null) {
return;
}
@@ -179,23 +163,17 @@ export class FullscreenViewer extends BaseComponent {
}
}
/**
* @param {KeyboardEvent} event
*/
#onDocumentKeyPressed(event) {
#onDocumentKeyPressed(event: KeyboardEvent) {
if (event.code === 'Escape' || event.code === 'Esc') {
this.#close();
}
}
/**
* @param {import("$lib/extension/settings/MiscSettings").FullscreenViewerSize} size
*/
#onSizeResolved(size) {
#onSizeResolved(size: FullscreenViewerSize) {
this.#sizeSelectorElement.value = size;
this.#isSizeFetched = true;
this.emit('size-loaded');
emit(this.container, EVENT_SIZE_LOADED, size);
}
#watchForSizeSelectionChanges() {
@@ -232,7 +210,7 @@ export class FullscreenViewer extends BaseComponent {
this.#currentURIs = null;
this.container.classList.remove(FullscreenViewer.#shownState);
document.body.style.overflow = null;
document.body.style.removeProperty('overflow');
requestAnimationFrame(() => {
this.#videoElement.volume = 0;
@@ -241,16 +219,18 @@ export class FullscreenViewer extends BaseComponent {
});
}
/**
* @param {App.ImageURIs} imageUris
* @return {Promise<string|null>}
*/
async #resolveCurrentSelectedSizeUrl(imageUris) {
async #resolveCurrentSelectedSizeUrl(imageUris: App.ImageURIs): Promise<string | null> {
if (!this.#isSizeFetched) {
await new Promise(resolve => this.on('size-loaded', resolve))
await new Promise(
resolve => on(
this.container,
EVENT_SIZE_LOADED,
resolve
),
);
}
let targetSize = this.#sizeSelectorElement.value;
let targetSize: FullscreenViewerSize | string = this.#sizeSelectorElement.value;
if (!imageUris.hasOwnProperty(targetSize)) {
targetSize = FullscreenViewer.#fallbackSize;
@@ -264,13 +244,10 @@ export class FullscreenViewer extends BaseComponent {
return null;
}
return imageUris[targetSize];
return imageUris[targetSize as FullscreenViewerSize];
}
/**
* @param {App.ImageURIs} imageUris
*/
async show(imageUris) {
async show(imageUris: App.ImageURIs): Promise<void> {
this.#currentURIs = imageUris;
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
@@ -308,11 +285,7 @@ export class FullscreenViewer extends BaseComponent {
this.container.append(this.#imageElement);
}
/**
* @param {string} url
* @return {boolean}
*/
static #isVideoUrl(url) {
static #isVideoUrl(url: string): boolean {
return url.endsWith('.mp4') || url.endsWith('.webm');
}
@@ -324,10 +297,7 @@ export class FullscreenViewer extends BaseComponent {
static #swipeState = 'swiped';
static #minRequiredDistance = 50;
/**
* @type {Record<import("$lib/extension/settings/MiscSettings").FullscreenViewerSize, string>}
*/
static #previewSizes = {
static #previewSizes: Record<FullscreenViewerSize, string> = {
full: 'Full',
large: 'Large',
medium: 'Medium',

View File

@@ -2,21 +2,23 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import MiscSettings from "$lib/extension/settings/MiscSettings";
import { FullscreenViewer } from "$lib/components/FullscreenViewer";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class ImageShowFullscreenButton extends BaseComponent {
/**
* @type {import('./MediaBoxTools').MediaBoxTools|null}
*/
#mediaBoxTools= null;
#isFullscreenButtonEnabled = false;
#mediaBoxTools: MediaBoxTools | null = null;
#isFullscreenButtonEnabled: boolean = false;
build() {
protected build() {
this.container.innerText = '🔍';
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
}
init() {
protected init() {
if (!this.container.parentElement) {
throw new Error('Missing parent element!');
}
this.#mediaBoxTools = getComponent(this.container.parentElement);
if (!this.#mediaBoxTools) {
@@ -32,7 +34,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
this.#updateFullscreenButtonVisibility();
})
.then(() => {
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
this.#updateFullscreenButtonVisibility();
})
@@ -45,28 +47,25 @@ export class ImageShowFullscreenButton extends BaseComponent {
}
#onButtonClicked() {
const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks;
if (!imageLinks) {
throw new Error('Failed to resolve image links from media box tools!');
}
ImageShowFullscreenButton
.#resolveViewer()
.show(this.#mediaBoxTools.mediaBox.imageLinks);
?.show(imageLinks);
}
/**
* @type {FullscreenViewer|null}
*/
static #viewer = null;
static #viewer: FullscreenViewer | null = null;
/**
* @return {FullscreenViewer}
*/
static #resolveViewer() {
static #resolveViewer(): FullscreenViewer {
this.#viewer ??= this.#buildViewer();
return this.#viewer;
}
/**
* @return {FullscreenViewer}
*/
static #buildViewer() {
static #buildViewer(): FullscreenViewer {
const element = document.createElement('div');
const viewer = new FullscreenViewer(element);
@@ -77,10 +76,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
return viewer;
}
/**
* @type {MiscSettings|null}
*/
static #miscSettings = null;
static #miscSettings: MiscSettings | null = null;
}
export function createImageShowFullscreenButton() {

View File

@@ -6,51 +6,31 @@ import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
import { tagsBlacklist } from "$config/tags";
import { emitterAt } from "$lib/components/events/comms";
import {
eventActiveProfileChanged,
eventMaintenanceStateChanged,
eventTagsUpdated
EVENT_ACTIVE_PROFILE_CHANGED,
EVENT_MAINTENANCE_STATE_CHANGED,
EVENT_TAGS_UPDATED
} from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
class BlackListedTagsEncounteredError extends Error {
/**
* @param {string} tagName
*/
constructor(tagName) {
super(`This tag is blacklisted and prevents submission: ${tagName}`);
constructor(tagName: string) {
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
cause: tagName
});
}
}
export class MaintenancePopup extends BaseComponent {
/** @type {HTMLElement} */
#tagsListElement = null;
/** @type {HTMLElement[]} */
#tagsList = [];
/** @type {Map<string, HTMLElement>} */
#suggestedInvalidTags = new Map();
/** @type {MaintenanceProfile|null} */
#activeProfile = null;
/** @type {import('$lib/components/MediaBoxTools').MediaBoxTools} */
#mediaBoxTools = null;
/** @type {Set<string>} */
#tagsToRemove = new Set();
/** @type {Set<string>} */
#tagsToAdd = new Set();
/** @type {boolean} */
#isPlanningToSubmit = false;
/** @type {boolean} */
#isSubmitting = false;
/** @type {number|null} */
#tagsSubmissionTimer = null;
#tagsListElement: HTMLElement = document.createElement('div');
#tagsList: HTMLElement[] = [];
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
#activeProfile: MaintenanceProfile | null = null;
#mediaBoxTools: MediaBoxTools | null = null;
#tagsToRemove: Set<string> = new Set();
#tagsToAdd: Set<string> = new Set();
#isPlanningToSubmit: boolean = false;
#isSubmitting: boolean = false;
#tagsSubmissionTimer: Timeout | null = null;
#emitter = emitterAt(this);
/**
@@ -60,7 +40,6 @@ export class MaintenancePopup extends BaseComponent {
this.container.innerHTML = '';
this.container.classList.add('maintenance-popup');
this.#tagsListElement = document.createElement('div');
this.#tagsListElement.classList.add('tags-list');
this.container.append(
@@ -72,14 +51,13 @@ export class MaintenancePopup extends BaseComponent {
* @protected
*/
init() {
const mediaBoxToolsElement = this.container.closest('.media-box-tools');
const mediaBoxToolsElement = this.container.closest<HTMLElement>('.media-box-tools');
if (!mediaBoxToolsElement) {
throw new Error('Maintenance popup initialized outside of the media box tools!');
}
/** @type {MediaBoxTools|null} */
const mediaBoxTools = getComponent(mediaBoxToolsElement);
const mediaBoxTools = getComponent<MediaBoxTools>(mediaBoxToolsElement);
if (!mediaBoxTools) {
throw new Error('Media box tools component not found!');
@@ -92,24 +70,28 @@ export class MaintenancePopup extends BaseComponent {
const mediaBox = this.#mediaBoxTools.mediaBox;
if (!mediaBox) {
throw new Error('Media box component not found!');
}
mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this));
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
}
/**
* @param {MaintenanceProfile|null} activeProfile
*/
#onActiveProfileChanged(activeProfile) {
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
this.#activeProfile = activeProfile;
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
this.#emitter.emit(eventActiveProfileChanged, activeProfile);
this.#emitter.emit(EVENT_ACTIVE_PROFILE_CHANGED, activeProfile);
}
#refreshTagsList() {
/** @type {string[]} */
const activeProfileTagsList = this.#activeProfile?.settings.tags || [];
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
for (const tagElement of this.#tagsList) {
tagElement.remove();
@@ -131,11 +113,11 @@ export class MaintenancePopup extends BaseComponent {
this.#tagsList[index] = tagElement;
this.#tagsListElement.appendChild(tagElement);
const isPresent = currentPostTags.has(tagName);
const isPresent = currentPostTags?.has(tagName);
tagElement.classList.toggle('is-present', isPresent);
tagElement.classList.toggle('is-missing', !isPresent);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags.get(tagName) !== tagName);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags?.get(tagName) !== tagName);
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
if (tagsBlacklist.includes(tagName)) {
@@ -147,17 +129,22 @@ export class MaintenancePopup extends BaseComponent {
/**
* Detect and process clicks made directly to the tags.
* @param {MouseEvent} event
*/
#handleTagClick(event) {
/** @type {HTMLElement} */
let tagElement = event.target;
#handleTagClick(event: MouseEvent) {
const targetObject = event.target;
if (!tagElement.classList.contains('tag')) {
tagElement = tagElement.closest('.tag');
if (!targetObject || !(targetObject instanceof HTMLElement)) {
return;
}
if (!tagElement) {
let tagElement: HTMLElement | null = targetObject;
if (!tagElement.classList.contains('tag')) {
tagElement = tagElement.closest<HTMLElement>('.tag');
}
if (!tagElement?.dataset.name) {
return;
}
@@ -190,7 +177,7 @@ export class MaintenancePopup extends BaseComponent {
}
this.#isPlanningToSubmit = true;
this.#emitter.emit(eventMaintenanceStateChanged, 'waiting');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'waiting');
}
}
@@ -210,14 +197,14 @@ export class MaintenancePopup extends BaseComponent {
}
async #onSubmissionTimerPassed() {
if (!this.#isPlanningToSubmit || this.#isSubmitting) {
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) {
return;
}
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.#emitter.emit(eventMaintenanceStateChanged, 'processing');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'processing');
let maybeTagsAndAliasesAfterUpdate;
@@ -259,17 +246,17 @@ export class MaintenancePopup extends BaseComponent {
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.#emitter.emit(eventMaintenanceStateChanged, 'failed');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
this.#isSubmitting = false;
return;
}
if (maybeTagsAndAliasesAfterUpdate) {
this.#emitter.emit(eventTagsUpdated, maybeTagsAndAliasesAfterUpdate);
this.#emitter.emit(EVENT_TAGS_UPDATED, maybeTagsAndAliasesAfterUpdate);
}
this.#emitter.emit(eventMaintenanceStateChanged, 'complete');
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'complete');
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
@@ -281,6 +268,10 @@ export class MaintenancePopup extends BaseComponent {
}
#revealInvalidTags() {
if (!this.#mediaBoxTools?.mediaBox) {
return;
}
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
if (!tagsAndAliases) {
@@ -310,18 +301,11 @@ export class MaintenancePopup extends BaseComponent {
}
}
/**
* @return {boolean}
*/
get isActive() {
return this.container.classList.contains('is-active');
}
/**
* @param {string} tagName
* @return {HTMLElement}
*/
static #buildTagElement(tagName) {
static #buildTagElement(tagName: string): HTMLElement {
const tagElement = document.createElement('span');
tagElement.classList.add('tag');
tagElement.innerText = tagName;
@@ -332,28 +316,26 @@ export class MaintenancePopup extends BaseComponent {
/**
* Marks the tag with red color.
* @param {HTMLElement} tagElement Element to mark.
* @param tagElement Element to mark.
*/
static #markTagAsInvalid(tagElement) {
static #markTagAsInvalid(tagElement: HTMLElement) {
tagElement.dataset.tagCategory = 'error';
tagElement.setAttribute('data-tag-category', 'error');
}
/**
* Controller with maintenance settings.
* @type {MaintenanceSettings}
*/
static #maintenanceSettings = new MaintenanceSettings();
/**
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
* at the very start to retrieve the currently active profile.
* @param {function(MaintenanceProfile|null):void} callback Callback to execute whenever selection of active profile
* or profile itself has been changed.
* @return {function(): void} Unsubscribe function. Call it to stop watching for changes.
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
* @return Unsubscribe function. Call it to stop watching for changes.
*/
static #watchActiveProfile(callback) {
let lastActiveProfileId;
static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void {
let lastActiveProfileId: string | null | undefined = null;
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
if (lastActiveProfileId) {
@@ -393,9 +375,9 @@ export class MaintenancePopup extends BaseComponent {
/**
* Notify the frontend about new pending submission started.
* @param {boolean} isStarted True if started, false if ended.
* @param isStarted True if started, false if ended.
*/
static #notifyAboutPendingSubmission(isStarted) {
static #notifyAboutPendingSubmission(isStarted: boolean) {
if (this.#pendingSubmissionCount === null) {
this.#pendingSubmissionCount = 0;
this.#initializeExitPromptHandler();
@@ -424,9 +406,8 @@ export class MaintenancePopup extends BaseComponent {
/**
* Amount of pending submissions or NULL if logic was not yet initialized.
* @type {number|null}
*/
static #pendingSubmissionCount = null;
static #pendingSubmissionCount: number|null = null;
}
export function createMaintenancePopup() {

View File

@@ -1,30 +1,31 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { on } from "$lib/components/events/comms";
import { eventMaintenanceStateChanged } from "$lib/components/events/maintenance-popup-events";
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
export class MaintenanceStatusIcon extends BaseComponent {
/** @type {import('./MediaBoxTools').MediaBoxTools} */
#mediaBoxTools;
#mediaBoxTools: MediaBoxTools | null = null;
build() {
this.container.innerText = '🔧';
}
init() {
if (!this.container.parentElement) {
throw new Error('Missing parent element for the maintenance status icon!');
}
this.#mediaBoxTools = getComponent(this.container.parentElement);
if (!this.#mediaBoxTools) {
throw new Error('Status icon element initialized outside of the media box!');
}
on(this.#mediaBoxTools, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this));
on(this.#mediaBoxTools, EVENT_MAINTENANCE_STATE_CHANGED, this.#onMaintenanceStateChanged.bind(this));
}
/**
* @param {CustomEvent<string>} stateChangeEvent
*/
#onMaintenanceStateChanged(stateChangeEvent) {
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {
// TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself.
switch (stateChangeEvent.detail) {
case "ready":

View File

@@ -2,17 +2,16 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
import { on } from "$lib/components/events/comms";
import { eventActiveProfileChanged } from "$lib/components/events/maintenance-popup-events";
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$lib/components/events/maintenance-popup-events";
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export class MediaBoxTools extends BaseComponent {
/** @type {import('./MediaBoxWrapper').MediaBoxWrapper|null} */
#mediaBox;
/** @type {MaintenancePopup|null} */
#maintenancePopup = null;
#mediaBox: MediaBoxWrapper | null = null;
#maintenancePopup: MaintenancePopup | null = null;
init() {
const mediaBoxElement = this.container.closest('.media-box');
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
if (!mediaBoxElement) {
throw new Error('Toolbox element initialized outside of the media box!');
@@ -21,6 +20,10 @@ export class MediaBoxTools extends BaseComponent {
this.#mediaBox = getComponent(mediaBoxElement);
for (let childElement of this.container.children) {
if (!(childElement instanceof HTMLElement)) {
continue;
}
const component = getComponent(childElement);
if (!component) {
@@ -36,37 +39,28 @@ export class MediaBoxTools extends BaseComponent {
}
}
on(this, eventActiveProfileChanged, this.#onActiveProfileChanged.bind(this));
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
}
/**
* @param {CustomEvent<import('$entities/MaintenanceProfile').default|null>} profileChangedEvent
*/
#onActiveProfileChanged(profileChangedEvent) {
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
}
/**
* @return {MaintenancePopup|null}
*/
get maintenancePopup() {
get maintenancePopup(): MaintenancePopup | null {
return this.#maintenancePopup;
}
/**
* @return {import('./MediaBoxWrapper').MediaBoxWrapper|null}
*/
get mediaBox() {
get mediaBox(): MediaBoxWrapper | null {
return this.#mediaBox;
}
}
/**
* Create a maintenance popup element.
* @param {HTMLElement[]} childrenElements List of children elements to append to the component.
* @return {HTMLElement} The maintenance popup element.
* @param childrenElements List of children elements to append to the component.
* @return The maintenance popup element.
*/
export function createMediaBoxTools(...childrenElements) {
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
const mediaBoxToolsContainer = document.createElement('div');
mediaBoxToolsContainer.classList.add('media-box-tools');

View File

@@ -1,104 +0,0 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import { on } from "$lib/components/events/comms";
import { eventTagsUpdated } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer = null;
#imageLinkElement = null;
/** @type {Map<string,string>|null} */
#tagsAndAliases = null;
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer.querySelector('a');
on(this, eventTagsUpdated, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
/**
* @param {CustomEvent<Map<string,string>|null>} tagsUpdatedEvent
*/
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
throw new TypeError("Tags and aliases should be stored as Map!");
}
this.#tagsAndAliases = updatedMap;
}
#calculateMediaBoxTags() {
/** @type {string[]|string[]} */
const
tagAliases = this.#thumbnailContainer.dataset.imageTagAliases?.split(', ') || [],
actualTags = this.#imageLinkElement.title.split(' | Tagged: ')[1]?.split(', ') || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
/**
* @return {Map<string, string>|null}
*/
get tagsAndAliases() {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
}
return this.#tagsAndAliases;
}
get imageId() {
return parseInt(
this.container.dataset.imageId
);
}
/**
* @return {App.ImageURIs}
*/
get imageLinks() {
return JSON.parse(this.#thumbnailContainer.dataset.uris);
}
}
/**
* Wrap the media box element into the special wrapper.
* @param {HTMLElement} mediaBoxContainer
* @param {HTMLElement[]} childComponentElements
*/
export function initializeMediaBox(mediaBoxContainer, childComponentElements) {
new MediaBoxWrapper(mediaBoxContainer)
.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
}
/**
* @param {NodeListOf<HTMLElement>} mediaBoxesList
*/
export function calculateMediaBoxesPositions(mediaBoxesList) {
window.addEventListener('resize', () => {
/** @type {HTMLElement|null} */
let lastMediaBox = null,
/** @type {number|null} */
lastMediaBoxPosition = null;
for (let mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
})
}

View File

@@ -0,0 +1,99 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import { on } from "$lib/components/events/comms";
import { EVENT_TAGS_UPDATED } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer: HTMLElement | null = null;
#imageLinkElement: HTMLAnchorElement | null = null;
#tagsAndAliases: Map<string, string> | null = null;
init() {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
const updatedMap = tagsUpdatedEvent.detail;
if (!(updatedMap instanceof Map)) {
throw new TypeError("Tags and aliases should be stored as Map!");
}
this.#tagsAndAliases = updatedMap;
}
#calculateMediaBoxTags() {
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
return buildTagsAndAliasesMap(tagAliases, actualTags);
}
get tagsAndAliases(): Map<string, string> | null {
if (!this.#tagsAndAliases) {
this.#tagsAndAliases = this.#calculateMediaBoxTags();
}
return this.#tagsAndAliases;
}
get imageId(): number {
const imageId = this.container.dataset.imageId;
if (!imageId) {
throw new Error('Missing image ID');
}
return parseInt(imageId);
}
get imageLinks(): App.ImageURIs {
const jsonUris = this.#thumbnailContainer?.dataset.uris;
if (!jsonUris) {
throw new Error('Missing URIs!');
}
return JSON.parse(jsonUris);
}
}
/**
* Wrap the media box element into the special wrapper.
*/
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
new MediaBoxWrapper(mediaBoxContainer)
.initialize();
for (let childComponentElement of childComponentElements) {
mediaBoxContainer.appendChild(childComponentElement);
getComponent(childComponentElement)?.initialize();
}
}
export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElement>) {
window.addEventListener('resize', () => {
let lastMediaBox: HTMLElement | null = null;
let lastMediaBoxPosition: number | null = null;
for (const mediaBoxElement of mediaBoxesList) {
const yPosition = mediaBoxElement.getBoundingClientRect().y;
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
lastMediaBox = mediaBoxElement;
lastMediaBoxPosition = yPosition;
}
// Last-ever media box is checked separately
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
lastMediaBox.classList.add('media-box--last');
}
})
}

View File

@@ -1,29 +1,25 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer";
import SearchSettings from "$lib/extension/settings/SearchSettings";
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
export class SearchWrapper extends BaseComponent {
/** @type {HTMLInputElement|null} */
#searchField = null;
/** @type {string|null} */
#lastParsedSearchValue = null;
/** @type {Token[]} */
#cachedParsedQuery = [];
#searchSettings = new SearchSettings();
#arePropertiesSuggestionsEnabled = false;
/** @type {"start"|"end"} */
#propertiesSuggestionsPosition = "start";
/** @type {HTMLElement|null} */
#cachedAutocompleteContainer = null;
/** @type {TermToken|QuotedTermToken|null} */
#lastTermToken = null;
#searchField: HTMLInputElement | null = null;
#lastParsedSearchValue: string | null = null;
#cachedParsedQuery: Token[] = [];
#searchSettings: SearchSettings = new SearchSettings();
#arePropertiesSuggestionsEnabled: boolean = false;
#propertiesSuggestionsPosition: SuggestionsPosition = "start";
#cachedAutocompleteContainer: HTMLElement | null = null;
#lastTermToken: TermToken | QuotedTermToken | null = null;
build() {
this.#searchField = this.container.querySelector('input[name=q]');
}
init() {
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this));
if (this.#searchField) {
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this))
}
this.#searchSettings.resolvePropertiesSuggestionsEnabled()
.then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled);
@@ -31,18 +27,18 @@ export class SearchWrapper extends BaseComponent {
.then(position => this.#propertiesSuggestionsPosition = position);
this.#searchSettings.subscribe(settings => {
this.#arePropertiesSuggestionsEnabled = settings.suggestProperties;
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition;
this.#arePropertiesSuggestionsEnabled = Boolean(settings.suggestProperties);
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition || "start";
});
}
/**
* Catch the user input and execute suggestions logic.
* @param {InputEvent} event Source event to find the input element from.
* @param event Source event to find the input element from.
*/
#onInputFindProperties(event) {
#onInputFindProperties(event: Event) {
// Ignore events until option is enabled.
if (!this.#arePropertiesSuggestionsEnabled) {
if (!this.#arePropertiesSuggestionsEnabled || !(event.currentTarget instanceof HTMLInputElement)) {
return;
}
@@ -60,20 +56,26 @@ export class SearchWrapper extends BaseComponent {
/**
* Get the selection position in the search field.
* @return {number}
*/
#getInputUserSelection() {
#getInputUserSelection(): number {
if (!this.#searchField) {
throw new Error('Missing search field!');
}
return Math.min(
this.#searchField.selectionStart,
this.#searchField.selectionEnd
this.#searchField.selectionStart ?? 0,
this.#searchField.selectionEnd ?? 0,
);
}
/**
* Parse the search query and return the list of parsed tokens. Result will be cached for current search query.
* @return {Token[]}
*/
#resolveQueryTokens() {
#resolveQueryTokens(): Token[] {
if (!this.#searchField) {
throw new Error('Missing search field!');
}
const searchValue = this.#searchField.value;
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
@@ -88,9 +90,9 @@ export class SearchWrapper extends BaseComponent {
/**
* Find the currently selected term.
* @return {string|null} Selected term or null if none found.
* @return Selected term or null if none found.
*/
#findCurrentTagFragment() {
#findCurrentTagFragment(): string | null {
if (!this.#searchField) {
return null;
}
@@ -127,9 +129,9 @@ export class SearchWrapper extends BaseComponent {
*
* This means, that properties will only be suggested once actual autocomplete logic was activated.
*
* @return {HTMLElement|null} Resolved element or nothing.
* @return Resolved element or nothing.
*/
#resolveAutocompleteContainer() {
#resolveAutocompleteContainer(): HTMLElement | null {
if (this.#cachedAutocompleteContainer) {
return this.#cachedAutocompleteContainer;
}
@@ -141,11 +143,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Render the list of suggestions into the existing popup or create and populate a new one.
* @param {string[]} suggestions List of suggestion to render the popup from.
* @param {HTMLInputElement} targetInput Target input to attach the popup to.
* @param suggestions List of suggestion to render the popup from.
* @param targetInput Target input to attach the popup to.
*/
#renderSuggestions(suggestions, targetInput) {
/** @type {HTMLElement[]} */
#renderSuggestions(suggestions: string[], targetInput: HTMLInputElement) {
const suggestedListItems = suggestions
.map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm));
@@ -170,6 +171,10 @@ export class SearchWrapper extends BaseComponent {
const listContainer = autocompleteContainer.querySelector('ul');
if (!listContainer) {
return;
}
switch (this.#propertiesSuggestionsPosition) {
case "start":
listContainer.prepend(...suggestedListItems);
@@ -183,10 +188,11 @@ export class SearchWrapper extends BaseComponent {
console.warn("Invalid position for property suggestions!");
}
const parentScrollTop = targetInput.parentElement?.scrollTop ?? 0;
autocompleteContainer.style.position = 'absolute';
autocompleteContainer.style.left = `${targetInput.offsetLeft}px`;
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - targetInput.parentElement.scrollTop}px`;
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - parentScrollTop}px`;
document.body.append(autocompleteContainer);
})
@@ -194,30 +200,28 @@ export class SearchWrapper extends BaseComponent {
/**
* Loosely estimate where current selected search term is located and return it if found.
* @param {Token[]} tokens Search value to find the actively selected term from.
* @param {number} userSelectionIndex The index of the user selection.
* @return {Token|null} Search term object or NULL if nothing found.
* @param tokens Search value to find the actively selected term from.
* @param userSelectionIndex The index of the user selection.
* @return Search term object or NULL if nothing found.
*/
static #findActiveSearchTermPosition(tokens, userSelectionIndex) {
static #findActiveSearchTermPosition(tokens: Token[], userSelectionIndex: number): Token | null {
return tokens.find(
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
);
) ?? null;
}
/**
* Regular expression to search the properties' syntax.
* @type {RegExp}
*/
static #propertySearchTermHeadingRegExp = /^(?<name>[a-z\d_]+)(?<op_syntax>\.(?<op>[a-z]*))?(?<value_syntax>:(?<value>.*))?$/;
/**
* Create a list of suggested elements using the input received from the user.
* @param {string} searchTermValue Original decoded term received from the user.
* @param searchTermValue Original decoded term received from the user.
* @return {string[]} List of suggestions. Could be empty.
*/
static #resolveSuggestionsFromTerm(searchTermValue) {
/** @type {string[]} */
const suggestionsList = [];
static #resolveSuggestionsFromTerm(searchTermValue: string): string[] {
const suggestionsList: string[] = [];
this.#propertySearchTermHeadingRegExp.lastIndex = 0;
const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue);
@@ -226,22 +230,28 @@ export class SearchWrapper extends BaseComponent {
return suggestionsList;
}
const propertyName = parsedResult.groups.name;
const propertyName = parsedResult.groups?.name;
if (!propertyName) {
return suggestionsList;
}
const propertyType = this.#properties.get(propertyName);
const hasOperatorSyntax = Boolean(parsedResult.groups.op_syntax);
const hasValueSyntax = Boolean(parsedResult.groups.value_syntax);
const hasOperatorSyntax = Boolean(parsedResult.groups?.op_syntax);
const hasValueSyntax = Boolean(parsedResult.groups?.value_syntax);
// No suggestions for values for now, maybe could add suggestions for namespaces like my:*
if (hasValueSyntax) {
if (hasValueSyntax && propertyType) {
if (this.#typeValues.has(propertyType)) {
const givenValue = parsedResult.groups.value;
const givenValue = parsedResult.groups?.value;
const candidateValues = this.#typeValues.get(propertyType) || [];
for (let candidateValue of this.#typeValues.get(propertyType)) {
for (let candidateValue of candidateValues) {
if (givenValue && !candidateValue.startsWith(givenValue)) {
continue;
}
suggestionsList.push(`${propertyName}${parsedResult.groups.op_syntax ?? ''}:${candidateValue}`);
suggestionsList.push(`${propertyName}${parsedResult.groups?.op_syntax ?? ''}:${candidateValue}`);
}
}
@@ -249,11 +259,12 @@ export class SearchWrapper extends BaseComponent {
}
// If at least one dot placed, start suggesting operators
if (hasOperatorSyntax) {
if (hasOperatorSyntax && propertyType) {
if (this.#typeOperators.has(propertyType)) {
const operatorName = parsedResult.groups.op;
const operatorName = parsedResult.groups?.op;
const candidateOperators = this.#typeOperators.get(propertyType) ?? [];
for (let candidateOperator of this.#typeOperators.get(propertyType)) {
for (let candidateOperator of candidateOperators) {
if (operatorName && !candidateOperator.startsWith(operatorName)) {
continue;
}
@@ -279,11 +290,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Render a single suggestion item and connect required events to interact with the user.
* @param {string} suggestedTerm Term to use for suggestion item.
* @return {HTMLElement} Resulting element.
* @param suggestedTerm Term to use for suggestion item.
* @return Resulting element.
*/
#renderTermSuggestion(suggestedTerm) {
/** @type {HTMLElement} */
#renderTermSuggestion(suggestedTerm: string): HTMLElement {
const suggestionItem = document.createElement('li');
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
suggestionItem.dataset.value = suggestedTerm;
@@ -311,10 +321,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Automatically replace the last active token stored in the variable with the new value.
* @param {string} suggestedTerm Term to replace the value with.
* @param suggestedTerm Term to replace the value with.
*/
#replaceLastActiveTokenWithSuggestion(suggestedTerm) {
if (!this.#lastTermToken) {
#replaceLastActiveTokenWithSuggestion(suggestedTerm: string) {
if (!this.#lastTermToken || !this.#searchField) {
return;
}
@@ -334,10 +344,10 @@ export class SearchWrapper extends BaseComponent {
/**
* Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's
* front-end.
* @param {HTMLElement} suggestedElement Target element to search from. If element is disconnected from the DOM,
* search will be halted.
* @param suggestedElement Target element to search from. If element is disconnected from the DOM, search will be
* halted.
*/
static #findAndResetSelectedSuggestion(suggestedElement) {
static #findAndResetSelectedSuggestion(suggestedElement: HTMLElement) {
if (!suggestedElement.parentElement) {
return;
}

View File

@@ -2,11 +2,10 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
import { SearchWrapper } from "$lib/components/SearchWrapper";
class SiteHeaderWrapper extends BaseComponent {
/** @type {SearchWrapper|null} */
#searchWrapper = null;
#searchWrapper: SearchWrapper | null = null;
build() {
const searchForm = this.container.querySelector('.header__search');
const searchForm = this.container.querySelector<HTMLElement>('.header__search');
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
}
@@ -17,7 +16,7 @@ class SiteHeaderWrapper extends BaseComponent {
}
}
export function initializeSiteHeader(siteHeaderElement) {
export function initializeSiteHeader(siteHeaderElement: HTMLElement) {
new SiteHeaderWrapper(siteHeaderElement)
.initialize();
}

View File

@@ -3,45 +3,40 @@ import MaintenanceProfile from "$entities/MaintenanceProfile";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import { getComponent } from "$lib/components/base/component-utils";
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
import { on } from "$lib/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import type TagGroup from "$entities/TagGroup";
const isTagEditorProcessedKey = Symbol();
const categoriesResolver = new CustomCategoriesResolver();
export class TagDropdownWrapper extends BaseComponent {
/**
* Container with dropdown elements to insert options into.
* @type {HTMLElement}
*/
#dropdownContainer;
#dropdownContainer: HTMLElement | null = null;
/**
* Button to add or remove the current tag into/from the active profile.
* @type {HTMLAnchorElement|null}
*/
#toggleOnExistingButton = null;
#toggleOnExistingButton: HTMLAnchorElement | null = null;
/**
* Button to create a new profile, make it active and add the current tag into the active profile.
* @type {HTMLAnchorElement|null}
*/
#addToNewButton = null;
#addToNewButton: HTMLAnchorElement | null = null;
/**
* Local clone of the currently active profile used for updating the list of tags.
* @type {MaintenanceProfile|null}
*/
#activeProfile = null;
#activeProfile: MaintenanceProfile | null = null;
/**
* Is cursor currently entered the dropdown.
* @type {boolean}
*/
#isEntered = false;
#isEntered: boolean = false;
/**
* @type {string|undefined|null}
*/
#originalCategory = null;
#originalCategory: string | undefined | null = null;
build() {
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
@@ -58,6 +53,23 @@ export class TagDropdownWrapper extends BaseComponent {
this.#updateButtons();
}
});
on(this, EVENT_TAG_GROUP_RESOLVED, this.#onTagGroupResolved.bind(this));
}
#onTagGroupResolved(resolvedGroupEvent: CustomEvent<TagGroup | null>) {
if (this.originalCategory) {
return;
}
const maybeTagGroup = resolvedGroupEvent.detail;
if (!maybeTagGroup) {
this.tagCategory = this.originalCategory;
return;
}
this.tagCategory = maybeTagGroup.settings.category;
}
get tagName() {
@@ -116,7 +128,7 @@ export class TagDropdownWrapper extends BaseComponent {
);
if (!this.#addToNewButton.isConnected) {
this.#dropdownContainer.append(this.#addToNewButton);
this.#dropdownContainer?.append(this.#addToNewButton);
}
} else {
this.#addToNewButton?.remove();
@@ -130,15 +142,16 @@ export class TagDropdownWrapper extends BaseComponent {
const profileName = this.#activeProfile.settings.name;
let profileSpecificButtonText = `Add to profile "${profileName}"`;
const tagName = this.tagName;
if (this.#activeProfile.settings.tags.includes(this.tagName)) {
if (tagName && this.#activeProfile.settings.tags.includes(tagName)) {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
if (!this.#toggleOnExistingButton.isConnected) {
this.#dropdownContainer.append(this.#toggleOnExistingButton);
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
}
return;
@@ -148,6 +161,12 @@ export class TagDropdownWrapper extends BaseComponent {
}
async #onAddToNewClicked() {
const tagName = this.tagName;
if (!tagName) {
throw new Error('Missing tag name to create the profile!');
}
const profile = new MaintenanceProfile(crypto.randomUUID(), {
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
tags: [this.tagName],
@@ -166,6 +185,10 @@ export class TagDropdownWrapper extends BaseComponent {
const tagsList = new Set(this.#activeProfile.settings.tags);
const targetTagName = this.tagName;
if (!targetTagName) {
throw new Error('Missing tag name!');
}
if (tagsList.has(targetTagName)) {
tagsList.delete(targetTagName);
} else {
@@ -181,14 +204,14 @@ export class TagDropdownWrapper extends BaseComponent {
/**
* Watch for changes to active profile.
* @param {(profile: MaintenanceProfile|null) => void} onActiveProfileChange Callback to call when profile was
* @param onActiveProfileChange Callback to call when profile was
* changed.
*/
static #watchActiveProfile(onActiveProfileChange) {
let lastActiveProfile;
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
let lastActiveProfile: string | null = null;
this.#maintenanceSettings.subscribe((settings) => {
lastActiveProfile = settings.activeProfile;
lastActiveProfile = settings.activeProfile ?? null;
this.#maintenanceSettings
.resolveActiveProfileAsObject()
@@ -199,7 +222,8 @@ export class TagDropdownWrapper extends BaseComponent {
const activeProfile = profiles
.find(profile => profile.id === lastActiveProfile);
onActiveProfileChange(activeProfile);
onActiveProfileChange(activeProfile ?? null
);
});
this.#maintenanceSettings
@@ -212,12 +236,11 @@ export class TagDropdownWrapper extends BaseComponent {
/**
* Create element for dropdown.
* @param {string} text Base text for the option.
* @param {(event: MouseEvent) => void} onClickHandler Click handler. Event will be prevented by default.
* @return {HTMLAnchorElement}
* @param text Base text for the option.
* @param onClickHandler Click handler. Event will be prevented by default.
* @return
*/
static #createDropdownLink(text, onClickHandler) {
/** @type {HTMLAnchorElement} */
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
const dropdownLink = document.createElement('a');
dropdownLink.href = '#';
dropdownLink.innerText = text;
@@ -232,7 +255,7 @@ export class TagDropdownWrapper extends BaseComponent {
}
}
export function wrapTagDropdown(element) {
export function wrapTagDropdown(element: HTMLElement) {
// Skip initialization when tag component is already wrapped
if (getComponent(element)) {
return;
@@ -244,6 +267,8 @@ export function wrapTagDropdown(element) {
categoriesResolver.addElement(tagDropdown);
}
const processedElementsSet = new WeakSet<HTMLElement>();
export function watchTagDropdownsInTagsEditor() {
// We only need to watch for new editor elements if there is a tag editor present on the page
if (!document.querySelector('#image_tags_and_source')) {
@@ -251,26 +276,35 @@ export function watchTagDropdownsInTagsEditor() {
}
document.body.addEventListener('mouseover', event => {
/** @type {HTMLElement} */
const targetElement = event.target;
if (targetElement[isTagEditorProcessedKey]) {
if (!(targetElement instanceof HTMLElement)) {
return;
}
/** @type {HTMLElement|null} */
const closestTagEditor = targetElement.closest('#image_tags_and_source');
if (!closestTagEditor || closestTagEditor[isTagEditorProcessedKey]) {
targetElement[isTagEditorProcessedKey] = true;
if (processedElementsSet.has(targetElement)) {
return;
}
targetElement[isTagEditorProcessedKey] = true;
closestTagEditor[isTagEditorProcessedKey] = true;
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
for (const tagDropdownElement of closestTagEditor.querySelectorAll('.tag.dropdown')) {
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
processedElementsSet.add(targetElement);
return;
}
processedElementsSet.add(targetElement);
processedElementsSet.add(closestTagEditor);
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
})
});
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
wrapTagDropdown(tagDropdownElement);
}
});
}

View File

@@ -1,81 +0,0 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
export class TagsForm extends BaseComponent {
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll('.tag');
for (let tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName);
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return {Map<string, string>}
*/
#gatherTagCategories() {
/** @type {Map<string, string>} */
const tagCategories = new Map();
for (let tagElement of document.querySelectorAll('.tag[data-tag-name][data-tag-category]')) {
tagCategories.set(tagElement.dataset.tagName, tagElement.dataset.tagCategory);
}
return tagCategories;
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector('#tags-form');
/** @type {TagsForm|null} */
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || (!tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
tagEditor.refreshTagColors();
});
}
}

View File

@@ -0,0 +1,150 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms";
import { EVENT_FETCH_COMPLETE } from "$lib/components/events/booru-events";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
export class TagsForm extends BaseComponent {
protected init() {
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
const unsubscribe = on(
this.container,
EVENT_FETCH_COMPLETE,
() => this.#waitAndDetectUpdatedForm(unsubscribe),
);
}
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
const elementContainingTagEditor = this.container
.closest('#image_tags_and_source')
?.parentElement;
if (!elementContainingTagEditor) {
return;
}
const observer = new MutationObserver(() => {
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
if (!tagsFormElement || getComponent(tagsFormElement)) {
return;
}
const tagFormComponent = new TagsForm(tagsFormElement);
tagFormComponent.initialize();
const fullTagEditor = tagFormComponent.parentTagEditorElement;
if (fullTagEditor) {
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
} else {
console.info('Tag form is not in the tag editor. Event is not sent.');
}
observer.disconnect();
unsubscribe();
});
observer.observe(elementContainingTagEditor, {
subtree: true,
childList: true,
});
// Make sure to forcibly disconnect everything after a while.
setTimeout(() => {
observer.disconnect();
unsubscribe();
}, 5000);
}
get parentTagEditorElement(): HTMLElement | null {
return this.container.closest<HTMLElement>('.js-tagsauce')
}
/**
* Collect all the tag categories available on the page and color the tags in the editor according to them.
*/
refreshTagColors() {
const tagCategories = this.#gatherTagCategories();
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
for (const tagElement of editableTags) {
// Tag name is stored in the "remove" link and not in the tag itself.
const removeLink = tagElement.querySelector('a');
if (!removeLink) {
continue;
}
const tagName = removeLink.dataset.tagName;
if (!tagName || !tagCategories.has(tagName)) {
continue;
}
const categoryName = tagCategories.get(tagName)!;
tagElement.dataset.tagCategory = categoryName;
tagElement.setAttribute('data-tag-category', categoryName);
}
}
/**
* Collect list of categories from the tags on the page.
* @return
*/
#gatherTagCategories(): Map<string, string> {
const tagCategories: Map<string, string> = new Map();
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
const tagName = tagElement.dataset.tagName;
const tagCategory = tagElement.dataset.tagCategory;
if (!tagName || !tagCategory) {
console.warn('Missing tag name or category!');
continue;
}
tagCategories.set(tagName, tagCategory);
}
return tagCategories;
}
static watchForEditors() {
document.body.addEventListener('click', event => {
const targetElement = event.target;
if (!(targetElement instanceof HTMLElement)) {
return;
}
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
if (!tagEditorWrapper) {
return;
}
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
if (!refreshTrigger) {
return;
}
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
if (!tagFormElement) {
return;
}
let tagEditor = getComponent(tagFormElement);
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
tagEditor = new TagsForm(tagFormElement);
tagEditor.initialize();
}
(tagEditor as TagsForm).refreshTagColors();
});
}
}

View File

@@ -0,0 +1,243 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import type TagGroup from "$entities/TagGroup";
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import { on } from "$lib/components/events/comms";
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
import { getComponent } from "$lib/components/base/component-utils";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
import TagSettings from "$lib/extension/settings/TagSettings";
export class TagsListBlock extends BaseComponent {
#tagsListButtonsContainer: HTMLElement | null = null;
#tagsListContainer: HTMLElement | null = null;
#toggleGroupingButton = document.createElement('a');
#toggleGroupingButtonIcon = document.createElement('i');
#tagSettings = new TagSettings();
#shouldDisplaySeparation = false;
#separatedGroups = new Map<string, TagGroup>();
#separatedHeaders = new Map<string, HTMLElement>();
#groupsCount = new Map<string, number>();
#lastTagGroup = new WeakMap<TagDropdownWrapper, TagGroup | null>;
#isReorderingPlanned = false;
protected build() {
this.#tagsListButtonsContainer = this.container.querySelector('.block.tagsauce .block__header__buttons');
this.#tagsListContainer = this.container.querySelector('.tag-list');
this.#toggleGroupingButton.innerText = ' Grouping';
this.#toggleGroupingButton.href = 'javascript:void(0)';
this.#toggleGroupingButton.classList.add('button', 'button--link', 'button--inline');
this.#toggleGroupingButton.title = 'Toggle the global groups separation option. This will only toggle global ' +
'setting without changing the separation of specific groups.';
this.#toggleGroupingButtonIcon.classList.add('fas', TagsListBlock.#iconGroupingDisabled);
this.#toggleGroupingButton.prepend(this.#toggleGroupingButtonIcon);
if (this.#tagsListButtonsContainer) {
this.#tagsListButtonsContainer.append(this.#toggleGroupingButton);
}
}
init() {
this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this));
this.#tagSettings.subscribe(settings => {
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
});
on(
this,
EVENT_TAG_GROUP_RESOLVED,
this.#onTagDropdownCustomGroupResolved.bind(this)
);
this.#toggleGroupingButton.addEventListener('click', this.#onToggleGroupingClicked.bind(this));
}
#onTagSeparationChange(isSeparationEnabled: boolean) {
if (this.#shouldDisplaySeparation === isSeparationEnabled) {
return;
}
this.#shouldDisplaySeparation = isSeparationEnabled;
this.#reorderSeparatedGroups();
this.#updateToggleSeparationButton();
}
#updateToggleSeparationButton() {
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingEnabled, this.#shouldDisplaySeparation);
this.#toggleGroupingButtonIcon.classList.toggle(TagsListBlock.#iconGroupingDisabled, !this.#shouldDisplaySeparation);
}
#onTagDropdownCustomGroupResolved(resolvedCustomGroupEvent: CustomEvent<TagGroup | null>) {
const maybeDropdownElement = resolvedCustomGroupEvent.target;
if (!(maybeDropdownElement instanceof HTMLElement)) {
return;
}
const tagDropdown = getComponent<TagDropdownWrapper>(maybeDropdownElement);
if (!tagDropdown) {
return;
}
const tagGroup = resolvedCustomGroupEvent.detail;
if (tagGroup) {
this.#handleTagGroupChanges(tagGroup);
}
this.#handleResolvedTagGroup(tagGroup, tagDropdown);
if (!this.#isReorderingPlanned) {
this.#isReorderingPlanned = true;
requestAnimationFrame(this.#reorderSeparatedGroups.bind(this));
}
}
#onToggleGroupingClicked(event: Event) {
event.preventDefault();
void this.#tagSettings.setGroupSeparation(!this.#shouldDisplaySeparation);
}
#handleTagGroupChanges(tagGroup: TagGroup) {
const groupId = tagGroup.id;
const processedGroup = this.#separatedGroups.get(groupId);
if (!tagGroup.settings.separate && processedGroup) {
this.#separatedGroups.delete(groupId);
this.#separatedHeaders.get(groupId)?.remove();
this.#separatedHeaders.delete(groupId);
return;
}
// Every time group is updated, a new object is being initialized
if (tagGroup !== processedGroup) {
this.#createOrUpdateHeaderForGroup(tagGroup);
this.#separatedGroups.set(groupId, tagGroup);
}
}
#createOrUpdateHeaderForGroup(group: TagGroup) {
let heading = this.#separatedHeaders.get(group.id);
if (!heading) {
heading = document.createElement('h2');
// Heading is hidden by default and shown on next frame if there are tags to show in the section.
heading.style.display = 'none';
heading.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(group.id)}, 0)`;
heading.style.flexBasis = '100%';
// We're inserting heading to the top just to make sure that heading is always in front of the tags related to
// this category.
this.#tagsListContainer?.insertAdjacentElement('afterbegin', heading);
this.#separatedHeaders.set(group.id, heading);
}
heading.innerText = group.settings.name;
}
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdownWrapper) {
const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id;
const currentGroupId = resolvedGroup?.id;
const isDifferentId = currentGroupId !== previousGroupId;
const isSeparationEnabled = resolvedGroup?.settings.separate;
if (isDifferentId) {
// Make sure to subtract the element from counters if there was a count before.
if (previousGroupId && this.#groupsCount.has(previousGroupId)) {
this.#groupsCount.set(previousGroupId, this.#groupsCount.get(previousGroupId)! - 1);
}
// We only need to count groups which have separation enabled.
if (currentGroupId && isSeparationEnabled) {
const count = this.#groupsCount.get(resolvedGroup.id) ?? 0;
this.#groupsCount.set(currentGroupId, count + 1);
}
}
// We're adding the CSS order for the tag as the CSS variable. This variable is updated later.
if (currentGroupId && isSeparationEnabled) {
tagComponent.container.style.order = `var(${TagsListBlock.#orderCssVariableForGroup(currentGroupId)}, 0)`;
} else {
tagComponent.container.style.removeProperty('order');
}
// If separation is disabled in the new group, then we should remove the tag from map, so it can be recaptured
// when tag group is getting enabled later.
if (currentGroupId && !isSeparationEnabled) {
this.#lastTagGroup.delete(tagComponent);
return;
}
// Mark this tag component as related to the following group.
this.#lastTagGroup.set(tagComponent, resolvedGroup);
}
#reorderSeparatedGroups() {
this.#isReorderingPlanned = false;
const tagGroups = Array.from(this.#separatedGroups.values())
.toSorted((a, b) => a.settings.name.localeCompare(b.settings.name));
for (let index = 0; index < tagGroups.length; index++) {
const tagGroup = tagGroups[index];
const groupId = tagGroup.id;
const usedCount = this.#groupsCount.get(groupId);
const relatedHeading = this.#separatedHeaders.get(groupId);
if (this.#shouldDisplaySeparation) {
this.container.style.setProperty(TagsListBlock.#orderCssVariableForGroup(groupId), (index + 1).toString());
} else {
this.container.style.removeProperty(TagsListBlock.#orderCssVariableForGroup(groupId));
}
if (relatedHeading) {
if (!this.#shouldDisplaySeparation || !usedCount || usedCount <= 0) {
relatedHeading.style.display = 'none';
} else {
relatedHeading.style.removeProperty('display');
}
}
}
}
static #orderCssVariableForGroup(groupId: string): string {
return `--ta-order-${groupId}`;
}
static #iconGroupingDisabled = 'fa-folder';
static #iconGroupingEnabled = 'fa-folder-tree';
}
export function initializeAllTagsLists() {
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
if (getComponent(element)) {
return;
}
new TagsListBlock(element)
.initialize();
}
}
export function watchForUpdatedTagLists() {
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
if (!tagsListElement || getComponent(tagsListElement)) {
return;
}
new TagsListBlock(tagsListElement)
.initialize();
});
}

View File

@@ -1,18 +1,14 @@
import { bindComponent } from "$lib/components/base/component-utils";
/**
* @abstract
*/
export class BaseComponent {
/** @type {HTMLElement} */
#container;
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;
export class BaseComponent<ContainerType extends HTMLElement = HTMLElement> {
readonly #container: ContainerType;
#isInitialized = false;
/**
* @param {HTMLElement} container
*/
constructor(container) {
constructor(container: ContainerType) {
this.#container = container;
bindComponent(container, this);
@@ -29,42 +25,33 @@ export class BaseComponent {
this.init();
}
/**
* @protected
*/
build() {
protected build(): void {
// This method can be implemented by the component classes to modify or create the inner elements.
}
/**
* @protected
*/
init() {
protected init(): void {
// This method can be implemented by the component classes to initialize the component.
}
};
/**
* @return {HTMLElement}
*/
get container() {
get container(): ContainerType {
return this.#container;
}
/**
* Check if the component is initialized already. If not checked, subsequent calls to the `initialize` method will
* throw an error.
* @return {boolean}
* @return
*/
get isInitialized() {
get isInitialized(): boolean {
return this.#isInitialized;
}
/**
* Emit the custom event on the container element.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {any} [detail] The event detail. Can be omitted.
* @param event The event name.
* @param [detail] The event detail. Can be omitted.
*/
emit(event, detail = undefined) {
emit(event: keyof HTMLElementEventMap | string, detail: any = undefined): void {
this.#container.dispatchEvent(
new CustomEvent(
event,
@@ -78,12 +65,16 @@ export class BaseComponent {
/**
* Subscribe to the DOM event on the container element.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {function(Event): void} listener The event listener.
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
* @return {function(): void} The unsubscribe function.
* @param event The event name.
* @param listener The event listener.
* @param [options] The event listener options. Can be omitted.
* @return The unsubscribe function.
*/
on(event, listener, options = undefined) {
on<EventName extends keyof HTMLElementEventMap>(
event: EventName,
listener: ComponentEventListener<EventName>,
options?: AddEventListenerOptions,
): () => void {
this.#container.addEventListener(event, listener, options);
return () => void this.#container.removeEventListener(event, listener, options);
@@ -91,12 +82,16 @@ export class BaseComponent {
/**
* Subscribe to the DOM event on the container element. The event listener will be called only once.
* @param {keyof HTMLElementEventMap|string} event The event name.
* @param {function(Event): void} listener The event listener.
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
* @return {function(): void} The unsubscribe function.
* @param event The event name.
* @param listener The event listener.
* @param [options] The event listener options. Can be omitted.
* @return The unsubscribe function.
*/
once(event, listener, options = undefined) {
once<EventName extends keyof HTMLElementEventMap>(
event: EventName,
listener: ComponentEventListener<EventName>,
options?: AddEventListenerOptions,
): () => void {
options = options || {};
options.once = true;

View File

@@ -1,9 +1,9 @@
import type { BaseComponent } from "$lib/components/base/BaseComponent";
const instanceSymbol = Symbol('instance');
const instanceSymbol = Symbol.for('instance');
interface ElementWithComponent extends HTMLElement {
[instanceSymbol]?: BaseComponent;
interface ElementWithComponent<T> extends HTMLElement {
[instanceSymbol]?: T;
}
/**
@@ -11,7 +11,7 @@ interface ElementWithComponent extends HTMLElement {
* @param {HTMLElement} element
* @return
*/
export function getComponent(element: ElementWithComponent): BaseComponent | null {
export function getComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>): T | null {
return element[instanceSymbol] || null;
}
@@ -20,7 +20,7 @@ export function getComponent(element: ElementWithComponent): BaseComponent | nul
* @param element The element to bind the component to.
* @param instance The component instance.
*/
export function bindComponent(element: ElementWithComponent, instance: BaseComponent): void {
export function bindComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>, instance: T): void {
if (element[instanceSymbol]) {
throw new Error('The element is already bound to a component.');
}

View File

@@ -0,0 +1,5 @@
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
export interface BooruEventsMap {
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
}

View File

@@ -1,11 +1,19 @@
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events";
import type { BooruEventsMap } from "$lib/components/events/booru-events";
import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events";
import type { TagDropdownEvents } from "$lib/components/events/tag-dropdown-events";
interface EventsMapping extends MaintenancePopupEventsMap {
}
type EventsMapping =
MaintenancePopupEventsMap
& FullscreenViewerEventsMap
& BooruEventsMap
& TagsFormEventsMap
& TagDropdownEvents;
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
type UnsubscribeFunction = () => void;
export type UnsubscribeFunction = () => void;
type ResolvableTarget = EventTarget | BaseComponent;
function resolveTarget(componentOrElement: ResolvableTarget): EventTarget {

View File

@@ -0,0 +1,7 @@
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
export const EVENT_SIZE_LOADED = 'size-loaded';
export interface FullscreenViewerEventsMap {
[EVENT_SIZE_LOADED]: FullscreenViewerSize;
}

View File

@@ -1,13 +1,13 @@
import type MaintenanceProfile from "$entities/MaintenanceProfile";
export const eventActiveProfileChanged = 'active-profile-changed';
export const eventMaintenanceStateChanged = 'maintenance-state-change';
export const eventTagsUpdated = 'tags-updated';
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
export const EVENT_TAGS_UPDATED = 'tags-updated';
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
export interface MaintenancePopupEventsMap {
[eventActiveProfileChanged]: MaintenanceProfile | null;
[eventMaintenanceStateChanged]: MaintenanceState;
[eventTagsUpdated]: Map<string, string> | null;
[EVENT_ACTIVE_PROFILE_CHANGED]: MaintenanceProfile | null;
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
}

View File

@@ -0,0 +1,7 @@
import type TagGroup from "$entities/TagGroup";
export const EVENT_TAG_GROUP_RESOLVED = 'tag-group-resolved';
export interface TagDropdownEvents {
[EVENT_TAG_GROUP_RESOLVED]: TagGroup | null;
}

View File

@@ -0,0 +1,5 @@
export const EVENT_FORM_EDITOR_UPDATED = 'tags-form-updated';
export interface TagsFormEventsMap {
[EVENT_FORM_EDITOR_UPDATED]: HTMLElement;
}

View File

@@ -0,0 +1,19 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { ImageListInfo } from "$lib/components/listing/ImageListInfo";
export class ImageListContainer extends BaseComponent {
#info: ImageListInfo | null = null;
protected build() {
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
if (imageListInfoContainer) {
this.#info = new ImageListInfo(imageListInfoContainer);
this.#info.initialize();
}
}
}
export function initializeImageListContainer(element: HTMLElement) {
new ImageListContainer(element).initialize();
}

View File

@@ -0,0 +1,75 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
export class ImageListInfo extends BaseComponent {
#tagElement: HTMLElement | null = null;
#impliedTags: string[] = [];
#showUntaggedImplicationsButton: HTMLAnchorElement = document.createElement('a');
protected build() {
const sectionAfterImage = this.container.querySelector('.tag-info__image + .flex__grow');
this.#tagElement = sectionAfterImage?.querySelector<HTMLElement>('.tag.dropdown') ?? null;
const labels = this.container
.querySelectorAll<HTMLElement>('.tag-info__image + .flex__grow strong');
let targetElementToInsertBefore: HTMLElement | null = null;
for (const potentialListStarter of labels) {
if (potentialListStarter.innerText === ImageListInfo.#implicationsStarterText) {
targetElementToInsertBefore = potentialListStarter;
this.#collectImplicationsFromListStarter(potentialListStarter);
break;
}
}
if (this.#impliedTags.length && targetElementToInsertBefore) {
this.#showUntaggedImplicationsButton.href = '#';
this.#showUntaggedImplicationsButton.innerText = '(Q)';
this.#showUntaggedImplicationsButton.title =
'Query untagged implications\n\n' +
'This will open the search results with all untagged implications for the current tag.';
this.#showUntaggedImplicationsButton.classList.add('detail-link');
targetElementToInsertBefore.insertAdjacentElement('beforebegin', this.#showUntaggedImplicationsButton);
}
}
protected init() {
this.#showUntaggedImplicationsButton.addEventListener('click', this.#onShowUntaggedImplicationsClicked.bind(this));
}
#collectImplicationsFromListStarter(listStarter: HTMLElement) {
let targetElement: Element | null = listStarter.nextElementSibling;
while (targetElement) {
if (targetElement instanceof HTMLAnchorElement) {
this.#impliedTags.push(targetElement.innerText.trim());
}
// First line break is considered the end of the list.
if (targetElement instanceof HTMLBRElement) {
break;
}
targetElement = targetElement.nextElementSibling;
}
}
#onShowUntaggedImplicationsClicked(event: Event) {
event.preventDefault();
const url = new URL(window.location.href);
url.pathname = '/search';
url.search = '';
const currentTagName = this.#tagElement?.dataset.tagName;
url.searchParams.set('q', `${currentTagName}, !(${this.#impliedTags.join(", ")})`);
location.assign(url.href);
}
static #implicationsStarterText = 'Implies:';
}

View File

@@ -2,12 +2,16 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag
export default class ConfigurationController {
readonly #configurationName: string;
readonly #storage: StorageHelper;
/**
* @param {string} configurationName Name of the configuration to work with.
* @param {StorageHelper} [storage] Selected storage where the settings are stored in. If not provided, local storage
* is used.
*/
constructor(configurationName: string) {
constructor(configurationName: string, storage: StorageHelper = new StorageHelper(chrome.storage.local)) {
this.#configurationName = configurationName;
this.#storage = storage;
}
/**
@@ -19,7 +23,7 @@ export default class ConfigurationController {
* @return The setting value or the default value if the setting does not exist.
*/
async readSetting<Type = any, DefaultType = any>(settingName: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
return settings[settingName] ?? defaultValue;
}
@@ -32,11 +36,11 @@ export default class ConfigurationController {
* @return {Promise<void>}
*/
async writeSetting(settingName: string, value: any): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
settings[settingName] = value;
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
this.#storage.write(this.#configurationName, settings);
}
/**
@@ -45,11 +49,11 @@ export default class ConfigurationController {
* @param {string} settingName Setting name to delete.
*/
async deleteSetting(settingName: string): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
const settings = await this.#storage.read(this.#configurationName, {});
delete settings[settingName];
ConfigurationController.#storageHelper.write(this.#configurationName, settings);
this.#storage.write(this.#configurationName, settings);
}
/**
@@ -69,10 +73,8 @@ export default class ConfigurationController {
callback(changes[this.#configurationName].newValue);
}
ConfigurationController.#storageHelper.subscribe(subscriber);
this.#storage.subscribe(subscriber);
return () => ConfigurationController.#storageHelper.unsubscribe(subscriber);
return () => this.#storage.unsubscribe(subscriber);
}
static #storageHelper = new StorageHelper(chrome.storage.local);
}

View File

@@ -1,12 +1,14 @@
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import TagGroup from "$entities/TagGroup";
import { escapeRegExp } from "$lib/utils";
import { emit } from "$lib/components/events/comms";
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
export default class CustomCategoriesResolver {
#tagCategories = new Map<string, string>();
#compiledRegExps = new Map<RegExp, string>();
#exactGroupMatches = new Map<string, TagGroup>();
#regExpGroupMatches = new Map<RegExp, TagGroup>();
#tagDropdowns: TagDropdownWrapper[] = [];
#nextQueuedUpdate = -1;
#nextQueuedUpdate: Timeout | null = null;
constructor() {
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
@@ -16,7 +18,7 @@ export default class CustomCategoriesResolver {
public addElement(tagDropdown: TagDropdownWrapper): void {
this.#tagDropdowns.push(tagDropdown);
if (!this.#tagCategories.size && !this.#compiledRegExps.size) {
if (!this.#exactGroupMatches.size && !this.#regExpGroupMatches.size) {
return;
}
@@ -24,7 +26,9 @@ export default class CustomCategoriesResolver {
}
#queueUpdatingTags() {
clearTimeout(this.#nextQueuedUpdate);
if (this.#nextQueuedUpdate) {
clearTimeout(this.#nextQueuedUpdate);
}
this.#nextQueuedUpdate = setTimeout(
this.#updateUnprocessedTags.bind(this),
@@ -34,7 +38,6 @@ export default class CustomCategoriesResolver {
#updateUnprocessedTags() {
this.#tagDropdowns
.filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory)
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
.filter(this.#matchCustomCategoryByRegExp.bind(this))
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
@@ -49,23 +52,33 @@ export default class CustomCategoriesResolver {
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
const tagName = tagDropdown.tagName!;
if (!this.#tagCategories.has(tagName)) {
if (!this.#exactGroupMatches.has(tagName)) {
return true;
}
tagDropdown.tagCategory = this.#tagCategories.get(tagName)!;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
this.#exactGroupMatches.get(tagName)!
);
return false;
}
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
const tagName = tagDropdown.tagName!;
for (const targetRegularExpression of this.#compiledRegExps.keys()) {
for (const targetRegularExpression of this.#regExpGroupMatches.keys()) {
if (!targetRegularExpression.test(tagName)) {
continue;
}
tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
this.#regExpGroupMatches.get(targetRegularExpression)!
);
return false;
}
@@ -73,24 +86,29 @@ export default class CustomCategoriesResolver {
}
#onTagGroupsReceived(tagGroups: TagGroup[]) {
this.#tagCategories.clear();
this.#compiledRegExps.clear();
this.#exactGroupMatches.clear();
this.#regExpGroupMatches.clear();
if (!tagGroups.length) {
return;
}
for (const tagGroup of tagGroups) {
const categoryName = tagGroup.settings.category;
for (const tagName of tagGroup.settings.tags) {
this.#tagCategories.set(tagName, categoryName);
this.#exactGroupMatches.set(tagName, tagGroup);
}
for (const tagPrefix of tagGroup.settings.prefixes) {
this.#compiledRegExps.set(
this.#regExpGroupMatches.set(
new RegExp(`^${escapeRegExp(tagPrefix)}`),
categoryName
tagGroup,
);
}
for (let tagSuffix of tagGroup.settings.suffixes) {
this.#regExpGroupMatches.set(
new RegExp(`${escapeRegExp(tagSuffix)}$`),
tagGroup,
);
}
}
@@ -98,12 +116,12 @@ export default class CustomCategoriesResolver {
this.#queueUpdatingTags();
}
static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean {
return !tagDropdown.originalCategory;
}
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
tagDropdown.tagCategory = tagDropdown.originalCategory;
emit(
tagDropdown,
EVENT_TAG_GROUP_RESOLVED,
null,
);
}
static #unprocessedTagsTimeout = 0;

View File

@@ -4,7 +4,9 @@ export interface TagGroupSettings {
name: string;
tags: string[];
prefixes: string[];
suffixes: string[];
category: string;
separate: boolean;
}
export default class TagGroup extends StorageEntity<TagGroupSettings> {
@@ -13,7 +15,9 @@ export default class TagGroup extends StorageEntity<TagGroupSettings> {
name: settings.name || '',
tags: settings.tags || [],
prefixes: settings.prefixes || [],
category: settings.category || ''
suffixes: settings.suffixes || [],
category: settings.category || '',
separate: Boolean(settings.separate),
});
}

View File

@@ -1,6 +1,6 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full';
export type FullscreenViewerSize = keyof App.ImageURIs;
interface MiscSettingsFields {
fullscreenViewer: boolean;

View File

@@ -1,8 +1,10 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
export type SuggestionsPosition = "start" | "end";
interface SearchSettingsFields {
suggestProperties: boolean;
suggestPropertiesPosition: "start" | "end";
suggestPropertiesPosition: SuggestionsPosition;
}
export default class SearchSettings extends CacheableSettings<SearchSettingsFields> {

View File

@@ -0,0 +1,19 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
interface TagSettingsFields {
groupSeparation: boolean;
}
export default class TagSettings extends CacheableSettings<TagSettingsFields> {
constructor() {
super("tag");
}
async resolveGroupSeparation() {
return this._resolveSetting("groupSeparation", true);
}
async setGroupSeparation(value: boolean) {
return this._writeSetting("groupSeparation", Boolean(value));
}
}

View File

@@ -20,6 +20,9 @@ const entitiesExporters: ExportersMap = {
name: entity.settings.name,
tags: entity.settings.tags,
prefixes: entity.settings.prefixes,
suffixes: entity.settings.suffixes,
category: entity.settings.category,
separate: entity.settings.separate,
}
}
};

48
src/lib/popup-links.ts Normal file
View File

@@ -0,0 +1,48 @@
function resolveReplaceableLink(target: EventTarget | null = null): HTMLAnchorElement | null {
if (!(target instanceof HTMLElement)) {
return null;
}
const closestLink = target.closest('a');
if (
closestLink instanceof HTMLAnchorElement
&& !closestLink.search
&& closestLink.origin === location.origin
) {
return closestLink;
}
return null;
}
function replaceLink(linkElement: HTMLAnchorElement) {
const params = new URLSearchParams([
['path', linkElement.pathname]
]);
linkElement.search = params.toString();
linkElement.pathname = "/index.html";
}
export function initializeLinksReplacement(): () => void {
const abortController = new AbortController();
const replacementHandler = (event: Event) => {
const closestLink = resolveReplaceableLink(event.target);
if (closestLink) {
replaceLink(closestLink);
}
}
// Dynamically replace the links from the Svelte default links to the links usable for the popup.
document.body.addEventListener('mousedown', replacementHandler, {
signal: abortController.signal,
});
document.body.addEventListener('click', replacementHandler, {
signal: abortController.signal,
})
return () => abortController.abort();
}

View File

@@ -1,21 +1,35 @@
<script>
import "../styles/popup.scss";
import Header from "$components/layout/Header.svelte";
import Footer from "$components/layout/Footer.svelte";
<script lang="ts">
import "../styles/popup.scss";
import Header from "$components/layout/Header.svelte";
import Footer from "$components/layout/Footer.svelte";
import { initializeLinksReplacement } from "$lib/popup-links";
import { onDestroy } from "svelte";
// Sort of a hack, detect if we rendered in the browser tab or in the popup.
// Popup will always should have fixed 320px size, otherwise we consider it opened in the tab.
document.body.classList.toggle('is-in-tab', window.innerWidth > 320);
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
// Sort of a hack, detect if we rendered in the browser tab or in the popup.
// Popup will always should have fixed 320px size, otherwise we consider it opened in the tab.
document.body.classList.toggle('is-in-tab', window.innerWidth > 320);
const disconnectLinkReplacement = initializeLinksReplacement();
onDestroy(() => {
disconnectLinkReplacement();
})
</script>
<Header/>
<main>
<slot/>
{@render children?.()}
</main>
<Footer/>
<style lang="scss" global>
main {
padding: .5em 24px;
}
<style global lang="scss">
main {
padding: .5em 24px;
}
</style>

View File

@@ -1,29 +1,29 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile";
/** @type {import('$entities/MaintenanceProfile').default|undefined} */
let activeProfile;
let activeProfile = $derived<MaintenanceProfile | null>(
$maintenanceProfiles.find(profile => profile.id === $activeProfileStore) || null
);
$: activeProfile = $maintenanceProfilesStore.find(profile => profile.id === $activeProfileStore);
function turnOffActiveProfile() {
$activeProfileStore = null;
}
function turnOffActiveProfile() {
$activeProfileStore = null;
}
</script>
<Menu>
{#if activeProfile}
<MenuCheckboxItem checked on:input={turnOffActiveProfile} href="/features/maintenance/{activeProfile.id}">
Active Profile: {activeProfile.settings.name}
</MenuCheckboxItem>
<hr>
{/if}
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
<MenuItem href="/features/groups">Tag Groups</MenuItem>
{#if activeProfile}
<MenuCheckboxItem checked oninput={turnOffActiveProfile} href="/features/maintenance/{activeProfile.id}">
Active Profile: {activeProfile.settings.name}
</MenuCheckboxItem>
<hr>
<MenuItem href="/preferences">Preferences</MenuItem>
<MenuItem href="/about">About</MenuItem>
{/if}
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
<MenuItem href="/features/groups">Tag Groups</MenuItem>
<hr>
<MenuItem href="/preferences">Preferences</MenuItem>
<MenuItem href="/about">About</MenuItem>
</Menu>

View File

@@ -1,25 +1,25 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
</script>
<Menu>
<MenuItem icon="arrow-left" href="/">Back</MenuItem>
<hr>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<h1>
Furbooru Tagging Assistant
Furbooru Tagging Assistant
</h1>
<p>
This is a tool made to help tag images on Furbooru more efficiently. It is currently in development and is not yet
ready for use, but it still can provide some useful functionality.
This is a tool made to help tag images on Furbooru more efficiently. It is currently in development and is not yet
ready for use, but it still can provide some useful functionality.
</p>
<Menu>
<hr>
<MenuItem icon="globe" href="https://furbooru.org" target="_blank">
Visit Furbooru
</MenuItem>
<MenuItem icon="info-circle" href="https://github.com/koloml/furbooru-tagging-assistant" target="_blank">
GitHub Repo
</MenuItem>
<hr>
<MenuItem href="https://furbooru.org" icon="globe" target="_blank">
Visit Furbooru
</MenuItem>
<MenuItem href="https://github.com/koloml/furbooru-tagging-assistant" icon="info-circle" target="_blank">
GitHub Repo
</MenuItem>
</Menu>

View File

@@ -1,10 +1,10 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
</script>
<Menu>
<MenuItem href="/">Back</MenuItem>
<hr>
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
<MenuItem href="/">Back</MenuItem>
<hr>
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
</Menu>

View File

@@ -1,23 +1,21 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroupsStore } from "$stores/tag-groups-store";
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroups } from "$stores/entities/tag-groups";
import TagGroup from "$entities/TagGroup";
/** @type {import('$entities/TagGroup').default[]} */
let groups = [];
$: groups = $tagGroupsStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));
let groups = $derived<TagGroup[]>($tagGroups.sort((a, b) => a.settings.name.localeCompare(b.settings.name)));
</script>
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/groups/new/edit" icon="plus">Create New</MenuItem>
{#if groups.length}
<hr>
{#each groups as group}
<MenuItem href="/features/groups/{group.id}">{group.settings.name}</MenuItem>
{/each}
{/if}
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/groups/new/edit" icon="plus">Create New</MenuItem>
{#if groups.length}
<hr>
<MenuItem href="/features/groups/import">Import Group</MenuItem>
{#each groups as group}
<MenuItem href="/features/groups/{group.id}">{group.settings.name}</MenuItem>
{/each}
{/if}
<hr>
<MenuItem href="/features/groups/import">Import Group</MenuItem>
</Menu>

View File

@@ -1,39 +1,38 @@
<script>
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import GroupView from "$components/features/GroupView.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroupsStore } from "$stores/tag-groups-store";
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import GroupView from "$components/features/GroupView.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroups } from "$stores/entities/tag-groups";
import TagGroup from "$entities/TagGroup";
const groupId = $page.params.id;
/** @type {import('$entities/TagGroup').default|null} */
let group = null;
let groupId = $derived<string>(page.params.id);
let group = $derived<TagGroup | null>($tagGroups.find(group => group.id === groupId) || null);
$effect(() => {
if (groupId === 'new') {
goto('/features/groups/new/edit');
goto('/features/groups/new/edit');
return;
}
$: {
group = $tagGroupsStore.find(group => group.id === groupId) || null;
if (!group) {
console.warn(`Group ${groupId} not found.`);
goto('/features/groups');
}
if (!group) {
console.warn(`Group ${groupId} not found.`);
goto('/features/groups');
}
})
</script>
<Menu>
<MenuItem href="/features/groups" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/features/groups" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if group}
<GroupView {group}/>
<GroupView {group}/>
{/if}
<Menu>
<hr>
<MenuItem href="/features/groups/{groupId}/edit" icon="wrench">Edit Group</MenuItem>
<MenuItem href="/features/groups/{groupId}/export" icon="file-export">Export Group</MenuItem>
<MenuItem href="/features/groups/{groupId}/delete" icon="trash">Delete Group</MenuItem>
<hr>
<MenuItem href="/features/groups/{groupId}/edit" icon="wrench">Edit Group</MenuItem>
<MenuItem href="/features/groups/{groupId}/export" icon="file-export">Export Group</MenuItem>
<MenuItem href="/features/groups/{groupId}/delete" icon="trash">Delete Group</MenuItem>
</Menu>

View File

@@ -1,41 +1,44 @@
<script>
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroupsStore } from "$stores/tag-groups-store";
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { tagGroups } from "$stores/entities/tag-groups";
import type TagGroup from "$entities/TagGroup";
const groupId = $page.params.id;
const targetGroup = $tagGroupsStore.find(group => group.id === groupId);
const groupId = $derived<string>(page.params.id);
const targetGroup = $derived<TagGroup | undefined>($tagGroups.find(group => group.id === groupId));
$effect(() => {
if (!targetGroup) {
void goto('/features/groups');
goto('/features/groups');
}
})
async function deleteGroup() {
if (!targetGroup) {
console.warn('Attempting to delete the group, but the group is not loaded yet.');
return;
}
async function deleteGroup() {
if (!targetGroup) {
console.warn('Attempting to delete the group, but the group is not loaded yet.');
return;
}
await targetGroup.delete();
await goto('/features/groups');
}
await targetGroup.delete();
await goto('/features/groups');
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/features/groups/{groupId}">Back</MenuItem>
<hr>
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if targetGroup}
<p>
Do you want to remove group "{targetGroup.settings.name}"? This action is irreversible.
</p>
<Menu>
<hr>
<MenuItem on:click={deleteGroup}>Yes</MenuItem>
<MenuItem href="/features/groups/{groupId}">No</MenuItem>
</Menu>
<p>
Do you want to remove group "{targetGroup.settings.name}"? This action is irreversible.
</p>
<Menu>
<hr>
<MenuItem onclick={deleteGroup}>Yes</MenuItem>
<MenuItem href="/features/groups/{groupId}">No</MenuItem>
</Menu>
{:else}
<p>Loading...</p>
<p>Loading...</p>
{/if}

View File

@@ -1,82 +1,112 @@
<script>
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import TagCategorySelectField from "$components/ui/forms/TagCategorySelectField.svelte";
import TextField from "$components/ui/forms/TextField.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagsEditor from "$components/tags/TagsEditor.svelte";
import TagGroup from "$entities/TagGroup";
import { tagGroupsStore } from "$stores/tag-groups-store";
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import TagCategorySelectField from "$components/ui/forms/TagCategorySelectField.svelte";
import TextField from "$components/ui/forms/TextField.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagsEditor from "$components/tags/TagsEditor.svelte";
import TagGroup from "$entities/TagGroup";
import { tagGroups } from "$stores/entities/tag-groups";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
const groupId = $page.params.id;
/** @type {TagGroup|null} */
let targetGroup = null;
let groupName = '';
/** @type {string[]} */
let tagsList = [];
/** @type {string[]} */
let prefixesList = [];
let tagCategory = '';
let groupId = $derived(page.params.id);
let targetGroup = $derived.by<TagGroup | null>(() => {
if (groupId === 'new') {
targetGroup = new TagGroup(crypto.randomUUID(), {});
} else {
targetGroup = $tagGroupsStore.find(group => group.id === groupId) || null;
if (targetGroup) {
groupName = targetGroup.settings.name;
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
tagCategory = targetGroup.settings.category;
} else {
goto('/features/groups');
}
return new TagGroup(crypto.randomUUID(), {});
}
async function saveGroup() {
if (!targetGroup) {
console.warn('Attempting to save group, but group is not loaded yet.');
return;
}
return $tagGroups.find(group => group.id === groupId) || null;
});
targetGroup.settings.name = groupName;
targetGroup.settings.tags = [...tagsList];
targetGroup.settings.prefixes = [...prefixesList];
targetGroup.settings.category = tagCategory;
let groupName = $state<string>('');
let tagsList = $state<string[]>([]);
let prefixesList = $state<string[]>([]);
let suffixesList = $state<string[]>([]);
let tagCategory = $state<string>('')
let separateGroup = $state<boolean>(false);
await targetGroup.save();
await goto(`/features/groups/${targetGroup.id}`);
$effect(() => {
if (groupId === 'new') {
return;
}
if (!targetGroup) {
goto('/features/groups');
return;
}
groupName = targetGroup.settings.name;
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
suffixesList = [...targetGroup.settings.suffixes].sort((a, b) => a.localeCompare(b));
tagCategory = targetGroup.settings.category;
separateGroup = targetGroup.settings.separate;
});
async function saveGroup() {
if (!targetGroup) {
console.warn('Attempting to save group, but group is not loaded yet.');
return;
}
targetGroup.settings.name = groupName;
targetGroup.settings.tags = [...tagsList];
targetGroup.settings.prefixes = [...prefixesList];
targetGroup.settings.suffixes = [...suffixesList];
targetGroup.settings.category = tagCategory;
targetGroup.settings.separate = separateGroup;
await targetGroup.save();
await goto(`/features/groups/${targetGroup.id}`);
}
function mapPrefixNames(tagName: string): string {
return `${tagName}*`;
}
function mapSuffixNames(tagName: string): string {
return `*${tagName}`;
}
</script>
<Menu>
<MenuItem href="/features/groups/${groupId}" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/features/groups/{groupId === 'new' ? '' : groupId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Group Name">
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
<FormControl label="Group Name">
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={separateGroup}>
Display tags found by this group in separate list after all other tags.
</CheckboxField>
</FormControl>
<FormControl label="Group Color">
<TagCategorySelectField bind:value={tagCategory}/>
</FormControl>
<TagsColorContainer targetCategory={tagCategory}>
<FormControl label="Tags">
<TagsEditor bind:tags={tagsList}/>
</FormControl>
<FormControl label="Group Color">
<TagCategorySelectField bind:value={tagCategory}/>
</TagsColorContainer>
<TagsColorContainer targetCategory={tagCategory}>
<FormControl label="Tag Prefixes">
<TagsEditor bind:tags={prefixesList} mapTagNames={mapPrefixNames}/>
</FormControl>
<TagsColorContainer targetCategory="{tagCategory}">
<FormControl label="Tags">
<TagsEditor bind:tags={tagsList}/>
</FormControl>
</TagsColorContainer>
<TagsColorContainer targetCategory="{tagCategory}">
<FormControl label="Tag Prefixes">
<TagsEditor bind:tags={prefixesList}/>
</FormControl>
</TagsColorContainer>
</TagsColorContainer>
<TagsColorContainer targetCategory={tagCategory}>
<FormControl label="Tag Suffixes">
<TagsEditor bind:tags={suffixesList} mapTagNames={mapSuffixNames}/>
</FormControl>
</TagsColorContainer>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={saveGroup}>Save Group</MenuItem>
<hr>
<MenuItem onclick={saveGroup}>Save Group</MenuItem>
</Menu>

View File

@@ -1,50 +1,53 @@
<script>
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagGroup from "$entities/TagGroup";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
import { tagGroupsStore } from "$stores/tag-groups-store";
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagGroup from "$entities/TagGroup";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
import { tagGroups } from "$stores/entities/tag-groups";
const groupId = $page.params.id;
const groupTransporter = new EntitiesTransporter(TagGroup);
const group = $tagGroupsStore.find(group => group.id === groupId);
let isEncodedGroupShown = $state(true);
/** @type {string} */
let rawExportedGroup;
/** @type {string} */
let encodedExportedGroup;
const groupId = $derived<string>(page.params.id);
const group = $derived<TagGroup | undefined>($tagGroups.find(group => group.id === groupId));
$effect(() => {
if (!group) {
goto('/features/groups');
} else {
rawExportedGroup = groupTransporter.exportToJSON(group);
encodedExportedGroup = groupTransporter.exportToCompressedJSON(group);
goto('/features/groups');
}
});
let isEncodedGroupShown = true;
const groupTransporter = new EntitiesTransporter(TagGroup);
let rawExportedGroup = $derived<string>(group ? groupTransporter.exportToJSON(group) : '');
let encodedExportedGroup = $derived<string>(group ? groupTransporter.exportToCompressedJSON(group) : '');
let selectedExportString = $derived<string>(isEncodedGroupShown ? encodedExportedGroup : rawExportedGroup);
function toggleEncoding() {
isEncodedGroupShown = !isEncodedGroupShown;
}
</script>
<Menu>
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Export string">
<textarea readonly rows="6">{isEncodedGroupShown ? encodedExportedGroup : rawExportedGroup}</textarea>
</FormControl>
<FormControl label="Export string">
<textarea readonly rows="6">{selectedExportString}</textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={() => isEncodedGroupShown = !isEncodedGroupShown}>
Switch Format:
{#if isEncodedGroupShown}
Base64-Encoded
{:else}
Raw JSON
{/if}
</MenuItem>
<hr>
<MenuItem onclick={toggleEncoding}>
Switch Format:
{#if isEncodedGroupShown}
Base64-Encoded
{:else}
Raw JSON
{/if}
</MenuItem>
</Menu>

View File

@@ -1,134 +1,130 @@
<script>
import { goto } from "$app/navigation";
import GroupView from "$components/features/GroupView.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagGroup from "$entities/TagGroup";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
import { tagGroupsStore } from "$stores/tag-groups-store";
<script lang="ts">
import { goto } from "$app/navigation";
import GroupView from "$components/features/GroupView.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagGroup from "$entities/TagGroup";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
import { tagGroups } from "$stores/entities/tag-groups";
const groupTransporter = new EntitiesTransporter(TagGroup);
const groupTransporter = new EntitiesTransporter(TagGroup);
/** @type {string} */
let importedString = '';
/** @type {string} */
let errorMessage = '';
let importedString = $state('');
let errorMessage = $state('');
/** @type {TagGroup|null} */
let candidateGroup = null;
/** @type {TagGroup|null} */
let existingGroup = null;
let candidateGroup = $state<TagGroup | null>(null);
let existingGroup = $state<TagGroup | null>(null);
function tryImportingGroup() {
candidateGroup = null;
existingGroup = null;
function tryImportingGroup() {
candidateGroup = null;
existingGroup = null;
errorMessage = '';
importedString = importedString.trim();
errorMessage = '';
importedString = importedString.trim();
if (!importedString) {
errorMessage = 'Nothing to import.';
return;
}
try {
if (importedString.trim().startsWith('{')) {
candidateGroup = groupTransporter.importFromJSON(importedString);
}
candidateGroup = groupTransporter.importFromCompressedJSON(importedString);
} catch (error) {
errorMessage = error instanceof Error
? error.message
: 'Unknown error';
}
if (candidateGroup) {
existingGroup = $tagGroupsStore.find(group => group.id === candidateGroup?.id) ?? null;
}
if (!importedString) {
errorMessage = 'Nothing to import.';
return;
}
function saveGroup() {
if (!candidateGroup) {
return;
}
candidateGroup.save().then(() => {
goto(`/features/groups`);
});
try {
if (importedString.trim().startsWith('{')) {
candidateGroup = groupTransporter.importFromJSON(importedString);
} else {
candidateGroup = groupTransporter.importFromCompressedJSON(importedString);
}
} catch (error) {
errorMessage = error instanceof Error
? error.message
: 'Unknown error';
}
function cloneAndSaveGroup() {
if (!candidateGroup) {
return;
}
const clonedProfile = new TagGroup(crypto.randomUUID(), candidateGroup.settings);
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
clonedProfile.save().then(() => {
goto(`/features/groups`);
});
if (candidateGroup) {
existingGroup = $tagGroups.find(group => group.id === candidateGroup?.id) ?? null;
}
}
function saveGroup() {
if (!candidateGroup) {
return;
}
candidateGroup.save().then(() => {
goto(`/features/groups`);
});
}
function cloneAndSaveGroup() {
if (!candidateGroup) {
return;
}
const clonedProfile = new TagGroup(crypto.randomUUID(), candidateGroup.settings);
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
clonedProfile.save().then(() => {
goto(`/features/groups`);
});
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/features/groups">Back</MenuItem>
<hr>
<MenuItem href="/features/groups" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if errorMessage}
<p class="error">Failed to import: {errorMessage}</p>
<Menu>
<hr>
</Menu>
<p class="error">Failed to import: {errorMessage}</p>
<Menu>
<hr>
</Menu>
{/if}
{#if !candidateGroup}
<FormContainer>
<FormControl label="Import string">
<textarea bind:value={importedString} rows="6"></textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={tryImportingGroup}>Import</MenuItem>
</Menu>
<FormContainer>
<FormControl label="Import string">
<textarea bind:value={importedString} rows="6"></textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem onclick={tryImportingGroup}>Import</MenuItem>
</Menu>
{:else}
{#if existingGroup}
<p class="warning">
This group will replace the existing "{existingGroup.settings.name}" group, since it have the same ID.
</p>
{/if}
<GroupView group={candidateGroup}></GroupView>
<Menu>
<hr>
{#if existingGroup}
<p class="warning">
This group will replace the existing "{existingGroup.settings.name}" group, since it have the same ID.
</p>
<MenuItem onclick={saveGroup}>Replace Existing Group</MenuItem>
<MenuItem onclick={cloneAndSaveGroup}>Save as New Group</MenuItem>
{:else}
<MenuItem onclick={saveGroup}>Import New Group</MenuItem>
{/if}
<GroupView group="{candidateGroup}"></GroupView>
<Menu>
<hr>
{#if existingGroup}
<MenuItem on:click={saveGroup}>Replace Existing Group</MenuItem>
<MenuItem on:click={cloneAndSaveGroup}>Save as New Group</MenuItem>
{:else}
<MenuItem on:click={saveGroup}>Import New Group</MenuItem>
{/if}
<MenuItem on:click={() => candidateGroup = null}>Cancel</MenuItem>
</Menu>
<MenuItem onclick={() => candidateGroup = null}>Cancel</MenuItem>
</Menu>
{/if}
<style lang="scss">
@use '$styles/colors';
@use '$styles/colors';
.error, .warning {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
.error, .warning {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
}
.error {
background: colors.$error-background;
}
.error {
background: colors.$error-background;
}
.warning {
background: colors.$warning-background;
margin-bottom: .5em;
}
.warning {
background: colors.$warning-background;
margin-bottom: .5em;
}
</style>

View File

@@ -1,46 +1,43 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import MaintenanceProfile from "$entities/MaintenanceProfile";
/** @type {import('$entities/MaintenanceProfile').default[]} */
let profiles = [];
let profiles = $derived<MaintenanceProfile[]>(
$maintenanceProfiles.sort((a, b) => a.settings.name.localeCompare(b.settings.name))
);
$: profiles = $maintenanceProfilesStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));
function resetActiveProfile() {
$activeProfileStore = null;
}
function resetActiveProfile() {
$activeProfileStore = null;
}
/**
* @param {Event} event
*/
function enableSelectedProfile(event) {
const target = event.target;
if (target instanceof HTMLInputElement && target.checked) {
activeProfileStore.set(target.value);
}
function enableSelectedProfile(event: Event) {
const target = event.target;
if (target instanceof HTMLInputElement && target.checked) {
activeProfileStore.set(target.value);
}
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/">Back</MenuItem>
<MenuItem icon="plus" href="/features/maintenance/new/edit">Create New</MenuItem>
{#if profiles.length}
<hr>
{/if}
{#each profiles as profile}
<MenuRadioItem href="/features/maintenance/{profile.id}"
name="active-profile"
value="{profile.id}"
checked="{$activeProfileStore === profile.id}"
on:input={enableSelectedProfile}>
{profile.settings.name}
</MenuRadioItem>
{/each}
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/maintenance/new/edit" icon="plus">Create New</MenuItem>
{#if profiles.length}
<hr>
<MenuItem href="#" on:click={resetActiveProfile}>Reset Active Profile</MenuItem>
<MenuItem href="/features/maintenance/import">Import Profile</MenuItem>
{/if}
{#each profiles as profile}
<MenuRadioItem href="/features/maintenance/{profile.id}"
name="active-profile"
value={profile.id}
checked={$activeProfileStore === profile.id}
oninput={enableSelectedProfile}>
{profile.settings.name}
</MenuRadioItem>
{/each}
<hr>
<MenuItem href="#" onclick={resetActiveProfile}>Reset Active Profile</MenuItem>
<MenuItem href="/features/maintenance/import">Import Profile</MenuItem>
</Menu>

View File

@@ -1,63 +1,68 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
import ProfileView from "$components/features/ProfileView.svelte";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
<script lang="ts">
import { run } from 'svelte/legacy';
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import ProfileView from "$components/features/ProfileView.svelte";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import { onMount } from "svelte";
const profileId = $page.params.id;
/** @type {import('$entities/MaintenanceProfile').default|null} */
let profile = null;
let profileId = $derived(page.params.id);
let profile = $derived<MaintenanceProfile|null>(
$maintenanceProfiles.find(profile => profile.id === profileId) || null
);
$effect(() => {
if (profileId === 'new') {
goto('/features/maintenance/new/edit');
goto('/features/maintenance/new/edit');
return;
}
$: {
const resolvedProfile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
if (!profile) {
console.warn(`Profile ${profileId} not found.`);
goto('/features/maintenance');
}
});
if (resolvedProfile) {
profile = resolvedProfile;
} else {
console.warn(`Profile ${profileId} not found.`);
goto('/features/maintenance');
}
let isActiveProfile = $state(false);
$effect.pre(() => {
isActiveProfile = $activeProfileStore === profileId;
});
$effect(() => {
if (isActiveProfile && $activeProfileStore !== profileId) {
$activeProfileStore = profileId;
}
let isActiveProfile = $activeProfileStore === profileId;
$: {
if (isActiveProfile && $activeProfileStore !== profileId) {
$activeProfileStore = profileId;
}
if (!isActiveProfile && $activeProfileStore === profileId) {
$activeProfileStore = null;
}
if (!isActiveProfile && $activeProfileStore === profileId) {
$activeProfileStore = null;
}
});
</script>
<Menu>
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if profile}
<ProfileView {profile}/>
<ProfileView {profile}/>
{/if}
<Menu>
<hr>
<MenuItem icon="wrench" href="/features/maintenance/{profileId}/edit">Edit Profile</MenuItem>
<MenuCheckboxItem bind:checked={isActiveProfile}>
Activate Profile
</MenuCheckboxItem>
<MenuItem icon="file-export" href="/features/maintenance/{profileId}/export">
Export Profile
</MenuItem>
<MenuItem icon="trash" href="/features/maintenance/{profileId}/delete">
Delete Profile
</MenuItem>
<hr>
<MenuItem href="/features/maintenance/{profileId}/edit" icon="wrench">Edit Profile</MenuItem>
<MenuCheckboxItem bind:checked={isActiveProfile}>
Activate Profile
</MenuCheckboxItem>
<MenuItem href="/features/maintenance/{profileId}/export" icon="file-export">
Export Profile
</MenuItem>
<MenuItem href="/features/maintenance/{profileId}/delete" icon="trash">
Delete Profile
</MenuItem>
</Menu>
<style lang="scss">

View File

@@ -1,41 +1,46 @@
<script>
import { goto } from "$app/navigation";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { page } from "$app/stores";
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
<script lang="ts">
import { goto } from "$app/navigation";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { page } from "$app/state";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import MaintenanceProfile from "$entities/MaintenanceProfile";
const profileId = $page.params.id;
const targetProfile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
const profileId = $derived(page.params.id);
const targetProfile = $derived<MaintenanceProfile | null>(
$maintenanceProfiles.find(profile => profile.id === profileId) || null
);
$effect(() => {
if (!targetProfile) {
void goto('/features/maintenance');
goto('/features/maintenance');
}
});
async function deleteProfile() {
if (!targetProfile) {
console.warn('Attempting to delete the profile, but the profile is not loaded yet.');
return;
}
async function deleteProfile() {
if (!targetProfile) {
console.warn('Attempting to delete the profile, but the profile is not loaded yet.');
return;
}
await targetProfile.delete();
await goto('/features/maintenance');
}
await targetProfile.delete();
await goto('/features/maintenance');
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/features/maintenance/{profileId}">Back</MenuItem>
<hr>
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if targetProfile}
<p>
Do you want to remove profile "{targetProfile.settings.name}"? This action is irreversible.
</p>
<Menu>
<hr>
<MenuItem on:click={deleteProfile}>Yes</MenuItem>
<MenuItem href="/features/maintenance/{profileId}">No</MenuItem>
</Menu>
<p>
Do you want to remove profile "{targetProfile.settings.name}"? This action is irreversible.
</p>
<Menu>
<hr>
<MenuItem onclick={deleteProfile}>Yes</MenuItem>
<MenuItem href="/features/maintenance/{profileId}">No</MenuItem>
</Menu>
{:else}
<p>Loading...</p>
<p>Loading...</p>
{/if}

View File

@@ -1,69 +1,73 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagsEditor from "$components/tags/TagsEditor.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import TextField from "$components/ui/forms/TextField.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
import MaintenanceProfile from "$entities/MaintenanceProfile";
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagsEditor from "$components/tags/TagsEditor.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import TextField from "$components/ui/forms/TextField.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import MaintenanceProfile from "$entities/MaintenanceProfile";
/** @type {string} */
let profileId = $page.params.id;
/** @type {MaintenanceProfile|null} */
let targetProfile = null;
/** @type {string} */
let profileName = '';
/** @type {string[]} */
let tagsList = [];
let profileId = $derived(page.params.id);
let targetProfile = $derived.by<MaintenanceProfile | null>(() => {
if (profileId === 'new') {
targetProfile = new MaintenanceProfile(crypto.randomUUID(), {});
} else {
const maybeExistingProfile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
if (maybeExistingProfile) {
targetProfile = maybeExistingProfile;
profileName = targetProfile.settings.name;
tagsList = [...targetProfile.settings.tags].sort((a, b) => a.localeCompare(b));
} else {
goto('/features/maintenance');
}
return new MaintenanceProfile(crypto.randomUUID(), {});
}
async function saveProfile() {
if (!targetProfile) {
console.warn('Attempting to save the profile, but the profile is not loaded yet.');
return;
}
return $maintenanceProfiles.find(profile => profile.id === profileId) || null;
});
targetProfile.settings.name = profileName;
targetProfile.settings.tags = [...tagsList];
targetProfile.settings.temporary = false;
let profileName = $state('');
let tagsList = $state<string[]>([]);
await targetProfile.save();
await goto('/features/maintenance/' + targetProfile.id);
$effect(() => {
if (profileId === 'new') {
return;
}
if (!targetProfile) {
goto('/features/maintenance');
return;
}
profileName = targetProfile.settings.name;
tagsList = [...targetProfile.settings.tags].sort((a, b) => a.localeCompare(b));
});
async function saveProfile() {
if (!targetProfile) {
console.warn('Attempting to save the profile, but the profile is not loaded yet.');
return;
}
targetProfile.settings.name = profileName;
targetProfile.settings.tags = [...tagsList];
targetProfile.settings.temporary = false;
await targetProfile.save();
await goto('/features/maintenance/' + targetProfile.id);
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/features/maintenance{profileId === 'new' ? '' : '/' + profileId}">
Back
</MenuItem>
<hr>
<MenuItem href="/features/maintenance{profileId === 'new' ? '' : '/' + profileId}" icon="arrow-left">
Back
</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Profile Name">
<TextField bind:value={profileName} placeholder="Profile Name"></TextField>
</FormControl>
<FormControl label="Tags">
<TagsEditor bind:tags={tagsList}></TagsEditor>
</FormControl>
<FormControl label="Profile Name">
<TextField bind:value={profileName} placeholder="Profile Name"></TextField>
</FormControl>
<FormControl label="Tags">
<TagsEditor bind:tags={tagsList}></TagsEditor>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem href="#" on:click={saveProfile}>Save Profile</MenuItem>
<hr>
<MenuItem href="#" onclick={saveProfile}>Save Profile</MenuItem>
</Menu>

View File

@@ -1,52 +1,53 @@
<script>
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
import MaintenanceProfile from "$entities/MaintenanceProfile";
<script lang="ts">
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
import MaintenanceProfile from "$entities/MaintenanceProfile";
const profileId = $page.params.id;
const profile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
let isCompressedProfileShown = $state(true);
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
/** @type {string} */
let exportedProfile = '';
/** @type {string} */
let compressedProfile = '';
const profileId = $derived(page.params.id);
const profile = $derived<MaintenanceProfile | null>(
$maintenanceProfiles.find(profile => profile.id === profileId) || null
);
$effect(() => {
if (!profile) {
goto('/features/maintenance/');
} else {
exportedProfile = profilesTransporter.exportToJSON(profile);
compressedProfile = profilesTransporter.exportToCompressedJSON(profile);
goto('/features/maintenance/');
}
});
let isCompressedProfileShown = true;
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
let rawExportedProfile = $derived(profile ? profilesTransporter.exportToJSON(profile) : '');
let compressedExportedProfile = $derived(profile ? profilesTransporter.exportToCompressedJSON(profile) : '');
let selectedExportString = $derived(isCompressedProfileShown ? compressedExportedProfile : rawExportedProfile);
</script>
<Menu>
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">
Back
</MenuItem>
<hr>
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">
Back
</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Export string">
<textarea readonly rows="6">{isCompressedProfileShown ? compressedProfile : exportedProfile}</textarea>
</FormControl>
<FormControl label="Export string">
<textarea readonly rows="6">{selectedExportString}</textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={() => isCompressedProfileShown = !isCompressedProfileShown}>
Switch Format:
{#if isCompressedProfileShown}
Base64-Encoded
{:else}
Raw JSON
{/if}
</MenuItem>
<hr>
<MenuItem onclick={() => isCompressedProfileShown = !isCompressedProfileShown}>
Switch Format:
{#if isCompressedProfileShown}
Base64-Encoded
{:else}
Raw JSON
{/if}
</MenuItem>
</Menu>

View File

@@ -1,134 +1,130 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import FormControl from "$components/ui/forms/FormControl.svelte";
import ProfileView from "$components/features/ProfileView.svelte";
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
import { goto } from "$app/navigation";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
<script lang="ts">
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import FormControl from "$components/ui/forms/FormControl.svelte";
import ProfileView from "$components/features/ProfileView.svelte";
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
import { goto } from "$app/navigation";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
/** @type {string} */
let importedString = '';
/** @type {string} */
let errorMessage = '';
let importedString = $state('');
let errorMessage = $state('');
/** @type {MaintenanceProfile|null} */
let candidateProfile = null;
/** @type {MaintenanceProfile|null} */
let existingProfile = null;
let candidateProfile = $state<MaintenanceProfile | null>(null);
let existingProfile = $state<MaintenanceProfile | null>(null);
function tryImportingProfile() {
candidateProfile = null;
existingProfile = null;
function tryImportingProfile() {
candidateProfile = null;
existingProfile = null;
errorMessage = '';
importedString = importedString.trim();
errorMessage = '';
importedString = importedString.trim();
if (!importedString) {
errorMessage = 'Nothing to import.';
return;
}
try {
if (importedString.trim().startsWith('{')) {
candidateProfile = profilesTransporter.importFromJSON(importedString);
}
candidateProfile = profilesTransporter.importFromCompressedJSON(importedString);
} catch (error) {
errorMessage = error instanceof Error
? error.message
: 'Unknown error';
}
if (candidateProfile) {
existingProfile = $maintenanceProfilesStore.find(profile => profile.id === candidateProfile?.id) ?? null;
}
if (!importedString) {
errorMessage = 'Nothing to import.';
return;
}
function saveProfile() {
if (!candidateProfile) {
return;
}
candidateProfile.save().then(() => {
goto(`/features/maintenance`);
});
try {
if (importedString.trim().startsWith('{')) {
candidateProfile = profilesTransporter.importFromJSON(importedString);
} else {
candidateProfile = profilesTransporter.importFromCompressedJSON(importedString)
}
} catch (error) {
errorMessage = error instanceof Error
? error.message
: 'Unknown error';
}
function cloneAndSaveProfile() {
if (!candidateProfile) {
return;
}
const clonedProfile = new MaintenanceProfile(crypto.randomUUID(), candidateProfile.settings);
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
clonedProfile.save().then(() => {
goto(`/features/maintenance`);
});
if (candidateProfile) {
existingProfile = $maintenanceProfiles.find(profile => profile.id === candidateProfile?.id) ?? null;
}
}
function saveProfile() {
if (!candidateProfile) {
return;
}
candidateProfile.save().then(() => {
goto(`/features/maintenance`);
});
}
function cloneAndSaveProfile() {
if (!candidateProfile) {
return;
}
const clonedProfile = new MaintenanceProfile(crypto.randomUUID(), candidateProfile.settings);
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
clonedProfile.save().then(() => {
goto(`/features/maintenance`);
});
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/features/maintenance">Back</MenuItem>
<hr>
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if errorMessage}
<p class="error">Failed to import: {errorMessage}</p>
<Menu>
<hr>
</Menu>
<p class="error">Failed to import: {errorMessage}</p>
<Menu>
<hr>
</Menu>
{/if}
{#if !candidateProfile}
<FormContainer>
<FormControl label="Import string">
<textarea bind:value={importedString} rows="6"></textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={tryImportingProfile}>Import</MenuItem>
</Menu>
<FormContainer>
<FormControl label="Import string">
<textarea bind:value={importedString} rows="6"></textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem onclick={tryImportingProfile}>Import</MenuItem>
</Menu>
{:else}
{#if existingProfile}
<p class="warning">
This profile will replace the existing "{existingProfile.settings.name}" profile, since it have the same ID.
</p>
{/if}
<ProfileView profile={candidateProfile}></ProfileView>
<Menu>
<hr>
{#if existingProfile}
<p class="warning">
This profile will replace the existing "{existingProfile.settings.name}" profile, since it have the same ID.
</p>
<MenuItem onclick={saveProfile}>Replace Existing Profile</MenuItem>
<MenuItem onclick={cloneAndSaveProfile}>Save as New Profile</MenuItem>
{:else}
<MenuItem onclick={saveProfile}>Import New Profile</MenuItem>
{/if}
<ProfileView profile="{candidateProfile}"></ProfileView>
<Menu>
<hr>
{#if existingProfile}
<MenuItem on:click={saveProfile}>Replace Existing Profile</MenuItem>
<MenuItem on:click={cloneAndSaveProfile}>Save as New Profile</MenuItem>
{:else}
<MenuItem on:click={saveProfile}>Import New Profile</MenuItem>
{/if}
<MenuItem on:click={() => candidateProfile = null}>Cancel</MenuItem>
</Menu>
<MenuItem onclick={() => candidateProfile = null}>Cancel</MenuItem>
</Menu>
{/if}
<style lang="scss">
@use '$styles/colors';
@use '$styles/colors';
.error, .warning {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
.error, .warning {
padding: 5px 24px;
margin: {
left: -24px;
right: -24px;
}
}
.error {
background: colors.$error-background;
}
.error {
background: colors.$error-background;
}
.warning {
background: colors.$warning-background;
margin-bottom: .5em;
}
.warning {
background: colors.$warning-background;
margin-bottom: .5em;
}
</style>

View File

@@ -1,14 +1,14 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
</script>
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/preferences/tags">Tagging</MenuItem>
<MenuItem href="/preferences/search">Search</MenuItem>
<MenuItem href="/preferences/misc">Misc & Tools</MenuItem>
<hr>
<MenuItem href="/preferences/debug">Debug</MenuItem>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/preferences/tags">Tagging</MenuItem>
<MenuItem href="/preferences/search">Search</MenuItem>
<MenuItem href="/preferences/misc">Misc & Tools</MenuItem>
<hr>
<MenuItem href="/preferences/debug">Debug</MenuItem>
</Menu>

View File

@@ -1,10 +1,10 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
</script>
<Menu>
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/preferences/debug/storage">Inspect Storages</MenuItem>
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
<hr>
<MenuItem href="/preferences/debug/storage">Inspect Storages</MenuItem>
</Menu>

View File

@@ -1,13 +1,13 @@
<script>
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import { storagesCollection } from "$stores/debug";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import { storagesCollection } from "$stores/debug";
</script>
<Menu>
<MenuItem href="/preferences/debug" icon="arrow-left">Back</MenuItem>
<hr>
{#each Object.keys($storagesCollection) as storageName}
<MenuItem href="/preferences/debug/storage/{storageName}/">Storage: {storageName}</MenuItem>
{/each}
<MenuItem href="/preferences/debug" icon="arrow-left">Back</MenuItem>
<hr>
{#each Object.keys($storagesCollection) as storageName}
<MenuItem href="/preferences/debug/storage/{storageName}/">Storage: {storageName}</MenuItem>
{/each}
</Menu>

View File

@@ -1,29 +1,38 @@
<script>
import StorageViewer from "$components/debugging/StorageViewer.svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
<script lang="ts">
import StorageViewer from "$components/debugging/StorageViewer.svelte";
import { page } from "$app/state";
import { goto } from "$app/navigation";
let pathString = '';
/** @type {string[]} */
let pathArray = [];
/** @type {string|undefined} */
let storageName = void 0;
let pathArray = $derived.by<string[]>(() => {
const pathString = page.params.path;
$: {
pathString = $page.params.path;
pathArray = pathString.length ? pathString.split("/") : [];
storageName = pathArray.shift()
return pathString.length ? pathString.split('/') : [];
});
if (pathArray.length && pathArray[pathArray.length - 1] === '') {
pathArray.pop();
}
let storageName = $derived.by<string | undefined>(() => {
return pathArray[0];
});
if (!storageName) {
goto("/preferences/debug/storage");
}
// Copy of the array without the storage or empty string at the end.
let normalizedPathArray = $derived.by<string[]>(() => {
// Excludes storage name
const resultArray = pathArray.slice(1);
// Getting rid of trailing empty entry
if (resultArray.length && resultArray[resultArray.length - 1] === '') {
resultArray.pop();
}
return resultArray;
});
$effect(() => {
if (!storageName) {
goto("/preferences/debug/storage");
}
});
</script>
{#if storageName}
<StorageViewer storage="{storageName}" path="{pathArray}"></StorageViewer>
<StorageViewer storage={storageName} path={normalizedPathArray}></StorageViewer>
{/if}

View File

@@ -1,20 +1,20 @@
<script>
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import { fullScreenViewerEnabled } from "$stores/misc-preferences";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import { fullScreenViewerEnabled } from "$stores/preferences/misc";
</script>
<Menu>
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
<hr>
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl>
<CheckboxField bind:checked={$fullScreenViewerEnabled}>
Enable fullscreen viewer button
</CheckboxField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={$fullScreenViewerEnabled}>
Enable fullscreen viewer button
</CheckboxField>
</FormControl>
</FormContainer>

View File

@@ -1,35 +1,32 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import {
searchPropertiesSuggestionsEnabled,
searchPropertiesSuggestionsPosition
} from "$stores/search-preferences";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import SelectField from "$components/ui/forms/SelectField.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import { searchPropertiesSuggestionsEnabled, searchPropertiesSuggestionsPosition } from "$stores/preferences/search";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import SelectField from "$components/ui/forms/SelectField.svelte";
const propertiesPositions = {
start: "At the start of the list",
end: "At the end of the list",
}
const propertiesPositions = {
start: "At the start of the list",
end: "At the end of the list",
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
<hr>
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl>
<CheckboxField bind:checked={$searchPropertiesSuggestionsEnabled}>
Auto-complete properties
</CheckboxField>
<FormControl>
<CheckboxField bind:checked={$searchPropertiesSuggestionsEnabled}>
Auto-complete properties
</CheckboxField>
</FormControl>
{#if $searchPropertiesSuggestionsEnabled}
<FormControl label="Show completed properties:">
<SelectField bind:value={$searchPropertiesSuggestionsPosition}
options="{propertiesPositions}"></SelectField>
</FormControl>
{#if $searchPropertiesSuggestionsEnabled}
<FormControl label="Show completed properties:">
<SelectField bind:value={$searchPropertiesSuggestionsPosition}
options="{propertiesPositions}"></SelectField>
</FormControl>
{/if}
{/if}
</FormContainer>

View File

@@ -1,20 +1,26 @@
<script>
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { stripBlacklistedTagsEnabled } from "$stores/maintenance-preferences";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { stripBlacklistedTagsEnabled } from "$stores/preferences/maintenance";
import { shouldSeparateTagGroups } from "$stores/preferences/tag";
</script>
<Menu>
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
<hr>
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl>
<CheckboxField bind:checked={$stripBlacklistedTagsEnabled}>
Automatically remove black-listed tags from the images
</CheckboxField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={$stripBlacklistedTagsEnabled}>
Automatically remove black-listed tags from the images
</CheckboxField>
</FormControl>
<FormControl>
<CheckboxField bind:checked={$shouldSeparateTagGroups}>
Enable separation of custom tag groups on the image pages
</CheckboxField>
</FormControl>
</FormContainer>

View File

@@ -1,12 +1,15 @@
import { writable } from "svelte/store";
import { type Writable, writable } from "svelte/store";
// todo: Maybe this could be dynamically resolved using map of entities and not currently existing list of all settings
// classes. For now it's just generic record.
type StorageContents = Record<string, any>;
/**
* This is readable version of storages. Any changes made to these objects will not be sent to the local storage.
* @type {Writable<Record<string, Object>>}
*/
export const storagesCollection = writable({});
export const storagesCollection: Writable<StorageContents> = writable({});
chrome.storage.local.get(storages => {
void chrome.storage.local.get<StorageContents>(null, storages => {
storagesCollection.set(storages);
});

View File

@@ -1,33 +1,28 @@
import { writable } from "svelte/store";
import { type Writable, writable } from "svelte/store";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
/**
* Store for working with maintenance profiles in the Svelte popup.
*
* @type {import('svelte/store').Writable<MaintenanceProfile[]>}
*/
export const maintenanceProfilesStore = writable([]);
export const maintenanceProfiles: Writable<MaintenanceProfile[]> = writable([]);
/**
* Store for the active maintenance profile ID.
*
* @type {import('svelte/store').Writable<string|null>}
*/
export const activeProfileStore = writable(null);
export const activeProfileStore: Writable<string|null> = writable(null);
const maintenanceSettings = new MaintenanceSettings();
/**
* Active profile ID stored locally. Used to reset active profile once the existing profile was removed.
* @type {string|null}
*/
let lastActiveProfileId = null;
let lastActiveProfileId: string|null = null;
Promise.allSettled([
// Read the initial values from the storages first
MaintenanceProfile.readAll().then(profiles => {
maintenanceProfilesStore.set(profiles);
maintenanceProfiles.set(profiles);
}),
maintenanceSettings.resolveActiveProfileId().then(activeProfileId => {
activeProfileStore.set(activeProfileId);
@@ -35,7 +30,7 @@ Promise.allSettled([
]).then(() => {
// And only after initial values are loaded, start watching for changes from storage and from user's interaction
MaintenanceProfile.subscribe(profiles => {
maintenanceProfilesStore.set(profiles);
maintenanceProfiles.set(profiles);
});
maintenanceSettings.subscribe(settings => {

View File

@@ -0,0 +1,11 @@
import { type Writable, writable } from "svelte/store";
import TagGroup from "$entities/TagGroup";
export const tagGroups: Writable<TagGroup[]> = writable([]);
TagGroup
.readAll()
.then(groups => tagGroups.set(groups))
.then(() => {
TagGroup.subscribe(groups => tagGroups.set(groups));
});

View File

@@ -1,10 +1,9 @@
import { writable } from "svelte/store";
import SearchSettings from "$lib/extension/settings/SearchSettings";
import { type Writable, writable } from "svelte/store";
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
export const searchPropertiesSuggestionsEnabled = writable(false);
/** @type {import('svelte/store').Writable<"start"|"end">} */
export const searchPropertiesSuggestionsPosition = writable('start');
export const searchPropertiesSuggestionsPosition: Writable<SuggestionsPosition> = writable('start');
const searchSettings = new SearchSettings();

View File

@@ -0,0 +1,18 @@
import { writable } from "svelte/store";
import TagSettings from "$lib/extension/settings/TagSettings";
const tagSettings = new TagSettings();
export const shouldSeparateTagGroups = writable(false);
tagSettings.resolveGroupSeparation()
.then(value => shouldSeparateTagGroups.set(value))
.then(() => {
shouldSeparateTagGroups.subscribe(value => {
void tagSettings.setGroupSeparation(value);
});
tagSettings.subscribe(settings => {
shouldSeparateTagGroups.set(Boolean(settings.groupSeparation));
});
})

View File

@@ -1,12 +0,0 @@
import { writable } from "svelte/store";
import TagGroup from "$entities/TagGroup";
/** @type {import('svelte/store').Writable<TagGroup[]>} */
export const tagGroupsStore = writable([]);
TagGroup
.readAll()
.then(groups => tagGroupsStore.set(groups))
.then(() => {
TagGroup.subscribe(groups => tagGroupsStore.set(groups));
});

View File

@@ -203,6 +203,7 @@
top: 5px;
left: 5px;
z-index: 1;
background-color: booru-vars.$background-color;
}
.close {

View File

@@ -19,6 +19,7 @@ const config = {
"$styles": "./src/styles",
"$stores": "./src/stores",
"$entities": "./src/lib/extension/entities",
"$tests": "./tests"
},
typescript: {
config: config => {

View File

@@ -0,0 +1,40 @@
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
import StorageHelper from "$lib/browser/StorageHelper";
import { expect } from "vitest";
describe('StorageHelper', () => {
let storageAreaMock: ChromeStorageArea;
let storageHelper: StorageHelper;
beforeEach(() => {
storageAreaMock = new ChromeStorageArea();
storageHelper = new StorageHelper(storageAreaMock);
});
it("should return value when data exists", async () => {
const key = 'existingKey';
const value = 'test value';
storageAreaMock.insertMockedData({[key]: value});
expect(await storageHelper.read(key)).toBe(value);
});
it('should return default when data is not present', async () => {
const fallbackValue = 'fallback';
expect(await storageHelper.read('nonexistent', fallbackValue)).toBe(fallbackValue);
});
it('should treat falsy values as existing values', async () => {
const falsyValues = [false, '', 0];
const key = 'testedKey';
const fallbackValue = 'fallback';
for (let testedValue of falsyValues) {
storageAreaMock.insertMockedData({[key]: testedValue});
expect(await storageHelper.read(key, fallbackValue)).toBe(testedValue);
}
});
});

View File

@@ -0,0 +1,106 @@
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { randomString } from "$tests/utils";
describe('BaseComponent', () => {
it('should bind the component to the element', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(getComponent(element)).toBe(component);
});
it('should throw an error when attempting to initialize component on same element multiple times', () => {
const element = document.createElement('div');
expect(() => new BaseComponent(element)).not.toThrowError();
expect(() => new BaseComponent(element)).toThrowError();
});
it('should return the element as component container', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(component.container).toBe(element);
});
it('should mark itself as initialized after initialization', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(component.isInitialized).toBe(false);
component.initialize();
expect(component.isInitialized).toBe(true);
});
it('should throw error when attempting to initialize component multiple times', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
expect(() => component.initialize()).not.toThrowError();
expect(() => component.initialize()).toThrowError();
});
it('should emit custom events on element', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
let receivedEvent: CustomEvent<string> | null = null;
const eventName = randomString();
const eventData = randomString();
const eventHandler = vi.fn(event => {
receivedEvent = event;
});
element.addEventListener(eventName, eventHandler);
component.emit(eventName, eventData);
expect(eventHandler).toBeCalled();
expect(receivedEvent).toBeInstanceOf(CustomEvent);
expect(receivedEvent!.detail).toBe(eventData);
});
it('should listen events on element', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
const eventName = 'click';
const eventHandler = vi.fn();
component.on(eventName, eventHandler);
element.dispatchEvent(new Event(eventName));
expect(eventHandler).toBeCalled();
});
it('should disconnect listener with unsubscribe function', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
const eventName = 'click';
const eventHandler = vi.fn();
const unsubscribe = component.on(eventName, eventHandler);
element.dispatchEvent(new Event(eventName));
unsubscribe();
element.dispatchEvent(new Event(eventName));
expect(eventHandler).toBeCalledTimes(1);
});
it('should listen for event once', () => {
const element = document.createElement('div');
const component = new BaseComponent(element);
const eventName = 'click';
const eventHandler = vi.fn();
component.once(eventName, eventHandler);
element.dispatchEvent(new Event(eventName));
element.dispatchEvent(new Event(eventName));
expect(eventHandler).toBeCalledTimes(1);
});
});

Some files were not shown because too many files have changed in this diff Show More