Compare commits
36 Commits
0.6.0.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 |
BIN
.github/assets/derpibooru-preview-of-tag-link-replacement.png
vendored
Normal file
|
After Width: | Height: | Size: 101 KiB |
@@ -174,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,
|
||||
@@ -209,6 +218,7 @@ export async function buildScriptsAndStyles(buildOptions) {
|
||||
?.push(...dependencies);
|
||||
}),
|
||||
derpibooruSwapPlugin,
|
||||
tantabusSwapPlugin,
|
||||
],
|
||||
define: defineConstants,
|
||||
});
|
||||
@@ -235,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.
|
||||
@@ -84,12 +85,55 @@ export async function packExtension(settings) {
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn('No replacement set up for site: ' + process.env.SITE);
|
||||
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'));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,16 +9,21 @@ tags to them without opening each individual image.
|
||||
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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant",
|
||||
"description": "Small experimental extension for slightly quicker tagging experience. Furbooru Edition.",
|
||||
"version": "0.6.0.1",
|
||||
"version": "0.7.0",
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "furbooru-tagging-assistant@thecore.city",
|
||||
@@ -50,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"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -87,6 +92,17 @@
|
||||
"css": [
|
||||
"src/styles/content/posts.scss"
|
||||
]
|
||||
},
|
||||
{
|
||||
"matches": [
|
||||
"*://*.furbooru.org/images/new"
|
||||
],
|
||||
"js": [
|
||||
"src/content/upload.ts"
|
||||
],
|
||||
"css": [
|
||||
"src/styles/content/tag-presets.scss"
|
||||
]
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
|
||||
786
package-lock.json
generated
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.6.0.1",
|
||||
"version": "0.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,24 +16,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.53.0",
|
||||
"@sveltejs/kit": "^2.55.0",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"amd-lite": "^1.0.1",
|
||||
"lz-string": "^1.5.0",
|
||||
"sass": "^1.97.3",
|
||||
"svelte": "^5.53.3"
|
||||
"sass": "^1.98.0",
|
||||
"svelte": "^5.53.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@types/chrome": "^0.1.37",
|
||||
"@types/node": "^25.3.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@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.3",
|
||||
"svelte-check": "^4.4.5",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
"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,8 +1,10 @@
|
||||
<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();
|
||||
@@ -10,32 +12,9 @@
|
||||
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>
|
||||
|
||||
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>
|
||||
@@ -34,6 +34,11 @@ export const namespaceCategories: Map<string, string> = new Map([
|
||||
['series', 'content-fanmade'],
|
||||
['spoiler', 'spoiler'],
|
||||
['video', 'content-fanmade'],
|
||||
...(__CURRENT_SITE__ === 'tantabus' ? <const> [
|
||||
["prompter", "origin"],
|
||||
["creator", "origin"],
|
||||
["generator", "origin"]
|
||||
] : [])
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import TagSettings from "$lib/extension/settings/TagSettings";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { decodeTagNameFromLink, resolveTagCategoryFromTagName } from "$lib/booru/tag-utils";
|
||||
|
||||
export class BlockCommunication extends BaseComponent {
|
||||
#contentSection: HTMLElement | null = null;
|
||||
#tagLinks: HTMLAnchorElement[] = [];
|
||||
|
||||
#tagLinksReplaced: boolean | null = null;
|
||||
|
||||
protected build() {
|
||||
this.#contentSection = this.container.querySelector('.communication__content');
|
||||
this.#tagLinks = this.#findAllTagLinks();
|
||||
}
|
||||
|
||||
protected init() {
|
||||
BlockCommunication.#tagSettings.resolveReplaceLinks().then(this.#onReplaceLinkSettingResolved.bind(this));
|
||||
BlockCommunication.#tagSettings.subscribe(settings => {
|
||||
this.#onReplaceLinkSettingResolved(settings.replaceLinks ?? false);
|
||||
});
|
||||
}
|
||||
|
||||
#onReplaceLinkSettingResolved(haveToReplaceLinks: boolean) {
|
||||
if (!this.#tagLinks.length || this.#tagLinksReplaced === haveToReplaceLinks) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (haveToReplaceLinks) {
|
||||
const maybeDecodedTagName = decodeTagNameFromLink(linkElement.pathname) ?? '';
|
||||
linkElement.dataset.tagCategory = resolveTagCategoryFromTagName(maybeDecodedTagName) ?? '';
|
||||
} else {
|
||||
linkElement.dataset.tagCategory = '';
|
||||
}
|
||||
}
|
||||
|
||||
this.#tagLinksReplaced = haveToReplaceLinks;
|
||||
}
|
||||
|
||||
#findAllTagLinks(): HTMLAnchorElement[] {
|
||||
return Array
|
||||
.from(this.#contentSection?.querySelectorAll('a') || [])
|
||||
.filter(link => link.pathname.startsWith('/tags/'))
|
||||
}
|
||||
|
||||
static #tagSettings = new TagSettings();
|
||||
|
||||
static findAndInitializeAll() {
|
||||
for (const container of document.querySelectorAll<HTMLElement>('.block.communication')) {
|
||||
if (getComponent(container)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
new BlockCommunication(container).initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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 } from "$content/components/events/booru-events";
|
||||
import { EVENT_FORM_EDITOR_UPDATED } from "$content/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 +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;
|
||||
}
|
||||
|
||||
@@ -4,13 +4,15 @@ import type { FullscreenViewerEventsMap } from "$content/components/events/fulls
|
||||
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,5 +1,5 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings";
|
||||
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";
|
||||
|
||||
@@ -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 "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
import { FullscreenViewer } from "$content/components/FullscreenViewer";
|
||||
import type { MediaBoxTools } from "$content/components/MediaBoxTools";
|
||||
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();
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { MaintenancePopup } from "$content/components/MaintenancePopup";
|
||||
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 { MediaBoxWrapper } from "$content/components/MediaBoxWrapper";
|
||||
import type MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import type { MediaBox } from "$content/components/philomena/MediaBox";
|
||||
import type TaggingProfile from "$entities/TaggingProfile";
|
||||
|
||||
export class MediaBoxTools extends BaseComponent {
|
||||
#mediaBox: MediaBoxWrapper | null = null;
|
||||
#maintenancePopup: MaintenancePopup | null = null;
|
||||
#mediaBox: MediaBox | null = null;
|
||||
#maintenancePopup: TaggingProfilePopup | null = null;
|
||||
|
||||
init() {
|
||||
const mediaBoxElement = this.container.closest<HTMLElement>('.media-box');
|
||||
@@ -34,7 +34,7 @@ export class MediaBoxTools extends BaseComponent {
|
||||
component.initialize();
|
||||
}
|
||||
|
||||
if (!this.#maintenancePopup && component instanceof MaintenancePopup) {
|
||||
if (!this.#maintenancePopup && component instanceof TaggingProfilePopup) {
|
||||
this.#maintenancePopup = component;
|
||||
}
|
||||
}
|
||||
@@ -42,33 +42,33 @@ export class MediaBoxTools extends BaseComponent {
|
||||
on(this, EVENT_ACTIVE_PROFILE_CHANGED, this.#onActiveProfileChanged.bind(this));
|
||||
}
|
||||
|
||||
#onActiveProfileChanged(profileChangedEvent: CustomEvent<MaintenanceProfile | null>) {
|
||||
#onActiveProfileChanged(profileChangedEvent: CustomEvent<TaggingProfile | null>) {
|
||||
this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null);
|
||||
}
|
||||
|
||||
get maintenancePopup(): MaintenancePopup | null {
|
||||
get maintenancePopup(): TaggingProfilePopup | null {
|
||||
return this.#maintenancePopup;
|
||||
}
|
||||
|
||||
get mediaBox(): MediaBoxWrapper | null {
|
||||
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.
|
||||
*/
|
||||
export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement {
|
||||
const mediaBoxToolsContainer = document.createElement('div');
|
||||
mediaBoxToolsContainer.classList.add('media-box-tools');
|
||||
/**
|
||||
* 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);
|
||||
if (childrenElements.length) {
|
||||
mediaBoxToolsContainer.append(...childrenElements);
|
||||
}
|
||||
|
||||
new MediaBoxTools(mediaBoxToolsContainer);
|
||||
|
||||
return mediaBoxToolsContainer;
|
||||
}
|
||||
|
||||
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,8 +1,8 @@
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
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/booru/scraped/ScrapedAPI";
|
||||
import ScrapedAPI from "$lib/philomena/scraping/ScrapedAPI";
|
||||
import { tagsBlacklist } from "$config/tags";
|
||||
import { emitterAt } from "$content/components/events/comms";
|
||||
import {
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
EVENT_MAINTENANCE_STATE_CHANGED,
|
||||
EVENT_TAGS_UPDATED
|
||||
} from "$content/components/events/maintenance-popup-events";
|
||||
import type { MediaBoxTools } from "$content/components/MediaBoxTools";
|
||||
import { resolveTagCategoryFromTagName } from "$lib/booru/tag-utils";
|
||||
import type { MediaBoxTools } from "$content/components/extension/MediaBoxTools";
|
||||
import { resolveTagCategoryFromTagName } from "$lib/philomena/tag-utils";
|
||||
|
||||
class BlackListedTagsEncounteredError extends Error {
|
||||
constructor(tagName: string) {
|
||||
@@ -21,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();
|
||||
@@ -66,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;
|
||||
@@ -79,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();
|
||||
@@ -110,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);
|
||||
|
||||
@@ -122,10 +122,10 @@ 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.#markTagElementWithCategory(tagElement, 'error');
|
||||
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
} else {
|
||||
MaintenancePopup.#markTagElementWithCategory(
|
||||
TaggingProfilePopup.#markTagElementWithCategory(
|
||||
tagElement,
|
||||
resolveTagCategoryFromTagName(tagName) ?? '',
|
||||
);
|
||||
@@ -179,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;
|
||||
@@ -197,7 +197,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
if (this.#isPlanningToSubmit && !this.#isSubmitting) {
|
||||
this.#tagsSubmissionTimer = setTimeout(
|
||||
this.#onSubmissionTimerPassed.bind(this),
|
||||
MaintenancePopup.#delayBeforeSubmissionMs
|
||||
TaggingProfilePopup.#delayBeforeSubmissionMs
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -214,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) {
|
||||
@@ -250,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;
|
||||
@@ -268,7 +268,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
this.#tagsToRemove.clear();
|
||||
|
||||
this.#refreshTagsList();
|
||||
MaintenancePopup.#notifyAboutPendingSubmission(false);
|
||||
TaggingProfilePopup.#notifyAboutPendingSubmission(false);
|
||||
|
||||
this.#isSubmitting = false;
|
||||
}
|
||||
@@ -292,8 +292,8 @@ export class MaintenancePopup extends BaseComponent {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagElement = MaintenancePopup.#buildTagElement(tagName);
|
||||
MaintenancePopup.#markTagElementWithCategory(tagElement, 'error');
|
||||
const tagElement = TaggingProfilePopup.#buildTagElement(tagName);
|
||||
TaggingProfilePopup.#markTagElementWithCategory(tagElement, 'error');
|
||||
tagElement.classList.add('is-present');
|
||||
|
||||
this.#suggestedInvalidTags.set(tagName, tagElement);
|
||||
@@ -311,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');
|
||||
@@ -333,7 +341,7 @@ export class MaintenancePopup extends BaseComponent {
|
||||
/**
|
||||
* 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
|
||||
@@ -341,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
|
||||
@@ -352,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;
|
||||
@@ -416,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;
|
||||
}
|
||||
@@ -2,9 +2,9 @@ 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/MediaBoxTools";
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { getComponent } from "$content/components/base/component-utils";
|
||||
import { buildTagsAndAliasesMap } from "$lib/booru/tag-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 MediaBoxWrapper extends BaseComponent {
|
||||
export class MediaBox extends BaseComponent {
|
||||
#thumbnailContainer: HTMLElement | null = null;
|
||||
#imageLinkElement: HTMLAnchorElement | null = null;
|
||||
#tagsAndAliases: Map<string, string> | null = null;
|
||||
@@ -60,40 +60,44 @@ export class MediaBoxWrapper extends BaseComponent {
|
||||
|
||||
return JSON.parse(jsonUris);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the media box element into the special wrapper.
|
||||
*/
|
||||
export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) {
|
||||
new MediaBoxWrapper(mediaBoxContainer)
|
||||
.initialize();
|
||||
/**
|
||||
* 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();
|
||||
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');
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +1,6 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
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 "$content/components/events/comms";
|
||||
@@ -8,9 +8,7 @@ import { EVENT_FORM_EDITOR_UPDATED } from "$content/components/events/tags-form-
|
||||
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)
|
||||
);
|
||||
@@ -172,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() {
|
||||
@@ -205,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);
|
||||
|
||||
@@ -231,8 +229,8 @@ export class TagDropdownWrapper extends BaseComponent {
|
||||
);
|
||||
});
|
||||
|
||||
this.#maintenanceSettings
|
||||
.resolveActiveProfileAsObject()
|
||||
this.#preferences
|
||||
.activeProfile.asObject()
|
||||
.then(activeProfile => {
|
||||
lastActiveProfile = activeProfile?.id ?? null;
|
||||
onActiveProfileChange(activeProfile);
|
||||
@@ -263,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 "$content/components/base/BaseComponent";
|
||||
import type TagGroup from "$entities/TagGroup";
|
||||
import type { TagDropdownWrapper } from "$content/components/TagDropdownWrapper";
|
||||
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 TagSettings from "$lib/extension/settings/TagSettings";
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseComponent } from "$content/components/base/BaseComponent";
|
||||
import { ImageListInfo } from "$content/components/listing/ImageListInfo";
|
||||
import { ImageListInfo } from "$content/components/philomena/listing/ImageListInfo";
|
||||
|
||||
export class ImageListContainer extends BaseComponent {
|
||||
#info: ImageListInfo | null = null;
|
||||
@@ -12,8 +12,12 @@ export class ImageListContainer extends BaseComponent {
|
||||
this.#info.initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeImageListContainer(element: HTMLElement) {
|
||||
new ImageListContainer(element).initialize();
|
||||
static findAndInitialize() {
|
||||
const imageListContainer = document.querySelector<HTMLElement>('#imagelist-container');
|
||||
|
||||
if (imageListContainer) {
|
||||
new ImageListContainer(imageListContainer).initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
import { createMaintenancePopup } from "$content/components/MaintenancePopup";
|
||||
import { createMediaBoxTools } from "$content/components/MediaBoxTools";
|
||||
import { calculateMediaBoxesPositions, initializeMediaBox } from "$content/components/MediaBoxWrapper";
|
||||
import { createMaintenanceStatusIcon } from "$content/components/MaintenanceStatusIcon";
|
||||
import { createImageShowFullscreenButton } from "$content/components/ImageShowFullscreenButton";
|
||||
import { initializeImageListContainer } from "$content/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();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { BlockCommunication } from "$content/components/BlockCommunication";
|
||||
import { BlockCommunication } from "$content/components/philomena/BlockCommunication";
|
||||
|
||||
BlockCommunication.findAndInitializeAll();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TagsForm } from "$content/components/TagsForm";
|
||||
import { initializeAllTagsLists, watchForUpdatedTagLists } from "$content/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 "$content/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();
|
||||
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,4 +1,4 @@
|
||||
import type { TagDropdownWrapper } from "$content/components/TagDropdownWrapper";
|
||||
import type { TagDropdown } from "$content/components/philomena/TagDropdown";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import { escapeRegExp } from "$lib/utils";
|
||||
import { emit } from "$content/components/events/comms";
|
||||
@@ -7,7 +7,7 @@ import { EVENT_TAG_GROUP_RESOLVED } from "$content/components/events/tag-dropdow
|
||||
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()) {
|
||||
@@ -117,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,28 +0,0 @@
|
||||
import CacheableSettings from "$lib/extension/base/CacheableSettings";
|
||||
|
||||
interface TagSettingsFields {
|
||||
groupSeparation: boolean;
|
||||
replaceLinks: boolean;
|
||||
}
|
||||
|
||||
export default class TagSettings extends CacheableSettings<TagSettingsFields> {
|
||||
constructor() {
|
||||
super("tag");
|
||||
}
|
||||
|
||||
async resolveGroupSeparation() {
|
||||
return this._resolveSetting("groupSeparation", true);
|
||||
}
|
||||
|
||||
async resolveReplaceLinks() {
|
||||
return this._resolveSetting("replaceLinks", false);
|
||||
}
|
||||
|
||||
async setGroupSeparation(value: boolean) {
|
||||
return this._writeSetting("groupSeparation", Boolean(value));
|
||||
}
|
||||
|
||||
async setReplaceLinks(value: boolean) {
|
||||
return this._writeSetting("replaceLinks", 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;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { namespaceCategories } from "$config/tags";
|
||||
import { QueryLexer, QuotedTermToken, TermToken } from "$lib/philomena/search/QueryLexer";
|
||||
|
||||
/**
|
||||
* Build the map containing both real tags and their aliases.
|
||||
@@ -51,16 +52,48 @@ const slugEncodedCharacters: Map<string, string> = new Map([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Decode the tag name from its link path.
|
||||
* 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 tagLink Full or partial link to the tag.
|
||||
* @param searchLink Link with search query.
|
||||
*
|
||||
* @return Tag name or NULL if function is failed to recognize the link as tag-related link.
|
||||
* @return Tag name or NULL if query contains more than 1 tag or doesn't have any tags at all.
|
||||
*/
|
||||
export function decodeTagNameFromLink(tagLink: string): string | null {
|
||||
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);
|
||||
const result = tagLinkRegExp.exec(tagLink.pathname);
|
||||
const encodedTagName = result?.groups?.encodedTagName;
|
||||
|
||||
if (!encodedTagName) {
|
||||
@@ -69,7 +102,8 @@ export function decodeTagNameFromLink(tagLink: string): string | null {
|
||||
|
||||
return decodeURIComponent(encodedTagName)
|
||||
.replaceAll(/-[a-z]+-/gi, match => slugEncodedCharacters.get(match) ?? match)
|
||||
.replaceAll('-', ' ');
|
||||
.replaceAll('-', ' ')
|
||||
.replaceAll('+', ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +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";
|
||||
|
||||
$popupTitle = null;
|
||||
|
||||
let activeProfile = $derived<MaintenanceProfile | null>(
|
||||
$maintenanceProfiles.find(profile => profile.id === $activeProfileStore) || 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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>
|
||||
@@ -2,45 +2,45 @@
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import MenuRadioItem from "$components/ui/menu/MenuRadioItem.svelte";
|
||||
import { activeProfileStore, maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
$popupTitle = 'Tagging Profiles';
|
||||
|
||||
let profiles = $derived<MaintenanceProfile[]>(
|
||||
$maintenanceProfiles.sort((a, b) => a.settings.name.localeCompare(b.settings.name))
|
||||
let profiles = $derived<TaggingProfile[]>(
|
||||
$taggingProfiles.sort((a, b) => a.settings.name.localeCompare(b.settings.name))
|
||||
);
|
||||
|
||||
function resetActiveProfile() {
|
||||
$activeProfileStore = null;
|
||||
$activeTaggingProfile = null;
|
||||
}
|
||||
|
||||
function enableSelectedProfile(event: Event) {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement && target.checked) {
|
||||
activeProfileStore.set(target.value);
|
||||
activeTaggingProfile.set(target.value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/maintenance/new/edit" icon="plus">Create New</MenuItem>
|
||||
<MenuItem href="/features/profiles/new/edit" icon="plus">Create New</MenuItem>
|
||||
{#if profiles.length}
|
||||
<hr>
|
||||
{/if}
|
||||
{#each profiles as profile}
|
||||
<MenuRadioItem href="/features/maintenance/{profile.id}"
|
||||
<MenuRadioItem href="/features/profiles/{profile.id}"
|
||||
name="active-profile"
|
||||
value={profile.id}
|
||||
checked={$activeProfileStore === profile.id}
|
||||
checked={$activeTaggingProfile === profile.id}
|
||||
oninput={enableSelectedProfile}>
|
||||
{profile.settings.name}
|
||||
</MenuRadioItem>
|
||||
{/each}
|
||||
<hr>
|
||||
<MenuItem href="#" onclick={resetActiveProfile}>Reset Active Profile</MenuItem>
|
||||
<MenuItem href="/features/maintenance/import">Import Profile</MenuItem>
|
||||
<MenuItem href="/features/profiles/import">Import Profile</MenuItem>
|
||||
</Menu>
|
||||
@@ -3,26 +3,26 @@
|
||||
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 { activeTaggingProfile, taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
let profileId = $derived(page.params.id);
|
||||
let profile = $derived<MaintenanceProfile|null>(
|
||||
$maintenanceProfiles.find(profile => profile.id === profileId) || null
|
||||
let profile = $derived<TaggingProfile|null>(
|
||||
$taggingProfiles.find(profile => profile.id === profileId) || null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (profileId === 'new') {
|
||||
goto('/features/maintenance/new/edit');
|
||||
goto('/features/profiles/new/edit');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
console.warn(`Profile ${profileId} not found.`);
|
||||
goto('/features/maintenance');
|
||||
goto('/features/profiles');
|
||||
} else {
|
||||
$popupTitle = `Tagging Profile: ${profile.settings.name}`;
|
||||
}
|
||||
@@ -31,22 +31,22 @@
|
||||
let isActiveProfile = $state(false);
|
||||
|
||||
$effect.pre(() => {
|
||||
isActiveProfile = $activeProfileStore === profileId;
|
||||
isActiveProfile = $activeTaggingProfile === profileId;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isActiveProfile && $activeProfileStore !== profileId) {
|
||||
$activeProfileStore = profileId;
|
||||
if (isActiveProfile && $activeTaggingProfile !== profileId) {
|
||||
$activeTaggingProfile = profileId;
|
||||
}
|
||||
|
||||
if (!isActiveProfile && $activeProfileStore === profileId) {
|
||||
$activeProfileStore = null;
|
||||
if (!isActiveProfile && $activeTaggingProfile === profileId) {
|
||||
$activeTaggingProfile = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/profiles" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if profile}
|
||||
@@ -54,14 +54,14 @@
|
||||
{/if}
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem href="/features/maintenance/{profileId}/edit" icon="wrench">Edit Profile</MenuItem>
|
||||
<MenuItem href="/features/profiles/{profileId}/edit" icon="wrench">Edit Profile</MenuItem>
|
||||
<MenuCheckboxItem bind:checked={isActiveProfile}>
|
||||
Activate Profile
|
||||
</MenuCheckboxItem>
|
||||
<MenuItem href="/features/maintenance/{profileId}/export" icon="file-export">
|
||||
<MenuItem href="/features/profiles/{profileId}/export" icon="file-export">
|
||||
Export Profile
|
||||
</MenuItem>
|
||||
<MenuItem href="/features/maintenance/{profileId}/delete" icon="trash">
|
||||
<MenuItem href="/features/profiles/{profileId}/delete" icon="trash">
|
||||
Delete Profile
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
@@ -3,18 +3,18 @@
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import { taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
const profileId = $derived(page.params.id);
|
||||
const targetProfile = $derived<MaintenanceProfile | null>(
|
||||
$maintenanceProfiles.find(profile => profile.id === profileId) || null
|
||||
const targetProfile = $derived<TaggingProfile | null>(
|
||||
$taggingProfiles.find(profile => profile.id === profileId) || null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!targetProfile) {
|
||||
goto('/features/maintenance');
|
||||
goto('/features/profiles');
|
||||
} else {
|
||||
$popupTitle = `Deleting Tagging Profile: ${targetProfile.settings.name}`
|
||||
}
|
||||
@@ -27,12 +27,12 @@
|
||||
}
|
||||
|
||||
await targetProfile.delete();
|
||||
await goto('/features/maintenance');
|
||||
await goto('/features/profiles');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/profiles/{profileId}" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if targetProfile}
|
||||
@@ -42,7 +42,7 @@
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuItem onclick={deleteProfile}>Yes</MenuItem>
|
||||
<MenuItem href="/features/maintenance/{profileId}">No</MenuItem>
|
||||
<MenuItem href="/features/profiles/{profileId}">No</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
<p>Loading...</p>
|
||||
@@ -7,19 +7,19 @@
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import { taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
|
||||
let profileId = $derived(page.params.id);
|
||||
|
||||
let targetProfile = $derived.by<MaintenanceProfile | null>(() => {
|
||||
let targetProfile = $derived.by<TaggingProfile | null>(() => {
|
||||
if (profileId === 'new') {
|
||||
return new MaintenanceProfile(crypto.randomUUID(), {});
|
||||
return new TaggingProfile(crypto.randomUUID(), {});
|
||||
}
|
||||
|
||||
return $maintenanceProfiles.find(profile => profile.id === profileId) || null;
|
||||
return $taggingProfiles.find(profile => profile.id === profileId) || null;
|
||||
});
|
||||
|
||||
let profileName = $state('');
|
||||
@@ -32,7 +32,7 @@
|
||||
}
|
||||
|
||||
if (!targetProfile) {
|
||||
goto('/features/maintenance');
|
||||
goto('/features/profiles');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,12 +53,12 @@
|
||||
targetProfile.settings.temporary = false;
|
||||
|
||||
await targetProfile.save();
|
||||
await goto('/features/maintenance/' + targetProfile.id);
|
||||
await goto('/features/profiles/' + targetProfile.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/maintenance{profileId === 'new' ? '' : '/' + profileId}" icon="arrow-left">
|
||||
<MenuItem href="/features/profiles{profileId === 'new' ? '' : '/' + profileId}" icon="arrow-left">
|
||||
Back
|
||||
</MenuItem>
|
||||
<hr>
|
||||
@@ -1,31 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import { taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
let isCompressedProfileShown = $state(true);
|
||||
|
||||
const profileId = $derived(page.params.id);
|
||||
const profile = $derived<MaintenanceProfile | null>(
|
||||
$maintenanceProfiles.find(profile => profile.id === profileId) || null
|
||||
const profile = $derived<TaggingProfile | null>(
|
||||
$taggingProfiles.find(profile => profile.id === profileId) || null
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!profile) {
|
||||
goto('/features/maintenance/');
|
||||
goto('/features/profiles/');
|
||||
} else {
|
||||
$popupTitle = `Export Tagging Profile: ${profile.settings.name}`;
|
||||
}
|
||||
});
|
||||
|
||||
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
|
||||
const profilesTransporter = new EntitiesTransporter(TaggingProfile);
|
||||
|
||||
let rawExportedProfile = $derived(profile ? profilesTransporter.exportToJSON(profile) : '');
|
||||
let compressedExportedProfile = $derived(profile ? profilesTransporter.exportToCompressedJSON(profile) : '');
|
||||
@@ -33,7 +33,7 @@
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/maintenance/{profileId}" icon="arrow-left">
|
||||
<MenuItem href="/features/profiles/{profileId}" icon="arrow-left">
|
||||
Back
|
||||
</MenuItem>
|
||||
<hr>
|
||||
@@ -2,22 +2,22 @@
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import { taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import { goto } from "$app/navigation";
|
||||
import EntitiesTransporter from "$lib/extension/EntitiesTransporter";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
import Notice from "$components/ui/Notice.svelte";
|
||||
|
||||
const profilesTransporter = new EntitiesTransporter(MaintenanceProfile);
|
||||
const profilesTransporter = new EntitiesTransporter(TaggingProfile);
|
||||
|
||||
let importedString = $state('');
|
||||
let errorMessage = $state('');
|
||||
|
||||
let candidateProfile = $state<MaintenanceProfile | null>(null);
|
||||
let existingProfile = $state<MaintenanceProfile | null>(null);
|
||||
let candidateProfile = $state<TaggingProfile | null>(null);
|
||||
let existingProfile = $state<TaggingProfile | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
$popupTitle = candidateProfile
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
|
||||
if (candidateProfile) {
|
||||
existingProfile = $maintenanceProfiles.find(profile => profile.id === candidateProfile?.id) ?? null;
|
||||
existingProfile = $taggingProfiles.find(profile => profile.id === candidateProfile?.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
}
|
||||
|
||||
candidateProfile.save().then(() => {
|
||||
goto(`/features/maintenance`);
|
||||
goto(`/features/profiles`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,16 +68,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const clonedProfile = new MaintenanceProfile(crypto.randomUUID(), candidateProfile.settings);
|
||||
const clonedProfile = new TaggingProfile(crypto.randomUUID(), candidateProfile.settings);
|
||||
clonedProfile.settings.name += ` (Clone ${new Date().toISOString()})`;
|
||||
clonedProfile.save().then(() => {
|
||||
goto(`/features/maintenance`);
|
||||
goto(`/features/profiles`);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuItem href="/features/maintenance" icon="arrow-left">Back</MenuItem>
|
||||
<MenuItem href="/features/profiles" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if errorMessage}
|
||||
@@ -4,8 +4,12 @@
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { stripBlacklistedTagsEnabled } from "$stores/preferences/maintenance";
|
||||
import { shouldReplaceLinksOnForumPosts, shouldSeparateTagGroups } from "$stores/preferences/tag";
|
||||
import { stripBlacklistedTagsEnabled } from "$stores/preferences/profiles";
|
||||
import {
|
||||
shouldReplaceLinksOnForumPosts,
|
||||
shouldReplaceTextOfTagLinks,
|
||||
shouldSeparateTagGroups
|
||||
} from "$stores/preferences/tags";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
|
||||
$popupTitle = 'Tagging Preferences';
|
||||
@@ -31,4 +35,11 @@
|
||||
Find and replace links to the tags in the forum posts
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
{#if $shouldReplaceLinksOnForumPosts}
|
||||
<FormControl>
|
||||
<CheckboxField bind:checked={$shouldReplaceTextOfTagLinks}>
|
||||
Try to replace text on links pointing to tags in forum posts
|
||||
</CheckboxField>
|
||||
</FormControl>
|
||||
{/if}
|
||||
</FormContainer>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuItem from "$components/ui/menu/MenuItem.svelte";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import { taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import BulkEntitiesTransporter from "$lib/extension/BulkEntitiesTransporter";
|
||||
@@ -9,11 +9,13 @@
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
|
||||
|
||||
const bulkTransporter = new BulkEntitiesTransporter();
|
||||
|
||||
let exportAllProfiles = $state(false);
|
||||
let exportAllGroups = $state(false);
|
||||
let exportAllPresets = $state(false);
|
||||
|
||||
let displayExportedString = $state(false);
|
||||
let shouldUseCompressed = $state(true);
|
||||
@@ -24,13 +26,14 @@
|
||||
const exportedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
|
||||
profiles: {},
|
||||
groups: {},
|
||||
presets: {},
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (displayExportedString) {
|
||||
const elementsToExport: StorageEntity[] = [];
|
||||
|
||||
$maintenanceProfiles.forEach(profile => {
|
||||
$taggingProfiles.forEach(profile => {
|
||||
if (exportedEntities.profiles[profile.id]) {
|
||||
elementsToExport.push(profile);
|
||||
}
|
||||
@@ -42,6 +45,12 @@
|
||||
}
|
||||
});
|
||||
|
||||
$tagEditorPresets.forEach(preset => {
|
||||
if (exportedEntities.presets[preset.id]) {
|
||||
elementsToExport.push(preset);
|
||||
}
|
||||
});
|
||||
|
||||
plainExport = bulkTransporter.exportToJSON(elementsToExport);
|
||||
compressedExport = bulkTransporter.exportToCompressedJSON(elementsToExport);
|
||||
}
|
||||
@@ -55,8 +64,9 @@
|
||||
|
||||
function refreshAreAllEntitiesChecked() {
|
||||
requestAnimationFrame(() => {
|
||||
exportAllProfiles = $maintenanceProfiles.every(profile => exportedEntities.profiles[profile.id]);
|
||||
exportAllProfiles = $taggingProfiles.every(profile => exportedEntities.profiles[profile.id]);
|
||||
exportAllGroups = $tagGroups.every(group => exportedEntities.groups[group.id]);
|
||||
exportAllPresets = $tagEditorPresets.every(preset => exportedEntities.presets[preset.id]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,11 +79,14 @@
|
||||
requestAnimationFrame(() => {
|
||||
switch (targetEntity) {
|
||||
case "profiles":
|
||||
$maintenanceProfiles.forEach(profile => exportedEntities.profiles[profile.id] = exportAllProfiles);
|
||||
$taggingProfiles.forEach(profile => exportedEntities.profiles[profile.id] = exportAllProfiles);
|
||||
break;
|
||||
case "groups":
|
||||
$tagGroups.forEach(group => exportedEntities.groups[group.id] = exportAllGroups);
|
||||
break;
|
||||
case "presets":
|
||||
$tagEditorPresets.forEach(preset => exportedEntities.presets[preset.id] = exportAllPresets);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Trying to toggle unsupported entity type: ${targetEntity}`);
|
||||
}
|
||||
@@ -94,11 +107,11 @@
|
||||
<Menu>
|
||||
<MenuItem href="/transporting" icon="arrow-left">Back</MenuItem>
|
||||
<hr>
|
||||
{#if $maintenanceProfiles.length}
|
||||
{#if $taggingProfiles.length}
|
||||
<MenuCheckboxItem bind:checked={exportAllProfiles} oninput={createToggleAllOnUserInput('profiles')}>
|
||||
Export All Profiles
|
||||
</MenuCheckboxItem>
|
||||
{#each $maintenanceProfiles as profile}
|
||||
{#each $taggingProfiles as profile}
|
||||
<MenuCheckboxItem bind:checked={exportedEntities.profiles[profile.id]} oninput={refreshAreAllEntitiesChecked}>
|
||||
Profile: {profile.settings.name}
|
||||
</MenuCheckboxItem>
|
||||
@@ -116,6 +129,17 @@
|
||||
{/each}
|
||||
<hr>
|
||||
{/if}
|
||||
{#if $tagEditorPresets.length}
|
||||
<MenuCheckboxItem bind:checked={exportAllPresets} oninput={createToggleAllOnUserInput('presets')}>
|
||||
Export All Presets
|
||||
</MenuCheckboxItem>
|
||||
{#each $tagEditorPresets as preset}
|
||||
<MenuCheckboxItem bind:checked={exportedEntities.presets[preset.id]} oninput={refreshAreAllEntitiesChecked}>
|
||||
Preset: {preset.settings.name}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
<hr>
|
||||
{/if}
|
||||
<MenuItem icon="file-export" onclick={toggleExportedStringDisplay}>Export Selected</MenuItem>
|
||||
</Menu>
|
||||
{:else}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
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 MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import TagGroup from "$entities/TagGroup";
|
||||
import BulkEntitiesTransporter from "$lib/extension/BulkEntitiesTransporter";
|
||||
import type StorageEntity from "$lib/extension/base/StorageEntity";
|
||||
import { maintenanceProfiles } from "$stores/entities/maintenance-profiles";
|
||||
import { taggingProfiles } from "$stores/entities/tagging-profiles";
|
||||
import { tagGroups } from "$stores/entities/tag-groups";
|
||||
import MenuCheckboxItem from "$components/ui/menu/MenuCheckboxItem.svelte";
|
||||
import ProfileView from "$components/features/ProfileView.svelte";
|
||||
@@ -16,30 +16,35 @@
|
||||
import type { SameSiteStatus } from "$lib/extension/EntitiesTransporter";
|
||||
import { popupTitle } from "$stores/popup";
|
||||
import Notice from "$components/ui/Notice.svelte";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
import { tagEditorPresets } from "$stores/entities/tag-editor-presets";
|
||||
|
||||
let importedString = $state('');
|
||||
let errorMessage = $state('');
|
||||
|
||||
let importedProfiles = $state<MaintenanceProfile[]>([]);
|
||||
let importedProfiles = $state<TaggingProfile[]>([]);
|
||||
let importedGroups = $state<TagGroup[]>([]);
|
||||
let importedPresets = $state<TagEditorPreset[]>([]);
|
||||
|
||||
let saveAllProfiles = $state(false);
|
||||
let saveAllGroups = $state(false);
|
||||
let saveAllPresets = $state(false);
|
||||
|
||||
let isSaving = $state(false);
|
||||
|
||||
let selectedEntities: Record<keyof App.EntityNamesMap, Record<string, boolean>> = $state({
|
||||
profiles: {},
|
||||
groups: {},
|
||||
presets: {},
|
||||
});
|
||||
|
||||
let previewedEntity = $state<StorageEntity | null>(null);
|
||||
|
||||
const existingProfilesMap = $derived(
|
||||
$maintenanceProfiles.reduce((map, profile) => {
|
||||
$taggingProfiles.reduce((map, profile) => {
|
||||
map.set(profile.id, profile);
|
||||
return map;
|
||||
}, new Map<string, MaintenanceProfile>())
|
||||
}, new Map<string, TaggingProfile>())
|
||||
);
|
||||
|
||||
const existingGroupsMap = $derived(
|
||||
@@ -49,8 +54,15 @@
|
||||
}, new Map<string, TagGroup>())
|
||||
);
|
||||
|
||||
const existingPresetsMap = $derived(
|
||||
$tagEditorPresets.reduce((map, preset) => {
|
||||
map.set(preset.id, preset);
|
||||
return map;
|
||||
}, new Map<string, TagEditorPreset>())
|
||||
);
|
||||
|
||||
const hasImportedEntities = $derived(
|
||||
Boolean(importedProfiles.length || importedGroups.length)
|
||||
Boolean(importedProfiles.length || importedGroups.length || importedPresets.length)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
@@ -70,6 +82,7 @@
|
||||
function tryBulkImport() {
|
||||
importedProfiles = [];
|
||||
importedGroups = [];
|
||||
importedPresets = [];
|
||||
errorMessage = '';
|
||||
|
||||
importedString = importedString.trim();
|
||||
@@ -98,11 +111,14 @@
|
||||
for (const targetImportedEntity of importedEntities) {
|
||||
switch (targetImportedEntity.type) {
|
||||
case "profiles":
|
||||
importedProfiles.push(targetImportedEntity as MaintenanceProfile);
|
||||
importedProfiles.push(targetImportedEntity as TaggingProfile);
|
||||
break;
|
||||
case "groups":
|
||||
importedGroups.push(targetImportedEntity as TagGroup);
|
||||
break;
|
||||
case "presets":
|
||||
importedPresets.push(targetImportedEntity as TagEditorPreset);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unprocessed entity type detected: ${targetImportedEntity.type}`, targetImportedEntity);
|
||||
}
|
||||
@@ -115,12 +131,14 @@
|
||||
function cancelImport() {
|
||||
importedProfiles = [];
|
||||
importedGroups = [];
|
||||
importedPresets = [];
|
||||
}
|
||||
|
||||
function refreshAreAllEntitiesChecked() {
|
||||
requestAnimationFrame(() => {
|
||||
saveAllProfiles = importedProfiles.every(profile => selectedEntities.profiles[profile.id]);
|
||||
saveAllGroups = importedGroups.every(group => selectedEntities.groups[group.id]);
|
||||
saveAllPresets = importedPresets.every(preset => selectedEntities.presets[preset.id]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,6 +152,9 @@
|
||||
case "groups":
|
||||
importedGroups.forEach(group => selectedEntities.groups[group.id] = saveAllGroups);
|
||||
break;
|
||||
case "presets":
|
||||
importedPresets.forEach(preset => selectedEntities.presets[preset.id] = saveAllPresets);
|
||||
break;
|
||||
default:
|
||||
console.warn(`Trying to toggle unsupported entity type: ${entityType}`);
|
||||
}
|
||||
@@ -171,6 +192,14 @@
|
||||
await group.save();
|
||||
}
|
||||
|
||||
for (const preset of importedPresets) {
|
||||
if (!selectedEntities.presets[preset.id]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await preset.save();
|
||||
}
|
||||
|
||||
await goto("/transporting");
|
||||
}
|
||||
</script>
|
||||
@@ -202,7 +231,7 @@
|
||||
<MenuItem onclick={() => previewedEntity = null} icon="arrow-left">Back to Selection</MenuItem>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if previewedEntity instanceof MaintenanceProfile}
|
||||
{#if previewedEntity instanceof TaggingProfile}
|
||||
<ProfileView profile={previewedEntity}></ProfileView>
|
||||
{:else if previewedEntity instanceof TagGroup}
|
||||
<GroupView group={previewedEntity}></GroupView>
|
||||
@@ -251,10 +280,7 @@
|
||||
{/if}
|
||||
{#if importedGroups.length}
|
||||
<hr>
|
||||
<MenuCheckboxItem
|
||||
bind:checked={saveAllGroups}
|
||||
oninput={createToggleAllOnUserInput('groups')}
|
||||
>
|
||||
<MenuCheckboxItem bind:checked={saveAllGroups} oninput={createToggleAllOnUserInput('groups')}>
|
||||
Import All Groups
|
||||
</MenuCheckboxItem>
|
||||
{#each importedGroups as candidateGroup}
|
||||
@@ -272,6 +298,26 @@
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if importedPresets.length}
|
||||
<hr>
|
||||
<MenuCheckboxItem bind:checked={saveAllPresets} oninput={createToggleAllOnUserInput('presets')}>
|
||||
Import All Presets
|
||||
</MenuCheckboxItem>
|
||||
{#each importedPresets as candidatePreset}
|
||||
<MenuCheckboxItem
|
||||
bind:checked={selectedEntities.presets[candidatePreset.id]}
|
||||
oninput={refreshAreAllEntitiesChecked}
|
||||
onitemclick={createShowPreviewForEntity(candidatePreset)}
|
||||
>
|
||||
{#if existingPresetsMap.has(candidatePreset.id)}
|
||||
Update:
|
||||
{:else}
|
||||
New:
|
||||
{/if}
|
||||
{candidatePreset.settings.name || 'Unnamed Preset'}
|
||||
</MenuCheckboxItem>
|
||||
{/each}
|
||||
{/if}
|
||||
<hr>
|
||||
<MenuItem onclick={saveSelectedEntities}>
|
||||
Imported Selected
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { type Writable, writable } from "svelte/store";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
|
||||
/**
|
||||
* Store for working with maintenance profiles in the Svelte popup.
|
||||
*/
|
||||
export const maintenanceProfiles: Writable<MaintenanceProfile[]> = writable([]);
|
||||
|
||||
/**
|
||||
* Store for the active maintenance profile ID.
|
||||
*/
|
||||
export const activeProfileStore: Writable<string|null> = writable(null);
|
||||
|
||||
const maintenanceSettings = new MaintenanceSettings();
|
||||
|
||||
/**
|
||||
* Active profile ID stored locally. Used to reset active profile once the existing profile was removed.
|
||||
*/
|
||||
let lastActiveProfileId: string|null = null;
|
||||
|
||||
Promise.allSettled([
|
||||
// Read the initial values from the storages first
|
||||
MaintenanceProfile.readAll().then(profiles => {
|
||||
maintenanceProfiles.set(profiles);
|
||||
}),
|
||||
maintenanceSettings.resolveActiveProfileId().then(activeProfileId => {
|
||||
activeProfileStore.set(activeProfileId);
|
||||
})
|
||||
]).then(() => {
|
||||
// And only after initial values are loaded, start watching for changes from storage and from user's interaction
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
maintenanceProfiles.set(profiles);
|
||||
});
|
||||
|
||||
maintenanceSettings.subscribe(settings => {
|
||||
activeProfileStore.set(settings.activeProfile || null);
|
||||
});
|
||||
|
||||
activeProfileStore.subscribe(profileId => {
|
||||
lastActiveProfileId = profileId;
|
||||
|
||||
void maintenanceSettings.setActiveProfileId(profileId);
|
||||
});
|
||||
|
||||
// Watch the existence of the active profile on every change.
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
if (!profiles.find(profile => profile.id === lastActiveProfileId)) {
|
||||
activeProfileStore.set(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
11
src/stores/entities/tag-editor-presets.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { type Writable, writable } from "svelte/store";
|
||||
import TagEditorPreset from "$entities/TagEditorPreset";
|
||||
|
||||
export const tagEditorPresets: Writable<TagEditorPreset[]> = writable([]);
|
||||
|
||||
TagEditorPreset
|
||||
.readAll()
|
||||
.then(presets => tagEditorPresets.set(presets))
|
||||
.then(() => {
|
||||
TagEditorPreset.subscribe(presets => tagEditorPresets.set(presets))
|
||||
});
|
||||
52
src/stores/entities/tagging-profiles.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { type Writable, writable } from "svelte/store";
|
||||
import TaggingProfile from "$entities/TaggingProfile";
|
||||
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
|
||||
|
||||
/**
|
||||
* Store for working with maintenance profiles in the Svelte popup.
|
||||
*/
|
||||
export const taggingProfiles: Writable<TaggingProfile[]> = writable([]);
|
||||
|
||||
/**
|
||||
* Store for the active maintenance profile ID.
|
||||
*/
|
||||
export const activeTaggingProfile: Writable<string|null> = writable(null);
|
||||
|
||||
const preferences = new TaggingProfilesPreferences();
|
||||
|
||||
/**
|
||||
* Active profile ID stored locally. Used to reset active profile once the existing profile was removed.
|
||||
*/
|
||||
let lastActiveProfileId: string|null = null;
|
||||
|
||||
Promise.allSettled([
|
||||
// Read the initial values from the storages first
|
||||
TaggingProfile.readAll().then(profiles => {
|
||||
taggingProfiles.set(profiles);
|
||||
}),
|
||||
preferences.activeProfile.get().then(activeProfileId => {
|
||||
activeTaggingProfile.set(activeProfileId);
|
||||
})
|
||||
]).then(() => {
|
||||
// And only after initial values are loaded, start watching for changes from storage and from user's interaction
|
||||
TaggingProfile.subscribe(profiles => {
|
||||
taggingProfiles.set(profiles);
|
||||
});
|
||||
|
||||
preferences.subscribe(settings => {
|
||||
activeTaggingProfile.set(settings.activeProfile || null);
|
||||
});
|
||||
|
||||
activeTaggingProfile.subscribe(profileId => {
|
||||
lastActiveProfileId = profileId;
|
||||
|
||||
void preferences.activeProfile.set(profileId);
|
||||
});
|
||||
|
||||
// Watch the existence of the active profile on every change.
|
||||
TaggingProfile.subscribe(profiles => {
|
||||
if (!profiles.find(profile => profile.id === lastActiveProfileId)) {
|
||||
activeTaggingProfile.set(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { writable } from "svelte/store";
|
||||
import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings";
|
||||
|
||||
export const stripBlacklistedTagsEnabled = writable(true);
|
||||
|
||||
const maintenanceSettings = new MaintenanceSettings();
|
||||
|
||||
Promise
|
||||
.all([
|
||||
maintenanceSettings.resolveStripBlacklistedTags().then(v => stripBlacklistedTagsEnabled.set(v ?? true))
|
||||
])
|
||||
.then(() => {
|
||||
maintenanceSettings.subscribe(settings => {
|
||||
stripBlacklistedTagsEnabled.set(typeof settings.stripBlacklistedTags === 'boolean' ? settings.stripBlacklistedTags : true);
|
||||
});
|
||||
|
||||
stripBlacklistedTagsEnabled.subscribe(v => maintenanceSettings.setStripBlacklistedTags(v));
|
||||
});
|
||||
@@ -1,18 +1,18 @@
|
||||
import { writable } from "svelte/store";
|
||||
import MiscSettings from "$lib/extension/settings/MiscSettings";
|
||||
import MiscPreferences from "$lib/extension/preferences/MiscPreferences";
|
||||
|
||||
export const fullScreenViewerEnabled = writable(true);
|
||||
|
||||
const miscSettings = new MiscSettings();
|
||||
const preferences = new MiscPreferences();
|
||||
|
||||
Promise.allSettled([
|
||||
miscSettings.resolveFullscreenViewerEnabled().then(v => fullScreenViewerEnabled.set(v))
|
||||
preferences.fullscreenViewer.get().then(v => fullScreenViewerEnabled.set(v))
|
||||
]).then(() => {
|
||||
fullScreenViewerEnabled.subscribe(value => {
|
||||
void miscSettings.setFullscreenViewerEnabled(value);
|
||||
void preferences.fullscreenViewer.set(value);
|
||||
});
|
||||
|
||||
miscSettings.subscribe(settings => {
|
||||
preferences.subscribe(settings => {
|
||||
fullScreenViewerEnabled.set(Boolean(settings.fullscreenViewer));
|
||||
});
|
||||
});
|
||||
|
||||
18
src/stores/preferences/profiles.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { writable } from "svelte/store";
|
||||
import TaggingProfilesPreferences from "$lib/extension/preferences/TaggingProfilesPreferences";
|
||||
|
||||
export const stripBlacklistedTagsEnabled = writable(true);
|
||||
|
||||
const preferences = new TaggingProfilesPreferences();
|
||||
|
||||
Promise
|
||||
.all([
|
||||
preferences.stripBlacklistedTags.get().then(v => stripBlacklistedTagsEnabled.set(v ?? true))
|
||||
])
|
||||
.then(() => {
|
||||
preferences.subscribe(settings => {
|
||||
stripBlacklistedTagsEnabled.set(typeof settings.stripBlacklistedTags === 'boolean' ? settings.stripBlacklistedTags : true);
|
||||
});
|
||||
|
||||
stripBlacklistedTagsEnabled.subscribe(v => preferences.stripBlacklistedTags.set(v));
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { writable } from "svelte/store";
|
||||
import TagSettings from "$lib/extension/settings/TagSettings";
|
||||
|
||||
const tagSettings = new TagSettings();
|
||||
|
||||
export const shouldSeparateTagGroups = writable(false);
|
||||
export const shouldReplaceLinksOnForumPosts = writable(false);
|
||||
|
||||
Promise
|
||||
.allSettled([
|
||||
tagSettings.resolveGroupSeparation().then(value => shouldSeparateTagGroups.set(value)),
|
||||
tagSettings.resolveReplaceLinks().then(value => shouldReplaceLinksOnForumPosts.set(value)),
|
||||
])
|
||||
.then(() => {
|
||||
shouldSeparateTagGroups.subscribe(value => {
|
||||
void tagSettings.setGroupSeparation(value);
|
||||
});
|
||||
|
||||
shouldReplaceLinksOnForumPosts.subscribe(value => {
|
||||
void tagSettings.setReplaceLinks(value);
|
||||
});
|
||||
|
||||
tagSettings.subscribe(settings => {
|
||||
shouldSeparateTagGroups.set(Boolean(settings.groupSeparation));
|
||||
shouldReplaceLinksOnForumPosts.set(Boolean(settings.replaceLinks));
|
||||
});
|
||||
});
|
||||
34
src/stores/preferences/tags.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { writable } from "svelte/store";
|
||||
import TagsPreferences from "$lib/extension/preferences/TagsPreferences";
|
||||
|
||||
const preferences = new TagsPreferences();
|
||||
|
||||
export const shouldSeparateTagGroups = writable(false);
|
||||
export const shouldReplaceLinksOnForumPosts = writable(false);
|
||||
export const shouldReplaceTextOfTagLinks = writable(true);
|
||||
|
||||
Promise
|
||||
.allSettled([
|
||||
preferences.groupSeparation.get().then(value => shouldSeparateTagGroups.set(value)),
|
||||
preferences.replaceLinks.get().then(value => shouldReplaceLinksOnForumPosts.set(value)),
|
||||
preferences.replaceLinkText.get().then(value => shouldReplaceTextOfTagLinks.set(value)),
|
||||
])
|
||||
.then(() => {
|
||||
shouldSeparateTagGroups.subscribe(value => {
|
||||
void preferences.groupSeparation.set(value);
|
||||
});
|
||||
|
||||
shouldReplaceLinksOnForumPosts.subscribe(value => {
|
||||
void preferences.replaceLinks.set(value);
|
||||
});
|
||||
|
||||
shouldReplaceTextOfTagLinks.subscribe(value => {
|
||||
void preferences.replaceLinkText.set(value);
|
||||
});
|
||||
|
||||
preferences.subscribe(settings => {
|
||||
shouldSeparateTagGroups.set(Boolean(settings.groupSeparation));
|
||||
shouldReplaceLinksOnForumPosts.set(Boolean(settings.replaceLinks));
|
||||
shouldReplaceTextOfTagLinks.set(Boolean(settings.replaceLinkText));
|
||||
});
|
||||
});
|
||||
16
src/styles/content/tag-presets.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
@use '$styles/booru-vars';
|
||||
|
||||
.block.tag-presets {
|
||||
.tag {
|
||||
cursor: pointer;
|
||||
|
||||
&.is-missing {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: booru-vars.$resolved-tag-background;
|
||||
background: booru-vars.$resolved-tag-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="132.29mm" height="132.29mm" version="1.1" viewBox="0 0 132.29 132.29" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(471.71 80.22)">
|
||||
<g transform="translate(.53454 1.7373)">
|
||||
<path d="m-360.22-74.459c-28.032 5.3761-64.196 11.821-92.316 21.773-22.49 7.9602-19.605 30.849 5.0539 26.858 30-4.8548 67.281-10.699 95.353-14.616-0.64545-11.834-2.5854-23.356-8.0905-34.016zm-96.954 29.841c3.9562-0.74441 7.7233 1.6284 8.4142 5.2999 0.69043 3.6716-1.9562 7.2513-5.9124 7.9957-3.9562 0.7444-7.7232-1.6285-8.4141-5.2999-0.69042-3.6715 1.9562-7.2512 5.9123-7.9957z" fill="#113456" style="paint-order:normal"/>
|
||||
<path d="m-354.11-76.234c-6.1145 1.1727-18.326 4.0969-24.43 5.2807l7.3928 33.203c6.3875-0.9175 18.616-2.3159 24.909-3.1941-0.64547-11.834-2.3666-24.63-7.8717-35.29z" fill="#275a8d" style="paint-order:normal"/>
|
||||
<g transform="matrix(1.1047 -.26755 .26755 1.1047 -295.73 5.0268)" fill="#418dd9" stroke-width="1px" aria-label="+ i really">
|
||||
<path d="m-109.59-68.714c-0.089 1.3875-2.4458-0.1706-2.4103 1.0204 0.36948 0.92893-0.61602 2.6504-1.1951 1.1347 0.0168-0.93201 0.24139-2.0611-1.1621-1.5039-1.6749 0.12542-0.90266-1.8258 0.45486-1.2068 1.4742 0.43966-0.0647-2.8816 1.747-1.9823 0.14653 1.24-0.13245 2.5429 1.722 1.9166 0.36032 0.02 0.86116 0.16781 0.84361 0.62124z"/>
|
||||
<path d="m-102.78-72.896c-2.2543-0.55533 1.0522-2.7623 0.72678-0.48004-0.12625 0.27727-0.413 0.49201-0.72678 0.48004zm0.30213 5.4517c0.0582 0.94586 0.41317 4.0299-1.2236 2.6788-0.17956-2.0272-0.0573-4.0762 0.0872-6.1012 2.0744-1.5718 0.95718 2.4924 1.1364 3.4224z"/>
|
||||
<path d="m-90.867-69.358c-0.35082 1.7588-1.6949 0.24631-1.2891-0.85938-1.4916 0.05506-2.992 1.3472-2.6886 2.9103-0.07892 1.057 0.19116 2.1931-0.15638 3.1909-1.3457 0.70152-1.0836-1.1527-1.0893-1.9485 0.01934-1.633-0.05224-3.2705 0.0705-4.8993 1.0358-1.4884 1.0728 1.7574 1.8807 0.18967 1.4368-1.159 3.8305-0.95952 3.2722 1.4162z"/>
|
||||
<path d="m-86.348-64.088c-2.353 0.27226-4.1408-2.1361-3.3655-4.3384 0.25129-2.1773 2.5915-3.6168 4.6256-2.7749 2.4364 0.84593 0.66104 3.3783-1.138 3.6566-0.78817 0.37345-1.5763 0.7469-2.3645 1.1204 1.2036 1.8958 3.7418 0.96604 5.0623 0.05203 1.3351 1.4318-1.7546 2.3186-2.8198 2.2844zm-0.18127-6.1163c-1.2603-0.54741-3.1258 3.3447-1.3751 2.164 1.0179-0.5018 2.0935-0.92053 2.9931-1.6269-0.4321-0.4072-1.0427-0.53936-1.618-0.53711z"/>
|
||||
<path d="m-76.694-63.953c-1.2507-1.5544-3.7187 1.1675-5.1562-0.9668-1.5477-2.2077-0.43496-5.8956 2.3599-6.4134 1.7565-0.47081 3.6364 0.76684 2.7141 2.6104-0.77494 1.6064 1.2116 3.4857 0.31555 4.7261zm-1.3965-4.6661c0.95522-2.544-2.6591-1.5586-2.9272 0.16617-0.9372 1.7855 0.85633 4.353 2.7544 2.8181 0.82545-0.56077-0.03186-2.0799 0.17288-2.9843z"/>
|
||||
<path d="m-73.196-69.674c-0.08328 1.7284-0.03569 3.4717-0.22072 5.1881-1.9466 1.2665-1.0589-2.4666-1.1435-3.5088 0.08144-2.2743 0.03377-4.5571 0.1498-6.8272 1.924-1.2991 1.1388 2.3198 1.2333 3.3867-0.01142 0.58697-0.01883 1.174-0.01888 1.7611z"/>
|
||||
<path d="m-69.436-69.674c-0.08328 1.7284-0.03569 3.4717-0.22072 5.1881-1.9466 1.2665-1.0589-2.4666-1.1435-3.5088 0.08144-2.2743 0.03377-4.5571 0.1498-6.8272 1.924-1.2991 1.1388 2.3198 1.2333 3.3867-0.01142 0.58697-0.01883 1.174-0.01888 1.7611z"/>
|
||||
<path d="m-61.527-70.385c-1.4366 3.1829-3.0461 6.2999-4.1853 9.6042-2.1757 0.84518-0.54642-2.5672-0.08224-3.4957 0.74289-0.83986-0.24231-1.6266-0.52828-2.4351-0.6457-1.3327-1.442-2.6142-2.0188-3.9621 1.2038-1.9336 2.3288 1.7382 2.9679 2.6755 0.42367 1.4505 0.79477 1.47 1.1681 0.15822 0.5285-1.1723 0.95491-2.4281 1.752-3.4447 0.54145-0.35656 1.2854 0.33916 0.92651 0.89966z"/>
|
||||
</g>
|
||||
<g transform="matrix(.96209 -.2075 .2075 .96209 25.912 -52.71)" fill="#dadada" stroke-width="1px" aria-label="69">
|
||||
<path d="m-388.94-81.96c-3.1615 0.07399-3.9724-4.0595-2.8389-6.3982 0.29035-1.6345 2.7892-5.2548 4.0606-3.6089-0.44097 1.0134-4.056 3.4132-1.0784 3.0781 2.8498-0.20087 4.3801 3.4509 2.6287 5.5412-0.6173 0.88091-1.6763 1.4927-2.772 1.3877zm0-5.5121c-2.8259-0.34004-2.5344 4.6205 0.17604 4.3457 2.5863-0.22618 2.7512-4.6838-0.17604-4.3457z"/>
|
||||
<path d="m-379.28-83.256c-0.7292 0.75861-4.068 2.2452-3.2966 0.2874 1.247-0.55674 4.5546-2.2796 3.3763-3.1846-2.8507 1.1008-5.9514-2.2308-4.2021-4.9031 1.74-3.059 6.8816-1.6565 6.6098 1.9514 0.0631 2.1314-0.83485 4.4599-2.4875 5.8489zm-0.95336-8.3521c-3.2899-0.34723-2.7447 5.4317 0.54912 4.3465 2.2445-0.5994 1.8649-4.0795-0.38969-4.3095l-0.13256-0.03075z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(.9297 .16991 -.16991 .9297 1852.1 945.27)" fill="#4aa158" stroke-width="1px" aria-label="i love tags">
|
||||
<path d="m-2555.4-574.69c-0.3676 1.6669 0.9835 4.556-0.6889 5.4079-1.3252-1.1002-0.2302-3.0805-0.6116-4.5626-0.059-1.9432-0.1407-3.8859-0.1648-5.83 1.8661-1.2106 1.4497 2.2146 1.3957 3.2387 0.018 0.58223 0.038 1.1644 0.07 1.7461z"/>
|
||||
<path d="m-2550.7-569.12c-1.695 0.0205-2.9714-1.6262-3.0509-3.2248-0.1501-1.8024 1.0236-3.7632 2.8687-4.1196 1.1017-0.20636 2.2681 0.43217 2.6999 1.465 0.9646 1.8328 0.4888 4.4339-1.3293 5.5548-0.3586 0.20843-0.7713 0.33513-1.1884 0.3246zm0.2522-6.0037c-1.2907 0.0944-2.056 1.5013-1.9549 2.6922 0.029 0.91158 0.6876 1.9392 1.6952 1.8903 1.2615-0.0788 1.9363-1.493 1.8203-2.6284-0.071-0.83944-0.6072-1.921-1.5606-1.9541z"/>
|
||||
<path d="m-2542.9-570.06c-1.3061 1.5667-1.9961-1.4672-2.3256-2.4011-0.1987-1.32-2.0746-3.0697-0.9388-4.1448 1.525 0.20085 1.3152 2.1228 1.8816 3.1912 0.1403 0.79789 0.9706 2.1182 1.1744 0.58798 0.7835-1.1228 0.5292-3.8843 2.1726-3.7984 0.8949 1.4035-0.7473 2.7947-0.9974 4.1803-0.3221 0.79501-0.6494 1.588-0.9668 2.3849z"/>
|
||||
<path d="m-2536.2-569.57c-1.8379 0.25369-3.7629-1.3102-3.5737-3.2271-0.1012-1.9097 1.1916-4.0598 3.2616-4.1279 1.6801-0.45649 3.7706 1.815 1.9193 3.0052-1.0691 1.0064-3.0605 1.1153-3.7768 2.3094 1.2557 1.3521 3.3826 0.86213 4.6361-0.23707 1.7687 0.85334-1.0741 2.4516-2.1601 2.2488zm-0.3347-6.1099c-1.0721-0.21792-2.5866 1.9217-1.5497 2.2758 0.8278-0.54282 3.244-1.0441 2.6665-2.1507-0.3611-0.12265-0.7486-0.0491-1.1168-0.12512z"/>
|
||||
<path d="m-2524.9-575.13c-1.5405-0.67558-1.6985 1.2986-1.5948 2.4528 0.099 1.1011 0.4855 2.9463-0.6623 3.5102-1.5514-0.27135-0.3798-2.0448-0.6241-3.0366-0.2283-0.92722 0.4148-2.4056-0.3475-2.9818-0.9704 0.62006-2.45-1.4007-0.8898-1.386 1.1211 0.29871 1.5228-0.80797 1.1782-1.6566 0.5385-1.1269 1.8956-0.16987 1.4068 0.84134-0.1404 1.6457 1.8808 0.0764 2.1409 1.4955 0.1437 0.39517-0.2034 0.79615-0.6074 0.7612z"/>
|
||||
<path d="m-2517.9-568.69c-0.5023-0.0829-0.6877-1.036-1.2791-0.6416-0.7152 0.37561-1.5473 0.56594-2.3442 0.36228-0.7872-0.14923-1.5089-0.66497-1.7954-1.4298-0.3806-1.0402-0.3821-2.2244-0.036-3.2745 0.5829-1.706 2.5214-2.9201 4.3075-2.445 0.5707 0.18706 1.265 0.40504 1.5386 0.98673 0 0.5278-0.316 1.0105-0.2776 1.5567-0.063 1.055-0.2308 2.1533 0.1349 3.1741 0.074 0.48738 0.5674 0.98265 0.2806 1.4585-0.1224 0.16152-0.3244 0.26651-0.5291 0.25261zm-1.2138-4.717c-0.1232-0.48283 0.4777-1.2468-0.099-1.5045-1.015-0.3911-2.1226 0.31928-2.6364 1.1837-0.5199 0.86317-0.573 1.9914-0.187 2.9144 0.3983 0.86477 1.5766 0.91664 2.3401 0.55213 0.4068-0.16553 0.8256-0.49949 0.6616-0.99063-0.082-0.71322-0.089-1.4375-0.08-2.1551z"/>
|
||||
<path d="m-2510.5-571.74c-0.3089 1.7209-0.065 3.5936-0.951 5.1716-0.2379 0.48456-0.6503 0.8476-1.1523 1.0399-1.2351 0.52484-2.7013 0.49722-3.915-0.0756-0.2566-0.46006-0.08-1.2164 0.582-1.0383 0.7552 0.0602 1.5125 0.42319 2.2691 0.18519 1.0202-0.20188 1.6183-1.2671 1.6977-2.2374 0.022-0.27324-0.01-0.94901-0.2136-0.93026-0.6468 0.90767-1.9707 1.0068-2.9064 0.52172-0.7507-0.34336-1.2755-1.0692-1.414-1.8769-0.3954-1.8524 0.632-3.9893 2.4517-4.642 1.0211-0.32659 2.1925-0.25087 3.1384 0.26587 0.5576 0.29287 0.9341 0.92545 0.8125 1.5612-0.068 0.69708-0.3593 1.3534-0.3991 2.0548zm-2.0403-2.9888c-1.1195-0.15393-2.1374 0.68405-2.5193 1.6896-0.3037 0.75721-0.3717 1.6748 0.03 2.4073 0.3353 0.54377 1.0446 0.82935 1.6492 0.58276 0.9398-0.35299 1.5684-1.2979 1.6896-2.2747 0.1379-0.6666 0.3574-1.4067 0.05-2.0577-0.1732-0.31946-0.5679-0.39385-0.8993-0.3473z"/>
|
||||
<path d="m-2504.1-573.77c-0.3876-2.4091-4.4757 0.62756-1.3652 0.92784 2.6224 0.90121 1.7346 4.9148-0.9851 4.7134-1.2495 0.50199-3.7735-1.8039-1.5043-1.8019 1.1893 0.86998 3.8843-0.32005 1.8423-1.4348-2.0484-0.34111-3.7316-2.7402-1.4066-4.0613 1.3001-1.1931 5.2979-0.75714 3.679 1.5424-0.087 0.0381-0.1734 0.0762-0.2601 0.11431z"/>
|
||||
</g>
|
||||
<path d="m-344.74-25.124c-6.1284-1.0979-18.576-2.7472-24.699-3.8311l-5.0054 33.646c6.2917 1.434 18.208 4.5136 24.398 5.9506 3.6411-11.279 6.6229-23.84 5.3066-35.766z" fill="#2d6236" style="paint-order:normal"/>
|
||||
<g transform="matrix(.9297 .16991 -.16991 .9297 1916.8 958.68)" fill="#dadada" stroke-width="1px" aria-label="34">
|
||||
<path d="m-2560.2-569.43q-1.0138 0-1.8194-0.42297-0.9064-0.49011-1.2219-1.3763-0.04-0.12085-0.04-0.23499 0-0.26855 0.2014-0.44311 0.2081-0.18128 0.4767-0.18128 0.2685 0 0.4632 0.24841l0.3089 0.46326q0.2417 0.32898 0.6512 0.49683 0.4096 0.16113 0.9802 0.16113 0.7318 0 1.2824-0.45654 0.5841-0.4834 0.5841-1.1816 0-1.0138-0.7453-1.618-0.6579-0.52368-1.7456-0.63781-0.7452-0.0739-0.7452-0.60425 0-0.39612 0.5841-0.60425l1.6046-0.39612q0.4633-0.16784 0.6983-0.44983 0.235-0.28869 0.2417-0.69824 0.013-0.59082-0.4566-0.95337-0.4901-0.37597-1.3763-0.37597-0.4566 0-0.8862 0.22155l-0.7587 0.47669q-0.2216 0.14099-0.3559 0.14099-0.2685 0-0.4632-0.20142-0.188-0.20813-0.188-0.46997 0-0.53039 1.0272-1.0205 0.893-0.43641 1.4704-0.43641 1.4434 0 2.2894 0.63782 0.9064 0.68482 0.9064 1.9537 0 1.5778-1.2757 2.0813-0.1141 0.047-0.2618 0.094 0.8929 0.32898 1.336 0.98694 0.4432 0.65124 0.4432 1.6315 0 1.3226-0.9333 2.2491-0.9332 0.9198-2.276 0.9198z"/>
|
||||
<path d="m-2549.4-572.67v2.4841q0 0.62439-0.611 0.62439-0.7117 0-0.7117-0.97352 0-0.12084 0.014-0.3424 0.013-0.22156 0.013-0.30213l-0.01-1.524-2.9608-0.0739q-0.9198 0-1.2757-0.0604-0.6109-0.10071-0.6109-0.47669 0-0.30212 0.3692-0.76538l0.5774-0.68481 3.3032-4.8206q0.4499-0.62439 1.1414-0.62439 0.7587 0 0.7587 0.65796v5.5792q0.1141-7e-3 0.2752-7e-3 1.2354 0 1.2354 0.66467 0 0.4834-0.5103 0.61097-0.2282 0.0604-1.0003 0.0336zm-1.3025-5.6799q-1.7926 2.8601-2.7997 4.2834l2.7997 0.0671z"/>
|
||||
</g>
|
||||
<g transform="matrix(.1976 .036114 -.036114 .1976 -325.27 -67.285)" fill="#4aa158" aria-label="+">
|
||||
<path d="m-493.08 314.34c-0.0489 3.7279-4.7678 3.8996-7.4987 3.3367-3.6053-1.4027-7.9235 0.96777-6.2932 5.2004 1.0798 3.1434-0.60545 8.8662-4.8234 6.7976-3.6452-2.0855-1.24-6.7037-1.8488-9.936-2.0642-4.0613-7.6116-0.089-10.84-2.4827-4.1787-2.5752 1.0937-8.1378 4.6872-6.1325 3.6391 1.3653 7.8794-1.1274 5.9251-5.2444-0.89217-3.2023 1.9875-8.7744 5.6881-6.1442 3.634 2.6914-0.77449 7.4233 1.9769 10.557 3.3424 2.2417 7.9403-1.1136 11.393 1.362 0.90882 0.59128 1.7649 1.5165 1.6334 2.6864z" fill="#4aa158" stroke-width="5.679px"/>
|
||||
</g>
|
||||
<path d="m-351.07-25.656c-28.095-5.0334-64.166-11.986-93.984-12.779-23.849-0.63414-29.364 21.767-4.9134 26.884 29.746 6.2259 66.643 14.139 94.252 20.549 3.6411-11.279 5.9617-22.729 4.6453-34.655zm-101.21-6.9112c3.96 0.72376 6.6257 4.2896 5.9541 7.9647-0.67209 3.675-4.4264 6.0676-8.3864 5.3438-3.96-0.72375-6.6256-4.2897-5.954-7.9647 0.67208-3.6749 4.4263-6.0675 8.3863-5.3438z" fill="#1b3c21" style="paint-order:normal"/>
|
||||
<g transform="matrix(.9297 .16991 -.16991 .9297 1852.1 945.27)" fill="#4aa158" stroke-width="1px" aria-label="i love tags">
|
||||
<path d="m-2555.4-574.69c-0.3676 1.6669 0.9835 4.556-0.6889 5.4079-1.3252-1.1002-0.2302-3.0805-0.6116-4.5626-0.059-1.9432-0.1407-3.8859-0.1648-5.83 1.8661-1.2106 1.4497 2.2146 1.3957 3.2387 0.018 0.58223 0.038 1.1644 0.07 1.7461z"/>
|
||||
<path d="m-2550.7-569.12c-1.695 0.0205-2.9714-1.6262-3.0509-3.2248-0.1501-1.8024 1.0236-3.7632 2.8687-4.1196 1.1017-0.20636 2.2681 0.43217 2.6999 1.465 0.9646 1.8328 0.4888 4.4339-1.3293 5.5548-0.3586 0.20843-0.7713 0.33513-1.1884 0.3246zm0.2522-6.0037c-1.2907 0.0944-2.056 1.5013-1.9549 2.6922 0.029 0.91158 0.6876 1.9392 1.6952 1.8903 1.2615-0.0788 1.9363-1.493 1.8203-2.6284-0.071-0.83944-0.6072-1.921-1.5606-1.9541z"/>
|
||||
<path d="m-2542.9-570.06c-1.3061 1.5667-1.9961-1.4672-2.3256-2.4011-0.1987-1.32-2.0746-3.0697-0.9388-4.1448 1.525 0.20085 1.3152 2.1228 1.8816 3.1912 0.1403 0.79789 0.9706 2.1182 1.1744 0.58798 0.7835-1.1228 0.5292-3.8843 2.1726-3.7984 0.8949 1.4035-0.7473 2.7947-0.9974 4.1803-0.3221 0.79501-0.6494 1.588-0.9668 2.3849z"/>
|
||||
<path d="m-2536.2-569.57c-1.8379 0.25369-3.7629-1.3102-3.5737-3.2271-0.1012-1.9097 1.1916-4.0598 3.2616-4.1279 1.6801-0.45649 3.7706 1.815 1.9193 3.0052-1.0691 1.0064-3.0605 1.1153-3.7768 2.3094 1.2557 1.3521 3.3826 0.86213 4.6361-0.23707 1.7687 0.85334-1.0741 2.4516-2.1601 2.2488zm-0.3347-6.1099c-1.0721-0.21792-2.5866 1.9217-1.5497 2.2758 0.8278-0.54282 3.244-1.0441 2.6665-2.1507-0.3611-0.12265-0.7486-0.0491-1.1168-0.12512z"/>
|
||||
<path d="m-2524.9-575.13c-1.5405-0.67558-1.6985 1.2986-1.5948 2.4528 0.099 1.1011 0.4855 2.9463-0.6623 3.5102-1.5514-0.27135-0.3798-2.0448-0.6241-3.0366-0.2283-0.92722 0.4148-2.4056-0.3475-2.9818-0.9704 0.62006-2.45-1.4007-0.8898-1.386 1.1211 0.29871 1.5228-0.80797 1.1782-1.6566 0.5385-1.1269 1.8956-0.16987 1.4068 0.84134-0.1404 1.6457 1.8808 0.0764 2.1409 1.4955 0.1437 0.39517-0.2034 0.79615-0.6074 0.7612z"/>
|
||||
<path d="m-2517.9-568.69c-0.5023-0.0829-0.6877-1.036-1.2791-0.6416-0.7152 0.37561-1.5473 0.56594-2.3442 0.36228-0.7872-0.14923-1.5089-0.66497-1.7954-1.4298-0.3806-1.0402-0.3821-2.2244-0.036-3.2745 0.5829-1.706 2.5214-2.9201 4.3075-2.445 0.5707 0.18706 1.265 0.40504 1.5386 0.98673 0 0.5278-0.316 1.0105-0.2776 1.5567-0.063 1.055-0.2308 2.1533 0.1349 3.1741 0.074 0.48738 0.5674 0.98265 0.2806 1.4585-0.1224 0.16152-0.3244 0.26651-0.5291 0.25261zm-1.2138-4.717c-0.1232-0.48283 0.4777-1.2468-0.099-1.5045-1.015-0.3911-2.1226 0.31928-2.6364 1.1837-0.5199 0.86317-0.573 1.9914-0.187 2.9144 0.3983 0.86477 1.5766 0.91664 2.3401 0.55213 0.4068-0.16553 0.8256-0.49949 0.6616-0.99063-0.082-0.71322-0.089-1.4375-0.08-2.1551z"/>
|
||||
<path d="m-2510.5-571.74c-0.3089 1.7209-0.065 3.5936-0.951 5.1716-0.2379 0.48456-0.6503 0.8476-1.1523 1.0399-1.2351 0.52484-2.7013 0.49722-3.915-0.0756-0.2566-0.46006-0.08-1.2164 0.582-1.0383 0.7552 0.0602 1.5125 0.42319 2.2691 0.18519 1.0202-0.20188 1.6183-1.2671 1.6977-2.2374 0.022-0.27324-0.01-0.94901-0.2136-0.93026-0.6468 0.90767-1.9707 1.0068-2.9064 0.52172-0.7507-0.34336-1.2755-1.0692-1.414-1.8769-0.3954-1.8524 0.632-3.9893 2.4517-4.642 1.0211-0.32659 2.1925-0.25087 3.1384 0.26587 0.5576 0.29287 0.9341 0.92545 0.8125 1.5612-0.068 0.69708-0.3593 1.3534-0.3991 2.0548zm-2.0403-2.9888c-1.1195-0.15393-2.1374 0.68405-2.5193 1.6896-0.3037 0.75721-0.3717 1.6748 0.03 2.4073 0.3353 0.54377 1.0446 0.82935 1.6492 0.58276 0.9398-0.35299 1.5684-1.2979 1.6896-2.2747 0.1379-0.6666 0.3574-1.4067 0.05-2.0577-0.1732-0.31946-0.5679-0.39385-0.8993-0.3473z"/>
|
||||
<path d="m-2504.1-573.77c-0.3876-2.4091-4.4757 0.62756-1.3652 0.92784 2.6224 0.90121 1.7346 4.9148-0.9851 4.7134-1.2495 0.50199-3.7735-1.8039-1.5043-1.8019 1.1893 0.86998 3.8843-0.32005 1.8423-1.4348-2.0484-0.34111-3.7316-2.7402-1.4066-4.0613 1.3001-1.1931 5.2979-0.75714 3.679 1.5424-0.087 0.0381-0.1734 0.0762-0.2601 0.11431z"/>
|
||||
</g>
|
||||
<path d="m-344.74-25.124c-6.1284-1.0979-18.576-2.7472-24.699-3.8311l-5.0054 33.646c6.2917 1.434 18.208 4.5136 24.398 5.9506 3.6411-11.279 6.6229-23.84 5.3066-35.766z" fill="#2d6236" style="paint-order:normal"/>
|
||||
<g transform="matrix(.9297 .16991 -.16991 .9297 1916.8 958.68)" fill="#dadada" stroke-width="1px" aria-label="34">
|
||||
<path d="m-2560.2-569.43c-1.2856 0.0748-3.2487-0.8227-2.9857-2.3245 0.8676-0.90346 1.4175 0.67501 2.1254 0.91811 1.1413 0.45829 2.7852-0.26374 2.7344-1.6257-0.01-1.4734-1.5962-2.1621-2.8331-2.1868-1.0993-1.0876 0.8582-1.4069 1.6354-1.5299 1.404-0.17239 1.3825-2.2613 0-2.4456-1.1389-0.51399-1.9641 0.71013-2.9866 0.70744-1.1306-0.91929 0.7004-1.8515 1.5546-1.9774 1.4588-0.34957 3.3826 0.3203 3.6813 1.9475 0.3366 1.1155-0.4205 2.2702-1.2292 2.7692 0.9537 0.52993 1.6163 1.529 1.5264 2.6612-0.042 1.69-1.5045 3.159-3.2189 3.0864z"/>
|
||||
<path d="m-2549.4-572.67c-0.01 0.87671 0.012 1.7544-0.01 2.6305-0.043 0.61406-1.0298 0.63582-1.2131 0.0978-0.1968-0.54036-0.036-1.1185-0.076-1.6787 0-0.36104 0-0.72207-0.01-1.0831-1.3196-0.0367-2.6399-0.059-3.959-0.10245-0.4686 0.0444-1.1513-0.30426-0.7961-0.85094 0.2984-0.55059 0.7995-0.9545 1.1182-1.4929 1.0334-1.5028 2.0571-3.0125 3.0965-4.511 0.3567-0.4952 1.1247-0.74148 1.6532-0.38429 0.2915 0.28649 0.1595 0.72501 0.1897 1.09v4.9827c0.4855-4e-3 1.0967-0.0553 1.4334 0.36688 0.2552 0.41477-0.1168 0.94035-0.5812 0.92651-0.2807 0.0266-0.5782 0.0165-0.849 9e-3zm-1.3025-5.6799c-0.909 1.4433-1.8196 2.8869-2.7997 4.2834 0.9332 0.0224 1.8665 0.0447 2.7997 0.0671v-4.3506z"/>
|
||||
</g>
|
||||
<g transform="matrix(.1976 .036114 -.036114 .1976 -325.27 -67.285)" fill="#4aa158" aria-label="+">
|
||||
<path d="m-493.08 314.34c-0.0489 3.7279-4.7678 3.8996-7.4987 3.3367-3.6053-1.4027-7.9235 0.96777-6.2932 5.2004 1.0798 3.1434-0.60545 8.8662-4.8234 6.7976-3.6452-2.0855-1.24-6.7037-1.8488-9.936-2.0642-4.0613-7.6116-0.089-10.84-2.4827-4.1787-2.5752 1.0937-8.1378 4.6872-6.1325 3.6391 1.3653 7.8794-1.1274 5.9251-5.2444-0.89217-3.2023 1.9875-8.7744 5.6881-6.1442 3.634 2.6914-0.77449 7.4233 1.9769 10.557 3.3424 2.2417 7.9403-1.1136 11.393 1.362 0.90882 0.59128 1.7649 1.5165 1.6334 2.6864z" fill="#4aa158" stroke-width="5.679px"/>
|
||||
</g>
|
||||
<g transform="rotate(19.399 -638.5 398.75)">
|
||||
<path d="m-501.72-53.894c-28.095-5.0334-64.166-11.986-93.984-12.779-23.849-0.63414-29.364 21.767-4.9134 26.884 29.746 6.2259 66.643 14.139 94.252 20.549 3.6411-11.279 5.9617-22.729 4.6453-34.655zm-101.21-6.9112c3.96 0.72376 6.6257 4.2896 5.9541 7.9647-0.67209 3.675-4.4264 6.0676-8.3864 5.3438-3.96-0.72375-6.6256-4.2897-5.954-7.9647 0.67208-3.6749 4.4263-6.0675 8.3863-5.3438z" fill="#193f47" style="paint-order:normal"/>
|
||||
<path d="m-495.38-53.362c-6.1284-1.0979-18.576-2.7472-24.699-3.8311l-5.0054 33.646c6.2917 1.434 18.208 4.5136 24.398 5.9506 3.6411-11.279 6.6229-23.84 5.3066-35.766z" fill="#2b6a78" style="paint-order:normal"/>
|
||||
<g transform="matrix(.9297 .16991 -.16991 .9297 1766.1 930.44)" fill="#dadada" aria-label="34">
|
||||
<path d="m-2563.1-573.66c-0.01-7.3938 10.433-3.4839 10.55-0.48341 0.1174 3.0005 10.91 8.4875 10.781 0.41697-0.1286-8.0705-10.744-3.18-10.781-0.41697-0.037 2.763-10.543 7.8772-10.55 0.48341z" fill="none" stroke="#dadada" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.7519" style="paint-order:normal"/>
|
||||
</g>
|
||||
<g transform="matrix(.1976 .036114 -.036114 .1976 -475.92 -95.524)" fill="#4aa158" aria-label="+">
|
||||
<path d="m-493.08 314.34c-0.0489 3.7279-4.7678 3.8996-7.4987 3.3367-3.6053-1.4027-7.9235 0.96777-6.2932 5.2004 1.0798 3.1434-0.60545 8.8662-4.8234 6.7976-3.6452-2.0855-1.24-6.7037-1.8488-9.936-2.0642-4.0613-7.6116-0.089-10.84-2.4827-4.1787-2.5752 1.0937-8.1378 4.6872-6.1325 3.6391 1.3653 7.8794-1.1274 5.9251-5.2444-0.89217-3.2023 1.9875-8.7744 5.6881-6.1442 3.634 2.6914-0.77449 7.4233 1.9769 10.557 3.3424 2.2417 7.9403-1.1136 11.393 1.362 0.90882 0.59128 1.7649 1.5165 1.6334 2.6864z" fill="#49aabf" stroke-width="5.679px"/>
|
||||
</g>
|
||||
<g transform="matrix(1.317 .30927 -.30927 1.317 -47.46 164.76)" fill="#49aabf" stroke-width="1px" aria-label="a lot">
|
||||
<path d="m-410.25-61.811c-0.65817-0.47252-1.0234-1.0261-1.793-0.36003-1.3776 0.68242-3.4363 0.10802-3.8268-1.5048-0.51946-1.6866-0.15989-3.743 1.3355-4.8252 1.2108-0.99587 3.1869-1.0464 4.3304 0.07008 0.12212 0.99307-0.30691 2.1479-0.15335 3.2374-0.15933 1.0779 0.64504 2.0133 0.68458 3.0099-0.0987 0.22178-0.3329 0.37805-0.57739 0.37262zm-1.3965-4.6661c0.0861-0.77055 0.34961-1.8109-0.8197-1.5846-1.6279 0.1933-2.5964 2.0219-2.2408 3.5415 0.0244 1.2019 1.3521 1.7652 2.3601 1.3216 1.4197-0.319 0.6606-1.7655 0.71377-2.7795-8e-3 -0.16619-0.0133-0.33256-0.0135-0.49893z"/>
|
||||
<path d="m-402.64-67.531c-0.0644 1.6837-0.0716 3.3716-0.17936 5.0518-0.47397 1.2143-1.738 0.02501-1.2806-0.89431 0.14762-3.0543 0.1549-6.1126 0.2148-9.1689 0.66106-1.3376 1.7124 0.20846 1.3059 1.1447-0.0163 1.289-0.0639 2.5776-0.0608 3.8668z"/>
|
||||
<path d="m-398.07-61.858c-1.7276 0.04356-2.9503-1.7082-2.8937-3.3234-0.11892-1.8565 1.1557-3.9955 3.1769-4.0297 1.3201-0.17846 2.3745 0.95782 2.6137 2.1699 0.44919 1.7744 0.0145 4.0808-1.764 4.9545-0.35479 0.15908-0.7452 0.2295-1.1329 0.22874zm0.40283-5.9955c-1.5353 0.0069-2.1416 1.8524-1.9407 3.132 0.0593 1.1077 1.3552 1.9156 2.3346 1.3079 1.2388-0.72072 1.16-2.4186 0.78696-3.6124-0.17409-0.49295-0.64956-0.85296-1.1809-0.82754z"/>
|
||||
<path d="m-388.87-67.974c-0.57242 0.04133-1.8431-0.29375-1.5597 0.63682 0.0222 1.5617 0.16373 3.1261 0.0895 4.686 0.0229 1.2973-1.7893 0.83962-1.3244-0.3101 0.0639-1.642-0.0722-3.2837-0.10538-4.9255-0.85075 0.10945-2.7999-0.18163-1.8098-1.3541 0.55725-0.22568 1.1868 0.01233 1.7762 0.0046 0.098-0.77503-0.5398-2.4403 0.75408-2.3814 0.75002 0.33896 0.55684 1.3355 0.56751 2.021-0.0144 0.82426 1.4999-0.16274 2.0397 0.4032 0.46304 0.37105 0.20127 1.2582-0.42781 1.2194z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 800 B After Width: | Height: | Size: 700 B |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.4 KiB |
BIN
static/icons/derpibooru/icon128.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
static/icons/derpibooru/icon16.png
Normal file
|
After Width: | Height: | Size: 686 B |
BIN
static/icons/derpibooru/icon256.png
Normal file
|
After Width: | Height: | Size: 14 KiB |