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

101 Commits
0.3.3 ... 0.4.1

Author SHA1 Message Date
f7dff0968e Merge pull request #86 from koloml/release/0.4.1
Release: 0.4.1
2025-02-15 18:58:27 -05:00
acacfe2027 Bumped version to 0.4.1 2025-02-16 03:55:22 +04:00
90e75f1e38 Bumping dependencies (#89)
* Updated `@fortawesome/fontawesome-free` to 6.7.2

* Updated `sass` to 1.85.0

* Updated `@sveltejs/kit` to 2.17.2

* Updated `svelte` to 5.20.1

* Updated `vite` to 6.1.0

* Updated `@types/chrome` to 0.0.304
2025-02-15 18:54:36 -05:00
805bb7543a Merge pull request #88 from koloml/feature/update-colors-for-content-styles
Updated maintenance popup styling to respect active site theme
2025-02-15 18:25:17 -05:00
b6a96abadf Fixed incorrect usage of media border variable 2025-02-16 03:22:37 +04:00
06b9fefe8f Updating the popup colors to respect active theme 2025-02-16 03:17:50 +04:00
ff79e0b7fc Renaming booru colors to vars, adding several new variables 2025-02-16 03:17:32 +04:00
a99e4d286a Merge pull request #85 from koloml/feature/converting-js-to-ts
Converting modules to TypeScript
2025-02-15 17:56:28 -05:00
26fae1dc4a Merge pull request #87 from koloml/bugfix/maintenance-popups-tags-appearance
Fixed tag in popups having broken appearance after themes updates for Philomena, updated how colors applied to tags
2025-02-15 17:53:31 -05:00
c834703781 Fixed content styles not respecting aliases 2025-02-16 02:48:08 +04:00
33e1948a22 Added site-managed vars as colors, removed override for category 2025-02-16 02:43:58 +04:00
1b324f2829 Added new site-defined vars for tag colors 2025-02-16 02:43:32 +04:00
22f158dda9 Fixed broken paddings 2025-02-16 02:37:20 +04:00
f5dd2f7711 Adding type annotations to the query lexer 2025-02-07 03:57:08 +04:00
d9affcf5a0 Renaming query lexer to TS 2025-02-07 03:47:45 +04:00
c6d75e2b2a Converting storage helper to TS, adding types for subscribe functions 2025-02-07 03:23:21 +04:00
7bb71807bc Renaming storage helper to TS 2025-02-07 03:08:09 +04:00
9be8db85a2 Converting config controller to TS 2025-02-07 03:03:22 +04:00
392513f375 Renaming config controller to TS 2025-02-07 02:45:40 +04:00
d504ce3b04 Converting the code to typescript, making validators more type-safe 2025-02-07 02:39:53 +04:00
08aa71c959 Renaming validators to TS 2025-02-07 01:58:36 +04:00
bda2756779 Annotating component utils with types 2025-02-06 23:57:19 +04:00
92a0efaace Annotating tag utils with types 2025-02-06 23:49:28 +04:00
01c08353f1 Renaming components & tags utils to TS 2025-02-06 23:47:45 +04:00
d6487bbc2b Renaming tag categories to TS 2025-02-06 23:41:00 +04:00
011139d191 Updating utils with type annotations from JSDoc 2025-02-06 23:40:25 +04:00
2eb824e54b Renaming utils to TS 2025-02-06 23:36:06 +04:00
d439a69aab Merge pull request #84 from koloml/feature/removing-js-ts-extensions
Removing extensions from imports for consistency, slightly changing the formatting settings for imports
2025-02-06 23:30:35 +04:00
b9a609a190 Removing extensions for JS and TS in imports, reformatting 2025-02-06 23:20:28 +04:00
2c2c2acf3e Merge pull request #83 from koloml/feature/typeified-custom-events
Adding separate methods for events dispatching/listening with better type safety
2025-02-06 22:46:19 +04:00
a8265e9baa Merge pull request #82 from koloml/feature/moving-components-around
Moving several components internally into proper directories
2025-02-06 22:16:32 +04:00
4ea0e11ec1 Implementing the unified functions for custom events with types 2025-02-06 15:43:52 +04:00
2561cd19c9 Making container getter public 2025-02-06 15:04:32 +04:00
1de6a89269 Moved ProfileView to features directory 2025-02-06 14:20:36 +04:00
2db2a20803 Moved TagsEditor to tags directory 2025-02-06 14:19:46 +04:00
c7919e0127 Merge pull request #76 from koloml/release/0.4.0
Release: 0.4
2025-01-17 03:54:44 +04:00
73ff913eb7 Merge pull request #77 from koloml/feature/updating-dependencies
Updated Svelte, Vite, SCSS and other internal tools, fixed related errors & warnings
2025-01-17 03:53:42 +04:00
dd312e170e Cover :is selectors into the :global as well 2025-01-17 03:46:58 +04:00
283629d64b Updated all @sveltejs/kit-related packages to latest versions 2025-01-17 03:39:13 +04:00
9af73f0598 Replaced @import with @use on popup stylesheets 2025-01-17 03:25:30 +04:00
bd85d165d3 Updated deprecated color adjustment methods 2025-01-17 03:22:27 +04:00
24e0937c6b Updated all @use calls to use the $styles alias 2025-01-17 02:59:29 +04:00
24cec58af5 Updated vite to 6.0.7 2025-01-17 02:55:02 +04:00
66f106364a Updated sass to 1.83.4 2025-01-17 02:54:02 +04:00
650c5714d0 Updated cheerio to 1.0.0 2025-01-17 02:49:51 +04:00
31e2993a12 Bumped version to 0.4.0 2025-01-17 02:45:23 +04:00
e8349ce9a3 Merge pull request #75 from koloml/feature/more-suggested-properties
Search Suggestions: Added category tags count & other missed properties described in the search docs
2025-01-13 22:05:27 +04:00
6b807db235 Merge pull request #74 from koloml/bugfix/incorrect-redirect
Fixed fallback redirect when visiting profile with ID "new"
2025-01-13 22:04:45 +04:00
9a8e3cc597 Added more documented searchable terms from Derpibooru docs 2025-01-13 21:50:20 +04:00
d2d02b06e4 Added search terms for category-based tag count 2025-01-13 21:45:21 +04:00
58b79531e4 Merge remote-tracking branch 'origin/release/0.4' into bugfix/incorrect-redirect
# Conflicts:
#	src/routes/features/maintenance/[id]/+page.svelte
2025-01-13 21:38:55 +04:00
927e4c157b Fixed incorrect fallback redirect for "new" profiles 2025-01-12 19:41:16 +04:00
5c534da875 Removing unused last index in category resolving (oops) 2025-01-05 01:36:05 +04:00
257c369b02 Merge pull request #73 from koloml/feature/update-active-profile-toggler
Tagging Profiles: Replaced simple link with checkbox for "Activate Profile" option
2025-01-05 01:32:01 +04:00
d559d16977 Replaced simple button with checkbox for profile activity 2025-01-05 01:18:36 +04:00
6cced5036c Bind checked values from MenuCheckboxItem to input 2025-01-05 01:17:51 +04:00
15e379c798 Merge pull request #72 from koloml/feature/auto-remove-temporary-profiles
Remove temporary profiles when last tag was removed through dropdown
2025-01-03 19:43:27 +04:00
a39099bb1e Merge remote-tracking branch 'origin/release/0.4' into feature/auto-remove-temporary-profiles
# Conflicts:
#	src/lib/components/TagDropdownWrapper.js
2025-01-03 19:35:58 +04:00
3af723e50d Merge pull request #70 from koloml/feature/fullscreen-controls
Fullscreen Viewer: Added image/video quality setting and more explicit "close" icon
2025-01-03 06:41:41 +04:00
3e028a1509 Merge pull request #69 from koloml/feature/tag-groups
Added new Tag Groups feature with ability to customize colors of any tag with specific category
2025-01-03 06:41:22 +04:00
ba10768496 Added visible close button 2025-01-03 06:28:09 +04:00
e5ffd59b9c Fixed missing gap between 2 form controls 2025-01-03 06:03:42 +04:00
9a73ad80dd Skip blocks if they're not filled up 2025-01-03 05:54:06 +04:00
cd10ad62f4 Implemented resolver for custom categories for all the tags 2025-01-03 05:14:06 +04:00
b7a829ff12 Added tag category, storing the original category in the component class 2025-01-03 05:13:29 +04:00
e06359a24a Export the tag dropdown class, make tag name getter public 2025-01-03 03:43:02 +04:00
bb2065cf07 Added escaping utility function for RegExp 2025-01-03 03:42:03 +04:00
309dd15598 Keep the current URIs and switch them when size is changed 2024-12-30 23:08:06 +04:00
757526ab52 Fixed fullscreen button hiding itself on settings update 2024-12-30 22:57:43 +04:00
112d60ac78 Fixed selector appearing behind the media and not being accessible 2024-12-30 22:52:48 +04:00
3d1e0d6f06 Updated viewer to receive all the image URLs and apply selected size 2024-12-30 22:43:49 +04:00
98d3b1c696 Prevent viewer from closing when selecting sizes 2024-12-30 22:40:17 +04:00
52a8b6e778 Rendering selector for preview size in the fullscreen viewer element 2024-12-30 21:57:35 +04:00
c9c441a8ae Added size setting to the misc settings controller 2024-12-30 21:53:35 +04:00
c241bfb25c Mark profile as no longer temporary once it's edited 2024-12-23 21:46:36 +04:00
ac85938355 Added temporary flag, auto-remove profile when tags list is empty 2024-12-23 21:45:09 +04:00
a8a27654fc Merge pull request #65 from koloml/release/0.3.4
Release: 0.3.4
2024-12-16 16:43:16 +04:00
f58c4aa818 Bumped version to 0.3.4 2024-12-16 16:41:05 +04:00
eda58cd2ca Merge pull request #67 from koloml/feature/option-to-auto-remove-invalid-tags
Detect invalid blacklisted tags from Furbooru and allow to remove them manually or automatically
2024-12-16 16:32:44 +04:00
8f3020bc7b Merge pull request #66 from koloml/feature/property-icons-in-autocomplete
Added icon for the properties to differentiate between properties & tags
2024-12-15 02:46:17 +04:00
abbfcf2e34 Merge pull request #64 from koloml/feature/typescriptify-settings-classes
Refactoring settings classes to TypeScript
2024-12-15 02:41:39 +04:00
c4f00c4905 Fixed hover colors for error-tags 2024-12-14 22:04:31 +04:00
71039ee657 Adding default theme colors for categories 2024-12-14 22:02:10 +04:00
fa8ff3b718 Auto-removing or simply revealing tags when detected 2024-12-14 22:00:35 +04:00
90562f3878 Added settings menu for tags with auto-remove option 2024-12-14 21:59:47 +04:00
d1e22eaa0c Added flag for auto-removing invalid tags 2024-12-14 21:48:35 +04:00
ca3c4f6618 Added $config alias for config directory 2024-12-14 21:25:36 +04:00
3123ce1c0f Added list of blacklisted tags from Furbooru 2024-12-14 20:18:33 +04:00
6775a2175a Moving settings classes to TypeScript 2024-12-14 19:55:45 +04:00
62bcba34da Editing tag category, applying category color on view 2024-12-08 22:59:13 +04:00
17396952c3 Added field for selection of category, added container for tags 2024-12-08 22:58:46 +04:00
e61f6af237 Added category field to tag group 2024-12-08 22:58:16 +04:00
27133077c8 Adding default theme colors for categories 2024-12-08 22:57:49 +04:00
f90e51b546 Added icon for the properties to differentiate between props & tags 2024-12-07 20:54:56 +04:00
9e9499c904 Group deletion confirmation 2024-12-04 23:25:04 +04:00
7d19693f5e Import & export views 2024-12-04 23:21:26 +04:00
4fcf83d732 Group detail view & editing form 2024-12-02 03:23:32 +04:00
93d6d3a174 Groups listing, added link on the main view 2024-12-02 03:19:58 +04:00
4bdf04f911 Added store for the groups 2024-12-02 03:17:58 +04:00
c9a9fe059c Group view element 2024-12-02 03:17:48 +04:00
e523ce4468 Added initial version of tag group entity with tags & prefixes 2024-12-02 03:12:09 +04:00
97 changed files with 2571 additions and 3362 deletions

View File

@@ -204,7 +204,7 @@ 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 = 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
@@ -221,7 +221,7 @@ 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 = auto
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
@@ -376,7 +376,7 @@ ij_typescript_spaces_within_brackets = false
ij_typescript_spaces_within_catch_parentheses = false
ij_typescript_spaces_within_for_parentheses = false
ij_typescript_spaces_within_if_parentheses = false
ij_typescript_spaces_within_imports = false
ij_typescript_spaces_within_imports = true
ij_typescript_spaces_within_interpolation_expressions = false
ij_typescript_spaces_within_method_call_parentheses = false
ij_typescript_spaces_within_method_parentheses = false
@@ -393,7 +393,7 @@ ij_typescript_ternary_operation_wrap = off
ij_typescript_union_types_wrap = on_every_item
ij_typescript_use_chained_calls_group_indents = false
ij_typescript_use_double_quotes = true
ij_typescript_use_explicit_js_extension = auto
ij_typescript_use_explicit_js_extension = never
ij_typescript_use_import_type = auto
ij_typescript_use_path_mapping = always
ij_typescript_use_public_modifier = false

View File

@@ -48,6 +48,7 @@ function wrapScriptIntoIIFE() {
*/
function makeAliases(rootDir) {
return {
"$config": path.resolve(rootDir, 'src/config'),
"$lib": path.resolve(rootDir, 'src/lib'),
"$entities": path.resolve(rootDir, 'src/lib/extension/entities'),
"$styles": path.resolve(rootDir, 'src/styles'),
@@ -111,6 +112,9 @@ export async function buildStyle(buildOptions) {
}
},
emptyOutDir: false,
},
resolve: {
alias: makeAliases(buildOptions.rootDir)
}
});

