diff --git a/src/app.d.ts b/src/app.d.ts index 3735081..6040841 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -1,6 +1,7 @@ // See https://kit.svelte.dev/docs/types#app // for information about these interfaces import MaintenanceProfile from "$entities/MaintenanceProfile.ts"; +import type TagGroup from "$entities/TagGroup.ts"; declare global { namespace App { @@ -24,6 +25,14 @@ declare global { interface EntityNamesMap { profiles: MaintenanceProfile; + groups: TagGroup; + } + + interface ImageURIs { + full: string; + large: string; + medium: string; + small: string; } } } diff --git a/src/components/features/GroupView.svelte b/src/components/features/GroupView.svelte new file mode 100644 index 0000000..e984d2a --- /dev/null +++ b/src/components/features/GroupView.svelte @@ -0,0 +1,59 @@ + + +
+ Group Name: +
{group.settings.name}
+
+{#if sortedTagsList.length} +
+ Tags: + +
+ {#each sortedTagsList as tagName} + {tagName} + {/each} +
+
+
+{/if} +{#if sortedPrefixes.length} +
+ Prefixes: + +
+ {#each sortedPrefixes as prefixName} + {prefixName}* + {/each} +
+
+
+{/if} + + diff --git a/src/components/tags/TagsColorContainer.svelte b/src/components/tags/TagsColorContainer.svelte new file mode 100644 index 0000000..ba02c41 --- /dev/null +++ b/src/components/tags/TagsColorContainer.svelte @@ -0,0 +1,62 @@ + + +
+ +
+ + diff --git a/src/components/ui/forms/TagCategorySelectField.svelte b/src/components/ui/forms/TagCategorySelectField.svelte new file mode 100644 index 0000000..61040ee --- /dev/null +++ b/src/components/ui/forms/TagCategorySelectField.svelte @@ -0,0 +1,80 @@ + + + + + diff --git a/src/lib/booru/tag-categories.js b/src/lib/booru/tag-categories.js new file mode 100644 index 0000000..cbd33da --- /dev/null +++ b/src/lib/booru/tag-categories.js @@ -0,0 +1,12 @@ +export const categories = [ + 'rating', + 'spoiler', + 'origin', + 'oc', + 'error', + 'character', + 'content-official', + 'content-fanmade', + 'species', + 'body-type', +]; diff --git a/src/lib/components/FullscreenViewer.js b/src/lib/components/FullscreenViewer.js index 204f287..ee8b939 100644 --- a/src/lib/components/FullscreenViewer.js +++ b/src/lib/components/FullscreenViewer.js @@ -1,13 +1,14 @@ import {BaseComponent} from "$lib/components/base/BaseComponent.js"; +import MiscSettings from "$lib/extension/settings/MiscSettings.ts"; export class FullscreenViewer extends BaseComponent { /** @type {HTMLVideoElement} */ #videoElement = document.createElement('video'); /** @type {HTMLImageElement} */ #imageElement = document.createElement('img'); - #spinnerElement = document.createElement('i'); - + #sizeSelectorElement = document.createElement('select'); + #closeButtonElement = document.createElement('i'); /** @type {number|null} */ #touchId = null; /** @type {number|null} */ @@ -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.js").FullscreenViewerSize} size + */ + #onSizeResolved(size) { + this.#sizeSelectorElement.value = size; + this.#isSizeFetched = true; + + this.emit('size-loaded'); + } + + #watchForSizeSelectionChanges() { + let lastActiveSize = this.#sizeSelectorElement.value; + + FullscreenViewer.#miscSettings.subscribe(settings => { + const targetSize = settings.fullscreenViewerSize; + + if (!targetSize || lastActiveSize === targetSize) { + return; + } + + lastActiveSize = targetSize; + this.#sizeSelectorElement.value = targetSize; + }); + + this.#sizeSelectorElement.addEventListener('input', () => { + const targetSize = this.#sizeSelectorElement.value; + + if (this.#currentURIs) { + void this.show(this.#currentURIs); + } + + if (!targetSize || targetSize === lastActiveSize || !(targetSize in FullscreenViewer.#previewSizes)) { + return; + } + + lastActiveSize = targetSize; + void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize); + }); + } + #close() { + this.#currentURIs = null; + this.container.classList.remove(FullscreenViewer.#shownState); document.body.style.overflow = null; @@ -175,9 +242,44 @@ export class FullscreenViewer extends BaseComponent { } /** - * @param {string} url + * @param {App.ImageURIs} imageUris + * @return {Promise} */ - 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} + */ + static #previewSizes = { + full: 'Full', + large: 'Large', + medium: 'Medium', + small: 'Small' + } + + static #fallbackSize = 'large'; } diff --git a/src/lib/components/ImageShowFullscreenButton.js b/src/lib/components/ImageShowFullscreenButton.js index f0cd8c7..2f8c6e3 100644 --- a/src/lib/components/ImageShowFullscreenButton.js +++ b/src/lib/components/ImageShowFullscreenButton.js @@ -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); } /** diff --git a/src/lib/components/MediaBoxWrapper.js b/src/lib/components/MediaBoxWrapper.js index cc4c6a2..5fb1a3f 100644 --- a/src/lib/components/MediaBoxWrapper.js +++ b/src/lib/components/MediaBoxWrapper.js @@ -56,7 +56,7 @@ export class MediaBoxWrapper extends BaseComponent { } /** - * @return {ImageURIs} + * @return {App.ImageURIs} */ get imageLinks() { return JSON.parse(this.#thumbnailContainer.dataset.uris); @@ -100,10 +100,3 @@ export function calculateMediaBoxesPositions(mediaBoxesList) { } }) } - -/** - * @typedef {Object} ImageURIs - * @property {string} full - * @property {string} large - * @property {string} small - */ diff --git a/src/lib/components/TagDropdownWrapper.js b/src/lib/components/TagDropdownWrapper.js index 2ad3d01..a532a15 100644 --- a/src/lib/components/TagDropdownWrapper.js +++ b/src/lib/components/TagDropdownWrapper.js @@ -2,10 +2,12 @@ import {BaseComponent} from "$lib/components/base/BaseComponent.js"; import MaintenanceProfile from "$entities/MaintenanceProfile.ts"; import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings.ts"; import {getComponent} from "$lib/components/base/ComponentUtils.js"; +import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver"; const isTagEditorProcessedKey = Symbol(); +const categoriesResolver = new CustomCategoriesResolver(); -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,7 @@ 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, }); @@ -122,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); @@ -196,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() { diff --git a/src/lib/extension/CustomCategoriesResolver.ts b/src/lib/extension/CustomCategoriesResolver.ts new file mode 100644 index 0000000..6021939 --- /dev/null +++ b/src/lib/extension/CustomCategoriesResolver.ts @@ -0,0 +1,115 @@ +import type {TagDropdownWrapper} from "$lib/components/TagDropdownWrapper"; +import TagGroup from "$entities/TagGroup.ts"; +import {escapeRegExp} from "$lib/utils"; + +export default class CustomCategoriesResolver { + #tagCategories = new Map(); + #compiledRegExps = new Map(); + #tagDropdowns: TagDropdownWrapper[] = []; + #lastProcessedIndex = -1; + #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() { + const startIndex = Math.max(0, this.#lastProcessedIndex); + + this.#tagDropdowns + .slice(startIndex) + .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(); + this.#lastProcessedIndex = -1; + + 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; +} diff --git a/src/lib/extension/entities/TagGroup.ts b/src/lib/extension/entities/TagGroup.ts new file mode 100644 index 0000000..95dc44a --- /dev/null +++ b/src/lib/extension/entities/TagGroup.ts @@ -0,0 +1,21 @@ +import StorageEntity from "$lib/extension/base/StorageEntity.ts"; + +export interface TagGroupSettings { + name: string; + tags: string[]; + prefixes: string[]; + category: string; +} + +export default class TagGroup extends StorageEntity { + constructor(id: string, settings: Partial) { + super(id, { + name: settings.name || '', + tags: settings.tags || [], + prefixes: settings.prefixes || [], + category: settings.category || '' + }); + } + + static _entityName = 'groups'; +} diff --git a/src/lib/extension/settings/MiscSettings.ts b/src/lib/extension/settings/MiscSettings.ts index 94ea7d2..1e0e76d 100644 --- a/src/lib/extension/settings/MiscSettings.ts +++ b/src/lib/extension/settings/MiscSettings.ts @@ -1,7 +1,10 @@ import CacheableSettings from "$lib/extension/base/CacheableSettings.ts"; +export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full'; + interface MiscSettingsFields { fullscreenViewer: boolean; + fullscreenViewerSize: FullscreenViewerSize; } export default class MiscSettings extends CacheableSettings { @@ -13,7 +16,15 @@ export default class MiscSettings extends CacheableSettings 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); + } } diff --git a/src/lib/extension/transporting/exporters.ts b/src/lib/extension/transporting/exporters.ts index c2386c4..468df8e 100644 --- a/src/lib/extension/transporting/exporters.ts +++ b/src/lib/extension/transporting/exporters.ts @@ -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, entityName: string): Record { diff --git a/src/lib/utils.js b/src/lib/utils.js index db17ea5..c7a6531 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -21,3 +21,23 @@ export function findDeepObject(targetObject, path) { return result; } + +/** + * Matches all the characters needing replacement. + * + * Gathered from right here: https://stackoverflow.com/a/3561711/16048617. Because I don't want to introduce some + * library for that. + * + * @type {RegExp} + */ +const unsafeRegExpCharacters = /[/\-\\^$*+?.()|[\]{}]/g; + +/** + * Escape all the RegExp syntax-related characters in the following value. + * @param {string} value Original value. + * @return {string} Resulting value with all needed characters escaped. + */ +export function escapeRegExp(value) { + unsafeRegExpCharacters.lastIndex = 0; + return value.replace(unsafeRegExpCharacters, "\\$&"); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7b1fa3a..49cdecb 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -22,6 +22,7 @@
{/if} Tagging Profiles + Tag Groups
Preferences About diff --git a/src/routes/features/groups/+page.svelte b/src/routes/features/groups/+page.svelte new file mode 100644 index 0000000..97fc1b7 --- /dev/null +++ b/src/routes/features/groups/+page.svelte @@ -0,0 +1,23 @@ + + + + Back + Create New + {#if groups.length} +
+ {#each groups as group} + {group.settings.name} + {/each} + {/if} +
+ Import Group +
diff --git a/src/routes/features/groups/[id]/+page.svelte b/src/routes/features/groups/[id]/+page.svelte new file mode 100644 index 0000000..fffcfdb --- /dev/null +++ b/src/routes/features/groups/[id]/+page.svelte @@ -0,0 +1,39 @@ + + + + Back +
+
+{#if group} + +{/if} + +
+ Edit Group + Export Group + Delete Group +
diff --git a/src/routes/features/groups/[id]/delete/+page.svelte b/src/routes/features/groups/[id]/delete/+page.svelte new file mode 100644 index 0000000..50f1b1b --- /dev/null +++ b/src/routes/features/groups/[id]/delete/+page.svelte @@ -0,0 +1,41 @@ + + + + Back +
+
+{#if targetGroup} +

+ Do you want to remove group "{targetGroup.settings.name}"? This action is irreversible. +

+ +
+ Yes + No +
+{:else} +

Loading...

+{/if} diff --git a/src/routes/features/groups/[id]/edit/+page.svelte b/src/routes/features/groups/[id]/edit/+page.svelte new file mode 100644 index 0000000..746c79d --- /dev/null +++ b/src/routes/features/groups/[id]/edit/+page.svelte @@ -0,0 +1,82 @@ + + + + Back +
+
+ + + + + + + + + + + + + + + + + + + +
+ Save Group +
diff --git a/src/routes/features/groups/[id]/export/+page.svelte b/src/routes/features/groups/[id]/export/+page.svelte new file mode 100644 index 0000000..1a2b891 --- /dev/null +++ b/src/routes/features/groups/[id]/export/+page.svelte @@ -0,0 +1,50 @@ + + + + Back +
+
+ + + + + + +
+ isEncodedGroupShown = !isEncodedGroupShown}> + Switch Format: + {#if isEncodedGroupShown} + Base64-Encoded + {:else} + Raw JSON + {/if} + +
diff --git a/src/routes/features/groups/import/+page.svelte b/src/routes/features/groups/import/+page.svelte new file mode 100644 index 0000000..01583bc --- /dev/null +++ b/src/routes/features/groups/import/+page.svelte @@ -0,0 +1,134 @@ + + + + Back +
+
+{#if errorMessage} +

Failed to import: {errorMessage}

+ +
+
+{/if} +{#if !candidateGroup} + + + + + + +
+ Import +
+{:else} + {#if existingGroup} +

+ This group will replace the existing "{existingGroup.settings.name}" group, since it have the same ID. +

+ {/if} + + +
+ {#if existingGroup} + Replace Existing Group + Save as New Group + {:else} + Import New Group + {/if} + candidateGroup = null}>Cancel +
+{/if} + + diff --git a/src/stores/tag-groups-store.js b/src/stores/tag-groups-store.js new file mode 100644 index 0000000..057becd --- /dev/null +++ b/src/stores/tag-groups-store.js @@ -0,0 +1,12 @@ +import {writable} from "svelte/store"; +import TagGroup from "$entities/TagGroup.ts"; + +/** @type {import('svelte/store').Writable} */ +export const tagGroupsStore = writable([]); + +TagGroup + .readAll() + .then(groups => tagGroupsStore.set(groups)) + .then(() => { + TagGroup.subscribe(groups => tagGroupsStore.set(groups)); + }); diff --git a/src/styles/content/listing.scss b/src/styles/content/listing.scss index 37804f5..a6ec9e9 100644 --- a/src/styles/content/listing.scss +++ b/src/styles/content/listing.scss @@ -199,6 +199,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;