mirror of
https://github.com/koloml/furbooru-tagging-assistant.git
synced 2025-12-23 23:02:58 +00:00
359
.editorconfig
359
.editorconfig
@@ -403,6 +403,365 @@ ij_typescript_while_brace_force = never
|
||||
ij_typescript_while_on_new_line = false
|
||||
ij_typescript_wrap_comments = false
|
||||
|
||||
[*.svelte]
|
||||
indent_size = 2
|
||||
tab_width = 2
|
||||
ij_continuation_indent_size = 2
|
||||
ij_javascript_align_imports = false
|
||||
ij_javascript_align_multiline_array_initializer_expression = false
|
||||
ij_javascript_align_multiline_binary_operation = false
|
||||
ij_javascript_align_multiline_chained_methods = false
|
||||
ij_javascript_align_multiline_extends_list = false
|
||||
ij_javascript_align_multiline_for = true
|
||||
ij_javascript_align_multiline_parameters = true
|
||||
ij_javascript_align_multiline_parameters_in_calls = false
|
||||
ij_javascript_align_multiline_ternary_operation = false
|
||||
ij_javascript_align_object_properties = 0
|
||||
ij_javascript_align_union_types = false
|
||||
ij_javascript_align_var_statements = 0
|
||||
ij_javascript_array_initializer_new_line_after_left_brace = false
|
||||
ij_javascript_array_initializer_right_brace_on_new_line = false
|
||||
ij_javascript_array_initializer_wrap = off
|
||||
ij_javascript_assignment_wrap = off
|
||||
ij_javascript_binary_operation_sign_on_next_line = false
|
||||
ij_javascript_binary_operation_wrap = off
|
||||
ij_javascript_blacklist_imports = rxjs/Rx, node_modules/**, **/node_modules/**, @angular/material, @angular/material/typings/**
|
||||
ij_javascript_blank_lines_after_imports = 1
|
||||
ij_javascript_blank_lines_around_class = 1
|
||||
ij_javascript_blank_lines_around_field = 0
|
||||
ij_javascript_blank_lines_around_function = 1
|
||||
ij_javascript_blank_lines_around_method = 1
|
||||
ij_javascript_block_brace_style = end_of_line
|
||||
ij_javascript_block_comment_add_space = false
|
||||
ij_javascript_block_comment_at_first_column = true
|
||||
ij_javascript_call_parameters_new_line_after_left_paren = false
|
||||
ij_javascript_call_parameters_right_paren_on_new_line = false
|
||||
ij_javascript_call_parameters_wrap = off
|
||||
ij_javascript_catch_on_new_line = false
|
||||
ij_javascript_chained_call_dot_on_new_line = true
|
||||
ij_javascript_class_brace_style = end_of_line
|
||||
ij_javascript_comma_on_new_line = false
|
||||
ij_javascript_do_while_brace_force = never
|
||||
ij_javascript_else_on_new_line = false
|
||||
ij_javascript_enforce_trailing_comma = keep
|
||||
ij_javascript_extends_keyword_wrap = off
|
||||
ij_javascript_extends_list_wrap = off
|
||||
ij_javascript_field_prefix = _
|
||||
ij_javascript_file_name_style = relaxed
|
||||
ij_javascript_finally_on_new_line = false
|
||||
ij_javascript_for_brace_force = never
|
||||
ij_javascript_for_statement_new_line_after_left_paren = false
|
||||
ij_javascript_for_statement_right_paren_on_new_line = false
|
||||
ij_javascript_for_statement_wrap = off
|
||||
ij_javascript_force_quote_style = false
|
||||
ij_javascript_force_semicolon_style = false
|
||||
ij_javascript_function_expression_brace_style = end_of_line
|
||||
ij_javascript_if_brace_force = never
|
||||
ij_javascript_import_merge_members = global
|
||||
ij_javascript_import_prefer_absolute_path = global
|
||||
ij_javascript_import_sort_members = true
|
||||
ij_javascript_import_sort_module_name = false
|
||||
ij_javascript_import_use_node_resolution = true
|
||||
ij_javascript_imports_wrap = on_every_item
|
||||
ij_javascript_indent_case_from_switch = true
|
||||
ij_javascript_indent_chained_calls = true
|
||||
ij_javascript_indent_package_children = 0
|
||||
ij_javascript_jsx_attribute_value = braces
|
||||
ij_javascript_keep_blank_lines_in_code = 2
|
||||
ij_javascript_keep_first_column_comment = true
|
||||
ij_javascript_keep_indents_on_empty_lines = false
|
||||
ij_javascript_keep_line_breaks = true
|
||||
ij_javascript_keep_simple_blocks_in_one_line = false
|
||||
ij_javascript_keep_simple_methods_in_one_line = false
|
||||
ij_javascript_line_comment_add_space = true
|
||||
ij_javascript_line_comment_at_first_column = false
|
||||
ij_javascript_method_brace_style = end_of_line
|
||||
ij_javascript_method_call_chain_wrap = off
|
||||
ij_javascript_method_parameters_new_line_after_left_paren = false
|
||||
ij_javascript_method_parameters_right_paren_on_new_line = false
|
||||
ij_javascript_method_parameters_wrap = off
|
||||
ij_javascript_object_literal_wrap = on_every_item
|
||||
ij_javascript_object_types_wrap = on_every_item
|
||||
ij_javascript_parentheses_expression_new_line_after_left_paren = false
|
||||
ij_javascript_parentheses_expression_right_paren_on_new_line = false
|
||||
ij_javascript_place_assignment_sign_on_next_line = false
|
||||
ij_javascript_prefer_as_type_cast = false
|
||||
ij_javascript_prefer_explicit_types_function_expression_returns = false
|
||||
ij_javascript_prefer_explicit_types_function_returns = false
|
||||
ij_javascript_prefer_explicit_types_vars_fields = false
|
||||
ij_javascript_prefer_parameters_wrap = false
|
||||
ij_javascript_property_prefix =
|
||||
ij_javascript_reformat_c_style_comments = false
|
||||
ij_javascript_space_after_colon = true
|
||||
ij_javascript_space_after_comma = true
|
||||
ij_javascript_space_after_dots_in_rest_parameter = false
|
||||
ij_javascript_space_after_generator_mult = true
|
||||
ij_javascript_space_after_property_colon = true
|
||||
ij_javascript_space_after_quest = true
|
||||
ij_javascript_space_after_type_colon = true
|
||||
ij_javascript_space_after_unary_not = false
|
||||
ij_javascript_space_before_async_arrow_lparen = true
|
||||
ij_javascript_space_before_catch_keyword = true
|
||||
ij_javascript_space_before_catch_left_brace = true
|
||||
ij_javascript_space_before_catch_parentheses = true
|
||||
ij_javascript_space_before_class_lbrace = true
|
||||
ij_javascript_space_before_class_left_brace = true
|
||||
ij_javascript_space_before_colon = true
|
||||
ij_javascript_space_before_comma = false
|
||||
ij_javascript_space_before_do_left_brace = true
|
||||
ij_javascript_space_before_else_keyword = true
|
||||
ij_javascript_space_before_else_left_brace = true
|
||||
ij_javascript_space_before_finally_keyword = true
|
||||
ij_javascript_space_before_finally_left_brace = true
|
||||
ij_javascript_space_before_for_left_brace = true
|
||||
ij_javascript_space_before_for_parentheses = true
|
||||
ij_javascript_space_before_for_semicolon = false
|
||||
ij_javascript_space_before_function_left_parenth = true
|
||||
ij_javascript_space_before_generator_mult = false
|
||||
ij_javascript_space_before_if_left_brace = true
|
||||
ij_javascript_space_before_if_parentheses = true
|
||||
ij_javascript_space_before_method_call_parentheses = false
|
||||
ij_javascript_space_before_method_left_brace = true
|
||||
ij_javascript_space_before_method_parentheses = false
|
||||
ij_javascript_space_before_property_colon = false
|
||||
ij_javascript_space_before_quest = true
|
||||
ij_javascript_space_before_switch_left_brace = true
|
||||
ij_javascript_space_before_switch_parentheses = true
|
||||
ij_javascript_space_before_try_left_brace = true
|
||||
ij_javascript_space_before_type_colon = false
|
||||
ij_javascript_space_before_unary_not = false
|
||||
ij_javascript_space_before_while_keyword = true
|
||||
ij_javascript_space_before_while_left_brace = true
|
||||
ij_javascript_space_before_while_parentheses = true
|
||||
ij_javascript_spaces_around_additive_operators = true
|
||||
ij_javascript_spaces_around_arrow_function_operator = true
|
||||
ij_javascript_spaces_around_assignment_operators = true
|
||||
ij_javascript_spaces_around_bitwise_operators = true
|
||||
ij_javascript_spaces_around_equality_operators = true
|
||||
ij_javascript_spaces_around_logical_operators = true
|
||||
ij_javascript_spaces_around_multiplicative_operators = true
|
||||
ij_javascript_spaces_around_relational_operators = true
|
||||
ij_javascript_spaces_around_shift_operators = true
|
||||
ij_javascript_spaces_around_unary_operator = false
|
||||
ij_javascript_spaces_within_array_initializer_brackets = false
|
||||
ij_javascript_spaces_within_brackets = false
|
||||
ij_javascript_spaces_within_catch_parentheses = false
|
||||
ij_javascript_spaces_within_for_parentheses = false
|
||||
ij_javascript_spaces_within_if_parentheses = false
|
||||
ij_javascript_spaces_within_imports = true
|
||||
ij_javascript_spaces_within_interpolation_expressions = false
|
||||
ij_javascript_spaces_within_method_call_parentheses = false
|
||||
ij_javascript_spaces_within_method_parentheses = false
|
||||
ij_javascript_spaces_within_object_literal_braces = true
|
||||
ij_javascript_spaces_within_object_type_braces = true
|
||||
ij_javascript_spaces_within_parentheses = false
|
||||
ij_javascript_spaces_within_switch_parentheses = false
|
||||
ij_javascript_spaces_within_type_assertion = false
|
||||
ij_javascript_spaces_within_union_types = true
|
||||
ij_javascript_spaces_within_while_parentheses = false
|
||||
ij_javascript_special_else_if_treatment = true
|
||||
ij_javascript_ternary_operation_signs_on_next_line = false
|
||||
ij_javascript_ternary_operation_wrap = off
|
||||
ij_javascript_union_types_wrap = on_every_item
|
||||
ij_javascript_use_chained_calls_group_indents = false
|
||||
ij_javascript_use_double_quotes = true
|
||||
ij_javascript_use_explicit_js_extension = never
|
||||
ij_javascript_use_import_type = auto
|
||||
ij_javascript_use_path_mapping = always
|
||||
ij_javascript_use_public_modifier = false
|
||||
ij_javascript_use_semicolon_after_statement = true
|
||||
ij_javascript_var_declaration_wrap = normal
|
||||
ij_javascript_while_brace_force = never
|
||||
ij_javascript_while_on_new_line = false
|
||||
ij_javascript_wrap_comments = false
|
||||
ij_scss_align_closing_brace_with_properties = false
|
||||
ij_scss_blank_lines_around_nested_selector = 1
|
||||
ij_scss_blank_lines_between_blocks = 1
|
||||
ij_scss_block_comment_add_space = false
|
||||
ij_scss_brace_placement = 0
|
||||
ij_scss_enforce_quotes_on_format = false
|
||||
ij_scss_hex_color_long_format = false
|
||||
ij_scss_hex_color_lower_case = false
|
||||
ij_scss_hex_color_short_format = false
|
||||
ij_scss_hex_color_upper_case = false
|
||||
ij_scss_keep_blank_lines_in_code = 2
|
||||
ij_scss_keep_indents_on_empty_lines = false
|
||||
ij_scss_keep_single_line_blocks = false
|
||||
ij_scss_line_comment_add_space = false
|
||||
ij_scss_line_comment_at_first_column = false
|
||||
ij_scss_properties_order = font, font-family, font-size, font-weight, font-style, font-variant, font-size-adjust, font-stretch, line-height, position, z-index, top, right, bottom, left, display, visibility, float, clear, overflow, overflow-x, overflow-y, clip, zoom, align-content, align-items, align-self, flex, flex-flow, flex-basis, flex-direction, flex-grow, flex-shrink, flex-wrap, justify-content, order, box-sizing, width, min-width, max-width, height, min-height, max-height, margin, margin-top, margin-right, margin-bottom, margin-left, padding, padding-top, padding-right, padding-bottom, padding-left, table-layout, empty-cells, caption-side, border-spacing, border-collapse, list-style, list-style-position, list-style-type, list-style-image, content, quotes, counter-reset, counter-increment, resize, cursor, user-select, nav-index, nav-up, nav-right, nav-down, nav-left, transition, transition-delay, transition-timing-function, transition-duration, transition-property, transform, transform-origin, animation, animation-name, animation-duration, animation-play-state, animation-timing-function, animation-delay, animation-iteration-count, animation-direction, text-align, text-align-last, vertical-align, white-space, text-decoration, text-emphasis, text-emphasis-color, text-emphasis-style, text-emphasis-position, text-indent, text-justify, letter-spacing, word-spacing, text-outline, text-transform, text-wrap, text-overflow, text-overflow-ellipsis, text-overflow-mode, word-wrap, word-break, tab-size, hyphens, pointer-events, opacity, color, border, border-width, border-style, border-color, border-top, border-top-width, border-top-style, border-top-color, border-right, border-right-width, border-right-style, border-right-color, border-bottom, border-bottom-width, border-bottom-style, border-bottom-color, border-left, border-left-width, border-left-style, border-left-color, border-radius, border-top-left-radius, border-top-right-radius, border-bottom-right-radius, border-bottom-left-radius, border-image, border-image-source, border-image-slice, border-image-width, border-image-outset, border-image-repeat, outline, outline-width, outline-style, outline-color, outline-offset, background, background-color, background-image, background-repeat, background-attachment, background-position, background-position-x, background-position-y, background-clip, background-origin, background-size, box-decoration-break, box-shadow, text-shadow
|
||||
ij_scss_space_after_colon = true
|
||||
ij_scss_space_before_opening_brace = true
|
||||
ij_scss_use_double_quotes = true
|
||||
ij_scss_value_alignment = 0
|
||||
ij_typescript_align_imports = false
|
||||
ij_typescript_align_multiline_array_initializer_expression = false
|
||||
ij_typescript_align_multiline_binary_operation = false
|
||||
ij_typescript_align_multiline_chained_methods = false
|
||||
ij_typescript_align_multiline_extends_list = false
|
||||
ij_typescript_align_multiline_for = true
|
||||
ij_typescript_align_multiline_parameters = true
|
||||
ij_typescript_align_multiline_parameters_in_calls = false
|
||||
ij_typescript_align_multiline_ternary_operation = false
|
||||
ij_typescript_align_object_properties = 0
|
||||
ij_typescript_align_union_types = false
|
||||
ij_typescript_align_var_statements = 0
|
||||
ij_typescript_array_initializer_new_line_after_left_brace = false
|
||||
ij_typescript_array_initializer_right_brace_on_new_line = false
|
||||
ij_typescript_array_initializer_wrap = off
|
||||
ij_typescript_assignment_wrap = off
|
||||
ij_typescript_binary_operation_sign_on_next_line = false
|
||||
ij_typescript_binary_operation_wrap = off
|
||||
ij_typescript_blacklist_imports = rxjs/Rx, node_modules/**, **/node_modules/**, @angular/material, @angular/material/typings/**
|
||||
ij_typescript_blank_lines_after_imports = 1
|
||||
ij_typescript_blank_lines_around_class = 1
|
||||
ij_typescript_blank_lines_around_field = 0
|
||||
ij_typescript_blank_lines_around_function = 1
|
||||
ij_typescript_blank_lines_around_method = 1
|
||||
ij_typescript_block_brace_style = end_of_line
|
||||
ij_typescript_block_comment_add_space = false
|
||||
ij_typescript_block_comment_at_first_column = true
|
||||
ij_typescript_call_parameters_new_line_after_left_paren = false
|
||||
ij_typescript_call_parameters_right_paren_on_new_line = false
|
||||
ij_typescript_call_parameters_wrap = off
|
||||
ij_typescript_catch_on_new_line = false
|
||||
ij_typescript_chained_call_dot_on_new_line = true
|
||||
ij_typescript_class_brace_style = end_of_line
|
||||
ij_typescript_comma_on_new_line = false
|
||||
ij_typescript_do_while_brace_force = never
|
||||
ij_typescript_else_on_new_line = false
|
||||
ij_typescript_enforce_trailing_comma = keep
|
||||
ij_typescript_extends_keyword_wrap = off
|
||||
ij_typescript_extends_list_wrap = off
|
||||
ij_typescript_field_prefix = _
|
||||
ij_typescript_file_name_style = relaxed
|
||||
ij_typescript_finally_on_new_line = false
|
||||
ij_typescript_for_brace_force = never
|
||||
ij_typescript_for_statement_new_line_after_left_paren = false
|
||||
ij_typescript_for_statement_right_paren_on_new_line = false
|
||||
ij_typescript_for_statement_wrap = off
|
||||
ij_typescript_force_quote_style = false
|
||||
ij_typescript_force_semicolon_style = false
|
||||
ij_typescript_function_expression_brace_style = end_of_line
|
||||
ij_typescript_if_brace_force = never
|
||||
ij_typescript_import_merge_members = global
|
||||
ij_typescript_import_prefer_absolute_path = global
|
||||
ij_typescript_import_sort_members = true
|
||||
ij_typescript_import_sort_module_name = false
|
||||
ij_typescript_import_use_node_resolution = true
|
||||
ij_typescript_imports_wrap = on_every_item
|
||||
ij_typescript_indent_case_from_switch = true
|
||||
ij_typescript_indent_chained_calls = true
|
||||
ij_typescript_indent_package_children = 0
|
||||
ij_typescript_jsx_attribute_value = braces
|
||||
ij_typescript_keep_blank_lines_in_code = 2
|
||||
ij_typescript_keep_first_column_comment = true
|
||||
ij_typescript_keep_indents_on_empty_lines = false
|
||||
ij_typescript_keep_line_breaks = true
|
||||
ij_typescript_keep_simple_blocks_in_one_line = false
|
||||
ij_typescript_keep_simple_methods_in_one_line = false
|
||||
ij_typescript_line_comment_add_space = true
|
||||
ij_typescript_line_comment_at_first_column = false
|
||||
ij_typescript_method_brace_style = end_of_line
|
||||
ij_typescript_method_call_chain_wrap = off
|
||||
ij_typescript_method_parameters_new_line_after_left_paren = false
|
||||
ij_typescript_method_parameters_right_paren_on_new_line = false
|
||||
ij_typescript_method_parameters_wrap = off
|
||||
ij_typescript_object_literal_wrap = on_every_item
|
||||
ij_typescript_object_types_wrap = on_every_item
|
||||
ij_typescript_parentheses_expression_new_line_after_left_paren = false
|
||||
ij_typescript_parentheses_expression_right_paren_on_new_line = false
|
||||
ij_typescript_place_assignment_sign_on_next_line = false
|
||||
ij_typescript_prefer_as_type_cast = false
|
||||
ij_typescript_prefer_explicit_types_function_expression_returns = false
|
||||
ij_typescript_prefer_explicit_types_function_returns = false
|
||||
ij_typescript_prefer_explicit_types_vars_fields = false
|
||||
ij_typescript_prefer_parameters_wrap = false
|
||||
ij_typescript_property_prefix =
|
||||
ij_typescript_reformat_c_style_comments = false
|
||||
ij_typescript_space_after_colon = true
|
||||
ij_typescript_space_after_comma = true
|
||||
ij_typescript_space_after_dots_in_rest_parameter = false
|
||||
ij_typescript_space_after_generator_mult = true
|
||||
ij_typescript_space_after_property_colon = true
|
||||
ij_typescript_space_after_quest = true
|
||||
ij_typescript_space_after_type_colon = true
|
||||
ij_typescript_space_after_unary_not = false
|
||||
ij_typescript_space_before_async_arrow_lparen = true
|
||||
ij_typescript_space_before_catch_keyword = true
|
||||
ij_typescript_space_before_catch_left_brace = true
|
||||
ij_typescript_space_before_catch_parentheses = true
|
||||
ij_typescript_space_before_class_lbrace = true
|
||||
ij_typescript_space_before_class_left_brace = true
|
||||
ij_typescript_space_before_colon = true
|
||||
ij_typescript_space_before_comma = false
|
||||
ij_typescript_space_before_do_left_brace = true
|
||||
ij_typescript_space_before_else_keyword = true
|
||||
ij_typescript_space_before_else_left_brace = true
|
||||
ij_typescript_space_before_finally_keyword = true
|
||||
ij_typescript_space_before_finally_left_brace = true
|
||||
ij_typescript_space_before_for_left_brace = true
|
||||
ij_typescript_space_before_for_parentheses = true
|
||||
ij_typescript_space_before_for_semicolon = false
|
||||
ij_typescript_space_before_function_left_parenth = true
|
||||
ij_typescript_space_before_generator_mult = false
|
||||
ij_typescript_space_before_if_left_brace = true
|
||||
ij_typescript_space_before_if_parentheses = true
|
||||
ij_typescript_space_before_method_call_parentheses = false
|
||||
ij_typescript_space_before_method_left_brace = true
|
||||
ij_typescript_space_before_method_parentheses = false
|
||||
ij_typescript_space_before_property_colon = false
|
||||
ij_typescript_space_before_quest = true
|
||||
ij_typescript_space_before_switch_left_brace = true
|
||||
ij_typescript_space_before_switch_parentheses = true
|
||||
ij_typescript_space_before_try_left_brace = true
|
||||
ij_typescript_space_before_type_colon = false
|
||||
ij_typescript_space_before_unary_not = false
|
||||
ij_typescript_space_before_while_keyword = true
|
||||
ij_typescript_space_before_while_left_brace = true
|
||||
ij_typescript_space_before_while_parentheses = true
|
||||
ij_typescript_spaces_around_additive_operators = true
|
||||
ij_typescript_spaces_around_arrow_function_operator = true
|
||||
ij_typescript_spaces_around_assignment_operators = true
|
||||
ij_typescript_spaces_around_bitwise_operators = true
|
||||
ij_typescript_spaces_around_equality_operators = true
|
||||
ij_typescript_spaces_around_logical_operators = true
|
||||
ij_typescript_spaces_around_multiplicative_operators = true
|
||||
ij_typescript_spaces_around_relational_operators = true
|
||||
ij_typescript_spaces_around_shift_operators = true
|
||||
ij_typescript_spaces_around_unary_operator = false
|
||||
ij_typescript_spaces_within_array_initializer_brackets = false
|
||||
ij_typescript_spaces_within_brackets = false
|
||||
ij_typescript_spaces_within_catch_parentheses = false
|
||||
ij_typescript_spaces_within_for_parentheses = false
|
||||
ij_typescript_spaces_within_if_parentheses = false
|
||||
ij_typescript_spaces_within_imports = true
|
||||
ij_typescript_spaces_within_interpolation_expressions = false
|
||||
ij_typescript_spaces_within_method_call_parentheses = false
|
||||
ij_typescript_spaces_within_method_parentheses = false
|
||||
ij_typescript_spaces_within_object_literal_braces = true
|
||||
ij_typescript_spaces_within_object_type_braces = true
|
||||
ij_typescript_spaces_within_parentheses = false
|
||||
ij_typescript_spaces_within_switch_parentheses = false
|
||||
ij_typescript_spaces_within_type_assertion = false
|
||||
ij_typescript_spaces_within_union_types = true
|
||||
ij_typescript_spaces_within_while_parentheses = false
|
||||
ij_typescript_special_else_if_treatment = true
|
||||
ij_typescript_ternary_operation_signs_on_next_line = false
|
||||
ij_typescript_ternary_operation_wrap = off
|
||||
ij_typescript_union_types_wrap = on_every_item
|
||||
ij_typescript_use_chained_calls_group_indents = false
|
||||
ij_typescript_use_double_quotes = true
|
||||
ij_typescript_use_explicit_js_extension = never
|
||||
ij_typescript_use_import_type = auto
|
||||
ij_typescript_use_path_mapping = always
|
||||
ij_typescript_use_public_modifier = false
|
||||
ij_typescript_use_semicolon_after_statement = true
|
||||
ij_typescript_var_declaration_wrap = normal
|
||||
ij_typescript_while_brace_force = never
|
||||
ij_typescript_while_on_new_line = false
|
||||
ij_typescript_wrap_comments = false
|
||||
|
||||
[{*.htm,*.html,*.sht,*.shtm,*.shtml}]
|
||||
ij_html_add_new_line_before_tags = body, div, p, form, h1, h2, h3
|
||||
ij_html_align_attributes = true
|
||||
|
||||
31
.github/workflows/build-and-tests.yml
vendored
Normal file
31
.github/workflows/build-and-tests.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Testing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
name: 'Run Unit Tests'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Building the extension
|
||||
run: npm run build
|
||||
|
||||
- name: Running unit tests
|
||||
run: npm run test
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,10 +2,11 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/coverage
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant",
|
||||
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city"
|
||||
@@ -27,7 +27,7 @@
|
||||
"*://*.furbooru.org/galleries/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/listing.js"
|
||||
"src/content/listing.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/listing.scss"
|
||||
@@ -38,7 +38,7 @@
|
||||
"*://*.furbooru.org/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/header.js"
|
||||
"src/content/header.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/header.scss"
|
||||
@@ -59,7 +59,7 @@
|
||||
"*://*.furbooru.org/filters/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/tags.js"
|
||||
"src/content/tags.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -67,7 +67,7 @@
|
||||
"*://*.furbooru.org/images/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/tags-editor.js"
|
||||
"src/content/tags-editor.ts"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
1857
package-lock.json
generated
1857
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.4.1",
|
||||
"version": "0.4.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "npm run build:popup && npm run build:extension",
|
||||
"build:popup": "vite build",
|
||||
"build:extension": "node build-extension.js",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test": "vitest run --coverage",
|
||||
"test:watch": "vitest watch --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^4.0.0",
|
||||
@@ -15,12 +17,15 @@
|
||||
"@sveltejs/kit": "^2.17.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/chrome": "^0.0.304",
|
||||
"@vitest/coverage-v8": "^3.0.6",
|
||||
"cheerio": "^1.0.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"sass": "^1.85.0",
|
||||
"svelte": "^5.20.1",
|
||||
"svelte-check": "^4.1.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.0.6"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,93 +1,123 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { storagesCollection } from "$stores/debug";
|
||||
import { goto } from "$app/navigation";
|
||||
import { findDeepObject } from "$lib/utils";
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { storagesCollection } from "$stores/debug";
|
||||
import { goto } from "$app/navigation";
|
||||
import { findDeepObject } from "$lib/utils";
|
||||
|
||||
/** @type {string} */
|
||||
export let storage;
|
||||
interface StorageViewerProps {
|
||||
storage: string;
|
||||
path: string[];
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
export let path;
|
||||
type BreadcrumbsArray = [string, string][];
|
||||
|
||||
/** @type {Object|null} */
|
||||
let targetStorage = null;
|
||||
/** @type {[string, string][]} */
|
||||
let breadcrumbs = [];
|
||||
/** @type {Object<string, any>|null} */
|
||||
let targetObject = null;
|
||||
let targetPathString = '';
|
||||
let { storage, path }: StorageViewerProps = $props();
|
||||
|
||||
$: {
|
||||
/** @type {[string, string][]} */
|
||||
const builtBreadcrumbs = [];
|
||||
let breadcrumbs = $derived.by<BreadcrumbsArray>(() => {
|
||||
return path.reduce<BreadcrumbsArray>((resultCrumbs, entry) => {
|
||||
let entryPath = entry;
|
||||
|
||||
breadcrumbs = path.reduce((resultCrumbs, entry) => {
|
||||
let entryPath = entry;
|
||||
if (resultCrumbs.length) {
|
||||
entryPath = resultCrumbs[resultCrumbs.length - 1][1] + "/" + entryPath;
|
||||
}
|
||||
|
||||
if (resultCrumbs.length) {
|
||||
entryPath = resultCrumbs[resultCrumbs.length - 1][1] + "/" + entryPath;
|
||||
}
|
||||
resultCrumbs.push([entry, entryPath]);
|
||||
|
||||
resultCrumbs.push([entry, entryPath]);
|
||||
return resultCrumbs;
|
||||
}, [])
|
||||
});
|
||||
|
||||
return resultCrumbs;
|
||||
}, builtBreadcrumbs);
|
||||
let targetStorage = $derived.by<object|null>(() => {
|
||||
return $storagesCollection[storage];
|
||||
});
|
||||
|
||||
targetPathString = path.join("/");
|
||||
let targetObject = $derived.by<Record<string, any> | null>(() => {
|
||||
return targetStorage
|
||||
? findDeepObject(targetStorage, path)
|
||||
: null;
|
||||
});
|
||||
|
||||
if (targetPathString.length) {
|
||||
targetPathString += "/";
|
||||
}
|
||||
let targetPathString = $derived.by<string>(() => {
|
||||
let pathString = path.join("/");
|
||||
|
||||
if (pathString.length) {
|
||||
pathString += "/";
|
||||
}
|
||||
|
||||
$: {
|
||||
targetStorage = $storagesCollection[storage];
|
||||
return pathString;
|
||||
});
|
||||
|
||||
if (!targetStorage) {
|
||||
goto("/preferences/debug/storage");
|
||||
}
|
||||
$effect(() => {
|
||||
if (!targetStorage) {
|
||||
goto("/preferences/debug/storage");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to resolve type, including the null.
|
||||
* @param value Value to resolve type from.
|
||||
* @return Type of the value, including "null" for null.
|
||||
*/
|
||||
function resolveType(value: unknown): string {
|
||||
let typeName: string = typeof value;
|
||||
|
||||
if (typeName === 'object' && value === null) {
|
||||
typeName = 'null';
|
||||
}
|
||||
|
||||
$: {
|
||||
targetObject = targetStorage
|
||||
? findDeepObject(targetStorage, path)
|
||||
: null;
|
||||
return typeName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to resolve value, including values like null or undefined.
|
||||
* @param value Value to resolve.
|
||||
* @return String representation of the value.
|
||||
*/
|
||||
function resolveValue(value: unknown): string {
|
||||
if (value === null) {
|
||||
return "null";
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
|
||||
return value?.toString() ?? '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/preferences/debug/storage" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences/debug/storage" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<p class="path">
|
||||
<span>/ <a href="/preferences/debug/storage/{storage}">{storage}</a></span>
|
||||
{#each breadcrumbs as [name, entryPath]}
|
||||
<span>/ <a href="/preferences/debug/storage/{storage}/{entryPath}/">{name}</a></span>
|
||||
{/each}
|
||||
<span>/ <a href="/preferences/debug/storage/{storage}">{storage}</a></span>
|
||||
{#each breadcrumbs as [name, entryPath]}
|
||||
<span>/ <a href="/preferences/debug/storage/{storage}/{entryPath}/">{name}</a></span>
|
||||
{/each}
|
||||
</p>
|
||||
{#if targetObject}
|
||||
<Menu>
|
||||
<hr>
|
||||
{#each Object.entries(targetObject) as [key, value]}
|
||||
{#if targetObject[key] && typeof targetObject[key] === 'object'}
|
||||
<MenuItem href="/preferences/debug/storage/{storage}/{targetPathString}{key}">
|
||||
{key}: Object
|
||||
</MenuItem>
|
||||
{:else}
|
||||
<MenuItem>
|
||||
{key}: {typeof targetObject[key]} = {targetObject[key]}
|
||||
</MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Menu>
|
||||
<Menu>
|
||||
<hr>
|
||||
{#each Object.entries(targetObject) as [key, _]}
|
||||
{#if targetObject[key] && typeof targetObject[key] === 'object'}
|
||||
<MenuItem href="/preferences/debug/storage/{storage}/{targetPathString}{key}">
|
||||
{key}: Object
|
||||
</MenuItem>
|
||||
{:else}
|
||||
<MenuItem>
|
||||
{key}: {resolveType(targetObject[key])} = {resolveValue(targetObject[key])}
|
||||
</MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Menu>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.path {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: .5em;
|
||||
}
|
||||
.path {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: .5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,59 +1,60 @@
|
||||
<script>
|
||||
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
|
||||
<script lang="ts">
|
||||
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
/**
|
||||
* @type {import('$entities/TagGroup').default}
|
||||
*/
|
||||
export let group;
|
||||
interface GroupViewProps {
|
||||
group: TagGroup;
|
||||
}
|
||||
|
||||
let sortedTagsList, sortedPrefixes;
|
||||
let { group }: GroupViewProps = $props();
|
||||
|
||||
let sortedTagsList = $derived<string[]>(group.settings.tags.sort((a, b) => a.localeCompare(b))),
|
||||
sortedPrefixes = $derived<string[]>(group.settings.prefixes.sort((a, b) => a.localeCompare(b)));
|
||||
|
||||
$: sortedTagsList = group.settings.tags.sort((a, b) => a.localeCompare(b));
|
||||
$: sortedPrefixes = group.settings.prefixes.sort((a, b) => a.localeCompare(b));
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<strong>Group Name:</strong>
|
||||
<div>{group.settings.name}</div>
|
||||
<strong>Group Name:</strong>
|
||||
<div>{group.settings.name}</div>
|
||||
</div>
|
||||
{#if sortedTagsList.length}
|
||||
<div class="block">
|
||||
<strong>Tags:</strong>
|
||||
<TagsColorContainer targetCategory="{group.settings.category}">
|
||||
<div class="tags-list">
|
||||
{#each sortedTagsList as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
<div class="block">
|
||||
<strong>Tags:</strong>
|
||||
<TagsColorContainer targetCategory={group.settings.category}>
|
||||
<div class="tags-list">
|
||||
{#each sortedTagsList as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
{/if}
|
||||
{#if sortedPrefixes.length}
|
||||
<div class="block">
|
||||
<strong>Prefixes:</strong>
|
||||
<TagsColorContainer targetCategory="{group.settings.category}">
|
||||
<div class="tags-list">
|
||||
{#each sortedPrefixes as prefixName}
|
||||
<span class="tag">{prefixName}*</span>
|
||||
{/each}
|
||||
</div>
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
<div class="block">
|
||||
<strong>Prefixes:</strong>
|
||||
<TagsColorContainer targetCategory={group.settings.category}>
|
||||
<div class="tags-list">
|
||||
{#each sortedPrefixes as prefixName}
|
||||
<span class="tag">{prefixName}*</span>
|
||||
{/each}
|
||||
</div>
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,36 +1,41 @@
|
||||
<script>
|
||||
/** @type {import('$entities/MaintenanceProfile').default} */
|
||||
export let profile;
|
||||
<script lang="ts">
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
|
||||
interface ProfileViewProps {
|
||||
profile: MaintenanceProfile;
|
||||
}
|
||||
|
||||
let { profile }: ProfileViewProps = $props();
|
||||
|
||||
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<strong>Profile:</strong>
|
||||
<div>{profile.settings.name}</div>
|
||||
<strong>Profile:</strong>
|
||||
<div>{profile.settings.name}</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<strong>Tags:</strong>
|
||||
<div class="tags-list">
|
||||
{#each sortedTagsList as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<strong>Tags:</strong>
|
||||
<div class="tags-list">
|
||||
{#each sortedTagsList as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
<script>
|
||||
import { version } from "$app/environment";
|
||||
import { version } from "$app/environment";
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<a href="https://github.com/koloml/furbooru-tagging-assistant/releases/tag/{version}" target="_blank">
|
||||
v{version}
|
||||
</a>
|
||||
<span>, made with ♥ by KoloMl.</span>
|
||||
<a href="https://github.com/koloml/furbooru-tagging-assistant/releases/tag/{version}" target="_blank">
|
||||
v{version}
|
||||
</a>
|
||||
<span>, made with ♥ by KoloMl.</span>
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background: colors.$footer;
|
||||
color: colors.$footer-text;
|
||||
padding: 0 24px;
|
||||
font-size: 12px;
|
||||
line-height: 36px;
|
||||
footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background: colors.$footer;
|
||||
color: colors.$footer-text;
|
||||
padding: 0 24px;
|
||||
font-size: 12px;
|
||||
line-height: 36px;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
<header>
|
||||
<a href="/">Furbooru Tagging Assistant</a>
|
||||
<a href="/">Furbooru Tagging Assistant</a>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
@use "$styles/colors";
|
||||
@use "$styles/colors";
|
||||
|
||||
header {
|
||||
background: colors.$header;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
header {
|
||||
background: colors.$header;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
a {
|
||||
color: colors.$text;
|
||||
line-height: 36px;
|
||||
padding: 0 12px;
|
||||
margin-left: -12px;
|
||||
a {
|
||||
color: colors.$text;
|
||||
line-height: 36px;
|
||||
padding: 0 12px;
|
||||
margin-left: -12px;
|
||||
|
||||
&:hover {
|
||||
background: colors.$header-hover-background;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: colors.$header-hover-background;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,62 +1,68 @@
|
||||
<script>
|
||||
/** @type {string} */
|
||||
export let targetCategory = '';
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface TagColorContainerProps {
|
||||
targetCategory?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { targetCategory = '', children }: TagColorContainerProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="tag-color-container tag-color-container--{targetCategory || 'default'}">
|
||||
<slot></slot>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
.tag-color-container:is(.tag-color-container--rating) :global(.tag) {
|
||||
background-color: colors.$tag-rating-background;
|
||||
color: colors.$tag-rating-text;
|
||||
}
|
||||
.tag-color-container:is(:global(.tag-color-container--rating)) :global(.tag) {
|
||||
background-color: colors.$tag-rating-background;
|
||||
color: colors.$tag-rating-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--spoiler) :global(.tag) {
|
||||
background-color: colors.$tag-spoiler-background;
|
||||
color: colors.$tag-spoiler-text;
|
||||
}
|
||||
.tag-color-container:is(:global(.tag-color-container--spoiler)) :global(.tag) {
|
||||
background-color: colors.$tag-spoiler-background;
|
||||
color: colors.$tag-spoiler-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--origin) :global(.tag) {
|
||||
background-color: colors.$tag-origin-background;
|
||||
color: colors.$tag-origin-text;
|
||||
}
|
||||
.tag-color-container:is(:global(.tag-color-container--origin)) :global(.tag) {
|
||||
background-color: colors.$tag-origin-background;
|
||||
color: colors.$tag-origin-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--oc) :global(.tag) {
|
||||
background-color: colors.$tag-oc-background;
|
||||
color: colors.$tag-oc-text;
|
||||
}
|
||||
.tag-color-container:is(:global(.tag-color-container--oc)) :global(.tag) {
|
||||
background-color: colors.$tag-oc-background;
|
||||
color: colors.$tag-oc-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--error) :global(.tag) {
|
||||
background-color: colors.$tag-error-background;
|
||||
color: colors.$tag-error-text;
|
||||
}
|
||||
.tag-color-container:is(:global(.tag-color-container--error)) :global(.tag) {
|
||||
background-color: colors.$tag-error-background;
|
||||
color: colors.$tag-error-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--character) :global(.tag) {
|
||||
background-color: colors.$tag-character-background;
|
||||
color: colors.$tag-character-text;
|
||||
}
|
||||
.tag-color-container:is(:global(.tag-color-container--character)) :global(.tag) {
|
||||
background-color: colors.$tag-character-background;
|
||||
color: colors.$tag-character-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--content-official) :global(.tag) {
|
||||
background-color: colors.$tag-content-official-background;
|
||||
color: colors.$tag-content-official-text;
|
||||
}
|
||||
.tag-color-container:is(:global(.tag-color-container--content-official)) :global(.tag) {
|
||||
background-color: colors.$tag-content-official-background;
|
||||
color: colors.$tag-content-official-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--content-fanmade) :global(.tag) {
|
||||
background-color: colors.$tag-content-fanmade-background;
|
||||
color: colors.$tag-content-fanmade-text;
|
||||
}
|
||||
.tag-color-container:is(:global(.tag-color-container--content-fanmade)) :global(.tag) {
|
||||
background-color: colors.$tag-content-fanmade-background;
|
||||
color: colors.$tag-content-fanmade-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--species) :global(.tag) {
|
||||
background-color: colors.$tag-species-background;
|
||||
color: colors.$tag-species-text;
|
||||
}
|
||||
.tag-color-container:is(:global(.tag-color-container--species)) :global(.tag) {
|
||||
background-color: colors.$tag-species-background;
|
||||
color: colors.$tag-species-text;
|
||||
}
|
||||
|
||||
.tag-color-container:is(.tag-color-container--body-type) :global(.tag) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
}
|
||||
.tag-color-container:is(:global(.tag-color-container--body-type)) :global(.tag) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,106 +1,113 @@
|
||||
<script>
|
||||
/**
|
||||
* List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
|
||||
* @type {string[]}
|
||||
*/
|
||||
export let tags = [];
|
||||
<script lang="ts">
|
||||
import type { EventHandler } from "svelte/elements";
|
||||
|
||||
/** @type {Set<string>} */
|
||||
let uniqueTags = new Set();
|
||||
interface TagEditorProps {
|
||||
// List of tags to edit. Any duplicated tags present in the array will be removed on the first edit.
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
$: uniqueTags = new Set(tags);
|
||||
let {
|
||||
tags = $bindable([])
|
||||
}: TagEditorProps = $props();
|
||||
|
||||
/** @type {string} */
|
||||
let addedTagName = '';
|
||||
let uniqueTags = $state<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* Create a callback function to pass into both mouse & keyboard events for tag removal.
|
||||
* @param {string} tagName
|
||||
* @return {function(Event)} Callback to pass as event listener.
|
||||
*/
|
||||
function createTagRemoveHandler(tagName) {
|
||||
return event => {
|
||||
if (event.type === 'click') {
|
||||
removeTag(tagName);
|
||||
}
|
||||
$effect.pre(() => {
|
||||
uniqueTags = new Set(tags);
|
||||
});
|
||||
|
||||
if (event instanceof KeyboardEvent && (event.code === 'Enter' || event.code === 'Space')) {
|
||||
// To be more comfortable, automatically focus next available tag's remove button in the list.
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
const currenTagElement = event.currentTarget.closest('.tag');
|
||||
const nextTagElement = currenTagElement?.previousElementSibling ?? currenTagElement?.parentElement?.firstElementChild;
|
||||
const nextRemoveButton = nextTagElement?.querySelector('.remove');
|
||||
let addedTagName = $state<string>('');
|
||||
|
||||
if (nextRemoveButton instanceof HTMLElement) {
|
||||
nextRemoveButton.focus();
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Create a callback function to pass into both mouse & keyboard events for tag removal.
|
||||
* @param tagName Name to remove when clicked.
|
||||
* @return Callback to pass as event listener.
|
||||
*/
|
||||
function createTagRemoveHandler(tagName: string): EventHandler<Event, HTMLElement> {
|
||||
return event => {
|
||||
if (event.type === 'click') {
|
||||
removeTag(tagName);
|
||||
}
|
||||
|
||||
removeTag(tagName);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event instanceof KeyboardEvent && (event.code === 'Enter' || event.code === 'Space')) {
|
||||
// To be more comfortable, automatically focus next available tag's remove button in the list.
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
const currenTagElement = event.currentTarget.closest('.tag');
|
||||
const nextTagElement = currenTagElement?.previousElementSibling ?? currenTagElement?.parentElement?.firstElementChild;
|
||||
const nextRemoveButton = nextTagElement?.querySelector('.remove');
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
*/
|
||||
function removeTag(tagName) {
|
||||
uniqueTags.delete(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
*/
|
||||
function addTag(tagName) {
|
||||
uniqueTags.add(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding new tags to the list or removing them when backspace is pressed.
|
||||
*
|
||||
* Additional note: For some reason, mobile Chrome breaks the usual behaviour inside extension. `code` is becoming
|
||||
* empty, while usually it should contain proper button code.
|
||||
*
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
function handleKeyPresses(event) {
|
||||
if ((event.code === 'Enter' || event.key === 'Enter') && addedTagName.length) {
|
||||
addTag(addedTagName)
|
||||
addedTagName = '';
|
||||
if (nextRemoveButton instanceof HTMLElement) {
|
||||
nextRemoveButton.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if ((event.code === 'Backspace' || event.key === 'Backspace') && !addedTagName.length && tags?.length) {
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
removeTag(tagName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the tag from the set.
|
||||
* @param tagName Name of the tag to remove.
|
||||
*/
|
||||
function removeTag(tagName: string) {
|
||||
uniqueTags.delete(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the tag to the set.
|
||||
* @param tagName Name of the tag to add.
|
||||
*/
|
||||
function addTag(tagName: string) {
|
||||
uniqueTags.add(tagName);
|
||||
tags = Array.from(uniqueTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle adding new tags to the list or removing them when backspace is pressed.
|
||||
*
|
||||
* Additional note: For some reason, mobile Chrome breaks the usual behaviour inside extension. `code` is becoming
|
||||
* empty, while usually it should contain proper button code.
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
function handleKeyPresses(event: KeyboardEvent) {
|
||||
if ((event.code === 'Enter' || event.key === 'Enter') && addedTagName.length) {
|
||||
addTag(addedTagName)
|
||||
addedTagName = '';
|
||||
}
|
||||
|
||||
if ((event.code === 'Backspace' || event.key === 'Backspace') && !addedTagName.length && tags?.length) {
|
||||
removeTag(tags[tags.length - 1]);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tags-editor">
|
||||
{#each uniqueTags.values() as tagName}
|
||||
<div class="tag">
|
||||
{tagName}
|
||||
<span class="remove" on:click={createTagRemoveHandler(tagName)}
|
||||
on:keydown={createTagRemoveHandler(tagName)}
|
||||
role="button" tabindex="0">x</span>
|
||||
</div>
|
||||
{/each}
|
||||
<input type="text"
|
||||
bind:value={addedTagName}
|
||||
on:keydown={handleKeyPresses}
|
||||
autocomplete="off"
|
||||
autocapitalize="none"/>
|
||||
{#each uniqueTags.values() as tagName}
|
||||
<div class="tag">
|
||||
{tagName}
|
||||
<span class="remove" onclick={createTagRemoveHandler(tagName)}
|
||||
onkeydown={createTagRemoveHandler(tagName)}
|
||||
role="button" tabindex="0">x</span>
|
||||
</div>
|
||||
{/each}
|
||||
<input autocapitalize="none"
|
||||
autocomplete="off"
|
||||
bind:value={addedTagName}
|
||||
onkeydown={handleKeyPresses}
|
||||
type="text"/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-editor {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
.tags-editor {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<script>
|
||||
/** @type {string|undefined} */
|
||||
export let name = undefined;
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
/** @type {boolean} */
|
||||
export let checked;
|
||||
interface CheckboxFieldProps {
|
||||
name?: string;
|
||||
checked: boolean;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
name = undefined,
|
||||
checked = $bindable(),
|
||||
children
|
||||
}: CheckboxFieldProps = $props();
|
||||
</script>
|
||||
|
||||
<input type="checkbox" {name} bind:checked={checked}>
|
||||
<input bind:checked={checked} {name} type="checkbox">
|
||||
<span>
|
||||
<slot></slot>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface FormContainerProps {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { children }: FormContainerProps = $props();
|
||||
</script>
|
||||
|
||||
<form>
|
||||
<slot></slot>
|
||||
{@render children?.()}
|
||||
</form>
|
||||
|
||||
<style lang="scss">
|
||||
form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let label = undefined;
|
||||
interface FormControlProps {
|
||||
label?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
label = undefined,
|
||||
children
|
||||
}: FormControlProps = $props();
|
||||
</script>
|
||||
|
||||
<label class="control">
|
||||
{#if label}
|
||||
<div class="label">{label}</div>
|
||||
{/if}
|
||||
<slot></slot>
|
||||
{#if label}
|
||||
<div class="label">{label}</div>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</label>
|
||||
|
||||
<style lang="scss">
|
||||
.label {
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
.label {
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
|
||||
.control {
|
||||
padding: 5px 0;
|
||||
.control {
|
||||
padding: 5px 0;
|
||||
|
||||
:global(textarea) {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
:global(textarea) {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
<script>
|
||||
/**
|
||||
* @type {string[]|Record<string, string>}
|
||||
*/
|
||||
export let options = [];
|
||||
<script lang="ts">
|
||||
type SelectFieldOptionsObject = Record<string, string>;
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let name = undefined;
|
||||
interface SelectFieldProps {
|
||||
options?: string[] | SelectFieldOptionsObject;
|
||||
name?: string;
|
||||
id?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let id = undefined;
|
||||
let {
|
||||
options = [],
|
||||
name = undefined,
|
||||
id = undefined,
|
||||
value = $bindable(undefined)
|
||||
}: SelectFieldProps = $props();
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let value = undefined;
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const optionPairs = {};
|
||||
const optionPairs = $derived.by<SelectFieldOptionsObject>(() => {
|
||||
const resultPairs: SelectFieldOptionsObject = {};
|
||||
|
||||
if (Array.isArray(options)) {
|
||||
for (let option of options) {
|
||||
optionPairs[option] = option;
|
||||
}
|
||||
for (let optionName of options) {
|
||||
resultPairs[optionName] = optionName;
|
||||
}
|
||||
} else if (options && typeof options === 'object') {
|
||||
Object.keys(options).forEach((key) => {
|
||||
optionPairs[key] = options[key];
|
||||
})
|
||||
Object.keys(options).forEach(optionKey => {
|
||||
resultPairs[optionKey] = options[optionKey];
|
||||
})
|
||||
}
|
||||
|
||||
return resultPairs;
|
||||
});
|
||||
</script>
|
||||
|
||||
<select {name} {id} bind:value={value}>
|
||||
{#each Object.entries(optionPairs) as [value, label]}
|
||||
<option {value}>{label}</option>
|
||||
{/each}
|
||||
<select bind:value={value} {id} {name}>
|
||||
{#each Object.entries(optionPairs) as [value, label]}
|
||||
<option {value}>{label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<style lang="scss">
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,80 +1,84 @@
|
||||
<script>
|
||||
import SelectField from "$components/ui/forms/SelectField.svelte";
|
||||
import { categories } from "$lib/booru/tag-categories";
|
||||
<script lang="ts">
|
||||
import SelectField from "$components/ui/forms/SelectField.svelte";
|
||||
import { categories } from "$lib/booru/tag-categories";
|
||||
|
||||
/** @type {string} */
|
||||
export let value = '';
|
||||
interface TagCategorySelectFieldProps {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
let tagCategoriesOptions = {
|
||||
'': 'Default'
|
||||
};
|
||||
let {
|
||||
value = $bindable('')
|
||||
}: TagCategorySelectFieldProps = $props();
|
||||
|
||||
tagCategoriesOptions = categories.reduce((options, category) => {
|
||||
options[category] = category
|
||||
.replace('-', ' ')
|
||||
.replace(/(?<=\s|^)\w/g, (matchedCharacter) => matchedCharacter.toUpperCase());
|
||||
let tagCategoriesOptions = $derived.by<Record<string, string>>(() => {
|
||||
return categories.reduce<Record<string, string>>((options, category) => {
|
||||
options[category] = category
|
||||
.replace('-', ' ')
|
||||
.replace(/(?<=\s|^)\w/g, (matchedCharacter) => matchedCharacter.toUpperCase());
|
||||
|
||||
return options;
|
||||
}, tagCategoriesOptions);
|
||||
return options;
|
||||
}, {
|
||||
'': 'Default'
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<SelectField bind:value={value} options={tagCategoriesOptions} name="tag_color"/>
|
||||
<SelectField bind:value={value} name="tag_color" options={tagCategoriesOptions}/>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
:global(select[name=tag_color]) {
|
||||
:global(option) {
|
||||
&:is(:global([value=rating])) {
|
||||
background-color: colors.$tag-rating-background;
|
||||
color: colors.$tag-rating-text;
|
||||
}
|
||||
:global(select[name=tag_color]) {
|
||||
:global(option) {
|
||||
&:is(:global([value=rating])) {
|
||||
background-color: colors.$tag-rating-background;
|
||||
color: colors.$tag-rating-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=spoiler])) {
|
||||
background-color: colors.$tag-spoiler-background;
|
||||
color: colors.$tag-spoiler-text;
|
||||
}
|
||||
&:is(:global([value=spoiler])) {
|
||||
background-color: colors.$tag-spoiler-background;
|
||||
color: colors.$tag-spoiler-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=origin])) {
|
||||
background-color: colors.$tag-origin-background;
|
||||
color: colors.$tag-origin-text;
|
||||
}
|
||||
&:is(:global([value=origin])) {
|
||||
background-color: colors.$tag-origin-background;
|
||||
color: colors.$tag-origin-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=oc])) {
|
||||
background-color: colors.$tag-oc-background;
|
||||
color: colors.$tag-oc-text;
|
||||
}
|
||||
&:is(:global([value=oc])) {
|
||||
background-color: colors.$tag-oc-background;
|
||||
color: colors.$tag-oc-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=error])) {
|
||||
background-color: colors.$tag-error-background;
|
||||
color: colors.$tag-error-text;
|
||||
}
|
||||
&:is(:global([value=error])) {
|
||||
background-color: colors.$tag-error-background;
|
||||
color: colors.$tag-error-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=character])) {
|
||||
background-color: colors.$tag-character-background;
|
||||
color: colors.$tag-character-text;
|
||||
}
|
||||
&:is(:global([value=character])) {
|
||||
background-color: colors.$tag-character-background;
|
||||
color: colors.$tag-character-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=content-official])) {
|
||||
background-color: colors.$tag-content-official-background;
|
||||
color: colors.$tag-content-official-text;
|
||||
}
|
||||
&:is(:global([value=content-official])) {
|
||||
background-color: colors.$tag-content-official-background;
|
||||
color: colors.$tag-content-official-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=content-fanmade])) {
|
||||
background-color: colors.$tag-content-fanmade-background;
|
||||
color: colors.$tag-content-fanmade-text;
|
||||
}
|
||||
&:is(:global([value=content-fanmade])) {
|
||||
background-color: colors.$tag-content-fanmade-background;
|
||||
color: colors.$tag-content-fanmade-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=species])) {
|
||||
background-color: colors.$tag-species-background;
|
||||
color: colors.$tag-species-text;
|
||||
}
|
||||
&:is(:global([value=species])) {
|
||||
background-color: colors.$tag-species-background;
|
||||
color: colors.$tag-species-text;
|
||||
}
|
||||
|
||||
&:is(:global([value=body-type])) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
}
|
||||
}
|
||||
&:is(:global([value=body-type])) {
|
||||
background-color: colors.$tag-body-type-background;
|
||||
color: colors.$tag-body-type-text;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<script>
|
||||
/** @type {string|undefined} */
|
||||
export let name = undefined;
|
||||
<script lang="ts">
|
||||
interface TextFieldProps {
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let placeholder = undefined;
|
||||
|
||||
/** @type {string} */
|
||||
export let value = '';
|
||||
let {
|
||||
name = undefined,
|
||||
placeholder = undefined,
|
||||
value = $bindable('')
|
||||
}: TextFieldProps = $props();
|
||||
</script>
|
||||
|
||||
<input type="text" {name} {placeholder} bind:value={value}>
|
||||
<input bind:value={value} {name} {placeholder} type="text">
|
||||
|
||||
<style lang="scss">
|
||||
:global(.control) input {
|
||||
width: 100%;
|
||||
}
|
||||
:global(.control) input {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,46 @@
|
||||
<script lang="ts">
|
||||
interface MenuProps {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: MenuProps = $props();
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
<slot></slot>
|
||||
{@render children?.()}
|
||||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > :global(.menu-item) {
|
||||
padding: 5px 24px;
|
||||
}
|
||||
|
||||
:global(.menu-item) {
|
||||
color: colors.$text;
|
||||
|
||||
&:hover {
|
||||
background: colors.$header-mobile-link-hover;
|
||||
}
|
||||
}
|
||||
|
||||
:global(hr) {
|
||||
background: colors.$block-border;
|
||||
margin: .5em 24px;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
:global(main) > & {
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
& > :global(.menu-item) {
|
||||
padding: 5px 24px;
|
||||
}
|
||||
|
||||
:global(.menu-item) {
|
||||
color: colors.$text;
|
||||
|
||||
&:hover {
|
||||
background: colors.$header-mobile-link-hover;
|
||||
}
|
||||
}
|
||||
|
||||
:global(hr) {
|
||||
background: colors.$block-border;
|
||||
margin: .5em 24px;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
:global(main) > & {
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,44 @@
|
||||
<script>
|
||||
import MenuLink from "$components/ui/menu/MenuItem.svelte";
|
||||
<script lang="ts">
|
||||
import MenuLink from "$components/ui/menu/MenuItem.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { FormEventHandler, MouseEventHandler } from "svelte/elements";
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
export let checked;
|
||||
interface MenuCheckboxItemProps {
|
||||
checked: boolean;
|
||||
name?: string;
|
||||
value?: string;
|
||||
href?: string;
|
||||
children?: Snippet;
|
||||
onclick?: MouseEventHandler<HTMLInputElement>;
|
||||
oninput?: FormEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {string|undefined}
|
||||
*/
|
||||
export let name = undefined;
|
||||
let {
|
||||
checked = $bindable(),
|
||||
name = undefined,
|
||||
value = undefined,
|
||||
href = undefined,
|
||||
children,
|
||||
onclick,
|
||||
oninput,
|
||||
}: MenuCheckboxItemProps = $props();
|
||||
|
||||
/**
|
||||
* @type {string|undefined}
|
||||
*/
|
||||
export let value = undefined;
|
||||
|
||||
/**
|
||||
* @type {string|null}
|
||||
*/
|
||||
export let href = null;
|
||||
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
|
||||
originalEvent.stopPropagation();
|
||||
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
|
||||
}
|
||||
</script>
|
||||
|
||||
<MenuLink {href}>
|
||||
<input type="checkbox" {name} {value} bind:checked={checked} on:input on:click|stopPropagation>
|
||||
<slot></slot>
|
||||
<input bind:checked={checked} {name} onclick={stopPropagationAndPassCallback} {oninput} type="checkbox" {value}>
|
||||
{@render children?.()}
|
||||
</MenuLink>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.menu-item) input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
:global(.menu-item) input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,45 @@
|
||||
<script>
|
||||
/**
|
||||
* @type {string|null}
|
||||
*/
|
||||
export let href = null;
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { MouseEventHandler } from "svelte/elements";
|
||||
|
||||
/**
|
||||
* @type {App.IconName|null}
|
||||
*/
|
||||
export let icon = null;
|
||||
interface MenuItemProps {
|
||||
href?: string | null;
|
||||
icon?: App.IconName | null;
|
||||
target?: App.LinkTarget | undefined;
|
||||
children?: Snippet;
|
||||
onclick?: MouseEventHandler<HTMLAnchorElement | HTMLSpanElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {App.LinkTarget|undefined}
|
||||
*/
|
||||
export let target = undefined;
|
||||
let {
|
||||
href = null,
|
||||
icon = null,
|
||||
target = undefined,
|
||||
children,
|
||||
onclick
|
||||
}: MenuItemProps = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element this="{href ? 'a': 'span'}" class="menu-item" {href} {target} on:click role="link" tabindex="0">
|
||||
{#if icon}
|
||||
<i class="icon icon-{icon}"></i>
|
||||
{/if}
|
||||
<slot></slot>
|
||||
<svelte:element class="menu-item" {href} {onclick} role="link" tabindex="0" {target} this="{href ? 'a': 'span'}">
|
||||
{#if icon}
|
||||
<i class="icon icon-{icon}"></i>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
.menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: colors.$text;
|
||||
margin-right: 6px;
|
||||
}
|
||||
i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: colors.$text;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,37 +1,44 @@
|
||||
<script>
|
||||
import MenuLink from "$components/ui/menu/MenuItem.svelte";
|
||||
<script lang="ts">
|
||||
import MenuLink from "$components/ui/menu/MenuItem.svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { FormEventHandler, MouseEventHandler } from "svelte/elements";
|
||||
|
||||
/**
|
||||
* @type {boolean}
|
||||
*/
|
||||
export let checked;
|
||||
interface MenuRadioItemProps {
|
||||
checked: boolean;
|
||||
name: string;
|
||||
value: string;
|
||||
href?: string | null;
|
||||
children?: Snippet;
|
||||
onclick?: MouseEventHandler<HTMLInputElement>;
|
||||
oninput?: FormEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
export let name;
|
||||
let {
|
||||
checked,
|
||||
name,
|
||||
value,
|
||||
href = null,
|
||||
children,
|
||||
onclick,
|
||||
oninput,
|
||||
}: MenuRadioItemProps = $props();
|
||||
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
export let value;
|
||||
|
||||
/**
|
||||
* @type {string|null}
|
||||
*/
|
||||
export let href = null;
|
||||
function stopPropagationAndPassCallback(originalEvent: MouseEvent) {
|
||||
originalEvent.stopPropagation();
|
||||
onclick?.(originalEvent as MouseEvent & { currentTarget: HTMLInputElement });
|
||||
}
|
||||
</script>
|
||||
|
||||
<MenuLink {href}>
|
||||
<input type="radio" {name} {value} {checked} on:input on:click|stopPropagation>
|
||||
<slot></slot>
|
||||
<input {checked} {name} onclick={stopPropagationAndPassCallback} {oninput} type="radio" {value}>
|
||||
{@render children?.()}
|
||||
</MenuLink>
|
||||
|
||||
<style lang="scss">
|
||||
:global(.menu-item) input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
:global(.menu-item) input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import PostParser from "$lib/booru/scraped/parsing/PostParser";
|
||||
|
||||
type UpdaterFunction = (tags: Set<string>) => Set<string>;
|
||||
|
||||
export default class ScrapedAPI {
|
||||
/**
|
||||
* Update the tags of the image using callback.
|
||||
* @param {number} imageId ID of the image.
|
||||
* @param {function(Set<string>): Set<string>} callback Callback to call to change the content.
|
||||
* @return {Promise<Map<string,string>|null>} Updated tags and aliases list for updating internal cached state.
|
||||
* @param imageId ID of the image.
|
||||
* @param callback Callback to call to change the content.
|
||||
* @return Updated tags and aliases list for updating internal cached state.
|
||||
*/
|
||||
async updateImageTags(imageId, callback) {
|
||||
async updateImageTags(imageId: number, callback: UpdaterFunction): Promise<Map<string, string> | null> {
|
||||
const postParser = new PostParser(imageId);
|
||||
const formData = await postParser.resolveTagEditorFormData();
|
||||
const tagsFieldValue = formData.get(PostParser.tagsInputName);
|
||||
|
||||
if (typeof tagsFieldValue !== 'string') {
|
||||
throw new Error('Missing tags field!');
|
||||
}
|
||||
|
||||
const tagsList = new Set(
|
||||
formData
|
||||
.get(PostParser.tagsInputName)
|
||||
tagsFieldValue
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim())
|
||||
);
|
||||
@@ -1,17 +1,12 @@
|
||||
export default class PageParser {
|
||||
/** @type {string} */
|
||||
#url;
|
||||
/** @type {DocumentFragment|null} */
|
||||
#fragment = null;
|
||||
readonly #url: string;
|
||||
#fragment: DocumentFragment | null = null;
|
||||
|
||||
constructor(url) {
|
||||
constructor(url: string) {
|
||||
this.#url = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<DocumentFragment>}
|
||||
*/
|
||||
async resolveFragment() {
|
||||
async resolveFragment(): Promise<DocumentFragment> {
|
||||
if (this.#fragment) {
|
||||
return this.#fragment;
|
||||
}
|
||||
@@ -34,12 +29,12 @@ export default class PageParser {
|
||||
/**
|
||||
* Create a document fragment from the following response.
|
||||
*
|
||||
* @param {Response} response Response to create a fragment from. Note, that this response will be used. If you need
|
||||
* to use the same response somewhere else, then you need to pass a cloned version of the response.
|
||||
* @param response Response to create a fragment from. Note, that this response will be used. If you need to use the
|
||||
* same response somewhere else, then you need to pass a cloned version of the response.
|
||||
*
|
||||
* @return {Promise<DocumentFragment>} Resulting document fragment ready for processing.
|
||||
* @return Resulting document fragment ready for processing.
|
||||
*/
|
||||
static async resolveFragmentFromResponse(response) {
|
||||
static async resolveFragmentFromResponse(response: Response): Promise<DocumentFragment> {
|
||||
const documentFragment = document.createDocumentFragment();
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = await response.text();
|
||||
@@ -2,23 +2,19 @@ import PageParser from "$lib/booru/scraped/parsing/PageParser";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
|
||||
export default class PostParser extends PageParser {
|
||||
/** @type {HTMLFormElement} */
|
||||
#tagEditorForm;
|
||||
#tagEditorForm: HTMLFormElement | null = null;
|
||||
|
||||
constructor(imageId) {
|
||||
constructor(imageId: number) {
|
||||
super(`/images/${imageId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<HTMLFormElement>}
|
||||
*/
|
||||
async resolveTagEditorForm() {
|
||||
async resolveTagEditorForm(): Promise<HTMLFormElement> {
|
||||
if (this.#tagEditorForm) {
|
||||
return this.#tagEditorForm;
|
||||
}
|
||||
|
||||
const documentFragment = await this.resolveFragment();
|
||||
const tagsFormElement = documentFragment.querySelector("#tags-form");
|
||||
const tagsFormElement = documentFragment.querySelector<HTMLFormElement>("#tags-form");
|
||||
|
||||
if (!tagsFormElement) {
|
||||
throw new Error("Failed to find the tag editor form");
|
||||
@@ -37,10 +33,8 @@ export default class PostParser extends PageParser {
|
||||
|
||||
/**
|
||||
* Resolve the tags and aliases mapping from the post page.
|
||||
*
|
||||
* @return {Promise<Map<string, string>|null>}
|
||||
*/
|
||||
async resolveTagsAndAliases() {
|
||||
async resolveTagsAndAliases(): Promise<Map<string, string> | null> {
|
||||
return PostParser.resolveTagsAndAliasesFromPost(
|
||||
await this.resolveFragment()
|
||||
);
|
||||
@@ -49,25 +43,32 @@ export default class PostParser extends PageParser {
|
||||
/**
|
||||
* Resolve the list of tags and aliases from the post content.
|
||||
*
|
||||
* @param {DocumentFragment} documentFragment Real content to parse the data from.
|
||||
* @param documentFragment Real content to parse the data from.
|
||||
*
|
||||
* @return {Map<string, string>|null} Tags and aliases or null if failed to parse.
|
||||
* @return Tags and aliases or null if failed to parse.
|
||||
*/
|
||||
static resolveTagsAndAliasesFromPost(documentFragment) {
|
||||
const imageShowContainer = documentFragment.querySelector('.image-show-container');
|
||||
const tagsForm = documentFragment.querySelector('#tags-form');
|
||||
static resolveTagsAndAliasesFromPost(documentFragment: DocumentFragment): Map<string, string> | null {
|
||||
const imageShowContainer = documentFragment.querySelector<HTMLElement>('.image-show-container');
|
||||
const tagsForm = documentFragment.querySelector<HTMLFormElement>('#tags-form');
|
||||
|
||||
if (!imageShowContainer || !tagsForm) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagsFormData = new FormData(tagsForm);
|
||||
const tagsAndAliasesValue = imageShowContainer.dataset.imageTagAliases;
|
||||
const tagsValue = tagsFormData.get(this.tagsInputName);
|
||||
|
||||
const tagsAndAliasesList = imageShowContainer.dataset.imageTagAliases
|
||||
if (!tagsAndAliasesValue || !tagsValue || typeof tagsValue !== 'string') {
|
||||
console.warn('Failed to locate tags & aliases!');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagsAndAliasesList = tagsAndAliasesValue
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim());
|
||||
|
||||
const actualTagsList = tagsFormData.get(this.tagsInputName)
|
||||
const actualTagsList = tagsValue
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim());
|
||||
|
||||
@@ -22,7 +22,7 @@ export default class StorageHelper {
|
||||
* @return The JSON object or the default value if the entry does not exist.
|
||||
*/
|
||||
async read<Type = any, DefaultType = any>(key: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
|
||||
return (await this.#storageArea.get(key))?.[key] || defaultValue;
|
||||
return (await this.#storageArea.get(key))?.[key] ?? defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
import { emit, on } from "$lib/components/events/comms";
|
||||
import { eventSizeLoaded } from "$lib/components/events/fullscreen-viewer-events";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
/** @type {HTMLVideoElement} */
|
||||
#videoElement = document.createElement('video');
|
||||
/** @type {HTMLImageElement} */
|
||||
#imageElement = document.createElement('img');
|
||||
#spinnerElement = document.createElement('i');
|
||||
#sizeSelectorElement = document.createElement('select');
|
||||
#closeButtonElement = document.createElement('i');
|
||||
/** @type {number|null} */
|
||||
#touchId = null;
|
||||
/** @type {number|null} */
|
||||
#startX = null;
|
||||
/** @type {number|null} */
|
||||
#startY = null;
|
||||
/** @type {boolean|null} */
|
||||
#isClosingSwipeStarted = null;
|
||||
#isSizeFetched = false;
|
||||
/** @type {App.ImageURIs|null} */
|
||||
#currentURIs = null;
|
||||
#videoElement: HTMLVideoElement = document.createElement('video');
|
||||
#imageElement: HTMLImageElement = document.createElement('img');
|
||||
#spinnerElement: HTMLElement = document.createElement('i');
|
||||
#sizeSelectorElement: HTMLSelectElement = document.createElement('select');
|
||||
#closeButtonElement: HTMLElement = document.createElement('i');
|
||||
#touchId: number | null = null;
|
||||
#startX: number | null = null;
|
||||
#startY: number | null = null;
|
||||
#isClosingSwipeStarted: boolean | null = null;
|
||||
#isSizeFetched: boolean = false;
|
||||
#currentURIs: App.ImageURIs | null = null;
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
protected build() {
|
||||
this.container.classList.add('fullscreen-viewer');
|
||||
|
||||
this.container.append(
|
||||
@@ -71,10 +63,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.container.classList.remove('loading');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#onTouchStart(event) {
|
||||
#onTouchStart(event: TouchEvent) {
|
||||
if (this.#touchId !== null) {
|
||||
return;
|
||||
}
|
||||
@@ -88,14 +77,12 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.#touchId = firstTouch.identifier;
|
||||
this.#startX = firstTouch.clientX;
|
||||
this.#startY = firstTouch.clientY;
|
||||
|
||||
this.container.classList.add(FullscreenViewer.#swipeState);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#onTouchEnd(event) {
|
||||
if (this.#touchId === null) {
|
||||
#onTouchEnd(event: TouchEvent) {
|
||||
if (this.#touchId === null || this.#startY === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,11 +113,8 @@ export class FullscreenViewer extends BaseComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TouchEvent} event
|
||||
*/
|
||||
#onTouchMove(event) {
|
||||
if (this.#touchId === null) {
|
||||
#onTouchMove(event: TouchEvent) {
|
||||
if (this.#touchId === null || this.#startY === null || this.#startX === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,23 +163,17 @@ export class FullscreenViewer extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onDocumentKeyPressed(event) {
|
||||
#onDocumentKeyPressed(event: KeyboardEvent) {
|
||||
if (event.code === 'Escape' || event.code === 'Esc') {
|
||||
this.#close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("$lib/extension/settings/MiscSettings").FullscreenViewerSize} size
|
||||
*/
|
||||
#onSizeResolved(size) {
|
||||
#onSizeResolved(size: FullscreenViewerSize) {
|
||||
this.#sizeSelectorElement.value = size;
|
||||
this.#isSizeFetched = true;
|
||||
|
||||
this.emit('size-loaded');
|
||||
emit(this.container, eventSizeLoaded, size);
|
||||
}
|
||||
|
||||
#watchForSizeSelectionChanges() {
|
||||
@@ -232,7 +210,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.#currentURIs = null;
|
||||
|
||||
this.container.classList.remove(FullscreenViewer.#shownState);
|
||||
document.body.style.overflow = null;
|
||||
document.body.style.removeProperty('overflow');
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.#videoElement.volume = 0;
|
||||
@@ -241,16 +219,18 @@ export class FullscreenViewer extends BaseComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {App.ImageURIs} imageUris
|
||||
* @return {Promise<string|null>}
|
||||
*/
|
||||
async #resolveCurrentSelectedSizeUrl(imageUris) {
|
||||
async #resolveCurrentSelectedSizeUrl(imageUris: App.ImageURIs): Promise<string | null> {
|
||||
if (!this.#isSizeFetched) {
|
||||
await new Promise(resolve => this.on('size-loaded', resolve))
|
||||
await new Promise(
|
||||
resolve => on(
|
||||
this.container,
|
||||
eventSizeLoaded,
|
||||
resolve
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
let targetSize = this.#sizeSelectorElement.value;
|
||||
let targetSize: FullscreenViewerSize | string = this.#sizeSelectorElement.value;
|
||||
|
||||
if (!imageUris.hasOwnProperty(targetSize)) {
|
||||
targetSize = FullscreenViewer.#fallbackSize;
|
||||
@@ -264,13 +244,10 @@ export class FullscreenViewer extends BaseComponent {
|
||||
return null;
|
||||
}
|
||||
|
||||
return imageUris[targetSize];
|
||||
return imageUris[targetSize as FullscreenViewerSize];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {App.ImageURIs} imageUris
|
||||
*/
|
||||
async show(imageUris) {
|
||||
async show(imageUris: App.ImageURIs): Promise<void> {
|
||||
this.#currentURIs = imageUris;
|
||||
|
||||
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
|
||||
@@ -308,11 +285,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.container.append(this.#imageElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @return {boolean}
|
||||
*/
|
||||
static #isVideoUrl(url) {
|
||||
static #isVideoUrl(url: string): boolean {
|
||||
return url.endsWith('.mp4') || url.endsWith('.webm');
|
||||
}
|
||||
|
||||
@@ -324,10 +297,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
static #swipeState = 'swiped';
|
||||
static #minRequiredDistance = 50;
|
||||
|
||||
/**
|
||||
* @type {Record<import("$lib/extension/settings/MiscSettings").FullscreenViewerSize, string>}
|
||||
*/
|
||||
static #previewSizes = {
|
||||
static #previewSizes: Record<FullscreenViewerSize, string> = {
|
||||
full: 'Full',
|
||||
large: 'Large',
|
||||
medium: 'Medium',
|
||||
@@ -2,21 +2,23 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
import { FullscreenViewer } from "$lib/components/FullscreenViewer";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
|
||||
export class ImageShowFullscreenButton extends BaseComponent {
|
||||
/**
|
||||
* @type {import('./MediaBoxTools').MediaBoxTools|null}
|
||||
*/
|
||||
#mediaBoxTools= null;
|
||||
#isFullscreenButtonEnabled = false;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#isFullscreenButtonEnabled: boolean = false;
|
||||
|
||||
build() {
|
||||
protected build() {
|
||||
this.container.innerText = '🔍';
|
||||
|
||||
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
|
||||
}
|
||||
|
||||
init() {
|
||||
protected init() {
|
||||
if (!this.container.parentElement) {
|
||||
throw new Error('Missing parent element!');
|
||||
}
|
||||
|
||||
this.#mediaBoxTools = getComponent(this.container.parentElement);
|
||||
|
||||
if (!this.#mediaBoxTools) {
|
||||
@@ -32,7 +34,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
.then(() => {
|
||||
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
|
||||
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
@@ -45,28 +47,25 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
}
|
||||
|
||||
#onButtonClicked() {
|
||||
const imageLinks = this.#mediaBoxTools?.mediaBox.imageLinks;
|
||||
|
||||
if (!imageLinks) {
|
||||
throw new Error('Failed to resolve image links from media box tools!');
|
||||
}
|
||||
|
||||
ImageShowFullscreenButton
|
||||
.#resolveViewer()
|
||||
.show(this.#mediaBoxTools.mediaBox.imageLinks);
|
||||
?.show(imageLinks);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {FullscreenViewer|null}
|
||||
*/
|
||||
static #viewer = null;
|
||||
static #viewer: FullscreenViewer | null = null;
|
||||
|
||||
/**
|
||||
* @return {FullscreenViewer}
|
||||
*/
|
||||
static #resolveViewer() {
|
||||
static #resolveViewer(): FullscreenViewer {
|
||||
this.#viewer ??= this.#buildViewer();
|
||||
return this.#viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {FullscreenViewer}
|
||||
*/
|
||||
static #buildViewer() {
|
||||
static #buildViewer(): FullscreenViewer {
|
||||
const element = document.createElement('div');
|
||||
const viewer = new FullscreenViewer(element);
|
||||
|
||||
@@ -77,10 +76,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
return viewer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {MiscSettings|null}
|
||||
*/
|
||||
static #miscSettings = null;
|
||||
static #miscSettings: MiscSettings | null = null;
|
||||
}
|
||||
|
||||
export function createImageShowFullscreenButton() {
|
||||
@@ -10,47 +10,27 @@ import {
|
||||
eventMaintenanceStateChanged,
|
||||
eventTagsUpdated
|
||||
} from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
|
||||
class BlackListedTagsEncounteredError extends Error {
|
||||
/**
|
||||
* @param {string} tagName
|
||||
*/
|
||||
constructor(tagName) {
|
||||
super(`This tag is blacklisted and prevents submission: ${tagName}`);
|
||||
constructor(tagName: string) {
|
||||
super(`This tag is blacklisted and prevents submission: ${tagName}`, {
|
||||
cause: tagName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class MaintenancePopup extends BaseComponent {
|
||||
/** @type {HTMLElement} */
|
||||
#tagsListElement = null;
|
||||
|
||||
/** @type {HTMLElement[]} */
|
||||
#tagsList = [];
|
||||
|
||||
/** @type {Map<string, HTMLElement>} */
|
||||
#suggestedInvalidTags = new Map();
|
||||
|
||||
/** @type {MaintenanceProfile|null} */
|
||||
#activeProfile = null;
|
||||
|
||||
/** @type {import('$lib/components/MediaBoxTools').MediaBoxTools} */
|
||||
#mediaBoxTools = null;
|
||||
|
||||
/** @type {Set<string>} */
|
||||
#tagsToRemove = new Set();
|
||||
|
||||
/** @type {Set<string>} */
|
||||
#tagsToAdd = new Set();
|
||||
|
||||
/** @type {boolean} */
|
||||
#isPlanningToSubmit = false;
|
||||
|
||||
/** @type {boolean} */
|
||||
#isSubmitting = false;
|
||||
|
||||
/** @type {number|null} */
|
||||
#tagsSubmissionTimer = null;
|
||||
|
||||
#tagsListElement: HTMLElement = document.createElement('div');
|
||||
#tagsList: HTMLElement[] = [];
|
||||
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
|
||||
#activeProfile: MaintenanceProfile | null = null;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#tagsToRemove: Set<string> = new Set();
|
||||
#tagsToAdd: Set<string> = new Set();
|
||||
#isPlanningToSubmit: boolean = false;
|
||||
#isSubmitting: boolean = false;
|
||||
#tagsSubmissionTimer: number | null = null;
|
||||
#emitter = emitterAt(this);
|
||||
|
||||
/**
|
||||
@@ -60,7 +40,6 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.container.innerHTML = '';
|
||||
this.container.classList.add('maintenance-popup');
|
||||
|
||||
this.#tagsListElement = document.createElement('div');
|
||||
this.#tagsListElement.classList.add('tags-list');
|
||||
|
||||
this.container.append(
|
||||
@@ -72,14 +51,13 @@ export class MaintenancePopup extends BaseComponent {
|
||||
* @protected
|
||||
*/
|
||||
init() {
|
||||
const mediaBoxToolsElement = this.container.closest('.media-box-tools');
|
||||
const mediaBoxToolsElement = this.container.closest<HTMLElement>('.media-box-tools');
|
||||
|
||||
if (!mediaBoxToolsElement) {
|
||||
throw new Error('Maintenance popup initialized outside of the media box tools!');
|
||||
}
|
||||
|
||||
/** @type {MediaBoxTools|null} */
|
||||
const mediaBoxTools = getComponent(mediaBoxToolsElement);
|
||||
const mediaBoxTools = getComponent<MediaBoxTools>(mediaBoxToolsElement);
|
||||
|
||||
if (!mediaBoxTools) {
|
||||
throw new Error('Media box tools component not found!');
|
||||
@@ -96,10 +74,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MaintenanceProfile|null} activeProfile
|
||||
*/
|
||||
#onActiveProfileChanged(activeProfile) {
|
||||
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
|
||||
this.#activeProfile = activeProfile;
|
||||
this.container.classList.toggle('is-active', activeProfile !== null);
|
||||
this.#refreshTagsList();
|
||||
@@ -108,8 +83,11 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
#refreshTagsList() {
|
||||
/** @type {string[]} */
|
||||
const activeProfileTagsList = this.#activeProfile?.settings.tags || [];
|
||||
if (!this.#mediaBoxTools) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || [];
|
||||
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.remove();
|
||||
@@ -147,17 +125,22 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Detect and process clicks made directly to the tags.
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
#handleTagClick(event) {
|
||||
/** @type {HTMLElement} */
|
||||
let tagElement = event.target;
|
||||
#handleTagClick(event: MouseEvent) {
|
||||
const targetObject = event.target;
|
||||
|
||||
if (!tagElement.classList.contains('tag')) {
|
||||
tagElement = tagElement.closest('.tag');
|
||||
|
||||
if (!targetObject || !(targetObject instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tagElement) {
|
||||
let tagElement: HTMLElement | null = targetObject;
|
||||
|
||||
if (!tagElement.classList.contains('tag')) {
|
||||
tagElement = tagElement.closest<HTMLElement>('.tag');
|
||||
}
|
||||
|
||||
if (!tagElement?.dataset.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -210,7 +193,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
async #onSubmissionTimerPassed() {
|
||||
if (!this.#isPlanningToSubmit || this.#isSubmitting) {
|
||||
if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -281,6 +264,10 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
#revealInvalidTags() {
|
||||
if (!this.#mediaBoxTools) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
|
||||
|
||||
if (!tagsAndAliases) {
|
||||
@@ -310,18 +297,11 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
get isActive() {
|
||||
return this.container.classList.contains('is-active');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} tagName
|
||||
* @return {HTMLElement}
|
||||
*/
|
||||
static #buildTagElement(tagName) {
|
||||
static #buildTagElement(tagName: string): HTMLElement {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.classList.add('tag');
|
||||
tagElement.innerText = tagName;
|
||||
@@ -332,28 +312,26 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Marks the tag with red color.
|
||||
* @param {HTMLElement} tagElement Element to mark.
|
||||
* @param tagElement Element to mark.
|
||||
*/
|
||||
static #markTagAsInvalid(tagElement) {
|
||||
static #markTagAsInvalid(tagElement: HTMLElement) {
|
||||
tagElement.dataset.tagCategory = 'error';
|
||||
tagElement.setAttribute('data-tag-category', 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller with maintenance settings.
|
||||
* @type {MaintenanceSettings}
|
||||
*/
|
||||
static #maintenanceSettings = new MaintenanceSettings();
|
||||
|
||||
/**
|
||||
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
|
||||
* at the very start to retrieve the currently active profile.
|
||||
* @param {function(MaintenanceProfile|null):void} callback Callback to execute whenever selection of active profile
|
||||
* or profile itself has been changed.
|
||||
* @return {function(): void} Unsubscribe function. Call it to stop watching for changes.
|
||||
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
|
||||
* @return Unsubscribe function. Call it to stop watching for changes.
|
||||
*/
|
||||
static #watchActiveProfile(callback) {
|
||||
let lastActiveProfileId;
|
||||
static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void {
|
||||
let lastActiveProfileId: string | null | undefined = null;
|
||||
|
||||
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
|
||||
if (lastActiveProfileId) {
|
||||
@@ -393,9 +371,9 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Notify the frontend about new pending submission started.
|
||||
* @param {boolean} isStarted True if started, false if ended.
|
||||
* @param isStarted True if started, false if ended.
|
||||
*/
|
||||
static #notifyAboutPendingSubmission(isStarted) {
|
||||
static #notifyAboutPendingSubmission(isStarted: boolean) {
|
||||
if (this.#pendingSubmissionCount === null) {
|
||||
this.#pendingSubmissionCount = 0;
|
||||
this.#initializeExitPromptHandler();
|
||||
@@ -424,9 +402,8 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Amount of pending submissions or NULL if logic was not yet initialized.
|
||||
* @type {number|null}
|
||||
*/
|
||||
static #pendingSubmissionCount = null;
|
||||
static #pendingSubmissionCount: number|null = null;
|
||||
}
|
||||
|
||||
export function createMaintenancePopup() {
|
||||
@@ -2,16 +2,20 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventMaintenanceStateChanged } from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
|
||||
export class MaintenanceStatusIcon extends BaseComponent {
|
||||
/** @type {import('./MediaBoxTools').MediaBoxTools} */
|
||||
#mediaBoxTools;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
|
||||
build() {
|
||||
this.container.innerText = '🔧';
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.container.parentElement) {
|
||||
throw new Error('Missing parent element for the maintenance status icon!');
|
||||
}
|
||||
|
||||
this.#mediaBoxTools = getComponent(this.container.parentElement);
|
||||
|
||||
if (!this.#mediaBoxTools) {
|
||||
@@ -21,10 +25,7 @@ export class MaintenanceStatusIcon extends BaseComponent {
|
||||
on(this.#mediaBoxTools, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<string>} stateChangeEvent
|
||||
*/
|
||||
#onMaintenanceStateChanged(stateChangeEvent) {
|
||||
#onMaintenanceStateChanged(stateChangeEvent: CustomEvent<string>) {
|
||||
// TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself.
|
||||
switch (stateChangeEvent.detail) {
|
||||
case "ready":
|
||||
@@ -3,16 +3,15 @@ import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventActiveProfileChanged } from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
export class MediaBoxTools extends BaseComponent {
|
||||
/** @type {import('./MediaBoxWrapper').MediaBoxWrapper|null} */
|
||||
#mediaBox;
|
||||
|
||||
/** @type {MaintenancePopup|null} */
|
||||
#maintenancePopup = null;
|
||||
#mediaBox: MediaBoxWrapper | null = null;
|
||||
#maintenancePopup: MaintenancePopup | null = null;
|
||||
|
||||
init() {
|
||||
const mediaBoxElement = this.container.closest('.media-box');
|
||||
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
|
||||
|
||||
if (!mediaBoxElement) {
|
||||
throw new Error('Toolbox element initialized outside of the media box!');
|
||||
@@ -21,6 +20,10 @@ export class MediaBoxTools extends BaseComponent {
|
||||
this.#mediaBox = getComponent(mediaBoxElement);
|
||||
|
||||
for (let childElement of this.container.children) {
|
||||
if (!(childElement instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const component = getComponent(childElement);
|
||||
|
||||
if (!component) {
|
||||
@@ -39,34 +42,25 @@ export class MediaBoxTools extends BaseComponent {
|
||||
on(this, eventActiveProfileChanged, this.#onActiveProfileChanged.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<import('$entities/MaintenanceProfile').default|null>} profileChangedEvent
|
||||
*/
|
||||
#onActiveProfileChanged(profileChangedEvent) {
|
||||
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
|
||||
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {MaintenancePopup|null}
|
||||
*/
|
||||
get maintenancePopup() {
|
||||
get maintenancePopup(): MaintenancePopup | null {
|
||||
return this.#maintenancePopup;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {import('./MediaBoxWrapper').MediaBoxWrapper|null}
|
||||
*/
|
||||
get mediaBox() {
|
||||
get mediaBox(): MediaBoxWrapper | null {
|
||||
return this.#mediaBox;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a maintenance popup element.
|
||||
* @param {HTMLElement[]} childrenElements List of children elements to append to the component.
|
||||
* @return {HTMLElement} The maintenance popup element.
|
||||
* @param childrenElements List of children elements to append to the component.
|
||||
* @return The maintenance popup element.
|
||||
*/
|
||||
export function createMediaBoxTools(...childrenElements) {
|
||||
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
|
||||
const mediaBoxToolsContainer = document.createElement('div');
|
||||
mediaBoxToolsContainer.classList.add('media-box-tools');
|
||||
|
||||
@@ -5,23 +5,18 @@ import { on } from "$lib/components/events/comms";
|
||||
import { eventTagsUpdated } from "$lib/components/events/maintenance-popup-events";
|
||||
|
||||
export class MediaBoxWrapper extends BaseComponent {
|
||||
#thumbnailContainer = null;
|
||||
#imageLinkElement = null;
|
||||
|
||||
/** @type {Map<string,string>|null} */
|
||||
#tagsAndAliases = null;
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
#imageLinkElement: HTMLAnchorElement | null = null;
|
||||
#tagsAndAliases: Map<string, string> | null = null;
|
||||
|
||||
init() {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer.querySelector('a');
|
||||
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
|
||||
|
||||
on(this, eventTagsUpdated, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<Map<string,string>|null>} tagsUpdatedEvent
|
||||
*/
|
||||
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent) {
|
||||
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
|
||||
const updatedMap = tagsUpdatedEvent.detail;
|
||||
|
||||
if (!(updatedMap instanceof Map)) {
|
||||
@@ -32,18 +27,13 @@ export class MediaBoxWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
#calculateMediaBoxTags() {
|
||||
/** @type {string[]|string[]} */
|
||||
const
|
||||
tagAliases = this.#thumbnailContainer.dataset.imageTagAliases?.split(', ') || [],
|
||||
actualTags = this.#imageLinkElement.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
|
||||
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
|
||||
return buildTagsAndAliasesMap(tagAliases, actualTags);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Map<string, string>|null}
|
||||
*/
|
||||
get tagsAndAliases() {
|
||||
get tagsAndAliases(): Map<string, string> | null {
|
||||
if (!this.#tagsAndAliases) {
|
||||
this.#tagsAndAliases = this.#calculateMediaBoxTags();
|
||||
}
|
||||
@@ -51,26 +41,31 @@ export class MediaBoxWrapper extends BaseComponent {
|
||||
return this.#tagsAndAliases;
|
||||
}
|
||||
|
||||
get imageId() {
|
||||
return parseInt(
|
||||
this.container.dataset.imageId
|
||||
);
|
||||
get imageId(): number {
|
||||
const imageId = this.container.dataset.imageId;
|
||||
|
||||
if (!imageId) {
|
||||
throw new Error('Missing image ID');
|
||||
}
|
||||
|
||||
return parseInt(imageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {App.ImageURIs}
|
||||
*/
|
||||
get imageLinks() {
|
||||
return JSON.parse(this.#thumbnailContainer.dataset.uris);
|
||||
get imageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.#thumbnailContainer?.dataset.uris;
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the media box element into the special wrapper.
|
||||
* @param {HTMLElement} mediaBoxContainer
|
||||
* @param {HTMLElement[]} childComponentElements
|
||||
*/
|
||||
export function initializeMediaBox(mediaBoxContainer, childComponentElements) {
|
||||
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
|
||||
new MediaBoxWrapper(mediaBoxContainer)
|
||||
.initialize();
|
||||
|
||||
@@ -80,17 +75,12 @@ export function initializeMediaBox(mediaBoxContainer, childComponentElements) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {NodeListOf<HTMLElement>} mediaBoxesList
|
||||
*/
|
||||
export function calculateMediaBoxesPositions(mediaBoxesList) {
|
||||
export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElement>) {
|
||||
window.addEventListener('resize', () => {
|
||||
/** @type {HTMLElement|null} */
|
||||
let lastMediaBox = null,
|
||||
/** @type {number|null} */
|
||||
lastMediaBoxPosition = null;
|
||||
let lastMediaBox: HTMLElement | null = null;
|
||||
let lastMediaBoxPosition: number | null = null;
|
||||
|
||||
for (let mediaBoxElement of mediaBoxesList) {
|
||||
for (const mediaBoxElement of mediaBoxesList) {
|
||||
const yPosition = mediaBoxElement.getBoundingClientRect().y;
|
||||
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer";
|
||||
import SearchSettings from "$lib/extension/settings/SearchSettings";
|
||||
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
|
||||
|
||||
export class SearchWrapper extends BaseComponent {
|
||||
/** @type {HTMLInputElement|null} */
|
||||
#searchField = null;
|
||||
/** @type {string|null} */
|
||||
#lastParsedSearchValue = null;
|
||||
/** @type {Token[]} */
|
||||
#cachedParsedQuery = [];
|
||||
#searchSettings = new SearchSettings();
|
||||
#arePropertiesSuggestionsEnabled = false;
|
||||
/** @type {"start"|"end"} */
|
||||
#propertiesSuggestionsPosition = "start";
|
||||
/** @type {HTMLElement|null} */
|
||||
#cachedAutocompleteContainer = null;
|
||||
/** @type {TermToken|QuotedTermToken|null} */
|
||||
#lastTermToken = null;
|
||||
#searchField: HTMLInputElement | null = null;
|
||||
#lastParsedSearchValue: string | null = null;
|
||||
#cachedParsedQuery: Token[] = [];
|
||||
#searchSettings: SearchSettings = new SearchSettings();
|
||||
#arePropertiesSuggestionsEnabled: boolean = false;
|
||||
#propertiesSuggestionsPosition: SuggestionsPosition = "start";
|
||||
#cachedAutocompleteContainer: HTMLElement | null = null;
|
||||
#lastTermToken: TermToken | QuotedTermToken | null = null;
|
||||
|
||||
build() {
|
||||
this.#searchField = this.container.querySelector('input[name=q]');
|
||||
}
|
||||
|
||||
init() {
|
||||
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this));
|
||||
if (this.#searchField) {
|
||||
this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this))
|
||||
}
|
||||
|
||||
this.#searchSettings.resolvePropertiesSuggestionsEnabled()
|
||||
.then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled);
|
||||
@@ -31,18 +27,18 @@ export class SearchWrapper extends BaseComponent {
|
||||
.then(position => this.#propertiesSuggestionsPosition = position);
|
||||
|
||||
this.#searchSettings.subscribe(settings => {
|
||||
this.#arePropertiesSuggestionsEnabled = settings.suggestProperties;
|
||||
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition;
|
||||
this.#arePropertiesSuggestionsEnabled = Boolean(settings.suggestProperties);
|
||||
this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition || "start";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch the user input and execute suggestions logic.
|
||||
* @param {InputEvent} event Source event to find the input element from.
|
||||
* @param event Source event to find the input element from.
|
||||
*/
|
||||
#onInputFindProperties(event) {
|
||||
#onInputFindProperties(event: Event) {
|
||||
// Ignore events until option is enabled.
|
||||
if (!this.#arePropertiesSuggestionsEnabled) {
|
||||
if (!this.#arePropertiesSuggestionsEnabled || !(event.currentTarget instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,20 +56,26 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Get the selection position in the search field.
|
||||
* @return {number}
|
||||
*/
|
||||
#getInputUserSelection() {
|
||||
#getInputUserSelection(): number {
|
||||
if (!this.#searchField) {
|
||||
throw new Error('Missing search field!');
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
this.#searchField.selectionStart,
|
||||
this.#searchField.selectionEnd
|
||||
this.#searchField.selectionStart ?? 0,
|
||||
this.#searchField.selectionEnd ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the search query and return the list of parsed tokens. Result will be cached for current search query.
|
||||
* @return {Token[]}
|
||||
*/
|
||||
#resolveQueryTokens() {
|
||||
#resolveQueryTokens(): Token[] {
|
||||
if (!this.#searchField) {
|
||||
throw new Error('Missing search field!');
|
||||
}
|
||||
|
||||
const searchValue = this.#searchField.value;
|
||||
|
||||
if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) {
|
||||
@@ -88,9 +90,9 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Find the currently selected term.
|
||||
* @return {string|null} Selected term or null if none found.
|
||||
* @return Selected term or null if none found.
|
||||
*/
|
||||
#findCurrentTagFragment() {
|
||||
#findCurrentTagFragment(): string | null {
|
||||
if (!this.#searchField) {
|
||||
return null;
|
||||
}
|
||||
@@ -127,9 +129,9 @@ export class SearchWrapper extends BaseComponent {
|
||||
*
|
||||
* This means, that properties will only be suggested once actual autocomplete logic was activated.
|
||||
*
|
||||
* @return {HTMLElement|null} Resolved element or nothing.
|
||||
* @return Resolved element or nothing.
|
||||
*/
|
||||
#resolveAutocompleteContainer() {
|
||||
#resolveAutocompleteContainer(): HTMLElement | null {
|
||||
if (this.#cachedAutocompleteContainer) {
|
||||
return this.#cachedAutocompleteContainer;
|
||||
}
|
||||
@@ -141,11 +143,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Render the list of suggestions into the existing popup or create and populate a new one.
|
||||
* @param {string[]} suggestions List of suggestion to render the popup from.
|
||||
* @param {HTMLInputElement} targetInput Target input to attach the popup to.
|
||||
* @param suggestions List of suggestion to render the popup from.
|
||||
* @param targetInput Target input to attach the popup to.
|
||||
*/
|
||||
#renderSuggestions(suggestions, targetInput) {
|
||||
/** @type {HTMLElement[]} */
|
||||
#renderSuggestions(suggestions: string[], targetInput: HTMLInputElement) {
|
||||
const suggestedListItems = suggestions
|
||||
.map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm));
|
||||
|
||||
@@ -170,6 +171,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
const listContainer = autocompleteContainer.querySelector('ul');
|
||||
|
||||
if (!listContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (this.#propertiesSuggestionsPosition) {
|
||||
case "start":
|
||||
listContainer.prepend(...suggestedListItems);
|
||||
@@ -183,10 +188,11 @@ export class SearchWrapper extends BaseComponent {
|
||||
console.warn("Invalid position for property suggestions!");
|
||||
}
|
||||
|
||||
const parentScrollTop = targetInput.parentElement?.scrollTop ?? 0;
|
||||
|
||||
autocompleteContainer.style.position = 'absolute';
|
||||
autocompleteContainer.style.left = `${targetInput.offsetLeft}px`;
|
||||
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - targetInput.parentElement.scrollTop}px`;
|
||||
autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - parentScrollTop}px`;
|
||||
|
||||
document.body.append(autocompleteContainer);
|
||||
})
|
||||
@@ -194,30 +200,28 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Loosely estimate where current selected search term is located and return it if found.
|
||||
* @param {Token[]} tokens Search value to find the actively selected term from.
|
||||
* @param {number} userSelectionIndex The index of the user selection.
|
||||
* @return {Token|null} Search term object or NULL if nothing found.
|
||||
* @param tokens Search value to find the actively selected term from.
|
||||
* @param userSelectionIndex The index of the user selection.
|
||||
* @return Search term object or NULL if nothing found.
|
||||
*/
|
||||
static #findActiveSearchTermPosition(tokens, userSelectionIndex) {
|
||||
static #findActiveSearchTermPosition(tokens: Token[], userSelectionIndex: number): Token | null {
|
||||
return tokens.find(
|
||||
token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex
|
||||
);
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regular expression to search the properties' syntax.
|
||||
* @type {RegExp}
|
||||
*/
|
||||
static #propertySearchTermHeadingRegExp = /^(?<name>[a-z\d_]+)(?<op_syntax>\.(?<op>[a-z]*))?(?<value_syntax>:(?<value>.*))?$/;
|
||||
|
||||
/**
|
||||
* Create a list of suggested elements using the input received from the user.
|
||||
* @param {string} searchTermValue Original decoded term received from the user.
|
||||
* @param searchTermValue Original decoded term received from the user.
|
||||
* @return {string[]} List of suggestions. Could be empty.
|
||||
*/
|
||||
static #resolveSuggestionsFromTerm(searchTermValue) {
|
||||
/** @type {string[]} */
|
||||
const suggestionsList = [];
|
||||
static #resolveSuggestionsFromTerm(searchTermValue: string): string[] {
|
||||
const suggestionsList: string[] = [];
|
||||
|
||||
this.#propertySearchTermHeadingRegExp.lastIndex = 0;
|
||||
const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue);
|
||||
@@ -226,22 +230,28 @@ export class SearchWrapper extends BaseComponent {
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
const propertyName = parsedResult.groups.name;
|
||||
const propertyName = parsedResult.groups?.name;
|
||||
|
||||
if (!propertyName) {
|
||||
return suggestionsList;
|
||||
}
|
||||
|
||||
const propertyType = this.#properties.get(propertyName);
|
||||
const hasOperatorSyntax = Boolean(parsedResult.groups.op_syntax);
|
||||
const hasValueSyntax = Boolean(parsedResult.groups.value_syntax);
|
||||
const hasOperatorSyntax = Boolean(parsedResult.groups?.op_syntax);
|
||||
const hasValueSyntax = Boolean(parsedResult.groups?.value_syntax);
|
||||
|
||||
// No suggestions for values for now, maybe could add suggestions for namespaces like my:*
|
||||
if (hasValueSyntax) {
|
||||
if (hasValueSyntax && propertyType) {
|
||||
if (this.#typeValues.has(propertyType)) {
|
||||
const givenValue = parsedResult.groups.value;
|
||||
const givenValue = parsedResult.groups?.value;
|
||||
const candidateValues = this.#typeValues.get(propertyType) || [];
|
||||
|
||||
for (let candidateValue of this.#typeValues.get(propertyType)) {
|
||||
for (let candidateValue of candidateValues) {
|
||||
if (givenValue && !candidateValue.startsWith(givenValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestionsList.push(`${propertyName}${parsedResult.groups.op_syntax ?? ''}:${candidateValue}`);
|
||||
suggestionsList.push(`${propertyName}${parsedResult.groups?.op_syntax ?? ''}:${candidateValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,11 +259,12 @@ export class SearchWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
// If at least one dot placed, start suggesting operators
|
||||
if (hasOperatorSyntax) {
|
||||
if (hasOperatorSyntax && propertyType) {
|
||||
if (this.#typeOperators.has(propertyType)) {
|
||||
const operatorName = parsedResult.groups.op;
|
||||
const operatorName = parsedResult.groups?.op;
|
||||
const candidateOperators = this.#typeOperators.get(propertyType) ?? [];
|
||||
|
||||
for (let candidateOperator of this.#typeOperators.get(propertyType)) {
|
||||
for (let candidateOperator of candidateOperators) {
|
||||
if (operatorName && !candidateOperator.startsWith(operatorName)) {
|
||||
continue;
|
||||
}
|
||||
@@ -279,11 +290,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Render a single suggestion item and connect required events to interact with the user.
|
||||
* @param {string} suggestedTerm Term to use for suggestion item.
|
||||
* @return {HTMLElement} Resulting element.
|
||||
* @param suggestedTerm Term to use for suggestion item.
|
||||
* @return Resulting element.
|
||||
*/
|
||||
#renderTermSuggestion(suggestedTerm) {
|
||||
/** @type {HTMLElement} */
|
||||
#renderTermSuggestion(suggestedTerm: string): HTMLElement {
|
||||
const suggestionItem = document.createElement('li');
|
||||
suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property');
|
||||
suggestionItem.dataset.value = suggestedTerm;
|
||||
@@ -311,10 +321,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Automatically replace the last active token stored in the variable with the new value.
|
||||
* @param {string} suggestedTerm Term to replace the value with.
|
||||
* @param suggestedTerm Term to replace the value with.
|
||||
*/
|
||||
#replaceLastActiveTokenWithSuggestion(suggestedTerm) {
|
||||
if (!this.#lastTermToken) {
|
||||
#replaceLastActiveTokenWithSuggestion(suggestedTerm: string) {
|
||||
if (!this.#lastTermToken || !this.#searchField) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -334,10 +344,10 @@ export class SearchWrapper extends BaseComponent {
|
||||
/**
|
||||
* Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's
|
||||
* front-end.
|
||||
* @param {HTMLElement} suggestedElement Target element to search from. If element is disconnected from the DOM,
|
||||
* search will be halted.
|
||||
* @param suggestedElement Target element to search from. If element is disconnected from the DOM, search will be
|
||||
* halted.
|
||||
*/
|
||||
static #findAndResetSelectedSuggestion(suggestedElement) {
|
||||
static #findAndResetSelectedSuggestion(suggestedElement: HTMLElement) {
|
||||
if (!suggestedElement.parentElement) {
|
||||
return;
|
||||
}
|
||||
@@ -2,11 +2,10 @@ import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { SearchWrapper } from "$lib/components/SearchWrapper";
|
||||
|
||||
class SiteHeaderWrapper extends BaseComponent {
|
||||
/** @type {SearchWrapper|null} */
|
||||
#searchWrapper = null;
|
||||
#searchWrapper: SearchWrapper | null = null;
|
||||
|
||||
build() {
|
||||
const searchForm = this.container.querySelector('.header__search');
|
||||
const searchForm = this.container.querySelector<HTMLElement>('.header__search');
|
||||
this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null;
|
||||
}
|
||||
|
||||
@@ -17,7 +16,7 @@ class SiteHeaderWrapper extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeSiteHeader(siteHeaderElement) {
|
||||
export function initializeSiteHeader(siteHeaderElement: HTMLElement) {
|
||||
new SiteHeaderWrapper(siteHeaderElement)
|
||||
.initialize();
|
||||
}
|
||||
@@ -3,45 +3,38 @@ import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
|
||||
|
||||
const isTagEditorProcessedKey = Symbol();
|
||||
const categoriesResolver = new CustomCategoriesResolver();
|
||||
|
||||
export class TagDropdownWrapper extends BaseComponent {
|
||||
/**
|
||||
* Container with dropdown elements to insert options into.
|
||||
* @type {HTMLElement}
|
||||
*/
|
||||
#dropdownContainer;
|
||||
#dropdownContainer: HTMLElement | null = null;
|
||||
|
||||
/**
|
||||
* Button to add or remove the current tag into/from the active profile.
|
||||
* @type {HTMLAnchorElement|null}
|
||||
*/
|
||||
#toggleOnExistingButton = null;
|
||||
#toggleOnExistingButton: HTMLAnchorElement | null = null;
|
||||
|
||||
/**
|
||||
* Button to create a new profile, make it active and add the current tag into the active profile.
|
||||
* @type {HTMLAnchorElement|null}
|
||||
*/
|
||||
#addToNewButton = null;
|
||||
#addToNewButton: HTMLAnchorElement | null = null;
|
||||
|
||||
/**
|
||||
* Local clone of the currently active profile used for updating the list of tags.
|
||||
* @type {MaintenanceProfile|null}
|
||||
*/
|
||||
#activeProfile = null;
|
||||
#activeProfile: MaintenanceProfile | null = null;
|
||||
|
||||
/**
|
||||
* Is cursor currently entered the dropdown.
|
||||
* @type {boolean}
|
||||
*/
|
||||
#isEntered = false;
|
||||
#isEntered: boolean = false;
|
||||
|
||||
/**
|
||||
* @type {string|undefined|null}
|
||||
*/
|
||||
#originalCategory = null;
|
||||
#originalCategory: string | undefined | null = null;
|
||||
|
||||
build() {
|
||||
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
|
||||
@@ -116,7 +109,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
);
|
||||
|
||||
if (!this.#addToNewButton.isConnected) {
|
||||
this.#dropdownContainer.append(this.#addToNewButton);
|
||||
this.#dropdownContainer?.append(this.#addToNewButton);
|
||||
}
|
||||
} else {
|
||||
this.#addToNewButton?.remove();
|
||||
@@ -130,15 +123,16 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
|
||||
const profileName = this.#activeProfile.settings.name;
|
||||
let profileSpecificButtonText = `Add to profile "${profileName}"`;
|
||||
const tagName = this.tagName;
|
||||
|
||||
if (this.#activeProfile.settings.tags.includes(this.tagName)) {
|
||||
if (tagName && this.#activeProfile.settings.tags.includes(tagName)) {
|
||||
profileSpecificButtonText = `Remove from profile "${profileName}"`;
|
||||
}
|
||||
|
||||
this.#toggleOnExistingButton.innerText = profileSpecificButtonText;
|
||||
|
||||
if (!this.#toggleOnExistingButton.isConnected) {
|
||||
this.#dropdownContainer.append(this.#toggleOnExistingButton);
|
||||
this.#dropdownContainer?.append(this.#toggleOnExistingButton);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -148,6 +142,12 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
async #onAddToNewClicked() {
|
||||
const tagName = this.tagName;
|
||||
|
||||
if (!tagName) {
|
||||
throw new Error('Missing tag name to create the profile!');
|
||||
}
|
||||
|
||||
const profile = new MaintenanceProfile(crypto.randomUUID(), {
|
||||
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
|
||||
tags: [this.tagName],
|
||||
@@ -166,6 +166,10 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
const tagsList = new Set(this.#activeProfile.settings.tags);
|
||||
const targetTagName = this.tagName;
|
||||
|
||||
if (!targetTagName) {
|
||||
throw new Error('Missing tag name!');
|
||||
}
|
||||
|
||||
if (tagsList.has(targetTagName)) {
|
||||
tagsList.delete(targetTagName);
|
||||
} else {
|
||||
@@ -181,14 +185,14 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Watch for changes to active profile.
|
||||
* @param {(profile: MaintenanceProfile|null) => void} onActiveProfileChange Callback to call when profile was
|
||||
* @param onActiveProfileChange Callback to call when profile was
|
||||
* changed.
|
||||
*/
|
||||
static #watchActiveProfile(onActiveProfileChange) {
|
||||
let lastActiveProfile;
|
||||
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile|null) => void) {
|
||||
let lastActiveProfile: string | null = null;
|
||||
|
||||
this.#maintenanceSettings.subscribe((settings) => {
|
||||
lastActiveProfile = settings.activeProfile;
|
||||
lastActiveProfile = settings.activeProfile ?? null;
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
@@ -199,7 +203,8 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
const activeProfile = profiles
|
||||
.find(profile => profile.id === lastActiveProfile);
|
||||
|
||||
onActiveProfileChange(activeProfile);
|
||||
onActiveProfileChange(activeProfile ?? null
|
||||
);
|
||||
});
|
||||
|
||||
this.#maintenanceSettings
|
||||
@@ -212,12 +217,11 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
|
||||
/**
|
||||
* Create element for dropdown.
|
||||
* @param {string} text Base text for the option.
|
||||
* @param {(event: MouseEvent) => void} onClickHandler Click handler. Event will be prevented by default.
|
||||
* @return {HTMLAnchorElement}
|
||||
* @param text Base text for the option.
|
||||
* @param onClickHandler Click handler. Event will be prevented by default.
|
||||
* @return
|
||||
*/
|
||||
static #createDropdownLink(text, onClickHandler) {
|
||||
/** @type {HTMLAnchorElement} */
|
||||
static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement {
|
||||
const dropdownLink = document.createElement('a');
|
||||
dropdownLink.href = '#';
|
||||
dropdownLink.innerText = text;
|
||||
@@ -232,7 +236,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapTagDropdown(element) {
|
||||
export function wrapTagDropdown(element: HTMLElement) {
|
||||
// Skip initialization when tag component is already wrapped
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
@@ -244,6 +248,8 @@ export function wrapTagDropdown(element) {
|
||||
categoriesResolver.addElement(tagDropdown);
|
||||
}
|
||||
|
||||
const processedElementsSet = new WeakSet<HTMLElement>();
|
||||
|
||||
export function watchTagDropdownsInTagsEditor() {
|
||||
// We only need to watch for new editor elements if there is a tag editor present on the page
|
||||
if (!document.querySelector('#image_tags_and_source')) {
|
||||
@@ -251,26 +257,35 @@ export function watchTagDropdownsInTagsEditor() {
|
||||
}
|
||||
|
||||
document.body.addEventListener('mouseover', event => {
|
||||
/** @type {HTMLElement} */
|
||||
const targetElement = event.target;
|
||||
|
||||
if (targetElement[isTagEditorProcessedKey]) {
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
const closestTagEditor = targetElement.closest('#image_tags_and_source');
|
||||
|
||||
if (!closestTagEditor || closestTagEditor[isTagEditorProcessedKey]) {
|
||||
targetElement[isTagEditorProcessedKey] = true;
|
||||
if (processedElementsSet.has(targetElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetElement[isTagEditorProcessedKey] = true;
|
||||
closestTagEditor[isTagEditorProcessedKey] = true;
|
||||
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
|
||||
|
||||
for (const tagDropdownElement of closestTagEditor.querySelectorAll('.tag.dropdown')) {
|
||||
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
|
||||
processedElementsSet.add(targetElement);
|
||||
return;
|
||||
}
|
||||
|
||||
processedElementsSet.add(targetElement);
|
||||
processedElementsSet.add(closestTagEditor);
|
||||
|
||||
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
|
||||
on(document.body, eventFormEditorUpdated, event => {
|
||||
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
|
||||
export class TagsForm extends BaseComponent {
|
||||
/**
|
||||
* Collect all the tag categories available on the page and color the tags in the editor according to them.
|
||||
*/
|
||||
refreshTagColors() {
|
||||
const tagCategories = this.#gatherTagCategories();
|
||||
const editableTags = this.container.querySelectorAll('.tag');
|
||||
|
||||
for (let tagElement of editableTags) {
|
||||
// Tag name is stored in the "remove" link and not in the tag itself.
|
||||
const removeLink = tagElement.querySelector('a');
|
||||
|
||||
if (!removeLink) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = removeLink.dataset.tagName;
|
||||
|
||||
if (!tagCategories.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryName = tagCategories.get(tagName);
|
||||
|
||||
tagElement.dataset.tagCategory = categoryName;
|
||||
tagElement.setAttribute('data-tag-category', categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect list of categories from the tags on the page.
|
||||
* @return {Map<string, string>}
|
||||
*/
|
||||
#gatherTagCategories() {
|
||||
/** @type {Map<string, string>} */
|
||||
const tagCategories = new Map();
|
||||
|
||||
for (let tagElement of document.querySelectorAll('.tag[data-tag-name][data-tag-category]')) {
|
||||
tagCategories.set(tagElement.dataset.tagName, tagElement.dataset.tagCategory);
|
||||
}
|
||||
|
||||
return tagCategories;
|
||||
}
|
||||
|
||||
static watchForEditors() {
|
||||
document.body.addEventListener('click', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
|
||||
|
||||
if (!tagEditorWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshTrigger = targetElement.closest('.js-taginput-show, #edit-tags')
|
||||
|
||||
if (!refreshTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormElement = tagEditorWrapper.querySelector('#tags-form');
|
||||
|
||||
/** @type {TagsForm|null} */
|
||||
let tagEditor = getComponent(tagFormElement);
|
||||
|
||||
if (!tagEditor || (!tagEditor instanceof TagsForm)) {
|
||||
tagEditor = new TagsForm(tagFormElement);
|
||||
tagEditor.initialize();
|
||||
}
|
||||
|
||||
tagEditor.refreshTagColors();
|
||||
});
|
||||
}
|
||||
}
|
||||
150
src/lib/components/TagsForm.ts
Normal file
150
src/lib/components/TagsForm.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms";
|
||||
import { eventFetchComplete } from "$lib/components/events/booru-events";
|
||||
import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events";
|
||||
|
||||
export class TagsForm extends BaseComponent {
|
||||
protected init() {
|
||||
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
|
||||
const unsubscribe = on(
|
||||
this.container,
|
||||
eventFetchComplete,
|
||||
() => this.#waitAndDetectUpdatedForm(unsubscribe),
|
||||
);
|
||||
}
|
||||
|
||||
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
|
||||
const elementContainingTagEditor = this.container
|
||||
.closest('#image_tags_and_source')
|
||||
?.parentElement;
|
||||
|
||||
if (!elementContainingTagEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagsFormElement || getComponent(tagsFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormComponent = new TagsForm(tagsFormElement);
|
||||
tagFormComponent.initialize();
|
||||
|
||||
const fullTagEditor = tagFormComponent.parentTagEditorElement;
|
||||
|
||||
if (fullTagEditor) {
|
||||
emit(document.body, eventFormEditorUpdated, fullTagEditor);
|
||||
} else {
|
||||
console.info('Tag form is not in the tag editor. Event is not sent.');
|
||||
}
|
||||
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
observer.observe(elementContainingTagEditor, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
|
||||
// Make sure to forcibly disconnect everything after a while.
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
get parentTagEditorElement(): HTMLElement | null {
|
||||
return this.container.closest<HTMLElement>('.js-tagsauce')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all the tag categories available on the page and color the tags in the editor according to them.
|
||||
*/
|
||||
refreshTagColors() {
|
||||
const tagCategories = this.#gatherTagCategories();
|
||||
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
|
||||
|
||||
for (const tagElement of editableTags) {
|
||||
// Tag name is stored in the "remove" link and not in the tag itself.
|
||||
const removeLink = tagElement.querySelector('a');
|
||||
|
||||
if (!removeLink) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = removeLink.dataset.tagName;
|
||||
|
||||
if (!tagName || !tagCategories.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryName = tagCategories.get(tagName)!;
|
||||
|
||||
tagElement.dataset.tagCategory = categoryName;
|
||||
tagElement.setAttribute('data-tag-category', categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect list of categories from the tags on the page.
|
||||
* @return
|
||||
*/
|
||||
#gatherTagCategories(): Map<string, string> {
|
||||
const tagCategories: Map<string, string> = new Map();
|
||||
|
||||
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
|
||||
const tagName = tagElement.dataset.tagName;
|
||||
const tagCategory = tagElement.dataset.tagCategory;
|
||||
|
||||
if (!tagName || !tagCategory) {
|
||||
console.warn('Missing tag name or category!');
|
||||
continue;
|
||||
}
|
||||
|
||||
tagCategories.set(tagName, tagCategory);
|
||||
}
|
||||
|
||||
return tagCategories;
|
||||
}
|
||||
|
||||
static watchForEditors() {
|
||||
document.body.addEventListener('click', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
|
||||
|
||||
if (!tagEditorWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
|
||||
|
||||
if (!refreshTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagFormElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagEditor = getComponent(tagFormElement);
|
||||
|
||||
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
|
||||
tagEditor = new TagsForm(tagFormElement);
|
||||
tagEditor.initialize();
|
||||
}
|
||||
|
||||
(tagEditor as TagsForm).refreshTagColors();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
import { bindComponent } from "$lib/components/base/component-utils";
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
*/
|
||||
export class BaseComponent {
|
||||
/** @type {HTMLElement} */
|
||||
#container;
|
||||
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
|
||||
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;
|
||||
|
||||
export class BaseComponent<ContainerType extends HTMLElement = HTMLElement> {
|
||||
readonly #container: ContainerType;
|
||||
|
||||
#isInitialized = false;
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
constructor(container) {
|
||||
constructor(container: ContainerType) {
|
||||
this.#container = container;
|
||||
|
||||
bindComponent(container, this);
|
||||
@@ -29,42 +25,33 @@ export class BaseComponent {
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
build() {
|
||||
protected build(): void {
|
||||
// This method can be implemented by the component classes to modify or create the inner elements.
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
*/
|
||||
init() {
|
||||
protected init(): void {
|
||||
// This method can be implemented by the component classes to initialize the component.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @return {HTMLElement}
|
||||
*/
|
||||
get container() {
|
||||
get container(): ContainerType {
|
||||
return this.#container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the component is initialized already. If not checked, subsequent calls to the `initialize` method will
|
||||
* throw an error.
|
||||
* @return {boolean}
|
||||
* @return
|
||||
*/
|
||||
get isInitialized() {
|
||||
get isInitialized(): boolean {
|
||||
return this.#isInitialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the custom event on the container element.
|
||||
* @param {keyof HTMLElementEventMap|string} event The event name.
|
||||
* @param {any} [detail] The event detail. Can be omitted.
|
||||
* @param event The event name.
|
||||
* @param [detail] The event detail. Can be omitted.
|
||||
*/
|
||||
emit(event, detail = undefined) {
|
||||
emit(event: keyof HTMLElementEventMap | string, detail: any = undefined): void {
|
||||
this.#container.dispatchEvent(
|
||||
new CustomEvent(
|
||||
event,
|
||||
@@ -78,12 +65,16 @@ export class BaseComponent {
|
||||
|
||||
/**
|
||||
* Subscribe to the DOM event on the container element.
|
||||
* @param {keyof HTMLElementEventMap|string} event The event name.
|
||||
* @param {function(Event): void} listener The event listener.
|
||||
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
|
||||
* @return {function(): void} The unsubscribe function.
|
||||
* @param event The event name.
|
||||
* @param listener The event listener.
|
||||
* @param [options] The event listener options. Can be omitted.
|
||||
* @return The unsubscribe function.
|
||||
*/
|
||||
on(event, listener, options = undefined) {
|
||||
on<EventName extends keyof HTMLElementEventMap>(
|
||||
event: EventName,
|
||||
listener: ComponentEventListener<EventName>,
|
||||
options?: AddEventListenerOptions,
|
||||
): () => void {
|
||||
this.#container.addEventListener(event, listener, options);
|
||||
|
||||
return () => void this.#container.removeEventListener(event, listener, options);
|
||||
@@ -91,12 +82,16 @@ export class BaseComponent {
|
||||
|
||||
/**
|
||||
* Subscribe to the DOM event on the container element. The event listener will be called only once.
|
||||
* @param {keyof HTMLElementEventMap|string} event The event name.
|
||||
* @param {function(Event): void} listener The event listener.
|
||||
* @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted.
|
||||
* @return {function(): void} The unsubscribe function.
|
||||
* @param event The event name.
|
||||
* @param listener The event listener.
|
||||
* @param [options] The event listener options. Can be omitted.
|
||||
* @return The unsubscribe function.
|
||||
*/
|
||||
once(event, listener, options = undefined) {
|
||||
once<EventName extends keyof HTMLElementEventMap>(
|
||||
event: EventName,
|
||||
listener: ComponentEventListener<EventName>,
|
||||
options?: AddEventListenerOptions,
|
||||
): () => void {
|
||||
options = options || {};
|
||||
options.once = true;
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
|
||||
const instanceSymbol = Symbol('instance');
|
||||
|
||||
interface ElementWithComponent extends HTMLElement {
|
||||
[instanceSymbol]?: BaseComponent;
|
||||
interface ElementWithComponent<T> extends HTMLElement {
|
||||
[instanceSymbol]?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -11,7 +11,7 @@ interface ElementWithComponent extends HTMLElement {
|
||||
* @param {HTMLElement} element
|
||||
* @return
|
||||
*/
|
||||
export function getComponent(element: ElementWithComponent): BaseComponent | null {
|
||||
export function getComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>): T | null {
|
||||
return element[instanceSymbol] || null;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function getComponent(element: ElementWithComponent): BaseComponent | nul
|
||||
* @param element The element to bind the component to.
|
||||
* @param instance The component instance.
|
||||
*/
|
||||
export function bindComponent(element: ElementWithComponent, instance: BaseComponent): void {
|
||||
export function bindComponent<T extends BaseComponent = BaseComponent>(element: ElementWithComponent<T>, instance: T): void {
|
||||
if (element[instanceSymbol]) {
|
||||
throw new Error('The element is already bound to a component.');
|
||||
}
|
||||
|
||||
5
src/lib/components/events/booru-events.ts
Normal file
5
src/lib/components/events/booru-events.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const eventFetchComplete = 'fetchcomplete';
|
||||
|
||||
export interface BooruEventsMap {
|
||||
[eventFetchComplete]: null; // Site sends the response, but extension will not get it due to isolation.
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events";
|
||||
import type { BooruEventsMap } from "$lib/components/events/booru-events";
|
||||
import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events";
|
||||
|
||||
interface EventsMapping extends MaintenancePopupEventsMap {
|
||||
}
|
||||
type EventsMapping =
|
||||
MaintenancePopupEventsMap
|
||||
& FullscreenViewerEventsMap
|
||||
& BooruEventsMap
|
||||
& TagsFormEventsMap;
|
||||
|
||||
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
type UnsubscribeFunction = () => void;
|
||||
export type UnsubscribeFunction = () => void;
|
||||
type ResolvableTarget = EventTarget | BaseComponent;
|
||||
|
||||
function resolveTarget(componentOrElement: ResolvableTarget): EventTarget {
|
||||
|
||||
7
src/lib/components/events/fullscreen-viewer-events.ts
Normal file
7
src/lib/components/events/fullscreen-viewer-events.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
|
||||
export const eventSizeLoaded = 'size-loaded';
|
||||
|
||||
export interface FullscreenViewerEventsMap {
|
||||
[eventSizeLoaded]: FullscreenViewerSize;
|
||||
}
|
||||
5
src/lib/components/events/tags-form-events.ts
Normal file
5
src/lib/components/events/tags-form-events.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const eventFormEditorUpdated = 'tags-form-updated';
|
||||
|
||||
export interface TagsFormEventsMap {
|
||||
[eventFormEditorUpdated]: HTMLElement;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full';
|
||||
export type FullscreenViewerSize = keyof App.ImageURIs;
|
||||
|
||||
interface MiscSettingsFields {
|
||||
fullscreenViewer: boolean;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
export type SuggestionsPosition = "start" | "end";
|
||||
|
||||
interface SearchSettingsFields {
|
||||
suggestProperties: boolean;
|
||||
suggestPropertiesPosition: "start" | "end";
|
||||
suggestPropertiesPosition: SuggestionsPosition;
|
||||
}
|
||||
|
||||
export default class SearchSettings extends CacheableSettings<SearchSettingsFields> {
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
<script>
|
||||
import "../styles/popup.scss";
|
||||
import Header from "$components/layout/Header.svelte";
|
||||
import Footer from "$components/layout/Footer.svelte";
|
||||
<script lang="ts">
|
||||
import "../styles/popup.scss";
|
||||
import Header from "$components/layout/Header.svelte";
|
||||
import Footer from "$components/layout/Footer.svelte";
|
||||
|
||||
// Sort of a hack, detect if we rendered in the browser tab or in the popup.
|
||||
// Popup will always should have fixed 320px size, otherwise we consider it opened in the tab.
|
||||
document.body.classList.toggle('is-in-tab', window.innerWidth > 320);
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
// Sort of a hack, detect if we rendered in the browser tab or in the popup.
|
||||
// Popup will always should have fixed 320px size, otherwise we consider it opened in the tab.
|
||||
document.body.classList.toggle('is-in-tab', window.innerWidth > 320);
|
||||
</script>
|
||||
|
||||
<Header/>
|
||||
<main>
|
||||
<slot/>
|
||||
{@render children?.()}
|
||||
</main>
|
||||
<Footer/>
|
||||
|
||||
<style lang="scss" global>
|
||||
main {
|
||||
padding: .5em 24px;
|
||||
}
|
||||
<style global lang="scss">
|
||||
main {
|
||||
padding: .5em 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
/** @type {import('$entities/MaintenanceProfile').default|undefined} */
|
||||
let activeProfile;
|
||||
let activeProfile = $derived<MaintenanceProfile | null>(
|
||||
$maintenanceProfiles.find(profile => profile.id === $activeProfileStore) || null
|
||||
);
|
||||
|
||||
$: activeProfile = $maintenanceProfilesStore.find(profile => profile.id === $activeProfileStore);
|
||||
|
||||
function turnOffActiveProfile() {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
function turnOffActiveProfile() {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
{#if activeProfile}
|
||||
<MenuCheckboxItem checked on:input={turnOffActiveProfile} href="/features/maintenance/{activeProfile.id}">
|
||||
Active Profile: {activeProfile.settings.name}
|
||||
</MenuCheckboxItem>
|
||||
<hr>
|
||||
{/if}
|
||||
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
|
||||
<MenuItem href="/features/groups">Tag Groups</MenuItem>
|
||||
{#if activeProfile}
|
||||
<MenuCheckboxItem checked oninput={turnOffActiveProfile} href="/features/maintenance/{activeProfile.id}">
|
||||
Active Profile: {activeProfile.settings.name}
|
||||
</MenuCheckboxItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences">Preferences</MenuItem>
|
||||
<MenuItem href="/about">About</MenuItem>
|
||||
{/if}
|
||||
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
|
||||
<MenuItem href="/features/groups">Tag Groups</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences">Preferences</MenuItem>
|
||||
<MenuItem href="/about">About</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<h1>
|
||||
Furbooru Tagging Assistant
|
||||
Furbooru Tagging Assistant
|
||||
</h1>
|
||||
<p>
|
||||
This is a tool made to help tag images on Furbooru more efficiently. It is currently in development and is not yet
|
||||
ready for use, but it still can provide some useful functionality.
|
||||
This is a tool made to help tag images on Furbooru more efficiently. It is currently in development and is not yet
|
||||
ready for use, but it still can provide some useful functionality.
|
||||
</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem icon="globe" href="https://furbooru.org" target="_blank">
|
||||
Visit Furbooru
|
||||
</MenuItem>
|
||||
<MenuItem icon="info-circle" href="https://github.com/koloml/furbooru-tagging-assistant" target="_blank">
|
||||
GitHub Repo
|
||||
</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="https://furbooru.org" icon="globe" target="_blank">
|
||||
Visit Furbooru
|
||||
</MenuItem>
|
||||
<MenuItem href="https://github.com/koloml/furbooru-tagging-assistant" icon="info-circle" target="_blank">
|
||||
GitHub Repo
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
|
||||
<MenuItem href="/">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
|
||||
/** @type {import('$entities/TagGroup').default[]} */
|
||||
let groups = [];
|
||||
|
||||
$: groups = $tagGroupsStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));
|
||||
let groups = $derived<TagGroup[]>($tagGroups.sort((a, b) => a.settings.name.localeCompare(b.settings.name)));
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/groups/new/edit" icon="plus">Create New</MenuItem>
|
||||
{#if groups.length}
|
||||
<hr>
|
||||
{#each groups as group}
|
||||
<MenuItem href="/features/groups/{group.id}">{group.settings.name}</MenuItem>
|
||||
{/each}
|
||||
{/if}
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/groups/new/edit" icon="plus">Create New</MenuItem>
|
||||
{#if groups.length}
|
||||
<hr>
|
||||
<MenuItem href="/features/groups/import">Import Group</MenuItem>
|
||||
{#each groups as group}
|
||||
<MenuItem href="/features/groups/{group.id}">{group.settings.name}</MenuItem>
|
||||
{/each}
|
||||
{/if}
|
||||
<hr>
|
||||
<MenuItem href="/features/groups/import">Import Group</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import GroupView from "$components/features/GroupView.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import GroupView from "$components/features/GroupView.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
/** @type {import('$entities/TagGroup').default|null} */
|
||||
let group = null;
|
||||
let groupId = $derived<string>(page.params.id);
|
||||
let group = $derived<TagGroup | null>($tagGroups.find(group => group.id === groupId) || null);
|
||||
|
||||
$effect(() => {
|
||||
if (groupId === 'new') {
|
||||
goto('/features/groups/new/edit');
|
||||
goto('/features/groups/new/edit');
|
||||
return;
|
||||
}
|
||||
|
||||
$: {
|
||||
group = $tagGroupsStore.find(group => group.id === groupId) || null;
|
||||
|
||||
if (!group) {
|
||||
console.warn(`Group ${groupId} not found.`);
|
||||
goto('/features/groups');
|
||||
}
|
||||
if (!group) {
|
||||
console.warn(`Group ${groupId} not found.`);
|
||||
goto('/features/groups');
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/groups" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/groups" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if group}
|
||||
<GroupView {group}/>
|
||||
<GroupView {group}/>
|
||||
{/if}
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem href="/features/groups/{groupId}/edit" icon="wrench">Edit Group</MenuItem>
|
||||
<MenuItem href="/features/groups/{groupId}/export" icon="file-export">Export Group</MenuItem>
|
||||
<MenuItem href="/features/groups/{groupId}/delete" icon="trash">Delete Group</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/groups/{groupId}/edit" icon="wrench">Edit Group</MenuItem>
|
||||
<MenuItem href="/features/groups/{groupId}/export" icon="file-export">Export Group</MenuItem>
|
||||
<MenuItem href="/features/groups/{groupId}/delete" icon="trash">Delete Group</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,41 +1,44 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
const targetGroup = $tagGroupsStore.find(group => group.id === groupId);
|
||||
const groupId = $derived<string>(page.params.id);
|
||||
const targetGroup = $derived<TagGroup | undefined>($tagGroups.find(group => group.id === groupId));
|
||||
|
||||
$effect(() => {
|
||||
if (!targetGroup) {
|
||||
void goto('/features/groups');
|
||||
goto('/features/groups');
|
||||
}
|
||||
})
|
||||
|
||||
async function deleteGroup() {
|
||||
if (!targetGroup) {
|
||||
console.warn('Attempting to delete the group, but the group is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
async function deleteGroup() {
|
||||
if (!targetGroup) {
|
||||
console.warn('Attempting to delete the group, but the group is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
await targetGroup.delete();
|
||||
await goto('/features/groups');
|
||||
}
|
||||
await targetGroup.delete();
|
||||
await goto('/features/groups');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/features/groups/{groupId}">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if targetGroup}
|
||||
<p>
|
||||
Do you want to remove group "{targetGroup.settings.name}"? This action is irreversible.
|
||||
</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={deleteGroup}>Yes</MenuItem>
|
||||
<MenuItem href="/features/groups/{groupId}">No</MenuItem>
|
||||
</Menu>
|
||||
<p>
|
||||
Do you want to remove group "{targetGroup.settings.name}"? This action is irreversible.
|
||||
</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem onclick={deleteGroup}>Yes</MenuItem>
|
||||
<MenuItem href="/features/groups/{groupId}">No</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
<p>Loading...</p>
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,82 +1,87 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import TagCategorySelectField from "$components/ui/forms/TagCategorySelectField.svelte";
|
||||
import TextField from "$components/ui/forms/TextField.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagsEditor from "$components/tags/TagsEditor.svelte";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import TagCategorySelectField from "$components/ui/forms/TagCategorySelectField.svelte";
|
||||
import TextField from "$components/ui/forms/TextField.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagsEditor from "$components/tags/TagsEditor.svelte";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
/** @type {TagGroup|null} */
|
||||
let targetGroup = null;
|
||||
|
||||
let groupName = '';
|
||||
/** @type {string[]} */
|
||||
let tagsList = [];
|
||||
/** @type {string[]} */
|
||||
let prefixesList = [];
|
||||
let tagCategory = '';
|
||||
let groupId = $derived(page.params.id);
|
||||
|
||||
let targetGroup = $derived.by<TagGroup | null>(() => {
|
||||
if (groupId === 'new') {
|
||||
targetGroup = new TagGroup(crypto.randomUUID(), {});
|
||||
} else {
|
||||
targetGroup = $tagGroupsStore.find(group => group.id === groupId) || null;
|
||||
|
||||
if (targetGroup) {
|
||||
groupName = targetGroup.settings.name;
|
||||
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
|
||||
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
|
||||
tagCategory = targetGroup.settings.category;
|
||||
} else {
|
||||
goto('/features/groups');
|
||||
}
|
||||
return new TagGroup(crypto.randomUUID(), {});
|
||||
}
|
||||
|
||||
async function saveGroup() {
|
||||
if (!targetGroup) {
|
||||
console.warn('Attempting to save group, but group is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
return $tagGroups.find(group => group.id === groupId) || null;
|
||||
});
|
||||
|
||||
targetGroup.settings.name = groupName;
|
||||
targetGroup.settings.tags = [...tagsList];
|
||||
targetGroup.settings.prefixes = [...prefixesList];
|
||||
targetGroup.settings.category = tagCategory;
|
||||
let groupName = $state<string>('');
|
||||
let tagsList = $state<string[]>([]);
|
||||
let prefixesList = $state<string[]>([]);
|
||||
let tagCategory = $state<string>('');
|
||||
|
||||
await targetGroup.save();
|
||||
await goto(`/features/groups/${targetGroup.id}`);
|
||||
$effect(() => {
|
||||
if (groupId === 'new') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetGroup) {
|
||||
goto('/features/groups');
|
||||
return;
|
||||
}
|
||||
|
||||
groupName = targetGroup.settings.name;
|
||||
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
|
||||
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
|
||||
tagCategory = targetGroup.settings.category;
|
||||
});
|
||||
|
||||
async function saveGroup() {
|
||||
if (!targetGroup) {
|
||||
console.warn('Attempting to save group, but group is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
targetGroup.settings.name = groupName;
|
||||
targetGroup.settings.tags = [...tagsList];
|
||||
targetGroup.settings.prefixes = [...prefixesList];
|
||||
targetGroup.settings.category = tagCategory;
|
||||
|
||||
await targetGroup.save();
|
||||
await goto(`/features/groups/${targetGroup.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/groups/${groupId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Group Name">
|
||||
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
|
||||
<FormControl label="Group Name">
|
||||
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
|
||||
</FormControl>
|
||||
<FormControl label="Group Color">
|
||||
<TagCategorySelectField bind:value={tagCategory}/>
|
||||
</FormControl>
|
||||
<TagsColorContainer targetCategory={tagCategory}>
|
||||
<FormControl label="Tags">
|
||||
<TagsEditor bind:tags={tagsList}/>
|
||||
</FormControl>
|
||||
<FormControl label="Group Color">
|
||||
<TagCategorySelectField bind:value={tagCategory}/>
|
||||
</TagsColorContainer>
|
||||
<TagsColorContainer targetCategory={tagCategory}>
|
||||
<FormControl label="Tag Prefixes">
|
||||
<TagsEditor bind:tags={prefixesList}/>
|
||||
</FormControl>
|
||||
<TagsColorContainer targetCategory="{tagCategory}">
|
||||
<FormControl label="Tags">
|
||||
<TagsEditor bind:tags={tagsList}/>
|
||||
</FormControl>
|
||||
</TagsColorContainer>
|
||||
<TagsColorContainer targetCategory="{tagCategory}">
|
||||
<FormControl label="Tag Prefixes">
|
||||
<TagsEditor bind:tags={prefixesList}/>
|
||||
</FormControl>
|
||||
</TagsColorContainer>
|
||||
</TagsColorContainer>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={saveGroup}>Save Group</MenuItem>
|
||||
<hr>
|
||||
<MenuItem onclick={saveGroup}>Save Group</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,50 +1,53 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
|
||||
const groupId = $page.params.id;
|
||||
const groupTransporter = new EntitiesTransporter(TagGroup);
|
||||
const group = $tagGroupsStore.find(group => group.id === groupId);
|
||||
let isEncodedGroupShown = $state(true);
|
||||
|
||||
/** @type {string} */
|
||||
let rawExportedGroup;
|
||||
/** @type {string} */
|
||||
let encodedExportedGroup;
|
||||
const groupId = $derived<string>(page.params.id);
|
||||
const group = $derived<TagGroup | undefined>($tagGroups.find(group => group.id === groupId));
|
||||
|
||||
$effect(() => {
|
||||
if (!group) {
|
||||
goto('/features/groups');
|
||||
} else {
|
||||
rawExportedGroup = groupTransporter.exportToJSON(group);
|
||||
encodedExportedGroup = groupTransporter.exportToCompressedJSON(group);
|
||||
goto('/features/groups');
|
||||
}
|
||||
});
|
||||
|
||||
let isEncodedGroupShown = true;
|
||||
const groupTransporter = new EntitiesTransporter(TagGroup);
|
||||
|
||||
let rawExportedGroup = $derived<string>(group ? groupTransporter.exportToJSON(group) : '');
|
||||
let encodedExportedGroup = $derived<string>(group ? groupTransporter.exportToCompressedJSON(group) : '');
|
||||
let selectedExportString = $derived<string>(isEncodedGroupShown ? encodedExportedGroup : rawExportedGroup);
|
||||
|
||||
function toggleEncoding() {
|
||||
isEncodedGroupShown = !isEncodedGroupShown;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Export string">
|
||||
<textarea readonly rows="6">{isEncodedGroupShown ? encodedExportedGroup : rawExportedGroup}</textarea>
|
||||
</FormControl>
|
||||
<FormControl label="Export string">
|
||||
<textarea readonly rows="6">{selectedExportString}</textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={() => isEncodedGroupShown = !isEncodedGroupShown}>
|
||||
Switch Format:
|
||||
{#if isEncodedGroupShown}
|
||||
Base64-Encoded
|
||||
{:else}
|
||||
Raw JSON
|
||||
{/if}
|
||||
</MenuItem>
|
||||
<hr>
|
||||
<MenuItem onclick={toggleEncoding}>
|
||||
Switch Format:
|
||||
{#if isEncodedGroupShown}
|
||||
Base64-Encoded
|
||||
{:else}
|
||||
Raw JSON
|
||||
{/if}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,134 +1,130 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import GroupView from "$components/features/GroupView.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import { tagGroupsStore } from "$stores/tag-groups-store";
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import GroupView from "$components/features/GroupView.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
|
||||
const groupTransporter = new EntitiesTransporter(TagGroup);
|
||||
const groupTransporter = new EntitiesTransporter(TagGroup);
|
||||
|
||||
/** @type {string} */
|
||||
let importedString = '';
|
||||
/** @type {string} */
|
||||
let errorMessage = '';
|
||||
let importedString = $state('');
|
||||
let errorMessage = $state('');
|
||||
|
||||
/** @type {TagGroup|null} */
|
||||
let candidateGroup = null;
|
||||
/** @type {TagGroup|null} */
|
||||
let existingGroup = null;
|
||||
let candidateGroup = $state<TagGroup | null>(null);
|
||||
let existingGroup = $state<TagGroup | null>(null);
|
||||
|
||||
function tryImportingGroup() {
|
||||
candidateGroup = null;
|
||||
existingGroup = null;
|
||||
function tryImportingGroup() {
|
||||
candidateGroup = null;
|
||||
existingGroup = null;
|
||||
|
||||
errorMessage = '';
|
||||
importedString = importedString.trim();
|
||||
errorMessage = '';
|
||||
importedString = importedString.trim();
|
||||
|
||||
if (!importedString) {
|
||||
errorMessage = 'Nothing to import.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (importedString.trim().startsWith('{')) {
|
||||
candidateGroup = groupTransporter.importFromJSON(importedString);
|
||||
}
|
||||
|
||||
candidateGroup = groupTransporter.importFromCompressedJSON(importedString);
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error';
|
||||
}
|
||||
|
||||
if (candidateGroup) {
|
||||
existingGroup = $tagGroupsStore.find(group => group.id === candidateGroup?.id) ?? null;
|
||||
}
|
||||
if (!importedString) {
|
||||
errorMessage = 'Nothing to import.';
|
||||
return;
|
||||
}
|
||||
|
||||
function saveGroup() {
|
||||
if (!candidateGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
candidateGroup.save().then(() => {
|
||||
goto(`/features/groups`);
|
||||
});
|
||||
try {
|
||||
if (importedString.trim().startsWith('{')) {
|
||||
candidateGroup = groupTransporter.importFromJSON(importedString);
|
||||
} else {
|
||||
candidateGroup = groupTransporter.importFromCompressedJSON(importedString);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error';
|
||||
}
|
||||
|
||||
function cloneAndSaveGroup() {
|
||||
if (!candidateGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clonedProfile = new TagGroup(crypto.randomUUID(), candidateGroup.settings);
|
||||
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
|
||||
clonedProfile.save().then(() => {
|
||||
goto(`/features/groups`);
|
||||
});
|
||||
if (candidateGroup) {
|
||||
existingGroup = $tagGroups.find(group => group.id === candidateGroup?.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveGroup() {
|
||||
if (!candidateGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
candidateGroup.save().then(() => {
|
||||
goto(`/features/groups`);
|
||||
});
|
||||
}
|
||||
|
||||
function cloneAndSaveGroup() {
|
||||
if (!candidateGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clonedProfile = new TagGroup(crypto.randomUUID(), candidateGroup.settings);
|
||||
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
|
||||
clonedProfile.save().then(() => {
|
||||
goto(`/features/groups`);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/features/groups">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/groups" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if errorMessage}
|
||||
<p class="error">Failed to import: {errorMessage}</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
</Menu>
|
||||
<p class="error">Failed to import: {errorMessage}</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
</Menu>
|
||||
{/if}
|
||||
{#if !candidateGroup}
|
||||
<FormContainer>
|
||||
<FormControl label="Import string">
|
||||
<textarea bind:value={importedString} rows="6"></textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={tryImportingGroup}>Import</MenuItem>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Import string">
|
||||
<textarea bind:value={importedString} rows="6"></textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem onclick={tryImportingGroup}>Import</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
{#if existingGroup}
|
||||
<p class="warning">
|
||||
This group will replace the existing "{existingGroup.settings.name}" group, since it have the same ID.
|
||||
</p>
|
||||
{/if}
|
||||
<GroupView group={candidateGroup}></GroupView>
|
||||
<Menu>
|
||||
<hr>
|
||||
{#if existingGroup}
|
||||
<p class="warning">
|
||||
This group will replace the existing "{existingGroup.settings.name}" group, since it have the same ID.
|
||||
</p>
|
||||
<MenuItem onclick={saveGroup}>Replace Existing Group</MenuItem>
|
||||
<MenuItem onclick={cloneAndSaveGroup}>Save as New Group</MenuItem>
|
||||
{:else}
|
||||
<MenuItem onclick={saveGroup}>Import New Group</MenuItem>
|
||||
{/if}
|
||||
<GroupView group="{candidateGroup}"></GroupView>
|
||||
<Menu>
|
||||
<hr>
|
||||
{#if existingGroup}
|
||||
<MenuItem on:click={saveGroup}>Replace Existing Group</MenuItem>
|
||||
<MenuItem on:click={cloneAndSaveGroup}>Save as New Group</MenuItem>
|
||||
{:else}
|
||||
<MenuItem on:click={saveGroup}>Import New Group</MenuItem>
|
||||
{/if}
|
||||
<MenuItem on:click={() => candidateGroup = null}>Cancel</MenuItem>
|
||||
</Menu>
|
||||
<MenuItem onclick={() => candidateGroup = null}>Cancel</MenuItem>
|
||||
</Menu>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
.error, .warning {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
.error, .warning {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: colors.$warning-background;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
.warning {
|
||||
background: colors.$warning-background;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
|
||||
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
|
||||
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
/** @type {import('$entities/MaintenanceProfile').default[]} */
|
||||
let profiles = [];
|
||||
let profiles = $derived<MaintenanceProfile[]>(
|
||||
$maintenanceProfiles.sort((a, b) => a.settings.name.localeCompare(b.settings.name))
|
||||
);
|
||||
|
||||
$: profiles = $maintenanceProfilesStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));
|
||||
function resetActiveProfile() {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
|
||||
function resetActiveProfile() {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
function enableSelectedProfile(event) {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement && target.checked) {
|
||||
activeProfileStore.set(target.value);
|
||||
}
|
||||
function enableSelectedProfile(event: Event) {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement && target.checked) {
|
||||
activeProfileStore.set(target.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/">Back</MenuItem>
|
||||
<MenuItem icon="plus" href="/features/maintenance/new/edit">Create New</MenuItem>
|
||||
{#if profiles.length}
|
||||
<hr>
|
||||
{/if}
|
||||
{#each profiles as profile}
|
||||
<MenuRadioItem href="/features/maintenance/{profile.id}"
|
||||
name="active-profile"
|
||||
value="{profile.id}"
|
||||
checked="{$activeProfileStore === profile.id}"
|
||||
on:input={enableSelectedProfile}>
|
||||
{profile.settings.name}
|
||||
</MenuRadioItem>
|
||||
{/each}
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/maintenance/new/edit" icon="plus">Create New</MenuItem>
|
||||
{#if profiles.length}
|
||||
<hr>
|
||||
<MenuItem href="#" on:click={resetActiveProfile}>Reset Active Profile</MenuItem>
|
||||
<MenuItem href="/features/maintenance/import">Import Profile</MenuItem>
|
||||
{/if}
|
||||
{#each profiles as profile}
|
||||
<MenuRadioItem href="/features/maintenance/{profile.id}"
|
||||
name="active-profile"
|
||||
value={profile.id}
|
||||
checked={$activeProfileStore === profile.id}
|
||||
oninput={enableSelectedProfile}>
|
||||
{profile.settings.name}
|
||||
</MenuRadioItem>
|
||||
{/each}
|
||||
<hr>
|
||||
<MenuItem href="#" onclick={resetActiveProfile}>Reset Active Profile</MenuItem>
|
||||
<MenuItem href="/features/maintenance/import">Import Profile</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,63 +1,68 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
/** @type {import('$entities/MaintenanceProfile').default|null} */
|
||||
let profile = null;
|
||||
let profileId = $derived(page.params.id);
|
||||
let profile = $derived<MaintenanceProfile|null>(
|
||||
$maintenanceProfiles.find(profile => profile.id === profileId) || null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (profileId === 'new') {
|
||||
goto('/features/maintenance/new/edit');
|
||||
goto('/features/maintenance/new/edit');
|
||||
return;
|
||||
}
|
||||
|
||||
$: {
|
||||
const resolvedProfile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
|
||||
if (!profile) {
|
||||
console.warn(`Profile ${profileId} not found.`);
|
||||
goto('/features/maintenance');
|
||||
}
|
||||
});
|
||||
|
||||
if (resolvedProfile) {
|
||||
profile = resolvedProfile;
|
||||
} else {
|
||||
console.warn(`Profile ${profileId} not found.`);
|
||||
goto('/features/maintenance');
|
||||
}
|
||||
let isActiveProfile = $state(false);
|
||||
|
||||
$effect.pre(() => {
|
||||
isActiveProfile = $activeProfileStore === profileId;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isActiveProfile && $activeProfileStore !== profileId) {
|
||||
$activeProfileStore = profileId;
|
||||
}
|
||||
|
||||
let isActiveProfile = $activeProfileStore === profileId;
|
||||
|
||||
$: {
|
||||
if (isActiveProfile && $activeProfileStore !== profileId) {
|
||||
$activeProfileStore = profileId;
|
||||
}
|
||||
|
||||
if (!isActiveProfile && $activeProfileStore === profileId) {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
if (!isActiveProfile && $activeProfileStore === profileId) {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if profile}
|
||||
<ProfileView {profile}/>
|
||||
<ProfileView {profile}/>
|
||||
{/if}
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem icon="wrench" href="/features/maintenance/{profileId}/edit">Edit Profile</MenuItem>
|
||||
<MenuCheckboxItem bind:checked={isActiveProfile}>
|
||||
Activate Profile
|
||||
</MenuCheckboxItem>
|
||||
<MenuItem icon="file-export" href="/features/maintenance/{profileId}/export">
|
||||
Export Profile
|
||||
</MenuItem>
|
||||
<MenuItem icon="trash" href="/features/maintenance/{profileId}/delete">
|
||||
Delete Profile
|
||||
</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/maintenance/{profileId}/edit" icon="wrench">Edit Profile</MenuItem>
|
||||
<MenuCheckboxItem bind:checked={isActiveProfile}>
|
||||
Activate Profile
|
||||
</MenuCheckboxItem>
|
||||
<MenuItem href="/features/maintenance/{profileId}/export" icon="file-export">
|
||||
Export Profile
|
||||
</MenuItem>
|
||||
<MenuItem href="/features/maintenance/{profileId}/delete" icon="trash">
|
||||
Delete Profile
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -1,41 +1,46 @@
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
const targetProfile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
|
||||
const profileId = $derived(page.params.id);
|
||||
const targetProfile = $derived<MaintenanceProfile | null>(
|
||||
$maintenanceProfiles.find(profile => profile.id === profileId) || null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!targetProfile) {
|
||||
void goto('/features/maintenance');
|
||||
goto('/features/maintenance');
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteProfile() {
|
||||
if (!targetProfile) {
|
||||
console.warn('Attempting to delete the profile, but the profile is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
async function deleteProfile() {
|
||||
if (!targetProfile) {
|
||||
console.warn('Attempting to delete the profile, but the profile is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
await targetProfile.delete();
|
||||
await goto('/features/maintenance');
|
||||
}
|
||||
await targetProfile.delete();
|
||||
await goto('/features/maintenance');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/features/maintenance/{profileId}">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if targetProfile}
|
||||
<p>
|
||||
Do you want to remove profile "{targetProfile.settings.name}"? This action is irreversible.
|
||||
</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={deleteProfile}>Yes</MenuItem>
|
||||
<MenuItem href="/features/maintenance/{profileId}">No</MenuItem>
|
||||
</Menu>
|
||||
<p>
|
||||
Do you want to remove profile "{targetProfile.settings.name}"? This action is irreversible.
|
||||
</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem onclick={deleteProfile}>Yes</MenuItem>
|
||||
<MenuItem href="/features/maintenance/{profileId}">No</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
<p>Loading...</p>
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,69 +1,73 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagsEditor from "$components/tags/TagsEditor.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import TextField from "$components/ui/forms/TextField.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import TagsEditor from "$components/tags/TagsEditor.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import TextField from "$components/ui/forms/TextField.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
/** @type {string} */
|
||||
let profileId = $page.params.id;
|
||||
/** @type {MaintenanceProfile|null} */
|
||||
let targetProfile = null;
|
||||
|
||||
/** @type {string} */
|
||||
let profileName = '';
|
||||
/** @type {string[]} */
|
||||
let tagsList = [];
|
||||
let profileId = $derived(page.params.id);
|
||||
|
||||
let targetProfile = $derived.by<MaintenanceProfile | null>(() => {
|
||||
if (profileId === 'new') {
|
||||
targetProfile = new MaintenanceProfile(crypto.randomUUID(), {});
|
||||
} else {
|
||||
const maybeExistingProfile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
|
||||
|
||||
if (maybeExistingProfile) {
|
||||
targetProfile = maybeExistingProfile;
|
||||
profileName = targetProfile.settings.name;
|
||||
tagsList = [...targetProfile.settings.tags].sort((a, b) => a.localeCompare(b));
|
||||
} else {
|
||||
goto('/features/maintenance');
|
||||
}
|
||||
return new MaintenanceProfile(crypto.randomUUID(), {});
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
if (!targetProfile) {
|
||||
console.warn('Attempting to save the profile, but the profile is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
return $maintenanceProfiles.find(profile => profile.id === profileId) || null;
|
||||
});
|
||||
|
||||
targetProfile.settings.name = profileName;
|
||||
targetProfile.settings.tags = [...tagsList];
|
||||
targetProfile.settings.temporary = false;
|
||||
let profileName = $state('');
|
||||
let tagsList = $state<string[]>([]);
|
||||
|
||||
await targetProfile.save();
|
||||
await goto('/features/maintenance/' + targetProfile.id);
|
||||
$effect(() => {
|
||||
if (profileId === 'new') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetProfile) {
|
||||
goto('/features/maintenance');
|
||||
return;
|
||||
}
|
||||
|
||||
profileName = targetProfile.settings.name;
|
||||
tagsList = [...targetProfile.settings.tags].sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
async function saveProfile() {
|
||||
if (!targetProfile) {
|
||||
console.warn('Attempting to save the profile, but the profile is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
targetProfile.settings.name = profileName;
|
||||
targetProfile.settings.tags = [...tagsList];
|
||||
targetProfile.settings.temporary = false;
|
||||
|
||||
await targetProfile.save();
|
||||
await goto('/features/maintenance/' + targetProfile.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/features/maintenance{profileId === 'new' ? '' : '/' + profileId}">
|
||||
Back
|
||||
</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/maintenance{profileId === 'new' ? '' : '/' + profileId}" icon="arrow-left">
|
||||
Back
|
||||
</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Profile Name">
|
||||
<TextField bind:value={profileName} placeholder="Profile Name"></TextField>
|
||||
</FormControl>
|
||||
<FormControl label="Tags">
|
||||
<TagsEditor bind:tags={tagsList}></TagsEditor>
|
||||
</FormControl>
|
||||
<FormControl label="Profile Name">
|
||||
<TextField bind:value={profileName} placeholder="Profile Name"></TextField>
|
||||
</FormControl>
|
||||
<FormControl label="Tags">
|
||||
<TagsEditor bind:tags={tagsList}></TagsEditor>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem href="#" on:click={saveProfile}>Save Profile</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="#" onclick={saveProfile}>Save Profile</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,52 +1,53 @@
|
||||
<script>
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
const profile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
|
||||
let isCompressedProfileShown = $state(true);
|
||||
|
||||
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
|
||||
/** @type {string} */
|
||||
let exportedProfile = '';
|
||||
/** @type {string} */
|
||||
let compressedProfile = '';
|
||||
const profileId = $derived(page.params.id);
|
||||
const profile = $derived<MaintenanceProfile | null>(
|
||||
$maintenanceProfiles.find(profile => profile.id === profileId) || null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!profile) {
|
||||
goto('/features/maintenance/');
|
||||
} else {
|
||||
exportedProfile = profilesTransporter.exportToJSON(profile);
|
||||
compressedProfile = profilesTransporter.exportToCompressedJSON(profile);
|
||||
goto('/features/maintenance/');
|
||||
}
|
||||
});
|
||||
|
||||
let isCompressedProfileShown = true;
|
||||
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
|
||||
|
||||
let rawExportedProfile = $derived(profile ? profilesTransporter.exportToJSON(profile) : '');
|
||||
let compressedExportedProfile = $derived(profile ? profilesTransporter.exportToCompressedJSON(profile) : '');
|
||||
let selectedExportString = $derived(isCompressedProfileShown ? compressedExportedProfile : rawExportedProfile);
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">
|
||||
Back
|
||||
</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">
|
||||
Back
|
||||
</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Export string">
|
||||
<textarea readonly rows="6">{isCompressedProfileShown ? compressedProfile : exportedProfile}</textarea>
|
||||
</FormControl>
|
||||
<FormControl label="Export string">
|
||||
<textarea readonly rows="6">{selectedExportString}</textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={() => isCompressedProfileShown = !isCompressedProfileShown}>
|
||||
Switch Format:
|
||||
{#if isCompressedProfileShown}
|
||||
Base64-Encoded
|
||||
{:else}
|
||||
Raw JSON
|
||||
{/if}
|
||||
</MenuItem>
|
||||
<hr>
|
||||
<MenuItem onclick={() => isCompressedProfileShown = !isCompressedProfileShown}>
|
||||
Switch Format:
|
||||
{#if isCompressedProfileShown}
|
||||
Base64-Encoded
|
||||
{:else}
|
||||
Raw JSON
|
||||
{/if}
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,134 +1,130 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
|
||||
import { goto } from "$app/navigation";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import { goto } from "$app/navigation";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
|
||||
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
|
||||
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
|
||||
|
||||
/** @type {string} */
|
||||
let importedString = '';
|
||||
/** @type {string} */
|
||||
let errorMessage = '';
|
||||
let importedString = $state('');
|
||||
let errorMessage = $state('');
|
||||
|
||||
/** @type {MaintenanceProfile|null} */
|
||||
let candidateProfile = null;
|
||||
/** @type {MaintenanceProfile|null} */
|
||||
let existingProfile = null;
|
||||
let candidateProfile = $state<MaintenanceProfile | null>(null);
|
||||
let existingProfile = $state<MaintenanceProfile | null>(null);
|
||||
|
||||
function tryImportingProfile() {
|
||||
candidateProfile = null;
|
||||
existingProfile = null;
|
||||
function tryImportingProfile() {
|
||||
candidateProfile = null;
|
||||
existingProfile = null;
|
||||
|
||||
errorMessage = '';
|
||||
importedString = importedString.trim();
|
||||
errorMessage = '';
|
||||
importedString = importedString.trim();
|
||||
|
||||
if (!importedString) {
|
||||
errorMessage = 'Nothing to import.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (importedString.trim().startsWith('{')) {
|
||||
candidateProfile = profilesTransporter.importFromJSON(importedString);
|
||||
}
|
||||
|
||||
candidateProfile = profilesTransporter.importFromCompressedJSON(importedString);
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error';
|
||||
}
|
||||
|
||||
if (candidateProfile) {
|
||||
existingProfile = $maintenanceProfilesStore.find(profile => profile.id === candidateProfile?.id) ?? null;
|
||||
}
|
||||
if (!importedString) {
|
||||
errorMessage = 'Nothing to import.';
|
||||
return;
|
||||
}
|
||||
|
||||
function saveProfile() {
|
||||
if (!candidateProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
candidateProfile.save().then(() => {
|
||||
goto(`/features/maintenance`);
|
||||
});
|
||||
try {
|
||||
if (importedString.trim().startsWith('{')) {
|
||||
candidateProfile = profilesTransporter.importFromJSON(importedString);
|
||||
} else {
|
||||
candidateProfile = profilesTransporter.importFromCompressedJSON(importedString)
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: 'Unknown error';
|
||||
}
|
||||
|
||||
function cloneAndSaveProfile() {
|
||||
if (!candidateProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clonedProfile = new MaintenanceProfile(crypto.randomUUID(), candidateProfile.settings);
|
||||
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
|
||||
clonedProfile.save().then(() => {
|
||||
goto(`/features/maintenance`);
|
||||
});
|
||||
if (candidateProfile) {
|
||||
existingProfile = $maintenanceProfiles.find(profile => profile.id === candidateProfile?.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveProfile() {
|
||||
if (!candidateProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
candidateProfile.save().then(() => {
|
||||
goto(`/features/maintenance`);
|
||||
});
|
||||
}
|
||||
|
||||
function cloneAndSaveProfile() {
|
||||
if (!candidateProfile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clonedProfile = new MaintenanceProfile(crypto.randomUUID(), candidateProfile.settings);
|
||||
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
|
||||
clonedProfile.save().then(() => {
|
||||
goto(`/features/maintenance`);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/features/maintenance">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if errorMessage}
|
||||
<p class="error">Failed to import: {errorMessage}</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
</Menu>
|
||||
<p class="error">Failed to import: {errorMessage}</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
</Menu>
|
||||
{/if}
|
||||
{#if !candidateProfile}
|
||||
<FormContainer>
|
||||
<FormControl label="Import string">
|
||||
<textarea bind:value={importedString} rows="6"></textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem on:click={tryImportingProfile}>Import</MenuItem>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Import string">
|
||||
<textarea bind:value={importedString} rows="6"></textarea>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem onclick={tryImportingProfile}>Import</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
{#if existingProfile}
|
||||
<p class="warning">
|
||||
This profile will replace the existing "{existingProfile.settings.name}" profile, since it have the same ID.
|
||||
</p>
|
||||
{/if}
|
||||
<ProfileView profile={candidateProfile}></ProfileView>
|
||||
<Menu>
|
||||
<hr>
|
||||
{#if existingProfile}
|
||||
<p class="warning">
|
||||
This profile will replace the existing "{existingProfile.settings.name}" profile, since it have the same ID.
|
||||
</p>
|
||||
<MenuItem onclick={saveProfile}>Replace Existing Profile</MenuItem>
|
||||
<MenuItem onclick={cloneAndSaveProfile}>Save as New Profile</MenuItem>
|
||||
{:else}
|
||||
<MenuItem onclick={saveProfile}>Import New Profile</MenuItem>
|
||||
{/if}
|
||||
<ProfileView profile="{candidateProfile}"></ProfileView>
|
||||
<Menu>
|
||||
<hr>
|
||||
{#if existingProfile}
|
||||
<MenuItem on:click={saveProfile}>Replace Existing Profile</MenuItem>
|
||||
<MenuItem on:click={cloneAndSaveProfile}>Save as New Profile</MenuItem>
|
||||
{:else}
|
||||
<MenuItem on:click={saveProfile}>Import New Profile</MenuItem>
|
||||
{/if}
|
||||
<MenuItem on:click={() => candidateProfile = null}>Cancel</MenuItem>
|
||||
</Menu>
|
||||
<MenuItem onclick={() => candidateProfile = null}>Cancel</MenuItem>
|
||||
</Menu>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
@use '$styles/colors';
|
||||
|
||||
.error, .warning {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
.error, .warning {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: colors.$warning-background;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
.warning {
|
||||
background: colors.$warning-background;
|
||||
margin-bottom: .5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences/tags">Tagging</MenuItem>
|
||||
<MenuItem href="/preferences/search">Search</MenuItem>
|
||||
<MenuItem href="/preferences/misc">Misc & Tools</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences/debug">Debug</MenuItem>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences/tags">Tagging</MenuItem>
|
||||
<MenuItem href="/preferences/search">Search</MenuItem>
|
||||
<MenuItem href="/preferences/misc">Misc & Tools</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences/debug">Debug</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences/debug/storage">Inspect Storages</MenuItem>
|
||||
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences/debug/storage">Inspect Storages</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script>
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import { storagesCollection } from "$stores/debug";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import { storagesCollection } from "$stores/debug";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/preferences/debug" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
{#each Object.keys($storagesCollection) as storageName}
|
||||
<MenuItem href="/preferences/debug/storage/{storageName}/">Storage: {storageName}</MenuItem>
|
||||
{/each}
|
||||
<MenuItem href="/preferences/debug" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
{#each Object.keys($storagesCollection) as storageName}
|
||||
<MenuItem href="/preferences/debug/storage/{storageName}/">Storage: {storageName}</MenuItem>
|
||||
{/each}
|
||||
</Menu>
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
<script>
|
||||
import StorageViewer from "$components/debugging/StorageViewer.svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
<script lang="ts">
|
||||
import StorageViewer from "$components/debugging/StorageViewer.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let pathString = '';
|
||||
/** @type {string[]} */
|
||||
let pathArray = [];
|
||||
/** @type {string|undefined} */
|
||||
let storageName = void 0;
|
||||
let pathArray = $derived.by<string[]>(() => {
|
||||
const pathString = page.params.path;
|
||||
|
||||
$: {
|
||||
pathString = $page.params.path;
|
||||
pathArray = pathString.length ? pathString.split("/") : [];
|
||||
storageName = pathArray.shift()
|
||||
return pathString.length ? pathString.split('/') : [];
|
||||
});
|
||||
|
||||
if (pathArray.length && pathArray[pathArray.length - 1] === '') {
|
||||
pathArray.pop();
|
||||
}
|
||||
let storageName = $derived.by<string | undefined>(() => {
|
||||
return pathArray[0];
|
||||
});
|
||||
|
||||
if (!storageName) {
|
||||
goto("/preferences/debug/storage");
|
||||
}
|
||||
// Copy of the array without the storage or empty string at the end.
|
||||
let normalizedPathArray = $derived.by<string[]>(() => {
|
||||
// Excludes storage name
|
||||
const resultArray = pathArray.slice(1);
|
||||
|
||||
// Getting rid of trailing empty entry
|
||||
if (resultArray.length && resultArray[resultArray.length - 1] === '') {
|
||||
resultArray.pop();
|
||||
}
|
||||
|
||||
return resultArray;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!storageName) {
|
||||
goto("/preferences/debug/storage");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if storageName}
|
||||
<StorageViewer storage="{storageName}" path="{pathArray}"></StorageViewer>
|
||||
<StorageViewer storage={storageName} path={normalizedPathArray}></StorageViewer>
|
||||
{/if}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script>
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
import { fullScreenViewerEnabled } from "$stores/misc-preferences";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
import { fullScreenViewerEnabled } from "$stores/preferences/misc";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={$fullScreenViewerEnabled}>
|
||||
Enable fullscreen viewer button
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={$fullScreenViewerEnabled}>
|
||||
Enable fullscreen viewer button
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import {
|
||||
searchPropertiesSuggestionsEnabled,
|
||||
searchPropertiesSuggestionsPosition
|
||||
} from "$stores/search-preferences";
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
import SelectField from "$components/ui/forms/SelectField.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import { searchPropertiesSuggestionsEnabled, searchPropertiesSuggestionsPosition } from "$stores/preferences/search";
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
import SelectField from "$components/ui/forms/SelectField.svelte";
|
||||
|
||||
const propertiesPositions = {
|
||||
start: "At the start of the list",
|
||||
end: "At the end of the list",
|
||||
}
|
||||
const propertiesPositions = {
|
||||
start: "At the start of the list",
|
||||
end: "At the end of the list",
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={$searchPropertiesSuggestionsEnabled}>
|
||||
Auto-complete properties
|
||||
</CheckboxField>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={$searchPropertiesSuggestionsEnabled}>
|
||||
Auto-complete properties
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
{#if $searchPropertiesSuggestionsEnabled}
|
||||
<FormControl label="Show completed properties:">
|
||||
<SelectField bind:value={$searchPropertiesSuggestionsPosition}
|
||||
options="{propertiesPositions}"></SelectField>
|
||||
</FormControl>
|
||||
{#if $searchPropertiesSuggestionsEnabled}
|
||||
<FormControl label="Show completed properties:">
|
||||
<SelectField bind:value={$searchPropertiesSuggestionsPosition}
|
||||
options="{propertiesPositions}"></SelectField>
|
||||
</FormControl>
|
||||
{/if}
|
||||
{/if}
|
||||
</FormContainer>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<script>
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { stripBlacklistedTagsEnabled } from "$stores/maintenance-preferences";
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { stripBlacklistedTagsEnabled } from "$stores/preferences/maintenance";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem icon="arrow-left" href="/preferences">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/preferences" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={$stripBlacklistedTagsEnabled}>
|
||||
Automatically remove black-listed tags from the images
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={$stripBlacklistedTagsEnabled}>
|
||||
Automatically remove black-listed tags from the images
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { type Writable, writable } from "svelte/store";
|
||||
|
||||
// todo: Maybe this could be dynamically resolved using map of entities and not currently existing list of all settings
|
||||
// classes. For now it's just generic record.
|
||||
type StorageContents = Record<string, any>;
|
||||
|
||||
/**
|
||||
* This is readable version of storages. Any changes made to these objects will not be sent to the local storage.
|
||||
* @type {Writable<Record<string, Object>>}
|
||||
*/
|
||||
export const storagesCollection = writable({});
|
||||
export const storagesCollection: Writable<StorageContents> = writable({});
|
||||
|
||||
chrome.storage.local.get(storages => {
|
||||
void chrome.storage.local.get<StorageContents>(null, storages => {
|
||||
storagesCollection.set(storages);
|
||||
});
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { type Writable, writable } from "svelte/store";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
|
||||
/**
|
||||
* Store for working with maintenance profiles in the Svelte popup.
|
||||
*
|
||||
* @type {import('svelte/store').Writable<MaintenanceProfile[]>}
|
||||
*/
|
||||
export const maintenanceProfilesStore = writable([]);
|
||||
export const maintenanceProfiles: Writable<MaintenanceProfile[]> = writable([]);
|
||||
|
||||
/**
|
||||
* Store for the active maintenance profile ID.
|
||||
*
|
||||
* @type {import('svelte/store').Writable<string|null>}
|
||||
*/
|
||||
export const activeProfileStore = writable(null);
|
||||
export const activeProfileStore: Writable<string|null> = writable(null);
|
||||
|
||||
const maintenanceSettings = new MaintenanceSettings();
|
||||
|
||||
/**
|
||||
* Active profile ID stored locally. Used to reset active profile once the existing profile was removed.
|
||||
* @type {string|null}
|
||||
*/
|
||||
let lastActiveProfileId = null;
|
||||
let lastActiveProfileId: string|null = null;
|
||||
|
||||
Promise.allSettled([
|
||||
// Read the initial values from the storages first
|
||||
MaintenanceProfile.readAll().then(profiles => {
|
||||
maintenanceProfilesStore.set(profiles);
|
||||
maintenanceProfiles.set(profiles);
|
||||
}),
|
||||
maintenanceSettings.resolveActiveProfileId().then(activeProfileId => {
|
||||
activeProfileStore.set(activeProfileId);
|
||||
@@ -35,7 +30,7 @@ Promise.allSettled([
|
||||
]).then(() => {
|
||||
// And only after initial values are loaded, start watching for changes from storage and from user's interaction
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
maintenanceProfilesStore.set(profiles);
|
||||
maintenanceProfiles.set(profiles);
|
||||
});
|
||||
|
||||
maintenanceSettings.subscribe(settings => {
|
||||
11
src/stores/entities/tag-groups.ts
Normal file
11
src/stores/entities/tag-groups.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { type Writable, writable } from "svelte/store";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
|
||||
export const tagGroups: Writable<TagGroup[]> = writable([]);
|
||||
|
||||
TagGroup
|
||||
.readAll()
|
||||
.then(groups => tagGroups.set(groups))
|
||||
.then(() => {
|
||||
TagGroup.subscribe(groups => tagGroups.set(groups));
|
||||
});
|
||||
@@ -1,10 +1,9 @@
|
||||
import { writable } from "svelte/store";
|
||||
import SearchSettings from "$lib/extension/settings/SearchSettings";
|
||||
import { type Writable, writable } from "svelte/store";
|
||||
import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings";
|
||||
|
||||
export const searchPropertiesSuggestionsEnabled = writable(false);
|
||||
|
||||
/** @type {import('svelte/store').Writable<"start"|"end">} */
|
||||
export const searchPropertiesSuggestionsPosition = writable('start');
|
||||
export const searchPropertiesSuggestionsPosition: Writable<SuggestionsPosition> = writable('start');
|
||||
|
||||
const searchSettings = new SearchSettings();
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { writable } from "svelte/store";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
|
||||
/** @type {import('svelte/store').Writable<TagGroup[]>} */
|
||||
export const tagGroupsStore = writable([]);
|
||||
|
||||
TagGroup
|
||||
.readAll()
|
||||
.then(groups => tagGroupsStore.set(groups))
|
||||
.then(() => {
|
||||
TagGroup.subscribe(groups => tagGroupsStore.set(groups));
|
||||
});
|
||||
@@ -203,6 +203,7 @@
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
z-index: 1;
|
||||
background-color: booru-vars.$background-color;
|
||||
}
|
||||
|
||||
.close {
|
||||
|
||||
@@ -19,6 +19,7 @@ const config = {
|
||||
"$styles": "./src/styles",
|
||||
"$stores": "./src/stores",
|
||||
"$entities": "./src/lib/extension/entities",
|
||||
"$tests": "./tests"
|
||||
},
|
||||
typescript: {
|
||||
config: config => {
|
||||
|
||||
40
tests/lib/browser/StorageHelper.spec.ts
Normal file
40
tests/lib/browser/StorageHelper.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
import StorageHelper from "$lib/browser/StorageHelper";
|
||||
import { expect } from "vitest";
|
||||
|
||||
describe('StorageHelper', () => {
|
||||
let storageAreaMock: ChromeStorageArea;
|
||||
let storageHelper: StorageHelper;
|
||||
|
||||
beforeEach(() => {
|
||||
storageAreaMock = new ChromeStorageArea();
|
||||
storageHelper = new StorageHelper(storageAreaMock);
|
||||
});
|
||||
|
||||
it("should return value when data exists", async () => {
|
||||
const key = 'existingKey';
|
||||
const value = 'test value';
|
||||
|
||||
storageAreaMock.insertMockedData({[key]: value});
|
||||
|
||||
expect(await storageHelper.read(key)).toBe(value);
|
||||
});
|
||||
|
||||
it('should return default when data is not present', async () => {
|
||||
const fallbackValue = 'fallback';
|
||||
|
||||
expect(await storageHelper.read('nonexistent', fallbackValue)).toBe(fallbackValue);
|
||||
});
|
||||
|
||||
it('should treat falsy values as existing values', async () => {
|
||||
const falsyValues = [false, '', 0];
|
||||
const key = 'testedKey';
|
||||
const fallbackValue = 'fallback';
|
||||
|
||||
for (let testedValue of falsyValues) {
|
||||
storageAreaMock.insertMockedData({[key]: testedValue});
|
||||
|
||||
expect(await storageHelper.read(key, fallbackValue)).toBe(testedValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
109
tests/lib/components/base/BaseComponent.spec.ts
Normal file
109
tests/lib/components/base/BaseComponent.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
|
||||
function randomString() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
describe('BaseComponent', () => {
|
||||
it('should bind the component to the element', () => {
|
||||
const element = document.createElement('div');
|
||||
const component = new BaseComponent(element);
|
||||
|
||||
expect(getComponent(element)).toBe(component);
|
||||
});
|
||||
|
||||
it('should throw an error when attempting to initialize component on same element multiple times', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
expect(() => new BaseComponent(element)).not.toThrowError();
|
||||
expect(() => new BaseComponent(element)).toThrowError();
|
||||
});
|
||||
|
||||
it('should return the element as component container', () => {
|
||||
const element = document.createElement('div');
|
||||
const component = new BaseComponent(element);
|
||||
|
||||
expect(component.container).toBe(element);
|
||||
});
|
||||
|
||||
it('should mark itself as initialized after initialization', () => {
|
||||
const element = document.createElement('div');
|
||||
const component = new BaseComponent(element);
|
||||
|
||||
expect(component.isInitialized).toBe(false);
|
||||
component.initialize();
|
||||
expect(component.isInitialized).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error when attempting to initialize component multiple times', () => {
|
||||
const element = document.createElement('div');
|
||||
const component = new BaseComponent(element);
|
||||
|
||||
expect(() => component.initialize()).not.toThrowError();
|
||||
expect(() => component.initialize()).toThrowError();
|
||||
});
|
||||
|
||||
it('should emit custom events on element', () => {
|
||||
const element = document.createElement('div');
|
||||
const component = new BaseComponent(element);
|
||||
|
||||
let receivedEvent: CustomEvent<string> | null = null;
|
||||
|
||||
const eventName = randomString();
|
||||
const eventData = randomString();
|
||||
const eventHandler = vi.fn(event => {
|
||||
receivedEvent = event;
|
||||
});
|
||||
|
||||
element.addEventListener(eventName, eventHandler);
|
||||
component.emit(eventName, eventData);
|
||||
|
||||
expect(eventHandler).toBeCalled();
|
||||
expect(receivedEvent).toBeInstanceOf(CustomEvent);
|
||||
expect(receivedEvent!.detail).toBe(eventData);
|
||||
});
|
||||
|
||||
it('should listen events on element', () => {
|
||||
const element = document.createElement('div');
|
||||
const component = new BaseComponent(element);
|
||||
|
||||
const eventName = 'click';
|
||||
const eventHandler = vi.fn();
|
||||
|
||||
component.on(eventName, eventHandler);
|
||||
element.dispatchEvent(new Event(eventName));
|
||||
expect(eventHandler).toBeCalled();
|
||||
});
|
||||
|
||||
it('should disconnect listener with unsubscribe function', () => {
|
||||
const element = document.createElement('div');
|
||||
const component = new BaseComponent(element);
|
||||
|
||||
const eventName = 'click';
|
||||
const eventHandler = vi.fn();
|
||||
|
||||
const unsubscribe = component.on(eventName, eventHandler);
|
||||
|
||||
element.dispatchEvent(new Event(eventName));
|
||||
unsubscribe();
|
||||
element.dispatchEvent(new Event(eventName));
|
||||
|
||||
expect(eventHandler).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should listen for event once', () => {
|
||||
const element = document.createElement('div');
|
||||
const component = new BaseComponent(element);
|
||||
|
||||
const eventName = 'click';
|
||||
const eventHandler = vi.fn();
|
||||
|
||||
component.once(eventName, eventHandler);
|
||||
|
||||
element.dispatchEvent(new Event(eventName));
|
||||
element.dispatchEvent(new Event(eventName));
|
||||
|
||||
expect(eventHandler).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
9
tests/mocks/ChromeEvent.ts
Normal file
9
tests/mocks/ChromeEvent.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export default class ChromeEvent<T extends Function> implements chrome.events.Event<T> {
|
||||
addListener = vi.fn();
|
||||
getRules = vi.fn();
|
||||
hasListener = vi.fn();
|
||||
removeRules = vi.fn();
|
||||
addRules = vi.fn();
|
||||
removeListener = vi.fn();
|
||||
hasListeners = vi.fn();
|
||||
}
|
||||
5
tests/mocks/ChromeLocalStorageArea.ts
Normal file
5
tests/mocks/ChromeLocalStorageArea.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import ChromeStorageArea from "$tests/mocks/ChromeStorageArea";
|
||||
|
||||
export class ChromeLocalStorageArea extends ChromeStorageArea implements chrome.storage.LocalStorageArea {
|
||||
QUOTA_BYTES = 100000;
|
||||
}
|
||||
71
tests/mocks/ChromeStorageArea.ts
Normal file
71
tests/mocks/ChromeStorageArea.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import ChromeEvent from "./ChromeEvent";
|
||||
|
||||
type ChangedEventCallback = (changes: chrome.storage.StorageChange) => void
|
||||
|
||||
export default class ChromeStorageArea implements chrome.storage.StorageArea {
|
||||
#mockedData: Record<string, any> = {};
|
||||
|
||||
getBytesInUse = vi.fn();
|
||||
clear = vi.fn((): Promise<void> => {
|
||||
return new Promise(resolve => {
|
||||
this.#mockedData = {};
|
||||
resolve();
|
||||
})
|
||||
});
|
||||
set = vi.fn((...args: any[]): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.#mockedData = Object.assign(this.#mockedData, args[0]);
|
||||
resolve();
|
||||
})
|
||||
});
|
||||
remove = vi.fn((...args: any[]): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const key = args[0];
|
||||
|
||||
if (typeof key === 'string') {
|
||||
delete this.#mockedData[key];
|
||||
resolve();
|
||||
}
|
||||
|
||||
reject(new Error('This behavior is not mocked!'));
|
||||
});
|
||||
});
|
||||
get = vi.fn((...args: any[]) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const key = args[0];
|
||||
|
||||
if (!key) {
|
||||
resolve(this.#mockedData);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof key === 'string') {
|
||||
resolve({[key]: this.#mockedData[key]});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(key)) {
|
||||
resolve(
|
||||
(key as string[]).reduce((entries, key) => {
|
||||
entries[key] = this.#mockedData[key];
|
||||
return entries;
|
||||
}, {} as Record<string, any>)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('This behavior is not implemented by the mock.'));
|
||||
});
|
||||
});
|
||||
setAccessLevel = vi.fn();
|
||||
onChanged = new ChromeEvent<ChangedEventCallback>();
|
||||
getKeys = vi.fn();
|
||||
|
||||
insertMockedData(data: Record<string, any>) {
|
||||
this.#mockedData = data;
|
||||
}
|
||||
|
||||
get mockedData(): Record<string, any> {
|
||||
return this.#mockedData;
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,9 @@
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"@types/chrome",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {sveltekit} from '@sveltejs/kit/vite';
|
||||
import {defineConfig} from 'vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
@@ -9,4 +9,13 @@ export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
exclude: ['**/node_modules/**', '.*\\.d\\.ts$', '.*\\.spec\\.ts$'],
|
||||
coverage: {
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/lib/**/*.{js,ts}'],
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user