Compare commits
94 Commits
0.5.1
...
f0083169f3
| Author | SHA1 | Date | |
|---|---|---|---|
| f0083169f3 | |||
| a416befcff | |||
| d840070a3e | |||
| 0bf2a35e9b | |||
| 8889c300a0 | |||
| 1a542e0fb7 | |||
| b973947070 | |||
| 7821cebb1b | |||
| 71fb565247 | |||
| 0ba81b1509 | |||
| f9fb2d66b8 | |||
| 2d7db61a76 | |||
| 4a3d7a1bb0 | |||
| 939b5fec20 | |||
| d7b7aa5b98 | |||
| 7f41a7e6f0 | |||
| 2a2a488592 | |||
| bda707b5ac | |||
| 74866949bb | |||
| 6c2ef795b3 | |||
| 58b620ef09 | |||
| 9445b1e862 | |||
| 9024883949 | |||
| dc29c6ca69 | |||
| 441091142c | |||
| 94733c9ff3 | |||
| d11cc2a9c5 | |||
| f4e30c60ad | |||
| 9031055ec9 | |||
| 8194a84ef7 | |||
| 2829ac022f | |||
| 5aac85dcaa | |||
| 9a14a5568d | |||
| a2ab0d4e7c | |||
| 5123b57320 | |||
| 2bdb789777 | |||
| 486ab9cafa | |||
| ba7b96d888 | |||
| b08937e47b | |||
| 1d332ea7d1 | |||
| 2920946015 | |||
| f879c45517 | |||
| 00083fdadb | |||
| 7ffee170c3 | |||
| db34b361b3 | |||
| bf81b7111f | |||
| dc79959b8f | |||
| dfdab180ee | |||
| b768f9072c | |||
| 72a731aaff | |||
| d181509d6f | |||
| 03b0763db4 | |||
| a7e0aefe6b | |||
| 687c12a8f4 | |||
| 9b7ba4a6e2 | |||
| 8d7b151911 | |||
| fccd79292d | |||
| 8041f2d2a1 | |||
| 3fac472ae0 | |||
| 44aca3120c | |||
| 3aee3defba | |||
| b7a9dc2a2b | |||
| 242dfc5972 | |||
| b6840996b6 | |||
| 4c5b796f1d | |||
| 7f2e06a1b1 | |||
| 31a33131cd | |||
| 7063459622 | |||
| 5a82b8751d | |||
| 9318bd51fa | |||
| ab625d0181 | |||
| c59d8f55f0 | |||
| 8dfc5f49f9 | |||
| 2ecd37512f | |||
| c8ff80d445 | |||
| 38cbd725d9 | |||
| 26f09c7c46 | |||
| 64be6a6e15 | |||
| cb22b2deab | |||
| 5c5e0812dc | |||
| 70129d7a0e | |||
| 5fd6dee999 | |||
| ec41ba5030 | |||
| 55624285e1 | |||
| b97255ccd6 | |||
| ef76560bfb | |||
| faa909a0db | |||
| 3955e3191e | |||
| 17dab5854c | |||
| a20632e58e | |||
| 5f4a1a6c00 | |||
| 48fc58f042 | |||
| 8356956b2e | |||
| 3833cada1e |
BIN
.github/assets/colors-in-editor.png
vendored
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
.github/assets/derpibooru-preview-of-tag-link-replacement.png
vendored
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
.github/assets/groups-showcase-0.png
vendored
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
.github/assets/groups-showcase-1.png
vendored
Normal file
|
After Width: | Height: | Size: 83 KiB |
63
.github/workflows/build-extensions.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Build Extensions
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
build-extensions:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
site: [furbooru, derpibooru, tantabus]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build extension for ${{ matrix.site }}
|
||||
run: |
|
||||
if [ "${{ matrix.site }}" = "furbooru" ]; then
|
||||
npm run build
|
||||
else
|
||||
npm run build:${{ matrix.site }}
|
||||
fi
|
||||
|
||||
- name: Create extension zip
|
||||
run: |
|
||||
cd build
|
||||
zip -r "../${{ matrix.site }}-tagging-assistant-extension.zip" .
|
||||
|
||||
- name: Upload extension artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.site }}-tagging-assistant-extension
|
||||
path: ${{ matrix.site }}-tagging-assistant-extension.zip
|
||||
retention-days: 30
|
||||
|
||||
create-release-artifacts:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-extensions
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create combined artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: all-extensions
|
||||
path: artifacts/
|
||||
retention-days: 90
|
||||
@@ -51,6 +51,7 @@ function wrapScriptIntoIIFE() {
|
||||
function makeAliases(rootDir) {
|
||||
return {
|
||||
"$config": path.resolve(rootDir, 'src/config'),
|
||||
"$content": path.resolve(rootDir, 'src/content'),
|
||||
"$lib": path.resolve(rootDir, 'src/lib'),
|
||||
"$entities": path.resolve(rootDir, 'src/lib/extension/entities'),
|
||||
"$styles": path.resolve(rootDir, 'src/styles'),
|
||||
@@ -173,6 +174,15 @@ export async function buildScriptsAndStyles(buildOptions) {
|
||||
}
|
||||
});
|
||||
|
||||
const tantabusSwapPlugin = SwapDefinedVariablesPlugin({
|
||||
envVariable: 'SITE',
|
||||
expectedValue: 'tantabus',
|
||||
define: {
|
||||
__CURRENT_SITE__: JSON.stringify('tantabus'),
|
||||
__CURRENT_SITE_NAME__: JSON.stringify('Tantabus'),
|
||||
}
|
||||
});
|
||||
|
||||
// Building all scripts together with AMD loader in mind
|
||||
await build({
|
||||
configFile: false,
|
||||
@@ -208,6 +218,7 @@ export async function buildScriptsAndStyles(buildOptions) {
|
||||
?.push(...dependencies);
|
||||
}),
|
||||
derpibooruSwapPlugin,
|
||||
tantabusSwapPlugin,
|
||||
],
|
||||
define: defineConstants,
|
||||
});
|
||||
@@ -234,6 +245,7 @@ export async function buildScriptsAndStyles(buildOptions) {
|
||||
wrapScriptIntoIIFE(),
|
||||
ScssViteReadEnvVariableFunctionPlugin(),
|
||||
derpibooruSwapPlugin,
|
||||
tantabusSwapPlugin,
|
||||
],
|
||||
define: defineConstants,
|
||||
});
|
||||
|
||||
@@ -96,21 +96,16 @@ class ManifestProcessor {
|
||||
singleOrMultipleHostnames = [singleOrMultipleHostnames];
|
||||
}
|
||||
|
||||
const matchPatterReplacer = ManifestProcessor.#createHostnameReplacementReduce(singleOrMultipleHostnames);
|
||||
|
||||
this.#manifestObject.host_permissions = singleOrMultipleHostnames.map(hostname => `*://*.${hostname}/`);
|
||||
|
||||
this.#manifestObject.content_scripts?.forEach(entry => {
|
||||
entry.matches = entry.matches.reduce((resultMatches, originalMatchPattern) => {
|
||||
for (const updatedHostname of singleOrMultipleHostnames) {
|
||||
resultMatches.push(
|
||||
originalMatchPattern.replace(
|
||||
/\*:\/\/\*\.[a-z]+\.[a-z]+\//,
|
||||
`*://*.${updatedHostname}/`
|
||||
),
|
||||
);
|
||||
}
|
||||
entry.matches = entry.matches.reduce(matchPatterReplacer, []);
|
||||
|
||||
return resultMatches;
|
||||
}, []);
|
||||
if (entry.exclude_matches) {
|
||||
entry.exclude_matches = entry.exclude_matches.reduce(matchPatterReplacer, []);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,6 +143,32 @@ class ManifestProcessor {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|(string[])} singleOrMultipleHostnames
|
||||
* @return {function(string[], string): string[]}
|
||||
*/
|
||||
static #createHostnameReplacementReduce(singleOrMultipleHostnames) {
|
||||
return (
|
||||
/**
|
||||
* @param {string[]} resultMatches
|
||||
* @param {string} originalMatchPattern
|
||||
* @return {string[]}
|
||||
*/
|
||||
(resultMatches, originalMatchPattern) => {
|
||||
for (const updatedHostname of singleOrMultipleHostnames) {
|
||||
resultMatches.push(
|
||||
originalMatchPattern.replace(
|
||||
/\*:\/\/\*\.[a-z]+\.[a-z]+\//,
|
||||
`*://*.${updatedHostname}/`
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return resultMatches;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +207,7 @@ export function loadManifest(filePath) {
|
||||
/**
|
||||
* @typedef {Object} ContentScriptsEntry
|
||||
* @property {string[]} matches
|
||||
* @property {string[]} exclude_matches
|
||||
* @property {string[]|undefined} js
|
||||
* @property {string[]|undefined} css
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "path";
|
||||
import { buildScriptsAndStyles } from "./lib/content-scripts.js";
|
||||
import { extractInlineScriptsFromIndex } from "./lib/index-file.js";
|
||||
import { normalizePath } from "vite";
|
||||
import fs from "fs";
|
||||
|
||||
/**
|
||||
* Build addition assets required for the extension and pack it into the directory.
|
||||
@@ -67,18 +68,72 @@ export async function packExtension(settings) {
|
||||
return entry;
|
||||
})
|
||||
|
||||
if (process.env.SITE === 'derpibooru') {
|
||||
manifest.replaceHostTo([
|
||||
'derpibooru.org',
|
||||
'trixiebooru.org'
|
||||
]);
|
||||
manifest.replaceBooruNameWith('Derpibooru');
|
||||
manifest.setGeckoIdentifier('derpibooru-tagging-assistant@thecore.city');
|
||||
switch (process.env.SITE) {
|
||||
case 'derpibooru':
|
||||
manifest.replaceHostTo([
|
||||
'derpibooru.org',
|
||||
'trixiebooru.org'
|
||||
]);
|
||||
manifest.replaceBooruNameWith('Derpibooru');
|
||||
manifest.setGeckoIdentifier('derpibooru-tagging-assistant@thecore.city');
|
||||
break;
|
||||
|
||||
case 'tantabus':
|
||||
manifest.replaceHostTo('tantabus.ai');
|
||||
manifest.replaceBooruNameWith('Tantabus');
|
||||
manifest.setGeckoIdentifier('tantabus-tagging-assistant@thecore.city');
|
||||
break;
|
||||
|
||||
default:
|
||||
if (process.env.SITE) {
|
||||
console.warn('No replacement set up for site: ' + process.env.SITE);
|
||||
}
|
||||
}
|
||||
|
||||
manifest.passVersionFromPackage(path.resolve(settings.rootDir, 'package.json'));
|
||||
manifest.saveTo(path.resolve(settings.exportDir, 'manifest.json'));
|
||||
|
||||
const iconsDirectory = path.resolve(settings.exportDir, 'icons');
|
||||
|
||||
switch (process.env.SITE) {
|
||||
case "derpibooru":
|
||||
case "tantabus":
|
||||
const siteIconsDirectory = path.resolve(iconsDirectory, process.env.SITE);
|
||||
|
||||
if (!fs.existsSync(siteIconsDirectory)) {
|
||||
console.warn(`Can't find replacement icons for site ${process.env.SITE}`);
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`Found replacement icons for ${process.env.SITE}, swapping them...`);
|
||||
|
||||
fs.readdirSync(siteIconsDirectory).forEach(fileName => {
|
||||
const originalIconPath = path.resolve(settings.exportDir, fileName);
|
||||
const replacementIconPath = path.resolve(siteIconsDirectory, fileName);
|
||||
|
||||
if (!fs.existsSync(originalIconPath)) {
|
||||
console.warn(`Original icon not found: ${originalIconPath}`)
|
||||
return;
|
||||
}
|
||||
|
||||
fs.rmSync(originalIconPath);
|
||||
fs.cpSync(replacementIconPath, originalIconPath);
|
||||
|
||||
console.log(`Replaced: ${path.relative(settings.rootDir, replacementIconPath)} → ${path.relative(settings.rootDir, originalIconPath)}`);
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (fs.existsSync(iconsDirectory)) {
|
||||
console.log('Cleaning up icon replacements directory');
|
||||
|
||||
fs.rmSync(iconsDirectory, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
extractInlineScriptsFromIndex(path.resolve(settings.exportDir, 'index.html'));
|
||||
}
|
||||
|
||||
|
||||
24
README.md
@@ -1,24 +1,29 @@
|
||||
# Philomena Tagging Assistant
|
||||
|
||||
This is a browser extension written for the [Furbooru](https://furbooru.org) and [Derpibooru](https://derpibooru.org)
|
||||
image-boards. It gives you the ability to manually go over the list of images and apply tags to them without opening
|
||||
each individual image.
|
||||
This is a browser extension written for the [Furbooru](https://furbooru.org), [Derpibooru](https://derpibooru.org) and
|
||||
[Tantabus](https://tantabus.ai) image-boards. It gives you the ability to manually go over the list of images and apply
|
||||
tags to them without opening each individual image.
|
||||
|
||||
## Installation
|
||||
|
||||
This extension is available for both Chromium- and Firefox-based browsers. You can find the links to the extension pages
|
||||
below.
|
||||
|
||||
### Furbooru Tagging Assistant
|
||||
### Furbooru
|
||||
|
||||
[](https://addons.mozilla.org/en-US/firefox/addon/furbooru-tagging-assistant/)
|
||||
[](https://chromewebstore.google.com/detail/kpgaphaooaaodgodmnkamhmoedjcnfkj)
|
||||
|
||||
### Derpibooru Tagging Assistant
|
||||
### Derpibooru
|
||||
|
||||
[](https://addons.mozilla.org/en-US/firefox/addon/derpibooru-tagging-assistant/)
|
||||
[](https://chromewebstore.google.com/detail/pnmbomcdbfcghgmegklfofncfigdielb)
|
||||
|
||||
### Tantabus
|
||||
|
||||
[](https://addons.mozilla.org/en-US/firefox/addon/tantabus-tagging-assistant/)
|
||||
[](https://chromewebstore.google.com/detail/jpfkohpgdnpabpjafgagonghknaiecih)
|
||||
|
||||
## Features
|
||||
|
||||
### Tagging Profiles
|
||||
@@ -59,14 +64,17 @@ npm install --save-dev
|
||||
Second, you need to run the `build` command. It will first build the popup using SvelteKit and then build all the
|
||||
content scripts/stylesheets and copy the manifest afterward.
|
||||
|
||||
Extension can currently be built for 2 different imageboards using one of the following commands:
|
||||
Extension can currently be built for multiple different imageboards using one of the following commands:
|
||||
|
||||
```shell
|
||||
# To build the extension for Furbooru, use:
|
||||
# Furbooru:
|
||||
npm run build
|
||||
|
||||
# To build the extension for Derpbooru, use:
|
||||
# Derpibooru:
|
||||
npm run build:derpibooru
|
||||
|
||||
# Tantabus:
|
||||
npm run build:tantabus
|
||||
```
|
||||
|
||||
When build is complete, extension files can be found in the `/build` directory. These files can be either used
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant",
|
||||
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.0",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city"
|
||||
"id": "furbooru-tagging-assistant@thecore.city",
|
||||
"data_collection_permissions": {
|
||||
"required": [
|
||||
"none"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
@@ -45,11 +50,16 @@
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images/*"
|
||||
],
|
||||
"exclude_matches": [
|
||||
"*://*.furbooru.org/images/new",
|
||||
"*://*.furbooru.org/images/new?*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/tags-editor.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/tags-editor.scss"
|
||||
"src/styles/content/tags-editor.scss",
|
||||
"src/styles/content/tag-presets.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -69,6 +79,30 @@
|
||||
"js": [
|
||||
"src/content/tags.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/posts",
|
||||
"*://*.furbooru.org/posts?*",
|
||||
"*://*.furbooru.org/forums/*/topics/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/posts.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/posts.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images/new"
|
||||
],
|
||||
"js": [
|
||||
"src/content/upload.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/tag-presets.scss"
|
||||
]
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
|
||||
2616
package-lock.json
generated
43
package.json
@@ -1,10 +1,12 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.5.1",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:popup && npm run build:extension",
|
||||
"build:derpibooru": "cross-env SITE=derpibooru npm run build",
|
||||
"build:tantabus": "cross-env SITE=tantabus npm run build",
|
||||
"build:popup": "vite build",
|
||||
"build:extension": "node build-extension.js",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -12,27 +14,26 @@
|
||||
"test": "vitest run --coverage",
|
||||
"test:watch": "vitest watch --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.21.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@types/chrome": "^0.0.326",
|
||||
"@types/node": "^22.15.29",
|
||||
"@vitest/coverage-v8": "^3.2.0",
|
||||
"cheerio": "^1.0.0",
|
||||
"cross-env": "^10.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"sass": "^1.89.1",
|
||||
"svelte": "^5.33.14",
|
||||
"svelte-check": "^4.2.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.2.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.55.0",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"amd-lite": "^1.0.1",
|
||||
"lz-string": "^1.5.0"
|
||||
"lz-string": "^1.5.0",
|
||||
"sass": "^1.98.0",
|
||||
"svelte": "^5.53.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@types/chrome": "^0.1.37",
|
||||
"@types/node": "^25.5.0",
|
||||
"@vitest/coverage-v8": "^4.1.0",
|
||||
"cheerio": "^1.2.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"svelte-check": "^4.4.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
6
src/app.d.ts
vendored
@@ -1,7 +1,8 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
import type TagEditorPreset from "$entities/TagEditorPreset";
|
||||
|
||||
declare global {
|
||||
/**
|
||||
@@ -37,8 +38,9 @@ declare global {
|
||||
);
|
||||
|
||||
interface EntityNamesMap {
|
||||
profiles: MaintenanceProfile;
|
||||
profiles: TaggingProfile;
|
||||
groups: TagGroup;
|
||||
presets: TagEditorPreset;
|
||||
}
|
||||
|
||||
interface ImageURIs {
|
||||
|
||||
57
src/assets/icon/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Extension Icon
|
||||
|
||||
This folder contains original resources used to make an icon for the extension. Since I'm not really an icon designer, I
|
||||
ended up just composing the icon from sites logos + the shorthand name of the extension with fancy font. Nothing
|
||||
special.
|
||||
|
||||
## Sources
|
||||
|
||||
All resources used for composing an icon are stored here as copies just to not lose anything. Original assets are
|
||||
sourced from the following places:
|
||||
|
||||
- [Derpibooru Logo](https://github.com/derpibooru/philomena/blob/40ffb1b75bd0d96db24fa7c84bce36fcb7f2935f/assets/static/favicon.svg)
|
||||
- [Furbooru Logo](https://github.com/furbooru/philomena/blob/cbfde406de34734403c06952bcaca51db6df1390/assets/static/favicon.svg)
|
||||
- [Tantabus Logo](https://github.com/tantabus-ai/philomena/blob/285a7666ae4be46ac4da36bbc9ac8fda9e5c0fc3/assets/static/favicon.svg)
|
||||
- [RoundFeather Font](https://drive.google.com/file/d/18ggNplAZNYtO4eNtMUpv3XpkeOAxSkxm/view?usp=sharing)
|
||||
- Made by [allorus162](https://bsky.app/profile/allorus162.bsky.social)
|
||||
- [Original Bluesky post](https://bsky.app/profile/allorus162.bsky.social/post/3mfqntff4j22i)
|
||||
|
||||
## Rendering
|
||||
|
||||
**Note:** You don't need to do anything to pack current version of icon to the extension. All icons are already pre-rendered and
|
||||
placed into the `static` directory.
|
||||
|
||||
For now, any change to the icons will require manual re-rendering of PNG versions of the logos used when packing
|
||||
extension for the release. All you need is to open `/src/assets/icon/icon.svg` in software like Inskape, hide the
|
||||
currently opened logo and toggle the required one and save it into `icon256.png`, `icon128.png`, `icon48.png` and
|
||||
`icon16.png`.
|
||||
|
||||
For the font on the bottom-right to work, you will need to install it from the file
|
||||
`src/assets/icon/fonts/roundfeather-regular-1.001.ttf` (or you can download and install it from the source link).
|
||||
|
||||
You should render them into `/static` directory in the following structure:
|
||||
|
||||
- Place Furbooru icons into `/static` directory
|
||||
- Then add same icons for Derpibooru and Tantabus into `/static/icons/depribooru` and `/static/icons/tantabus`
|
||||
respectively.
|
||||
|
||||
Resulting structure will look like this:
|
||||
|
||||
```
|
||||
static/
|
||||
icons/
|
||||
derpibooru/
|
||||
icon16.png
|
||||
icon48.png
|
||||
icon128.png
|
||||
icon256.png
|
||||
tantabus/
|
||||
icon16.png
|
||||
icon48.png
|
||||
icon128.png
|
||||
icon256.png
|
||||
icon16.png
|
||||
icon48.png
|
||||
icon128.png
|
||||
icon256.png
|
||||
```
|
||||
2
src/assets/icon/favicons/derpibooru.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="132.29mm" height="132.29mm" version="1.1" viewBox="0 0 132.29 132.29" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.0427 0 0 1.0427 -.017147 -1.1711)"><path d="m103.79 1.5c2.226 55.884-2.592 66.082-26.774 81.11-10.46 6.495-24.44-7.2571-36.56-5.1501-14.529 2.53-33.119 12.655-39.169 20.603l0.00607-0.0012c56.82 1.164 44.414 29.924 80.7 29.64 21.39-0.17 35.89-18.483 38.62-27.615 10.456-34.942 3.1379-65.073-16.823-98.587zm11.375 57.714c-1.6412 10.692-0.54281 11.478 6.66 16.938-8.649-2.0114-10.977 0.7243-14.702 8.2179 1.5999-8.243 0.4741-11.62-5.8526-15.099 8.3149 0.88078 9.4155 0.05081 13.895-10.057zm-34.884 33.692c-3.7887 11.898-1.8578 13.462 6.4355 18.092-12.032-2.4927-11.44 1.5364-16.965 7.6122 2.9076-9.5873 1.2336-12.084-5.6426-16.122 5.6959 0.20558 11.418 1.8392 16.172-9.5819z" style="fill:#73d6ed"/><path d="m69.863 33.123-42.793 78.941 5.4648 2.9629 42.791-78.943z" style="-inkscape-stroke:none;color:#000000;fill:#73d6ed"/><g style="fill:#73d6ed"><path d="m64.894 48.218 7.18-13.796" style="-inkscape-stroke:none;color:#000000;fill:#73d6ed;stroke-width:6.557"/></g><path d="m89.844 3.2499-14.504 13.697-16.245-10.04 7.31 17.535-16.03 14.152 21.282-2.9352 8.782 19.4 2.8299-22.61 19.26-1.614-17.67-8.8749zm-7.235 13.028-1.5585 8.3965 7.2681 3.9461-8.1864 1.1558-1.6952 9.6819-3.8619-8.8428-9.5827 1.4895 7.3746-6.5192-3.4781-7.6725 7.4099 4.1481z" style="fill:#73d6ed"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
2
src/assets/icon/favicons/furbooru.svg
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="132.29mm" height="132.29mm" version="1.1" viewBox="0 0 132.29 132.29" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(.13264 0 0 .13264 -.54013 .21654)"><path d="m396.42 189.81c-51.874-0.0769-99.544 21.731-131.29 68.784 3.6141-17.709 12.731-35.245 20.313-51.85-64.951 23.01-110.98 58.779-153.95 130.96 31.861 78.452 47.007 130.73 32.991 180.63-12.789 45.531-57.874 81.129-120.66 54.031 11.733 79.157 138.9 169.44 265.13 95.148-15.402 34.788-33.651 44.104-58.67 59.609 109.87 18.28 240.46-16.733 327.04-89.364 80.473-67.509 145.76-176.62 150.49-271.1-16.925 5.2436-48.396 10.423-61.314 8.6977-4.9416-13.958-6.5445-47.664-6.6378-65.502-78.741 1.4559-134.21-82.398-225.29-56.079 24.483-19.73 56.853-19.667 84.992-25.658-39.916-24.957-82.805-38.256-123.15-38.315zm46.019 201.23c33.445 0.29408 70.846 13.949 108.14 44.486-71.827-12.193-167.68 9.8684-228.78 100.49-3.4843-86.066 49.678-145.6 120.65-144.98z" style="fill:#9a91d9"/><path d="m70.559 99.836c-11.606 103.12 31.76 196.05 73.743 312.81 47.028 130.79-54.846 190.58-100.97 125.58-46.123-65.005-59.672-280.22 27.226-438.39z" style="fill:#9a91d9"/><path d="m126.15 320.07s-84.457-177.47-13.898-310.03c12.18 95.304 37.741 170.41 85.526 228.25-34.102 31.125-51.147 53.357-71.628 81.784z" style="fill:#9a91d9"/><path d="m54.523 683.55c36.454 189.97 245.77 300.87 372.04 295.07-11.047-9.7005-22.094-18.617-33.141-31.003 95.291 43.85 187.43 17.122 251.23-12.829-16.164-4.6041-32.272-9.3803-47.039-18.174 21.351-4.409 43.671-15.588 59.868-33.141-52.566-1.4772-102.82-10.573-151.86-54.928 57.575-28.86 90.002-66.925 102.7-97.767-13.158 6.0202-27.475 9.3163-40.636 10.507 12.007-23.538 20.064-48.835 23.52-78.043-232.6 178.14-441.6 75.628-536.68 20.312z" style="fill:#9a91d9"/><path d="m611.51 653.89c-3.4653 21.491-9.3328 46.627-17.472 65.294 25.751-12.728 37.33-30.294 47.406-48.456 0 0-10.691 113.32-106.91 158.22 0 0 57.784 50.56 163.62 32.385-12.482 20.32-26.396 37.375-64.404 55.584 57.955 10.976 153.12-24.053 185.6-80.951-28.742 9.1492-48.987 9.8028-69.933 11.156 160.72-63.788 92.562-249.91 248.03-342.1-61.514-30.693-156.71-52.064-253.37 42.763 9.4346-13.824 22.374-34.745 43.832-56.661-72.65 21.206-114.21 112.97-176.4 162.77z" style="fill:#9a91d9"/><path d="m676.96 308.22c-2.2133 13.699-3.9692 34.942 1.8899 51.493 15.962 2.1315 36.687-1.0078 49.243-8.4218-0.26672-23.09-4.5591-41.009-10.829-57.596-12.902 6.3961-22.273 10.895-40.304 14.525z" style="fill:#9a91d9"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
41
src/assets/icon/favicons/tantabus.svg
Normal file
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="250mm"
|
||||
height="250mm"
|
||||
version="1.1"
|
||||
viewBox="0 0 250 250"
|
||||
xml:space="preserve"
|
||||
id="svg10"
|
||||
sodipodi:docname="favicon.svg"
|
||||
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs14" /><sodipodi:namedview
|
||||
id="namedview12"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.46847003"
|
||||
inkscape:cx="662.79587"
|
||||
inkscape:cy="691.61308"
|
||||
inkscape:window-width="2048"
|
||||
inkscape:window-height="1403"
|
||||
inkscape:window-x="1966"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg10" /><path
|
||||
id="circle4"
|
||||
style="fill:#b189d7;stroke-width:1.0041"
|
||||
d="M 43.466348 113.64503 A 112.33 112.33 0 0 1 40.650026 88.648915 A 112.33 112.33 0 0 1 40.918168 81.481475 A 81.915001 81.915001 0 0 0 121.44996 148.71379 A 81.915001 81.915001 0 0 0 203.36481 66.799192 A 81.915001 81.915001 0 0 0 121.45008 -15.116154 A 81.915001 81.915001 0 0 0 107.06707 -13.789539 A 112.33 112.33 0 0 1 152.97993 -23.681174 A 112.33 112.33 0 0 1 265.31002 88.648731 A 112.33 112.33 0 0 1 152.98012 200.97882 A 112.33 112.33 0 0 1 43.466348 113.64503 z "
|
||||
transform="matrix(.25882 .96593 .96593 -.25882 0 0)" /><path
|
||||
d="m120.78 137.49c-13.186 22.457-39.753 18.697-46.615-10.102-3.3594-14.101 17.903-33.046 13.75-51.609-2.9261-13.079 16.12-43.432 15.115-40.727-5.9218 15.937-6.5312 30.238 1.25 42.264 16.946 26.186 24.595 46.387 16.499 60.175z"
|
||||
style="fill:#b189d7;stroke-linecap:round;stroke-linejoin:round;stroke-width:2.0644"
|
||||
id="path8" /></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src/assets/icon/fonts/roundfeather-regular-1.001.ttf
Normal file
115
src/assets/icon/icon.svg
Normal file
@@ -0,0 +1,115 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="500"
|
||||
height="500"
|
||||
version="1.1"
|
||||
viewBox="0 0 132.29167 132.29167"
|
||||
xml:space="preserve"
|
||||
id="svg6"
|
||||
sodipodi:docname="icon.svg"
|
||||
inkscape:version="1.4.3 (0d15f75, 2025-12-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs6" /><sodipodi:namedview
|
||||
id="namedview6"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="1"
|
||||
inkscape:cx="179"
|
||||
inkscape:cy="388.49999"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1009"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg6"
|
||||
showguides="false" /><g
|
||||
id="g19"
|
||||
transform="matrix(0.58884997,0,0,0.58884997,-7.6052124,-7.356684)"
|
||||
inkscape:label="tantabus"
|
||||
style="display:none"><path
|
||||
id="circle4"
|
||||
style="fill:#b189d7;stroke-width:1.0041"
|
||||
d="m 43.466348,113.64503 a 112.33,112.33 0 0 1 -2.816322,-24.996115 112.33,112.33 0 0 1 0.268142,-7.16744 81.915001,81.915001 0 0 0 80.531792,67.232315 81.915001,81.915001 0 0 0 81.91485,-81.914598 81.915001,81.915001 0 0 0 -81.91473,-81.915346 81.915001,81.915001 0 0 0 -14.38301,1.326615 112.33,112.33 0 0 1 45.91286,-9.891635 A 112.33,112.33 0 0 1 265.31002,88.648731 112.33,112.33 0 0 1 152.98012,200.97882 112.33,112.33 0 0 1 43.466348,113.64503 Z"
|
||||
transform="matrix(0.25882,0.96593,0.96593,-0.25882,0,0)" /><path
|
||||
d="M 120.78,137.49 C 107.594,159.947 81.027,156.187 74.165,127.388 70.8056,113.287 92.068,94.342 87.915,75.779 84.9889,62.7 104.035,32.347 103.03,35.052 c -5.9218,15.937 -6.5312,30.238 1.25,42.264 16.946,26.186 24.595,46.387 16.499,60.175 z"
|
||||
style="fill:#b189d7;stroke-width:2.0644;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="path8" /></g><g
|
||||
transform="matrix(1.048236,0,0,1.048236,-0.23527111,-1.5720411)"
|
||||
id="g4"
|
||||
inkscape:label="derpibooru"
|
||||
style="display:none"><path
|
||||
d="m 103.79,1.5 c 2.226,55.884 -2.592,66.082 -26.774,81.11 -10.46,6.495 -24.44,-7.2571 -36.56,-5.1501 -14.529,2.53 -33.119,12.655 -39.169,20.603 l 0.00607,-0.0012 c 56.82,1.164 44.414,29.924 80.7,29.64 21.39,-0.17 35.89,-18.483 38.62,-27.615 10.456,-34.942 3.1379,-65.073 -16.823,-98.587 z m 11.375,57.714 c -1.6412,10.692 -0.54281,11.478 6.66,16.938 -8.649,-2.0114 -10.977,0.7243 -14.702,8.2179 1.5999,-8.243 0.4741,-11.62 -5.8526,-15.099 8.3149,0.88078 9.4155,0.05081 13.895,-10.057 z M 80.281,92.906 c -3.7887,11.898 -1.8578,13.462 6.4355,18.092 -12.032,-2.4927 -11.44,1.5364 -16.965,7.6122 2.9076,-9.5873 1.2336,-12.084 -5.6426,-16.122 5.6959,0.20558 11.418,1.8392 16.172,-9.5819 z"
|
||||
style="fill:#73d6ed"
|
||||
id="path1-0" /><path
|
||||
d="m 69.863,33.123 -42.793,78.941 5.4648,2.9629 42.791,-78.943 z"
|
||||
style="color:#000000;fill:#73d6ed;-inkscape-stroke:none"
|
||||
id="path2-9" /><g
|
||||
style="fill:#73d6ed"
|
||||
id="g3"><path
|
||||
d="m 64.894,48.218 7.18,-13.796"
|
||||
style="color:#000000;fill:#73d6ed;stroke-width:6.557;-inkscape-stroke:none"
|
||||
id="path3-4" /></g><path
|
||||
d="m 89.844,3.2499 -14.504,13.697 -16.245,-10.04 7.31,17.535 -16.03,14.152 21.282,-2.9352 8.782,19.4 2.8299,-22.61 19.26,-1.614 -17.67,-8.8749 z m -7.235,13.028 -1.5585,8.3965 7.2681,3.9461 -8.1864,1.1558 -1.6952,9.6819 -3.8619,-8.8428 -9.5827,1.4895 7.3746,-6.5192 -3.4781,-7.6725 7.4099,4.1481 z"
|
||||
style="fill:#73d6ed"
|
||||
id="path4-8" /></g><g
|
||||
transform="matrix(0.13355808,0,0,0.13355808,-0.92543932,0.1095915)"
|
||||
id="g6"
|
||||
inkscape:label="furbooru"
|
||||
style="display:inline"><path
|
||||
d="m 396.42,189.81 c -51.874,-0.0769 -99.544,21.731 -131.29,68.784 3.6141,-17.709 12.731,-35.245 20.313,-51.85 -64.951,23.01 -110.98,58.779 -153.95,130.96 31.861,78.452 47.007,130.73 32.991,180.63 -12.789,45.531 -57.874,81.129 -120.66,54.031 11.733,79.157 138.9,169.44 265.13,95.148 -15.402,34.788 -33.651,44.104 -58.67,59.609 109.87,18.28 240.46,-16.733 327.04,-89.364 80.473,-67.509 145.76,-176.62 150.49,-271.1 -16.925,5.2436 -48.396,10.423 -61.314,8.6977 -4.9416,-13.958 -6.5445,-47.664 -6.6378,-65.502 -78.741,1.4559 -134.21,-82.398 -225.29,-56.079 24.483,-19.73 56.853,-19.667 84.992,-25.658 -39.916,-24.957 -82.805,-38.256 -123.15,-38.315 z m 46.019,201.23 c 33.445,0.29408 70.846,13.949 108.14,44.486 -71.827,-12.193 -167.68,9.8684 -228.78,100.49 -3.4843,-86.066 49.678,-145.6 120.65,-144.98 z"
|
||||
style="fill:#9a91d9"
|
||||
id="path1"
|
||||
inkscape:label="head" /><path
|
||||
d="m 70.559,99.836 c -11.606,103.12 31.76,196.05 73.743,312.81 47.028,130.79 -54.846,190.58 -100.97,125.58 C -2.791,473.221 -16.34,258.006 70.558,99.836 Z"
|
||||
style="fill:#9a91d9"
|
||||
id="path2"
|
||||
inkscape:label="rightear" /><path
|
||||
d="m 126.15,320.07 c 0,0 -84.457,-177.47 -13.898,-310.03 12.18,95.304 37.741,170.41 85.526,228.25 -34.102,31.125 -51.147,53.357 -71.628,81.784 z"
|
||||
style="fill:#9a91d9"
|
||||
id="path3"
|
||||
inkscape:label="leftear" /><path
|
||||
d="m 54.523,683.55 c 36.454,189.97 245.77,300.87 372.04,295.07 -11.047,-9.7005 -22.094,-18.617 -33.141,-31.003 95.291,43.85 187.43,17.122 251.23,-12.829 -16.164,-4.6041 -32.272,-9.3803 -47.039,-18.174 21.351,-4.409 43.671,-15.588 59.868,-33.141 -52.566,-1.4772 -102.82,-10.573 -151.86,-54.928 57.575,-28.86 90.002,-66.925 102.7,-97.767 -13.158,6.0202 -27.475,9.3163 -40.636,10.507 12.007,-23.538 20.064,-48.835 23.52,-78.043 -232.6,178.14 -441.6,75.628 -536.68,20.312 z"
|
||||
style="fill:#9a91d9"
|
||||
id="path4"
|
||||
inkscape:label="tail" /><path
|
||||
d="m 611.51,653.89 c -3.4653,21.491 -9.3328,46.627 -17.472,65.294 25.751,-12.728 37.33,-30.294 47.406,-48.456 0,0 -10.691,113.32 -106.91,158.22 0,0 57.784,50.56 163.62,32.385 -12.482,20.32 -26.396,37.375 -64.404,55.584 57.955,10.976 153.12,-24.053 185.6,-80.951 -28.742,9.1492 -48.987,9.8028 -69.933,11.156 160.72,-63.788 92.562,-249.91 248.03,-342.1 -61.514,-30.693 -156.71,-52.064 -253.37,42.763 9.4346,-13.824 22.374,-34.745 43.832,-56.661 -72.65,21.206 -114.21,112.97 -176.4,162.77 z"
|
||||
style="fill:#9a91d9"
|
||||
id="path5"
|
||||
inkscape:label="tailend" /><path
|
||||
d="m 676.96,308.22 c -2.2133,13.699 -3.9692,34.942 1.8899,51.493 15.962,2.1315 36.687,-1.0078 49.243,-8.4218 -0.26672,-23.09 -4.5591,-41.009 -10.829,-57.596 -12.902,6.3961 -22.273,10.895 -40.304,14.525 z"
|
||||
style="fill:#9a91d9"
|
||||
id="path6"
|
||||
inkscape:label="nose" /></g><g
|
||||
id="g18"
|
||||
inkscape:label="badge"
|
||||
style="display:inline"
|
||||
transform="matrix(0.9615385,0,0,0.9615385,66.145836,5.0881347)"><rect
|
||||
style="opacity:1;fill:#1b3c21;fill-opacity:1;stroke:none;stroke-width:1.42664;stroke-linecap:round;stroke-linejoin:round"
|
||||
id="rect6"
|
||||
width="68.791664"
|
||||
height="44.450001"
|
||||
x="-2.5431316e-06"
|
||||
y="87.841667"
|
||||
ry="5.2916665"
|
||||
inkscape:label="bg" /><text
|
||||
xml:space="preserve"
|
||||
style="font-size:34.0036px;line-height:0.85;font-family:sans-serif;text-align:center;text-anchor:middle;fill:#4aa158;fill-opacity:1;stroke-width:0.261214"
|
||||
x="34.024727"
|
||||
y="128.18593"
|
||||
id="text11"
|
||||
transform="scale(1.0109068,0.98921086)"
|
||||
inkscape:label="text"><tspan
|
||||
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:34.0036px;line-height:0.85;font-family:Roundfeather;-inkscape-font-specification:Roundfeather;fill:#4aa158;fill-opacity:1;stroke-width:0.261214"
|
||||
x="34.024727"
|
||||
y="128.18593"
|
||||
id="tspan12"
|
||||
sodipodi:role="line">PTA</tspan></text></g></svg>
|
||||
|
After Width: | Height: | Size: 7.8 KiB |
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import TagsColorContainer from "$components/tags/TagsColorContainer.svelte";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
|
||||
import TagsList from "$components/tags/TagsList.svelte";
|
||||
|
||||
interface GroupViewProps {
|
||||
group: TagGroup;
|
||||
@@ -14,60 +16,27 @@
|
||||
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<strong>Group Name:</strong>
|
||||
<div>{group.settings.name}</div>
|
||||
</div>
|
||||
<DetailsBlock title="Group Name">
|
||||
{group.settings.name}
|
||||
</DetailsBlock>
|
||||
{#if sortedTagsList.length}
|
||||
<div class="block">
|
||||
<strong>Tags:</strong>
|
||||
<DetailsBlock title="Tags">
|
||||
<TagsColorContainer targetCategory={group.settings.category}>
|
||||
<div class="tags-list">
|
||||
{#each sortedTagsList as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<TagsList tags={sortedTagsList} />
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
</DetailsBlock>
|
||||
{/if}
|
||||
{#if sortedPrefixes.length}
|
||||
<div class="block">
|
||||
<strong>Prefixes:</strong>
|
||||
<DetailsBlock title="Prefixes">
|
||||
<TagsColorContainer targetCategory={group.settings.category}>
|
||||
<div class="tags-list">
|
||||
{#each sortedPrefixes as prefixName}
|
||||
<span class="tag">{prefixName}*</span>
|
||||
{/each}
|
||||
</div>
|
||||
<TagsList tags={sortedPrefixes} append="*" />
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
</DetailsBlock>
|
||||
{/if}
|
||||
{#if sortedSuffixes.length}
|
||||
<div class="block">
|
||||
<strong>Suffixes:</strong>
|
||||
<DetailsBlock title="Suffixes">
|
||||
<TagsColorContainer targetCategory={group.settings.category}>
|
||||
<div class="tags-list">
|
||||
{#each sortedSuffixes as suffixName}
|
||||
<span class="tag">*{suffixName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<TagsList tags={sortedSuffixes} prepend="*" />
|
||||
</TagsColorContainer>
|
||||
</div>
|
||||
</DetailsBlock>
|
||||
{/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>
|
||||
|
||||
20
src/components/features/PresetView.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
|
||||
import TagsList from "$components/tags/TagsList.svelte";
|
||||
|
||||
interface PresetViewProps {
|
||||
preset: TagEditorPreset;
|
||||
}
|
||||
|
||||
let { preset }: PresetViewProps = $props();
|
||||
|
||||
const sortedTagsList = $derived(preset.settings.tags.toSorted((a, b) => a.localeCompare(b)));
|
||||
</script>
|
||||
|
||||
<DetailsBlock title="Preset Name">
|
||||
{preset.settings.name}
|
||||
</DetailsBlock>
|
||||
<DetailsBlock title="Tags">
|
||||
<TagsList tags={sortedTagsList}></TagsList>
|
||||
</DetailsBlock>
|
||||
@@ -1,41 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
import DetailsBlock from "$components/ui/DetailsBlock.svelte";
|
||||
import TagsList from "$components/tags/TagsList.svelte";
|
||||
|
||||
interface ProfileViewProps {
|
||||
profile: MaintenanceProfile;
|
||||
profile: TaggingProfile;
|
||||
}
|
||||
|
||||
let { profile }: ProfileViewProps = $props();
|
||||
|
||||
const sortedTagsList = profile.settings.tags.sort((a, b) => a.localeCompare(b));
|
||||
const sortedTagsList = $derived(profile.settings.tags.sort((a, b) => a.localeCompare(b)));
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<strong>Profile:</strong>
|
||||
<div>{profile.settings.name}</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<strong>Tags:</strong>
|
||||
<div class="tags-list">
|
||||
{#each sortedTagsList as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.block + .block {
|
||||
margin-top: .5em;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<DetailsBlock title="Profile">
|
||||
{profile.settings.name}
|
||||
</DetailsBlock>
|
||||
<DetailsBlock title="Tags">
|
||||
<TagsList tags={sortedTagsList} />
|
||||
</DetailsBlock>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { PLUGIN_NAME } from "$lib/constants";
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<a href="/">{__CURRENT_SITE_NAME__} Tagging Assistant</a>
|
||||
<a href="/">{PLUGIN_NAME}</a>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
23
src/components/tags/TagsList.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface TagsListProps {
|
||||
tags: string[];
|
||||
prepend?: string;
|
||||
append?: string;
|
||||
}
|
||||
|
||||
let { tags, prepend, append }: TagsListProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="tags-list">
|
||||
{#each tags as tagName}
|
||||
<div class="tag">{prepend || ''}{tagName}{append || ''}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
30
src/components/ui/DetailsBlock.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
interface DetailsBlockProps {
|
||||
title?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { title, children }: DetailsBlockProps = $props();
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
{#if title?.length}
|
||||
<strong>{title}:</strong>
|
||||
{/if}
|
||||
<div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.block strong {
|
||||
display: block;
|
||||
margin-bottom: .25em;
|
||||
}
|
||||
|
||||
.block + :global(.block) {
|
||||
margin-top: .5em;
|
||||
}
|
||||
</style>
|
||||
32
src/components/ui/Notice.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
interface MessageProps {
|
||||
children?: import('svelte').Snippet;
|
||||
level: 'warning' | 'error';
|
||||
}
|
||||
|
||||
let { children, level }: MessageProps = $props();
|
||||
</script>
|
||||
|
||||
<p class="{level}">
|
||||
{@render children?.()}
|
||||
</p>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$styles/colors';
|
||||
|
||||
p {
|
||||
padding: 5px 24px;
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: colors.$warning-background;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: colors.$error-background;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import SelectField from "$components/ui/forms/SelectField.svelte";
|
||||
import { categories } from "$lib/booru/tag-categories";
|
||||
import { categories } from "$config/tags";
|
||||
|
||||
interface TagCategorySelectFieldProps {
|
||||
value?: string;
|
||||
|
||||
@@ -1,3 +1,50 @@
|
||||
/**
|
||||
* List of categories defined by the sites.
|
||||
*/
|
||||
export const categories: string[] = [
|
||||
'rating',
|
||||
'spoiler',
|
||||
'origin',
|
||||
'oc',
|
||||
'error',
|
||||
'character',
|
||||
'content-official',
|
||||
'content-fanmade',
|
||||
'species',
|
||||
'body-type',
|
||||
];
|
||||
|
||||
/**
|
||||
* Mapping of namespaces to their respective categories. These namespaces are automatically assigned to them, so we can
|
||||
* automatically assume categories of tags which start with them. Mapping is extracted from Philomena directly.
|
||||
*
|
||||
* This mapping may differ between boorus.
|
||||
*
|
||||
* @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/tags/tag.ex#L33-L45
|
||||
*/
|
||||
export const namespaceCategories: Map<string, string> = new Map([
|
||||
['artist', 'origin'],
|
||||
['art pack', 'content-fanmade'],
|
||||
['colorist', 'origin'],
|
||||
['comic', 'content-fanmade'],
|
||||
['editor', 'origin'],
|
||||
['fanfic', 'content-fanmade'],
|
||||
['oc', 'oc'],
|
||||
['photographer', 'origin'],
|
||||
['series', 'content-fanmade'],
|
||||
['spoiler', 'spoiler'],
|
||||
['video', 'content-fanmade'],
|
||||
...(__CURRENT_SITE__ === 'tantabus' ? <const> [
|
||||
["prompter", "origin"],
|
||||
["creator", "origin"],
|
||||
["generator", "origin"]
|
||||
] : [])
|
||||
]);
|
||||
|
||||
/**
|
||||
* List of tags which marked by the site as blacklisted. These tags are blocked from being added by the tag editor and
|
||||
* should usually just be removed automatically.
|
||||
*/
|
||||
export const tagsBlacklist: string[] = (__CURRENT_SITE__ === 'furbooru' ? [
|
||||
"anthro art",
|
||||
"anthro artist",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { bindComponent } from "$lib/components/base/component-utils";
|
||||
import { bindComponent } from "$content/components/base/component-utils";
|
||||
|
||||
type ComponentEventListener<EventName extends keyof HTMLElementEventMap> =
|
||||
(this: HTMLElement, event: HTMLElementEventMap[EventName]) => void;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import type { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
|
||||
const instanceSymbol = Symbol.for('instance');
|
||||
|
||||
15
src/content/components/events/booru-events.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
|
||||
export const EVENT_RELOAD = 'reload';
|
||||
|
||||
/**
|
||||
* Custom data for the reload event on plain editor textarea. Philomena doesn't send anything on this event.
|
||||
*/
|
||||
export interface ReloadCustomOptions {
|
||||
skipTagColorRefresh?: boolean;
|
||||
skipTagRefresh?: boolean;
|
||||
}
|
||||
|
||||
export interface BooruEventsMap {
|
||||
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
|
||||
[EVENT_RELOAD]: ReloadCustomOptions|null;
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events";
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events";
|
||||
import type { BooruEventsMap } from "$lib/components/events/booru-events";
|
||||
import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events";
|
||||
import type { TagDropdownEvents } from "$lib/components/events/tag-dropdown-events";
|
||||
import type { MaintenancePopupEventsMap } from "$content/components/events/maintenance-popup-events";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import type { FullscreenViewerEventsMap } from "$content/components/events/fullscreen-viewer-events";
|
||||
import type { BooruEventsMap } from "$content/components/events/booru-events";
|
||||
import type { TagsFormEventsMap } from "$content/components/events/tags-form-events";
|
||||
import type { TagDropdownEvents } from "$content/components/events/tag-dropdown-events";
|
||||
import type { PresetBlockEventsMap } from "$content/components/events/preset-block-events";
|
||||
|
||||
type EventsMapping =
|
||||
MaintenancePopupEventsMap
|
||||
& FullscreenViewerEventsMap
|
||||
& BooruEventsMap
|
||||
& TagsFormEventsMap
|
||||
& TagDropdownEvents;
|
||||
& TagDropdownEvents
|
||||
& PresetBlockEventsMap;
|
||||
|
||||
type EventCallback<EventDetails> = (event: CustomEvent<EventDetails>) => void;
|
||||
export type UnsubscribeFunction = () => void;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
import type { FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences";
|
||||
|
||||
export const EVENT_SIZE_LOADED = 'size-loaded';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
|
||||
export const EVENT_ACTIVE_PROFILE_CHANGED = 'active-profile-changed';
|
||||
export const EVENT_MAINTENANCE_STATE_CHANGED = 'maintenance-state-change';
|
||||
@@ -7,7 +7,7 @@ export const EVENT_TAGS_UPDATED = 'tags-updated';
|
||||
type MaintenanceState = 'processing' | 'failed' | 'complete' | 'waiting';
|
||||
|
||||
export interface MaintenancePopupEventsMap {
|
||||
[EVENT_ACTIVE_PROFILE_CHANGED]: MaintenanceProfile | null;
|
||||
[EVENT_ACTIVE_PROFILE_CHANGED]: TaggingProfile | null;
|
||||
[EVENT_MAINTENANCE_STATE_CHANGED]: MaintenanceState;
|
||||
[EVENT_TAGS_UPDATED]: Map<string, string> | null;
|
||||
}
|
||||
10
src/content/components/events/preset-block-events.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const EVENT_PRESET_TAG_CHANGE_APPLIED = 'preset-tag-changed';
|
||||
|
||||
export interface PresetTagChange {
|
||||
addedTags?: Set<string>;
|
||||
removedTags?: Set<string>;
|
||||
}
|
||||
|
||||
export interface PresetBlockEventsMap {
|
||||
[EVENT_PRESET_TAG_CHANGE_APPLIED]: PresetTagChange;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
import { emit, on } from "$lib/components/events/comms";
|
||||
import { EVENT_SIZE_LOADED } from "$lib/components/events/fullscreen-viewer-events";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import MiscPreferences, { type FullscreenViewerSize } from "$lib/extension/preferences/MiscPreferences";
|
||||
import { emit, on } from "$content/components/events/comms";
|
||||
import { EVENT_SIZE_LOADED } from "$content/components/events/fullscreen-viewer-events";
|
||||
|
||||
export class FullscreenViewer extends BaseComponent {
|
||||
#videoElement: HTMLVideoElement = document.createElement('video');
|
||||
@@ -53,8 +53,8 @@ export class FullscreenViewer extends BaseComponent {
|
||||
this.#imageElement.addEventListener('load', this.#onLoaded.bind(this));
|
||||
this.#sizeSelectorElement.addEventListener('click', event => event.stopPropagation());
|
||||
|
||||
FullscreenViewer.#miscSettings
|
||||
.resolveFullscreenViewerPreviewSize()
|
||||
FullscreenViewer.#preferences
|
||||
.fullscreenViewerSize.get()
|
||||
.then(this.#onSizeResolved.bind(this))
|
||||
.then(this.#watchForSizeSelectionChanges.bind(this));
|
||||
}
|
||||
@@ -179,7 +179,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
#watchForSizeSelectionChanges() {
|
||||
let lastActiveSize = this.#sizeSelectorElement.value;
|
||||
|
||||
FullscreenViewer.#miscSettings.subscribe(settings => {
|
||||
FullscreenViewer.#preferences.subscribe(settings => {
|
||||
const targetSize = settings.fullscreenViewerSize;
|
||||
|
||||
if (!targetSize || lastActiveSize === targetSize) {
|
||||
@@ -202,7 +202,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
}
|
||||
|
||||
lastActiveSize = targetSize;
|
||||
void FullscreenViewer.#miscSettings.setFullscreenViewerPreviewSize(targetSize);
|
||||
void FullscreenViewer.#preferences.fullscreenViewerSize.set(targetSize as FullscreenViewerSize);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ export class FullscreenViewer extends BaseComponent {
|
||||
return url.endsWith('.mp4') || url.endsWith('.webm');
|
||||
}
|
||||
|
||||
static #miscSettings = new MiscSettings();
|
||||
static #preferences = new MiscPreferences();
|
||||
|
||||
static #offsetProperty = '--offset';
|
||||
static #opacityProperty = '--opacity';
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
import { FullscreenViewer } from "$lib/components/FullscreenViewer";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import MiscPreferences from "$lib/extension/preferences/MiscPreferences";
|
||||
import { FullscreenViewer } from "$content/components/extension/FullscreenViewer";
|
||||
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
|
||||
|
||||
export class ImageShowFullscreenButton extends BaseComponent {
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
@@ -10,8 +10,6 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
|
||||
protected build() {
|
||||
this.container.innerText = '🔍';
|
||||
|
||||
ImageShowFullscreenButton.#miscSettings ??= new MiscSettings();
|
||||
}
|
||||
|
||||
protected init() {
|
||||
@@ -27,14 +25,14 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
|
||||
this.on('click', this.#onButtonClicked.bind(this));
|
||||
|
||||
if (ImageShowFullscreenButton.#miscSettings) {
|
||||
ImageShowFullscreenButton.#miscSettings.resolveFullscreenViewerEnabled()
|
||||
if (ImageShowFullscreenButton.#preferences) {
|
||||
ImageShowFullscreenButton.#preferences.fullscreenViewer.get()
|
||||
.then(isEnabled => {
|
||||
this.#isFullscreenButtonEnabled = isEnabled;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
.then(() => {
|
||||
ImageShowFullscreenButton.#miscSettings?.subscribe(settings => {
|
||||
ImageShowFullscreenButton.#preferences?.subscribe(settings => {
|
||||
this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true;
|
||||
this.#updateFullscreenButtonVisibility();
|
||||
})
|
||||
@@ -58,6 +56,15 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
?.show(imageLinks);
|
||||
}
|
||||
|
||||
static create(): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('media-box-show-fullscreen');
|
||||
|
||||
new ImageShowFullscreenButton(element);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
static #viewer: FullscreenViewer | null = null;
|
||||
|
||||
static #resolveViewer(): FullscreenViewer {
|
||||
@@ -76,14 +83,5 @@ export class ImageShowFullscreenButton extends BaseComponent {
|
||||
return viewer;
|
||||
}
|
||||
|
||||
static #miscSettings: MiscSettings | null = null;
|
||||
}
|
||||
|
||||
export function createImageShowFullscreenButton() {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('media-box-show-fullscreen');
|
||||
|
||||
new ImageShowFullscreenButton(element);
|
||||
|
||||
return element;
|
||||
static #preferences = new MiscPreferences();
|
||||
}
|
||||
74
src/content/components/extension/MediaBoxTools.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_ACTIVE_PROFILE_CHANGED } from "$content/components/events/maintenance-popup-events";
|
||||
import type { MediaBox } from "$content/components/philomena/MediaBox";
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
|
||||
export class MediaBoxTools extends BaseComponent {
|
||||
#mediaBox: MediaBox | null = null;
|
||||
#maintenancePopup: TaggingProfilePopup | null = null;
|
||||
|
||||
init() {
|
||||
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
|
||||
|
||||
if (!mediaBoxElement) {
|
||||
throw new Error('Toolbox element initialized outside of the media box!');
|
||||
}
|
||||
|
||||
this.#mediaBox = getComponent(mediaBoxElement);
|
||||
|
||||
for (let childElement of this.container.children) {
|
||||
if (!(childElement instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const component = getComponent(childElement);
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!component.isInitialized) {
|
||||
component.initialize();
|
||||
}
|
||||
|
||||
if (!this.#maintenancePopup && component instanceof TaggingProfilePopup) {
|
||||
this.#maintenancePopup = component;
|
||||
}
|
||||
}
|
||||
|
||||
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(profileChangedEvent: CustomEvent<TaggingProfile | null>) {
|
||||
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
|
||||
}
|
||||
|
||||
get maintenancePopup(): TaggingProfilePopup | null {
|
||||
return this.#maintenancePopup;
|
||||
}
|
||||
|
||||
get mediaBox(): MediaBox | null {
|
||||
return this.#mediaBox;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a maintenance popup element.
|
||||
* @param childrenElements List of children elements to append to the component.
|
||||
* @return The maintenance popup element.
|
||||
*/
|
||||
static create(...childrenElements: HTMLElement[]): HTMLElement {
|
||||
const mediaBoxToolsContainer = document.createElement('div');
|
||||
mediaBoxToolsContainer.classList.add('media-box-tools');
|
||||
|
||||
if (childrenElements.length) {
|
||||
mediaBoxToolsContainer.append(...childrenElements);
|
||||
}
|
||||
|
||||
new MediaBoxTools(mediaBoxToolsContainer);
|
||||
|
||||
return mediaBoxToolsContainer;
|
||||
}
|
||||
}
|
||||
102
src/content/components/extension/presets/EditorPresetsBlock.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import PresetTableRow from "$content/components/extension/presets/PresetTableRow";
|
||||
import { createFontAwesomeIcon } from "$lib/dom-utils";
|
||||
import { sortEntitiesByField } from "$lib/utils";
|
||||
|
||||
export default class EditorPresetsBlock extends BaseComponent {
|
||||
#presetsTable = document.createElement('table');
|
||||
#presetBlocks: PresetTableRow[] = [];
|
||||
#tags: Set<string> = new Set();
|
||||
|
||||
protected build() {
|
||||
this.container.classList.add('block', 'hidden', 'tag-presets');
|
||||
this.container.style.marginTop = 'var(--block-spacing)';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.classList.add('block__header');
|
||||
|
||||
const headerTitle = document.createElement('div');
|
||||
headerTitle.classList.add('block__header__title')
|
||||
headerTitle.textContent = ' Presets';
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.classList.add('block__content');
|
||||
|
||||
this.#presetsTable.append(
|
||||
document.createElement('thead'),
|
||||
document.createElement('tbody'),
|
||||
);
|
||||
|
||||
this.#presetsTable.tHead?.append(
|
||||
EditorPresetsBlock.#createRowWithTableHeads([
|
||||
'Name',
|
||||
'Tags',
|
||||
'Actions'
|
||||
]),
|
||||
);
|
||||
|
||||
headerTitle.prepend(createFontAwesomeIcon('layer-group'));
|
||||
header.append(headerTitle);
|
||||
content.append(this.#presetsTable);
|
||||
|
||||
this.container.append(
|
||||
header,
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
protected init() {
|
||||
TagEditorPreset.readAll()
|
||||
.then(this.#refreshPresets.bind(this))
|
||||
.then(() => TagEditorPreset.subscribe(this.#refreshPresets.bind(this)));
|
||||
}
|
||||
|
||||
toggleVisibility(shouldBeVisible: boolean | undefined = undefined) {
|
||||
this.container.classList.toggle('hidden', shouldBeVisible);
|
||||
}
|
||||
|
||||
updateTags(tags: Set<string>) {
|
||||
this.#tags = tags;
|
||||
|
||||
for (const presetBlock of this.#presetBlocks) {
|
||||
presetBlock.updateTags(tags);
|
||||
}
|
||||
}
|
||||
|
||||
#refreshPresets(presetsList: TagEditorPreset[]) {
|
||||
if (this.#presetBlocks.length) {
|
||||
for (const block of this.#presetBlocks) {
|
||||
block.remove();
|
||||
}
|
||||
}
|
||||
|
||||
for (const preset of sortEntitiesByField(presetsList, "name")) {
|
||||
const block = PresetTableRow.create(preset);
|
||||
this.#presetsTable.tBodies[0]?.append(block.container);
|
||||
block.initialize();
|
||||
block.updateTags(this.#tags);
|
||||
|
||||
this.#presetBlocks.push(block);
|
||||
}
|
||||
}
|
||||
|
||||
static create(): EditorPresetsBlock {
|
||||
return new EditorPresetsBlock(
|
||||
document.createElement('div')
|
||||
);
|
||||
}
|
||||
|
||||
static #createRowWithTableHeads(columnNames: string[]): HTMLTableRowElement {
|
||||
const rowElement = document.createElement('tr');
|
||||
|
||||
for (const columnName of columnNames) {
|
||||
const columnHeadElement = document.createElement('th');
|
||||
columnHeadElement.textContent = columnName;
|
||||
|
||||
rowElement.append(columnHeadElement);
|
||||
}
|
||||
|
||||
return rowElement;
|
||||
}
|
||||
}
|
||||
129
src/content/components/extension/presets/PresetTableRow.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import type TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import { emit } from "$content/components/events/comms";
|
||||
import { EVENT_PRESET_TAG_CHANGE_APPLIED } from "$content/components/events/preset-block-events";
|
||||
import { createFontAwesomeIcon } from "$lib/dom-utils";
|
||||
|
||||
export default class PresetTableRow extends BaseComponent {
|
||||
#preset: TagEditorPreset;
|
||||
#tagsList: HTMLElement[] = [];
|
||||
#applyAllButton = document.createElement('button');
|
||||
#removeAllButton = document.createElement('button');
|
||||
|
||||
constructor(container: HTMLElement, preset: TagEditorPreset) {
|
||||
super(container);
|
||||
|
||||
this.#preset = preset;
|
||||
}
|
||||
|
||||
protected build() {
|
||||
this.#tagsList = this.#preset.settings.tags
|
||||
.toSorted((a, b) => a.localeCompare(b))
|
||||
.map(tagName => {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.classList.add('tag');
|
||||
tagElement.textContent = tagName;
|
||||
tagElement.dataset.tagName = tagName;
|
||||
|
||||
return tagElement;
|
||||
});
|
||||
|
||||
const nameCell = document.createElement('td');
|
||||
nameCell.textContent = this.#preset.settings.name;
|
||||
|
||||
const tagsCell = document.createElement('td');
|
||||
|
||||
const tagsListContainer = document.createElement('div');
|
||||
tagsListContainer.classList.add('tag-list');
|
||||
tagsListContainer.append(...this.#tagsList);
|
||||
|
||||
tagsCell.append(tagsListContainer);
|
||||
|
||||
const actionsCell = document.createElement('td');
|
||||
|
||||
const actionsContainer = document.createElement('div');
|
||||
actionsContainer.classList.add('flex', 'flex--gap-small');
|
||||
|
||||
this.#applyAllButton.classList.add('button', 'button--state-success', 'button--bold');
|
||||
this.#applyAllButton.append(createFontAwesomeIcon('circle-plus'));
|
||||
this.#applyAllButton.title = 'Add all tags from this preset into the editor';
|
||||
|
||||
this.#removeAllButton.classList.add('button', 'button--state-danger', 'button--bold');
|
||||
this.#removeAllButton.append(createFontAwesomeIcon('circle-minus'));
|
||||
this.#removeAllButton.title = 'Remove all tags from this preset from the editor';
|
||||
|
||||
actionsContainer.append(
|
||||
this.#applyAllButton,
|
||||
this.#removeAllButton,
|
||||
);
|
||||
|
||||
actionsCell.append(actionsContainer);
|
||||
|
||||
this.container.append(
|
||||
nameCell,
|
||||
tagsCell,
|
||||
actionsCell,
|
||||
);
|
||||
}
|
||||
|
||||
protected init() {
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.addEventListener('click', this.#onTagClicked.bind(this));
|
||||
}
|
||||
|
||||
this.#applyAllButton.addEventListener('click', this.#onApplyAllClicked.bind(this));
|
||||
this.#removeAllButton.addEventListener('click', this.#onRemoveAllClicked.bind(this));
|
||||
}
|
||||
|
||||
#onTagClicked(event: Event) {
|
||||
const targetElement = event.currentTarget;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = targetElement.dataset.tagName;
|
||||
const isMissing = targetElement.classList.contains(PresetTableRow.#tagMissingClassName);
|
||||
|
||||
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
|
||||
[isMissing ? 'addedTags' : 'removedTags']: new Set([tagName])
|
||||
});
|
||||
}
|
||||
|
||||
#onApplyAllClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
|
||||
addedTags: new Set(this.#preset.settings.tags),
|
||||
});
|
||||
}
|
||||
|
||||
#onRemoveAllClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
emit(this, EVENT_PRESET_TAG_CHANGE_APPLIED, {
|
||||
removedTags: new Set(this.#preset.settings.tags),
|
||||
});
|
||||
}
|
||||
|
||||
updateTags(tags: Set<string>) {
|
||||
for (const tagElement of this.#tagsList) {
|
||||
tagElement.classList.toggle(
|
||||
PresetTableRow.#tagMissingClassName,
|
||||
!tags.has(tagElement.dataset.tagName || ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.container.remove();
|
||||
}
|
||||
|
||||
static create(preset: TagEditorPreset) {
|
||||
return new this(document.createElement('tr'), preset);
|
||||
}
|
||||
|
||||
static #tagMissingClassName = 'is-missing';
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
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 TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import ScrapedAPI from "$lib/philomena/scraping/ScrapedAPI";
|
||||
import { tagsBlacklist } from "$config/tags";
|
||||
import { emitterAt } from "$lib/components/events/comms";
|
||||
import { emitterAt } from "$content/components/events/comms";
|
||||
import {
|
||||
EVENT_ACTIVE_PROFILE_CHANGED,
|
||||
EVENT_MAINTENANCE_STATE_CHANGED,
|
||||
EVENT_TAGS_UPDATED
|
||||
} from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
} from "$content/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
|
||||
import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
|
||||
|
||||
class BlackListedTagsEncounteredError extends Error {
|
||||
constructor(tagName: string) {
|
||||
@@ -20,11 +21,11 @@ class BlackListedTagsEncounteredError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class MaintenancePopup extends BaseComponent {
|
||||
export class TaggingProfilePopup extends BaseComponent {
|
||||
#tagsListElement: HTMLElement = document.createElement('div');
|
||||
#tagsList: HTMLElement[] = [];
|
||||
#suggestedInvalidTags: Map<string, HTMLElement> = new Map();
|
||||
#activeProfile: MaintenanceProfile | null = null;
|
||||
#activeProfile: TaggingProfile | null = null;
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
#tagsToRemove: Set<string> = new Set();
|
||||
#tagsToAdd: Set<string> = new Set();
|
||||
@@ -65,7 +66,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
this.#mediaBoxTools = mediaBoxTools;
|
||||
|
||||
MaintenancePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
|
||||
TaggingProfilePopup.#watchActiveProfile(this.#onActiveProfileChanged.bind(this));
|
||||
this.#tagsListElement.addEventListener('click', this.#handleTagClick.bind(this));
|
||||
|
||||
const mediaBox = this.#mediaBoxTools.mediaBox;
|
||||
@@ -78,7 +79,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this));
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(activeProfile: MaintenanceProfile | null) {
|
||||
#onActiveProfileChanged(activeProfile: TaggingProfile | null) {
|
||||
this.#activeProfile = activeProfile;
|
||||
this.container.classList.toggle('is-active', activeProfile !== null);
|
||||
this.#refreshTagsList();
|
||||
@@ -109,7 +110,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
activeProfileTagsList
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.forEach((tagName, index) => {
|
||||
const tagElement = MaintenancePopup.#buildTagElement(tagName);
|
||||
const tagElement = TaggingProfilePopup.#buildTagElement(tagName);
|
||||
this.#tagsList[index] = tagElement;
|
||||
this.#tagsListElement.appendChild(tagElement);
|
||||
|
||||
@@ -121,8 +122,13 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
// Just to prevent duplication, we need to include this tag to the map of suggested invalid tags
|
||||
if (tagsBlacklist.includes(tagName)) {
|
||||
MaintenancePopup.#markTagAsInvalid(tagElement);
|
||||
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
} else {
|
||||
TaggingProfilePopup.#markTagElementWithCategory(
|
||||
tagElement,
|
||||
resolveTagCategoryFromTagName(tagName) ?? '',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -173,7 +179,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
if (this.#tagsToAdd.size || this.#tagsToRemove.size) {
|
||||
// Notify only once, when first planning to submit
|
||||
if (!this.#isPlanningToSubmit) {
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(true);
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(true);
|
||||
}
|
||||
|
||||
this.#isPlanningToSubmit = true;
|
||||
@@ -191,7 +197,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
|
||||
this.#tagsSubmissionTimer = setTimeout(
|
||||
this.#onSubmissionTimerPassed.bind(this),
|
||||
MaintenancePopup.#delayBeforeSubmissionMs
|
||||
TaggingProfilePopup.#delayBeforeSubmissionMs
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -208,10 +214,10 @@ export class MaintenancePopup extends BaseComponent {
|
||||
|
||||
let maybeTagsAndAliasesAfterUpdate;
|
||||
|
||||
const shouldAutoRemove = await MaintenancePopup.#maintenanceSettings.resolveStripBlacklistedTags();
|
||||
const shouldAutoRemove = await TaggingProfilePopup.#preferences.stripBlacklistedTags.get();
|
||||
|
||||
try {
|
||||
maybeTagsAndAliasesAfterUpdate = await MaintenancePopup.#scrapedAPI.updateImageTags(
|
||||
maybeTagsAndAliasesAfterUpdate = await TaggingProfilePopup.#scrapedAPI.updateImageTags(
|
||||
this.#mediaBoxTools.mediaBox.imageId,
|
||||
tagsList => {
|
||||
for (let tagName of this.#tagsToRemove) {
|
||||
@@ -244,7 +250,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
console.warn('Tags submission failed:', e);
|
||||
}
|
||||
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#emitter.emit(EVENT_MAINTENANCE_STATE_CHANGED, 'failed');
|
||||
this.#isSubmitting = false;
|
||||
@@ -262,7 +268,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.#tagsToRemove.clear();
|
||||
|
||||
this.#refreshTagsList();
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#isSubmitting = false;
|
||||
}
|
||||
@@ -286,8 +292,8 @@ export class MaintenancePopup extends BaseComponent {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagElement = MaintenancePopup.#buildTagElement(tagName);
|
||||
MaintenancePopup.#markTagAsInvalid(tagElement);
|
||||
const tagElement = TaggingProfilePopup.#buildTagElement(tagName);
|
||||
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
|
||||
tagElement.classList.add('is-present');
|
||||
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
@@ -305,6 +311,14 @@ export class MaintenancePopup extends BaseComponent {
|
||||
return this.container.classList.contains('is-active');
|
||||
}
|
||||
|
||||
static create(): HTMLElement {
|
||||
const container = document.createElement('div');
|
||||
|
||||
new this(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
static #buildTagElement(tagName: string): HTMLElement {
|
||||
const tagElement = document.createElement('span');
|
||||
tagElement.classList.add('tag');
|
||||
@@ -315,18 +329,19 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the tag with red color.
|
||||
* Mark the tag element with specified category.
|
||||
* @param tagElement Element to mark.
|
||||
* @param category Code name of category to mark.
|
||||
*/
|
||||
static #markTagAsInvalid(tagElement: HTMLElement) {
|
||||
tagElement.dataset.tagCategory = 'error';
|
||||
tagElement.setAttribute('data-tag-category', 'error');
|
||||
static #markTagElementWithCategory(tagElement: HTMLElement, category: string) {
|
||||
tagElement.dataset.tagCategory = category;
|
||||
tagElement.setAttribute('data-tag-category', category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Controller with maintenance settings.
|
||||
*/
|
||||
static #maintenanceSettings = new MaintenanceSettings();
|
||||
static #preferences = new TaggingProfilesPreferences();
|
||||
|
||||
/**
|
||||
* Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback
|
||||
@@ -334,10 +349,10 @@ export class MaintenancePopup extends BaseComponent {
|
||||
* @param callback Callback to execute whenever selection of active profile or profile itself has been changed.
|
||||
* @return Unsubscribe function. Call it to stop watching for changes.
|
||||
*/
|
||||
static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void {
|
||||
static #watchActiveProfile(callback: (profile: TaggingProfile | null) => void): () => void {
|
||||
let lastActiveProfileId: string | null | undefined = null;
|
||||
|
||||
const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => {
|
||||
const unsubscribeFromProfilesChanges = TaggingProfile.subscribe(profiles => {
|
||||
if (lastActiveProfileId) {
|
||||
callback(
|
||||
profiles.find(profile => profile.id === lastActiveProfileId) || null
|
||||
@@ -345,20 +360,18 @@ export class MaintenancePopup extends BaseComponent {
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeFromMaintenanceSettings = this.#maintenanceSettings.subscribe(settings => {
|
||||
const unsubscribeFromMaintenanceSettings = this.#preferences.subscribe(settings => {
|
||||
if (settings.activeProfile === lastActiveProfileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastActiveProfileId = settings.activeProfile;
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
this.#preferences.activeProfile.asObject()
|
||||
.then(callback);
|
||||
});
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
this.#preferences.activeProfile.asObject()
|
||||
.then(profileOrNull => {
|
||||
if (profileOrNull) {
|
||||
lastActiveProfileId = profileOrNull.id;
|
||||
@@ -409,11 +422,3 @@ export class MaintenancePopup extends BaseComponent {
|
||||
*/
|
||||
static #pendingSubmissionCount: number|null = null;
|
||||
}
|
||||
|
||||
export function createMaintenancePopup() {
|
||||
const container = document.createElement('div');
|
||||
|
||||
new MaintenancePopup(container);
|
||||
|
||||
return container;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$lib/components/MediaBoxTools";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_MAINTENANCE_STATE_CHANGED } from "$content/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
|
||||
|
||||
export class MaintenanceStatusIcon extends BaseComponent {
|
||||
export class TaggingProfileStatusIcon extends BaseComponent {
|
||||
#mediaBoxTools: MediaBoxTools | null = null;
|
||||
|
||||
build() {
|
||||
@@ -52,13 +52,13 @@ export class MaintenanceStatusIcon extends BaseComponent {
|
||||
this.container.innerText = '❓';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createMaintenanceStatusIcon() {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('maintenance-status-icon');
|
||||
|
||||
new MaintenanceStatusIcon(element);
|
||||
|
||||
return element;
|
||||
|
||||
static create(): HTMLElement {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('maintenance-status-icon');
|
||||
|
||||
new TaggingProfileStatusIcon(element);
|
||||
|
||||
return element;
|
||||
}
|
||||
}
|
||||
133
src/content/components/philomena/BlockCommunication.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { resolveTagNameFromLink, resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
|
||||
|
||||
export class BlockCommunication extends BaseComponent {
|
||||
#contentSection: HTMLElement | null = null;
|
||||
#tagLinks: HTMLAnchorElement[] = [];
|
||||
|
||||
#tagLinksReplaced: boolean | null = null;
|
||||
#linkTextReplaced: boolean | null = null;
|
||||
|
||||
protected build() {
|
||||
this.#contentSection = this.container.querySelector('.communication__content');
|
||||
this.#tagLinks = this.#findAllTagLinks();
|
||||
}
|
||||
|
||||
protected init() {
|
||||
Promise.all([
|
||||
BlockCommunication.#preferences.replaceLinks.get(),
|
||||
BlockCommunication.#preferences.replaceLinkText.get(),
|
||||
]).then(([replaceLinks, replaceLinkText]) => {
|
||||
this.#onReplaceLinkSettingResolved(
|
||||
replaceLinks,
|
||||
replaceLinkText
|
||||
);
|
||||
});
|
||||
|
||||
BlockCommunication.#preferences.subscribe(settings => {
|
||||
this.#onReplaceLinkSettingResolved(
|
||||
settings.replaceLinks ?? false,
|
||||
settings.replaceLinkText ?? true
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#onReplaceLinkSettingResolved(haveToReplaceLinks: boolean, shouldReplaceLinkText: boolean) {
|
||||
if (
|
||||
!this.#tagLinks.length
|
||||
|| this.#tagLinksReplaced === haveToReplaceLinks
|
||||
&& this.#linkTextReplaced === shouldReplaceLinkText
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const linkElement of this.#tagLinks) {
|
||||
linkElement.classList.toggle('tag', haveToReplaceLinks);
|
||||
|
||||
// Sometimes tags are being decorated with the code block inside. It should be fine to replace it right away.
|
||||
if (linkElement.childElementCount === 1 && linkElement.children[0].tagName === 'CODE') {
|
||||
linkElement.textContent = linkElement.children[0].textContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved tag name. It should be stored for the text replacement.
|
||||
*/
|
||||
let tagName: string | undefined;
|
||||
|
||||
if (haveToReplaceLinks) {
|
||||
tagName = resolveTagNameFromLink(new URL(linkElement.href)) ?? '';
|
||||
linkElement.dataset.tagCategory = resolveTagCategoryFromTagName(tagName) ?? '';
|
||||
} else {
|
||||
linkElement.dataset.tagCategory = '';
|
||||
}
|
||||
|
||||
this.#toggleTagLinkText(
|
||||
linkElement,
|
||||
haveToReplaceLinks && shouldReplaceLinkText,
|
||||
tagName,
|
||||
);
|
||||
}
|
||||
|
||||
this.#tagLinksReplaced = haveToReplaceLinks;
|
||||
this.#linkTextReplaced = shouldReplaceLinkText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap the link text with the tag name or restore it back to original content. This function will only perform
|
||||
* replacement on links without any additional tags inside. This will ensure link won't break original content.
|
||||
* @param linkElement Element to swap the text on.
|
||||
* @param shouldSwapToTagName Should we swap the text to tag name or retore it back from memory.
|
||||
* @param tagName Tag name to swap the text to. If not provided, text will be swapped back.
|
||||
* @private
|
||||
*/
|
||||
#toggleTagLinkText(linkElement: HTMLElement, shouldSwapToTagName: boolean, tagName?: string) {
|
||||
if (linkElement.childElementCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure we save the original text to memory.
|
||||
if (!BlockCommunication.#originalTagLinkTexts.has(linkElement)) {
|
||||
BlockCommunication.#originalTagLinkTexts.set(linkElement, linkElement.textContent);
|
||||
}
|
||||
|
||||
if (shouldSwapToTagName && tagName) {
|
||||
linkElement.textContent = tagName;
|
||||
} else {
|
||||
linkElement.textContent = BlockCommunication.#originalTagLinkTexts.get(linkElement) ?? linkElement.textContent;
|
||||
}
|
||||
}
|
||||
|
||||
#findAllTagLinks(): HTMLAnchorElement[] {
|
||||
return Array
|
||||
.from(this.#contentSection?.querySelectorAll('a') || [])
|
||||
.filter(
|
||||
link =>
|
||||
// Support links pointing to the tag page.
|
||||
link.pathname.startsWith('/tags/')
|
||||
// Also capture link which point to the search results with single tag.
|
||||
|| link.pathname.startsWith('/search')
|
||||
&& link.search.includes('q=')
|
||||
);
|
||||
}
|
||||
|
||||
static #preferences = new TagsPreferences();
|
||||
|
||||
/**
|
||||
* Map of links to their original texts. These texts need to be stored here to make them restorable. Keys is a link
|
||||
* element and value is a text.
|
||||
* @private
|
||||
*/
|
||||
static #originalTagLinkTexts: WeakMap<HTMLElement, string> = new WeakMap();
|
||||
|
||||
static findAndInitializeAll() {
|
||||
for (const container of document.querySelectorAll<HTMLElement>('.block.communication')) {
|
||||
if (getComponent(container)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
new BlockCommunication(container).initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/content/components/philomena/MediaBox.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { buildTagsAndAliasesMap } from "$lib/philomena/tag-utils";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_TAGS_UPDATED } from "$content/components/events/maintenance-popup-events";
|
||||
|
||||
export class MediaBox extends BaseComponent {
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
#imageLinkElement: HTMLAnchorElement | null = null;
|
||||
#tagsAndAliases: Map<string, string> | null = null;
|
||||
|
||||
init() {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
|
||||
|
||||
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
|
||||
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
|
||||
const updatedMap = tagsUpdatedEvent.detail;
|
||||
|
||||
if (!(updatedMap instanceof Map)) {
|
||||
throw new TypeError("Tags and aliases should be stored as Map!");
|
||||
}
|
||||
|
||||
this.#tagsAndAliases = updatedMap;
|
||||
}
|
||||
|
||||
#calculateMediaBoxTags() {
|
||||
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
|
||||
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
|
||||
return buildTagsAndAliasesMap(tagAliases, actualTags);
|
||||
}
|
||||
|
||||
get tagsAndAliases(): Map<string, string> | null {
|
||||
if (!this.#tagsAndAliases) {
|
||||
this.#tagsAndAliases = this.#calculateMediaBoxTags();
|
||||
}
|
||||
|
||||
return this.#tagsAndAliases;
|
||||
}
|
||||
|
||||
get imageId(): number {
|
||||
const imageId = this.container.dataset.imageId;
|
||||
|
||||
if (!imageId) {
|
||||
throw new Error('Missing image ID');
|
||||
}
|
||||
|
||||
return parseInt(imageId);
|
||||
}
|
||||
|
||||
get imageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.#thumbnailContainer?.dataset.uris;
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the media box element into the special wrapper.
|
||||
*/
|
||||
static initialize(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
|
||||
new MediaBox(mediaBoxContainer)
|
||||
.initialize();
|
||||
|
||||
for (let childComponentElement of childComponentElements) {
|
||||
mediaBoxContainer.appendChild(childComponentElement);
|
||||
getComponent(childComponentElement)?.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
static findElements(): NodeListOf<HTMLElement> {
|
||||
return document.querySelectorAll('.media-box');
|
||||
}
|
||||
|
||||
static initializePositionCalculation(mediaBoxesList: NodeListOf<HTMLElement>) {
|
||||
window.addEventListener('resize', () => {
|
||||
let lastMediaBox: HTMLElement | null = null;
|
||||
let lastMediaBoxPosition: number | null = null;
|
||||
|
||||
for (const mediaBoxElement of mediaBoxesList) {
|
||||
const yPosition = mediaBoxElement.getBoundingClientRect().y;
|
||||
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
|
||||
|
||||
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
|
||||
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
|
||||
|
||||
lastMediaBox = mediaBoxElement;
|
||||
lastMediaBoxPosition = yPosition;
|
||||
}
|
||||
|
||||
// Last-ever media box is checked separately
|
||||
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
|
||||
lastMediaBox.classList.add('media-box--last');
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,14 @@
|
||||
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 { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
const categoriesResolver = new CustomCategoriesResolver();
|
||||
|
||||
export class TagDropdownWrapper extends BaseComponent {
|
||||
export class TagDropdown extends BaseComponent {
|
||||
/**
|
||||
* Container with dropdown elements to insert options into.
|
||||
*/
|
||||
@@ -29,7 +27,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
/**
|
||||
* Local clone of the currently active profile used for updating the list of tags.
|
||||
*/
|
||||
#activeProfile: MaintenanceProfile | null = null;
|
||||
#activeProfile: TaggingProfile | null = null;
|
||||
|
||||
/**
|
||||
* Is cursor currently entered the dropdown.
|
||||
@@ -46,7 +44,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
this.on('mouseenter', this.#onDropdownEntered.bind(this));
|
||||
this.on('mouseleave', this.#onDropdownLeft.bind(this));
|
||||
|
||||
TagDropdownWrapper.#watchActiveProfile(activeProfileOrNull => {
|
||||
TagDropdown.#watchActiveProfile(activeProfileOrNull => {
|
||||
this.#activeProfile = activeProfileOrNull;
|
||||
|
||||
if (this.#isEntered) {
|
||||
@@ -122,7 +120,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
|
||||
#updateButtons() {
|
||||
if (!this.#activeProfile) {
|
||||
this.#addToNewButton ??= TagDropdownWrapper.#createDropdownLink(
|
||||
this.#addToNewButton ??= TagDropdown.#createDropdownLink(
|
||||
'Add to new tagging profile',
|
||||
this.#onAddToNewClicked.bind(this)
|
||||
);
|
||||
@@ -135,7 +133,7 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
}
|
||||
|
||||
if (this.#activeProfile) {
|
||||
this.#toggleOnExistingButton ??= TagDropdownWrapper.#createDropdownLink(
|
||||
this.#toggleOnExistingButton ??= TagDropdown.#createDropdownLink(
|
||||
'Add to existing tagging profile',
|
||||
this.#onToggleInExistingClicked.bind(this)
|
||||
);
|
||||
@@ -148,11 +146,10 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
profileSpecificButtonText = `Remove from profile "${profileName}"`;
|
||||
}
|
||||
|
||||
// Derpibooru has icons in dropdown. Make sure to only edit text and keep the icon untouched. Also, add the space
|
||||
// before the text to make space between text and icon.
|
||||
if (__CURRENT_SITE__ === 'derpibooru' && this.#toggleOnExistingButton.lastChild instanceof Text) {
|
||||
if (this.#toggleOnExistingButton.lastChild instanceof Text) {
|
||||
this.#toggleOnExistingButton.lastChild.textContent = ` ${profileSpecificButtonText}`;
|
||||
} else {
|
||||
// Just in case last child is missing, then update the text on the full element.
|
||||
this.#toggleOnExistingButton.textContent = profileSpecificButtonText;
|
||||
}
|
||||
|
||||
@@ -173,14 +170,14 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
throw new Error('Missing tag name to create the profile!');
|
||||
}
|
||||
|
||||
const profile = new MaintenanceProfile(crypto.randomUUID(), {
|
||||
const profile = new TaggingProfile(crypto.randomUUID(), {
|
||||
name: 'Temporary Profile (' + (new Date().toISOString()) + ')',
|
||||
tags: [this.tagName],
|
||||
temporary: true,
|
||||
});
|
||||
|
||||
await profile.save();
|
||||
await TagDropdownWrapper.#maintenanceSettings.setActiveProfileId(profile.id);
|
||||
await TagDropdown.#preferences.activeProfile.set(profile.id);
|
||||
}
|
||||
|
||||
async #onToggleInExistingClicked() {
|
||||
@@ -206,25 +203,25 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
await this.#activeProfile.save();
|
||||
}
|
||||
|
||||
static #maintenanceSettings = new MaintenanceSettings();
|
||||
static #preferences = new TaggingProfilesPreferences();
|
||||
|
||||
/**
|
||||
* Watch for changes to active profile.
|
||||
* @param onActiveProfileChange Callback to call when profile was
|
||||
* changed.
|
||||
*/
|
||||
static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile | null) => void) {
|
||||
static #watchActiveProfile(onActiveProfileChange: (profile: TaggingProfile | null) => void) {
|
||||
let lastActiveProfile: string | null = null;
|
||||
|
||||
this.#maintenanceSettings.subscribe((settings) => {
|
||||
this.#preferences.subscribe((settings) => {
|
||||
lastActiveProfile = settings.activeProfile ?? null;
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
this.#preferences
|
||||
.activeProfile.asObject()
|
||||
.then(onActiveProfileChange);
|
||||
});
|
||||
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
TaggingProfile.subscribe(profiles => {
|
||||
const activeProfile = profiles
|
||||
.find(profile => profile.id === lastActiveProfile);
|
||||
|
||||
@@ -232,8 +229,8 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
);
|
||||
});
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
this.#preferences
|
||||
.activeProfile.asObject()
|
||||
.then(activeProfile => {
|
||||
lastActiveProfile = activeProfile?.id ?? null;
|
||||
onActiveProfileChange(activeProfile);
|
||||
@@ -251,16 +248,11 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
dropdownLink.href = '#';
|
||||
dropdownLink.className = 'tag__dropdown__link';
|
||||
|
||||
// Derpibooru has an icon in dropdown item. Create the icon and place the text with additional space in front of it.
|
||||
if (__CURRENT_SITE__ === 'derpibooru') {
|
||||
const dropdownLinkIcon = document.createElement('i');
|
||||
dropdownLinkIcon.classList.add('fa', 'fa-tags');
|
||||
const dropdownLinkIcon = document.createElement('i');
|
||||
dropdownLinkIcon.classList.add('fa', 'fa-tags');
|
||||
|
||||
dropdownLink.textContent = ` ${text}`;
|
||||
dropdownLink.insertAdjacentElement('afterbegin', dropdownLinkIcon);
|
||||
} else {
|
||||
dropdownLink.textContent = text;
|
||||
}
|
||||
dropdownLink.textContent = ` ${text}`;
|
||||
dropdownLink.insertAdjacentElement('afterbegin', dropdownLinkIcon);
|
||||
|
||||
dropdownLink.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
@@ -269,58 +261,65 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
|
||||
return dropdownLink;
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapTagDropdown(element: HTMLElement) {
|
||||
// Skip initialization when tag component is already wrapped
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
static #categoriesResolver = new CustomCategoriesResolver();
|
||||
static #processedElements: WeakSet<HTMLElement> = new WeakSet();
|
||||
|
||||
static #findAll(parentNode: ParentNode = document): NodeListOf<HTMLElement> {
|
||||
return parentNode.querySelectorAll('.tag.dropdown');
|
||||
}
|
||||
|
||||
const tagDropdown = new TagDropdownWrapper(element);
|
||||
tagDropdown.initialize();
|
||||
static #initialize(element: HTMLElement) {
|
||||
// Skip initialization when tag component is already wrapped
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
categoriesResolver.addElement(tagDropdown);
|
||||
}
|
||||
const tagDropdown = new TagDropdown(element);
|
||||
tagDropdown.initialize();
|
||||
|
||||
const processedElementsSet = new WeakSet<HTMLElement>();
|
||||
|
||||
export function watchTagDropdownsInTagsEditor() {
|
||||
// We only need to watch for new editor elements if there is a tag editor present on the page
|
||||
if (!document.querySelector('#image_tags_and_source')) {
|
||||
return;
|
||||
this.#categoriesResolver.addElement(tagDropdown);
|
||||
}
|
||||
|
||||
document.body.addEventListener('mouseover', event => {
|
||||
const targetElement = event.target;
|
||||
static findAllAndInitialize(parentNode: ParentNode = document) {
|
||||
for (const element of this.#findAll(parentNode)) {
|
||||
this.#initialize(element);
|
||||
}
|
||||
}
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
static watch() {
|
||||
// We only need to watch for new editor elements if there is a tag editor present on the page
|
||||
if (!document.querySelector('#image_tags_and_source')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (processedElementsSet.has(targetElement)) {
|
||||
return;
|
||||
}
|
||||
document.body.addEventListener('mouseover', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) {
|
||||
processedElementsSet.add(targetElement);
|
||||
return;
|
||||
}
|
||||
if (this.#processedElements.has(targetElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
processedElementsSet.add(targetElement);
|
||||
processedElementsSet.add(closestTagEditor);
|
||||
const closestTagEditor = targetElement.closest<HTMLElement>('#image_tags_and_source');
|
||||
|
||||
for (const tagDropdownElement of closestTagEditor.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
});
|
||||
if (!closestTagEditor || this.#processedElements.has(closestTagEditor)) {
|
||||
this.#processedElements.add(targetElement);
|
||||
return;
|
||||
}
|
||||
|
||||
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
|
||||
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
|
||||
for (const tagDropdownElement of event.detail.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
});
|
||||
this.#processedElements.add(targetElement);
|
||||
this.#processedElements.add(closestTagEditor);
|
||||
|
||||
this.findAllAndInitialize(closestTagEditor);
|
||||
});
|
||||
|
||||
// When form is submitted, its DOM is completely updated. We need to fetch those tags in this case.
|
||||
on(document.body, EVENT_FORM_EDITOR_UPDATED, event => {
|
||||
this.findAllAndInitialize(event.detail);
|
||||
});
|
||||
}
|
||||
}
|
||||
291
src/content/components/philomena/TagsForm.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { emit, on, type UnsubscribeFunction } from "$content/components/events/comms";
|
||||
import { EVENT_FETCH_COMPLETE, EVENT_RELOAD, type ReloadCustomOptions } from "$content/components/events/booru-events";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
|
||||
import EditorPresetsBlock from "$content/components/extension/presets/EditorPresetsBlock";
|
||||
import { EVENT_PRESET_TAG_CHANGE_APPLIED, type PresetTagChange } from "$content/components/events/preset-block-events";
|
||||
|
||||
export class TagsForm extends BaseComponent {
|
||||
#togglePresetsButton: HTMLButtonElement = document.createElement('button');
|
||||
#presetsList = EditorPresetsBlock.create();
|
||||
#plainEditorTextarea: HTMLTextAreaElement|null = null;
|
||||
#fancyEditorInput: HTMLInputElement|null = null;
|
||||
#tagsSet: Set<string> = new Set();
|
||||
|
||||
protected build() {
|
||||
this.#togglePresetsButton.classList.add(
|
||||
'button',
|
||||
'button--state-primary',
|
||||
'button--bold',
|
||||
'button--separate-left',
|
||||
);
|
||||
|
||||
this.#togglePresetsButton.textContent = 'Presets';
|
||||
|
||||
this.container
|
||||
.querySelector(':is(.fancy-tag-edit, .fancy-tag-upload) ~ button:last-of-type')
|
||||
?.after(this.#togglePresetsButton, this.#presetsList.container);
|
||||
|
||||
this.#plainEditorTextarea = this.container.querySelector('textarea.tagsinput');
|
||||
this.#fancyEditorInput = this.container.querySelector('.js-taginput-fancy input');
|
||||
}
|
||||
|
||||
protected init() {
|
||||
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
|
||||
const unsubscribe = on(
|
||||
this.container,
|
||||
EVENT_FETCH_COMPLETE,
|
||||
() => this.#waitAndDetectUpdatedForm(unsubscribe),
|
||||
);
|
||||
|
||||
this.#togglePresetsButton.addEventListener('click', this.#togglePresetsList.bind(this));
|
||||
this.#presetsList.initialize();
|
||||
|
||||
this.#plainEditorTextarea?.addEventListener('input', this.#refreshTagsListForPresets.bind(this));
|
||||
this.#fancyEditorInput?.addEventListener('keydown', this.#refreshTagsListForPresets.bind(this));
|
||||
|
||||
this.#refreshTagsListForPresets();
|
||||
|
||||
on(this.#presetsList, EVENT_PRESET_TAG_CHANGE_APPLIED, this.#onTagChangeRequested.bind(this));
|
||||
|
||||
if (this.#plainEditorTextarea) {
|
||||
// When reloaded, we should catch and refresh the colors. Extension reuses this event to force site to update
|
||||
// list of tags in the fancy tag editor.
|
||||
on(this.#plainEditorTextarea, EVENT_RELOAD, this.#onPlainEditorReloadRequested.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
|
||||
const elementContainingTagEditor = this.container
|
||||
.closest('#image_tags_and_source')
|
||||
?.parentElement;
|
||||
|
||||
if (!elementContainingTagEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagsFormElement || getComponent(tagsFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormComponent = new TagsForm(tagsFormElement);
|
||||
tagFormComponent.initialize();
|
||||
|
||||
const fullTagEditor = tagFormComponent.parentTagEditorElement;
|
||||
|
||||
if (fullTagEditor) {
|
||||
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
|
||||
} else {
|
||||
console.info('Tag form is not in the tag editor. Event is not sent.');
|
||||
}
|
||||
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
observer.observe(elementContainingTagEditor, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
|
||||
// Make sure to forcibly disconnect everything after a while.
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
get parentTagEditorElement(): HTMLElement | null {
|
||||
return this.container.closest<HTMLElement>('.js-tagsauce')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all the tag categories available on the page and color the tags in the editor according to them.
|
||||
*/
|
||||
refreshTagColors() {
|
||||
const tagCategories = this.#gatherTagCategories();
|
||||
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
|
||||
|
||||
for (const tagElement of editableTags) {
|
||||
// Tag name is stored in the "remove" link and not in the tag itself.
|
||||
const removeLink = tagElement.querySelector('a');
|
||||
|
||||
if (!removeLink) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = removeLink.dataset.tagName;
|
||||
|
||||
if (!tagName || !tagCategories.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryName = tagCategories.get(tagName)!;
|
||||
|
||||
tagElement.dataset.tagCategory = categoryName;
|
||||
tagElement.setAttribute('data-tag-category', categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect list of categories from the tags on the page.
|
||||
* @return
|
||||
*/
|
||||
#gatherTagCategories(): Map<string, string> {
|
||||
const tagCategories: Map<string, string> = new Map();
|
||||
|
||||
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
|
||||
const tagName = tagElement.dataset.tagName;
|
||||
const tagCategory = tagElement.dataset.tagCategory;
|
||||
|
||||
if (!tagName || !tagCategory) {
|
||||
console.warn('Missing tag name or category!');
|
||||
continue;
|
||||
}
|
||||
|
||||
tagCategories.set(tagName, tagCategory);
|
||||
}
|
||||
|
||||
return tagCategories;
|
||||
}
|
||||
|
||||
#togglePresetsList(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
this.#presetsList.toggleVisibility();
|
||||
this.#refreshTagsListForPresets();
|
||||
}
|
||||
|
||||
#refreshTagsListForPresets() {
|
||||
this.#tagsSet = new Set(
|
||||
this.#plainEditorTextarea?.value
|
||||
.split(',')
|
||||
.map(tagName => tagName.trim())
|
||||
);
|
||||
|
||||
this.#presetsList.updateTags(this.#tagsSet);
|
||||
}
|
||||
|
||||
#onTagChangeRequested(event: CustomEvent<PresetTagChange>) {
|
||||
const { addedTags = null, removedTags = null } = event.detail;
|
||||
let tagChangeList: string[] = [];
|
||||
|
||||
if (addedTags) {
|
||||
tagChangeList.push(...addedTags);
|
||||
}
|
||||
|
||||
if (removedTags) {
|
||||
tagChangeList.push(
|
||||
...Array.from(removedTags)
|
||||
.filter(tagName => this.#tagsSet.has(tagName))
|
||||
.map(tagName => `-${tagName}`)
|
||||
);
|
||||
}
|
||||
|
||||
const offsetBeforeSubmission = this.#presetsList.container.offsetTop;
|
||||
|
||||
this.#applyTagChangesWithFancyTagEditor(
|
||||
tagChangeList.join(',')
|
||||
);
|
||||
|
||||
const offsetDifference = this.#presetsList.container.offsetTop - offsetBeforeSubmission;
|
||||
|
||||
// Compensating for the layout shift: when user clicks on a tag (or on "add/remove all tags"), tag editor might
|
||||
// overflow the current line and wrap tags around to the next line, causing presets section to shift. We need to
|
||||
// avoid that for better UX.
|
||||
if (offsetDifference !== 0) {
|
||||
window.scrollTo({
|
||||
top: window.scrollY + offsetDifference,
|
||||
behavior: 'instant',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#applyTagChangesWithFancyTagEditor(tagsListWithChanges: string): void {
|
||||
if (!this.#fancyEditorInput || !this.#plainEditorTextarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalValue = this.#fancyEditorInput.value;
|
||||
|
||||
// We have to tell plain text editor to also refresh the list of tags in the fancy editor, just in case user
|
||||
// made changes to it in plain mode.
|
||||
emit(this.#plainEditorTextarea, EVENT_RELOAD, {
|
||||
// Sending that we don't need to refresh the color on this event, since we will do that ourselves later, after
|
||||
// changes are applied.
|
||||
skipTagColorRefresh: true,
|
||||
skipTagRefresh: true,
|
||||
});
|
||||
|
||||
this.#fancyEditorInput.value = tagsListWithChanges;
|
||||
this.#fancyEditorInput.dispatchEvent(new KeyboardEvent('keydown', {
|
||||
key: 'Comma',
|
||||
}));
|
||||
|
||||
this.#fancyEditorInput.value = originalValue;
|
||||
|
||||
this.refreshTagColors();
|
||||
}
|
||||
|
||||
#onPlainEditorReloadRequested(event: CustomEvent<ReloadCustomOptions|null>) {
|
||||
if (!event.detail?.skipTagColorRefresh) {
|
||||
this.refreshTagColors();
|
||||
}
|
||||
|
||||
if (!event.detail?.skipTagRefresh) {
|
||||
this.#refreshTagsListForPresets();
|
||||
}
|
||||
}
|
||||
|
||||
static watchForEditors() {
|
||||
document.body.addEventListener('click', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
|
||||
|
||||
if (!tagEditorWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
|
||||
|
||||
if (!refreshTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagFormElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagEditor = getComponent(tagFormElement);
|
||||
|
||||
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
|
||||
tagEditor = new TagsForm(tagFormElement);
|
||||
tagEditor.initialize();
|
||||
}
|
||||
|
||||
(tagEditor as TagsForm).refreshTagColors();
|
||||
});
|
||||
}
|
||||
|
||||
static initializeUploadEditor() {
|
||||
const uploadEditorContainer = document.querySelector<HTMLElement>('.field:has(.fancy-tag-upload)');
|
||||
|
||||
if (!uploadEditorContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagsForm(uploadEditorContainer).initialize();
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
|
||||
import { on } from "$lib/components/events/comms";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
import TagSettings from "$lib/extension/settings/TagSettings";
|
||||
import type { TagDropdown } from "$content/components/philomena/TagDropdown";
|
||||
import { on } from "$content/components/events/comms";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-events";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
|
||||
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
|
||||
|
||||
export class TagsListBlock extends BaseComponent {
|
||||
#tagsListButtonsContainer: HTMLElement | null = null;
|
||||
@@ -14,14 +14,14 @@ export class TagsListBlock extends BaseComponent {
|
||||
#toggleGroupingButton = document.createElement('a');
|
||||
#toggleGroupingButtonIcon = document.createElement('i');
|
||||
|
||||
#tagSettings = new TagSettings();
|
||||
#preferences = new TagsPreferences();
|
||||
|
||||
#shouldDisplaySeparation = false;
|
||||
|
||||
#separatedGroups = new Map<string, TagGroup>();
|
||||
#separatedHeaders = new Map<string, HTMLElement>();
|
||||
#groupsCount = new Map<string, number>();
|
||||
#lastTagGroup = new WeakMap<TagDropdownWrapper, TagGroup | null>;
|
||||
#lastTagGroup = new WeakMap<TagDropdown, TagGroup | null>;
|
||||
|
||||
#isReorderingPlanned = false;
|
||||
|
||||
@@ -44,8 +44,8 @@ export class TagsListBlock extends BaseComponent {
|
||||
}
|
||||
|
||||
init() {
|
||||
this.#tagSettings.resolveGroupSeparation().then(this.#onTagSeparationChange.bind(this));
|
||||
this.#tagSettings.subscribe(settings => {
|
||||
this.#preferences.groupSeparation.get().then(this.#onTagSeparationChange.bind(this));
|
||||
this.#preferences.subscribe(settings => {
|
||||
this.#onTagSeparationChange(Boolean(settings.groupSeparation))
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ export class TagsListBlock extends BaseComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagDropdown = getComponent<TagDropdownWrapper>(maybeDropdownElement);
|
||||
const tagDropdown = getComponent<TagDropdown>(maybeDropdownElement);
|
||||
|
||||
if (!tagDropdown) {
|
||||
return;
|
||||
@@ -103,7 +103,7 @@ export class TagsListBlock extends BaseComponent {
|
||||
|
||||
#onToggleGroupingClicked(event: Event) {
|
||||
event.preventDefault();
|
||||
void this.#tagSettings.setGroupSeparation(!this.#shouldDisplaySeparation);
|
||||
void this.#preferences.groupSeparation.set(!this.#shouldDisplaySeparation);
|
||||
}
|
||||
|
||||
#handleTagGroupChanges(tagGroup: TagGroup) {
|
||||
@@ -146,7 +146,7 @@ export class TagsListBlock extends BaseComponent {
|
||||
heading.innerText = group.settings.name;
|
||||
}
|
||||
|
||||
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdownWrapper) {
|
||||
#handleResolvedTagGroup(resolvedGroup: TagGroup | null, tagComponent: TagDropdown) {
|
||||
const previousGroupId = this.#lastTagGroup.get(tagComponent)?.id;
|
||||
const currentGroupId = resolvedGroup?.id;
|
||||
const isDifferentId = currentGroupId !== previousGroupId;
|
||||
@@ -217,28 +217,28 @@ export class TagsListBlock extends BaseComponent {
|
||||
|
||||
static #iconGroupingDisabled = 'fa-folder';
|
||||
static #iconGroupingEnabled = 'fa-folder-tree';
|
||||
}
|
||||
|
||||
export function initializeAllTagsLists() {
|
||||
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
static initializeAll() {
|
||||
for (let element of document.querySelectorAll<HTMLElement>('#image_tags_and_source')) {
|
||||
if (getComponent(element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagsListBlock(element)
|
||||
.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
new TagsListBlock(element)
|
||||
.initialize();
|
||||
static watchUpdatedLists() {
|
||||
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
|
||||
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
|
||||
|
||||
if (!tagsListElement || getComponent(tagsListElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagsListBlock(tagsListElement)
|
||||
.initialize();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function watchForUpdatedTagLists() {
|
||||
on(document, EVENT_FORM_EDITOR_UPDATED, event => {
|
||||
const tagsListElement = event.detail.closest<HTMLElement>('#image_tags_and_source');
|
||||
|
||||
if (!tagsListElement || getComponent(tagsListElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
new TagsListBlock(tagsListElement)
|
||||
.initialize();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { ImageListInfo } from "$content/components/philomena/listing/ImageListInfo";
|
||||
|
||||
export class ImageListContainer extends BaseComponent {
|
||||
#info: ImageListInfo | null = null;
|
||||
|
||||
protected build() {
|
||||
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
|
||||
|
||||
if (imageListInfoContainer) {
|
||||
this.#info = new ImageListInfo(imageListInfoContainer);
|
||||
this.#info.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
static findAndInitialize() {
|
||||
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
|
||||
|
||||
if (imageListContainer) {
|
||||
new ImageListContainer(imageListContainer).initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
|
||||
export class ImageListInfo extends BaseComponent {
|
||||
#tagElement: HTMLElement | null = null;
|
||||
@@ -1,19 +1,18 @@
|
||||
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";
|
||||
import { initializeImageListContainer } from "$lib/components/listing/ImageListContainer";
|
||||
import { TaggingProfilePopup } from "$content/components/extension/profiles/TaggingProfilePopup";
|
||||
import { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
|
||||
import { MediaBox } from "$content/components/philomena/MediaBox";
|
||||
import { TaggingProfileStatusIcon } from "$content/components/extension/profiles/TaggingProfileStatusIcon";
|
||||
import { ImageShowFullscreenButton } from "$content/components/extension/ImageShowFullscreenButton";
|
||||
import { ImageListContainer } from "$content/components/philomena/listing/ImageListContainer";
|
||||
|
||||
const mediaBoxes = document.querySelectorAll<HTMLElement>('.media-box');
|
||||
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
|
||||
const mediaBoxes = MediaBox.findElements();
|
||||
|
||||
mediaBoxes.forEach(mediaBoxElement => {
|
||||
initializeMediaBox(mediaBoxElement, [
|
||||
createMediaBoxTools(
|
||||
createMaintenancePopup(),
|
||||
createMaintenanceStatusIcon(),
|
||||
createImageShowFullscreenButton(),
|
||||
MediaBox.initialize(mediaBoxElement, [
|
||||
MediaBoxTools.create(
|
||||
TaggingProfilePopup.create(),
|
||||
TaggingProfileStatusIcon.create(),
|
||||
ImageShowFullscreenButton.create(),
|
||||
)
|
||||
]);
|
||||
|
||||
@@ -23,8 +22,5 @@ mediaBoxes.forEach(mediaBoxElement => {
|
||||
})
|
||||
});
|
||||
|
||||
calculateMediaBoxesPositions(mediaBoxes);
|
||||
|
||||
if (imageListContainer) {
|
||||
initializeImageListContainer(imageListContainer);
|
||||
}
|
||||
MediaBox.initializePositionCalculation(mediaBoxes);
|
||||
ImageListContainer.findAndInitialize();
|
||||
|
||||
3
src/content/posts.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { BlockCommunication } from "$content/components/philomena/BlockCommunication";
|
||||
|
||||
BlockCommunication.findAndInitializeAll();
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TagsForm } from "$lib/components/TagsForm";
|
||||
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$lib/components/TagsListBlock";
|
||||
import { TagsForm } from "$content/components/philomena/TagsForm";
|
||||
import { TagsListBlock } from "$content/components/philomena/TagsListBlock";
|
||||
|
||||
initializeAllTagsLists();
|
||||
watchForUpdatedTagLists();
|
||||
TagsListBlock.initializeAll();
|
||||
TagsListBlock.watchUpdatedLists();
|
||||
TagsForm.watchForEditors();
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$lib/components/TagDropdownWrapper";
|
||||
import { TagDropdown } from "$content/components/philomena/TagDropdown";
|
||||
|
||||
for (let tagDropdownElement of document.querySelectorAll<HTMLElement>('.tag.dropdown')) {
|
||||
wrapTagDropdown(tagDropdownElement);
|
||||
}
|
||||
|
||||
watchTagDropdownsInTagsEditor();
|
||||
TagDropdown.findAllAndInitialize();
|
||||
TagDropdown.watch();
|
||||
|
||||
3
src/content/upload.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { TagsForm } from "$content/components/philomena/TagsForm";
|
||||
|
||||
TagsForm.initializeUploadEditor();
|
||||
@@ -1,12 +0,0 @@
|
||||
export const categories = [
|
||||
'rating',
|
||||
'spoiler',
|
||||
'origin',
|
||||
'oc',
|
||||
'error',
|
||||
'character',
|
||||
'content-official',
|
||||
'content-fanmade',
|
||||
'species',
|
||||
'body-type',
|
||||
];
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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 { EVENT_ACTIVE_PROFILE_CHANGED } from "$lib/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper";
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
|
||||
export class MediaBoxTools extends BaseComponent {
|
||||
#mediaBox: MediaBoxWrapper | null = null;
|
||||
#maintenancePopup: MaintenancePopup | null = null;
|
||||
|
||||
init() {
|
||||
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
|
||||
|
||||
if (!mediaBoxElement) {
|
||||
throw new Error('Toolbox element initialized outside of the media box!');
|
||||
}
|
||||
|
||||
this.#mediaBox = getComponent(mediaBoxElement);
|
||||
|
||||
for (let childElement of this.container.children) {
|
||||
if (!(childElement instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const component = getComponent(childElement);
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!component.isInitialized) {
|
||||
component.initialize();
|
||||
}
|
||||
|
||||
if (!this.#maintenancePopup && component instanceof MaintenancePopup) {
|
||||
this.#maintenancePopup = component;
|
||||
}
|
||||
}
|
||||
|
||||
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
|
||||
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
|
||||
}
|
||||
|
||||
get maintenancePopup(): MaintenancePopup | null {
|
||||
return this.#maintenancePopup;
|
||||
}
|
||||
|
||||
get mediaBox(): MediaBoxWrapper | null {
|
||||
return this.#mediaBox;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a maintenance popup element.
|
||||
* @param childrenElements List of children elements to append to the component.
|
||||
* @return The maintenance popup element.
|
||||
*/
|
||||
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
|
||||
const mediaBoxToolsContainer = document.createElement('div');
|
||||
mediaBoxToolsContainer.classList.add('media-box-tools');
|
||||
|
||||
if (childrenElements.length) {
|
||||
mediaBoxToolsContainer.append(...childrenElements);
|
||||
}
|
||||
|
||||
new MediaBoxTools(mediaBoxToolsContainer);
|
||||
|
||||
return mediaBoxToolsContainer;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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 { EVENT_TAGS_UPDATED } from "$lib/components/events/maintenance-popup-events";
|
||||
|
||||
export class MediaBoxWrapper extends BaseComponent {
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
#imageLinkElement: HTMLAnchorElement | null = null;
|
||||
#tagsAndAliases: Map<string, string> | null = null;
|
||||
|
||||
init() {
|
||||
this.#thumbnailContainer = this.container.querySelector('.image-container');
|
||||
this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null;
|
||||
|
||||
on(this, EVENT_TAGS_UPDATED, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this));
|
||||
}
|
||||
|
||||
#onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent<Map<string, string> | null>) {
|
||||
const updatedMap = tagsUpdatedEvent.detail;
|
||||
|
||||
if (!(updatedMap instanceof Map)) {
|
||||
throw new TypeError("Tags and aliases should be stored as Map!");
|
||||
}
|
||||
|
||||
this.#tagsAndAliases = updatedMap;
|
||||
}
|
||||
|
||||
#calculateMediaBoxTags() {
|
||||
const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || [];
|
||||
const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || [];
|
||||
|
||||
return buildTagsAndAliasesMap(tagAliases, actualTags);
|
||||
}
|
||||
|
||||
get tagsAndAliases(): Map<string, string> | null {
|
||||
if (!this.#tagsAndAliases) {
|
||||
this.#tagsAndAliases = this.#calculateMediaBoxTags();
|
||||
}
|
||||
|
||||
return this.#tagsAndAliases;
|
||||
}
|
||||
|
||||
get imageId(): number {
|
||||
const imageId = this.container.dataset.imageId;
|
||||
|
||||
if (!imageId) {
|
||||
throw new Error('Missing image ID');
|
||||
}
|
||||
|
||||
return parseInt(imageId);
|
||||
}
|
||||
|
||||
get imageLinks(): App.ImageURIs {
|
||||
const jsonUris = this.#thumbnailContainer?.dataset.uris;
|
||||
|
||||
if (!jsonUris) {
|
||||
throw new Error('Missing URIs!');
|
||||
}
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the media box element into the special wrapper.
|
||||
*/
|
||||
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
|
||||
new MediaBoxWrapper(mediaBoxContainer)
|
||||
.initialize();
|
||||
|
||||
for (let childComponentElement of childComponentElements) {
|
||||
mediaBoxContainer.appendChild(childComponentElement);
|
||||
getComponent(childComponentElement)?.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf<HTMLElement>) {
|
||||
window.addEventListener('resize', () => {
|
||||
let lastMediaBox: HTMLElement | null = null;
|
||||
let lastMediaBoxPosition: number | null = null;
|
||||
|
||||
for (const mediaBoxElement of mediaBoxesList) {
|
||||
const yPosition = mediaBoxElement.getBoundingClientRect().y;
|
||||
const isOnTheSameLine = yPosition === lastMediaBoxPosition;
|
||||
|
||||
mediaBoxElement.classList.toggle('media-box--first', !isOnTheSameLine);
|
||||
lastMediaBox?.classList.toggle('media-box--last', !isOnTheSameLine);
|
||||
|
||||
lastMediaBox = mediaBoxElement;
|
||||
lastMediaBoxPosition = yPosition;
|
||||
}
|
||||
|
||||
// Last-ever media box is checked separately
|
||||
if (lastMediaBox && !lastMediaBox.nextElementSibling) {
|
||||
lastMediaBox.classList.add('media-box--last');
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { getComponent } from "$lib/components/base/component-utils";
|
||||
import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms";
|
||||
import { EVENT_FETCH_COMPLETE } from "$lib/components/events/booru-events";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$lib/components/events/tags-form-events";
|
||||
|
||||
export class TagsForm extends BaseComponent {
|
||||
protected init() {
|
||||
// Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here.
|
||||
const unsubscribe = on(
|
||||
this.container,
|
||||
EVENT_FETCH_COMPLETE,
|
||||
() => this.#waitAndDetectUpdatedForm(unsubscribe),
|
||||
);
|
||||
}
|
||||
|
||||
#waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void {
|
||||
const elementContainingTagEditor = this.container
|
||||
.closest('#image_tags_and_source')
|
||||
?.parentElement;
|
||||
|
||||
if (!elementContainingTagEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const tagsFormElement = elementContainingTagEditor.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagsFormElement || getComponent(tagsFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormComponent = new TagsForm(tagsFormElement);
|
||||
tagFormComponent.initialize();
|
||||
|
||||
const fullTagEditor = tagFormComponent.parentTagEditorElement;
|
||||
|
||||
if (fullTagEditor) {
|
||||
emit(document.body, EVENT_FORM_EDITOR_UPDATED, fullTagEditor);
|
||||
} else {
|
||||
console.info('Tag form is not in the tag editor. Event is not sent.');
|
||||
}
|
||||
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
observer.observe(elementContainingTagEditor, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
});
|
||||
|
||||
// Make sure to forcibly disconnect everything after a while.
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
unsubscribe();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
get parentTagEditorElement(): HTMLElement | null {
|
||||
return this.container.closest<HTMLElement>('.js-tagsauce')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all the tag categories available on the page and color the tags in the editor according to them.
|
||||
*/
|
||||
refreshTagColors() {
|
||||
const tagCategories = this.#gatherTagCategories();
|
||||
const editableTags = this.container.querySelectorAll<HTMLElement>('.tag');
|
||||
|
||||
for (const tagElement of editableTags) {
|
||||
// Tag name is stored in the "remove" link and not in the tag itself.
|
||||
const removeLink = tagElement.querySelector('a');
|
||||
|
||||
if (!removeLink) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = removeLink.dataset.tagName;
|
||||
|
||||
if (!tagName || !tagCategories.has(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryName = tagCategories.get(tagName)!;
|
||||
|
||||
tagElement.dataset.tagCategory = categoryName;
|
||||
tagElement.setAttribute('data-tag-category', categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect list of categories from the tags on the page.
|
||||
* @return
|
||||
*/
|
||||
#gatherTagCategories(): Map<string, string> {
|
||||
const tagCategories: Map<string, string> = new Map();
|
||||
|
||||
for (const tagElement of document.querySelectorAll<HTMLElement>('.tag[data-tag-name][data-tag-category]')) {
|
||||
const tagName = tagElement.dataset.tagName;
|
||||
const tagCategory = tagElement.dataset.tagCategory;
|
||||
|
||||
if (!tagName || !tagCategory) {
|
||||
console.warn('Missing tag name or category!');
|
||||
continue;
|
||||
}
|
||||
|
||||
tagCategories.set(tagName, tagCategory);
|
||||
}
|
||||
|
||||
return tagCategories;
|
||||
}
|
||||
|
||||
static watchForEditors() {
|
||||
document.body.addEventListener('click', event => {
|
||||
const targetElement = event.target;
|
||||
|
||||
if (!(targetElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagEditorWrapper = targetElement.closest('#image_tags_and_source');
|
||||
|
||||
if (!tagEditorWrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshTrigger = targetElement.closest<HTMLElement>('.js-taginput-show, #edit-tags')
|
||||
|
||||
if (!refreshTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagFormElement = tagEditorWrapper.querySelector<HTMLElement>('#tags-form');
|
||||
|
||||
if (!tagFormElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
let tagEditor = getComponent(tagFormElement);
|
||||
|
||||
if (!tagEditor || !(tagEditor instanceof TagsForm)) {
|
||||
tagEditor = new TagsForm(tagFormElement);
|
||||
tagEditor.initialize();
|
||||
}
|
||||
|
||||
(tagEditor as TagsForm).refreshTagColors();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export const EVENT_FETCH_COMPLETE = 'fetchcomplete';
|
||||
|
||||
export interface BooruEventsMap {
|
||||
[EVENT_FETCH_COMPLETE]: null; // Site sends the response, but extension will not get it due to isolation.
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { BaseComponent } from "$lib/components/base/BaseComponent";
|
||||
import { ImageListInfo } from "$lib/components/listing/ImageListInfo";
|
||||
|
||||
export class ImageListContainer extends BaseComponent {
|
||||
#info: ImageListInfo | null = null;
|
||||
|
||||
protected build() {
|
||||
const imageListInfoContainer = this.container.querySelector<HTMLElement>('.js-imagelist-info');
|
||||
|
||||
if (imageListInfoContainer) {
|
||||
this.#info = new ImageListInfo(imageListInfoContainer);
|
||||
this.#info.initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeImageListContainer(element: HTMLElement) {
|
||||
new ImageListContainer(element).initialize();
|
||||
}
|
||||
4
src/lib/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Automatically generated name of the plugin.
|
||||
*/
|
||||
export const PLUGIN_NAME = __CURRENT_SITE_NAME__ + ' Tagging Assistant';
|
||||
11
src/lib/dom-utils.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Reusable function to create icons from FontAwesome. Usable only for website, since extension doesn't host its own
|
||||
* copy of FA styles. Extension should use imports of SVGs inside CSS instead.
|
||||
* @param iconSlug Slug of the icon to be added.
|
||||
* @return Element with classes for FontAwesome icon added.
|
||||
*/
|
||||
export function createFontAwesomeIcon(iconSlug: string): HTMLElement {
|
||||
const iconElement = document.createElement('i');
|
||||
iconElement.classList.add('fa-solid', `fa-${iconSlug}`);
|
||||
return iconElement;
|
||||
}
|
||||
@@ -2,8 +2,9 @@ import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";
|
||||
import type { ImportableElementsList, ImportableEntityObject } from "$lib/extension/transporting/importables";
|
||||
import EntitiesTransporter, { type SameSiteStatus } from "$lib/extension/EntitiesTransporter";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
|
||||
type TransportersMapping = {
|
||||
[EntityName in keyof App.EntityNamesMap]: EntitiesTransporter<App.EntityNamesMap[EntityName]>;
|
||||
@@ -73,10 +74,12 @@ export default class BulkEntitiesTransporter {
|
||||
elements: entities
|
||||
.map(entity => {
|
||||
switch (true) {
|
||||
case entity instanceof MaintenanceProfile:
|
||||
case entity instanceof TaggingProfile:
|
||||
return BulkEntitiesTransporter.#transporters.profiles.exportToObject(entity);
|
||||
case entity instanceof TagGroup:
|
||||
return BulkEntitiesTransporter.#transporters.groups.exportToObject(entity);
|
||||
case entity instanceof TagEditorPreset:
|
||||
return BulkEntitiesTransporter.#transporters.presets.exportToObject(entity);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -99,8 +102,9 @@ export default class BulkEntitiesTransporter {
|
||||
}
|
||||
|
||||
static #transporters: TransportersMapping = {
|
||||
profiles: new EntitiesTransporter(MaintenanceProfile),
|
||||
profiles: new EntitiesTransporter(TaggingProfile),
|
||||
groups: new EntitiesTransporter(TagGroup),
|
||||
presets: new EntitiesTransporter(TagEditorPreset),
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { TagDropdownWrapper } from "$lib/components/TagDropdownWrapper";
|
||||
import type { TagDropdown } from "$content/components/philomena/TagDropdown";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { escapeRegExp } from "$lib/utils";
|
||||
import { emit } from "$lib/components/events/comms";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$lib/components/events/tag-dropdown-events";
|
||||
import { emit } from "$content/components/events/comms";
|
||||
import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdown-events";
|
||||
|
||||
export default class CustomCategoriesResolver {
|
||||
#exactGroupMatches = new Map<string, TagGroup>();
|
||||
#regExpGroupMatches = new Map<RegExp, TagGroup>();
|
||||
#tagDropdowns: TagDropdownWrapper[] = [];
|
||||
#tagDropdowns: TagDropdown[] = [];
|
||||
#nextQueuedUpdate: Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
@@ -15,7 +15,7 @@ export default class CustomCategoriesResolver {
|
||||
TagGroup.readAll().then(this.#onTagGroupsReceived.bind(this));
|
||||
}
|
||||
|
||||
public addElement(tagDropdown: TagDropdownWrapper): void {
|
||||
public addElement(tagDropdown: TagDropdown): void {
|
||||
this.#tagDropdowns.push(tagDropdown);
|
||||
|
||||
if (!this.#exactGroupMatches.size && !this.#regExpGroupMatches.size) {
|
||||
@@ -49,7 +49,7 @@ export default class CustomCategoriesResolver {
|
||||
* @return {boolean} Will return false when tag is processed and true when it is not found.
|
||||
* @private
|
||||
*/
|
||||
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdownWrapper): boolean {
|
||||
#applyCustomCategoryForExactMatches(tagDropdown: TagDropdown): boolean {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
if (!this.#exactGroupMatches.has(tagName)) {
|
||||
@@ -65,7 +65,7 @@ export default class CustomCategoriesResolver {
|
||||
return false;
|
||||
}
|
||||
|
||||
#matchCustomCategoryByRegExp(tagDropdown: TagDropdownWrapper) {
|
||||
#matchCustomCategoryByRegExp(tagDropdown: TagDropdown) {
|
||||
const tagName = tagDropdown.tagName!;
|
||||
|
||||
for (const targetRegularExpression of this.#regExpGroupMatches.keys()) {
|
||||
@@ -90,6 +90,7 @@ export default class CustomCategoriesResolver {
|
||||
this.#regExpGroupMatches.clear();
|
||||
|
||||
if (!tagGroups.length) {
|
||||
this.#queueUpdatingTags();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -116,7 +117,7 @@ export default class CustomCategoriesResolver {
|
||||
this.#queueUpdatingTags();
|
||||
}
|
||||
|
||||
static #resetToOriginalCategory(tagDropdown: TagDropdownWrapper): void {
|
||||
static #resetToOriginalCategory(tagDropdown: TagDropdown): void {
|
||||
emit(
|
||||
tagDropdown,
|
||||
EVENT_TAG_GROUP_RESOLVED,
|
||||
|
||||
179
src/lib/extension/base/CacheablePreferences.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
|
||||
/**
|
||||
* Initialization options for the preference field helper class.
|
||||
*/
|
||||
type PreferenceFieldOptions<FieldKey, ValueType> = {
|
||||
/**
|
||||
* Field name which will be read or updated.
|
||||
*/
|
||||
field: FieldKey;
|
||||
/**
|
||||
* Default value for this field.
|
||||
*/
|
||||
defaultValue: ValueType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper class for a field. Contains all information needed to read or set the values into the preferences while
|
||||
* retaining proper types for the values.
|
||||
*/
|
||||
export class PreferenceField<
|
||||
/**
|
||||
* Mapping of keys to fields. Usually this is the same type used for defining the structure of the storage itself.
|
||||
* Is automatically captured when preferences class instance is passed into the constructor.
|
||||
*/
|
||||
Fields extends Record<string, any> = Record<string, any>,
|
||||
/**
|
||||
* Field key for resolving which value will be resolved from getter or which value type should be passed into the
|
||||
* setter method.
|
||||
*/
|
||||
Key extends keyof Fields = keyof Fields
|
||||
> {
|
||||
/**
|
||||
* Instance of the preferences class to read/update values on.
|
||||
* @private
|
||||
*/
|
||||
readonly #preferences: CacheablePreferences<Fields>;
|
||||
/**
|
||||
* Key of a field we want to read or write with the helper class.
|
||||
* @private
|
||||
*/
|
||||
readonly #fieldKey: Key;
|
||||
/**
|
||||
* Stored default value for a field.
|
||||
* @private
|
||||
*/
|
||||
readonly #defaultValue: Fields[Key];
|
||||
|
||||
/**
|
||||
* @param preferencesInstance Instance of preferences to work with.
|
||||
* @param options Initialization options for this field.
|
||||
*/
|
||||
constructor(preferencesInstance: CacheablePreferences<Fields>, options: PreferenceFieldOptions<Key, Fields[Key]>) {
|
||||
this.#preferences = preferencesInstance;
|
||||
this.#fieldKey = options.field;
|
||||
this.#defaultValue = options.defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the field value from the preferences.
|
||||
*/
|
||||
get() {
|
||||
return this.#preferences.readRaw(this.#fieldKey, this.#defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the preference field with provided value.
|
||||
* @param value Value to update the field with.
|
||||
*/
|
||||
set(value: Fields[Key]) {
|
||||
return this.#preferences.writeRaw(this.#fieldKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper type for preference classes to enforce having field objects inside the preferences instance. It should be
|
||||
* applied on child classes of {@link CacheablePreferences}.
|
||||
*/
|
||||
export type WithFields<FieldsType extends Record<string, any>> = {
|
||||
readonly [FieldKey in keyof FieldsType]: PreferenceField<FieldsType, FieldKey>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for any preferences instances. It contains methods for reading or updating any arbitrary values inside
|
||||
* extension storage. It also tries to save the value resolved from the storage into special internal cache after the
|
||||
* first call.
|
||||
*
|
||||
* Should be usually paired with implementation of {@link WithFields} helper type as interface for much more usable
|
||||
* API.
|
||||
*/
|
||||
export default abstract class CacheablePreferences<Fields> {
|
||||
#controller: ConfigurationController;
|
||||
#cachedValues: Map<keyof Fields, any> = new Map();
|
||||
#disposables: Function[] = [];
|
||||
|
||||
/**
|
||||
* @param settingsNamespace Name of the field inside the extension storage where these preferences stored.
|
||||
* @protected
|
||||
*/
|
||||
protected 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 as keyof Fields,
|
||||
settings[key]
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the value from the preferences by the field. This function doesn't handle default values, so you generally
|
||||
* should avoid using this method and accessing the special fields instead.
|
||||
* @param settingName Name of the field to read.
|
||||
* @param defaultValue Default value to return if value is not set.
|
||||
* @return Value of the field or default value if it is not set.
|
||||
*/
|
||||
public async readRaw<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 as string, defaultValue);
|
||||
|
||||
this.#cachedValues.set(settingName, settingValue);
|
||||
|
||||
return settingValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the value into specific field of the storage. You should generally avoid calling this function directly and
|
||||
* instead rely on special field helpers inside your preferences class.
|
||||
* @param settingName Name of the setting to write.
|
||||
* @param value Value to pass.
|
||||
* @param force Ignore the cache and force the update.
|
||||
* @protected
|
||||
*/
|
||||
async writeRaw<Key extends keyof Fields>(settingName: Key, value: Fields[Key], force: boolean = false): Promise<void> {
|
||||
if (
|
||||
!force
|
||||
&& this.#cachedValues.has(settingName)
|
||||
&& this.#cachedValues.get(settingName) === value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.#controller.writeSetting(
|
||||
settingName as string,
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the changes made to the storage.
|
||||
* @param callback Callback which will receive list of settings on every update. This function will not be called
|
||||
* on initialization.
|
||||
* @return Unsubscribe function to call in order to disable the watching.
|
||||
*/
|
||||
subscribe(callback: (settings: Partial<Fields>) => void): () => void {
|
||||
const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record<string, any>) => void);
|
||||
|
||||
this.#disposables.push(unsubscribeCallback);
|
||||
|
||||
return unsubscribeCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completely disable all subscriptions.
|
||||
*/
|
||||
dispose() {
|
||||
for (let disposeCallback of this.#disposables) {
|
||||
disposeCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import ConfigurationController from "$lib/extension/ConfigurationController";
|
||||
|
||||
export default class CacheableSettings<Fields> {
|
||||
#controller: ConfigurationController;
|
||||
#cachedValues: Map<keyof Fields, any> = new Map();
|
||||
#disposables: Function[] = [];
|
||||
|
||||
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 as keyof Fields,
|
||||
settings[key]
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template SettingType
|
||||
* @param {string} settingName
|
||||
* @param {SettingType} defaultValue
|
||||
* @return {Promise<SettingType>}
|
||||
* @protected
|
||||
*/
|
||||
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 as string, defaultValue);
|
||||
|
||||
this.#cachedValues.set(settingName, settingValue);
|
||||
|
||||
return settingValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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<Key extends keyof Fields>(settingName: Key, value: Fields[Key], force: boolean = false): Promise<void> {
|
||||
if (
|
||||
!force
|
||||
&& this.#cachedValues.has(settingName)
|
||||
&& this.#cachedValues.get(settingName) === value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.#controller.writeSetting(
|
||||
settingName as string,
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the changes made to the storage.
|
||||
* @param {function(Object): void} callback Callback which will receive list of settings.
|
||||
* @return {function(): void} Unsubscribe function.
|
||||
*/
|
||||
subscribe(callback: (settings: Partial<Fields>) => void): () => void {
|
||||
const unsubscribeCallback = this.#controller.subscribeToChanges(callback as (fields: Record<string, any>) => void);
|
||||
|
||||
this.#disposables.push(unsubscribeCallback);
|
||||
|
||||
return unsubscribeCallback;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (let disposeCallback of this.#disposables) {
|
||||
disposeCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/lib/extension/entities/TagEditorPreset.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
interface TagEditorPresetSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export default class TagEditorPreset extends StorageEntity<TagEditorPresetSettings> {
|
||||
constructor(id: string, settings: Partial<TagEditorPresetSettings>) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
});
|
||||
}
|
||||
|
||||
public static readonly _entityName = 'presets';
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
|
||||
export interface MaintenanceProfileSettings {
|
||||
export interface TaggingProfileSettings {
|
||||
name: string;
|
||||
tags: string[];
|
||||
temporary: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class representing the maintenance profile entity.
|
||||
* Class representing the tagging profile entity.
|
||||
*/
|
||||
export default class MaintenanceProfile extends StorageEntity<MaintenanceProfileSettings> {
|
||||
export default class TaggingProfile extends StorageEntity<TaggingProfileSettings> {
|
||||
/**
|
||||
* @param id ID of the entity.
|
||||
* @param settings Maintenance profile settings object.
|
||||
*/
|
||||
constructor(id: string, settings: Partial<MaintenanceProfileSettings>) {
|
||||
constructor(id: string, settings: Partial<TaggingProfileSettings>) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || [],
|
||||
27
src/lib/extension/preferences/MiscPreferences.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import CacheablePreferences, {
|
||||
PreferenceField,
|
||||
type WithFields
|
||||
} from "$lib/extension/base/CacheablePreferences";
|
||||
|
||||
export type FullscreenViewerSize = keyof App.ImageURIs;
|
||||
|
||||
interface MiscPreferencesFields {
|
||||
fullscreenViewer: boolean;
|
||||
fullscreenViewerSize: FullscreenViewerSize;
|
||||
}
|
||||
|
||||
export default class MiscPreferences extends CacheablePreferences<MiscPreferencesFields> implements WithFields<MiscPreferencesFields> {
|
||||
constructor() {
|
||||
super("misc");
|
||||
}
|
||||
|
||||
readonly fullscreenViewer = new PreferenceField(this, {
|
||||
field: "fullscreenViewer",
|
||||
defaultValue: true,
|
||||
});
|
||||
|
||||
readonly fullscreenViewerSize = new PreferenceField(this, {
|
||||
field: "fullscreenViewerSize",
|
||||
defaultValue: "large",
|
||||
});
|
||||
}
|
||||
40
src/lib/extension/preferences/TaggingProfilesPreferences.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
|
||||
interface TaggingProfilePreferencesFields {
|
||||
activeProfile: string | null;
|
||||
stripBlacklistedTags: boolean;
|
||||
}
|
||||
|
||||
class ActiveProfilePreference extends PreferenceField<TaggingProfilePreferencesFields, "activeProfile"> {
|
||||
constructor(preferencesInstance: CacheablePreferences<TaggingProfilePreferencesFields>) {
|
||||
super(preferencesInstance, {
|
||||
field: "activeProfile",
|
||||
defaultValue: null,
|
||||
});
|
||||
}
|
||||
|
||||
async asObject(): Promise<TaggingProfile | null> {
|
||||
const activeProfileId = await this.get();
|
||||
|
||||
if (!activeProfileId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await TaggingProfile.readAll())
|
||||
.find(profile => profile.id === activeProfileId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
export default class TaggingProfilesPreferences extends CacheablePreferences<TaggingProfilePreferencesFields> implements WithFields<TaggingProfilePreferencesFields> {
|
||||
constructor() {
|
||||
super("maintenance");
|
||||
}
|
||||
|
||||
readonly activeProfile = new ActiveProfilePreference(this);
|
||||
|
||||
readonly stripBlacklistedTags = new PreferenceField(this, {
|
||||
field: "stripBlacklistedTags",
|
||||
defaultValue: false,
|
||||
});
|
||||
}
|
||||
28
src/lib/extension/preferences/TagsPreferences.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import CacheablePreferences, { PreferenceField, type WithFields } from "$lib/extension/base/CacheablePreferences";
|
||||
|
||||
interface TagsPreferencesFields {
|
||||
groupSeparation: boolean;
|
||||
replaceLinks: boolean;
|
||||
replaceLinkText: boolean;
|
||||
}
|
||||
|
||||
export default class TagsPreferences extends CacheablePreferences<TagsPreferencesFields> implements WithFields<TagsPreferencesFields> {
|
||||
constructor() {
|
||||
super("tag");
|
||||
}
|
||||
|
||||
readonly groupSeparation = new PreferenceField(this, {
|
||||
field: "groupSeparation",
|
||||
defaultValue: true,
|
||||
});
|
||||
|
||||
readonly replaceLinks = new PreferenceField(this, {
|
||||
field: "replaceLinks",
|
||||
defaultValue: false,
|
||||
});
|
||||
|
||||
readonly replaceLinkText = new PreferenceField(this, {
|
||||
field: "replaceLinkText",
|
||||
defaultValue: true,
|
||||
});
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
export type FullscreenViewerSize = keyof App.ImageURIs;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
interface TagSettingsFields {
|
||||
groupSeparation: boolean;
|
||||
}
|
||||
|
||||
export default class TagSettings extends CacheableSettings<TagSettingsFields> {
|
||||
constructor() {
|
||||
super("tag");
|
||||
}
|
||||
|
||||
async resolveGroupSeparation() {
|
||||
return this._resolveSetting("groupSeparation", true);
|
||||
}
|
||||
|
||||
async setGroupSeparation(value: boolean) {
|
||||
return this._writeSetting("groupSeparation", Boolean(value));
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,16 @@ const entitiesExporters: ExportersMap = {
|
||||
category: entity.settings.category,
|
||||
separate: entity.settings.separate,
|
||||
}
|
||||
},
|
||||
presets: entity => {
|
||||
return {
|
||||
$type: "presets",
|
||||
$site: __CURRENT_SITE__,
|
||||
v: 1,
|
||||
id: entity.id,
|
||||
name: entity.settings.name,
|
||||
tags: entity.settings.tags,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -64,6 +64,19 @@ const entitiesValidators: EntitiesValidationMap = {
|
||||
throw new Error('Invalid group format detected!');
|
||||
}
|
||||
},
|
||||
presets: importedObject => {
|
||||
if (!importedObject.v || importedObject.v > 1) {
|
||||
throw new Error('Unsupported preset version!');
|
||||
}
|
||||
|
||||
if (
|
||||
!validateRequiredString(importedObject?.id)
|
||||
|| !validateRequiredString(importedObject?.name)
|
||||
|| !validateOptionalArray(importedObject?.tags)
|
||||
) {
|
||||
throw new Error('Invalid preset format detected!');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import PostParser from "$lib/booru/scraped/parsing/PostParser";
|
||||
import PostParser from "$lib/philomena/scraping/parsing/PostParser";
|
||||
|
||||
type UpdaterFunction = (tags: Set<string>) => Set<string>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import PageParser from "$lib/booru/scraped/parsing/PageParser";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils";
|
||||
import PageParser from "$lib/philomena/scraping/parsing/PageParser";
|
||||
import { buildTagsAndAliasesMap } from "$lib/philomena/tag-utils";
|
||||
|
||||
export default class PostParser extends PageParser {
|
||||
#tagEditorForm: HTMLFormElement | null = null;
|
||||
118
src/lib/philomena/tag-utils.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { namespaceCategories } from "$config/tags";
|
||||
import { QueryLexer, QuotedTermToken, TermToken } from "$lib/philomena/search/QueryLexer";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
const tagLinkRegExp = /\/tags\/(?<encodedTagName>[^/?#]+)/;
|
||||
|
||||
/**
|
||||
* List of encoded characters from Philomena.
|
||||
*
|
||||
* @see https://github.com/philomena-dev/philomena/blob/6086757b654da8792ae52adb2a2f501ea6c30d12/lib/philomena/slug.ex#L52-L57
|
||||
*/
|
||||
const slugEncodedCharacters: Map<string, string> = new Map([
|
||||
['-dash-', '-'],
|
||||
['-fwslash-', '/'],
|
||||
['-bwslash-', '\\'],
|
||||
['-colon-', ':'],
|
||||
['-dot-', '.'],
|
||||
['-plus-', '+'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Try to parse the tag name from the search query URL. It uses the same tokenizer as the booru. It only returns the
|
||||
* tag name if query contains only one single tag without any additional conditions.
|
||||
*
|
||||
* @param searchLink Link with search query.
|
||||
*
|
||||
* @return Tag name or NULL if query contains more than 1 tag or doesn't have any tags at all.
|
||||
*/
|
||||
function parseTagNameFromSearchQuery(searchLink: URL): string | null {
|
||||
const lexer = new QueryLexer(searchLink.searchParams.get('q') || '');
|
||||
const parsedQuery = lexer.parse();
|
||||
|
||||
if (parsedQuery.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [token] = parsedQuery;
|
||||
|
||||
switch (true) {
|
||||
case token instanceof TermToken:
|
||||
return token.value;
|
||||
case token instanceof QuotedTermToken:
|
||||
return token.decodedValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the tag name from the following link.
|
||||
*
|
||||
* @param tagLink Search link or link to the tag to parse the tag name from.
|
||||
*
|
||||
* @return Tag name or NULL if function is failed to parse the name of the tag.
|
||||
*/
|
||||
export function resolveTagNameFromLink(tagLink: URL): string | null {
|
||||
if (tagLink.pathname.startsWith('/search') && tagLink.searchParams.has('q')) {
|
||||
return parseTagNameFromSearchQuery(tagLink);
|
||||
}
|
||||
|
||||
tagLinkRegExp.lastIndex = 0;
|
||||
|
||||
const result = tagLinkRegExp.exec(tagLink.pathname);
|
||||
const encodedTagName = result?.groups?.encodedTagName;
|
||||
|
||||
if (!encodedTagName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodeURIComponent(encodedTagName)
|
||||
.replaceAll(/-[a-z]+-/gi, match => slugEncodedCharacters.get(match) ?? match)
|
||||
.replaceAll('-', ' ')
|
||||
.replaceAll('+', ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to resolve the category from the tag name.
|
||||
*
|
||||
* @param tagName Name of the tag.
|
||||
*/
|
||||
export function resolveTagCategoryFromTagName(tagName: string): string | null {
|
||||
const namespace = tagName.split(':')[0];
|
||||
|
||||
return namespaceCategories.get(namespace) ?? null;
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
|
||||
/**
|
||||
* Traverse and find the object using the key path.
|
||||
* @param targetObject Target object to traverse into.
|
||||
@@ -39,3 +42,14 @@ export function escapeRegExp(value: string): string {
|
||||
unsafeRegExpCharacters.lastIndex = 0;
|
||||
return value.replace(unsafeRegExpCharacters, "\\$&");
|
||||
}
|
||||
|
||||
type OnlyStringFields<Fields extends Record<string, any>> = {
|
||||
[FieldKey in keyof Fields as Fields[FieldKey] extends string ? FieldKey : never]: string;
|
||||
};
|
||||
|
||||
export function sortEntitiesByField<Fields extends Object>(entities: StorageEntity<Fields>[], fieldName: keyof OnlyStringFields<Fields>) {
|
||||
return entities.toSorted(
|
||||
(a, b) => (a.settings[fieldName] as string)
|
||||
.localeCompare(b.settings[fieldName] as string)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Footer from "$components/layout/Footer.svelte";
|
||||
import { initializeLinksReplacement } from "$lib/popup-links";
|
||||
import { onDestroy } from "svelte";
|
||||
import { headTitle } from "$stores/popup";
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
@@ -22,6 +23,10 @@
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$headTitle}</title>
|
||||
</svelte:head>
|
||||
|
||||
<Header/>
|
||||
<main>
|
||||
{@render children?.()}
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
let activeProfile = $derived<MaintenanceProfile | null>(
|
||||
$maintenanceProfiles.find(profile => profile.id === $activeProfileStore) || null
|
||||
$popupTitle = null;
|
||||
|
||||
let activeProfile = $derived<TaggingProfile | null>(
|
||||
$taggingProfiles.find(profile => profile.id === $activeTaggingProfile) || null
|
||||
);
|
||||
|
||||
function turnOffActiveProfile() {
|
||||
$activeProfileStore = null;
|
||||
$activeTaggingProfile = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
{#if activeProfile}
|
||||
<MenuCheckboxItem checked oninput={turnOffActiveProfile} href="/features/maintenance/{activeProfile.id}">
|
||||
<MenuCheckboxItem checked oninput={turnOffActiveProfile} href="/features/profiles/{activeProfile.id}">
|
||||
Active Profile: {activeProfile.settings.name}
|
||||
</MenuCheckboxItem>
|
||||
<hr>
|
||||
{/if}
|
||||
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
|
||||
<MenuItem href="/features/profiles">Tagging Profiles</MenuItem>
|
||||
<MenuItem href="/features/groups">Tag Groups</MenuItem>
|
||||
<MenuItem href="/features/presets">Tag Presets</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/transporting">Import/Export</MenuItem>
|
||||
<MenuItem href="/preferences">Preferences</MenuItem>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { PLUGIN_NAME } from "$lib/constants";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
$popupTitle = 'About';
|
||||
|
||||
let currentSiteUrl = 'https://furbooru.org';
|
||||
|
||||
if (__CURRENT_SITE__ === 'derpibooru') {
|
||||
currentSiteUrl = 'https://derpibooru.org';
|
||||
}
|
||||
|
||||
if (__CURRENT_SITE__ === 'tantabus') {
|
||||
currentSiteUrl = 'https://tantabus.ai';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
@@ -14,7 +22,7 @@
|
||||
<hr>
|
||||
</Menu>
|
||||
<h1>
|
||||
{__CURRENT_SITE_NAME__} Tagging Assistant
|
||||
{PLUGIN_NAME}
|
||||
</h1>
|
||||
<p>
|
||||
This is a small tool to make tagging on {__CURRENT_SITE_NAME__} just a little bit more convenient. Group tags with
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
<Menu>
|
||||
<MenuItem href="/">Back</MenuItem>
|
||||
<hr>
|
||||
<MenuItem href="/features/maintenance">Tagging Profiles</MenuItem>
|
||||
<MenuItem href="/features/profiles">Tagging Profiles</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
$popupTitle = 'Tag Groups';
|
||||
|
||||
let groups = $derived<TagGroup[]>($tagGroups.sort((a, b) => a.settings.name.localeCompare(b.settings.name)));
|
||||
</script>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
let groupId = $derived<string>(page.params.id);
|
||||
let group = $derived<TagGroup | null>($tagGroups.find(group => group.id === groupId) || null);
|
||||
@@ -19,6 +20,8 @@
|
||||
if (!group) {
|
||||
console.warn(`Group ${groupId} not found.`);
|
||||
goto('/features/groups');
|
||||
} else {
|
||||
$popupTitle = `Tag Group: ${group.settings.name}`;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
const groupId = $derived<string>(page.params.id);
|
||||
const targetGroup = $derived<TagGroup | undefined>($tagGroups.find(group => group.id === groupId));
|
||||
@@ -12,6 +13,8 @@
|
||||
$effect(() => {
|
||||
if (!targetGroup) {
|
||||
goto('/features/groups');
|
||||
} else {
|
||||
$popupTitle = `Deleting Tag Group: ${targetGroup.settings.name}`;
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import CheckboxField from "$components/ui/forms/CheckboxField.svelte";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
let groupId = $derived(page.params.id);
|
||||
|
||||
@@ -32,6 +33,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (groupId === 'new') {
|
||||
$popupTitle = 'Create Tag Group';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,6 +42,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
$popupTitle = `Edit Tag Group: ${targetGroup.settings.name}`;
|
||||
|
||||
groupName = targetGroup.settings.name;
|
||||
tagsList = [...targetGroup.settings.tags].sort((a, b) => a.localeCompare(b));
|
||||
prefixesList = [...targetGroup.settings.prefixes].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
let isEncodedGroupShown = $state(true);
|
||||
|
||||
@@ -17,6 +18,8 @@
|
||||
$effect(() => {
|
||||
if (!group) {
|
||||
goto('/features/groups');
|
||||
} else {
|
||||
$popupTitle = `Export Tag Group: ${group.settings.name}`;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
import Notice from "$components/ui/Notice.svelte";
|
||||
|
||||
const groupTransporter = new EntitiesTransporter(TagGroup);
|
||||
|
||||
@@ -17,6 +19,12 @@
|
||||
let candidateGroup = $state<TagGroup | null>(null);
|
||||
let existingGroup = $state<TagGroup | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
$popupTitle = candidateGroup
|
||||
? 'Confirm Imported Tag Group'
|
||||
: 'Import Tag Group';
|
||||
});
|
||||
|
||||
function tryImportingGroup() {
|
||||
candidateGroup = null;
|
||||
existingGroup = null;
|
||||
@@ -74,7 +82,7 @@
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if errorMessage}
|
||||
<p class="error">Failed to import: {errorMessage}</p>
|
||||
<Notice level="error">Failed to import: {errorMessage}</Notice>
|
||||
<Menu>
|
||||
<hr>
|
||||
</Menu>
|
||||
@@ -91,9 +99,10 @@
|
||||
</Menu>
|
||||
{:else}
|
||||
{#if existingGroup}
|
||||
<p class="warning">
|
||||
<Notice level="warning">
|
||||
This group will replace the existing "{existingGroup.settings.name}" group, since it have the same ID.
|
||||
</p>
|
||||
</Notice>
|
||||
<br>
|
||||
{/if}
|
||||
<GroupView group={candidateGroup}></GroupView>
|
||||
<Menu>
|
||||
@@ -107,24 +116,3 @@
|
||||
<MenuItem onclick={() => 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>
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { run } from 'svelte/legacy';
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let profileId = $derived(page.params.id);
|
||||
let profile = $derived<MaintenanceProfile|null>(
|
||||
$maintenanceProfiles.find(profile => profile.id === profileId) || null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (profileId === 'new') {
|
||||
goto('/features/maintenance/new/edit');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
console.warn(`Profile ${profileId} not found.`);
|
||||
goto('/features/maintenance');
|
||||
}
|
||||
});
|
||||
|
||||
let isActiveProfile = $state(false);
|
||||
|
||||
$effect.pre(() => {
|
||||
isActiveProfile = $activeProfileStore === profileId;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isActiveProfile && $activeProfileStore !== profileId) {
|
||||
$activeProfileStore = profileId;
|
||||
}
|
||||
|
||||
if (!isActiveProfile && $activeProfileStore === profileId) {
|
||||
$activeProfileStore = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if profile}
|
||||
<ProfileView {profile}/>
|
||||
{/if}
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem href="/features/maintenance/{profileId}/edit" icon="wrench">Edit Profile</MenuItem>
|
||||
<MenuCheckboxItem bind:checked={isActiveProfile}>
|
||||
Activate Profile
|
||||
</MenuCheckboxItem>
|
||||
<MenuItem href="/features/maintenance/{profileId}/export" icon="file-export">
|
||||
Export Profile
|
||||
</MenuItem>
|
||||
<MenuItem href="/features/maintenance/{profileId}/delete" icon="trash">
|
||||
Delete Profile
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
19
src/routes/features/presets/+page.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
|
||||
import { sortEntitiesByField } from "$lib/utils";
|
||||
|
||||
let presets = $derived(sortEntitiesByField($tagEditorPresets, "name"))
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/presets/new/edit" icon="plus">Create New</MenuItem>
|
||||
{#if presets.length}
|
||||
<hr>
|
||||
{#each presets as preset}
|
||||
<MenuItem href="/features/presets/{preset.id}">{preset.settings.name}</MenuItem>
|
||||
{/each}
|
||||
{/if}
|
||||
</Menu>
|
||||
42
src/routes/features/presets/[id]/+page.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
|
||||
import { goto } from "$app/navigation";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import PresetView from "$components/features/PresetView.svelte";
|
||||
|
||||
let presetId = $derived(page.params.id);
|
||||
let preset = $derived<TagEditorPreset|null>(
|
||||
$tagEditorPresets.find(preset => preset.id === presetId) || null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (presetId === 'new') {
|
||||
goto(`/features/presets/new/edit`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preset) {
|
||||
console.warn(`Preset ${presetId} not found.`);
|
||||
goto('/features/presets');
|
||||
} else {
|
||||
$popupTitle = `Preset: ${preset.settings.name}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/presets" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if preset}
|
||||
<PresetView {preset}></PresetView>
|
||||
{/if}
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem href="/features/presets/{presetId}/edit" icon="wrench">Edit Preset</MenuItem>
|
||||
<MenuItem href="/features/presets/{presetId}/delete" icon="trash">Delete Preset</MenuItem>
|
||||
</Menu>
|
||||
49
src/routes/features/presets/[id]/delete/+page.svelte
Normal file
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { page } from "$app/state";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
|
||||
import { goto } from "$app/navigation";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
const presetId = $derived(page.params.id);
|
||||
const targetPreset = $derived<TagEditorPreset | null>(
|
||||
$tagEditorPresets.find(preset => preset.id === presetId) || null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!targetPreset) {
|
||||
goto('/features/presets');
|
||||
} else {
|
||||
$popupTitle = `Deleting Preset: ${targetPreset.settings.name}`
|
||||
}
|
||||
});
|
||||
|
||||
async function deletePreset() {
|
||||
if (!targetPreset) {
|
||||
console.warn('Attempting to delete the preset, but the preset is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
await targetPreset.delete();
|
||||
await goto('/features/presets');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/presets/{presetId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if targetPreset}
|
||||
<p>
|
||||
Do you want to remove preset "{targetPreset.settings.name}"? This action is irreversible.
|
||||
</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem onclick={deletePreset}>Yes</MenuItem>
|
||||
<MenuItem href="/features/presets/{presetId}">No</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
<p>Loading...</p>
|
||||
{/if}
|
||||
74
src/routes/features/presets/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
import { goto } from "$app/navigation";
|
||||
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 TextField from "$components/ui/forms/TextField.svelte";
|
||||
import TagsEditor from "$components/tags/TagsEditor.svelte";
|
||||
|
||||
let presetId = $derived(page.params.id);
|
||||
|
||||
let targetPreset = $derived.by<TagEditorPreset | null>(() => {
|
||||
if (presetId === 'new') {
|
||||
return new TagEditorPreset(crypto.randomUUID(), {});
|
||||
}
|
||||
|
||||
return $tagEditorPresets.find(preset => preset.id === presetId) || null;
|
||||
});
|
||||
|
||||
let presetName = $state('');
|
||||
let tagsList = $state<string[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (presetId === 'new') {
|
||||
$popupTitle = 'Create New Preset';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetPreset) {
|
||||
goto('/features/presets');
|
||||
return;
|
||||
}
|
||||
|
||||
$popupTitle = `Edit Tagging Preset: ${targetPreset.settings.name}}`;
|
||||
|
||||
presetName = targetPreset.settings.name;
|
||||
tagsList = [...targetPreset.settings.tags].sort((a, b) => a.localeCompare(b));
|
||||
});
|
||||
|
||||
async function savePreset() {
|
||||
if (!targetPreset) {
|
||||
console.warn('Attempting to save the preset, but the preset is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
targetPreset.settings.name = presetName;
|
||||
targetPreset.settings.tags = [...tagsList];
|
||||
|
||||
await targetPreset.save();
|
||||
await goto(`/features/presets/${targetPreset.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/presets{presetId === 'new' ? '' : '/' + presetId}" icon="arrow-left">
|
||||
Back
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Preset Name">
|
||||
<TextField bind:value={presetName} placeholder="Preset Name"></TextField>
|
||||
</FormControl>
|
||||
<FormControl label="Tags">
|
||||
<TagsEditor bind:tags={tagsList}></TagsEditor>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem href="#" onclick={savePreset}>Save Preset</MenuItem>
|
||||
</Menu>
|
||||