1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2025-12-24 07:12:57 +00:00

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
This commit is contained in:
2025-01-03 06:41:22 +04:00
committed by GitHub
18 changed files with 813 additions and 6 deletions

2
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 type TagGroup from "$entities/TagGroup.ts";
declare global {
namespace App {
@@ -24,6 +25,7 @@ declare global {
interface EntityNamesMap {
profiles: MaintenanceProfile;
groups: TagGroup;
}
}
}

View File

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

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

View File

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

View File

@@ -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]
});
await profile.save();
@@ -121,7 +163,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 +237,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

@@ -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<string, string>();
#compiledRegExps = new Map<RegExp, string>();
#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;
}

View File

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

View File

@@ -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

@@ -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, "\\$&");
}

View File

@@ -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.js";
/** @type {import('$entities/TagGroup.ts').default[]} */
let groups = [];
$: groups = $tagGroupsStore.sort((a, b) => a.settings.name.localeCompare(b.settings.name));
</script>
<Menu>
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
<MenuItem href="/features/groups/new/edit" icon="plus">Create New</MenuItem>
{#if groups.length}
<hr>
{#each groups as group}
<MenuItem href="/features/groups/{group.id}">{group.settings.name}</MenuItem>
{/each}
{/if}
<hr>
<MenuItem href="/features/groups/import">Import Group</MenuItem>
</Menu>

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.js";
const groupId = $page.params.id;
/** @type {import('$entities/TagGroup.ts').default|null} */
let group = null;
if (groupId==='new') {
goto('/features/groups/new/edit');
}
$: {
group = $tagGroupsStore.find(group => group.id===groupId) || null;
if (!group) {
console.warn(`Group ${ groupId } not found.`);
goto('/features/groups');
}
}
</script>
<Menu>
<MenuItem href="/features/groups" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
{#if group}
<GroupView {group}/>
{/if}
<Menu>
<hr>
<MenuItem href="/features/groups/{groupId}/edit" icon="wrench">Edit Group</MenuItem>
<MenuItem href="/features/groups/{groupId}/export" icon="file-export">Export Group</MenuItem>
<MenuItem href="/features/groups/{groupId}/delete" icon="trash">Delete Group</MenuItem>
</Menu>

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.js";
const groupId = $page.params.id;
const targetGroup = $tagGroupsStore.find(group => group.id===groupId);
if (!targetGroup) {
void goto('/features/groups');
}
async function deleteGroup() {
if (!targetGroup) {
console.warn('Attempting to delete the group, but the group is not loaded yet.');
return;
}
await targetGroup.delete();
await goto('/features/groups');
}
</script>
<Menu>
<MenuItem icon="arrow-left" href="/features/groups/{groupId}">Back</MenuItem>
<hr>
</Menu>
{#if targetGroup}
<p>
Do you want to remove group "{targetGroup.settings.name}"? This action is irreversible.
</p>
<Menu>
<hr>
<MenuItem on:click={deleteGroup}>Yes</MenuItem>
<MenuItem href="/features/groups/{groupId}">No</MenuItem>
</Menu>
{:else}
<p>Loading...</p>
{/if}

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

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.ts";
import EntitiesTransporter from "$lib/extension/EntitiesTransporter.ts";
import { tagGroupsStore } from "$stores/tag-groups-store.js";
const groupId = $page.params.id;
const groupTransporter = new EntitiesTransporter(TagGroup);
const group = $tagGroupsStore.find(group => group.id===groupId);
/** @type {string} */
let rawExportedGroup;
/** @type {string} */
let encodedExportedGroup;
if (!group) {
goto('/features/groups');
} else {
rawExportedGroup = groupTransporter.exportToJSON(group);
encodedExportedGroup = groupTransporter.exportToCompressedJSON(group);
}
let isEncodedGroupShown = true;
</script>
<Menu>
<MenuItem href="/features/groups/{groupId}" icon="arrow-left">Back</MenuItem>
<hr>
</Menu>
<FormContainer>
<FormControl label="Export string">
<textarea readonly rows="6">{isEncodedGroupShown ? encodedExportedGroup : rawExportedGroup}</textarea>
</FormControl>
</FormContainer>
<Menu>
<hr>
<MenuItem on:click={() => isEncodedGroupShown = !isEncodedGroupShown}>
Switch Format:
{#if isEncodedGroupShown}
Base64-Encoded
{:else}
Raw JSON
{/if}
</MenuItem>
</Menu>

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

View File

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