mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2026-02-06 23:32:58 +00:00
Compare commits
117 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| d2140c6eee | |||
| 741bc71f11 | |||
| 9732fa2005 | |||
| c45d4619a8 | |||
| e42419e3c5 | |||
| 5c48e1cca6 | |||
| c55b9cc851 | |||
| 723e72b65f | |||
| 8da814c8dd | |||
| 4bd7a67a03 | |||
| b302e8fbb7 | |||
| e6e537ea0c | |||
| 15d318ec90 | |||
| a81a7c5d27 | |||
| eda7342144 |
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 |
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.
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true
|
||||
}
|
||||
// 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.0",
|
||||
"version": "0.3.1",
|
||||
"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"
|
||||
@@ -33,9 +39,32 @@
|
||||
],
|
||||
"js": [
|
||||
"src/content/header.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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/*"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/header.scss"
|
||||
"js": [
|
||||
"src/content/tags.js"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/tags-editor.js"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
7122
package-lock.json
generated
7122
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "npm run build:popup && npm run build:extension",
|
||||
@@ -17,10 +17,13 @@
|
||||
"@types/chrome": "^0.0.262",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"sass": "^1.71.0",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-check": "^3.6.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.3"
|
||||
"vite": "^5.4.9"
|
||||
},
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"lz-string": "^1.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
11
src/app.d.ts
vendored
11
src/app.d.ts
vendored
@@ -7,6 +7,17 @@ declare global {
|
||||
// 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
@@ -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';
|
||||
|
||||
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>
|
||||
|
||||
36
src/components/maintenance/ProfileView.svelte
Normal file
36
src/components/maintenance/ProfileView.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').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>
|
||||
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>
|
||||
@@ -10,3 +10,9 @@
|
||||
</script>
|
||||
|
||||
<input type="text" {name} {placeholder} bind:value={value}>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.control) input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
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} {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>
|
||||
@@ -1,21 +1,106 @@
|
||||
<script>
|
||||
import "$lib/web-components/TagEditorComponent.js";
|
||||
|
||||
/**
|
||||
* List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
|
||||
* @type {string[]}
|
||||
*/
|
||||
export let tags = [];
|
||||
|
||||
let tagsAttribute = tags.join(',');
|
||||
/** @type {Set<string>} */
|
||||
let uniqueTags = new Set();
|
||||
|
||||
$: uniqueTags = new Set(tags);
|
||||
|
||||
/** @type {string} */
|
||||
let addedTagName = '';
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<string[]>} event
|
||||
* Create a callback function to pass into both mouse & keyboard events for tag removal.
|
||||
* @param {string} tagName
|
||||
* @return {function(Event)} Callback to pass as event listener.
|
||||
*/
|
||||
function onTagsChanged(event) {
|
||||
tags = event.detail;
|
||||
function createTagRemoveHandler(tagName) {
|
||||
return event => {
|
||||
if (event.type === 'click') {
|
||||
removeTag(tagName);
|
||||
}
|
||||
|
||||
if (event instanceof KeyboardEvent && (event.code === 'Enter' || event.code === 'Space')) {
|
||||
// To be more comfortable, automatically focus next available tag's remove button in the list.
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
const currenTagElement = event.currentTarget.closest('.tag');
|
||||
const nextTagElement = currenTagElement?.previousElementSibling ?? currenTagElement?.parentElement?.firstElementChild;
|
||||
const nextRemoveButton = nextTagElement?.querySelector('.remove');
|
||||
|
||||
if (nextRemoveButton instanceof HTMLElement) {
|
||||
nextRemoveButton.focus();
|
||||
}
|
||||
}
|
||||
|
||||
removeTag(tagName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: tagsAttribute = tags.join(',');
|
||||
/**
|
||||
* @param {string} tagName
|
||||
*/
|
||||
function removeTag(tagName) {
|
||||
uniqueTags.delete(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
*/
|
||||
function addTag(tagName) {
|
||||
uniqueTags.add(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding new tags to the list or removing them when backspace is pressed.
|
||||
*
|
||||
* Additional note: For some reason, mobile Chrome breaks the usual behaviour inside extension. `code` is becoming
|
||||
* empty, while usually it should contain proper button code.
|
||||
*
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
function handleKeyPresses(event) {
|
||||
if ((event.code === 'Enter' || event.key === 'Enter') && addedTagName.length) {
|
||||
addTag(addedTagName)
|
||||
addedTagName = '';
|
||||
}
|
||||
|
||||
if ((event.code === 'Backspace' || event.key === 'Backspace') && !addedTagName.length && tags?.length) {
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<tags-editor tags="{tagsAttribute}" on:change={onTagsChanged}></tags-editor>
|
||||
<div class="tags-editor">
|
||||
{#each uniqueTags.values() as tagName}
|
||||
<div class="tag">
|
||||
{tagName}
|
||||
<span class="remove" on:click={createTagRemoveHandler(tagName)}
|
||||
on:keydown={createTagRemoveHandler(tagName)}
|
||||
role="button" tabindex="0">x</span>
|
||||
</div>
|
||||
{/each}
|
||||
<input type="text"
|
||||
bind:value={addedTagName}
|
||||
on:keydown={handleKeyPresses}
|
||||
autocomplete="off"
|
||||
autocapitalize="none"/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-editor {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {createMaintenancePopup} from "$lib/components/MaintenancePopup.js";
|
||||
import {createMediaBoxTools} from "$lib/components/MediaBoxTools.js";
|
||||
import {initializeMediaBox} from "$lib/components/MediaBoxWrapper.js";
|
||||
import {calculateMediaBoxesPositions, initializeMediaBox} from "$lib/components/MediaBoxWrapper.js";
|
||||
import {createMaintenanceStatusIcon} from "$lib/components/MaintenanceStatusIcon.js";
|
||||
import {createImageShowFullscreenButton} from "$lib/components/ImageShowFullscreenButton.js";
|
||||
|
||||
document.querySelectorAll('.media-box').forEach(mediaBoxElement => {
|
||||
/** @type {NodeListOf<HTMLElement>} */
|
||||
const mediaBoxes = document.querySelectorAll('.media-box');
|
||||
|
||||
mediaBoxes.forEach(mediaBoxElement => {
|
||||
initializeMediaBox(mediaBoxElement, [
|
||||
createMediaBoxTools(
|
||||
createMaintenancePopup(),
|
||||
@@ -18,3 +21,5 @@ document.querySelectorAll('.media-box').forEach(mediaBoxElement => {
|
||||
window.dispatchEvent(new CustomEvent('resize'));
|
||||
})
|
||||
});
|
||||
|
||||
calculateMediaBoxesPositions(mediaBoxes);
|
||||
|
||||
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();
|
||||
211
src/lib/components/FullscreenViewer.js
Normal file
211
src/lib/components/FullscreenViewer.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
/** @type {HTMLVideoElement} */
|
||||
#videoElement;
|
||||
/** @type {HTMLImageElement} */
|
||||
#imageElement;
|
||||
|
||||
/** @type {number|null} */
|
||||
#touchId = null;
|
||||
/** @type {number|null} */
|
||||
#startX = null;
|
||||
/** @type {number|null} */
|
||||
#startY = null;
|
||||
/** @type {boolean|null} */
|
||||
#isClosingSwipeStarted = null;
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
this.container.classList.add('fullscreen-viewer');
|
||||
|
||||
this.#videoElement = document.createElement('video');
|
||||
this.#imageElement = document.createElement('img');
|
||||
}
|
||||
|
||||
/**
|
||||
* @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));
|
||||
}
|
||||
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
|
||||
#close() {
|
||||
this.container.classList.remove(FullscreenViewer.#shownState);
|
||||
document.body.style.overflow = null;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.#videoElement.volume = 0;
|
||||
this.#videoElement.pause();
|
||||
this.#videoElement.remove();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
*/
|
||||
show(url) {
|
||||
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 #offsetProperty = '--offset';
|
||||
static #opacityProperty = '--opacity';
|
||||
static #shownState = 'shown';
|
||||
static #swipeState = 'swiped';
|
||||
static #minRequiredDistance = 50;
|
||||
}
|
||||
@@ -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.js";
|
||||
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,49 +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;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#updateFullscreenButtonVisibility() {
|
||||
this.container.classList.toggle('is-visible', this.#isFullscreenButtonEnabled);
|
||||
}
|
||||
|
||||
#onButtonClicked() {
|
||||
const imageViewer = ImageShowFullscreenButton.#resolveFullscreenViewer();
|
||||
let imageElement = imageViewer.querySelector('img') ?? document.createElement('img');
|
||||
|
||||
imageElement.src = this.#mediaBoxTools.mediaBox.imageLinks.large;
|
||||
imageViewer.appendChild(imageElement);
|
||||
|
||||
imageViewer.classList.add('shown');
|
||||
ImageShowFullscreenButton
|
||||
.#resolveViewer()
|
||||
.show(this.#mediaBoxTools.mediaBox.imageLinks.large);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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') {
|
||||
element.classList.remove('shown');
|
||||
}
|
||||
});
|
||||
|
||||
return element;
|
||||
return viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {MiscSettings|null}
|
||||
*/
|
||||
static #miscSettings = null;
|
||||
}
|
||||
|
||||
export function createImageShowFullscreenButton() {
|
||||
|
||||
@@ -151,6 +151,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 +186,32 @@ 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);
|
||||
}
|
||||
try {
|
||||
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
|
||||
this.#mediaBoxTools.mediaBox.imageId,
|
||||
tagsList => {
|
||||
for (let tagName of this.#tagsToRemove) {
|
||||
tagsList.delete(tagName);
|
||||
}
|
||||
|
||||
return tagsList;
|
||||
}
|
||||
);
|
||||
for (let tagName of this.#tagsToAdd) {
|
||||
tagsList.add(tagName);
|
||||
}
|
||||
|
||||
return tagsList;
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
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,6 +223,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.#tagsToRemove.clear();
|
||||
|
||||
this.#refreshTagsList();
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#isSubmitting = false;
|
||||
}
|
||||
@@ -254,12 +272,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 +286,13 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
.then(callback);
|
||||
.then(profileOrNull => {
|
||||
if (profileOrNull) {
|
||||
lastActiveProfileId = profileOrNull.id;
|
||||
}
|
||||
|
||||
callback(profileOrNull);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFromProfilesChanges();
|
||||
@@ -276,9 +300,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:
|
||||
|
||||
@@ -78,6 +78,29 @@ export function initializeMediaBox(mediaBoxContainer, childComponentElements) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {NodeListOf<HTMLElement>} mediaBoxesList
|
||||
*/
|
||||
export function calculateMediaBoxesPositions(mediaBoxesList) {
|
||||
window.addEventListener('resize', () => {
|
||||
/** @type {HTMLElement|null} */
|
||||
let lastMediaBox = null,
|
||||
/** @type {number|null} */
|
||||
lastMediaBoxPosition = null;
|
||||
|
||||
for (let mediaBoxElement of mediaBoxesList) {
|
||||
const yPosition = mediaBoxElement.getBoundingClientRect().y;
|
||||
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
|
||||
|
||||
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
|
||||
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
|
||||
|
||||
lastMediaBox = mediaBoxElement;
|
||||
lastMediaBoxPosition = yPosition;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ImageURIs
|
||||
* @property {string} full
|
||||
|
||||
@@ -1,44 +1,63 @@
|
||||
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.js";
|
||||
|
||||
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";
|
||||
|
||||
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 +65,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 +83,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) {
|
||||
@@ -151,21 +113,45 @@ export class SearchWrapper extends BaseComponent {
|
||||
return searchValue;
|
||||
}
|
||||
|
||||
#emitAutoComplete(userInputFragment) {
|
||||
this.#autoCompleteField.value = userInputFragment;
|
||||
/**
|
||||
* 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 => SearchWrapper.#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 = document.querySelector('.autocomplete') ?? SearchWrapper.#renderAutocompleteContainer();
|
||||
|
||||
const autocompleteContainer = document.querySelector('.autocomplete');
|
||||
|
||||
if (autocompleteContainer) {
|
||||
autocompleteContainer.style.left = `${this.container.offsetLeft}px`;
|
||||
for (let existingTerm of autocompleteContainer.querySelectorAll('.autocomplete__item--property')) {
|
||||
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 +165,182 @@ 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 new autocomplete container similar to the one generated by website. Might be sensitive to the updates
|
||||
* made to the Philomena.
|
||||
* @return {HTMLElement}
|
||||
*/
|
||||
static #renderAutocompleteContainer() {
|
||||
const autocompleteContainer = document.createElement('div');
|
||||
autocompleteContainer.className = 'autocomplete';
|
||||
|
||||
const innerListContainer = document.createElement('ul');
|
||||
innerListContainer.className = 'autocomplete__list';
|
||||
|
||||
autocompleteContainer.append(innerListContainer);
|
||||
|
||||
return autocompleteContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
static #renderTermSuggestion(suggestedTerm) {
|
||||
/** @type {HTMLElement} */
|
||||
const suggestionItem = document.createElement('li');
|
||||
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
|
||||
suggestionItem.dataset.value = suggestedTerm;
|
||||
suggestionItem.innerText = suggestedTerm;
|
||||
|
||||
suggestionItem.addEventListener('mouseover', () => {
|
||||
this.#findAndResetSelectedSuggestion(suggestionItem);
|
||||
suggestionItem.classList.add('autocomplete__item--selected');
|
||||
});
|
||||
|
||||
suggestionItem.addEventListener('mouseout', () => {
|
||||
this.#findAndResetSelectedSuggestion(suggestionItem);
|
||||
})
|
||||
|
||||
return suggestionItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 #properties = new Map([
|
||||
['aspect_ratio', SearchWrapper.#typeNumeric],
|
||||
['comment_count', SearchWrapper.#typeNumeric],
|
||||
['created_at', SearchWrapper.#typeDate],
|
||||
['description', SearchWrapper.#typeLiteral],
|
||||
['downvotes', SearchWrapper.#typeNumeric],
|
||||
['faved_by', SearchWrapper.#typeLiteral],
|
||||
['faved_by_id', SearchWrapper.#typeNumeric],
|
||||
['faves', SearchWrapper.#typeNumeric],
|
||||
['first_seen_at', SearchWrapper.#typeDate],
|
||||
['height', SearchWrapper.#typeNumeric],
|
||||
['id', SearchWrapper.#typeNumeric],
|
||||
['orig_sha512_hash', SearchWrapper.#typeLiteral],
|
||||
['score', SearchWrapper.#typeNumeric],
|
||||
['sha512_hash', SearchWrapper.#typeLiteral],
|
||||
['source_url', SearchWrapper.#typeLiteral],
|
||||
['tag_count', SearchWrapper.#typeNumeric],
|
||||
['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',
|
||||
]]
|
||||
]);
|
||||
}
|
||||
|
||||
230
src/lib/components/TagDropdownWrapper.js
Normal file
230
src/lib/components/TagDropdownWrapper.js
Normal file
@@ -0,0 +1,230 @@
|
||||
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
|
||||
import {getComponent} from "$lib/components/base/ComponentUtils.js";
|
||||
|
||||
const isTagEditorProcessedKey = Symbol();
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#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]
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
new TagDropdownWrapper(element).initialize();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import StorageHelper from "$lib/chrome/StorageHelper.js";
|
||||
import StorageHelper from "$lib/browser/StorageHelper.js";
|
||||
|
||||
export default class EntitiesController {
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
@@ -90,4 +90,4 @@ export default class EntitiesController {
|
||||
|
||||
return () => this.#storageHelper.unsubscribe(storageChangesSubscriber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
73
src/lib/extension/EntitiesTransporter.ts
Normal file
73
src/lib/extension/EntitiesTransporter.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {validateImportedEntity} from "$lib/extension/transporting/validators.js";
|
||||
import {exportEntityToObject} from "$lib/extension/transporting/exporters.js";
|
||||
import StorageEntity from "./base/StorageEntity.js";
|
||||
import {compressToEncodedURIComponent, decompressFromEncodedURIComponent} from "lz-string";
|
||||
|
||||
type EntityConstructor<T extends StorageEntity> =
|
||||
(new (id: string, settings: Record<string, any>) => T)
|
||||
& typeof StorageEntity;
|
||||
|
||||
export default class EntitiesTransporter<EntityType extends StorageEntity> {
|
||||
readonly #targetEntityConstructor: EntityConstructor<EntityType>;
|
||||
|
||||
constructor(entityConstructor: EntityConstructor<EntityType>) {
|
||||
this.#targetEntityConstructor = entityConstructor;
|
||||
}
|
||||
|
||||
importFromJSON(jsonString: string): EntityType {
|
||||
const importedObject = this.#tryParsingAsJSON(jsonString);
|
||||
|
||||
if (!importedObject) {
|
||||
throw new Error('Invalid JSON!');
|
||||
}
|
||||
|
||||
validateImportedEntity(
|
||||
importedObject,
|
||||
this.#targetEntityConstructor._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!');
|
||||
}
|
||||
|
||||
const exportableObject = exportEntityToObject(
|
||||
entityObject,
|
||||
this.#targetEntityConstructor._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
|
||||
}
|
||||
}
|
||||
79
src/lib/extension/base/CacheableSettings.js
Normal file
79
src/lib/extension/base/CacheableSettings.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController.js";
|
||||
|
||||
export default class CacheableSettings {
|
||||
/** @type {ConfigurationController} */
|
||||
#controller;
|
||||
/** @type {Map<string, any>} */
|
||||
#cachedValues = new Map();
|
||||
/** @type {function[]} */
|
||||
#disposables = [];
|
||||
|
||||
constructor(settingsNamespace) {
|
||||
this.#controller = new ConfigurationController(settingsNamespace);
|
||||
|
||||
this.#disposables.push(
|
||||
this.#controller.subscribeToChanges(settings => {
|
||||
for (const key of Object.keys(settings)) {
|
||||
this.#cachedValues.set(key, settings[key]);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template SettingType
|
||||
* @param {string} settingName
|
||||
* @param {SettingType} defaultValue
|
||||
* @return {Promise<SettingType>}
|
||||
* @protected
|
||||
*/
|
||||
async _resolveSetting(settingName, defaultValue) {
|
||||
if (this.#cachedValues.has(settingName)) {
|
||||
return this.#cachedValues.get(settingName);
|
||||
}
|
||||
|
||||
const settingValue = await this.#controller.readSetting(settingName, defaultValue);
|
||||
|
||||
this.#cachedValues.set(settingName, settingValue);
|
||||
|
||||
return settingValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} settingName Name of the setting to write.
|
||||
* @param {*} value Value to pass.
|
||||
* @param {boolean} [force=false] Ignore the cache and force the update.
|
||||
* @return {Promise<void>}
|
||||
* @protected
|
||||
*/
|
||||
async _writeSetting(settingName, value, force = false) {
|
||||
if (
|
||||
!force
|
||||
&& this.#cachedValues.has(settingName)
|
||||
&& this.#cachedValues.get(settingName) === value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.#controller.writeSetting(settingName, 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) {
|
||||
const unsubscribeCallback = this.#controller.subscribeToChanges(callback);
|
||||
|
||||
this.#disposables.push(unsubscribeCallback);
|
||||
|
||||
return unsubscribeCallback;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (let disposeCallback of this.#disposables) {
|
||||
disposeCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,4 +60,4 @@ class MaintenanceProfile extends StorageEntity {
|
||||
}
|
||||
}
|
||||
|
||||
export default MaintenanceProfile;
|
||||
export default MaintenanceProfile;
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController.js";
|
||||
import MaintenanceProfile from "$lib/extension/entities/MaintenanceProfile.js";
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
|
||||
|
||||
export default class MaintenanceSettings {
|
||||
#isInitialized = false;
|
||||
#activeProfileId = null;
|
||||
|
||||
export default class MaintenanceSettings extends CacheableSettings {
|
||||
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;
|
||||
super("maintenance");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,18 +13,7 @@ export default class MaintenanceSettings {
|
||||
* @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;
|
||||
return this._resolveSetting("activeProfile", null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,31 +37,27 @@ export default class MaintenanceSettings {
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async setActiveProfileId(profileId) {
|
||||
this.#activeProfileId = profileId;
|
||||
|
||||
await MaintenanceSettings.#controller.writeSetting("activeProfile", profileId);
|
||||
await this._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.
|
||||
* @param {function(MaintenanceSettingsObject): 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 => {
|
||||
subscribe(callback) {
|
||||
return super.subscribe(settings => {
|
||||
callback({
|
||||
activeProfileId: settings.activeProfile || null,
|
||||
activeProfile: settings.activeProfile || null,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} MaintenanceSettingsObject
|
||||
* @property {string|null} activeProfile
|
||||
*/
|
||||
|
||||
39
src/lib/extension/settings/MiscSettings.js
Normal file
39
src/lib/extension/settings/MiscSettings.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
|
||||
|
||||
export default class MiscSettings extends CacheableSettings {
|
||||
constructor() {
|
||||
super("misc");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<boolean>}
|
||||
*/
|
||||
async resolveFullscreenViewerEnabled() {
|
||||
return this._resolveSetting("fullscreenViewer", true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} isEnabled
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async setFullscreenViewerEnabled(isEnabled) {
|
||||
return this._writeSetting("fullscreenViewer", isEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {function(MiscSettingsObject): void} callback
|
||||
* @return {function(): void}
|
||||
*/
|
||||
subscribe(callback) {
|
||||
return super.subscribe(settings => {
|
||||
callback({
|
||||
fullscreenViewer: settings.fullscreenViewer ?? true,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} MiscSettingsObject
|
||||
* @property {boolean} fullscreenViewer
|
||||
*/
|
||||
42
src/lib/extension/settings/SearchSettings.js
Normal file
42
src/lib/extension/settings/SearchSettings.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
|
||||
|
||||
export default class SearchSettings extends CacheableSettings {
|
||||
constructor() {
|
||||
super("search");
|
||||
}
|
||||
|
||||
async resolvePropertiesSuggestionsEnabled() {
|
||||
return this._resolveSetting("suggestProperties", false);
|
||||
}
|
||||
|
||||
async resolvePropertiesSuggestionsPosition() {
|
||||
return this._resolveSetting("suggestPropertiesPosition", "start");
|
||||
}
|
||||
|
||||
async setPropertiesSuggestions(isEnabled) {
|
||||
return this._writeSetting("suggestProperties", isEnabled);
|
||||
}
|
||||
|
||||
async setPropertiesSuggestionsPosition(position) {
|
||||
return this._writeSetting("suggestPropertiesPosition", position);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {function(SearchSettingsObject): void} callback
|
||||
* @return {function(): void}
|
||||
*/
|
||||
subscribe(callback) {
|
||||
return super.subscribe(rawSettings => {
|
||||
callback({
|
||||
suggestProperties: rawSettings.suggestProperties ?? false,
|
||||
suggestPropertiesPosition: rawSettings.suggestPropertiesPosition ?? "start",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} SearchSettingsObject
|
||||
* @property {boolean} suggestProperties
|
||||
* @property {"start"|"end"} suggestPropertiesPosition
|
||||
*/
|
||||
26
src/lib/extension/transporting/exporters.js
Normal file
26
src/lib/extension/transporting/exporters.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @type {Map<string, ((entity: import('../base/StorageEntity.js').default) => Record<string, any>)>}
|
||||
*/
|
||||
const entitiesExporters = new Map([
|
||||
['profiles', /** @param {import('../entities/MaintenanceProfile.js').default} entity */entity => {
|
||||
return {
|
||||
v: 1,
|
||||
id: entity.id,
|
||||
name: entity.settings.name,
|
||||
tags: entity.settings.tags,
|
||||
}
|
||||
}]
|
||||
])
|
||||
|
||||
/**
|
||||
* @param entityInstance
|
||||
* @param {string} entityName
|
||||
* @returns {Record<string, *>}
|
||||
*/
|
||||
export function exportEntityToObject(entityInstance, entityName) {
|
||||
if (!entitiesExporters.has(entityName)) {
|
||||
throw new Error(`Missing exporter for entity: ${entityName}`);
|
||||
}
|
||||
|
||||
return entitiesExporters.get(entityName).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<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);
|
||||
}
|
||||
23
src/lib/utils.js
Normal file
23
src/lib/utils.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
export default class TagEditorComponent extends HTMLElement {
|
||||
/**
|
||||
* Array of elements representing tags.
|
||||
* @type {HTMLElement[]}
|
||||
*/
|
||||
#tagElements = [];
|
||||
|
||||
/**
|
||||
* Generated input for adding new tags to the tag list. Will be rendered on connecting.
|
||||
* @type {HTMLInputElement|undefined}
|
||||
*/
|
||||
#tagInput;
|
||||
|
||||
/**
|
||||
* Cached list of tag names. Changing this value will not automatically change the actual tags.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
#tagsSet = new Set();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (!this.#tagInput) {
|
||||
this.#tagInput = document.createElement('input');
|
||||
this.appendChild(this.#tagInput);
|
||||
this.#tagInput.addEventListener('keydown', this.#onKeyDownDetectActions.bind(this));
|
||||
}
|
||||
|
||||
if (!this.#tagElements.length) {
|
||||
this.#renderTags();
|
||||
}
|
||||
|
||||
this.addEventListener('click', this.#onClickDetectTagRemoval.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the list of tag elements based on the tag attribute. Should be called every time tag attribute is changed.
|
||||
*/
|
||||
#renderTags() {
|
||||
const tags = this.getAttribute(TagEditorComponent.#tagsAttribute) || '';
|
||||
|
||||
const updatedTagsSet = new Set(
|
||||
tags.split(',')
|
||||
.map(tagName => tagName.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
this.#tagsSet = new Set(updatedTagsSet.values());
|
||||
|
||||
this.#tagElements = this.#tagElements.filter(tagElement => {
|
||||
const tagName = tagElement.dataset.tag;
|
||||
|
||||
if (!updatedTagsSet.has(tagName)) {
|
||||
tagElement.remove();
|
||||
return false;
|
||||
}
|
||||
|
||||
updatedTagsSet.delete(tagName);
|
||||
return true;
|
||||
});
|
||||
|
||||
for (let tagName of updatedTagsSet) {
|
||||
const tagElement = document.createElement('div');
|
||||
tagElement.classList.add('tag');
|
||||
tagElement.innerText = tagName;
|
||||
tagElement.dataset.tag = tagName;
|
||||
|
||||
const tagRemoveElement = document.createElement("span");
|
||||
tagRemoveElement.classList.add('remove');
|
||||
tagRemoveElement.innerText = 'x';
|
||||
|
||||
tagElement.appendChild(tagRemoveElement);
|
||||
|
||||
this.#tagInput.insertAdjacentElement('beforebegin', tagElement);
|
||||
this.#tagElements.push(tagElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect add/remove keyboard shortcuts on the input.
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onKeyDownDetectActions(event) {
|
||||
const isTagSubmit = event.key === 'Enter';
|
||||
const isTagRemove = event.key === 'Backspace' && !this.#tagInput.value.length;
|
||||
|
||||
if (!isTagSubmit && !isTagRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTagSubmit) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const providedTagName = this.#tagInput.value.trim();
|
||||
|
||||
if (providedTagName && isTagSubmit) {
|
||||
if (!this.#tagsSet.has(providedTagName)) {
|
||||
this.setAttribute(
|
||||
TagEditorComponent.#tagsAttribute,
|
||||
[...this.#tagsSet, providedTagName].join(',')
|
||||
);
|
||||
}
|
||||
|
||||
this.#tagInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTagRemove && this.#tagsSet.size) {
|
||||
this.setAttribute(
|
||||
TagEditorComponent.#tagsAttribute,
|
||||
[...this.#tagsSet].slice(0, -1).join(',')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect clicks on the "remove" button inside tags.
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
#onClickDetectTagRemoval(event) {
|
||||
/** @type {HTMLElement} */
|
||||
const maybeRemoveTagElement = event.target;
|
||||
|
||||
if (!maybeRemoveTagElement.classList.contains('remove')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const tagElement = maybeRemoveTagElement.closest('.tag');
|
||||
|
||||
if (!tagElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = tagElement.dataset.tag;
|
||||
|
||||
if (this.#tagsSet.has(tagName)) {
|
||||
this.#tagsSet.delete(tagName);
|
||||
this.setAttribute(
|
||||
TagEditorComponent.#tagsAttribute,
|
||||
[...this.#tagsSet].join(",")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} from
|
||||
* @param {string} to
|
||||
*/
|
||||
attributeChangedCallback(name, from, to) {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === TagEditorComponent.#tagsAttribute) {
|
||||
this.#renderTags();
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(
|
||||
'change',
|
||||
{
|
||||
detail: [...this.#tagsSet.values()]
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return [this.#tagsAttribute];
|
||||
}
|
||||
|
||||
static #tagsAttribute = 'tags';
|
||||
}
|
||||
|
||||
if (!customElements.get('tags-editor')) {
|
||||
customElements.define('tags-editor', TagEditorComponent);
|
||||
} else {
|
||||
console.warn('Tags Component is attempting to initialize twice!');
|
||||
}
|
||||
@@ -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,28 @@
|
||||
<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('$lib/extension/entities/MaintenanceProfile.js').default|undefined} */
|
||||
let activeProfile;
|
||||
|
||||
$: activeProfile = $maintenanceProfilesStore.find(profile => profile.id === $activeProfileStore);
|
||||
|
||||
function turnOffActiveProfile() {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink href="/settings/maintenance">Manual Tags Maintenance</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>
|
||||
<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>
|
||||
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('$lib/extension/entities/MaintenanceProfile.js').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>
|
||||
@@ -1,11 +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";
|
||||
import {page} from "$app/stores";
|
||||
import {goto} from "$app/navigation";
|
||||
|
||||
import {onDestroy} from "svelte";
|
||||
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
import ProfileView from "$components/maintenance/ProfileView.svelte";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default|null} */
|
||||
@@ -23,7 +22,7 @@
|
||||
profile = resolvedProfile;
|
||||
} else {
|
||||
console.warn(`Profile ${profileId} not found.`);
|
||||
goto('/settings/maintenance');
|
||||
goto('/features/maintenance');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,38 +38,26 @@
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink href="/settings/maintenance" icon="arrow-left">Back</MenuLink>
|
||||
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if profile}
|
||||
<div>
|
||||
<strong>Profile:</strong><br>
|
||||
{profile.settings.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Focused Tags:</strong>
|
||||
<div class="tags-list">
|
||||
{#each profile.settings.tags as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<ProfileView {profile}/>
|
||||
{/if}
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuLink icon="wrench" href="/settings/maintenance/{profileId}/edit">Edit Profile</MenuLink>
|
||||
<MenuLink icon="tag" href="#" on:click={activateProfile}>
|
||||
<MenuItem icon="wrench" href="/features/maintenance/{profileId}/edit">Edit Profile</MenuItem>
|
||||
<MenuItem icon="tag" href="#" on:click={activateProfile}>
|
||||
{#if isActiveProfile}
|
||||
<span>Profile is Active</span>
|
||||
{:else}
|
||||
<span>Activate Profile</span>
|
||||
{/if}
|
||||
</MenuLink>
|
||||
</MenuItem>
|
||||
<MenuItem icon="file-export" href="/features/maintenance/{profileId}/export">
|
||||
Export Profile
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<style>
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
@@ -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";
|
||||
@@ -9,7 +9,6 @@
|
||||
import {goto} from "$app/navigation";
|
||||
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
|
||||
import {onDestroy} from "svelte";
|
||||
|
||||
/** @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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +44,7 @@
|
||||
targetProfile.settings.tags = [...tagsList];
|
||||
|
||||
await targetProfile.save();
|
||||
await goto('/settings/maintenance/' + targetProfile.id);
|
||||
await goto('/features/maintenance/' + targetProfile.id);
|
||||
}
|
||||
|
||||
async function deleteProfile() {
|
||||
@@ -55,14 +54,14 @@
|
||||
}
|
||||
|
||||
await targetProfile.delete();
|
||||
await goto('/settings/maintenance');
|
||||
await goto('/features/maintenance');
|
||||
}
|
||||
</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 +74,8 @@
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuLink href="#" on:click={saveProfile}>Save Profile</MenuLink>
|
||||
<MenuItem href="#" on:click={saveProfile}>Save Profile</MenuItem>
|
||||
{#if profileId !== 'new'}
|
||||
<MenuLink href="#" on:click={deleteProfile}>Delete Profile</MenuLink>
|
||||
<MenuItem href="#" on:click={deleteProfile}>Delete Profile</MenuItem>
|
||||
{/if}
|
||||
</Menu>
|
||||
56
src/routes/features/maintenance/[id]/export/+page.svelte
Normal file
56
src/routes/features/maintenance/[id]/export/+page.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<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.js";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
|
||||
/**
|
||||
* @type {import('$lib/extension/entities/MaintenanceProfile.js').default|undefined}
|
||||
*/
|
||||
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.js";
|
||||
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>
|
||||
13
src/routes/preferences/+page.svelte
Normal file
13
src/routes/preferences/+page.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<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/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>
|
||||
@@ -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">Maintenance</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>
|
||||
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;
|
||||
})
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
14
src/stores/misc-preferences.js
Normal file
14
src/stores/misc-preferences.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import {writable} from "svelte/store";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings.js";
|
||||
|
||||
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);
|
||||
})
|
||||
});
|
||||
24
src/stores/search-preferences.js
Normal file
24
src/stores/search-preferences.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import {writable} from "svelte/store";
|
||||
import SearchSettings from "$lib/extension/settings/SearchSettings.js";
|
||||
|
||||
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);
|
||||
});
|
||||
})
|
||||
@@ -27,3 +27,8 @@ $tag-text: #4aa158;
|
||||
|
||||
$input-background: #26232d;
|
||||
$input-border: #5c5a61;
|
||||
|
||||
$error-background: #7a2725;
|
||||
|
||||
$warning-background: #7d4825;
|
||||
$warning-border: #95562c;
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.header__search--completable {
|
||||
.search-autocomplete-dummy {
|
||||
position: absolute;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,51 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.media-box--first:not(.media-box--last) {
|
||||
.media-box-tools:before {
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
.media-box-tools:after {
|
||||
right: -75%;
|
||||
}
|
||||
|
||||
.maintenance-popup {
|
||||
left: -1px;
|
||||
right: -75%;
|
||||
}
|
||||
}
|
||||
|
||||
&.media-box--last:not(.media-box--first) {
|
||||
.media-box-tools:before {
|
||||
left: -75%;
|
||||
}
|
||||
|
||||
.media-box-tools:after {
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
.maintenance-popup {
|
||||
left: -75%;
|
||||
right: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
&.media-box--last.media-box--first {
|
||||
.media-box-tools:before {
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
.media-box-tools:after {
|
||||
right: -1px;
|
||||
}
|
||||
|
||||
.maintenance-popup {
|
||||
left: -1px;
|
||||
right: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.media-box-tools.has-active-profile {
|
||||
&:before, &:after {
|
||||
@@ -106,7 +151,7 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-box-show-fullscreen {
|
||||
.media-box-show-fullscreen.is-visible {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@@ -115,9 +160,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;
|
||||
@@ -126,15 +171,21 @@
|
||||
display: flex;
|
||||
justify-content: stretch;
|
||||
align-items: stretch;
|
||||
transform: translateY(var(--offset, 0));
|
||||
|
||||
img {
|
||||
img, video {
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.shown {
|
||||
opacity: 1;
|
||||
opacity: var(--opacity, 1);
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
&.swiped {
|
||||
opacity: var(--opacity, 1);
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,8 @@
|
||||
|
||||
.icon.icon-plus {
|
||||
@include insert-icon('/img/plus.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.icon.icon-file-export {
|
||||
@include insert-icon('/img/file-export.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,12 +0,0 @@
|
||||
@use '../colors';
|
||||
@import "input";
|
||||
|
||||
tags-editor {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
input {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,21 @@
|
||||
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;
|
||||
min-width: 320px;
|
||||
max-height: min(100vh, 320px);
|
||||
font-family: verdana, arial, helvetica, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -30,6 +39,6 @@ a {
|
||||
}
|
||||
}
|
||||
|
||||
@import "injectable/tags-editor";
|
||||
@import "injectable/input";
|
||||
@import "injectable/tag";
|
||||
@import "injectable/icons";
|
||||
@import "injectable/icons";
|
||||
|
||||
3
static/img/file-export.svg
Normal file
3
static/img/file-export.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
|
||||
<path d="M384 121.9c0-6.3-2.5-12.4-7-16.9L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128v-6.1zM192 336v-32c0-8.84 7.16-16 16-16h176V160H248c-13.2 0-24-10.8-24-24V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V352H208c-8.84 0-16-7.16-16-16zm379.05-28.02l-95.7-96.43c-10.06-10.14-27.36-3.01-27.36 11.27V288H384v64h63.99v65.18c0 14.28 17.29 21.41 27.36 11.27l95.7-96.42c6.6-6.66 6.6-17.4 0-24.05z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 492 B |
@@ -19,6 +19,12 @@ const config = {
|
||||
"$stores": "./src/stores",
|
||||
"$entities": "./src/lib/extension/entities",
|
||||
},
|
||||
typescript: {
|
||||
config: config => {
|
||||
config.compilerOptions = config.compilerOptions || {};
|
||||
config.compilerOptions.allowImportingTsExtension = true
|
||||
}
|
||||
}
|
||||
},
|
||||
preprocess: [
|
||||
vitePreprocess({
|
||||
|
||||
Reference in New Issue
Block a user