View File

@@ -1,7 +1,7 @@
{
"name": "Furbooru Tagging Assistant",
"description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.",
"version": "0.3.3",
"version": "0.4.1",
"browser_specific_settings": {
"gecko": {
"id": "furbooru-tagging-assistant@thecore.city"
@@ -39,6 +39,9 @@
],
"js": [
"src/content/header.js"
],
"css": [
"src/styles/content/header.scss"
]
},
{

3298
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "furbooru-tagging-assistant",
"version": "0.3.3",
"version": "0.4.1",
"private": true,
"scripts": {
"build": "npm run build:popup && npm run build:extension",
@@ -10,21 +10,21 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/chrome": "^0.0.262",
"cheerio": "^1.0.0-rc.12",
"sass": "^1.71.0",
"svelte": "^4.2.19",
"svelte-check": "^3.6.0",
"typescript": "^5.0.0",
"vite": "^5.4.9"
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/chrome": "^0.0.304",
"cheerio": "^1.0.0",
"sass": "^1.85.0",
"svelte": "^5.20.1",
"svelte-check": "^4.1.4",
"typescript": "^5.7.3",
"vite": "^6.1.0"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.1",
"@fortawesome/fontawesome-free": "^6.7.2",
"lz-string": "^1.5.0"
}
}

11
src/app.d.ts vendored
View File

@@ -1,6 +1,7 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import type TagGroup from "$entities/TagGroup";
declare global {
namespace App {
@@ -24,6 +25,14 @@ declare global {
interface EntityNamesMap {
profiles: MaintenanceProfile;
groups: TagGroup;
}
interface ImageURIs {
full: string;
large: string;
medium: string;
small: string;
}
}
}

View File

@@ -1,9 +1,9 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import {storagesCollection} from "$stores/debug.js";
import {goto} from "$app/navigation";
import {findDeepObject} from "$lib/utils.js";
import { storagesCollection } from "$stores/debug";
import { goto } from "$app/navigation";
import { findDeepObject } from "$lib/utils";
/** @type {string} */
export let storage;

View File

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

View File

@@ -1,5 +1,5 @@
<script>
/** @type {import('$entities/MaintenanceProfile.ts').default} */
/** @type {import('$entities/MaintenanceProfile').default} */
export let profile;
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));

View File

