mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2026-02-06 23:32:58 +00:00
Compare commits
200 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7919e0127 | |||
| 73ff913eb7 | |||
| dd312e170e | |||
| 283629d64b | |||
| 9af73f0598 | |||
| bd85d165d3 | |||
| 24e0937c6b | |||
| 24cec58af5 | |||
| 66f106364a | |||
| 650c5714d0 | |||
| 31e2993a12 | |||
| e8349ce9a3 | |||
| 6b807db235 | |||
| 9a8e3cc597 | |||
| d2d02b06e4 | |||
| 58b79531e4 | |||
| 927e4c157b | |||
| 5c534da875 | |||
| 257c369b02 | |||
| d559d16977 | |||
| 6cced5036c | |||
| 15e379c798 | |||
| a39099bb1e | |||
| 3af723e50d | |||
| 3e028a1509 | |||
| ba10768496 | |||
| e5ffd59b9c | |||
| 9a73ad80dd | |||
| cd10ad62f4 | |||
| b7a829ff12 | |||
| e06359a24a | |||
| bb2065cf07 | |||
| 309dd15598 | |||
| 757526ab52 | |||
| 112d60ac78 | |||
| 3d1e0d6f06 | |||
| 98d3b1c696 | |||
| 52a8b6e778 | |||
| c9c441a8ae | |||
| c241bfb25c | |||
| ac85938355 | |||
| a8a27654fc | |||
| f58c4aa818 | |||
| eda58cd2ca | |||
| 8f3020bc7b | |||
| abbfcf2e34 | |||
| c4f00c4905 | |||
| 71039ee657 | |||
| fa8ff3b718 | |||
| 90562f3878 | |||
| d1e22eaa0c | |||
| ca3c4f6618 | |||
| 3123ce1c0f | |||
| 6775a2175a | |||
| 62bcba34da | |||
| 17396952c3 | |||
| e61f6af237 | |||
| 27133077c8 | |||
| f90e51b546 | |||
| 9e9499c904 | |||
| 7d19693f5e | |||
| 4fcf83d732 | |||
| 93d6d3a174 | |||
| 4bdf04f911 | |||
| c9a9fe059c | |||
| e523ce4468 | |||
| d5f6ed1a3e | |||
| 0eba277f48 | |||
| b562752778 | |||
| 015e5d6ec4 | |||
| fffb915985 | |||
| 8151d1519a | |||
| 4f0f3142a1 | |||
| be97ac5640 | |||
| 9f61f99548 | |||
| 59c958ab32 | |||
| ee3fcd1b08 | |||
| d14fc8ba7d | |||
| b3a92653ad | |||
| c7f40e99b7 | |||
| f6ab60d939 | |||
| 666d374057 | |||
| 1ecb15c986 | |||
| b22749812f | |||
| 4b3414be47 | |||
| 6e4aef519f | |||
| feb57eec38 | |||
| 2a451d18be | |||
| 1420ad1ece | |||
| c0590ae347 | |||
| 39260f9c5d | |||
| 97b79b0b0d | |||
| b645a1ca7a | |||
| c0a00e0c05 | |||
| a8f0f16121 | |||
| e13d9054cc | |||
| c0139d0638 | |||
| 80ba4671f5 | |||
| bab919f0f8 | |||
| 72f901a2b7 | |||
| fd8efccfb3 | |||
| 3621bb9f0e | |||
| c15fae7c3d | |||
| 01e538c5c2 | |||
| 4375613768 | |||
| 3e05b1964d | |||
| 5092dc7f6d | |||
| 64dfac310e | |||
| 2da2716844 | |||
| 10b5bff377 | |||
| 198b9da407 | |||
| b5cdb0d81b | |||
| dc4f575576 | |||
| 0a947219d0 | |||
| e83d70fbd9 | |||
| 844853ff57 | |||
| 774409aac6 | |||
| 917775c5cd | |||
| a68c261b52 | |||
| f3a9694b1b | |||
| 2cb4c6b4b2 | |||
| bafdb68f1e | |||
| 02f9f3b36e | |||
| 57c505bee9 | |||
| 03512a6539 | |||
| f95eaacaaa | |||
| 38cb925fa4 | |||
| 7dd738d0e8 | |||
| 2f8a47b808 | |||
| d0c910d5bb | |||
| dc1e49e60c | |||
| 5e7e92614d | |||
| 727b2c81ff | |||
| 8059c93baa | |||
| 2f8d608e6b | |||
| 4635ccdb2b | |||
| 68d1d726af | |||
| e8c3e610eb | |||
| f9cb73bafc | |||
| 6bb3e83684 | |||
| b99846ba6a | |||
| 4ca84b0c14 | |||
| 25fe769a1e | |||
| c9d20be33d | |||
| e9b68137de | |||
| e0820c50ec | |||
| 135ed48c01 | |||
| 9d9aa38a9d | |||
| 323fa4e2b7 | |||
| 920804467e | |||
| 9c66f62408 | |||
| 16d126598e | |||
| a93430f3e3 | |||
| 1927c2ec31 | |||
| 63e6ee394d | |||
| 342cc38292 | |||
| f11827d516 | |||
| 595e73aff3 | |||
| c9107ab109 | |||
| 3adfab9555 | |||
| 0a740273a3 | |||
| e325e51b41 | |||
| 2637eac162 | |||
| 64ad82b985 | |||
| 5bb6055aee | |||
| 56f397c2d8 | |||
| e85055368a | |||
| 0265622337 | |||
| 91648a1ee0 | |||
| fa77e8b923 | |||
| 67d3b57eb1 | |||
| b0889486c7 | |||
| c0b1259e45 | |||
| 8aacd83474 | |||
| be1ae8f004 | |||
| 3f22852714 | |||
| 71ab75efaf | |||
| f71dc5a029 | |||
| 1a086625b9 | |||
| 60914e11c4 | |||
| d3fc78533d | |||
| b240c2aefe | |||
| e1026fd108 | |||
| 81c66134a1 | |||
| 8d8d7cc7e4 | |||
| 688ed15939 | |||
| d671ca13f6 | |||
| aaa1441a38 | |||
| b7a53daa9b | |||
| 965045e672 | |||
| 7ee1a72302 | |||
| dc27f33231 | |||
| eae5016daa | |||
| 7b236f12cd | |||
| 7eab8d633f | |||
| 68de994811 | |||
| be4aec54fe | |||
| 9ca663ffdb | |||
| bb0f84c9ad | |||
| c8eb54ab98 |
172
.editorconfig
172
.editorconfig
@@ -231,6 +231,178 @@ ij_javascript_while_brace_force = never
|
||||
ij_javascript_while_on_new_line = false
|
||||
ij_javascript_wrap_comments = false
|
||||
|
||||
[{*.ts,*.tsx}]
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
ij_continuation_indent_size = 2
|
||||
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 = false
|
||||
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 = false
|
||||
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 = auto
|
||||
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
|
||||
|
||||
BIN
.github/assets/chrome.png
vendored
Normal file
BIN
.github/assets/chrome.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
BIN
.github/assets/firefox.png
vendored
Normal file
BIN
.github/assets/firefox.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
@@ -48,6 +48,7 @@ function wrapScriptIntoIIFE() {
|
||||
*/
|
||||
function makeAliases(rootDir) {
|
||||
return {
|
||||
"$config": path.resolve(rootDir, 'src/config'),
|
||||
"$lib": path.resolve(rootDir, 'src/lib'),
|
||||
"$entities": path.resolve(rootDir, 'src/lib/extension/entities'),
|
||||
"$styles": path.resolve(rootDir, 'src/styles'),
|
||||
|
||||
27
README.md
27
README.md
@@ -1,4 +1,29 @@
|
||||
# Furbooru Tagging Assistant
|
||||
|
||||
[](https://addons.mozilla.org/en-US/firefox/addon/furbooru-tagging-assistant/)
|
||||
[](https://chromewebstore.google.com/detail/kpgaphaooaaodgodmnkamhmoedjcnfkj)
|
||||
|
||||
This is a browser extension written for the [Furbooru](https://furbooru.org) image-board. It gives you the ability to
|
||||
tag the images more easily and quickly.
|
||||
tag the images more easily and quickly.
|
||||
|
||||
## Building
|
||||
|
||||
Recommendations on environment:
|
||||
|
||||
- Recommended version of Node.js: LTS (20)
|
||||
|
||||
First you need to clone the repository and install all packages:
|
||||
|
||||
```shell
|
||||
npm install --save-dev
|
||||
```
|
||||
|
||||
Second, you need to run the `build` command. It will first build the popup using SvelteKit and then build all the
|
||||
content scripts/stylesheets and copy the manifest afterward. Simply run:
|
||||
|
||||
```shell
|
||||
npm run build
|
||||
```
|
||||
|
||||
When building is complete, resulting files can be found in the `/build` directory. These files can be either used
|
||||
directly in Chrome (via loading the extension as unpacked extension) or manually compressed into `*.zip` file.
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
{
|
||||
"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.1.1",
|
||||
"version": "0.4.0",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icon16.png",
|
||||
"48": "icon48.png",
|
||||
@@ -18,7 +23,8 @@
|
||||
"*://*.furbooru.org/",
|
||||
"*://*.furbooru.org/images?*",
|
||||
"*://*.furbooru.org/search?*",
|
||||
"*://*.furbooru.org/tags/*"
|
||||
"*://*.furbooru.org/tags/*",
|
||||
"*://*.furbooru.org/galleries/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/listing.js"
|
||||
@@ -37,6 +43,32 @@
|
||||
"css": [
|
||||
"src/styles/content/header.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images?*",
|
||||
"*://*.furbooru.org/images/*",
|
||||
"*://*.furbooru.org/images/*/tag_changes",
|
||||
"*://*.furbooru.org/images/*/tag_changes?*",
|
||||
"*://*.furbooru.org/search?*",
|
||||
"*://*.furbooru.org/tags",
|
||||
"*://*.furbooru.org/tags?*",
|
||||
"*://*.furbooru.org/tags/*",
|
||||
"*://*.furbooru.org/profiles/*/tag_changes",
|
||||
"*://*.furbooru.org/profiles/*/tag_changes?*",
|
||||
"*://*.furbooru.org/filters/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/tags.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/tags-editor.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
|
||||
4825
package-lock.json
generated
4825
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
32
package.json
32
package.json
@@ -1,26 +1,30 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.1.1",
|
||||
"version": "0.4.0",
|
||||
"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 ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.15.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/chrome": "^0.0.262",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"sass": "^1.71.0",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte-check": "^3.6.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.3"
|
||||
"cheerio": "^1.0.0",
|
||||
"sass": "^1.83.4",
|
||||
"svelte": "^5.18.0",
|
||||
"svelte-check": "^4.1.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
},
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.1",
|
||||
"lz-string": "^1.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
39
src/app.d.ts
vendored
39
src/app.d.ts
vendored
@@ -1,13 +1,40 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import type TagGroup from "$entities/TagGroup.ts";
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
type LinkTarget = "_blank" | "_self" | "_parent" | "_top";
|
||||
type IconName = (
|
||||
"tag"
|
||||
| "paint-brush"
|
||||
| "arrow-left"
|
||||
| "info-circle"
|
||||
| "wrench"
|
||||
| "globe"
|
||||
| "plus"
|
||||
| "file-export"
|
||||
| "trash"
|
||||
);
|
||||
|
||||
interface EntityNamesMap {
|
||||
profiles: MaintenanceProfile;
|
||||
groups: TagGroup;
|
||||
}
|
||||
|
||||
interface ImageURIs {
|
||||
full: string;
|
||||
large: string;
|
||||
medium: string;
|
||||
small: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
93
src/components/debugging/StorageViewer.svelte
Normal file
93
src/components/debugging/StorageViewer.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import {storagesCollection} from "$stores/debug.js";
|
||||
import {goto} from "$app/navigation";
|
||||
import {findDeepObject} from "$lib/utils.js";
|
||||
|
||||
/** @type {string} */
|
||||
export let storage;
|
||||
|
||||
/** @type {string[]} */
|
||||
export let path;
|
||||
|
||||
/** @type {Object|null} */
|
||||
let targetStorage = null;
|
||||
/** @type {[string, string][]} */
|
||||
let breadcrumbs = [];
|
||||
/** @type {Object<string, any>|null} */
|
||||
let targetObject = null;
|
||||
let targetPathString = '';
|
||||
|
||||
$: {
|
||||
/** @type {[string, string][]} */
|
||||
const builtBreadcrumbs = [];
|
||||
|
||||
breadcrumbs = path.reduce((resultCrumbs, entry) => {
|
||||
let entryPath = entry;
|
||||
|
||||
if (resultCrumbs.length) {
|
||||
entryPath = resultCrumbs[resultCrumbs.length - 1][1] + "/" + entryPath;
|
||||
}
|
||||
|
||||
resultCrumbs.push([entry, entryPath]);
|
||||
|
||||
return resultCrumbs;
|
||||
}, builtBreadcrumbs);
|
||||
|
||||
targetPathString = path.join("/");
|
||||
|
||||
if (targetPathString.length) {
|
||||
targetPathString += "/";
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
targetStorage = $storagesCollection[storage];
|
||||
|
||||
if (!targetStorage) {
|
||||
goto("/preferences/debug/storage");
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
targetObject = targetStorage
|
||||
? findDeepObject(targetStorage, path)
|
||||
: null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<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}
|
||||
</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>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.path {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: .5em;
|
||||
}
|
||||
</style>
|
||||
59
src/components/features/GroupView.svelte
Normal file
59
src/components/features/GroupView.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script>
|
||||
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
|
||||
|
||||
/**
|
||||
* @type {import('$entities/TagGroup.ts').default}
|
||||
*/
|
||||
export let group;
|
||||
|
||||
let sortedTagsList, sortedPrefixes;
|
||||
|
||||
$: 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>
|
||||
</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>
|
||||
{/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>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,17 +3,30 @@
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
v{version}, made with ♥ by KoloMl.
|
||||
<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 'src/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;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
@use "src/styles/colors";
|
||||
@use "$styles/colors";
|
||||
|
||||
header {
|
||||
background: colors.$header;
|
||||
|
||||
36
src/components/maintenance/ProfileView.svelte
Normal file
36
src/components/maintenance/ProfileView.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
/** @type {import('$entities/MaintenanceProfile.ts').default} */
|
||||
export let profile;
|
||||
|
||||
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
62
src/components/tags/TagsColorContainer.svelte
Normal file
62
src/components/tags/TagsColorContainer.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
/** @type {string} */
|
||||
export let targetCategory = '';
|
||||
</script>
|
||||
|
||||
<div class="tag-color-container tag-color-container--{targetCategory || 'default'}">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@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(.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(.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(.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(.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(.tag-color-container--body-type) :global(.tag) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
}
|
||||
</style>
|
||||
12
src/components/ui/forms/CheckboxField.svelte
Normal file
12
src/components/ui/forms/CheckboxField.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
/** @type {string|undefined} */
|
||||
export let name = undefined;
|
||||
|
||||
/** @type {boolean} */
|
||||
export let checked;
|
||||
</script>
|
||||
|
||||
<input type="checkbox" {name} bind:checked={checked}>
|
||||
<span>
|
||||
<slot></slot>
|
||||
</span>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let label;
|
||||
export let label = undefined;
|
||||
</script>
|
||||
|
||||
<label class="control">
|
||||
@@ -12,5 +12,16 @@
|
||||
</label>
|
||||
|
||||
<style lang="scss">
|
||||
.label {
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
|
||||
.control {
|
||||
padding: 5px 0;
|
||||
|
||||
:global(textarea) {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
40
src/components/ui/forms/SelectField.svelte
Normal file
40
src/components/ui/forms/SelectField.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
/**
|
||||
* @type {string[]|Record<string, string>}
|
||||
*/
|
||||
export let options = [];
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let name = undefined;
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let id = undefined;
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let value = undefined;
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const optionPairs = {};
|
||||
|
||||
if (Array.isArray(options)) {
|
||||
for (let option of options) {
|
||||
optionPairs[option] = option;
|
||||
}
|
||||
} else if (options && typeof options === 'object') {
|
||||
Object.keys(options).forEach((key) => {
|
||||
optionPairs[key] = options[key];
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<select {name} {id} bind:value={value}>
|
||||
{#each Object.entries(optionPairs) as [value, label]}
|
||||
<option {value}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<style lang="scss">
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
80
src/components/ui/forms/TagCategorySelectField.svelte
Normal file
80
src/components/ui/forms/TagCategorySelectField.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script>
|
||||
import SelectField from "$components/ui/forms/SelectField.svelte";
|
||||
import { categories } from "$lib/booru/tag-categories.js";
|
||||
|
||||
/** @type {string} */
|
||||
export let value = '';
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
let tagCategoriesOptions = {
|
||||
'': 'Default'
|
||||
};
|
||||
|
||||
tagCategoriesOptions = categories.reduce((options, category) => {
|
||||
options[category] = category
|
||||
.replace('-', ' ')
|
||||
.replace(/(?<=\s|^)\w/g, (matchedCharacter) => matchedCharacter.toUpperCase());
|
||||
|
||||
return options;
|
||||
}, tagCategoriesOptions);
|
||||
</script>
|
||||
|
||||
<SelectField bind:value={value} options={tagCategoriesOptions} name="tag_color"/>
|
||||
|
||||
<style lang="scss">
|
||||
@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;
|
||||
}
|
||||
|
||||
&: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=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=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-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=body-type])) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,3 +10,9 @@
|
||||
</script>
|
||||
|
||||
<input type="text" {name} {placeholder} bind:value={value}>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.control) input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'src/styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > :global(a) {
|
||||
& > :global(.menu-item) {
|
||||
padding: 5px 24px;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
:global(.menu-item) {
|
||||
color: colors.$text;
|
||||
|
||||
&:hover {
|
||||
|
||||
37
src/components/ui/menu/MenuCheckboxItem.svelte
Normal file
37
src/components/ui/menu/MenuCheckboxItem.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script>
|
||||
import MenuLink from "$components/ui/menu/MenuItem.svelte";
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
export let checked;
|
||||
|
||||
/**
|
||||
* @type {string|undefined}
|
||||
*/
|
||||
export let name = undefined;
|
||||
|
||||
/**
|
||||
* @type {string|undefined}
|
||||
*/
|
||||
export let value = undefined;
|
||||
|
||||
/**
|
||||
* @type {string|null}
|
||||
*/
|
||||
export let href = null;
|
||||
</script>
|
||||
|
||||
<MenuLink {href}>
|
||||
<input type="checkbox" {name} {value} bind:checked={checked} on:input on:click|stopPropagation>
|
||||
<slot></slot>
|
||||
</MenuLink>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.menu-item) input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
41
src/components/ui/menu/MenuItem.svelte
Normal file
41
src/components/ui/menu/MenuItem.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
/**
|
||||
* @type {string|null}
|
||||
*/
|
||||
export let href = null;
|
||||
|
||||
/**
|
||||
* @type {App.IconName|null}
|
||||
*/
|
||||
export let icon = null;
|
||||
|
||||
/**
|
||||
* @type {App.LinkTarget|undefined}
|
||||
*/
|
||||
export let target = undefined;
|
||||
</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>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: colors.$text;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script>
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
export let href;
|
||||
|
||||
/**
|
||||
* @type {"tag"|"paint-brush"|"arrow-left"|"info-circle"|"wrench"|"globe"|"plus"|null}
|
||||
*/
|
||||
export let icon = null;
|
||||
|
||||
/**
|
||||
* @type {"_blank"|"_self"|"_parent"|"_top"|undefined}
|
||||
*/
|
||||
export let target = undefined;
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} {target} on:click>
|
||||
{#if icon}
|
||||
<i class="icon icon-{icon}"></i>
|
||||
{/if}
|
||||
<slot></slot>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../../styles/colors';
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: colors.$text;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
37
src/components/ui/menu/MenuRadioItem.svelte
Normal file
37
src/components/ui/menu/MenuRadioItem.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script>
|
||||
import MenuLink from "$components/ui/menu/MenuItem.svelte";
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
export let checked;
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
export let name;
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
export let value;
|
||||
|
||||
/**
|
||||
* @type {string|null}
|
||||
*/
|
||||
export let href = null;
|
||||
</script>
|
||||
|
||||
<MenuLink {href}>
|
||||
<input type="radio" {name} {value} {checked} on:input on:click|stopPropagation>
|
||||
<slot></slot>
|
||||
</MenuLink>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.menu-item) input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -59,15 +59,19 @@
|
||||
|
||||
/**
|
||||
* 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' && addedTagName.length) {
|
||||
if ((event.code === 'Enter' || event.key === 'Enter') && addedTagName.length) {
|
||||
addTag(addedTagName)
|
||||
addedTagName = '';
|
||||
}
|
||||
|
||||
if (event.code === 'Backspace' && !addedTagName.length && tags?.length) {
|
||||
if ((event.code === 'Backspace' || event.key === 'Backspace') && !addedTagName.length && tags?.length) {
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
}
|
||||
@@ -82,7 +86,11 @@
|
||||
role="button" tabindex="0">x</span>
|
||||
</div>
|
||||
{/each}
|
||||
<input type="text" bind:value={addedTagName} on:keydown={handleKeyPresses}/>
|
||||
<input type="text"
|
||||
bind:value={addedTagName}
|
||||
on:keydown={handleKeyPresses}
|
||||
autocomplete="off"
|
||||
autocapitalize="none"/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -90,5 +98,9 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
66
src/config/tags.ts
Normal file
66
src/config/tags.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export const tagsBlacklist: string[] = [
|
||||
"anthro art",
|
||||
"anthro artist",
|
||||
"anthro cute",
|
||||
"anthro furry",
|
||||
"anthro nsfw",
|
||||
"anthro oc",
|
||||
"anthroart",
|
||||
"anthroartist",
|
||||
"anthrofurry",
|
||||
"anthronsfw",
|
||||
"anthrooc",
|
||||
"art",
|
||||
"artist",
|
||||
"artwork",
|
||||
"cringe",
|
||||
"cringeworthy",
|
||||
"cute art",
|
||||
"cute artwork",
|
||||
"cute furry",
|
||||
"downvotes galore",
|
||||
"drama in comments",
|
||||
"drama in the comments",
|
||||
"fandom",
|
||||
"furries",
|
||||
"furry anthro",
|
||||
"furry art",
|
||||
"furry artist",
|
||||
"furry artwork",
|
||||
"furry character",
|
||||
"furry community",
|
||||
"furry cute",
|
||||
"furry fandom",
|
||||
"furry nsfw",
|
||||
"furry oc",
|
||||
"furryanthro",
|
||||
"furryart",
|
||||
"furryartist",
|
||||
"furryartwork",
|
||||
"furrynsfw",
|
||||
"furryoc",
|
||||
"image",
|
||||
"no tag",
|
||||
"not tagged",
|
||||
"notag",
|
||||
"notags",
|
||||
"nsfw anthro",
|
||||
"nsfw art",
|
||||
"nsfw artist",
|
||||
"nsfw artwork",
|
||||
"nsfw",
|
||||
"nsfwanthro",
|
||||
"nsfwart",
|
||||
"nsfwartist",
|
||||
"nsfwartwork",
|
||||
"paywall",
|
||||
"rcf community",
|
||||
"sfw",
|
||||
"solo oc",
|
||||
"tag me",
|
||||
"tag needed",
|
||||
"tag your shit",
|
||||
"tagme",
|
||||
"upvotes galore",
|
||||
"wall of faves"
|
||||
];
|
||||
3
src/content/tags-editor.js
Normal file
3
src/content/tags-editor.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import {TagsForm} from "$lib/components/TagsForm.js";
|
||||
|
||||
TagsForm.watchForEditors();
|
||||
7
src/content/tags.js
Normal file
7
src/content/tags.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import {watchTagDropdownsInTagsEditor, wrapTagDropdown} from "$lib/components/TagDropdownWrapper.js";
|
||||
|
||||
for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
|
||||
watchTagDropdownsInTagsEditor();
|
||||
12
src/lib/booru/tag-categories.js
Normal file
12
src/lib/booru/tag-categories.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export const categories = [
|
||||
'rating',
|
||||
'spoiler',
|
||||
'origin',
|
||||
'oc',
|
||||
'error',
|
||||
'character',
|
||||
'content-official',
|
||||
'content-fanmade',
|
||||
'species',
|
||||
'body-type',
|
||||
];
|
||||
338
src/lib/components/FullscreenViewer.js
Normal file
338
src/lib/components/FullscreenViewer.js
Normal file
@@ -0,0 +1,338 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings.ts";
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
this.container.classList.add('fullscreen-viewer');
|
||||
|
||||
this.container.append(
|
||||
this.#spinnerElement,
|
||||
this.#sizeSelectorElement,
|
||||
this.#closeButtonElement,
|
||||
);
|
||||
|
||||
this.#spinnerElement.classList.add('spinner', 'fa', 'fa-circle-notch', 'fa-spin');
|
||||
this.#closeButtonElement.classList.add('close', 'fa', 'fa-xmark');
|
||||
this.#sizeSelectorElement.classList.add('size-selector', 'input');
|
||||
|
||||
for (const [sizeKey, sizeName] of Object.entries(FullscreenViewer.#previewSizes)) {
|
||||
const sizeOptionElement = document.createElement('option');
|
||||
sizeOptionElement.value = sizeKey;
|
||||
sizeOptionElement.innerText = sizeName;
|
||||
|
||||
this.#sizeSelectorElement.append(sizeOptionElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
init() {
|
||||
document.addEventListener('keydown', this.#onDocumentKeyPressed.bind(this));
|
||||
this.on('click', this.#close.bind(this));
|
||||
|
||||
this.on('touchstart', this.#onTouchStart.bind(this));
|
||||
this.on('touchmove', this.#onTouchMove.bind(this));
|
||||
this.on('touchend', this.#onTouchEnd.bind(this));
|
||||
|
||||
this.#videoElement.addEventListener('loadeddata', this.#onLoaded.bind(this));
|
||||
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
|
||||
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
|
||||
|
||||
FullscreenViewer.#miscSettings
|
||||
.resolveFullscreenViewerPreviewSize()
|
||||
.then(this.#onSizeResolved.bind(this))
|
||||
.then(this.#watchForSizeSelectionChanges.bind(this));
|
||||
}
|
||||
|
||||
#onLoaded() {
|
||||
this.container.classList.remove('loading');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#onTouchStart(event) {
|
||||
if (this.#touchId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTouch = event.touches.item(0);
|
||||
|
||||
if (!firstTouch) {
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
const endedTouch = Array.from(event.changedTouches)
|
||||
.find(touch => touch.identifier === this.#touchId);
|
||||
|
||||
if (!endedTouch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const verticalDistance = Math.abs(endedTouch.clientY - this.#startY);
|
||||
const requiredClosingDistance = window.innerHeight / 3;
|
||||
|
||||
if (this.#isClosingSwipeStarted && verticalDistance > requiredClosingDistance) {
|
||||
this.#close();
|
||||
}
|
||||
|
||||
this.#touchId = null;
|
||||
this.#startX = null;
|
||||
this.#startY = null;
|
||||
this.#isClosingSwipeStarted = null;
|
||||
|
||||
this.container.classList.remove(FullscreenViewer.#swipeState);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.container.style.removeProperty(FullscreenViewer.#offsetProperty);
|
||||
this.container.style.removeProperty(FullscreenViewer.#opacityProperty);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#onTouchMove(event) {
|
||||
if (this.#touchId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#isClosingSwipeStarted === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const changedTouch of event.changedTouches) {
|
||||
if (changedTouch.identifier !== this.#touchId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const verticalDistance = changedTouch.clientY - this.#startY;
|
||||
|
||||
if (this.#isClosingSwipeStarted === null) {
|
||||
const horizontalDistance = changedTouch.clientX - this.#startX;
|
||||
|
||||
if (Math.abs(verticalDistance) >= FullscreenViewer.#minRequiredDistance) {
|
||||
this.#isClosingSwipeStarted = true;
|
||||
} else if (Math.abs(horizontalDistance) >= FullscreenViewer.#minRequiredDistance) {
|
||||
this.#isClosingSwipeStarted = false;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.container.style.setProperty(
|
||||
FullscreenViewer.#offsetProperty,
|
||||
verticalDistance.toString().concat('px')
|
||||
);
|
||||
|
||||
const maxDistance = window.innerHeight * 2;
|
||||
let opacity = 1;
|
||||
|
||||
if (verticalDistance !== 0) {
|
||||
opacity -= Math.min(1, Math.abs(verticalDistance) / maxDistance);
|
||||
}
|
||||
|
||||
this.container.style.setProperty(
|
||||
FullscreenViewer.#opacityProperty,
|
||||
opacity.toString()
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onDocumentKeyPressed(event) {
|
||||
if (event.code === 'Escape' || event.code === 'Esc') {
|
||||
this.#close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("$lib/extension/settings/MiscSettings.js").FullscreenViewerSize} size
|
||||
*/
|
||||
#onSizeResolved(size) {
|
||||
this.#sizeSelectorElement.value = size;
|
||||
this.#isSizeFetched = true;
|
||||
|
||||
this.emit('size-loaded');
|
||||
}
|
||||
|
||||
#watchForSizeSelectionChanges() {
|
||||
let lastActiveSize = this.#sizeSelectorElement.value;
|
||||
|
||||
FullscreenViewer.#miscSettings.subscribe(settings => {
|
||||
const targetSize = settings.fullscreenViewerSize;
|
||||
|
||||
if (!targetSize || lastActiveSize === targetSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActiveSize = targetSize;
|
||||
this.#sizeSelectorElement.value = targetSize;
|
||||
});
|
||||
|
||||
this.#sizeSelectorElement.addEventListener('input', () => {
|
||||
const targetSize = this.#sizeSelectorElement.value;
|
||||
|
||||
if (this.#currentURIs) {
|
||||
void this.show(this.#currentURIs);
|
||||
}
|
||||
|
||||
if (!targetSize || targetSize === lastActiveSize || !(targetSize in FullscreenViewer.#previewSizes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActiveSize = targetSize;
|
||||
void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize);
|
||||
});
|
||||
}
|
||||
|
||||
#close() {
|
||||
this.#currentURIs = null;
|
||||
|
||||
this.container.classList.remove(FullscreenViewer.#shownState);
|
||||
document.body.style.overflow = null;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.#videoElement.volume = 0;
|
||||
this.#videoElement.pause();
|
||||
this.#videoElement.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {App.ImageURIs} imageUris
|
||||
* @return {Promise<string|null>}
|
||||
*/
|
||||
async #resolveCurrentSelectedSizeUrl(imageUris) {
|
||||
if (!this.#isSizeFetched) {
|
||||
await new Promise(resolve => this.on('size-loaded', resolve))
|
||||
}
|
||||
|
||||
let targetSize = this.#sizeSelectorElement.value;
|
||||
|
||||
if (!imageUris.hasOwnProperty(targetSize)) {
|
||||
targetSize = FullscreenViewer.#fallbackSize;
|
||||
}
|
||||
|
||||
if (!imageUris.hasOwnProperty(targetSize)) {
|
||||
targetSize = Object.keys(imageUris)[0];
|
||||
}
|
||||
|
||||
if (!targetSize) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return imageUris[targetSize];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {App.ImageURIs} imageUris
|
||||
*/
|
||||
async show(imageUris) {
|
||||
this.#currentURIs = imageUris;
|
||||
|
||||
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
|
||||
|
||||
if (!url) {
|
||||
console.warn('Failed to resolve media for the viewer!');
|
||||
return;
|
||||
}
|
||||
|
||||
this.container.classList.add('loading');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.container.classList.add(FullscreenViewer.#shownState);
|
||||
document.body.style.overflow = 'hidden';
|
||||
});
|
||||
|
||||
if (FullscreenViewer.#isVideoUrl(url)) {
|
||||
this.#imageElement.remove();
|
||||
|
||||
this.#videoElement.src = url;
|
||||
this.#videoElement.volume = 0;
|
||||
this.#videoElement.autoplay = true;
|
||||
this.#videoElement.loop = true;
|
||||
this.#videoElement.controls = true;
|
||||
|
||||
this.container.append(this.#videoElement);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.#videoElement.remove();
|
||||
|
||||
this.#imageElement.src = url;
|
||||
|
||||
this.container.append(this.#imageElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @return {boolean}
|
||||
*/
|
||||
static #isVideoUrl(url) {
|
||||
return url.endsWith('.mp4') || url.endsWith('.webm');
|
||||
}
|
||||
|
||||
static #miscSettings = new MiscSettings();
|
||||
|
||||
static #offsetProperty = '--offset';
|
||||
static #opacityProperty = '--opacity';
|
||||
static #shownState = 'shown';
|
||||
static #swipeState = 'swiped';
|
||||
static #minRequiredDistance = 50;
|
||||
|
||||
/**
|
||||
* @type {Record<import("$lib/extension/settings/MiscSettings.js").FullscreenViewerSize, string>}
|
||||
*/
|
||||
static #previewSizes = {
|
||||
full: 'Full',
|
||||
large: 'Large',
|
||||
medium: 'Medium',
|
||||
small: 'Small'
|
||||
}
|
||||
|
||||
static #fallbackSize = 'large';
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings.ts";
|
||||
import {FullscreenViewer} from "$lib/components/FullscreenViewer.js";
|
||||
|
||||
export class ImageShowFullscreenButton extends BaseComponent {
|
||||
/**
|
||||
* @type {MediaBoxTools}
|
||||
*/
|
||||
#mediaBoxTools;
|
||||
#isFullscreenButtonEnabled = false;
|
||||
|
||||
build() {
|
||||
this.container.innerText = '🔍';
|
||||
ImageShowFullscreenButton.#resolveFullscreenViewer();
|
||||
|
||||
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -20,99 +24,63 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
}
|
||||
|
||||
this.on('click', this.#onButtonClicked.bind(this));
|
||||
|
||||
if (ImageShowFullscreenButton.#miscSettings) {
|
||||
ImageShowFullscreenButton.#miscSettings.resolveFullscreenViewerEnabled()
|
||||
.then(isEnabled => {
|
||||
this.#isFullscreenButtonEnabled = isEnabled;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
.then(() => {
|
||||
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#updateFullscreenButtonVisibility() {
|
||||
this.container.classList.toggle('is-visible', this.#isFullscreenButtonEnabled);
|
||||
}
|
||||
|
||||
#onButtonClicked() {
|
||||
const imageViewer = ImageShowFullscreenButton.#resolveFullscreenViewer();
|
||||
const largeSourceUrl = this.#mediaBoxTools.mediaBox.imageLinks.large;
|
||||
|
||||
let imageElement = imageViewer.querySelector('img');
|
||||
let videoElement = imageViewer.querySelector('video');
|
||||
|
||||
if (imageElement) {
|
||||
imageElement.remove();
|
||||
}
|
||||
|
||||
if (videoElement) {
|
||||
videoElement.remove();
|
||||
}
|
||||
|
||||
if (largeSourceUrl.endsWith('.webm') || largeSourceUrl.endsWith('.mp4')) {
|
||||
videoElement ??= document.createElement('video');
|
||||
videoElement.src = largeSourceUrl;
|
||||
videoElement.volume = 0;
|
||||
videoElement.autoplay = true;
|
||||
videoElement.loop = true;
|
||||
videoElement.controls = true;
|
||||
|
||||
imageViewer.appendChild(videoElement);
|
||||
} else {
|
||||
imageElement ??= document.createElement('img');
|
||||
imageElement.src = largeSourceUrl;
|
||||
|
||||
imageViewer.appendChild(imageElement);
|
||||
}
|
||||
|
||||
imageViewer.classList.add('shown');
|
||||
ImageShowFullscreenButton
|
||||
.#resolveViewer()
|
||||
.show(this.#mediaBoxTools.mediaBox.imageLinks);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {HTMLElement|null}
|
||||
* @type {FullscreenViewer|null}
|
||||
*/
|
||||
static #fullscreenViewerElement = null;
|
||||
static #viewer = null;
|
||||
|
||||
/**
|
||||
* @return {HTMLElement}
|
||||
* @return {FullscreenViewer}
|
||||
*/
|
||||
static #resolveFullscreenViewer() {
|
||||
this.#fullscreenViewerElement ??= this.#buildFullscreenViewer();
|
||||
return this.#fullscreenViewerElement;
|
||||
static #resolveViewer() {
|
||||
this.#viewer ??= this.#buildViewer();
|
||||
return this.#viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {HTMLElement}
|
||||
* @return {FullscreenViewer}
|
||||
*/
|
||||
static #buildFullscreenViewer() {
|
||||
static #buildViewer() {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('fullscreen-viewer');
|
||||
const viewer = new FullscreenViewer(element);
|
||||
|
||||
viewer.initialize();
|
||||
|
||||
document.body.append(element);
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
// When ESC pressed
|
||||
if (event.code === 'Escape' || event.code === 'Esc') {
|
||||
this.#closeFullscreenViewer(element);
|
||||
}
|
||||
});
|
||||
|
||||
element.addEventListener('click', () => {
|
||||
this.#closeFullscreenViewer(element);
|
||||
});
|
||||
|
||||
return element;
|
||||
return viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} [viewerElement]
|
||||
* @type {MiscSettings|null}
|
||||
*/
|
||||
static #closeFullscreenViewer(viewerElement = null) {
|
||||
viewerElement ??= this.#resolveFullscreenViewer();
|
||||
viewerElement.classList.remove('shown');
|
||||
|
||||
/** @type {HTMLVideoElement} */
|
||||
const videoElement = viewerElement.querySelector('video');
|
||||
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stopping and muting the video
|
||||
requestAnimationFrame(() => {
|
||||
videoElement.volume = 0;
|
||||
videoElement.pause();
|
||||
videoElement.remove();
|
||||
})
|
||||
}
|
||||
static #miscSettings = null;
|
||||
}
|
||||
|
||||
export function createImageShowFullscreenButton() {
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI.js";
|
||||
import {tagsBlacklist} from "$config/tags.ts";
|
||||
|
||||
class BlackListedTagsEncounteredError extends Error {
|
||||
/**
|
||||
* @param {string} tagName
|
||||
*/
|
||||
constructor(tagName) {
|
||||
super(`This tag is blacklisted and prevents submission: ${tagName}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class MaintenancePopup extends BaseComponent {
|
||||
/** @type {HTMLElement} */
|
||||
@@ -11,6 +21,9 @@ export class MaintenancePopup extends BaseComponent {
|
||||
/** @type {HTMLElement[]} */
|
||||
#tagsList = [];
|
||||
|
||||
/** @type {Map<string, HTMLElement>} */
|
||||
#suggestedInvalidTags = new Map();
|
||||
|
||||
/** @type {MaintenanceProfile|null} */
|
||||
#activeProfile = null;
|
||||
|
||||
@@ -89,11 +102,16 @@ export class MaintenancePopup extends BaseComponent {
|
||||
/** @type {string[]} */
|
||||
const activeProfileTagsList = this.#activeProfile?.settings.tags || [];
|
||||
|
||||
for (let tagElement of this.#tagsList) {
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.remove();
|
||||
}
|
||||
|
||||
for (const tagElement of this.#suggestedInvalidTags.values()) {
|
||||
tagElement.remove();
|
||||
}
|
||||
|
||||
this.#tagsList = new Array(activeProfileTagsList.length);
|
||||
this.#suggestedInvalidTags.clear();
|
||||
|
||||
const currentPostTags = this.#mediaBoxTools.mediaBox.tagsAndAliases;
|
||||
|
||||
@@ -109,6 +127,12 @@ export class MaintenancePopup extends BaseComponent {
|
||||
tagElement.classList.toggle('is-present', isPresent);
|
||||
tagElement.classList.toggle('is-missing', !isPresent);
|
||||
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)) {
|
||||
MaintenancePopup.#markTagAsInvalid(tagElement);
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,6 +175,11 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
|
||||
// Notify only once, when first planning to submit
|
||||
if (!this.#isPlanningToSubmit) {
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(true);
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = true;
|
||||
this.emit('maintenance-state-change', 'waiting');
|
||||
}
|
||||
@@ -181,20 +210,50 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
this.emit('maintenance-state-change', 'processing');
|
||||
|
||||
const maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
|
||||
this.#mediaBoxTools.mediaBox.imageId,
|
||||
tagsList => {
|
||||
for (let tagName of this.#tagsToRemove) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
let maybeTagsAndAliasesAfterUpdate;
|
||||
|
||||
for (let tagName of this.#tagsToAdd) {
|
||||
tagsList.add(tagName);
|
||||
}
|
||||
const shouldAutoRemove = await MaintenancePopup.#maintenanceSettings.resolveStripBlacklistedTags();
|
||||
|
||||
return tagsList;
|
||||
try {
|
||||
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
|
||||
this.#mediaBoxTools.mediaBox.imageId,
|
||||
tagsList => {
|
||||
for (let tagName of this.#tagsToRemove) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
|
||||
for (let tagName of this.#tagsToAdd) {
|
||||
tagsList.add(tagName);
|
||||
}
|
||||
|
||||
if (shouldAutoRemove) {
|
||||
for (let tagName of tagsBlacklist) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
} else {
|
||||
for (let tagName of tagsList) {
|
||||
if (tagsBlacklist.includes(tagName)) {
|
||||
throw new BlackListedTagsEncounteredError(tagName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tagsList;
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
if (e instanceof BlackListedTagsEncounteredError) {
|
||||
this.#revealInvalidTags();
|
||||
} else {
|
||||
console.warn('Tags submission failed:', e);
|
||||
}
|
||||
);
|
||||
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
this.emit('maintenance-state-change', 'failed');
|
||||
this.#isSubmitting = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (maybeTagsAndAliasesAfterUpdate) {
|
||||
this.emit('tags-updated', maybeTagsAndAliasesAfterUpdate);
|
||||
@@ -206,10 +265,41 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.#tagsToRemove.clear();
|
||||
|
||||
this.#refreshTagsList();
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#isSubmitting = false;
|
||||
}
|
||||
|
||||
#revealInvalidTags() {
|
||||
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
|
||||
|
||||
if (!tagsAndAliases) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstTagInList = this.#tagsList[0];
|
||||
|
||||
for (let tagName of tagsBlacklist) {
|
||||
if (tagsAndAliases.has(tagName)) {
|
||||
if (this.#suggestedInvalidTags.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagElement = MaintenancePopup.#buildTagElement(tagName);
|
||||
MaintenancePopup.#markTagAsInvalid(tagElement);
|
||||
tagElement.classList.add('is-present');
|
||||
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
|
||||
if (firstTagInList && firstTagInList.isConnected) {
|
||||
this.#tagsListElement.insertBefore(tagElement, firstTagInList);
|
||||
} else {
|
||||
this.#tagsListElement.appendChild(tagElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
@@ -230,6 +320,15 @@ export class MaintenancePopup extends BaseComponent {
|
||||
return tagElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the tag with red color.
|
||||
* @param {HTMLElement} tagElement Element to mark.
|
||||
*/
|
||||
static #markTagAsInvalid(tagElement) {
|
||||
tagElement.dataset.tagCategory = 'error';
|
||||
tagElement.setAttribute('data-tag-category', 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller with maintenance settings.
|
||||
* @type {MaintenanceSettings}
|
||||
@@ -254,12 +353,12 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeFromMaintenanceSettings = MaintenanceSettings.subscribe(settings => {
|
||||
if (settings.activeProfileId === lastActiveProfileId) {
|
||||
const unsubscribeFromMaintenanceSettings = this.#maintenanceSettings.subscribe(settings => {
|
||||
if (settings.activeProfile === lastActiveProfileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActiveProfileId = settings.activeProfileId;
|
||||
lastActiveProfileId = settings.activeProfile;
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
@@ -268,7 +367,13 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
.then(callback);
|
||||
.then(profileOrNull => {
|
||||
if (profileOrNull) {
|
||||
lastActiveProfileId = profileOrNull.id;
|
||||
}
|
||||
|
||||
callback(profileOrNull);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromProfilesChanges();
|
||||
@@ -276,9 +381,42 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the frontend about new pending submission started.
|
||||
* @param {boolean} isStarted True if started, false if ended.
|
||||
*/
|
||||
static #notifyAboutPendingSubmission(isStarted) {
|
||||
if (this.#pendingSubmissionCount === null) {
|
||||
this.#pendingSubmissionCount = 0;
|
||||
this.#initializeExitPromptHandler();
|
||||
}
|
||||
|
||||
this.#pendingSubmissionCount += isStarted ? 1 : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the global window closing event, show the prompt when there are pending submission.
|
||||
*/
|
||||
static #initializeExitPromptHandler() {
|
||||
window.addEventListener('beforeunload', event => {
|
||||
if (!this.#pendingSubmissionCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.returnValue = true;
|
||||
});
|
||||
}
|
||||
|
||||
static #scrapedAPI = new ScrapedAPI();
|
||||
|
||||
static #delayBeforeSubmissionMs = 500;
|
||||
|
||||
/**
|
||||
* Amount of pending submissions or NULL if logic was not yet initialized.
|
||||
* @type {number|null}
|
||||
*/
|
||||
static #pendingSubmissionCount = null;
|
||||
}
|
||||
|
||||
export function createMaintenancePopup() {
|
||||
|
||||
@@ -38,7 +38,11 @@ export class MaintenanceStatusIcon extends BaseComponent {
|
||||
break;
|
||||
|
||||
case "complete":
|
||||
this.container.innerText = '✅'
|
||||
this.container.innerText = '✅';
|
||||
break;
|
||||
|
||||
case "failed":
|
||||
this.container.innerText = '⚠️';
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -56,7 +56,7 @@ export class MediaBoxWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ImageURIs}
|
||||
* @return {App.ImageURIs}
|
||||
*/
|
||||
get imageLinks() {
|
||||
return JSON.parse(this.#thumbnailContainer.dataset.uris);
|
||||
@@ -100,10 +100,3 @@ export function calculateMediaBoxesPositions(mediaBoxesList) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ImageURIs
|
||||
* @property {string} full
|
||||
* @property {string} large
|
||||
* @property {string} small
|
||||
*/
|
||||
|
||||
@@ -1,44 +1,67 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {QueryLexer, QuotedTermToken, TermToken, Token} from "$lib/booru/search/QueryLexer.js";
|
||||
import SearchSettings from "$lib/extension/settings/SearchSettings.ts";
|
||||
|
||||
export class SearchWrapper extends BaseComponent {
|
||||
/** @type {HTMLInputElement|null} */
|
||||
#searchField = null;
|
||||
/** @type {HTMLInputElement|null} */
|
||||
#autoCompleteField = 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;
|
||||
|
||||
build() {
|
||||
this.container.classList.add('header__search--completable');
|
||||
|
||||
this.#searchField = this.container.querySelector('input[name=q]');
|
||||
this.#searchField.autocomplete = 'off'; // Browser's auto-complete will get in the way!
|
||||
|
||||
const autoCompleteField = document.createElement('input');
|
||||
autoCompleteField.dataset.ac = 'true';
|
||||
autoCompleteField.dataset.acMinLength = '3';
|
||||
autoCompleteField.dataset.acSource = '/autocomplete/tags?term=';
|
||||
autoCompleteField.classList.add('search-autocomplete-dummy');
|
||||
|
||||
this.#autoCompleteField = autoCompleteField;
|
||||
|
||||
this.container.appendChild(autoCompleteField);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.#searchField.addEventListener('input', this.#updateAutoCompletedFragment.bind(this));
|
||||
this.#searchField.addEventListener('keydown', this.#onSearchFieldKeyPressed.bind(this));
|
||||
this.#searchField.addEventListener('selectionchange', this.#updateAutoCompletedFragment.bind(this));
|
||||
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this));
|
||||
|
||||
this.#searchSettings.resolvePropertiesSuggestionsEnabled()
|
||||
.then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled);
|
||||
this.#searchSettings.resolvePropertiesSuggestionsPosition()
|
||||
.then(position => this.#propertiesSuggestionsPosition = position);
|
||||
|
||||
this.#searchSettings.subscribe(settings => {
|
||||
this.#arePropertiesSuggestionsEnabled = settings.suggestProperties;
|
||||
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition;
|
||||
});
|
||||
}
|
||||
|
||||
#updateAutoCompletedFragment() {
|
||||
const searchableFragment = this.#findCurrentTagFragment();
|
||||
this.#emitAutoComplete(searchableFragment || '');
|
||||
/**
|
||||
* Catch the user input and execute suggestions logic.
|
||||
* @param {InputEvent} event Source event to find the input element from.
|
||||
*/
|
||||
#onInputFindProperties(event) {
|
||||
// Ignore events until option is enabled.
|
||||
if (!this.#arePropertiesSuggestionsEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFragment = this.#findCurrentTagFragment();
|
||||
|
||||
if (!currentFragment) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#renderSuggestions(
|
||||
SearchWrapper.#resolveSuggestionsFromTerm(currentFragment),
|
||||
event.currentTarget
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selection position in the search field.
|
||||
* @return {number}
|
||||
*/
|
||||
#getInputUserSelection() {
|
||||
return Math.min(
|
||||
this.#searchField.selectionStart,
|
||||
@@ -46,6 +69,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the search query and return the list of parsed tokens. Result will be cached for current search query.
|
||||
* @return {Token[]}
|
||||
*/
|
||||
#resolveQueryTokens() {
|
||||
const searchValue = this.#searchField.value;
|
||||
|
||||
@@ -60,69 +87,8 @@ export class SearchWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onSearchFieldKeyPressed(event) {
|
||||
// On enter, attempt to replace the current active tag in the query with autocomplete selection
|
||||
if (event.code === 'Enter') {
|
||||
this.#onEnterPressed(event);
|
||||
}
|
||||
|
||||
this.#autoCompleteField.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
keyCode: event.keyCode
|
||||
})
|
||||
);
|
||||
|
||||
// Similarly to the site's autocomplete logic, we need to prevent the arrows up/down from causing any issues
|
||||
if (event.keyCode === 38 || event.keyCode === 40) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onEnterPressed(event) {
|
||||
const autocompleteSelection = document.querySelector('.autocomplete__item--selected');
|
||||
|
||||
if (!autocompleteSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeToken = SearchWrapper.#findActiveSearchTermPosition(
|
||||
this.#resolveQueryTokens(),
|
||||
this.#getInputUserSelection(),
|
||||
);
|
||||
|
||||
if (activeToken instanceof TermToken || activeToken instanceof QuotedTermToken) {
|
||||
const selectionStart = activeToken.index;
|
||||
const selectionEnd = activeToken.index + activeToken.value.length;
|
||||
|
||||
let autocompletedValue = autocompleteSelection.dataset.value;
|
||||
|
||||
if (activeToken instanceof QuotedTermToken) {
|
||||
autocompletedValue = `"${QuotedTermToken.encode(autocompletedValue)}"`;
|
||||
}
|
||||
|
||||
this.#searchField.value = this.#searchField.value.slice(0, selectionStart)
|
||||
+ autocompletedValue
|
||||
+ this.#searchField.value.slice(selectionEnd);
|
||||
|
||||
const newSelectionEnd = selectionStart + autocompletedValue.length;
|
||||
|
||||
// Place the caret at the end of the currently active tag.
|
||||
// Actually, this does not work for some reason. After the tag is sent to the field and selection was changed to
|
||||
// the end of the inserted tag, browser just does not scroll the input to the caret position.
|
||||
this.#searchField.focus();
|
||||
this.#searchField.setSelectionRange(newSelectionEnd, newSelectionEnd);
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string|null}
|
||||
* Find the currently selected term.
|
||||
* @return {string|null} Selected term or null if none found.
|
||||
*/
|
||||
#findCurrentTagFragment() {
|
||||
if (!this.#searchField) {
|
||||
@@ -132,6 +98,7 @@ export class SearchWrapper extends BaseComponent {
|
||||
let searchValue = this.#searchField.value;
|
||||
|
||||
if (!searchValue) {
|
||||
this.#lastTermToken = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -141,31 +108,88 @@ export class SearchWrapper extends BaseComponent {
|
||||
);
|
||||
|
||||
if (token instanceof TermToken) {
|
||||
this.#lastTermToken = token;
|
||||
return token.value;
|
||||
}
|
||||
|
||||
if (token instanceof QuotedTermToken) {
|
||||
this.#lastTermToken = token;
|
||||
return token.decodedValue;
|
||||
}
|
||||
|
||||
this.#lastTermToken = null;
|
||||
return searchValue;
|
||||
}
|
||||
|
||||
#emitAutoComplete(userInputFragment) {
|
||||
this.#autoCompleteField.value = userInputFragment;
|
||||
/**
|
||||
* Resolve the autocomplete container from the document. Once resolved, it can be safely reused without breaking
|
||||
* anything. Assuming refactored autocomplete handler is still implemented the way it is.
|
||||
*
|
||||
* This means, that properties will only be suggested once actual autocomplete logic was activated.
|
||||
*
|
||||
* @return {HTMLElement|null} Resolved element or nothing.
|
||||
*/
|
||||
#resolveAutocompleteContainer() {
|
||||
if (this.#cachedAutocompleteContainer) {
|
||||
return this.#cachedAutocompleteContainer;
|
||||
}
|
||||
|
||||
this.#cachedAutocompleteContainer = document.querySelector('.autocomplete');
|
||||
|
||||
return this.#cachedAutocompleteContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
#renderSuggestions(suggestions, targetInput) {
|
||||
/** @type {HTMLElement[]} */
|
||||
const suggestedListItems = suggestions
|
||||
.map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm));
|
||||
|
||||
// Should be at least one frame away, since input event always removes autocomplete window
|
||||
requestAnimationFrame(() => {
|
||||
this.#autoCompleteField.dispatchEvent(
|
||||
new InputEvent('input', {bubbles: true})
|
||||
);
|
||||
const autocompleteContainer = this.#resolveAutocompleteContainer();
|
||||
|
||||
const autocompleteContainer = document.querySelector('.autocomplete');
|
||||
|
||||
if (autocompleteContainer) {
|
||||
autocompleteContainer.style.left = `${this.container.offsetLeft}px`;
|
||||
if (!autocompleteContainer) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Since the autocomplete popup was refactored to re-use the same element over and over again, we need to remove
|
||||
// the options from the popup manually when autocomplete was removed from the DOM, since site is not doing that.
|
||||
const termsToRemove = autocompleteContainer.isConnected
|
||||
// Only removing properties when element is still connected to the DOM (popup is used by the website)
|
||||
? autocompleteContainer.querySelectorAll('.autocomplete__item--property')
|
||||
// Remove everything if popup was disconnected from the DOM.
|
||||
: autocompleteContainer.querySelectorAll('.autocomplete__item')
|
||||
|
||||
for (let existingTerm of termsToRemove) {
|
||||
existingTerm.remove();
|
||||
}
|
||||
|
||||
const listContainer = autocompleteContainer.querySelector('ul');
|
||||
|
||||
switch (this.#propertiesSuggestionsPosition) {
|
||||
case "start":
|
||||
listContainer.prepend(...suggestedListItems);
|
||||
break;
|
||||
|
||||
case "end":
|
||||
listContainer.append(...suggestedListItems);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn("Invalid position for property suggestions!");
|
||||
}
|
||||
|
||||
|
||||
autocompleteContainer.style.position = 'absolute';
|
||||
autocompleteContainer.style.left = `${targetInput.offsetLeft}px`;
|
||||
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - targetInput.parentElement.scrollTop}px`;
|
||||
|
||||
document.body.append(autocompleteContainer);
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,8 +203,217 @@ export class SearchWrapper extends BaseComponent {
|
||||
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeSearWrapper(formElement) {
|
||||
new SearchWrapper(formElement).initialize();
|
||||
/**
|
||||
* 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.
|
||||
* @return {string[]} List of suggestions. Could be empty.
|
||||
*/
|
||||
static #resolveSuggestionsFromTerm(searchTermValue) {
|
||||
/** @type {string[]} */
|
||||
const suggestionsList = [];
|
||||
|
||||
this.#propertySearchTermHeadingRegExp.lastIndex = 0;
|
||||
const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue);
|
||||
|
||||
if (!parsedResult) {
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
const propertyName = parsedResult.groups.name;
|
||||
const propertyType = this.#properties.get(propertyName);
|
||||
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 (this.#typeValues.has(propertyType)) {
|
||||
const givenValue = parsedResult.groups.value;
|
||||
|
||||
for (let candidateValue of this.#typeValues.get(propertyType)) {
|
||||
if (givenValue && !candidateValue.startsWith(givenValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestionsList.push(`${propertyName}${parsedResult.groups.op_syntax ?? ''}:${candidateValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
// If at least one dot placed, start suggesting operators
|
||||
if (hasOperatorSyntax) {
|
||||
if (this.#typeOperators.has(propertyType)) {
|
||||
const operatorName = parsedResult.groups.op;
|
||||
|
||||
for (let candidateOperator of this.#typeOperators.get(propertyType)) {
|
||||
if (operatorName && !candidateOperator.startsWith(operatorName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestionsList.push(`${propertyName}.${candidateOperator}:`);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
// Otherwise, search for properties with names starting with the term
|
||||
for (let [candidateProperty] of this.#properties) {
|
||||
if (propertyName && !candidateProperty.startsWith(propertyName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestionsList.push(candidateProperty);
|
||||
}
|
||||
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
#renderTermSuggestion(suggestedTerm) {
|
||||
/** @type {HTMLElement} */
|
||||
const suggestionItem = document.createElement('li');
|
||||
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
|
||||
suggestionItem.dataset.value = suggestedTerm;
|
||||
suggestionItem.innerText = suggestedTerm;
|
||||
|
||||
const propertyIcon = document.createElement('i');
|
||||
propertyIcon.classList.add('fa', 'fa-info-circle');
|
||||
suggestionItem.insertAdjacentElement('afterbegin', propertyIcon);
|
||||
|
||||
suggestionItem.addEventListener('mouseover', () => {
|
||||
SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem);
|
||||
suggestionItem.classList.add('autocomplete__item--selected');
|
||||
});
|
||||
|
||||
suggestionItem.addEventListener('mouseout', () => {
|
||||
SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem);
|
||||
});
|
||||
|
||||
suggestionItem.addEventListener('click', () => {
|
||||
this.#replaceLastActiveTokenWithSuggestion(suggestedTerm);
|
||||
});
|
||||
|
||||
return suggestionItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically replace the last active token stored in the variable with the new value.
|
||||
* @param {string} suggestedTerm Term to replace the value with.
|
||||
*/
|
||||
#replaceLastActiveTokenWithSuggestion(suggestedTerm) {
|
||||
if (!this.#lastTermToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchQuery = this.#searchField.value;
|
||||
const beforeToken = searchQuery.substring(0, this.#lastTermToken.index);
|
||||
const afterToken = searchQuery.substring(this.#lastTermToken.index + this.#lastTermToken.value.length);
|
||||
|
||||
let replacementValue = suggestedTerm;
|
||||
|
||||
if (replacementValue.includes('"')) {
|
||||
replacementValue = `"${QuotedTermToken.encode(replacementValue)}"`
|
||||
}
|
||||
|
||||
this.#searchField.value = beforeToken + replacementValue + afterToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
static #findAndResetSelectedSuggestion(suggestedElement) {
|
||||
if (!suggestedElement.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let selectedElement of suggestedElement.parentElement.querySelectorAll('.autocomplete__item--selected')) {
|
||||
selectedElement.classList.remove('autocomplete__item--selected');
|
||||
}
|
||||
}
|
||||
|
||||
static #typeNumeric = Symbol();
|
||||
static #typeDate = Symbol();
|
||||
static #typeLiteral = Symbol();
|
||||
static #typePersonal = Symbol();
|
||||
static #typeBoolean = Symbol();
|
||||
|
||||
static #properties = new Map([
|
||||
['animated', SearchWrapper.#typeBoolean],
|
||||
['aspect_ratio', SearchWrapper.#typeNumeric],
|
||||
['body_type_tag_count', SearchWrapper.#typeNumeric],
|
||||
['character_tag_count', SearchWrapper.#typeNumeric],
|
||||
['comment_count', SearchWrapper.#typeNumeric],
|
||||
['content_fanmade_tag_count', SearchWrapper.#typeNumeric],
|
||||
['content_official_tag_count', SearchWrapper.#typeNumeric],
|
||||
['created_at', SearchWrapper.#typeDate],
|
||||
['description', SearchWrapper.#typeLiteral],
|
||||
['downvotes', SearchWrapper.#typeNumeric],
|
||||
['duration', SearchWrapper.#typeNumeric],
|
||||
['error_tag_count', SearchWrapper.#typeNumeric],
|
||||
['faved_by', SearchWrapper.#typeLiteral],
|
||||
['faved_by_id', SearchWrapper.#typeNumeric],
|
||||
['faves', SearchWrapper.#typeNumeric],
|
||||
['file_name', SearchWrapper.#typeLiteral],
|
||||
['first_seen_at', SearchWrapper.#typeDate],
|
||||
['height', SearchWrapper.#typeNumeric],
|
||||
['id', SearchWrapper.#typeNumeric],
|
||||
['oc_tag_count', SearchWrapper.#typeNumeric],
|
||||
['orig_sha512_hash', SearchWrapper.#typeLiteral],
|
||||
['original_format', SearchWrapper.#typeLiteral],
|
||||
['pixels', SearchWrapper.#typeNumeric],
|
||||
['rating_tag_count', SearchWrapper.#typeNumeric],
|
||||
['score', SearchWrapper.#typeNumeric],
|
||||
['sha512_hash', SearchWrapper.#typeLiteral],
|
||||
['size', SearchWrapper.#typeNumeric],
|
||||
['source_count', SearchWrapper.#typeNumeric],
|
||||
['source_url', SearchWrapper.#typeLiteral],
|
||||
['species_tag_count', SearchWrapper.#typeNumeric],
|
||||
['spoiler_tag_count', SearchWrapper.#typeNumeric],
|
||||
['tag_count', SearchWrapper.#typeNumeric],
|
||||
['updated_at', SearchWrapper.#typeDate],
|
||||
['uploader', SearchWrapper.#typeLiteral],
|
||||
['uploader_id', SearchWrapper.#typeNumeric],
|
||||
['upvotes', SearchWrapper.#typeNumeric],
|
||||
['width', SearchWrapper.#typeNumeric],
|
||||
['wilson_score', SearchWrapper.#typeNumeric],
|
||||
['my', SearchWrapper.#typePersonal],
|
||||
]);
|
||||
|
||||
static #comparisonOperators = ['gt', 'gte', 'lt', 'lte'];
|
||||
|
||||
static #typeOperators = new Map([
|
||||
[SearchWrapper.#typeNumeric, SearchWrapper.#comparisonOperators],
|
||||
[SearchWrapper.#typeDate, SearchWrapper.#comparisonOperators],
|
||||
]);
|
||||
|
||||
static #typeValues = new Map([
|
||||
[SearchWrapper.#typePersonal, [
|
||||
'comments',
|
||||
'faves',
|
||||
'posts',
|
||||
'uploads',
|
||||
'upvotes',
|
||||
'watched',
|
||||
]],
|
||||
[SearchWrapper.#typeBoolean, [
|
||||
'true',
|
||||
'false',
|
||||
]]
|
||||
]);
|
||||
}
|
||||
|
||||
276
src/lib/components/TagDropdownWrapper.js
Normal file
276
src/lib/components/TagDropdownWrapper.js
Normal file
@@ -0,0 +1,276 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
|
||||
|
||||
const isTagEditorProcessedKey = Symbol();
|
||||
const categoriesResolver = new CustomCategoriesResolver();
|
||||
|
||||
export class TagDropdownWrapper extends BaseComponent {
|
||||
/**
|
||||
* Container with dropdown elements to insert options into.
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
#dropdownContainer;
|
||||
|
||||
/**
|
||||
* Button to add or remove the current tag into/from the active profile.
|
||||
* @type {HTMLAnchorElement|null}
|
||||
*/
|
||||
#toggleOnExistingButton = null;
|
||||
|
||||
/**
|
||||
* Button to create a new profile, make it active and add the current tag into the active profile.
|
||||
* @type {HTMLAnchorElement|null}
|
||||
*/
|
||||
#addToNewButton = null;
|
||||
|
||||
/**
|
||||
* Local clone of the currently active profile used for updating the list of tags.
|
||||
* @type {MaintenanceProfile|null}
|
||||
*/
|
||||
#activeProfile = null;
|
||||
|
||||
/**
|
||||
* Is cursor currently entered the dropdown.
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isEntered = false;
|
||||
|
||||
/**
|
||||
* @type {string|undefined|null}
|
||||
*/
|
||||
#originalCategory = null;
|
||||
|
||||
build() {
|
||||
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
|
||||
}
|
||||
|
||||
init() {
|
||||
this.on('mouseenter', this.#onDropdownEntered.bind(this));
|
||||
this.on('mouseleave', this.#onDropdownLeft.bind(this));
|
||||
|
||||
TagDropdownWrapper.#watchActiveProfile(activeProfileOrNull => {
|
||||
this.#activeProfile = activeProfileOrNull;
|
||||
|
||||
if (this.#isEntered) {
|
||||
this.#updateButtons();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get tagName() {
|
||||
return this.container.dataset.tagName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string|undefined}
|
||||
*/
|
||||
get tagCategory() {
|
||||
return this.container.dataset.tagCategory;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|undefined} targetCategory
|
||||
*/
|
||||
set tagCategory(targetCategory) {
|
||||
// Make sure original category is properly stored.
|
||||
this.originalCategory;
|
||||
|
||||
this.container.dataset.tagCategory = targetCategory;
|
||||
|
||||
if (targetCategory) {
|
||||
this.container.setAttribute('data-tag-category', targetCategory);
|
||||
return;
|
||||
}
|
||||
|
||||
this.container.removeAttribute('data-tag-category');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string|undefined}
|
||||
*/
|
||||
get originalCategory() {
|
||||
if (this.#originalCategory === null) {
|
||||
this.#originalCategory = this.tagCategory;
|
||||
}
|
||||
|
||||
return this.#originalCategory;
|
||||
}
|
||||
|
||||
#onDropdownEntered() {
|
||||
this.#isEntered = true;
|
||||
this.#updateButtons();
|
||||
}
|
||||
|
||||
#onDropdownLeft() {
|
||||
this.#isEntered = false;
|
||||
}
|
||||
|
||||
#updateButtons() {
|
||||
if (!this.#activeProfile) {
|
||||
this.#addToNewButton ??= TagDropdownWrapper.#createDropdownLink(
|
||||
'Add to new tagging profile',
|
||||
this.#onAddToNewClicked.bind(this)
|
||||
);
|
||||
|
||||
if (!this.#addToNewButton.isConnected) {
|
||||
this.#dropdownContainer.append(this.#addToNewButton);
|
||||
}
|
||||
} else {
|
||||
this.#addToNewButton?.remove();
|
||||
}
|
||||
|
||||
if (this.#activeProfile) {
|
||||
this.#toggleOnExistingButton ??= TagDropdownWrapper.#createDropdownLink(
|
||||
'Add to existing tagging profile',
|
||||
this.#onToggleInExistingClicked.bind(this)
|
||||
);
|
||||
|
||||
const profileName = this.#activeProfile.settings.name;
|
||||
let profileSpecificButtonText = `Add to profile "${profileName}"`;
|
||||
|
||||
if (this.#activeProfile.settings.tags.includes(this.tagName)) {
|
||||
profileSpecificButtonText = `Remove from profile "${profileName}"`;
|
||||
}
|
||||
|
||||
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
|
||||
|
||||
if (!this.#toggleOnExistingButton.isConnected) {
|
||||
this.#dropdownContainer.append(this.#toggleOnExistingButton);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.#toggleOnExistingButton?.remove();
|
||||
}
|
||||
|
||||
async #onAddToNewClicked() {
|
||||
const profile = new MaintenanceProfile(crypto.randomUUID(), {
|
||||
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
|
||||
tags: [this.tagName],
|
||||
temporary: true,
|
||||
});
|
||||
|
||||
await profile.save();
|
||||
await TagDropdownWrapper.#maintenanceSettings.setActiveProfileId(profile.id);
|
||||
}
|
||||
|
||||
async #onToggleInExistingClicked() {
|
||||
if (!this.#activeProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagsList = new Set(this.#activeProfile.settings.tags);
|
||||
const targetTagName = this.tagName;
|
||||
|
||||
if (tagsList.has(targetTagName)) {
|
||||
tagsList.delete(targetTagName);
|
||||
} else {
|
||||
tagsList.add(targetTagName);
|
||||
}
|
||||
|
||||
this.#activeProfile.settings.tags = Array.from(tagsList.values());
|
||||
|
||||
await this.#activeProfile.save();
|
||||
}
|
||||
|
||||
static #maintenanceSettings = new MaintenanceSettings();
|
||||
|
||||
/**
|
||||
* Watch for changes to active profile.
|
||||
* @param {(profile: MaintenanceProfile|null) => void} onActiveProfileChange Callback to call when profile was
|
||||
* changed.
|
||||
*/
|
||||
static #watchActiveProfile(onActiveProfileChange) {
|
||||
let lastActiveProfile;
|
||||
|
||||
this.#maintenanceSettings.subscribe((settings) => {
|
||||
lastActiveProfile = settings.activeProfile;
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
.then(onActiveProfileChange);
|
||||
});
|
||||
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
const activeProfile = profiles
|
||||
.find(profile => profile.id === lastActiveProfile);
|
||||
|
||||
onActiveProfileChange(activeProfile);
|
||||
});
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
.then(activeProfile => {
|
||||
lastActiveProfile = activeProfile?.id ?? null;
|
||||
onActiveProfileChange(activeProfile);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}
|
||||
*/
|
||||
static #createDropdownLink(text, onClickHandler) {
|
||||
/** @type {HTMLAnchorElement} */
|
||||
const dropdownLink = document.createElement('a');
|
||||
dropdownLink.href = '#';
|
||||
dropdownLink.innerText = text;
|
||||
dropdownLink.className = 'tag__dropdown__link';
|
||||
|
||||
dropdownLink.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
onClickHandler(event);
|
||||
});
|
||||
|
||||
return dropdownLink;
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapTagDropdown(element) {
|
||||
// Skip initialization when tag component is already wrapped
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagDropdown = new TagDropdownWrapper(element);
|
||||
tagDropdown.initialize();
|
||||
|
||||
categoriesResolver.addElement(tagDropdown);
|
||||
}
|
||||
|
||||
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')) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.addEventListener('mouseover', event => {
|
||||
/** @type {HTMLElement} */
|
||||
const targetElement = event.target;
|
||||
|
||||
if (targetElement[isTagEditorProcessedKey]) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
const closestTagEditor = targetElement.closest('#image_tags_and_source');
|
||||
|
||||
if (!closestTagEditor || closestTagEditor[isTagEditorProcessedKey]) {
|
||||
targetElement[isTagEditorProcessedKey] = true;
|
||||
return;
|
||||
}
|
||||
|
||||
targetElement[isTagEditorProcessedKey] = true;
|
||||
closestTagEditor[isTagEditorProcessedKey] = true;
|
||||
|
||||
for (const tagDropdownElement of closestTagEditor.querySelectorAll('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
})
|
||||
}
|
||||
81
src/lib/components/TagsForm.js
Normal file
81
src/lib/components/TagsForm.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import StorageHelper from "$lib/chrome/StorageHelper.js";
|
||||
import StorageHelper from "$lib/browser/StorageHelper.js";
|
||||
|
||||
export default class ConfigurationController {
|
||||
/** @type {string} */
|
||||
@@ -79,4 +79,4 @@ export default class ConfigurationController {
|
||||
}
|
||||
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
}
|
||||
}
|
||||
|
||||
110
src/lib/extension/CustomCategoriesResolver.ts
Normal file
110
src/lib/extension/CustomCategoriesResolver.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {TagDropdownWrapper} from "$lib/components/TagDropdownWrapper";
|
||||
import TagGroup from "$entities/TagGroup.ts";
|
||||
import {escapeRegExp} from "$lib/utils";
|
||||
|
||||
export default class CustomCategoriesResolver {
|
||||
#tagCategories = new Map<string, string>();
|
||||
#compiledRegExps = new Map<RegExp, string>();
|
||||
#tagDropdowns: TagDropdownWrapper[] = [];
|
||||
#nextQueuedUpdate = -1;
|
||||
|
||||
constructor() {
|
||||
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
|
||||
TagGroup.readAll().then(this.#onTagGroupsReceived.bind(this));
|
||||
}
|
||||
|
||||
public addElement(tagDropdown: TagDropdownWrapper): void {
|
||||
this.#tagDropdowns.push(tagDropdown);
|
||||
|
||||
if (!this.#tagCategories.size && !this.#compiledRegExps.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#queueUpdatingTags();
|
||||
}
|
||||
|
||||
#queueUpdatingTags() {
|
||||
clearTimeout(this.#nextQueuedUpdate);
|
||||
|
||||
this.#nextQueuedUpdate = setTimeout(
|
||||
this.#updateUnprocessedTags.bind(this),
|
||||
CustomCategoriesResolver.#unprocessedTagsTimeout
|
||||
);
|
||||
}
|
||||
|
||||
#updateUnprocessedTags() {
|
||||
this.#tagDropdowns
|
||||
.filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory)
|
||||
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
|
||||
.filter(this.#matchCustomCategoryByRegExp.bind(this))
|
||||
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply custom categories for the exact tag names.
|
||||
* @param tagDropdown Element to try applying the category for.
|
||||
* @return {boolean} Will return false when tag is processed and true when it is not found.
|
||||
* @private
|
||||
*/
|
||||
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
if (!this.#tagCategories.has(tagName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
tagDropdown.tagCategory = this.#tagCategories.get(tagName)!;
|
||||
return false;
|
||||
}
|
||||
|
||||
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
for (const targetRegularExpression of this.#compiledRegExps.keys()) {
|
||||
if (!targetRegularExpression.test(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
#onTagGroupsReceived(tagGroups: TagGroup[]) {
|
||||
this.#tagCategories.clear();
|
||||
this.#compiledRegExps.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);
|
||||
}
|
||||
|
||||
for (const tagPrefix of tagGroup.settings.prefixes) {
|
||||
this.#compiledRegExps.set(
|
||||
new RegExp(`^${escapeRegExp(tagPrefix)}`),
|
||||
categoryName
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.#queueUpdatingTags();
|
||||
}
|
||||
|
||||
static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean {
|
||||
return !tagDropdown.originalCategory;
|
||||
}
|
||||
|
||||
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
|
||||
tagDropdown.tagCategory = tagDropdown.originalCategory;
|
||||
}
|
||||
|
||||
static #unprocessedTagsTimeout = 0;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import StorageHelper from "$lib/chrome/StorageHelper.js";
|
||||
import StorageHelper from "$lib/browser/StorageHelper.js";
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity.ts";
|
||||
|
||||
export default class EntitiesController {
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
@@ -6,15 +7,13 @@ export default class EntitiesController {
|
||||
/**
|
||||
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
|
||||
*
|
||||
* @template EntityClass
|
||||
* @param entityName Name of the entity to read.
|
||||
* @param entityClass Class of the entity to read. Must have a constructor that accepts the ID and the settings
|
||||
* object.
|
||||
*
|
||||
* @param {string} entityName Name of the entity to read.
|
||||
* @param {EntityClass} entityClass Class of the entity to read. Must have a constructor that accepts the ID and the
|
||||
* settings object.
|
||||
*
|
||||
* @return {Promise<InstanceType<EntityClass>[]>} List of entities of the given type.
|
||||
* @return List of entities of the given type.
|
||||
*/
|
||||
static async readAllEntities(entityName, entityClass) {
|
||||
static async readAllEntities<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type): Promise<Type[]> {
|
||||
const rawEntities = await this.#storageHelper.read(entityName, {});
|
||||
|
||||
if (!rawEntities || Object.keys(rawEntities).length === 0) {
|
||||
@@ -29,13 +28,11 @@ export default class EntitiesController {
|
||||
/**
|
||||
* Update the single entity in the storage. If the entity with the given ID already exists, it will be overwritten.
|
||||
*
|
||||
* @param {string} entityName Name of the entity to update.
|
||||
* @param {StorageEntity} entity Entity to update.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
* @param entityName Name of the entity to update.
|
||||
* @param entity Entity to update.
|
||||
*/
|
||||
static async updateEntity(entityName, entity) {
|
||||
await this.#storageHelper.write(
|
||||
static async updateEntity(entityName: string, entity: StorageEntity<Object>): Promise<void> {
|
||||
this.#storageHelper.write(
|
||||
entityName,
|
||||
Object.assign(
|
||||
await this.#storageHelper.read(
|
||||
@@ -51,15 +48,13 @@ export default class EntitiesController {
|
||||
/**
|
||||
* Delete the entity with the given ID.
|
||||
*
|
||||
* @param {string} entityName Name of the entity to delete.
|
||||
* @param {string} entityId ID of the entity to delete.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
* @param entityName Name of the entity to delete.
|
||||
* @param entityId ID of the entity to delete.
|
||||
*/
|
||||
static async deleteEntity(entityName, entityId) {
|
||||
static async deleteEntity(entityName: string, entityId: string): Promise<void> {
|
||||
const entities = await this.#storageHelper.read(entityName, {});
|
||||
delete entities[entityId];
|
||||
await this.#storageHelper.write(entityName, entities);
|
||||
this.#storageHelper.write(entityName, entities);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,17 +62,16 @@ export default class EntitiesController {
|
||||
*
|
||||
* @template EntityClass
|
||||
*
|
||||
* @param {string} entityName Name of the entity to subscribe to.
|
||||
* @param {EntityClass} entityClass Class of the entity to subscribe to.
|
||||
* @param {function(InstanceType<EntityClass>[]): any} callback Callback to call when the storage changes.
|
||||
* @return {function(): void} Unsubscribe function.
|
||||
* @param entityName Name of the entity to subscribe to.
|
||||
* @param entityClass Class of the entity to subscribe to.
|
||||
* @param callback Callback to call when the storage changes.
|
||||
* @return Unsubscribe function.
|
||||
*/
|
||||
static subscribeToEntity(entityName, entityClass, callback) {
|
||||
static subscribeToEntity<Type extends StorageEntity<any>>(entityName: string, entityClass: new (...any: any[]) => Type, callback: (entities: Type[]) => void): () => void {
|
||||
/**
|
||||
* Watch the changes made to the storage and call the callback when the entity changes.
|
||||
* @param {Object<string, StorageChange>} changes Changes made to the storage.
|
||||
*/
|
||||
const storageChangesSubscriber = changes => {
|
||||
const storageChangesSubscriber = (changes: Record<string, chrome.storage.StorageChange>) => {
|
||||
if (!changes[entityName]) {
|
||||
return;
|
||||
}
|
||||
@@ -90,4 +84,4 @@ export default class EntitiesController {
|
||||
|
||||
return () => this.#storageHelper.unsubscribe(storageChangesSubscriber);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/lib/extension/EntitiesTransporter.ts
Normal file
89
src/lib/extension/EntitiesTransporter.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {validateImportedEntity} from "$lib/extension/transporting/validators.js";
|
||||
import {exportEntityToObject} from "$lib/extension/transporting/exporters.ts";
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
|
||||
import {compressToEncodedURIComponent, decompressFromEncodedURIComponent} from "lz-string";
|
||||
|
||||
export default class EntitiesTransporter<EntityType> {
|
||||
readonly #targetEntityConstructor: new (...any: any[]) => EntityType;
|
||||
|
||||
/**
|
||||
* Name of the entity, exported directly from the constructor.
|
||||
* @private
|
||||
*/
|
||||
get #entityName() {
|
||||
// How the hell should I even do this?
|
||||
return ((this.#targetEntityConstructor as any) as typeof StorageEntity)._entityName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param entityConstructor Class which should be used for import or export.
|
||||
*/
|
||||
constructor(entityConstructor: new (...any: any[]) => EntityType) {
|
||||
if (!(entityConstructor.prototype instanceof StorageEntity)) {
|
||||
throw new TypeError('Invalid class provided as the target for importing!');
|
||||
}
|
||||
|
||||
this.#targetEntityConstructor = entityConstructor;
|
||||
}
|
||||
|
||||
importFromJSON(jsonString: string): EntityType {
|
||||
const importedObject = this.#tryParsingAsJSON(jsonString);
|
||||
|
||||
if (!importedObject) {
|
||||
throw new Error('Invalid JSON!');
|
||||
}
|
||||
|
||||
validateImportedEntity(
|
||||
importedObject,
|
||||
this.#entityName
|
||||
);
|
||||
|
||||
return new this.#targetEntityConstructor(
|
||||
importedObject.id,
|
||||
importedObject
|
||||
);
|
||||
}
|
||||
|
||||
importFromCompressedJSON(compressedJsonString: string): EntityType {
|
||||
return this.importFromJSON(
|
||||
decompressFromEncodedURIComponent(compressedJsonString)
|
||||
)
|
||||
}
|
||||
|
||||
exportToJSON(entityObject: EntityType): string {
|
||||
if (!(entityObject instanceof this.#targetEntityConstructor)) {
|
||||
throw new TypeError('Transporter should be connected to the same entity to export!');
|
||||
}
|
||||
|
||||
if (!(entityObject instanceof StorageEntity)) {
|
||||
throw new TypeError('Only storage entities could be exported!');
|
||||
}
|
||||
|
||||
const exportableObject = exportEntityToObject(
|
||||
entityObject,
|
||||
this.#entityName
|
||||
);
|
||||
|
||||
return JSON.stringify(exportableObject, null, 2);
|
||||
}
|
||||
|
||||
exportToCompressedJSON(entityObject: EntityType): string {
|
||||
return compressToEncodedURIComponent(this.exportToJSON(entityObject));
|
||||
}
|
||||
|
||||
#tryParsingAsJSON(jsonString: string): Record<string, any> | null {
|
||||
let jsonObject: Record<string, any> | null = null;
|
||||
|
||||
try {
|
||||
jsonObject = JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
|
||||
}
|
||||
|
||||
if (typeof jsonObject !== "object") {
|
||||
throw new TypeError("Should be an object!");
|
||||
}
|
||||
|
||||
return jsonObject
|
||||
}
|
||||
}
|
||||
81
src/lib/extension/base/CacheableSettings.ts
Normal file
81
src/lib/extension/base/CacheableSettings.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController.js";
|
||||
|
||||
export default class CacheableSettings<Fields> {
|
||||
#controller: ConfigurationController;
|
||||
#cachedValues: Map<keyof Fields, any> = new Map();
|
||||
#disposables: Function[] = [];
|
||||
|
||||
constructor(settingsNamespace: string) {
|
||||
this.#controller = new ConfigurationController(settingsNamespace);
|
||||
|
||||
this.#disposables.push(
|
||||
this.#controller.subscribeToChanges(settings => {
|
||||
for (const key of Object.keys(settings)) {
|
||||
this.#cachedValues.set(
|
||||
key as keyof Fields,
|
||||
settings[key]
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template SettingType
|
||||
* @param {string} settingName
|
||||
* @param {SettingType} defaultValue
|
||||
* @return {Promise<SettingType>}
|
||||
* @protected
|
||||
*/
|
||||
protected async _resolveSetting<Key extends keyof Fields>(settingName: Key, defaultValue: Fields[Key]): Promise<Fields[Key]> {
|
||||
if (this.#cachedValues.has(settingName)) {
|
||||
return this.#cachedValues.get(settingName);
|
||||
}
|
||||
|
||||
const settingValue = await this.#controller.readSetting(settingName as string, defaultValue);
|
||||
|
||||
this.#cachedValues.set(settingName, settingValue);
|
||||
|
||||
return settingValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param settingName Name of the setting to write.
|
||||
* @param value Value to pass.
|
||||
* @param force Ignore the cache and force the update.
|
||||
* @protected
|
||||
*/
|
||||
async _writeSetting<Key extends keyof Fields>(settingName: Key, value: Fields[Key], force: boolean = false): Promise<void> {
|
||||
if (
|
||||
!force
|
||||
&& this.#cachedValues.has(settingName)
|
||||
&& this.#cachedValues.get(settingName) === value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.#controller.writeSetting(
|
||||
settingName as string,
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the changes made to the storage.
|
||||
* @param {function(Object): void} callback Callback which will receive list of settings.
|
||||
* @return {function(): void} Unsubscribe function.
|
||||
*/
|
||||
subscribe(callback: (settings: Partial<Fields>) => void): () => void {
|
||||
const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record<string, any>) => void);
|
||||
|
||||
this.#disposables.push(unsubscribeCallback);
|
||||
|
||||
return unsubscribeCallback;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (let disposeCallback of this.#disposables) {
|
||||
disposeCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import EntitiesController from "$lib/extension/EntitiesController.js";
|
||||
|
||||
class StorageEntity {
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
#id;
|
||||
|
||||
/**
|
||||
* @type {Object}
|
||||
*/
|
||||
#settings;
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {Object} settings
|
||||
*/
|
||||
constructor(id, settings = {}) {
|
||||
this.#id = id;
|
||||
this.#settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
get id() {
|
||||
return this.#id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Object}
|
||||
*/
|
||||
get settings() {
|
||||
return this.#settings;
|
||||
}
|
||||
|
||||
static _entityName = "entity";
|
||||
|
||||
async save() {
|
||||
await EntitiesController.updateEntity(this.constructor._entityName, this);
|
||||
}
|
||||
|
||||
async delete() {
|
||||
await EntitiesController.deleteEntity(this.constructor._entityName, this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static function to read all entities of this type from the storage. Must be implemented in the child class.
|
||||
* @return {Promise<array>}
|
||||
*/
|
||||
static async readAll() {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
export default StorageEntity;
|
||||
59
src/lib/extension/base/StorageEntity.ts
Normal file
59
src/lib/extension/base/StorageEntity.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import EntitiesController from "$lib/extension/EntitiesController.js";
|
||||
|
||||
export default abstract class StorageEntity<SettingsType extends Object = {}> {
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
readonly #id: string;
|
||||
|
||||
/**
|
||||
* @type {Object}
|
||||
*/
|
||||
readonly #settings: SettingsType;
|
||||
|
||||
protected constructor(id: string, settings: SettingsType) {
|
||||
this.#id = id;
|
||||
this.#settings = settings;
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.#id;
|
||||
}
|
||||
|
||||
get settings(): SettingsType {
|
||||
return this.#settings;
|
||||
}
|
||||
|
||||
public static readonly _entityName: string = "entity";
|
||||
|
||||
async save() {
|
||||
await EntitiesController.updateEntity(
|
||||
(this.constructor as typeof StorageEntity)._entityName,
|
||||
this
|
||||
);
|
||||
}
|
||||
|
||||
async delete() {
|
||||
await EntitiesController.deleteEntity(
|
||||
(this.constructor as typeof StorageEntity)._entityName,
|
||||
this.id
|
||||
);
|
||||
}
|
||||
|
||||
public static async readAll<Type extends StorageEntity<any>>(this: new (...args: any[]) => Type): Promise<Type[]> {
|
||||
return await EntitiesController.readAllEntities(
|
||||
// Voodoo magic, once again.
|
||||
((this as any) as typeof StorageEntity)._entityName,
|
||||
this
|
||||
)
|
||||
}
|
||||
|
||||
public static subscribe<Type extends StorageEntity<any>>(this: new (...args: any[]) => Type, callback: (entities: Type[]) => void): () => void {
|
||||
return EntitiesController.subscribeToEntity(
|
||||
// And once more.
|
||||
((this as any) as typeof StorageEntity)._entityName,
|
||||
this,
|
||||
callback
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity.js";
|
||||
import EntitiesController from "$lib/extension/EntitiesController.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} MaintenanceProfileSettings
|
||||
* @property {string} name
|
||||
* @property {string[]} tags
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class representing the maintenance profile entity.
|
||||
*/
|
||||
class MaintenanceProfile extends StorageEntity {
|
||||
/**
|
||||
* @param {string} id ID of the entity.
|
||||
* @param {Partial<MaintenanceProfileSettings>} settings Maintenance profile settings object.
|
||||
*/
|
||||
constructor(id, settings) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || []
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {MaintenanceProfileSettings}
|
||||
*/
|
||||
get settings() {
|
||||
return super.settings;
|
||||
}
|
||||
|
||||
static _entityName = "profiles";
|
||||
|
||||
/**
|
||||
* Read all maintenance profiles from the storage.
|
||||
*
|
||||
* @return {Promise<InstanceType<MaintenanceProfile>[]>}
|
||||
*/
|
||||
static async readAll() {
|
||||
return await EntitiesController.readAllEntities(
|
||||
this._entityName,
|
||||
MaintenanceProfile
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the changes and receive the new list of profiles when they change.
|
||||
*
|
||||
* @param {function(MaintenanceProfile[]): void} callback Callback to call when the profiles change. The new list of
|
||||
* profiles is passed as an argument.
|
||||
*
|
||||
* @return {function(): void} Unsubscribe function.
|
||||
*/
|
||||
static subscribe(callback) {
|
||||
return EntitiesController.subscribeToEntity(
|
||||
this._entityName,
|
||||
MaintenanceProfile,
|
||||
callback
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MaintenanceProfile;
|
||||
35
src/lib/extension/entities/MaintenanceProfile.ts
Normal file
35
src/lib/extension/entities/MaintenanceProfile.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
|
||||
import EntitiesController from "$lib/extension/EntitiesController.ts";
|
||||
|
||||
export interface MaintenanceProfileSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
temporary: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class representing the maintenance profile entity.
|
||||
*/
|
||||
export default class MaintenanceProfile extends StorageEntity<MaintenanceProfileSettings> {
|
||||
/**
|
||||
* @param id ID of the entity.
|
||||
* @param settings Maintenance profile settings object.
|
||||
*/
|
||||
constructor(id: string, settings: Partial<MaintenanceProfileSettings>) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
temporary: settings.temporary ?? false
|
||||
});
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (this.settings.temporary && !this.settings.tags?.length) {
|
||||
return this.delete();
|
||||
}
|
||||
|
||||
return super.save();
|
||||
}
|
||||
|
||||
public static readonly _entityName = "profiles";
|
||||
}
|
||||
21
src/lib/extension/entities/TagGroup.ts
Normal file
21
src/lib/extension/entities/TagGroup.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
|
||||
|
||||
export interface TagGroupSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
prefixes: string[];
|
||||
category: string;
|
||||
}
|
||||
|
||||
export default class TagGroup extends StorageEntity<TagGroupSettings> {
|
||||
constructor(id: string, settings: Partial<TagGroupSettings>) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
prefixes: settings.prefixes || [],
|
||||
category: settings.category || ''
|
||||
});
|
||||
}
|
||||
|
||||
static _entityName = 'groups';
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController.js";
|
||||
import MaintenanceProfile from "$lib/extension/entities/MaintenanceProfile.js";
|
||||
|
||||
export default class MaintenanceSettings {
|
||||
#isInitialized = false;
|
||||
#activeProfileId = null;
|
||||
|
||||
constructor() {
|
||||
void this.#initializeSettings();
|
||||
}
|
||||
|
||||
async #initializeSettings() {
|
||||
MaintenanceSettings.#controller.subscribeToChanges(settings => {
|
||||
this.#activeProfileId = settings.activeProfile || null;
|
||||
});
|
||||
|
||||
this.#activeProfileId = await MaintenanceSettings.#controller.readSetting("activeProfile", null);
|
||||
this.#isInitialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active maintenance profile.
|
||||
*
|
||||
* @return {Promise<string|null>}
|
||||
*/
|
||||
async resolveActiveProfileId() {
|
||||
if (!this.#isInitialized && !this.#activeProfileId) {
|
||||
this.#activeProfileId = await MaintenanceSettings.#controller.readSetting(
|
||||
"activeProfile",
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.#activeProfileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.#activeProfileId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active maintenance profile if it is set.
|
||||
*
|
||||
* @return {Promise<MaintenanceProfile|null>}
|
||||
*/
|
||||
async resolveActiveProfileAsObject() {
|
||||
const resolvedProfileId = await this.resolveActiveProfileId();
|
||||
|
||||
return (await MaintenanceProfile.readAll())
|
||||
.find(profile => profile.id === resolvedProfileId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active maintenance profile.
|
||||
*
|
||||
* @param {string|null} profileId ID of the profile to set as active. If `null`, the active profile will be considered
|
||||
* unset.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async setActiveProfileId(profileId) {
|
||||
this.#activeProfileId = profileId;
|
||||
|
||||
await MaintenanceSettings.#controller.writeSetting("activeProfile", profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller for interaction with the settings stored in the extension's storage.
|
||||
*
|
||||
* @type {ConfigurationController}
|
||||
*/
|
||||
static #controller = new ConfigurationController("maintenance");
|
||||
|
||||
/**
|
||||
* Subscribe to the changes in the maintenance-related settings.
|
||||
*
|
||||
* @param {function({activeProfileId: string|null}): void} callback Callback to call when the settings change. The new settings are
|
||||
* passed as an argument.
|
||||
*
|
||||
* @return {function(): void} Unsubscribe function.
|
||||
*/
|
||||
static subscribe(callback) {
|
||||
return MaintenanceSettings.#controller.subscribeToChanges(settings => {
|
||||
callback({
|
||||
activeProfileId: settings.activeProfile || null,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
48
src/lib/extension/settings/MaintenanceSettings.ts
Normal file
48
src/lib/extension/settings/MaintenanceSettings.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings.ts";
|
||||
|
||||
interface MaintenanceSettingsFields {
|
||||
activeProfile: string | null;
|
||||
stripBlacklistedTags: boolean;
|
||||
}
|
||||
|
||||
export default class MaintenanceSettings extends CacheableSettings<MaintenanceSettingsFields> {
|
||||
constructor() {
|
||||
super("maintenance");
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active maintenance profile.
|
||||
*/
|
||||
async resolveActiveProfileId() {
|
||||
return this._resolveSetting("activeProfile", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active maintenance profile if it is set.
|
||||
*/
|
||||
async resolveActiveProfileAsObject(): Promise<MaintenanceProfile | null> {
|
||||
const resolvedProfileId = await this.resolveActiveProfileId();
|
||||
|
||||
return (await MaintenanceProfile.readAll())
|
||||
.find(profile => profile.id === resolvedProfileId) || null;
|
||||
}
|
||||
|
||||
async resolveStripBlacklistedTags() {
|
||||
return this._resolveSetting('stripBlacklistedTags', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the active maintenance profile.
|
||||
*
|
||||
* @param profileId ID of the profile to set as active. If `null`, the active profile will be considered
|
||||
* unset.
|
||||
*/
|
||||
async setActiveProfileId(profileId: string | null): Promise<void> {
|
||||
await this._writeSetting("activeProfile", profileId);
|
||||
}
|
||||
|
||||
async setStripBlacklistedTags(isEnabled: boolean) {
|
||||
await this._writeSetting('stripBlacklistedTags', isEnabled);
|
||||
}
|
||||
}
|
||||
30
src/lib/extension/settings/MiscSettings.ts
Normal file
30
src/lib/extension/settings/MiscSettings.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings.ts";
|
||||
|
||||
export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full';
|
||||
|
||||
interface MiscSettingsFields {
|
||||
fullscreenViewer: boolean;
|
||||
fullscreenViewerSize: FullscreenViewerSize;
|
||||
}
|
||||
|
||||
export default class MiscSettings extends CacheableSettings<MiscSettingsFields> {
|
||||
constructor() {
|
||||
super("misc");
|
||||
}
|
||||
|
||||
async resolveFullscreenViewerEnabled() {
|
||||
return this._resolveSetting("fullscreenViewer", true);
|
||||
}
|
||||
|
||||
async resolveFullscreenViewerPreviewSize() {
|
||||
return this._resolveSetting('fullscreenViewerSize', 'large');
|
||||
}
|
||||
|
||||
async setFullscreenViewerEnabled(isEnabled: boolean) {
|
||||
return this._writeSetting("fullscreenViewer", isEnabled);
|
||||
}
|
||||
|
||||
async setFullscreenViewerPreviewSize(size: FullscreenViewerSize | string) {
|
||||
return this._writeSetting('fullscreenViewerSize', size as FullscreenViewerSize);
|
||||
}
|
||||
}
|
||||
28
src/lib/extension/settings/SearchSettings.ts
Normal file
28
src/lib/extension/settings/SearchSettings.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings.ts";
|
||||
|
||||
interface SearchSettingsFields {
|
||||
suggestProperties: boolean;
|
||||
suggestPropertiesPosition: "start" | "end";
|
||||
}
|
||||
|
||||
export default class SearchSettings extends CacheableSettings<SearchSettingsFields> {
|
||||
constructor() {
|
||||
super("search");
|
||||
}
|
||||
|
||||
async resolvePropertiesSuggestionsEnabled() {
|
||||
return this._resolveSetting("suggestProperties", false);
|
||||
}
|
||||
|
||||
async resolvePropertiesSuggestionsPosition() {
|
||||
return this._resolveSetting("suggestPropertiesPosition", "start");
|
||||
}
|
||||
|
||||
async setPropertiesSuggestions(isEnabled: boolean) {
|
||||
return this._writeSetting("suggestProperties", isEnabled);
|
||||
}
|
||||
|
||||
async setPropertiesSuggestionsPosition(position: "start" | "end") {
|
||||
return this._writeSetting("suggestPropertiesPosition", position);
|
||||
}
|
||||
}
|
||||
33
src/lib/extension/transporting/exporters.ts
Normal file
33
src/lib/extension/transporting/exporters.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
|
||||
|
||||
type ExportersMap = {
|
||||
[EntityName in keyof App.EntityNamesMap]: (entity: App.EntityNamesMap[EntityName]) => Record<string, any>
|
||||
};
|
||||
|
||||
const entitiesExporters: ExportersMap = {
|
||||
profiles: entity => {
|
||||
return {
|
||||
v: 1,
|
||||
id: entity.id,
|
||||
name: entity.settings.name,
|
||||
tags: entity.settings.tags,
|
||||
}
|
||||
},
|
||||
groups: entity => {
|
||||
return {
|
||||
v: 1,
|
||||
id: entity.id,
|
||||
name: entity.settings.name,
|
||||
tags: entity.settings.tags,
|
||||
prefixes: entity.settings.prefixes,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function exportEntityToObject(entityInstance: StorageEntity<any>, entityName: string): Record<string, any> {
|
||||
if (!(entityName in entitiesExporters) || !entitiesExporters.hasOwnProperty(entityName)) {
|
||||
throw new Error(`Missing exporter for entity: ${entityName}`);
|
||||
}
|
||||
|
||||
return entitiesExporters[entityName as keyof App.EntityNamesMap].call(null, entityInstance);
|
||||
}
|
||||
39
src/lib/extension/transporting/validators.js
Normal file
39
src/lib/extension/transporting/validators.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Map of validators for each entity. Function should throw the error if validation failed.
|
||||
* @type {Map<keyof App.EntityNamesMap|string, ((importedObject: Object) => void)>}
|
||||
*/
|
||||
const entitiesValidators = new Map([
|
||||
['profiles', importedObject => {
|
||||
if (importedObject.v !== 1) {
|
||||
throw new Error('Unsupported version!');
|
||||
}
|
||||
|
||||
if (
|
||||
!importedObject.id
|
||||
|| typeof importedObject.id !== "string"
|
||||
|| !importedObject.name
|
||||
|| typeof importedObject.name !== "string"
|
||||
|| !importedObject.tags
|
||||
|| !Array.isArray(importedObject.tags)
|
||||
) {
|
||||
throw new Error('Invalid profile format detected!');
|
||||
}
|
||||
}]
|
||||
])
|
||||
|
||||
/**
|
||||
* Validate the structure of the entity.
|
||||
* @param {Object} importedObject Object imported from JSON.
|
||||
* @param {string} entityName Name of the entity to validate. Should be loaded from the entity class.
|
||||
* @throws {Error} Error in case validation failed with the reason stored in the message.
|
||||
*/
|
||||
export function validateImportedEntity(importedObject, entityName) {
|
||||
if (!entitiesValidators.has(entityName)) {
|
||||
console.error(`Trying to validate entity without the validator present! Entity name: ${entityName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
entitiesValidators
|
||||
.get(entityName)
|
||||
.call(null, importedObject);
|
||||
}
|
||||
43
src/lib/utils.js
Normal file
43
src/lib/utils.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Traverse and find the object using the key path.
|
||||
* @param {Object} targetObject Target object to traverse into.
|
||||
* @param {string[]} path Path of keys to traverse deep into the object.
|
||||
* @return {Object|null} Resulting object or null if nothing found (or target entry is not an object.
|
||||
*/
|
||||
export function findDeepObject(targetObject, path) {
|
||||
let result = targetObject;
|
||||
|
||||
for (let key of path) {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
result = result[key];
|
||||
}
|
||||
|
||||
if (!result || typeof result !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches all the characters needing replacement.
|
||||
*
|
||||
* Gathered from right here: https://stackoverflow.com/a/3561711/16048617. Because I don't want to introduce some
|
||||
* library for that.
|
||||
*
|
||||
* @type {RegExp}
|
||||
*/
|
||||
const unsafeRegExpCharacters = /[/\-\\^$*+?.()|[\]{}]/g;
|
||||
|
||||
/**
|
||||
* Escape all the RegExp syntax-related characters in the following value.
|
||||
* @param {string} value Original value.
|
||||
* @return {string} Resulting value with all needed characters escaped.
|
||||
*/
|
||||
export function escapeRegExp(value) {
|
||||
unsafeRegExpCharacters.lastIndex = 0;
|
||||
return value.replace(unsafeRegExpCharacters, "\\$&");
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
import "../styles/popup.scss";
|
||||
import Header from "$components/layout/Header.svelte";
|
||||
import Footer from "$components/layout/Footer.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);
|
||||
</script>
|
||||
|
||||
<Header/>
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store.js";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
|
||||
/** @type {import('$entities/MaintenanceProfile.ts').default|undefined} */
|
||||
let activeProfile;
|
||||
|
||||
$: activeProfile = $maintenanceProfilesStore.find(profile => profile.id === $activeProfileStore);
|
||||
|
||||
function turnOffActiveProfile() {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink href="/settings/maintenance">Tagging Profiles</MenuLink>
|
||||
{#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>
|
||||
<hr>
|
||||
<MenuLink href="/about">About</MenuLink>
|
||||
<MenuItem href="/preferences">Preferences</MenuItem>
|
||||
<MenuItem href="/about">About</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink icon="arrow-left" href="/">Back</MenuLink>
|
||||
<MenuItem icon="arrow-left" href="/">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<h1>
|
||||
@@ -16,10 +16,10 @@
|
||||
</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuLink icon="globe" href="https://furbooru.org" target="_blank">
|
||||
<MenuItem icon="globe" href="https://furbooru.org" target="_blank">
|
||||
Visit Furbooru
|
||||
</MenuLink>
|
||||
<MenuLink icon="info-circle" href="https://github.com/koloml/furbooru-tagging-assistant" target="_blank">
|
||||
</MenuItem>
|
||||
<MenuItem icon="info-circle" href="https://github.com/koloml/furbooru-tagging-assistant" target="_blank">
|
||||
GitHub Repo
|
||||
</MenuLink>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
10
src/routes/features/+page.svelte
Normal file
10
src/routes/features/+page.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
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>
|
||||
</Menu>
|
||||
23
src/routes/features/groups/+page.svelte
Normal file
23
src/routes/features/groups/+page.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store.js";
|
||||
|
||||
/** @type {import('$entities/TagGroup.ts').default[]} */
|
||||
let groups = [];
|
||||
|
||||
$: groups = $tagGroupsStore.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}
|
||||
<hr>
|
||||
<MenuItem href="/features/groups/import">Import Group</MenuItem>
|
||||
</Menu>
|
||||
39
src/routes/features/groups/[id]/+page.svelte
Normal file
39
src/routes/features/groups/[id]/+page.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<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.js";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
/** @type {import('$entities/TagGroup.ts').default|null} */
|
||||
let group = null;
|
||||
|
||||
if (groupId==='new') {
|
||||
goto('/features/groups/new/edit');
|
||||
}
|
||||
|
||||
$: {
|
||||
group = $tagGroupsStore.find(group => group.id===groupId) || null;
|
||||
|
||||
if (!group) {
|
||||
console.warn(`Group ${ groupId } not found.`);
|
||||
goto('/features/groups');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/groups" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if 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>
|
||||
</Menu>
|
||||
41
src/routes/features/groups/[id]/delete/+page.svelte
Normal file
41
src/routes/features/groups/[id]/delete/+page.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<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.js";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
const targetGroup = $tagGroupsStore.find(group => group.id===groupId);
|
||||
|
||||
if (!targetGroup) {
|
||||
void goto('/features/groups');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/features/groups/{groupId}">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>
|
||||
{:else}
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
82
src/routes/features/groups/[id]/edit/+page.svelte
Normal file
82
src/routes/features/groups/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<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/web-components/TagsEditor.svelte";
|
||||
import TagGroup from "$entities/TagGroup.ts";
|
||||
import {tagGroupsStore} from "$stores/tag-groups-store.js";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
/** @type {TagGroup|null} */
|
||||
let targetGroup = null;
|
||||
|
||||
let groupName = '';
|
||||
/** @type {string[]} */
|
||||
let tagsList = [];
|
||||
/** @type {string[]} */
|
||||
let prefixesList = [];
|
||||
let tagCategory = '';
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
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.category = tagCategory;
|
||||
|
||||
await targetGroup.save();
|
||||
await goto(`/features/groups/${targetGroup.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/groups/${groupId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Group Name">
|
||||
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
|
||||
</FormControl>
|
||||
<FormControl label="Group Color">
|
||||
<TagCategorySelectField bind:value={tagCategory}/>
|
||||
</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>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={saveGroup}>Save Group</MenuItem>
|
||||
</Menu>
|
||||
50
src/routes/features/groups/[id]/export/+page.svelte
Normal file
50
src/routes/features/groups/[id]/export/+page.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<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.ts";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter.ts";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store.js";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
const groupTransporter = new EntitiesTransporter(TagGroup);
|
||||
const group = $tagGroupsStore.find(group => group.id===groupId);
|
||||
|
||||
/** @type {string} */
|
||||
let rawExportedGroup;
|
||||
/** @type {string} */
|
||||
let encodedExportedGroup;
|
||||
|
||||
if (!group) {
|
||||
goto('/features/groups');
|
||||
} else {
|
||||
rawExportedGroup = groupTransporter.exportToJSON(group);
|
||||
encodedExportedGroup = groupTransporter.exportToCompressedJSON(group);
|
||||
}
|
||||
|
||||
let isEncodedGroupShown = true;
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<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>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={() => isEncodedGroupShown = !isEncodedGroupShown}>
|
||||
Switch Format:
|
||||
{#if isEncodedGroupShown}
|
||||
Base64-Encoded
|
||||
{:else}
|
||||
Raw JSON
|
||||
{/if}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
134
src/routes/features/groups/import/+page.svelte
Normal file
134
src/routes/features/groups/import/+page.svelte
Normal file
@@ -0,0 +1,134 @@
|
||||
<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.ts";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter.ts";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store.js";
|
||||
|
||||
const groupTransporter = new EntitiesTransporter(TagGroup);
|
||||
|
||||
/** @type {string} */
|
||||
let importedString = '';
|
||||
/** @type {string} */
|
||||
let errorMessage = '';
|
||||
|
||||
/** @type {TagGroup|null} */
|
||||
let candidateGroup = null;
|
||||
/** @type {TagGroup|null} */
|
||||
let existingGroup = null;
|
||||
|
||||
function tryImportingGroup() {
|
||||
candidateGroup = null;
|
||||
existingGroup = null;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</Menu>
|
||||
{#if errorMessage}
|
||||
<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>
|
||||
{: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}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
.error, .warning {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: colors.$warning-background;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
</style>
|
||||
46
src/routes/features/maintenance/+page.svelte
Normal file
46
src/routes/features/maintenance/+page.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<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.js";
|
||||
|
||||
/** @type {import('$entities/MaintenanceProfile.ts').default[]} */
|
||||
let profiles = [];
|
||||
|
||||
$: profiles = $maintenanceProfilesStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));
|
||||
|
||||
function resetActiveProfile() {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
function enableSelectedProfile(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}
|
||||
<hr>
|
||||
<MenuItem href="#" on:click={resetActiveProfile}>Reset Active Profile</MenuItem>
|
||||
<MenuItem href="/features/maintenance/import">Import Profile</MenuItem>
|
||||
</Menu>
|
||||
64
src/routes/features/maintenance/[id]/+page.svelte
Normal file
64
src/routes/features/maintenance/[id]/+page.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<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.js";
|
||||
import ProfileView from "$components/maintenance/ProfileView.svelte";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
/** @type {import('$entities/MaintenanceProfile.ts').default|null} */
|
||||
let profile = null;
|
||||
|
||||
if (profileId==='new') {
|
||||
goto('/features/maintenance/new/edit');
|
||||
}
|
||||
|
||||
$: {
|
||||
const resolvedProfile = $maintenanceProfilesStore.find(profile => profile.id===profileId);
|
||||
|
||||
if (resolvedProfile) {
|
||||
profile = resolvedProfile;
|
||||
} else {
|
||||
console.warn(`Profile ${profileId} not found.`);
|
||||
goto('/features/maintenance');
|
||||
}
|
||||
}
|
||||
|
||||
let isActiveProfile = $activeProfileStore===profileId;
|
||||
|
||||
$: {
|
||||
if (isActiveProfile && $activeProfileStore!==profileId) {
|
||||
$activeProfileStore = profileId;
|
||||
}
|
||||
|
||||
if (!isActiveProfile && $activeProfileStore===profileId) {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if 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>
|
||||
</Menu>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
41
src/routes/features/maintenance/[id]/delete/+page.svelte
Normal file
41
src/routes/features/maintenance/[id]/delete/+page.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<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.js";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
const targetProfile = $maintenanceProfilesStore.find(profile => profile.id===profileId);
|
||||
|
||||
if (!targetProfile) {
|
||||
void goto('/features/maintenance');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/features/maintenance/{profileId}">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>
|
||||
{:else}
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagsEditor from "$components/web-components/TagsEditor.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import TextField from "$components/ui/forms/TextField.svelte";
|
||||
@@ -8,8 +8,7 @@
|
||||
import {page} from "$app/stores";
|
||||
import {goto} from "$app/navigation";
|
||||
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
|
||||
import {onDestroy} from "svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
|
||||
/** @type {string} */
|
||||
let profileId = $page.params.id;
|
||||
@@ -29,9 +28,9 @@
|
||||
if (maybeExistingProfile) {
|
||||
targetProfile = maybeExistingProfile;
|
||||
profileName = targetProfile.settings.name;
|
||||
tagsList = [...targetProfile.settings.tags];
|
||||
tagsList = [...targetProfile.settings.tags].sort((a, b) => a.localeCompare(b));
|
||||
} else {
|
||||
goto('/settings/maintenance');
|
||||
goto('/features/maintenance');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,26 +42,17 @@
|
||||
|
||||
targetProfile.settings.name = profileName;
|
||||
targetProfile.settings.tags = [...tagsList];
|
||||
targetProfile.settings.temporary = false;
|
||||
|
||||
await targetProfile.save();
|
||||
await goto('/settings/maintenance/' + targetProfile.id);
|
||||
}
|
||||
|
||||
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('/settings/maintenance');
|
||||
await goto('/features/maintenance/' + targetProfile.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink icon="arrow-left" href="/settings/maintenance{profileId === 'new' ? '' : '/' + profileId}">
|
||||
<MenuItem icon="arrow-left" href="/features/maintenance{profileId === 'new' ? '' : '/' + profileId}">
|
||||
Back
|
||||
</MenuLink>
|
||||
</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
@@ -75,8 +65,5 @@
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuLink href="#" on:click={saveProfile}>Save Profile</MenuLink>
|
||||
{#if profileId !== 'new'}
|
||||
<MenuLink href="#" on:click={deleteProfile}>Delete Profile</MenuLink>
|
||||
{/if}
|
||||
<MenuItem href="#" on:click={saveProfile}>Save Profile</MenuItem>
|
||||
</Menu>
|
||||
52
src/routes/features/maintenance/[id]/export/+page.svelte
Normal file
52
src/routes/features/maintenance/[id]/export/+page.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script>
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store.js";
|
||||
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.ts";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
const profile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
|
||||
|
||||
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
|
||||
/** @type {string} */
|
||||
let exportedProfile = '';
|
||||
/** @type {string} */
|
||||
let compressedProfile = '';
|
||||
|
||||
if (!profile) {
|
||||
goto('/features/maintenance/');
|
||||
} else {
|
||||
exportedProfile = profilesTransporter.exportToJSON(profile);
|
||||
compressedProfile = profilesTransporter.exportToCompressedJSON(profile);
|
||||
}
|
||||
|
||||
let isCompressedProfileShown = true;
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<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>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={() => isCompressedProfileShown = !isCompressedProfileShown}>
|
||||
Switch Format:
|
||||
{#if isCompressedProfileShown}
|
||||
Base64-Encoded
|
||||
{:else}
|
||||
Raw JSON
|
||||
{/if}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
134
src/routes/features/maintenance/import/+page.svelte
Normal file
134
src/routes/features/maintenance/import/+page.svelte
Normal file
@@ -0,0 +1,134 @@
|
||||
<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.ts";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import ProfileView from "$components/maintenance/ProfileView.svelte";
|
||||
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
import {goto} from "$app/navigation";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter.ts";
|
||||
|
||||
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
|
||||
|
||||
/** @type {string} */
|
||||
let importedString = '';
|
||||
/** @type {string} */
|
||||
let errorMessage = '';
|
||||
|
||||
/** @type {MaintenanceProfile|null} */
|
||||
let candidateProfile = null;
|
||||
/** @type {MaintenanceProfile|null} */
|
||||
let existingProfile = null;
|
||||
|
||||
function tryImportingProfile() {
|
||||
candidateProfile = null;
|
||||
existingProfile = null;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</Menu>
|
||||
{#if errorMessage}
|
||||
<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>
|
||||
{: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}
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
.error, .warning {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: colors.$warning-background;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
</style>
|
||||
14
src/routes/preferences/+page.svelte
Normal file
14
src/routes/preferences/+page.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script>
|
||||
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>
|
||||
</Menu>
|
||||
10
src/routes/preferences/debug/+page.svelte
Normal file
10
src/routes/preferences/debug/+page.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
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>
|
||||
</Menu>
|
||||
13
src/routes/preferences/debug/storage/+page.svelte
Normal file
13
src/routes/preferences/debug/storage/+page.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script>
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import {storagesCollection} from "$stores/debug.js";
|
||||
</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}
|
||||
</Menu>
|
||||
29
src/routes/preferences/debug/storage/[...path]/+page.svelte
Normal file
29
src/routes/preferences/debug/storage/[...path]/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script>
|
||||
import StorageViewer from "$components/debugging/StorageViewer.svelte";
|
||||
import {page} from "$app/stores";
|
||||
import {goto} from "$app/navigation";
|
||||
|
||||
let pathString = '';
|
||||
/** @type {string[]} */
|
||||
let pathArray = [];
|
||||
/** @type {string|undefined} */
|
||||
let storageName = void 0;
|
||||
|
||||
$: {
|
||||
pathString = $page.params.path;
|
||||
pathArray = pathString.length ? pathString.split("/") : [];
|
||||
storageName = pathArray.shift()
|
||||
|
||||
if (pathArray.length && pathArray[pathArray.length - 1] === '') {
|
||||
pathArray.pop();
|
||||
}
|
||||
|
||||
if (!storageName) {
|
||||
goto("/preferences/debug/storage");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if storageName}
|
||||
<StorageViewer storage="{storageName}" path="{pathArray}"></StorageViewer>
|
||||
{/if}
|
||||
20
src/routes/preferences/misc/+page.svelte
Normal file
20
src/routes/preferences/misc/+page.svelte
Normal file
@@ -0,0 +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.js";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={$fullScreenViewerEnabled}>
|
||||
Enable fullscreen viewer button
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
35
src/routes/preferences/search/+page.svelte
Normal file
35
src/routes/preferences/search/+page.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<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.js";
|
||||
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",
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<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}
|
||||
</FormContainer>
|
||||
20
src/routes/preferences/tags/+page.svelte
Normal file
20
src/routes/preferences/tags/+page.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<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.ts";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={$stripBlacklistedTagsEnabled}>
|
||||
Automatically remove black-listed tags from the images
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
@@ -1,10 +0,0 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink href="/">Back</MenuLink>
|
||||
<hr>
|
||||
<MenuLink href="/settings/maintenance">Tagging Profiles</MenuLink>
|
||||
</Menu>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
|
||||
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default[]} */
|
||||
let profiles = [];
|
||||
|
||||
$: profiles = $maintenanceProfilesStore.sort((a, b) => b.settings.name.localeCompare(a.settings.name));
|
||||
|
||||
function resetActiveProfile() {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink icon="arrow-left" href="/">Back</MenuLink>
|
||||
<MenuLink icon="plus" href="/settings/maintenance/new/edit">Create New</MenuLink>
|
||||
{#if profiles.length}
|
||||
<hr>
|
||||
{/if}
|
||||
{#each profiles as profile}
|
||||
<MenuLink href="/settings/maintenance/{profile.id}"
|
||||
icon="{$activeProfileStore === profile.id ? 'tag' : null}">
|
||||
{profile.settings.name}
|
||||
</MenuLink>
|
||||
{/each}
|
||||
<hr>
|
||||
<MenuLink href="#" on:click={resetActiveProfile}>Reset Active Profile</MenuLink>
|
||||
</Menu>
|
||||
@@ -1,86 +0,0 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
import {page} from "$app/stores";
|
||||
import {goto} from "$app/navigation";
|
||||
|
||||
import {onDestroy} from "svelte";
|
||||
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default|null} */
|
||||
let profile = null;
|
||||
let isActiveProfile = false;
|
||||
|
||||
if (profileId === 'new') {
|
||||
goto('/maintenance/profiles/new/edit');
|
||||
}
|
||||
|
||||
$: {
|
||||
const resolvedProfile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
|
||||
|
||||
if (resolvedProfile) {
|
||||
profile = resolvedProfile;
|
||||
} else {
|
||||
console.warn(`Profile ${profileId} not found.`);
|
||||
goto('/settings/maintenance');
|
||||
}
|
||||
}
|
||||
|
||||
$: isActiveProfile = $activeProfileStore === profileId;
|
||||
|
||||
function activateProfile() {
|
||||
if (isActiveProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activeProfileStore = profileId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink href="/settings/maintenance" icon="arrow-left">Back</MenuLink>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if profile}
|
||||
<div class="block">
|
||||
<strong>Profile:</strong>
|
||||
<div>{profile.settings.name}</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<strong>Tags:</strong>
|
||||
<div class="tags-list">
|
||||
{#each profile.settings.tags as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuLink icon="wrench" href="/settings/maintenance/{profileId}/edit">Edit Profile</MenuLink>
|
||||
<MenuLink icon="tag" href="#" on:click={activateProfile}>
|
||||
{#if isActiveProfile}
|
||||
<span>Profile is Active</span>
|
||||
{:else}
|
||||
<span>Activate Profile</span>
|
||||
{/if}
|
||||
</MenuLink>
|
||||
</Menu>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
src/stores/debug.js
Normal file
21
src/stores/debug.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import {writable} from "svelte/store";
|
||||
|
||||
/**
|
||||
* 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({});
|
||||
|
||||
chrome.storage.local.get(storages => {
|
||||
storagesCollection.set(storages);
|
||||
});
|
||||
|
||||
chrome.storage.local.onChanged.addListener(changes => {
|
||||
storagesCollection.update(storages => {
|
||||
for (let updatedStorageName of Object.keys(changes)) {
|
||||
storages[updatedStorageName] = changes[updatedStorageName].newValue;
|
||||
}
|
||||
|
||||
return storages;
|
||||
})
|
||||
});
|
||||
18
src/stores/maintenance-preferences.ts
Normal file
18
src/stores/maintenance-preferences.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {writable} from "svelte/store";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
|
||||
|
||||
export const stripBlacklistedTagsEnabled = writable(true);
|
||||
|
||||
const maintenanceSettings = new MaintenanceSettings();
|
||||
|
||||
Promise
|
||||
.all([
|
||||
maintenanceSettings.resolveStripBlacklistedTags().then(v => stripBlacklistedTagsEnabled.set(v ?? true))
|
||||
])
|
||||
.then(() => {
|
||||
maintenanceSettings.subscribe(settings => {
|
||||
stripBlacklistedTagsEnabled.set(typeof settings.stripBlacklistedTags === 'boolean' ? settings.stripBlacklistedTags : true);
|
||||
});
|
||||
|
||||
stripBlacklistedTagsEnabled.subscribe(v => maintenanceSettings.setStripBlacklistedTags(v));
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import {writable} from "svelte/store";
|
||||
import MaintenanceProfile from "$lib/extension/entities/MaintenanceProfile.js";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts";
|
||||
|
||||
/**
|
||||
* Store for working with maintenance profiles in the Svelte popup.
|
||||
@@ -9,14 +9,6 @@ import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js"
|
||||
*/
|
||||
export const maintenanceProfilesStore = writable([]);
|
||||
|
||||
MaintenanceProfile.readAll().then(profiles => {
|
||||
maintenanceProfilesStore.set(profiles);
|
||||
});
|
||||
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
maintenanceProfilesStore.set(profiles);
|
||||
});
|
||||
|
||||
/**
|
||||
* Store for the active maintenance profile ID.
|
||||
*
|
||||
@@ -26,29 +18,40 @@ export const activeProfileStore = writable(null);
|
||||
|
||||
const maintenanceSettings = new MaintenanceSettings();
|
||||
|
||||
maintenanceSettings.resolveActiveProfileId().then(activeProfileId => {
|
||||
activeProfileStore.set(activeProfileId);
|
||||
});
|
||||
|
||||
MaintenanceSettings.subscribe(settings => {
|
||||
activeProfileStore.set(settings.activeProfileId || null);
|
||||
});
|
||||
|
||||
/**
|
||||
* Active profile ID stored locally. Used to reset active profile once the existing profile was removed.
|
||||
* @type {string|null}
|
||||
*/
|
||||
let lastActiveProfileId = null;
|
||||
|
||||
activeProfileStore.subscribe(profileId => {
|
||||
if (profileId === lastActiveProfileId) {
|
||||
return;
|
||||
}
|
||||
Promise.allSettled([
|
||||
// Read the initial values from the storages first
|
||||
MaintenanceProfile.readAll().then(profiles => {
|
||||
maintenanceProfilesStore.set(profiles);
|
||||
}),
|
||||
maintenanceSettings.resolveActiveProfileId().then(activeProfileId => {
|
||||
activeProfileStore.set(activeProfileId);
|
||||
})
|
||||
]).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);
|
||||
});
|
||||
|
||||
lastActiveProfileId = profileId;
|
||||
maintenanceSettings.subscribe(settings => {
|
||||
activeProfileStore.set(settings.activeProfile || null);
|
||||
});
|
||||
|
||||
void maintenanceSettings.setActiveProfileId(profileId);
|
||||
});
|
||||
|
||||
// Watch the existence of the active profile on every change.
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
if (!profiles.find(profile => profile.id === lastActiveProfileId)) {
|
||||
activeProfileStore.set(null);
|
||||
}
|
||||
activeProfileStore.subscribe(profileId => {
|
||||
lastActiveProfileId = profileId;
|
||||
|
||||
void maintenanceSettings.setActiveProfileId(profileId);
|
||||
});
|
||||
|
||||
// Watch the existence of the active profile on every change.
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
if (!profiles.find(profile => profile.id === lastActiveProfileId)) {
|
||||
activeProfileStore.set(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
18
src/stores/misc-preferences.js
Normal file
18
src/stores/misc-preferences.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import {writable} from "svelte/store";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings.ts";
|
||||
|
||||
export const fullScreenViewerEnabled = writable(true);
|
||||
|
||||
const miscSettings = new MiscSettings();
|
||||
|
||||
Promise.allSettled([
|
||||
miscSettings.resolveFullscreenViewerEnabled().then(v => fullScreenViewerEnabled.set(v))
|
||||
]).then(() => {
|
||||
fullScreenViewerEnabled.subscribe(value => {
|
||||
void miscSettings.setFullscreenViewerEnabled(value);
|
||||
});
|
||||
|
||||
miscSettings.subscribe(settings => {
|
||||
fullScreenViewerEnabled.set(Boolean(settings.fullscreenViewer));
|
||||
});
|
||||
});
|
||||
29
src/stores/search-preferences.js
Normal file
29
src/stores/search-preferences.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import {writable} from "svelte/store";
|
||||
import SearchSettings from "$lib/extension/settings/SearchSettings.ts";
|
||||
|
||||
export const searchPropertiesSuggestionsEnabled = writable(false);
|
||||
|
||||
/** @type {import('svelte/store').Writable<"start"|"end">} */
|
||||
export const searchPropertiesSuggestionsPosition = writable('start');
|
||||
|
||||
const searchSettings = new SearchSettings();
|
||||
|
||||
Promise.allSettled([
|
||||
// First we wait for all properties to load and save
|
||||
searchSettings.resolvePropertiesSuggestionsEnabled().then(v => searchPropertiesSuggestionsEnabled.set(v)),
|
||||
searchSettings.resolvePropertiesSuggestionsPosition().then(v => searchPropertiesSuggestionsPosition.set(v))
|
||||
]).then(() => {
|
||||
// And then we can start reading value changes from the writable objects
|
||||
searchPropertiesSuggestionsEnabled.subscribe(value => {
|
||||
void searchSettings.setPropertiesSuggestions(value);
|
||||
});
|
||||
|
||||
searchPropertiesSuggestionsPosition.subscribe(value => {
|
||||
void searchSettings.setPropertiesSuggestionsPosition(value);
|
||||
});
|
||||
|
||||
searchSettings.subscribe(settings => {
|
||||
searchPropertiesSuggestionsEnabled.set(Boolean(settings.suggestProperties));
|
||||
searchPropertiesSuggestionsPosition.set(settings.suggestPropertiesPosition || 'start');
|
||||
});
|
||||
})
|
||||
12
src/stores/tag-groups-store.js
Normal file
12
src/stores/tag-groups-store.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import {writable} from "svelte/store";
|
||||
import TagGroup from "$entities/TagGroup.ts";
|
||||
|
||||
/** @type {import('svelte/store').Writable<TagGroup[]>} */
|
||||
export const tagGroupsStore = writable([]);
|
||||
|
||||
TagGroup
|
||||
.readAll()
|
||||
.then(groups => tagGroupsStore.set(groups))
|
||||
.then(() => {
|
||||
TagGroup.subscribe(groups => tagGroupsStore.set(groups));
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
@use 'sass:color';
|
||||
|
||||
$background: #15121a;
|
||||
|
||||
$text: #dadada;
|
||||
@@ -25,5 +27,31 @@ $tag-background: #1b3c21;
|
||||
$tag-count-background: #2d6236;
|
||||
$tag-text: #4aa158;
|
||||
|
||||
$tag-rating-text: #418dd9;
|
||||
$tag-rating-background: color.adjust($tag-rating-text, $lightness: -35%);
|
||||
$tag-spoiler-text: #d49b39;
|
||||
$tag-spoiler-background: color.adjust($tag-spoiler-text, $lightness: -34%);
|
||||
$tag-origin-text: #6f66d6;
|
||||
$tag-origin-background: color.adjust($tag-origin-text, $lightness: -40%);
|
||||
$tag-oc-text: #b157b7;
|
||||
$tag-oc-background: color.adjust($tag-oc-text, $lightness: -33%);
|
||||
$tag-error-text: #d45460;
|
||||
$tag-error-background: color.adjust($tag-error-text, $lightness: -38%, $saturation: -6%, $space: hsl);
|
||||
$tag-character-text: #4aaabf;
|
||||
$tag-character-background: color.adjust($tag-character-text, $lightness: -33%);
|
||||
$tag-content-official-text: #b9b541;
|
||||
$tag-content-official-background: color.adjust($tag-content-official-text, $lightness: -29%, $saturation: -2%, $space: hsl);
|
||||
$tag-content-fanmade-text: #cc8eb5;
|
||||
$tag-content-fanmade-background: color.adjust($tag-content-fanmade-text, $lightness: -40%);
|
||||
$tag-species-text: #b16b50;
|
||||
$tag-species-background: color.adjust($tag-species-text, $lightness: -35%);
|
||||
$tag-body-type-text: #b8b8b8;
|
||||
$tag-body-type-background: color.adjust($tag-body-type-text, $lightness: -35%, $saturation: -10%, $space: hsl);
|
||||
|
||||
$input-background: #26232d;
|
||||
$input-border: #5c5a61;
|
||||
|
||||
$error-background: #7a2725;
|
||||
|
||||
$warning-background: #7d4825;
|
||||
$warning-border: #95562c;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
.header__search--completable {
|
||||
.search-autocomplete-dummy {
|
||||
position: absolute;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
align-self: flex-end;
|
||||
.autocomplete {
|
||||
&__item {
|
||||
&--property {
|
||||
i {
|
||||
margin-right: .5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
color: colors.$tag-background;
|
||||
}
|
||||
|
||||
&[data-tag-category=error]:hover {
|
||||
background: colors.$tag-error-text;
|
||||
color: colors.$tag-error-background;
|
||||
}
|
||||
|
||||
&.is-missing:not(.is-added),
|
||||
&.is-present.is-removed {
|
||||
opacity: 0.5;
|
||||
@@ -151,7 +156,7 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-box-show-fullscreen {
|
||||
.media-box-show-fullscreen.is-visible {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -160,9 +165,9 @@
|
||||
.fullscreen-viewer {
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
opacity: var(--opacity, 0);
|
||||
background-color: black;
|
||||
transition: opacity 0.1s;
|
||||
transition: opacity 0.1s, transform 0.1s;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@@ -171,15 +176,70 @@
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
transform: translateY(var(--offset, 0));
|
||||
|
||||
img, video {
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: fixed;
|
||||
opacity: 0;
|
||||
left: 50vw;
|
||||
top: 50vh;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 64px;
|
||||
text-shadow: 0 0 15px black;
|
||||
}
|
||||
|
||||
img, video, .spinner {
|
||||
transition: opacity .25s ease;
|
||||
}
|
||||
|
||||
.size-selector {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
z-index: 1;
|
||||
padding: 5px;
|
||||
background-color: colors.$text;
|
||||
color: colors.$background;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
text-align: center;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
opacity: var(--opacity, 1);
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
&.swiped {
|
||||
opacity: var(--opacity, 1);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
img, video {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
40
src/styles/injectable/base-styles.scss
Normal file
40
src/styles/injectable/base-styles.scss
Normal file
@@ -0,0 +1,40 @@
|
||||
@use '../colors';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 320px;
|
||||
|
||||
// Hacky class which is added by the JavaScript indicating that page was (probably) opened in the tab
|
||||
&.is-in-tab {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: colors.$background;
|
||||
color: colors.$text;
|
||||
font-size: 16px;
|
||||
font-family: verdana, arial, helvetica, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: colors.$link;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: colors.$link-hover;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
@mixin insert-icon($icon_src) {
|
||||
mask-image: url($icon_src);
|
||||
-webkit-mask-image: url($icon_src);
|
||||
@mixin insert-icon($url) {
|
||||
mask-image: $url;
|
||||
-webkit-mask-image: $url;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -11,29 +11,37 @@
|
||||
}
|
||||
|
||||
.icon.icon-tag {
|
||||
@include insert-icon('/img/tag.svg');
|
||||
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/tag.svg'));
|
||||
}
|
||||
|
||||
.icon.icon-paint-brush {
|
||||
@include insert-icon('/img/paint-brush.svg');
|
||||
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/paintbrush.svg'));
|
||||
}
|
||||
|
||||
.icon.icon-arrow-left {
|
||||
@include insert-icon('/img/arrow-left.svg');
|
||||
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/arrow-left.svg'));
|
||||
}
|
||||
|
||||
.icon.icon-info-circle {
|
||||
@include insert-icon('/img/info-circle.svg');
|
||||
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/circle-info.svg'));
|
||||
}
|
||||
|
||||
.icon.icon-wrench {
|
||||
@include insert-icon('/img/wrench.svg');
|
||||
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/wrench.svg'));
|
||||
}
|
||||
|
||||
.icon.icon-globe {
|
||||
@include insert-icon('/img/globe.svg');
|
||||
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/globe.svg'));
|
||||
}
|
||||
|
||||
.icon.icon-plus {
|
||||
@include insert-icon('/img/plus.svg');
|
||||
}
|
||||
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/plus.svg'));
|
||||
}
|
||||
|
||||
.icon.icon-file-export {
|
||||
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/file-export.svg'));
|
||||
}
|
||||
|
||||
.icon.icon-trash {
|
||||
@include insert-icon(url('@fortawesome/fontawesome-free/svgs/solid/trash.svg'));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@use '../colors';
|
||||
|
||||
input {
|
||||
input, textarea, select {
|
||||
background: colors.$input-background;
|
||||
border: 1px solid colors.$input-border;
|
||||
color: colors.$text;
|
||||
@@ -8,4 +8,8 @@ input {
|
||||
font-family: monospace;
|
||||
padding: 0 6px;
|
||||
line-height: 26px;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
@@ -1,35 +1,5 @@
|
||||
@use './colors';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: colors.$background;
|
||||
color: colors.$text;
|
||||
font-size: 16px;
|
||||
min-width: 320px;
|
||||
max-height: min(100vh, 320px);
|
||||
font-family: verdana, arial, helvetica, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: colors.$link;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: colors.$link-hover;
|
||||
}
|
||||
}
|
||||
|
||||
@import "injectable/input";
|
||||
@import "injectable/tag";
|
||||
@import "injectable/icons";
|
||||
@use 'colors';
|
||||
@use 'injectable/base-styles';
|
||||
@use 'injectable/input';
|
||||
@use 'injectable/tag';
|
||||
@use 'injectable/icons';
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 -256 1472 1558" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1,0,0,-1,-64,1099)">
|
||||
<path d="m1536 640v-128q0-53-32.5-90.5t-84.5-37.5h-704l293-294q38-36 38-90t-38-90l-75-76q-37-37-90-37-52 0-91 37l-651 652q-37 37-37 90 0 52 37 91l651 650q38 38 91 38 52 0 90-38l75-74q38-38 38-91t-38-91l-293-293h704q52 0 84.5-37.5t32.5-90.5z"
|
||||
fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 453 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 -256 1536 1536" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1,0,0,-1,0,1152)">
|
||||
<path d="m1193 993q11 7 25 22v-1q0-2-9.5-10t-11.5-12q-1 1-4 1zm-6-1q-1 1-2.5 3t-1.5 3q3-2 10-5-6-4-6-1zm-459 183q-16 2-26 5 1 0 6.5-1t10.5-2 9-2zm45 37q7 4 13.5 2.5t7.5-7.5q-5 3-21 5zm-8-6-3 2q-2 3-5.5 5t-4.5 2q2-1 21-3-6-4-8-6zm-102 84v2q1-2 3-5.5t3-5.5zm-105-40q0-2-1-2l-1 2zm375-1044v-1zm-165 1202q209 0 385.5-103t279.5-279.5 103-385.5-103-385.5-279.5-279.5-385.5-103-385.5 103-279.5 279.5-103 385.5 103 385.5 279.5 279.5 385.5 103zm472-1246 5 5q-7 10-29 12 1 12-14 26.5t-27 15.5q0 4-10.5 11t-17.5 8q-9 2-27-9-7-3-4-5-3 3-12 11t-16 11q-2 1-7.5 1t-8.5 2q-1 1-6 4.5t-7 4.5-6.5 3-7.5 1.5-7.5-2.5-8.5-6-4.5-15.5-2.5-14.5q-8 6-0.5 20t1.5 20q-7 7-21 0.5t-21-15.5q-1-1-9.5-5.5t-11.5-7.5q-4-6-9-17.5t-6-13.5q0 2-2.5 6.5t-2.5 6.5q-12-2-16 3 5-16 8-17l-4 2q-1-6 3-15t4-11q1-5-1.5-13t-2.5-11q0-2 5-11 4-19-2-32 0-1-3.5-7t-6.5-11l-2-5-2 1q-1 1-2 0-1-6-9-13t-10-11q-15-23-9-38 3-8 10-10 3-1 3 2 1-9-11-27 1-1 4-3-17 0-10-14 202 36 352 181h-3zm-560 185q16 3 30.5-16t22.5-23q41-20 59-11 0-9 14-28 3-4 6.5-11.5t5.5-10.5q5-7 19-16t19-16q6 3 9 9 13-35 24-34 5 0 8 8 0-1-0.5-3t-1.5-3q7 15 5 26l6 4q5 4 5 5-6 6-9-3-30-14-48 22-2 3-4.5 8t-5 12-1.5 11.5 6 4.5q11 0 12.5 1.5t-2.5 6-4 7.5q-1 4-1.5 12.5t-1.5 12.5l-5 6q-5 6-11.5 13.5t-7.5 9.5q-4-10-16.5-8.5t-18.5 9.5q1-2-0.5-6.5t-1.5-6.5q-14 0-17 1 1 6 3 21t4 22q1 5 5.5 13.5t8 15.5 4.5 14-4.5 10.5-18.5 2.5q-20-1-29-22-1-3-3-11.5t-5-12.5-9-7q-8-3-27-2t-26 5q-14 8-24 30.5t-11 41.5q0 10 3 27.5t3 27-6 26.5q3 2 10 10.5t11 11.5q2 2 5 2h5t4 2 3 6q-1 1-4 3-3 3-4 3 4-3 19-1t19 2q0 1 22 0 17-13 24 2 0 1-2.5 10.5t-0.5 14.5q5-29 32-10 3-4 16.5-6t18.5-5q3-2 7-5.5t6-5 6-0.5 9 7q11-17 13-25 11-43 20-48 8-2 12.5-2t5 10.5 0 15.5-1.5 13l-2 37q-16 3-20 12.5t1.5 20 16.5 19.5q1 1 16.5 8t21.5 12q24 19 17 39 9-2 11 9l-5 3q-4 3-8 5.5t-5 1.5q11 7 2 18 5 3 8 11.5t9 11.5q9-14 22-3 8 9 2 18 5 8 22 11.5t20 9.5q5-1 7 0t2 4.5v7.5t1 8.5 3 7.5q4 6 16 10.5t14 5.5l19 12q4 4 0 4 18-2 32 11 13 12-5 23 2 7-4 10.5t-16 5.5q3 1 12 0.5t12 1.5q15 11-7 17-20 5-47-13-3-2-13-12t-17-11q15 18 5 22 8-1 22.5 9t15.5 11q4 2 10.5 2.5t8.5 1.5q71 25 92-1 8 11 11 15t9.5 9 15.5 8q21 7 23 9l1 23q-12-1-18 8t-7 22l-6-8q0 6-3.5 7.5t-7.5 0.5-9.5-2-7.5 0q-9 2-19.5 15.5t-14.5 16.5q9 0 9 5-2 5-10 8 1 6-2 8t-9 0q-2 12-1 13-6 1-11 11t-8 10q-2 0-4.5-2t-5-5.5l-5-7t-3.5-5.5l-2-2q-12 6-24-10-9 1-17-2 15 6 2 13-11 5-21 2 12 5 10 14t-12 16q1 0 4-1t4-1q-1 5-9.5 9.5t-19.5 9-14 6.5q-7 5-36 10.5t-36 1.5q-5-3-6-6t1.5-8.5 3.5-8.5q6-23 5-27-1-3-8.5-8t-5.5-12q1-4 11.5-10t12.5-12q5-13-4-25-4-5-15-11t-14-10q-5-5-3.5-11.5t0.5-9.5q1 1 1 2.5t1 2.5q0-13 11-22 8-6-16-18-20-11-20-4 1 8-7.5 16t-10.5 12-3.5 19-9.5 21q-6 4-19 4t-18-5q0 10-49 30-17 8-58 4 7 1 0 17-8 16-21 12-8 25-4 35 2 5 9 14t9 15q1 3 15.5 6t16.5 8q1 4-2.5 6.5t-9.5 4.5q53-6 63 18 5 9 3 14 0-1 2-1t2-1q12 3 7 17 19 8 26 8 5-1 11-6t10-5q17-3 21.5 10t-9.5 23q7-4 7 6-1 13-7 19-3 2-6.5 2.5t-6.5 0-7 0.5q-1 0-8 2-1-1-2-1h-8q-4-2-4-5v-1q-1-3 4-6l5-1 3-2q-1 0-2.5-2.5t-2.5-2.5q0-3 3-5-2-1-14-7.5t-17-10.5q-1-1-4-2.5t-4-2.5q-2-1-4 2t-4 9-4 11.5-4.5 10-5.5 4.5q-12 0-18-17 3 10-13 17.5t-25 7.5q20 15-9 30l-1 1q-30-4-45-7-2-6 3-12-1-7 6-9 0-1 0.5-1t0.5-1q0 1-0.5 1t-0.5 1q3-1 10.5-1.5t9.5-1.5q3-1 4.5-2l7.5-5t5.5-6-2.5-5q-2-1-9-4t-12.5-5.5-6.5-3.5q-3-5 0-16t-2-15q-5 5-10 18.5t-8 17.5q8-9-30-6l-8 1q-4 0-15-2t-16-1q-7 0-29 6 7 17 5 25 5 0 7 2l-6 3q-3-1-25-9 2-3 8-9.5t9-11.5q-22 6-27-2 0-1-9 0-25 1-24-7 1-4 9-12 0-9-1-9-27 22-30 23-172-83-276-248 1-2 2.5-11t3.5-8.5 11 4.5q9-9 3-21 2 2 36-21 56-40 22-53v5.5t1 6.5q-9-1-19 5-3-6 0.5-20t11.5-14q-8 0-10.5-17t-2.5-38.5-1-25.5l2-1q-3-13 6-37.5t24-20.5q-4-18 5-21-1-4 0-8t4.5-8.5 6-7l13.5-13.5q28-11 41-29 4-6 10.5-24.5t15.5-25.5q-2-6 10-21.5t11-25.5q-1 0-2.5-0.5t-2.5-0.5q3-8 16.5-16t16.5-14q2-3 2.5-10.5t3-12 8.5-2.5q3 24-26 68-16 27-18 31-3 5-5.5 16.5t-4.5 15.5q27-9 26-13-5-10 26-52 2-3 10-10t11-12q3-4 9.5-14.5t10.5-15.5q-1 0-3-2l-3-3q4-2 9-5t8-4.5 7.5-5 7.5-7.5q16-18 20-33 1-4 0.5-15.5t1.5-16.5q2-6 6-11t11.5-10 11.5-7 14.5-6.5 11.5-5.5q2-1 18-11t25-14q10-4 16.5-4.5t16 2.5 15.5 4z"
|
||||
fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.1 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 0 496 496" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m248 0c-136.96 0-248 111.08-248 248 0 137 111.04 248 248 248s248-111 248-248c0-136.92-111.04-248-248-248zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 514 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="32" height="32" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M167.02 309.34c-40.12 2.58-76.53 17.86-97.19 72.3-2.35 6.21-8 9.98-14.59 9.98-11.11 0-45.46-27.67-55.25-34.35C0 439.62 37.93 512 128 512c75.86 0 128-43.77 128-120.19 0-3.11-.65-6.08-.97-9.13l-88.01-73.34zM457.89 0c-15.16 0-29.37 6.71-40.21 16.45C213.27 199.05 192 203.34 192 257.09c0 13.7 3.25 26.76 8.73 38.7l63.82 53.18c7.21 1.8 14.64 3.03 22.39 3.03 62.11 0 98.11-45.47 211.16-256.46 7.38-14.35 13.9-29.85 13.9-45.99C512 20.64 486 0 457.89 0z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 554 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user