@@ -1,5 +1,5 @@
<script>
import {version} from "$app/environment";
import { version } from "$app/environment";
</script>
<footer>
@@ -10,7 +10,7 @@
</footer>
<style lang="scss">
@use 'src/styles/colors';
@use '$styles/colors';
footer {
display: flex;

View File

@@ -3,7 +3,7 @@
</header>
<style lang="scss">
@use "src/styles/colors";
@use "$styles/colors";
header {
background: colors.$header;

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
</nav>
<style lang="scss">
@use 'src/styles/colors';
@use '$styles/colors';
nav {
display: flex;

View File

@@ -23,7 +23,7 @@
</script>
<MenuLink {href}>
<input type="checkbox" {name} {value} {checked} on:input on:click|stopPropagation>
<input type="checkbox" {name} {value} bind:checked={checked} on:input on:click|stopPropagation>
<slot></slot>
</MenuLink>

View File

@@ -23,7 +23,7 @@
</svelte:element>
<style lang="scss">
@use '../../../styles/colors';
@use '$styles/colors';
.menu-item {
display: flex;

66
src/config/tags.ts Normal file
View File

@@ -0,0 +1,66 @@
export const tagsBlacklist: string[] = [
"anthro art",
"anthro artist",
"anthro cute",
"anthro furry",
"anthro nsfw",
"anthro oc",
"anthroart",
"anthroartist",
"anthrofurry",
"anthronsfw",
"anthrooc",
"art",
"artist",
"artwork",
"cringe",
"cringeworthy",
"cute art",
"cute artwork",
"cute furry",
"downvotes galore",
"drama in comments",
"drama in the comments",
"fandom",
"furries",
"furry anthro",
"furry art",
"furry artist",
"furry artwork",
"furry character",
"furry community",
"furry cute",
"furry fandom",
"furry nsfw",
"furry oc",
"furryanthro",
"furryart",
"furryartist",
"furryartwork",
"furrynsfw",
"furryoc",
"image",
"no tag",
"not tagged",
"notag",
"notags",
"nsfw anthro",
"nsfw art",
"nsfw artist",
"nsfw artwork",
"nsfw",
"nsfwanthro",
"nsfwart",
"nsfwartist",
"nsfwartwork",
"paywall",
"rcf community",
"sfw",
"solo oc",
"tag me",
"tag needed",
"tag your shit",
"tagme",
"upvotes galore",
"wall of faves"
];

View File

@@ -1,4 +1,4 @@
import {initializeSiteHeader} from "$lib/components/SiteHeaderWrapper.js";
import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper";
const siteHeader = document.querySelector('.header');

View File

@@ -1,8 +1,8 @@
import {createMaintenancePopup} from "$lib/components/MaintenancePopup.js";
import {createMediaBoxTools} from "$lib/components/MediaBoxTools.js";
import {calculateMediaBoxesPositions, initializeMediaBox} from "$lib/components/MediaBoxWrapper.js";
import {createMaintenanceStatusIcon} from "$lib/components/MaintenanceStatusIcon.js";
import {createImageShowFullscreenButton} from "$lib/components/ImageShowFullscreenButton.js";
import { createMaintenancePopup } from "$lib/components/MaintenancePopup";
import { createMediaBoxTools } from "$lib/components/MediaBoxTools";
import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/components/MediaBoxWrapper";
import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon";
import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton";
/** @type {NodeListOf<HTMLElement>} */
const mediaBoxes = document.querySelectorAll('.media-box');

View File

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

View File

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

View File

@@ -1,34 +0,0 @@
/**
* Build the map containing both real tags and their aliases.
*
* @param {string[]} realAndAliasedTags List combining aliases and tag names.
* @param {string[]} realTags List of actual tag names, excluding aliases.
*
* @return {Map<string, string>} Map where key is a tag or alias and value is an actual tag name.
*/
export function buildTagsAndAliasesMap(realAndAliasedTags, realTags) {
/** @type {Map<string, string>} */
const tagsAndAliasesMap = new Map();
for (let tagName of realTags) {
tagsAndAliasesMap.set(tagName, tagName);
}
let realTagName = null;
for (let tagNameOrAlias of realAndAliasedTags) {
if (tagsAndAliasesMap.has(tagNameOrAlias)) {
realTagName = tagNameOrAlias;
continue;
}
if (!realTagName) {
console.warn('No real tag found for the alias:', tagNameOrAlias);
continue;
}
tagsAndAliasesMap.set(tagNameOrAlias, realTagName);
}
return tagsAndAliasesMap;
}

View File

@@ -1,4 +1,4 @@
import PostParser from "$lib/booru/scraped/parsing/PostParser.js";
import PostParser from "$lib/booru/scraped/parsing/PostParser";
export default class ScrapedAPI {
/**

View File

@@ -1,5 +1,5 @@
import PageParser from "$lib/booru/scraped/parsing/PageParser.js";
import {buildTagsAndAliasesMap} from "$lib/booru/TagsUtils.js";
import PageParser from "$lib/booru/scraped/parsing/PageParser";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
export default class PostParser extends PageParser {
/** @type {HTMLFormElement} */

View File

@@ -1,8 +1,8 @@
export class Token {
index;
value;
readonly index: number;
readonly value: string;
constructor(index, value) {
constructor(index: number, value: string) {
this.index = index;
this.value = value;
}
@@ -28,12 +28,9 @@ export class BoostToken extends Token {
}
export class QuotedTermToken extends Token {
/**
* @type {string}
*/
#quotedValue;
readonly #quotedValue: string;
constructor(index, value, quotedValue) {
constructor(index: number, value: string, quotedValue: string) {
super(index, value);
this.#quotedValue = quotedValue;
@@ -43,19 +40,11 @@ export class QuotedTermToken extends Token {
return QuotedTermToken.decode(this.#quotedValue);
}
/**
* @param {string} value
* @return {string}
*/
static decode(value) {
static decode(value: string): string {
return value.replace(/\\([\\"])/g, "$1");
}
/**
* @param {string} value
* @return {string}
*/
static encode(value) {
static encode(value: string): string {
return value.replace(/[\\"]/g, "\\$&");
}
}
@@ -63,6 +52,10 @@ export class QuotedTermToken extends Token {
export class TermToken extends Token {
}
type MatchResultCarry = {
match?: RegExpMatchArray | null
}
/**
* Search query tokenizer. Should mostly work for the cases of parsing and finding the selected term for
* auto-completion. Follows the rules described in the Philomena booru engine.
@@ -70,38 +63,28 @@ export class TermToken extends Token {
export class QueryLexer {
/**
* The original value to be parsed.
* @type {string}
*/
#value;
readonly #value: string;
/**
* Current position of the parser in the value.
* @type {number}
*/
#index = 0;
#index: number = 0;
/**
* @param {string} value
*/
constructor(value) {
constructor(value: string) {
this.#value = value;
}
/**
* Parse the query and get the list of tokens.
*
* @return {Token[]} List of tokens.
* @return List of tokens.
*/
parse() {
/** @type {Token[]} */
const tokens = [];
parse(): Token[] {
const tokens: Token[] = [];
const result: MatchResultCarry = {};
/**
* @type {{match: RegExpMatchArray|null}}
*/
const result = {};
let dirtyText;
let dirtyText: string;
while (this.#index < this.#value.length) {
if (this.#value[this.#index] === QueryLexer.#commaCharacter) {
@@ -111,26 +94,26 @@ export class QueryLexer {
}
if (this.#match(QueryLexer.#negotiationOperator, result)) {
tokens.push(new NotToken(this.#index, result.match[0]));
this.#index += result.match[0].length;
tokens.push(new NotToken(this.#index, result.match![0]));
this.#index += result.match![0].length;
continue;
}
if (this.#match(QueryLexer.#andOperator, result)) {
tokens.push(new AndToken(this.#index, result.match[0]));
this.#index += result.match[0].length;
tokens.push(new AndToken(this.#index, result.match![0]));
this.#index += result.match![0].length;
continue;
}
if (this.#match(QueryLexer.#orOperator, result)) {
tokens.push(new OrToken(this.#index, result.match[0]));
this.#index += result.match[0].length;
tokens.push(new OrToken(this.#index, result.match![0]));
this.#index += result.match![0].length;
continue;
}
if (this.#match(QueryLexer.#notOperator, result)) {
tokens.push(new NotToken(this.#index, result.match[0]));
this.#index += result.match[0].length;
tokens.push(new NotToken(this.#index, result.match![0]));
this.#index += result.match![0].length;
continue;
}
@@ -147,19 +130,19 @@ export class QueryLexer {
}
if (this.#match(QueryLexer.#boostOperator, result)) {
tokens.push(new BoostToken(this.#index, result.match[0]));
this.#index += result.match[0].length;
tokens.push(new BoostToken(this.#index, result.match![0]));
this.#index += result.match![0].length;
continue;
}
if (this.#match(QueryLexer.#whitespaces, result)) {
this.#index += result.match[0].length;
this.#index += result.match![0].length;
continue;
}
if (this.#match(QueryLexer.#quotedText, result)) {
tokens.push(new QuotedTermToken(this.#index, result.match[0], result.match[1]));
this.#index += result.match[0].length;
tokens.push(new QuotedTermToken(this.#index, result.match![0], result.match![1]));
this.#index += result.match![0].length;
continue;
}
@@ -180,25 +163,25 @@ export class QueryLexer {
/**
* Match the provided regular expression on the string with the current parser position.
*
* @param {RegExp} targetRegExp Target RegExp to parse with.
* @param {{match: any}} [resultCarrier] Object for passing the results into.
* @param targetRegExp Target RegExp to parse with.
* @param [resultCarrier] Object for passing the results into.
*
* @return {boolean} Is there a match?
* @return Is there a match?
*/
#match(targetRegExp, resultCarrier = {}) {
#match(targetRegExp: RegExp, resultCarrier: MatchResultCarry = {}): boolean {
return this.#matchAt(targetRegExp, this.#index, resultCarrier);
}
/**
* Match the provided regular expression in the string with the specific index.
*
* @param {RegExp} targetRegExp Target RegExp to parse with.
* @param {number} index Index to match the expression from.
* @param {{match: any}} [resultCarrier] Object for passing the results into.
* @param targetRegExp Target RegExp to parse with.
* @param index Index to match the expression from.
* @param [resultCarrier] Object for passing the results into.
*
* @return {boolean} Is there a match?
* @return Is there a match?
*/
#matchAt(targetRegExp, index, resultCarrier = {}) {
#matchAt(targetRegExp: RegExp, index: number, resultCarrier: MatchResultCarry = {}): boolean {
targetRegExp.lastIndex = index;
resultCarrier.match = this.#value.match(targetRegExp);
@@ -212,11 +195,10 @@ export class QueryLexer {
*
* @return {string} Matched text.
*/
#parseDirtyText(index) {
let resultValue = '';
#parseDirtyText(index: number): string {
let resultValue: string = '';
/** @type {{match: RegExpMatchArray|null}} */
const result = {match: null};
const result: MatchResultCarry = {match: null};
// Loop over
while (index < this.#value.length) {
@@ -226,8 +208,8 @@ export class QueryLexer {
}
if (this.#matchAt(QueryLexer.#dirtyTextContent, index, result)) {
resultValue += result.match[0];
index += result.match[0].length;
resultValue += result.match![0];
index += result.match![0].length;
continue;
}

View File

@@ -0,0 +1,12 @@
export const categories = [
'rating',
'spoiler',
'origin',
'oc',
'error',
'character',
'content-official',
'content-fanmade',
'species',
'body-type',
];

View File

@@ -0,0 +1,33 @@
/**
* Build the map containing both real tags and their aliases.
*
* @param realAndAliasedTags List combining aliases and tag names.
* @param realTags List of actual tag names, excluding aliases.
*
* @return Map where key is a tag or alias and value is an actual tag name.
*/
export function buildTagsAndAliasesMap(realAndAliasedTags: string[], realTags: string[]): Map<string, string> {
const tagsAndAliasesMap: Map<string, string> = new Map();
for (const tagName of realTags) {
tagsAndAliasesMap.set(tagName, tagName);
}
let realTagName: string | null = null;
for (const tagNameOrAlias of realAndAliasedTags) {
if (tagsAndAliasesMap.has(tagNameOrAlias)) {
realTagName = tagNameOrAlias;
continue;
}
if (!realTagName) {
console.warn('No real tag found for the alias:', tagNameOrAlias);
continue;
}
tagsAndAliasesMap.set(tagNameOrAlias, realTagName);
}
return tagsAndAliasesMap;
}

View File

@@ -1,57 +0,0 @@
/**
* Helper class to read and write JSON objects to the local storage.
* @class
*/
class StorageHelper {
/**
* @type {import('@types/chrome').storage.StorageArea}
*/
#storageArea;
/**
* @param {import('@types/chrome').storage.StorageArea} storageArea
*/
constructor(storageArea) {
this.#storageArea = storageArea;
}
/**
* Read the following entry from the local storage as a JSON object.
*
* @param {string} key Key of the entry to read.
* @param {any} defaultValue Default value to return if the entry does not exist.
*
* @return {Promise<any>} The JSON object or the default value if the entry does not exist.
*/
async read(key, defaultValue = null) {
return (await this.#storageArea.get(key))?.[key] || defaultValue;
}
/**
* Write the following JSON object to the local storage.
*
* @param {string} key Key of the entry to write.
* @param {any} value JSON object to write.
*/
write(key, value) {
void this.#storageArea.set({[key]: value});
}
/**
* Subscribe to changes in the local storage.
* @param {function(Record<string, StorageChange>): void} callback
*/
subscribe(callback) {
this.#storageArea.onChanged.addListener(callback);
}
/**
* Unsubscribe from changes in the local storage.
* @param {function(Record<string, StorageChange>): void} callback
*/
unsubscribe(callback) {
this.#storageArea.onChanged.removeListener(callback);
}
}
export default StorageHelper;

View File

@@ -0,0 +1,53 @@
/**
* Changes subscribe function. It receives changes with old and new value for keys of the storage.
*/
export type StorageChangeSubscriber = (changes: Record<string, chrome.storage.StorageChange>) => void;
/**
* Helper class to read and write JSON objects to the local storage.
*/
export default class StorageHelper {
readonly #storageArea: chrome.storage.StorageArea;
constructor(storageArea: chrome.storage.StorageArea) {
this.#storageArea = storageArea;
}
/**
* Read the following entry from the local storage as a JSON object.
*
* @param key Key of the entry to read.
* @param defaultValue Default value to return if the entry does not exist.
*
* @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;
}
/**
* Write the following JSON object to the local storage.
*
* @param key Key of the entry to write.
* @param value Value to write.
*/
write(key: string, value: any): void {
void this.#storageArea.set({[key]: value});
}
/**
* Subscribe to changes in the local storage.
* @param callback Listener function to receive changes.
*/
subscribe(callback: StorageChangeSubscriber): void {
this.#storageArea.onChanged.addListener(callback);
}
/**
* Unsubscribe from changes in the local storage.
* @param callback Reference to the callback for unsubscribing.
*/
unsubscribe(callback: StorageChangeSubscriber): void {
this.#storageArea.onChanged.removeListener(callback);
}
}

View File

@@ -1,13 +1,14 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import MiscSettings from "$lib/extension/settings/MiscSettings";
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} */
@@ -16,15 +17,33 @@ export class FullscreenViewer extends BaseComponent {
#startY = null;
/** @type {boolean|null} */
#isClosingSwipeStarted = null;
#isSizeFetched = false;
/** @type {App.ImageURIs|null} */
#currentURIs = null;
/**
* @protected
*/
build() {
this.container.classList.add('fullscreen-viewer');
this.container.append(this.#spinnerElement);
this.container.append(
this.#spinnerElement,
this.#sizeSelectorElement,
this.#closeButtonElement,
);
this.#spinnerElement.classList.add('spinner', 'fa', 'fa-circle-notch', 'fa-spin');
this.#closeButtonElement.classList.add('close', 'fa', 'fa-xmark');
this.#sizeSelectorElement.classList.add('size-selector', 'input');
for (const [sizeKey, sizeName] of Object.entries(FullscreenViewer.#previewSizes)) {
const sizeOptionElement = document.createElement('option');
sizeOptionElement.value = sizeKey;
sizeOptionElement.innerText = sizeName;
this.#sizeSelectorElement.append(sizeOptionElement);
}
}
/**
@@ -40,6 +59,12 @@ export class FullscreenViewer extends BaseComponent {
this.#videoElement.addEventListener('loadeddata', this.#onLoaded.bind(this));
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
FullscreenViewer.#miscSettings
.resolveFullscreenViewerPreviewSize()
.then(this.#onSizeResolved.bind(this))
.then(this.#watchForSizeSelectionChanges.bind(this));
}
#onLoaded() {
@@ -163,7 +188,49 @@ export class FullscreenViewer extends BaseComponent {
}
}
/**
* @param {import("$lib/extension/settings/MiscSettings").FullscreenViewerSize} size
*/
#onSizeResolved(size) {
this.#sizeSelectorElement.value = size;
this.#isSizeFetched = true;
this.emit('size-loaded');
}
#watchForSizeSelectionChanges() {
let lastActiveSize = this.#sizeSelectorElement.value;
FullscreenViewer.#miscSettings.subscribe(settings => {
const targetSize = settings.fullscreenViewerSize;
if (!targetSize || lastActiveSize === targetSize) {
return;
}
lastActiveSize = targetSize;
this.#sizeSelectorElement.value = targetSize;
});
this.#sizeSelectorElement.addEventListener('input', () => {
const targetSize = this.#sizeSelectorElement.value;
if (this.#currentURIs) {
void this.show(this.#currentURIs);
}
if (!targetSize || targetSize === lastActiveSize || !(targetSize in FullscreenViewer.#previewSizes)) {
return;
}
lastActiveSize = targetSize;
void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize);
});
}
#close() {
this.#currentURIs = null;
this.container.classList.remove(FullscreenViewer.#shownState);
document.body.style.overflow = null;
@@ -175,9 +242,44 @@ export class FullscreenViewer extends BaseComponent {
}
/**
* @param {string} url
* @param {App.ImageURIs} imageUris
* @return {Promise<string|null>}
*/
show(url) {
async #resolveCurrentSelectedSizeUrl(imageUris) {
if (!this.#isSizeFetched) {
await new Promise(resolve => this.on('size-loaded', resolve))
}
let targetSize = this.#sizeSelectorElement.value;
if (!imageUris.hasOwnProperty(targetSize)) {
targetSize = FullscreenViewer.#fallbackSize;
}
if (!imageUris.hasOwnProperty(targetSize)) {
targetSize = Object.keys(imageUris)[0];
}
if (!targetSize) {
return null;
}
return imageUris[targetSize];
}
/**
* @param {App.ImageURIs} imageUris
*/
async show(imageUris) {
this.#currentURIs = imageUris;
const url = await this.#resolveCurrentSelectedSizeUrl(imageUris);
if (!url) {
console.warn('Failed to resolve media for the viewer!');
return;
}
this.container.classList.add('loading');
requestAnimationFrame(() => {
@@ -214,9 +316,23 @@ export class FullscreenViewer extends BaseComponent {
return url.endsWith('.mp4') || url.endsWith('.webm');
}
static #miscSettings = new MiscSettings();
static #offsetProperty = '--offset';
static #opacityProperty = '--opacity';
static #shownState = 'shown';
static #swipeState = 'swiped';
static #minRequiredDistance = 50;
/**
* @type {Record<import("$lib/extension/settings/MiscSettings").FullscreenViewerSize, string>}
*/
static #previewSizes = {
full: 'Full',
large: 'Large',
medium: 'Medium',
small: 'Small'
}
static #fallbackSize = 'large';
}

View File

@@ -1,13 +1,13 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import MiscSettings from "$lib/extension/settings/MiscSettings.js";
import {FullscreenViewer} from "$lib/components/FullscreenViewer.js";
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";
export class ImageShowFullscreenButton extends BaseComponent {
/**
* @type {MediaBoxTools}
* @type {import('./MediaBoxTools').MediaBoxTools|null}
*/
#mediaBoxTools;
#mediaBoxTools= null;
#isFullscreenButtonEnabled = false;
build() {
@@ -33,7 +33,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
})
.then(() => {
ImageShowFullscreenButton.#miscSettings.subscribe(settings => {
this.#isFullscreenButtonEnabled = settings.fullscreenViewer;
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
this.#updateFullscreenButtonVisibility();
})
})
@@ -47,7 +47,7 @@ export class ImageShowFullscreenButton extends BaseComponent {
#onButtonClicked() {
ImageShowFullscreenButton
.#resolveViewer()
.show(this.#mediaBoxTools.mediaBox.imageLinks.large);
.show(this.#mediaBoxTools.mediaBox.imageLinks);
}
/**

View File

@@ -1,8 +1,24 @@
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI.js";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import ScrapedAPI from "$lib/booru/scraped/ScrapedAPI";
import { tagsBlacklist } from "$config/tags";
import { emitterAt } from "$lib/components/events/comms";
import {
eventActiveProfileChanged,
eventMaintenanceStateChanged,
eventTagsUpdated
} from "$lib/components/events/maintenance-popup-events";
class BlackListedTagsEncounteredError extends Error {
/**
* @param {string} tagName
*/
constructor(tagName) {
super(`This tag is blacklisted and prevents submission: ${tagName}`);
}
}
export class MaintenancePopup extends BaseComponent {
/** @type {HTMLElement} */
@@ -11,10 +27,13 @@ export class MaintenancePopup extends BaseComponent {
/** @type {HTMLElement[]} */
#tagsList = [];
/** @type {Map<string, HTMLElement>} */
#suggestedInvalidTags = new Map();
/** @type {MaintenanceProfile|null} */
#activeProfile = null;
/** @type {import('$lib/components/MediaBoxTools.js').MediaBoxTools} */
/** @type {import('$lib/components/MediaBoxTools').MediaBoxTools} */
#mediaBoxTools = null;
/** @type {Set<string>} */
@@ -32,6 +51,8 @@ export class MaintenancePopup extends BaseComponent {
/** @type {number|null} */
#tagsSubmissionTimer = null;
#emitter = emitterAt(this);
/**
* @protected
*/
@@ -82,18 +103,24 @@ export class MaintenancePopup extends BaseComponent {
this.#activeProfile = activeProfile;
this.container.classList.toggle('is-active', activeProfile !== null);
this.#refreshTagsList();
this.emit('active-profile-changed', activeProfile);
this.#emitter.emit(eventActiveProfileChanged, activeProfile);
}
#refreshTagsList() {
/** @type {string[]} */
const activeProfileTagsList = this.#activeProfile?.settings.tags || [];
for (let tagElement of this.#tagsList) {
for (const tagElement of this.#tagsList) {
tagElement.remove();
}
for (const tagElement of this.#suggestedInvalidTags.values()) {
tagElement.remove();
}
this.#tagsList = new Array(activeProfileTagsList.length);
this.#suggestedInvalidTags.clear();
const currentPostTags = this.#mediaBoxTools.mediaBox.tagsAndAliases;
@@ -109,6 +136,12 @@ export class MaintenancePopup extends BaseComponent {
tagElement.classList.toggle('is-present', isPresent);
tagElement.classList.toggle('is-missing', !isPresent);
tagElement.classList.toggle('is-aliased', isPresent && currentPostTags.get(tagName) !== tagName);
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
if (tagsBlacklist.includes(tagName)) {
MaintenancePopup.#markTagAsInvalid(tagElement);
this.#suggestedInvalidTags.set(tagName, tagElement);
}
});
}
@@ -157,7 +190,7 @@ export class MaintenancePopup extends BaseComponent {
}
this.#isPlanningToSubmit = true;
this.emit('maintenance-state-change', 'waiting');
this.#emitter.emit(eventMaintenanceStateChanged, 'waiting');
}
}
@@ -184,10 +217,12 @@ export class MaintenancePopup extends BaseComponent {
this.#isPlanningToSubmit = false;
this.#isSubmitting = true;
this.emit('maintenance-state-change', 'processing');
this.#emitter.emit(eventMaintenanceStateChanged, 'processing');
let maybeTagsAndAliasesAfterUpdate;
const shouldAutoRemove = await MaintenancePopup.#maintenanceSettings.resolveStripBlacklistedTags();
try {
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
this.#mediaBoxTools.mediaBox.imageId,
@@ -200,24 +235,41 @@ export class MaintenancePopup extends BaseComponent {
tagsList.add(tagName);
}
if (shouldAutoRemove) {
for (let tagName of tagsBlacklist) {
tagsList.delete(tagName);
}
} else {
for (let tagName of tagsList) {
if (tagsBlacklist.includes(tagName)) {
throw new BlackListedTagsEncounteredError(tagName);
}
}
}
return tagsList;
}
);
} catch (e) {
console.warn('Tags submission failed:', e);
if (e instanceof BlackListedTagsEncounteredError) {
this.#revealInvalidTags();
} else {
console.warn('Tags submission failed:', e);
}
MaintenancePopup.#notifyAboutPendingSubmission(false);
this.emit('maintenance-state-change', 'failed');
this.#emitter.emit(eventMaintenanceStateChanged, 'failed');
this.#isSubmitting = false;
return;
}
if (maybeTagsAndAliasesAfterUpdate) {
this.emit('tags-updated', maybeTagsAndAliasesAfterUpdate);
this.#emitter.emit(eventTagsUpdated, maybeTagsAndAliasesAfterUpdate);
}
this.emit('maintenance-state-change', 'complete');
this.#emitter.emit(eventMaintenanceStateChanged, 'complete');
this.#tagsToAdd.clear();
this.#tagsToRemove.clear();
@@ -228,6 +280,36 @@ export class MaintenancePopup extends BaseComponent {
this.#isSubmitting = false;
}
#revealInvalidTags() {
const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases;
if (!tagsAndAliases) {
return;
}
const firstTagInList = this.#tagsList[0];
for (let tagName of tagsBlacklist) {
if (tagsAndAliases.has(tagName)) {
if (this.#suggestedInvalidTags.has(tagName)) {
continue;
}
const tagElement = MaintenancePopup.#buildTagElement(tagName);
MaintenancePopup.#markTagAsInvalid(tagElement);
tagElement.classList.add('is-present');
this.#suggestedInvalidTags.set(tagName, tagElement);
if (firstTagInList && firstTagInList.isConnected) {
this.#tagsListElement.insertBefore(tagElement, firstTagInList);
} else {
this.#tagsListElement.appendChild(tagElement);
}
}
}
}
/**
* @return {boolean}
*/
@@ -248,6 +330,15 @@ export class MaintenancePopup extends BaseComponent {
return tagElement;
}
/**
* Marks the tag with red color.
* @param {HTMLElement} tagElement Element to mark.
*/
static #markTagAsInvalid(tagElement) {
tagElement.dataset.tagCategory = 'error';
tagElement.setAttribute('data-tag-category', 'error');
}
/**
* Controller with maintenance settings.
* @type {MaintenanceSettings}

View File

@@ -1,8 +1,10 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
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";
export class MaintenanceStatusIcon extends BaseComponent {
/** @type {import('MediaBoxTools.js').MediaBoxTools} */
/** @type {import('./MediaBoxTools').MediaBoxTools} */
#mediaBoxTools;
build() {
@@ -16,7 +18,7 @@ export class MaintenanceStatusIcon extends BaseComponent {
throw new Error('Status icon element initialized outside of the media box!');
}
this.#mediaBoxTools.on('maintenance-state-change', this.#onMaintenanceStateChanged.bind(this));
on(this.#mediaBoxTools, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this));
}
/**

View File

@@ -1,9 +1,11 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import {MaintenancePopup} from "$lib/components/MaintenancePopup.js";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { MaintenancePopup } from "$lib/components/MaintenancePopup";
import { on } from "$lib/components/events/comms";
import { eventActiveProfileChanged } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxTools extends BaseComponent {
/** @type {import('MediaBoxWrapper.js').MediaBoxWrapper|null} */
/** @type {import('./MediaBoxWrapper').MediaBoxWrapper|null} */
#mediaBox;
/** @type {MaintenancePopup|null} */
@@ -34,11 +36,11 @@ export class MediaBoxTools extends BaseComponent {
}
}
this.on('active-profile-changed', this.#onActiveProfileChanged.bind(this));
on(this, eventActiveProfileChanged, this.#onActiveProfileChanged.bind(this));
}
/**
* @param {CustomEvent<MaintenanceProfile|null>} profileChangedEvent
* @param {CustomEvent<import('$entities/MaintenanceProfile').default|null>} profileChangedEvent
*/
#onActiveProfileChanged(profileChangedEvent) {
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
@@ -52,7 +54,7 @@ export class MediaBoxTools extends BaseComponent {
}
/**
* @return {import('MediaBoxWrapper.js').MediaBoxWrapper|null}
* @return {import('./MediaBoxWrapper').MediaBoxWrapper|null}
*/
get mediaBox() {
return this.#mediaBox;
@@ -61,7 +63,7 @@ export class MediaBoxTools extends BaseComponent {
/**
* Create a maintenance popup element.
* @param {HTMLElement} childrenElements List of children elements to append to the component.
* @param {HTMLElement[]} childrenElements List of children elements to append to the component.
* @return {HTMLElement} The maintenance popup element.
*/
export function createMediaBoxTools(...childrenElements) {

View File

@@ -1,6 +1,8 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import {buildTagsAndAliasesMap} from "$lib/booru/TagsUtils.js";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
import { on } from "$lib/components/events/comms";
import { eventTagsUpdated } from "$lib/components/events/maintenance-popup-events";
export class MediaBoxWrapper extends BaseComponent {
#thumbnailContainer = null;
@@ -13,11 +15,11 @@ export class MediaBoxWrapper extends BaseComponent {
this.#thumbnailContainer = this.container.querySelector('.image-container');
this.#imageLinkElement = this.#thumbnailContainer.querySelector('a');
this.on('tags-updated', this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
on(this, eventTagsUpdated, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
}
/**
* @param {CustomEvent<Map<string,string>>} tagsUpdatedEvent
* @param {CustomEvent<Map<string,string>|null>} tagsUpdatedEvent
*/
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent) {
const updatedMap = tagsUpdatedEvent.detail;
@@ -56,7 +58,7 @@ export class MediaBoxWrapper extends BaseComponent {
}
/**
* @return {ImageURIs}
* @return {App.ImageURIs}
*/
get imageLinks() {
return JSON.parse(this.#thumbnailContainer.dataset.uris);
@@ -100,10 +102,3 @@ export function calculateMediaBoxesPositions(mediaBoxesList) {
}
})
}
/**
* @typedef {Object} ImageURIs
* @property {string} full
* @property {string} large
* @property {string} small
*/

View File

@@ -1,6 +1,6 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {QueryLexer, QuotedTermToken, TermToken, Token} from "$lib/booru/search/QueryLexer.js";
import SearchSettings from "$lib/extension/settings/SearchSettings.js";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer";
import SearchSettings from "$lib/extension/settings/SearchSettings";
export class SearchWrapper extends BaseComponent {
/** @type {HTMLInputElement|null} */
@@ -289,6 +289,10 @@ export class SearchWrapper extends BaseComponent {
suggestionItem.dataset.value = suggestedTerm;
suggestionItem.innerText = suggestedTerm;
const propertyIcon = document.createElement('i');
propertyIcon.classList.add('fa', 'fa-info-circle');
suggestionItem.insertAdjacentElement('afterbegin', propertyIcon);
suggestionItem.addEventListener('mouseover', () => {
SearchWrapper.#findAndResetSelectedSuggestion(suggestionItem);
suggestionItem.classList.add('autocomplete__item--selected');
@@ -347,24 +351,42 @@ export class SearchWrapper extends BaseComponent {
static #typeDate = Symbol();
static #typeLiteral = Symbol();
static #typePersonal = Symbol();
static #typeBoolean = Symbol();
static #properties = new Map([
['animated', SearchWrapper.#typeBoolean],
['aspect_ratio', SearchWrapper.#typeNumeric],
['body_type_tag_count', SearchWrapper.#typeNumeric],
['character_tag_count', SearchWrapper.#typeNumeric],
['comment_count', SearchWrapper.#typeNumeric],
['content_fanmade_tag_count', SearchWrapper.#typeNumeric],
['content_official_tag_count', SearchWrapper.#typeNumeric],
['created_at', SearchWrapper.#typeDate],
['description', SearchWrapper.#typeLiteral],
['downvotes', SearchWrapper.#typeNumeric],
['duration', SearchWrapper.#typeNumeric],
['error_tag_count', SearchWrapper.#typeNumeric],
['faved_by', SearchWrapper.#typeLiteral],
['faved_by_id', SearchWrapper.#typeNumeric],
['faves', SearchWrapper.#typeNumeric],
['file_name', SearchWrapper.#typeLiteral],
['first_seen_at', SearchWrapper.#typeDate],
['height', SearchWrapper.#typeNumeric],
['id', SearchWrapper.#typeNumeric],
['oc_tag_count', SearchWrapper.#typeNumeric],
['orig_sha512_hash', SearchWrapper.#typeLiteral],
['original_format', SearchWrapper.#typeLiteral],
['pixels', SearchWrapper.#typeNumeric],
['rating_tag_count', SearchWrapper.#typeNumeric],
['score', SearchWrapper.#typeNumeric],
['sha512_hash', SearchWrapper.#typeLiteral],
['size', SearchWrapper.#typeNumeric],
['source_count', SearchWrapper.#typeNumeric],
['source_url', SearchWrapper.#typeLiteral],
['species_tag_count', SearchWrapper.#typeNumeric],
['spoiler_tag_count', SearchWrapper.#typeNumeric],
['tag_count', SearchWrapper.#typeNumeric],
['updated_at', SearchWrapper.#typeDate],
['uploader', SearchWrapper.#typeLiteral],
['uploader_id', SearchWrapper.#typeNumeric],
['upvotes', SearchWrapper.#typeNumeric],
@@ -388,6 +410,10 @@ export class SearchWrapper extends BaseComponent {
'uploads',
'upvotes',
'watched',
]],
[SearchWrapper.#typeBoolean, [
'true',
'false',
]]
]);
}

View File

@@ -1,5 +1,5 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {SearchWrapper} from "$lib/components/SearchWrapper.js";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { SearchWrapper } from "$lib/components/SearchWrapper";
class SiteHeaderWrapper extends BaseComponent {
/** @type {SearchWrapper|null} */

View File

@@ -1,11 +1,13 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import { BaseComponent } from "$lib/components/base/BaseComponent";
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";
const isTagEditorProcessedKey = Symbol();
const categoriesResolver = new CustomCategoriesResolver();
class TagDropdownWrapper extends BaseComponent {
export class TagDropdownWrapper extends BaseComponent {
/**
* Container with dropdown elements to insert options into.
* @type {HTMLElement}
@@ -36,6 +38,11 @@ class TagDropdownWrapper extends BaseComponent {
*/
#isEntered = false;
/**
* @type {string|undefined|null}
*/
#originalCategory = null;
build() {
this.#dropdownContainer = this.container.querySelector('.dropdown__content');
}
@@ -53,10 +60,45 @@ class TagDropdownWrapper extends BaseComponent {
});
}
get #tagName() {
get tagName() {
return this.container.dataset.tagName;
}
/**
* @return {string|undefined}
*/
get tagCategory() {
return this.container.dataset.tagCategory;
}
/**
* @param {string|undefined} targetCategory
*/
set tagCategory(targetCategory) {
// Make sure original category is properly stored.
this.originalCategory;
this.container.dataset.tagCategory = targetCategory;
if (targetCategory) {
this.container.setAttribute('data-tag-category', targetCategory);
return;
}
this.container.removeAttribute('data-tag-category');
}
/**
* @return {string|undefined}
*/
get originalCategory() {
if (this.#originalCategory === null) {
this.#originalCategory = this.tagCategory;
}
return this.#originalCategory;
}
#onDropdownEntered() {
this.#isEntered = true;
this.#updateButtons();
@@ -89,7 +131,7 @@ class TagDropdownWrapper extends BaseComponent {
const profileName = this.#activeProfile.settings.name;
let profileSpecificButtonText = `Add to profile "${profileName}"`;
if (this.#activeProfile.settings.tags.includes(this.#tagName)) {
if (this.#activeProfile.settings.tags.includes(this.tagName)) {
profileSpecificButtonText = `Remove from profile "${profileName}"`;
}
@@ -108,7 +150,8 @@ class TagDropdownWrapper extends BaseComponent {
async #onAddToNewClicked() {
const profile = new MaintenanceProfile(crypto.randomUUID(), {
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
tags: [this.#tagName]
tags: [this.tagName],
temporary: true,
});
await profile.save();
@@ -121,7 +164,7 @@ class TagDropdownWrapper extends BaseComponent {
}
const tagsList = new Set(this.#activeProfile.settings.tags);
const targetTagName = this.#tagName;
const targetTagName = this.tagName;
if (tagsList.has(targetTagName)) {
tagsList.delete(targetTagName);
@@ -195,7 +238,10 @@ export function wrapTagDropdown(element) {
return;
}
new TagDropdownWrapper(element).initialize();
const tagDropdown = new TagDropdownWrapper(element);
tagDropdown.initialize();
categoriesResolver.addElement(tagDropdown);
}
export function watchTagDropdownsInTagsEditor() {

View File

@@ -1,5 +1,5 @@
import {BaseComponent} from "$lib/components/base/BaseComponent.js";
import {getComponent} from "$lib/components/base/ComponentUtils.js";
import { BaseComponent } from "$lib/components/base/BaseComponent";
import { getComponent } from "$lib/components/base/component-utils";
export class TagsForm extends BaseComponent {
/**

View File

@@ -1,4 +1,4 @@
import {bindComponent} from "$lib/components/base/ComponentUtils.js";
import { bindComponent } from "$lib/components/base/component-utils";
/**
* @abstract
@@ -45,7 +45,6 @@ export class BaseComponent {
/**
* @return {HTMLElement}
* @protected
*/
get container() {
return this.#container;

View File

@@ -1,22 +0,0 @@
const instanceSymbol = Symbol('instance');
/**
* @param {HTMLElement} element
* @return {import('./BaseComponent.js').BaseComponent|null}
*/
export function getComponent(element) {
return element[instanceSymbol] || null;
}
/**
* Bind the component to the selected element.
* @param {HTMLElement} element The element to bind the component to.
* @param {import('./BaseComponent.js').BaseComponent} instance The component instance.
*/
export function bindComponent(element, instance) {
if (element[instanceSymbol]) {
throw new Error('The element is already bound to a component.');
}
element[instanceSymbol] = instance;
}

View File

@@ -0,0 +1,29 @@
import type { BaseComponent } from "$lib/components/base/BaseComponent";
const instanceSymbol = Symbol('instance');
interface ElementWithComponent extends HTMLElement {
[instanceSymbol]?: BaseComponent;
}
/**
* Get the component from the element, if there is one.
* @param {HTMLElement} element
* @return
*/
export function getComponent(element: ElementWithComponent): BaseComponent | null {
return element[instanceSymbol] || null;
}
/**
* Bind the component to the selected element.
* @param element The element to bind the component to.
* @param instance The component instance.
*/
export function bindComponent(element: ElementWithComponent, instance: BaseComponent): void {
if (element[instanceSymbol]) {
throw new Error('The element is already bound to a component.');
}
element[instanceSymbol] = instance;
}

View File

@@ -0,0 +1,92 @@
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
import { BaseComponent } from "$lib/components/base/BaseComponent";
interface EventsMapping extends MaintenancePopupEventsMap {
}
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
type UnsubscribeFunction = () => void;
type ResolvableTarget = EventTarget | BaseComponent;
function resolveTarget(componentOrElement: ResolvableTarget): EventTarget {
if (componentOrElement instanceof BaseComponent) {
return componentOrElement.container;
}
return componentOrElement;
}
export function emit<Event extends keyof EventsMapping>(
targetOrComponent: ResolvableTarget,
event: Event,
details: EventsMapping[Event]
) {
const target = resolveTarget(targetOrComponent);
target.dispatchEvent(
new CustomEvent(event, {
detail: details,
bubbles: true
})
);
}
export function on<Event extends keyof EventsMapping>(
targetOrComponent: ResolvableTarget,
eventName: Event,
callback: EventCallback<EventsMapping[Event]>,
options: AddEventListenerOptions | null = null
): UnsubscribeFunction {
const target = resolveTarget(targetOrComponent);
const controller = new AbortController();
target.addEventListener(
eventName,
callback as EventListener,
{
signal: controller.signal,
once: options?.once
}
);
return () => controller.abort();
}
const onceOptions = {once: true};
export function once<Event extends keyof EventsMapping>(
targetOrComponent: ResolvableTarget,
eventName: Event,
callback: EventCallback<EventsMapping[Event]>
): UnsubscribeFunction {
return on(
targetOrComponent,
eventName,
callback,
onceOptions
);
}
class TargetedEmitter {
readonly #element: ResolvableTarget;
constructor(targetOrComponent: ResolvableTarget) {
this.#element = targetOrComponent;
}
emit<Event extends keyof EventsMapping>(eventName: Event, details: EventsMapping[Event]): void {
emit(this.#element, eventName, details);
}
on<Event extends keyof EventsMapping>(eventName: Event, callback: EventCallback<EventsMapping[Event]>, options: AddEventListenerOptions | null = null): UnsubscribeFunction {
return on(this.#element, eventName, callback, options);
}
once<Event extends keyof EventsMapping>(eventName: Event, callback: EventCallback<EventsMapping[Event]>): UnsubscribeFunction {
return once(this.#element, eventName, callback);
}
}
export function emitterAt(targetOrComponent: ResolvableTarget): TargetedEmitter {
return new TargetedEmitter(targetOrComponent);
}

View File

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

View File

@@ -1,25 +1,24 @@
import StorageHelper from "$lib/browser/StorageHelper.js";
import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/StorageHelper";
export default class ConfigurationController {
/** @type {string} */
#configurationName;
readonly #configurationName: string;
/**
* @param {string} configurationName Name of the configuration to work with.
*/
constructor(configurationName) {
constructor(configurationName: string) {
this.#configurationName = configurationName;
}
/**
* Read the setting with the given name.
*
* @param {string} settingName Setting name.
* @param {any} [defaultValue] Default value to return if the setting does not exist. Defaults to `null`.
* @param settingName Setting name.
* @param [defaultValue] Default value to return if the setting does not exist. Defaults to `null`.
*
* @return {Promise<any|null>} The setting value or the default value if the setting does not exist.
* @return The setting value or the default value if the setting does not exist.
*/
async readSetting(settingName, defaultValue = null) {
async readSetting<Type = any, DefaultType = any>(settingName: string, defaultValue: DefaultType | null = null): Promise<Type | DefaultType> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
return settings[settingName] ?? defaultValue;
}
@@ -27,12 +26,12 @@ export default class ConfigurationController {
/**
* Write the given value to the setting.
*
* @param {string} settingName Setting name.
* @param {any} value Value to write.
* @param settingName Setting name.
* @param value Value to write.
*
* @return {Promise<void>}
*/
async writeSetting(settingName, value) {
async writeSetting(settingName: string, value: any): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
settings[settingName] = value;
@@ -44,10 +43,8 @@ export default class ConfigurationController {
* Delete the specific setting.
*
* @param {string} settingName Setting name to delete.
*
* @return {Promise<void>}
*/
async deleteSetting(settingName) {
async deleteSetting(settingName: string): Promise<void> {
const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {});
delete settings[settingName];
@@ -63,9 +60,8 @@ export default class ConfigurationController {
*
* @return {function(): void} Unsubscribe function.
*/
subscribeToChanges(callback) {
/** @param {Record<string, StorageChange>} changes */
const changesSubscriber = changes => {
subscribeToChanges(callback: (record: Record<string, any>) => void): () => void {
const subscriber: StorageChangeSubscriber = changes => {
if (!changes[this.#configurationName]) {
return;
}
@@ -73,9 +69,9 @@ export default class ConfigurationController {
callback(changes[this.#configurationName].newValue);
}
ConfigurationController.#storageHelper.subscribe(changesSubscriber);
ConfigurationController.#storageHelper.subscribe(subscriber);
return () => ConfigurationController.#storageHelper.unsubscribe(changesSubscriber);
return () => ConfigurationController.#storageHelper.unsubscribe(subscriber);
}
static #storageHelper = new StorageHelper(chrome.storage.local);

View File

@@ -0,0 +1,110 @@
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
import TagGroup from "$entities/TagGroup";
import { escapeRegExp } from "$lib/utils";
export default class CustomCategoriesResolver {
#tagCategories = new Map<string, string>();
#compiledRegExps = new Map<RegExp, string>();
#tagDropdowns: TagDropdownWrapper[] = [];
#nextQueuedUpdate = -1;
constructor() {
TagGroup.subscribe(this.#onTagGroupsReceived.bind(this));
TagGroup.readAll().then(this.#onTagGroupsReceived.bind(this));
}
public addElement(tagDropdown: TagDropdownWrapper): void {
this.#tagDropdowns.push(tagDropdown);
if (!this.#tagCategories.size && !this.#compiledRegExps.size) {
return;
}
this.#queueUpdatingTags();
}
#queueUpdatingTags() {
clearTimeout(this.#nextQueuedUpdate);
this.#nextQueuedUpdate = setTimeout(
this.#updateUnprocessedTags.bind(this),
CustomCategoriesResolver.#unprocessedTagsTimeout
);
}
#updateUnprocessedTags() {
this.#tagDropdowns
.filter(CustomCategoriesResolver.#skipTagsWithOriginalCategory)
.filter(this.#applyCustomCategoryForExactMatches.bind(this))
.filter(this.#matchCustomCategoryByRegExp.bind(this))
.forEach(CustomCategoriesResolver.#resetToOriginalCategory);
}
/**
* Apply custom categories for the exact tag names.
* @param tagDropdown Element to try applying the category for.
* @return {boolean} Will return false when tag is processed and true when it is not found.
* @private
*/
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
const tagName = tagDropdown.tagName!;
if (!this.#tagCategories.has(tagName)) {
return true;
}
tagDropdown.tagCategory = this.#tagCategories.get(tagName)!;
return false;
}
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
const tagName = tagDropdown.tagName!;
for (const targetRegularExpression of this.#compiledRegExps.keys()) {
if (!targetRegularExpression.test(tagName)) {
continue;
}
tagDropdown.tagCategory = this.#compiledRegExps.get(targetRegularExpression)!;
return false;
}
return true;
}
#onTagGroupsReceived(tagGroups: TagGroup[]) {
this.#tagCategories.clear();
this.#compiledRegExps.clear();
if (!tagGroups.length) {
return;
}
for (const tagGroup of tagGroups) {
const categoryName = tagGroup.settings.category;
for (const tagName of tagGroup.settings.tags) {
this.#tagCategories.set(tagName, categoryName);
}
for (const tagPrefix of tagGroup.settings.prefixes) {
this.#compiledRegExps.set(
new RegExp(`^${escapeRegExp(tagPrefix)}`),
categoryName
);
}
}
this.#queueUpdatingTags();
}
static #skipTagsWithOriginalCategory(tagDropdown: TagDropdownWrapper): boolean {
return !tagDropdown.originalCategory;
}
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
tagDropdown.tagCategory = tagDropdown.originalCategory;
}
static #unprocessedTagsTimeout = 0;
}

View File

@@ -1,5 +1,5 @@
import StorageHelper from "$lib/browser/StorageHelper.js";
import type StorageEntity from "$lib/extension/base/StorageEntity.ts";
import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/StorageHelper";
import type StorageEntity from "$lib/extension/base/StorageEntity";
export default class EntitiesController {
static #storageHelper = new StorageHelper(chrome.storage.local);
@@ -71,7 +71,7 @@ export default class EntitiesController {
/**
* Watch the changes made to the storage and call the callback when the entity changes.
*/
const storageChangesSubscriber = (changes: Record<string, chrome.storage.StorageChange>) => {
const subscriber: StorageChangeSubscriber = changes => {
if (!changes[entityName]) {
return;
}
@@ -80,8 +80,8 @@ export default class EntitiesController {
.then(callback);
}
this.#storageHelper.subscribe(storageChangesSubscriber);
this.#storageHelper.subscribe(subscriber);
return () => this.#storageHelper.unsubscribe(storageChangesSubscriber);
return () => this.#storageHelper.unsubscribe(subscriber);
}
}

View File

@@ -1,7 +1,7 @@
import {validateImportedEntity} from "$lib/extension/transporting/validators.js";
import {exportEntityToObject} from "$lib/extension/transporting/exporters.ts";
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
import {compressToEncodedURIComponent, decompressFromEncodedURIComponent} from "lz-string";
import { validateImportedEntity } from "$lib/extension/transporting/validators";
import { exportEntityToObject } from "$lib/extension/transporting/exporters";
import StorageEntity from "$lib/extension/base/StorageEntity";
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
export default class EntitiesTransporter<EntityType> {
readonly #targetEntityConstructor: new (...any: any[]) => EntityType;

View File

@@ -1,20 +1,20 @@
import ConfigurationController from "$lib/extension/ConfigurationController.js";
import ConfigurationController from "$lib/extension/ConfigurationController";
export default class CacheableSettings {
/** @type {ConfigurationController} */
#controller;
/** @type {Map<string, any>} */
#cachedValues = new Map();
/** @type {function[]} */
#disposables = [];
export default class CacheableSettings<Fields> {
#controller: ConfigurationController;
#cachedValues: Map<keyof Fields, any> = new Map();
#disposables: Function[] = [];
constructor(settingsNamespace) {
constructor(settingsNamespace: string) {
this.#controller = new ConfigurationController(settingsNamespace);
this.#disposables.push(
this.#controller.subscribeToChanges(settings => {
for (const key of Object.keys(settings)) {
this.#cachedValues.set(key, settings[key]);
this.#cachedValues.set(
key as keyof Fields,
settings[key]
);
}
})
);
@@ -27,12 +27,12 @@ export default class CacheableSettings {
* @return {Promise<SettingType>}
* @protected
*/
async _resolveSetting(settingName, defaultValue) {
protected async _resolveSetting<Key extends keyof Fields>(settingName: Key, defaultValue: Fields[Key]): Promise<Fields[Key]> {
if (this.#cachedValues.has(settingName)) {
return this.#cachedValues.get(settingName);
}
const settingValue = await this.#controller.readSetting(settingName, defaultValue);
const settingValue = await this.#controller.readSetting(settingName as string, defaultValue);
this.#cachedValues.set(settingName, settingValue);
@@ -40,13 +40,12 @@ export default class CacheableSettings {
}
/**
* @param {string} settingName Name of the setting to write.
* @param {*} value Value to pass.
* @param {boolean} [force=false] Ignore the cache and force the update.
* @return {Promise<void>}
* @param settingName Name of the setting to write.
* @param value Value to pass.
* @param force Ignore the cache and force the update.
* @protected
*/
async _writeSetting(settingName, value, force = false) {
async _writeSetting<Key extends keyof Fields>(settingName: Key, value: Fields[Key], force: boolean = false): Promise<void> {
if (
!force
&& this.#cachedValues.has(settingName)
@@ -55,7 +54,10 @@ export default class CacheableSettings {
return;
}
return this.#controller.writeSetting(settingName, value);
return this.#controller.writeSetting(
settingName as string,
value
);
}
/**
@@ -63,8 +65,8 @@ export default class CacheableSettings {
* @param {function(Object): void} callback Callback which will receive list of settings.
* @return {function(): void} Unsubscribe function.
*/
subscribe(callback) {
const unsubscribeCallback = this.#controller.subscribeToChanges(callback);
subscribe(callback: (settings: Partial<Fields>) => void): () => void {
const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record<string, any>) => void);
this.#disposables.push(unsubscribeCallback);

View File

@@ -1,4 +1,4 @@
import EntitiesController from "$lib/extension/EntitiesController.js";
import EntitiesController from "$lib/extension/EntitiesController";
export default abstract class StorageEntity<SettingsType extends Object = {}> {
/**

View File

@@ -1,9 +1,9 @@
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
import EntitiesController from "$lib/extension/EntitiesController.ts";
import StorageEntity from "$lib/extension/base/StorageEntity";
export interface MaintenanceProfileSettings {
name: string;
tags: string[];
temporary: boolean;
}
/**
@@ -17,9 +17,18 @@ export default class MaintenanceProfile extends StorageEntity<MaintenanceProfile
constructor(id: string, settings: Partial<MaintenanceProfileSettings>) {
super(id, {
name: settings.name || '',
tags: settings.tags || []
tags: settings.tags || [],
temporary: settings.temporary ?? false
});
}
async save(): Promise<void> {
if (this.settings.temporary && !this.settings.tags?.length) {
return this.delete();
}
return super.save();
}
public static readonly _entityName = "profiles";
}

View File

@@ -0,0 +1,21 @@
import StorageEntity from "$lib/extension/base/StorageEntity";
export interface TagGroupSettings {
name: string;
tags: string[];
prefixes: string[];
category: string;
}
export default class TagGroup extends StorageEntity<TagGroupSettings> {
constructor(id: string, settings: Partial<TagGroupSettings>) {
super(id, {
name: settings.name || '',
tags: settings.tags || [],
prefixes: settings.prefixes || [],
category: settings.category || ''
});
}
static _entityName = 'groups';
}

View File

@@ -1,63 +0,0 @@
import ConfigurationController from "$lib/extension/ConfigurationController.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
export default class MaintenanceSettings extends CacheableSettings {
constructor() {
super("maintenance");
}
/**
* Set the active maintenance profile.
*
* @return {Promise<string|null>}
*/
async resolveActiveProfileId() {
return this._resolveSetting("activeProfile", null);
}
/**
* Get the active maintenance profile if it is set.
*
* @return {Promise<MaintenanceProfile|null>}
*/
async resolveActiveProfileAsObject() {
const resolvedProfileId = await this.resolveActiveProfileId();
return (await MaintenanceProfile.readAll())
.find(profile => profile.id === resolvedProfileId) || null;
}
/**
* Set the active maintenance profile.
*
* @param {string|null} profileId ID of the profile to set as active. If `null`, the active profile will be considered
* unset.
*
* @return {Promise<void>}
*/
async setActiveProfileId(profileId) {
await this._writeSetting("activeProfile", profileId);
}
/**
* Subscribe to the changes in the maintenance-related settings.
*
* @param {function(MaintenanceSettingsObject): void} callback Callback to call when the settings change. The new
* settings are passed as an argument.
*
* @return {function(): void} Unsubscribe function.
*/
subscribe(callback) {
return super.subscribe(settings => {
callback({
activeProfile: settings.activeProfile || null,
});
});
}
}
/**
* @typedef {Object} MaintenanceSettingsObject
* @property {string|null} activeProfile
*/

View File

@@ -0,0 +1,48 @@
import MaintenanceProfile from "$entities/MaintenanceProfile";
import CacheableSettings from "$lib/extension/base/CacheableSettings";
interface MaintenanceSettingsFields {
activeProfile: string | null;
stripBlacklistedTags: boolean;
}
export default class MaintenanceSettings extends CacheableSettings<MaintenanceSettingsFields> {
constructor() {
super("maintenance");
}
/**
* Set the active maintenance profile.
*/
async resolveActiveProfileId() {
return this._resolveSetting("activeProfile", null);
}
/**
* Get the active maintenance profile if it is set.
*/
async resolveActiveProfileAsObject(): Promise<MaintenanceProfile | null> {
const resolvedProfileId = await this.resolveActiveProfileId();
return (await MaintenanceProfile.readAll())
.find(profile => profile.id === resolvedProfileId) || null;
}
async resolveStripBlacklistedTags() {
return this._resolveSetting('stripBlacklistedTags', false);
}
/**
* Set the active maintenance profile.
*
* @param profileId ID of the profile to set as active. If `null`, the active profile will be considered
* unset.
*/
async setActiveProfileId(profileId: string | null): Promise<void> {
await this._writeSetting("activeProfile", profileId);
}
async setStripBlacklistedTags(isEnabled: boolean) {
await this._writeSetting('stripBlacklistedTags', isEnabled);
}
}

View File

@@ -1,39 +0,0 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
export default class MiscSettings extends CacheableSettings {
constructor() {
super("misc");
}
/**
* @return {Promise<boolean>}
*/
async resolveFullscreenViewerEnabled() {
return this._resolveSetting("fullscreenViewer", true);
}
/**
* @param {boolean} isEnabled
* @return {Promise<void>}
*/
async setFullscreenViewerEnabled(isEnabled) {
return this._writeSetting("fullscreenViewer", isEnabled);
}
/**
* @param {function(MiscSettingsObject): void} callback
* @return {function(): void}
*/
subscribe(callback) {
return super.subscribe(settings => {
callback({
fullscreenViewer: settings.fullscreenViewer ?? true,
})
});
}
}
/**
* @typedef {Object} MiscSettingsObject
* @property {boolean} fullscreenViewer
*/

View File

@@ -0,0 +1,30 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full';
interface MiscSettingsFields {
fullscreenViewer: boolean;
fullscreenViewerSize: FullscreenViewerSize;
}
export default class MiscSettings extends CacheableSettings<MiscSettingsFields> {
constructor() {
super("misc");
}
async resolveFullscreenViewerEnabled() {
return this._resolveSetting("fullscreenViewer", true);
}
async resolveFullscreenViewerPreviewSize() {
return this._resolveSetting('fullscreenViewerSize', 'large');
}
async setFullscreenViewerEnabled(isEnabled: boolean) {
return this._writeSetting("fullscreenViewer", isEnabled);
}
async setFullscreenViewerPreviewSize(size: FullscreenViewerSize | string) {
return this._writeSetting('fullscreenViewerSize', size as FullscreenViewerSize);
}
}

View File

@@ -1,42 +0,0 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings.js";
export default class SearchSettings extends CacheableSettings {
constructor() {
super("search");
}
async resolvePropertiesSuggestionsEnabled() {
return this._resolveSetting("suggestProperties", false);
}
async resolvePropertiesSuggestionsPosition() {
return this._resolveSetting("suggestPropertiesPosition", "start");
}
async setPropertiesSuggestions(isEnabled) {
return this._writeSetting("suggestProperties", isEnabled);
}
async setPropertiesSuggestionsPosition(position) {
return this._writeSetting("suggestPropertiesPosition", position);
}
/**
* @param {function(SearchSettingsObject): void} callback
* @return {function(): void}
*/
subscribe(callback) {
return super.subscribe(rawSettings => {
callback({
suggestProperties: rawSettings.suggestProperties ?? false,
suggestPropertiesPosition: rawSettings.suggestPropertiesPosition ?? "start",
});
});
}
}
/**
* @typedef {Object} SearchSettingsObject
* @property {boolean} suggestProperties
* @property {"start"|"end"} suggestPropertiesPosition
*/

View File

@@ -0,0 +1,28 @@
import CacheableSettings from "$lib/extension/base/CacheableSettings";
interface SearchSettingsFields {
suggestProperties: boolean;
suggestPropertiesPosition: "start" | "end";
}
export default class SearchSettings extends CacheableSettings<SearchSettingsFields> {
constructor() {
super("search");
}
async resolvePropertiesSuggestionsEnabled() {
return this._resolveSetting("suggestProperties", false);
}
async resolvePropertiesSuggestionsPosition() {
return this._resolveSetting("suggestPropertiesPosition", "start");
}
async setPropertiesSuggestions(isEnabled: boolean) {
return this._writeSetting("suggestProperties", isEnabled);
}
async setPropertiesSuggestionsPosition(position: "start" | "end") {
return this._writeSetting("suggestPropertiesPosition", position);
}
}

View File

@@ -1,4 +1,4 @@
import StorageEntity from "$lib/extension/base/StorageEntity.ts";
import StorageEntity from "$lib/extension/base/StorageEntity";
type ExportersMap = {
[EntityName in keyof App.EntityNamesMap]: (entity: App.EntityNamesMap[EntityName]) => Record<string, any>
@@ -13,6 +13,15 @@ const entitiesExporters: ExportersMap = {
tags: entity.settings.tags,
}
},
groups: entity => {
return {
v: 1,
id: entity.id,
name: entity.settings.name,
tags: entity.settings.tags,
prefixes: entity.settings.prefixes,
}
}
};
export function exportEntityToObject(entityInstance: StorageEntity<any>, entityName: string): Record<string, any> {

View File

@@ -1,39 +0,0 @@
/**
* Map of validators for each entity. Function should throw the error if validation failed.
* @type {Map<keyof App.EntityNamesMap|string, ((importedObject: Object) => void)>}
*/
const entitiesValidators = new Map([
['profiles', importedObject => {
if (importedObject.v !== 1) {
throw new Error('Unsupported version!');
}
if (
!importedObject.id
|| typeof importedObject.id !== "string"
|| !importedObject.name
|| typeof importedObject.name !== "string"
|| !importedObject.tags
|| !Array.isArray(importedObject.tags)
) {
throw new Error('Invalid profile format detected!');
}
}]
])
/**
* Validate the structure of the entity.
* @param {Object} importedObject Object imported from JSON.
* @param {string} entityName Name of the entity to validate. Should be loaded from the entity class.
* @throws {Error} Error in case validation failed with the reason stored in the message.
*/
export function validateImportedEntity(importedObject, entityName) {
if (!entitiesValidators.has(entityName)) {
console.error(`Trying to validate entity without the validator present! Entity name: ${entityName}`);
return;
}
entitiesValidators
.get(entityName)
.call(null, importedObject);
}

View File

@@ -0,0 +1,74 @@
import type StorageEntity from "$lib/extension/base/StorageEntity";
/**
* Base information on the object which should be present on every entity.
*/
interface BaseImportableObject {
/**
* Numeric version of the entity for upgrading.
*/
v: number;
/**
* Unique ID of the entity.
*/
id: string;
}
/**
* Utility type which combines base importable object and the entity type interfaces together. It strips away any types
* defined for the properties, since imported object can not be trusted and should be type-checked by the validators.
*/
type ImportableObject<EntityType extends StorageEntity> = { [ObjectKey in keyof BaseImportableObject]: any }
& { [SettingKey in keyof EntityType["settings"]]: any };
/**
* Function for validating the entities.
* @todo Probably would be better to replace the throw-catch method with some kind of result-error returning type.
* Errors are only properly definable in the JSDoc.
*/
type ValidationFunction<EntityType extends StorageEntity> = (importedObject: ImportableObject<EntityType>) => void;
/**
* Mapping of validation functions for all entities present in the extension. Key is a name of entity and value is a
* function which throws an error when validation is failed.
*/
type EntitiesValidationMap = {
[EntityKey in keyof App.EntityNamesMap]?: ValidationFunction<App.EntityNamesMap[EntityKey]>;
};
/**
* Map of validators for each entity. Function should throw the error if validation failed.
*/
const entitiesValidators: EntitiesValidationMap = {
profiles: importedObject => {
if (importedObject.v !== 1) {
throw new Error('Unsupported version!');
}
if (
!importedObject.id
|| typeof importedObject.id !== "string"
|| !importedObject.name
|| typeof importedObject.name !== "string"
|| !importedObject.tags
|| !Array.isArray(importedObject.tags)
) {
throw new Error('Invalid profile format detected!');
}
}
};
/**
* Validate the structure of the entity.
* @param importedObject Object imported from JSON.
* @param entityName Name of the entity to validate. Should be loaded from the entity class.
* @throws {Error} Error in case validation failed with the reason stored in the message.
*/
export function validateImportedEntity(importedObject: any, entityName: string) {
if (!entitiesValidators.hasOwnProperty(entityName)) {
console.error(`Trying to validate entity without the validator present! Entity name: ${entityName}`);
return;
}
entitiesValidators[entityName as keyof EntitiesValidationMap]!.call(null, importedObject);
}

View File

@@ -1,23 +0,0 @@
/**
* Traverse and find the object using the key path.
* @param {Object} targetObject Target object to traverse into.
* @param {string[]} path Path of keys to traverse deep into the object.
* @return {Object|null} Resulting object or null if nothing found (or target entry is not an object.
*/
export function findDeepObject(targetObject, path) {
let result = targetObject;
for (let key of path) {
if (!result || typeof result !== 'object') {
return null;
}
result = result[key];
}
if (!result || typeof result !== "object") {
return null;
}
return result;
}

41
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* Traverse and find the object using the key path.
* @param targetObject Target object to traverse into.
* @param path Path of keys to traverse deep into the object.
* @return Resulting object or null if nothing found (or target entry is not an object).
*/
export function findDeepObject(targetObject: Record<string, any>, path: string[]): Object|null {
let result = targetObject;
for (const key of path) {
if (!result || typeof result !== 'object') {
return null;
}
result = result[key];
}
if (!result || typeof result !== "object") {
return null;
}
return result;
}
/**
* Matches all the characters needing replacement.
*
* Gathered from right here: https://stackoverflow.com/a/3561711/16048617. Because I don't want to introduce some
* library for that.
*/
const unsafeRegExpCharacters: RegExp = /[/\-\\^$*+?.()|[\]{}]/g;
/**
* Escape all the RegExp syntax-related characters in the following value.
* @param value Original value.
* @return Resulting value with all needed characters escaped.
*/
export function escapeRegExp(value: string): string {
unsafeRegExpCharacters.lastIndex = 0;
return value.replace(unsafeRegExpCharacters, "\\$&");
}

View File

@@ -1,10 +1,10 @@
<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.js";
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
/** @type {import('$entities/MaintenanceProfile.ts').default|undefined} */
/** @type {import('$entities/MaintenanceProfile').default|undefined} */
let activeProfile;
$: activeProfile = $maintenanceProfilesStore.find(profile => profile.id === $activeProfileStore);
@@ -22,6 +22,7 @@
<hr>
{/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>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
<script>
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import TagCategorySelectField from "$components/ui/forms/TagCategorySelectField.svelte";
import TextField from "$components/ui/forms/TextField.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagsEditor from "$components/tags/TagsEditor.svelte";
import TagGroup from "$entities/TagGroup";
import { tagGroupsStore } from "$stores/tag-groups-store";
const groupId = $page.params.id;
/** @type {TagGroup|null} */
let targetGroup = null;
let groupName = '';
/** @type {string[]} */
let tagsList = [];
/** @type {string[]} */
let prefixesList = [];
let tagCategory = '';
if (groupId === 'new') {
targetGroup = new TagGroup(crypto.randomUUID(), {});
} else {
targetGroup = $tagGroupsStore.find(group => group.id === groupId) || null;
if (targetGroup) {
groupName = targetGroup.settings.name;
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
tagCategory = targetGroup.settings.category;
} else {
goto('/features/groups');
}
}
async function saveGroup() {
if (!targetGroup) {
console.warn('Attempting to save group, but group is not loaded yet.');
return;
}
targetGroup.settings.name = groupName;
targetGroup.settings.tags = [...tagsList];
targetGroup.settings.prefixes = [...prefixesList];
targetGroup.settings.category = tagCategory;
await targetGroup.save();
await goto(`/features/groups/${targetGroup.id}`);
}
</script>
<Menu>
<MenuItem href="/features/groups/${groupId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Group Name">
<TextField bind:value={groupName} placeholder="Group Name"></TextField>
</FormControl>
<FormControl label="Group Color">
<TagCategorySelectField bind:value={tagCategory}/>
</FormControl>
<TagsColorContainer targetCategory="{tagCategory}">
<FormControl label="Tags">
<TagsEditor bind:tags={tagsList}/>
</FormControl>
</TagsColorContainer>
<TagsColorContainer targetCategory="{tagCategory}">
<FormControl label="Tag Prefixes">
<TagsEditor bind:tags={prefixesList}/>
</FormControl>
</TagsColorContainer>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={saveGroup}>Save Group</MenuItem>
</Menu>

View File

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

View File

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

View File

@@ -2,9 +2,9 @@
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import { activeProfileStore, maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
/** @type {import('$entities/MaintenanceProfile.ts').default[]} */
/** @type {import('$entities/MaintenanceProfile').default[]} */
let profiles = [];
$: profiles = $maintenanceProfilesStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));

View File

@@ -1,18 +1,18 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import {page} from "$app/stores";
import {goto} from "$app/navigation";
import {activeProfileStore, maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import ProfileView from "$components/maintenance/ProfileView.svelte";
import { 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";
const profileId = $page.params.id;
/** @type {import('$entities/MaintenanceProfile.ts').default|null} */
/** @type {import('$entities/MaintenanceProfile').default|null} */
let profile = null;
let isActiveProfile = false;
if (profileId === 'new') {
goto('/maintenance/profiles/new/edit');
goto('/features/maintenance/new/edit');
}
$: {
@@ -26,14 +26,16 @@
}
}
$: isActiveProfile = $activeProfileStore === profileId;
let isActiveProfile = $activeProfileStore === profileId;
function activateProfile() {
if (isActiveProfile) {
return;
$: {
if (isActiveProfile && $activeProfileStore !== profileId) {
$activeProfileStore = profileId;
}
$activeProfileStore = profileId;
if (!isActiveProfile && $activeProfileStore === profileId) {
$activeProfileStore = null;
}
}
</script>
@@ -47,13 +49,9 @@
<Menu>
<hr>
<MenuItem icon="wrench" href="/features/maintenance/{profileId}/edit">Edit Profile</MenuItem>
<MenuItem icon="tag" href="#" on:click={activateProfile}>
{#if isActiveProfile}
<span>Profile is Active</span>
{:else}
<span>Activate Profile</span>
{/if}
</MenuItem>
<MenuCheckboxItem bind:checked={isActiveProfile}>
Activate Profile
</MenuCheckboxItem>
<MenuItem icon="file-export" href="/features/maintenance/{profileId}/export">
Export Profile
</MenuItem>

View File

@@ -3,10 +3,10 @@
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import { page } from "$app/stores";
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store.js";
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
const profileId = $page.params.id;
const targetProfile = $maintenanceProfilesStore.find(profile => profile.id===profileId);
const targetProfile = $maintenanceProfilesStore.find(profile => profile.id === profileId);
if (!targetProfile) {
void goto('/features/maintenance');

View File

@@ -1,14 +1,14 @@
<script>
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import TagsEditor from "$components/web-components/TagsEditor.svelte";
import 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.js";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store";
import MaintenanceProfile from "$entities/MaintenanceProfile";
/** @type {string} */
let profileId = $page.params.id;
@@ -42,6 +42,7 @@
targetProfile.settings.name = profileName;
targetProfile.settings.tags = [...tagsList];
targetProfile.settings.temporary = false;
await targetProfile.save();
await goto('/features/maintenance/' + targetProfile.id);

View File

@@ -1,13 +1,13 @@
<script>
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { maintenanceProfilesStore } from "$stores/maintenance-profiles-store.js";
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.ts";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
import MaintenanceProfile from "$entities/MaintenanceProfile";
const profileId = $page.params.id;
const profile = $maintenanceProfilesStore.find(profile => profile.id === profileId);

View File

@@ -2,12 +2,12 @@
import Menu from "$components/ui/menu/Menu.svelte";
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import MaintenanceProfile from "$entities/MaintenanceProfile";
import FormControl from "$components/ui/forms/FormControl.svelte";
import ProfileView from "$components/maintenance/ProfileView.svelte";
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
import {goto} from "$app/navigation";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter.ts";
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";
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
@@ -113,7 +113,7 @@
{/if}
<style lang="scss">
@use '../../../../styles/colors';
@use '$styles/colors';
.error, .warning {
padding: 5px 24px;

View File

@@ -6,6 +6,7 @@
<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>

View File

@@ -1,7 +1,7 @@
<script>
import MenuItem from "$components/ui/menu/MenuItem.svelte";
import Menu from "$components/ui/menu/Menu.svelte";
import {storagesCollection} from "$stores/debug.js";
import { storagesCollection } from "$stores/debug";
</script>
<Menu>

View File

@@ -1,7 +1,7 @@
<script>
import StorageViewer from "$components/debugging/StorageViewer.svelte";
import {page} from "$app/stores";
import {goto} from "$app/navigation";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
let pathString = '';
/** @type {string[]} */

View File

@@ -4,7 +4,7 @@
import FormContainer from "$components/ui/forms/FormContainer.svelte";
import FormControl from "$components/ui/forms/FormControl.svelte";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import {fullScreenViewerEnabled} from "$stores/misc-preferences.js";
import { fullScreenViewerEnabled } from "$stores/misc-preferences";
</script>
<Menu>

View File

@@ -6,7 +6,7 @@
import {
searchPropertiesSuggestionsEnabled,
searchPropertiesSuggestionsPosition
} from "$stores/search-preferences.js";
} from "$stores/search-preferences";
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
import SelectField from "$components/ui/forms/SelectField.svelte";

View File

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

View File

@@ -1,4 +1,4 @@
import {writable} from "svelte/store";
import { writable } from "svelte/store";
/**
* This is readable version of storages. Any changes made to these objects will not be sent to the local storage.

View File

@@ -0,0 +1,18 @@
import { writable } from "svelte/store";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
export const stripBlacklistedTagsEnabled = writable(true);
const maintenanceSettings = new MaintenanceSettings();
Promise
.all([
maintenanceSettings.resolveStripBlacklistedTags().then(v => stripBlacklistedTagsEnabled.set(v ?? true))
])
.then(() => {
maintenanceSettings.subscribe(settings => {
stripBlacklistedTagsEnabled.set(typeof settings.stripBlacklistedTags === 'boolean' ? settings.stripBlacklistedTags : true);
});
stripBlacklistedTagsEnabled.subscribe(v => maintenanceSettings.setStripBlacklistedTags(v));
});

View File

@@ -1,6 +1,6 @@
import {writable} from "svelte/store";
import MaintenanceProfile from "$entities/MaintenanceProfile.ts";
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.js";
import { 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.

View File

@@ -1,5 +1,5 @@
import {writable} from "svelte/store";
import MiscSettings from "$lib/extension/settings/MiscSettings.js";
import { writable } from "svelte/store";
import MiscSettings from "$lib/extension/settings/MiscSettings";
export const fullScreenViewerEnabled = writable(true);
@@ -13,6 +13,6 @@ Promise.allSettled([
});
miscSettings.subscribe(settings => {
fullScreenViewerEnabled.set(settings.fullscreenViewer);
fullScreenViewerEnabled.set(Boolean(settings.fullscreenViewer));
});
});

View File

@@ -1,5 +1,5 @@
import {writable} from "svelte/store";
import SearchSettings from "$lib/extension/settings/SearchSettings.js";
import { writable } from "svelte/store";
import SearchSettings from "$lib/extension/settings/SearchSettings";
export const searchPropertiesSuggestionsEnabled = writable(false);
@@ -23,7 +23,7 @@ Promise.allSettled([
});
searchSettings.subscribe(settings => {
searchPropertiesSuggestionsEnabled.set(settings.suggestProperties);
searchPropertiesSuggestionsPosition.set(settings.suggestPropertiesPosition);
searchPropertiesSuggestionsEnabled.set(Boolean(settings.suggestProperties));
searchPropertiesSuggestionsPosition.set(settings.suggestPropertiesPosition || 'start');
});
})

View File

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

View File

@@ -0,0 +1,8 @@
$background-color: var(--background-color);
$media-border: var(--media-border);
$media-box-color: var(--media-box-color);
// These variables are defined dynamically based on the category of the tag
$resolved-tag-background: var(--tag-background);
$resolved-tag-border: var(--tag-border);
$resolved-tag-color: var(--tag-color);

View File

@@ -1,3 +1,5 @@
@use 'sass:color';
$background: #15121a;
$text: #dadada;
@@ -25,6 +27,27 @@ $tag-background: #1b3c21;
$tag-count-background: #2d6236;
$tag-text: #4aa158;
$tag-rating-text: #418dd9;
$tag-rating-background: color.adjust($tag-rating-text, $lightness: -35%);
$tag-spoiler-text: #d49b39;
$tag-spoiler-background: color.adjust($tag-spoiler-text, $lightness: -34%);
$tag-origin-text: #6f66d6;
$tag-origin-background: color.adjust($tag-origin-text, $lightness: -40%);
$tag-oc-text: #b157b7;
$tag-oc-background: color.adjust($tag-oc-text, $lightness: -33%);
$tag-error-text: #d45460;
$tag-error-background: color.adjust($tag-error-text, $lightness: -38%, $saturation: -6%, $space: hsl);
$tag-character-text: #4aaabf;
$tag-character-background: color.adjust($tag-character-text, $lightness: -33%);
$tag-content-official-text: #b9b541;
$tag-content-official-background: color.adjust($tag-content-official-text, $lightness: -29%, $saturation: -2%, $space: hsl);
$tag-content-fanmade-text: #cc8eb5;
$tag-content-fanmade-background: color.adjust($tag-content-fanmade-text, $lightness: -40%);
$tag-species-text: #b16b50;
$tag-species-background: color.adjust($tag-species-text, $lightness: -35%);
$tag-body-type-text: #b8b8b8;
$tag-body-type-background: color.adjust($tag-body-type-text, $lightness: -35%, $saturation: -10%, $space: hsl);
$input-background: #26232d;
$input-border: #5c5a61;

View File

@@ -0,0 +1,9 @@
.autocomplete {
&__item {
&--property {
i {
margin-right: .5em;
}
}
}
}

View File

@@ -1,4 +1,5 @@
@use '../colors';
@use '$styles/colors';
@use '$styles/booru-vars';
// This will fix wierd misplacing of the modified media boxes in the listing.
.js-resizable-media-container {
@@ -18,22 +19,22 @@
top: -1px;
bottom: 0;
z-index: 8;
background: colors.$footer;
border-top: 23px solid colors.$media-box-border;
background: booru-vars.$background-color;
border-top: 23px solid booru-vars.$media-box-color;
}
&:before {
right: calc(100% - 1px);
left: -50%;
border-left: 1px solid colors.$media-box-border;
box-shadow: colors.$footer -10px 0 10px;
border-left: booru-vars.$media-border;
box-shadow: booru-vars.$background-color -10px 0 10px;
}
&:after {
left: calc(100% - 1px);
right: -50%;
border-right: 1px solid colors.$media-box-border;
box-shadow: colors.$footer 10px 0 10px;
border-right: booru-vars.$media-border;
box-shadow: booru-vars.$background-color 10px 0 10px;
}
}
@@ -45,9 +46,12 @@
left: -50%;
right: -50%;
z-index: 8;
background: colors.$footer;
border: 1px solid colors.$media-box-border;
border-top: 0;
background: booru-vars.$background-color;
border: {
left: booru-vars.$media-border;
right: booru-vars.$media-border;
bottom: booru-vars.$media-border;
};
.tags-list {
display: flex;
@@ -62,12 +66,12 @@
.tag {
cursor: pointer;
padding: 0 6px;
padding: 5px;
user-select: none;
&:hover {
background: colors.$tag-text;
color: colors.$tag-background;
background: booru-vars.$resolved-tag-color;
color: booru-vars.$resolved-tag-background;
}
&.is-missing:not(.is-added),
@@ -194,6 +198,30 @@
transition: opacity .25s ease;
}
.size-selector {
position: absolute;
top: 5px;
left: 5px;
z-index: 1;
}
.close {
position: absolute;
top: 5px;
right: 5px;
z-index: 1;
padding: 5px;
background-color: colors.$text;
color: colors.$background;
font-size: 20px;
line-height: 20px;
width: 20px;
height: 20px;
text-align: center;
display: block;
cursor: pointer;
}
&.shown {
opacity: var(--opacity, 1);
pointer-events: initial;

View File

@@ -0,0 +1,40 @@
@use '../colors';
* {
box-sizing: border-box;
margin: 0;
padding: 0;
text-decoration: none;
color: inherit;
font-family: inherit;
font-size: inherit;
}
body {
width: 320px;
// Hacky class which is added by the JavaScript indicating that page was (probably) opened in the tab
&.is-in-tab {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
}
html, body {
background-color: colors.$background;
color: colors.$text;
font-size: 16px;
font-family: verdana, arial, helvetica, sans-serif;
margin: 0;
padding: 0;
}
a {
color: colors.$link;
text-decoration: none;
&:hover, &:focus {
color: colors.$link-hover;
}
}

View File

@@ -1,44 +1,5 @@
@use './colors';
* {
box-sizing: border-box;
margin: 0;
padding: 0;
text-decoration: none;
color: inherit;
font-family: inherit;
font-size: inherit;
}
body {
width: 320px;
// Hacky class which is added by the JavaScript indicating that page was (probably) opened in the tab
&.is-in-tab {
width: 100%;
max-width: 640px;
margin: 0 auto;
}
}
html, body {
background-color: colors.$background;
color: colors.$text;
font-size: 16px;
font-family: verdana, arial, helvetica, sans-serif;
margin: 0;
padding: 0;
}
a {
color: colors.$link;
text-decoration: none;
&:hover, &:focus {
color: colors.$link-hover;
}
}
@import "injectable/input";
@import "injectable/tag";
@import "injectable/icons";
@use 'colors';
@use 'injectable/base-styles';
@use 'injectable/input';
@use 'injectable/tag';
@use 'injectable/icons';

View File

@@ -14,6 +14,7 @@ const config = {
name: Date.now().toString(36)
},
alias: {
"$config": "./src/config",
"$components": "./src/components",
"$styles": "./src/styles",
"$stores": "./src/stores",

View File

@@ -10,6 +10,6 @@
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true
"allowImportingTsExtensions": false,
}
}