First draft version with Svelte for popup and additional build steps
12
.gitignore
vendored
@@ -1 +1,11 @@
|
||||
.idea
|
||||
.idea
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
18
jsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
14
manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Furbooru Tagging Assistant (Rewrite)",
|
||||
"version": "0.0.1",
|
||||
"manifest_version": 3,
|
||||
"host_permissions": [
|
||||
"*://*.furbooru.org/"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "index.html"
|
||||
},
|
||||
"permissions": [
|
||||
"storage"
|
||||
]
|
||||
}
|
||||
3544
package-lock.json
generated
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "furbooru-tagging-assistant",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "npm run build:popup && npm run build:extension",
|
||||
"build:popup": "vite build",
|
||||
"build:extension": "vite build --config vite.config.extension.js",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@types/chrome": "^0.0.262",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"sass": "^1.71.0",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte-check": "^3.6.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.3"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
13
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
src/app.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
19
src/components/layout/Footer.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script>
|
||||
import {version} from "$app/environment";
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
v{version}, made with ♥ by KoloMl.
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'src/styles/colors';
|
||||
|
||||
footer {
|
||||
background: colors.$footer;
|
||||
color: colors.$footer-text;
|
||||
padding: 0 24px;
|
||||
font-size: 12px;
|
||||
line-height: 36px;
|
||||
}
|
||||
</style>
|
||||
28
src/components/layout/Header.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<header>
|
||||
<a href="/">Furbooru Tagging Assistant</a>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
@use "src/styles/colors";
|
||||
|
||||
header {
|
||||
background: colors.$header;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
|
||||
a {
|
||||
color: colors.$text;
|
||||
line-height: 36px;
|
||||
padding: 0 12px;
|
||||
margin-left: -12px;
|
||||
|
||||
&:hover {
|
||||
background: colors.$header-hover-background;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
11
src/components/ui/forms/FormContainer.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<form>
|
||||
<slot></slot>
|
||||
</form>
|
||||
|
||||
<style lang="scss">
|
||||
form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
16
src/components/ui/forms/FormControl.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let label;
|
||||
</script>
|
||||
|
||||
<label class="control">
|
||||
{#if label}
|
||||
<div class="label">{label}</div>
|
||||
{/if}
|
||||
<slot></slot>
|
||||
</label>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
</style>
|
||||
12
src/components/ui/forms/TextField.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script>
|
||||
/** @type {string|undefined} */
|
||||
export let name = undefined;
|
||||
|
||||
/** @type {string|undefined} */
|
||||
export let placeholder = undefined;
|
||||
|
||||
/** @type {string} */
|
||||
export let value = '';
|
||||
</script>
|
||||
|
||||
<input type="text" {name} {placeholder} bind:value={value}>
|
||||
38
src/components/ui/menu/Menu.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<nav>
|
||||
<slot></slot>
|
||||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'src/styles/colors';
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > :global(a) {
|
||||
padding: 5px 24px;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: colors.$text;
|
||||
|
||||
&:hover {
|
||||
background: colors.$header-mobile-link-hover;
|
||||
}
|
||||
}
|
||||
|
||||
:global(hr) {
|
||||
background: colors.$block-border;
|
||||
margin: .5em 24px;
|
||||
border: 0;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
:global(main) > & {
|
||||
margin: {
|
||||
left: -24px;
|
||||
right: -24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
41
src/components/ui/menu/MenuLink.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script>
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
export let href;
|
||||
|
||||
/**
|
||||
* @type {"tag"|"paint-brush"|"arrow-left"|"info-circle"|"wrench"|"globe"|"plus"|null}
|
||||
*/
|
||||
export let icon = null;
|
||||
|
||||
/**
|
||||
* @type {"_blank"|"_self"|"_parent"|"_top"|undefined}
|
||||
*/
|
||||
export let target = undefined;
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a {href} {target} on:click>
|
||||
{#if icon}
|
||||
<i class="icon icon-{icon}"></i>
|
||||
{/if}
|
||||
<slot></slot>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../../styles/colors';
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: colors.$text;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
src/components/web-components/TagsEditor.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import "$lib/web-components/TagEditorComponent.js";
|
||||
|
||||
/**
|
||||
* @type {string[]}
|
||||
*/
|
||||
export let tags = [];
|
||||
|
||||
let tagsAttribute = tags.join(',');
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<string[]>} event
|
||||
*/
|
||||
function onTagsChanged(event) {
|
||||
tags = event.detail;
|
||||
}
|
||||
|
||||
$: tagsAttribute = tags.join(',');
|
||||
</script>
|
||||
|
||||
<tags-editor tags="{tagsAttribute}" on:change={onTagsChanged}></tags-editor>
|
||||
3
src/content/listing.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import "$styles/content/listing.scss";
|
||||
|
||||
import "$lib/web-components/TagEditorComponent.js";
|
||||
8
src/hooks.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('@sveltejs/kit').Reroute} */
|
||||
export function reroute({url}) {
|
||||
// Reroute index.html as just / for the root.
|
||||
// Browser extension starts from with the index.html file in the pathname which is not correct for the router.
|
||||
if (url.pathname === '/index.html') {
|
||||
return "/";
|
||||
}
|
||||
}
|
||||
36
src/lib/booru/parsing/ImagePageParser.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import PageParser from "$lib/booru/parsing/PageParser.js";
|
||||
|
||||
export default class ImagePageParser extends PageParser {
|
||||
/** @type {HTMLFormElement} */
|
||||
#tagEditorForm;
|
||||
|
||||
constructor(imageId) {
|
||||
super(`/images/${imageId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<HTMLFormElement>}
|
||||
*/
|
||||
async resolveTagEditorForm() {
|
||||
if (this.#tagEditorForm) {
|
||||
return this.#tagEditorForm;
|
||||
}
|
||||
|
||||
const documentFragment = await this.resolveFragment();
|
||||
const tagsFormElement = documentFragment.querySelector("#tags-form");
|
||||
|
||||
if (!tagsFormElement) {
|
||||
throw new Error("Failed to find the tag editor form");
|
||||
}
|
||||
|
||||
this.#tagEditorForm = tagsFormElement;
|
||||
|
||||
return tagsFormElement;
|
||||
}
|
||||
|
||||
async resolveTagEditorFormData() {
|
||||
return new FormData(
|
||||
await this.resolveTagEditorForm()
|
||||
);
|
||||
}
|
||||
}
|
||||
40
src/lib/booru/parsing/PageParser.js
Normal file
@@ -0,0 +1,40 @@
|
||||
export default class PageParser {
|
||||
/** @type {string} */
|
||||
#url;
|
||||
/** @type {DocumentFragment|null} */
|
||||
#fragment;
|
||||
|
||||
constructor(url) {
|
||||
this.#url = url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<DocumentFragment>}
|
||||
*/
|
||||
async resolveFragment() {
|
||||
if (this.#fragment) {
|
||||
return this.#fragment;
|
||||
}
|
||||
|
||||
const response = await fetch(this.#url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page from ${this.#url}`);
|
||||
}
|
||||
|
||||
const documentFragment = document.createDocumentFragment();
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = await response.text();
|
||||
|
||||
documentFragment.append(...template.content.childNodes);
|
||||
|
||||
this.#fragment = documentFragment;
|
||||
|
||||
return documentFragment;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#fragment = null;
|
||||
}
|
||||
}
|
||||
|
||||
57
src/lib/chrome/StorageHelper.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Helper class to read and write JSON objects to the local storage.
|
||||
* @class
|
||||
*/
|
||||
class StorageHelper {
|
||||
/**
|
||||
* @type {import('@types/chrome').storage.StorageArea}
|
||||
*/
|
||||
#storageArea;
|
||||
|
||||
/**
|
||||
* @param {import('@types/chrome').storage.StorageArea} storageArea
|
||||
*/
|
||||
constructor(storageArea) {
|
||||
this.#storageArea = storageArea;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the following entry from the local storage as a JSON object.
|
||||
*
|
||||
* @param {string} key Key of the entry to read.
|
||||
* @param {any} defaultValue Default value to return if the entry does not exist.
|
||||
*
|
||||
* @return {Promise<any>} The JSON object or the default value if the entry does not exist.
|
||||
*/
|
||||
async read(key, defaultValue = null) {
|
||||
return (await this.#storageArea.get(key))?.[key] || defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the following JSON object to the local storage.
|
||||
*
|
||||
* @param {string} key Key of the entry to write.
|
||||
* @param {any} value JSON object to write.
|
||||
*/
|
||||
write(key, value) {
|
||||
void this.#storageArea.set({[key]: value});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to changes in the local storage.
|
||||
* @param {function(Record<string, StorageChange>): void} callback
|
||||
*/
|
||||
subscribe(callback) {
|
||||
this.#storageArea.onChanged.addListener(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from changes in the local storage.
|
||||
* @param {function(Record<string, StorageChange>): void} callback
|
||||
*/
|
||||
unsubscribe(callback) {
|
||||
this.#storageArea.onChanged.removeListener(callback);
|
||||
}
|
||||
}
|
||||
|
||||
export default StorageHelper;
|
||||
93
src/lib/extension/EntitiesController.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import StorageHelper from "$lib/chrome/StorageHelper.js";
|
||||
|
||||
export default class EntitiesController {
|
||||
static #storageHelper = new StorageHelper(chrome.storage.local);
|
||||
|
||||
/**
|
||||
* Read all entities of the given type from the storage. Build the entities from the raw data and return them.
|
||||
*
|
||||
* @template EntityClass
|
||||
*
|
||||
* @param {string} entityName Name of the entity to read.
|
||||
* @param {EntityClass} entityClass Class of the entity to read. Must have a constructor that accepts the ID and the
|
||||
* settings object.
|
||||
*
|
||||
* @return {Promise<InstanceType<EntityClass>[]>} List of entities of the given type.
|
||||
*/
|
||||
static async readAllEntities(entityName, entityClass) {
|
||||
const rawEntities = await this.#storageHelper.read(entityName, {});
|
||||
|
||||
if (!rawEntities || Object.keys(rawEntities).length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object
|
||||
.entries(rawEntities)
|
||||
.map(([id, settings]) => new entityClass(id, settings));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the single entity in the storage. If the entity with the given ID already exists, it will be overwritten.
|
||||
*
|
||||
* @param {string} entityName Name of the entity to update.
|
||||
* @param {StorageEntity} entity Entity to update.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
static async updateEntity(entityName, entity) {
|
||||
await this.#storageHelper.write(
|
||||
entityName,
|
||||
Object.assign(
|
||||
await this.#storageHelper.read(
|
||||
entityName, {}
|
||||
),
|
||||
{
|
||||
[entity.id]: entity.settings
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entity with the given ID.
|
||||
*
|
||||
* @param {string} entityName Name of the entity to delete.
|
||||
* @param {string} entityId ID of the entity to delete.
|
||||
*
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
static async deleteEntity(entityName, entityId) {
|
||||
const entities = await this.#storageHelper.read(entityName, {});
|
||||
delete entities[entityId];
|
||||
await this.#storageHelper.write(entityName, entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to all changes made to the storage.
|
||||
*
|
||||
* @template EntityClass
|
||||
*
|
||||
* @param {string} entityName Name of the entity to subscribe to.
|
||||
* @param {EntityClass} entityClass Class of the entity to subscribe to.
|
||||
* @param {function(InstanceType<EntityClass>[]): any} callback Callback to call when the storage changes.
|
||||
* @return {function(): void} Unsubscribe function.
|
||||
*/
|
||||
static subscribeToEntity(entityName, entityClass, callback) {
|
||||
/**
|
||||
* Watch the changes made to the storage and call the callback when the entity changes.
|
||||
* @param {Object<string, StorageChange>} changes Changes made to the storage.
|
||||
*/
|
||||
const storageChangesSubscriber = changes => {
|
||||
if (!changes[entityName]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readAllEntities(entityName, entityClass)
|
||||
.then(callback);
|
||||
}
|
||||
|
||||
this.#storageHelper.subscribe(storageChangesSubscriber);
|
||||
|
||||
return () => this.#storageHelper.unsubscribe(storageChangesSubscriber);
|
||||
}
|
||||
}
|
||||
56
src/lib/extension/base/StorageEntity.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import EntitiesController from "$lib/extension/EntitiesController.js";
|
||||
|
||||
class StorageEntity {
|
||||
/**
|
||||
* @type {string}
|
||||
*/
|
||||
#id;
|
||||
|
||||
/**
|
||||
* @type {Object}
|
||||
*/
|
||||
#settings;
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {Object} settings
|
||||
*/
|
||||
constructor(id, settings = {}) {
|
||||
this.#id = id;
|
||||
this.#settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
get id() {
|
||||
return this.#id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Object}
|
||||
*/
|
||||
get settings() {
|
||||
return this.#settings;
|
||||
}
|
||||
|
||||
static _entityName = "entity";
|
||||
|
||||
async save() {
|
||||
await EntitiesController.updateEntity(this.constructor._entityName, this);
|
||||
}
|
||||
|
||||
async delete() {
|
||||
await EntitiesController.deleteEntity(this.constructor._entityName, this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Static function to read all entities of this type from the storage. Must be implemented in the child class.
|
||||
* @return {Promise<array>}
|
||||
*/
|
||||
static async readAll() {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
}
|
||||
|
||||
export default StorageEntity;
|
||||
63
src/lib/extension/entities/MaintenanceProfile.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import StorageEntity from "$lib/extension/base/StorageEntity.js";
|
||||
import EntitiesController from "$lib/extension/EntitiesController.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} MaintenanceProfileSettings
|
||||
* @property {string} name
|
||||
* @property {string[]} tags
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class representing the maintenance profile entity.
|
||||
*/
|
||||
class MaintenanceProfile extends StorageEntity {
|
||||
/**
|
||||
* @param {string} id ID of the entity.
|
||||
* @param {Partial<MaintenanceProfileSettings>} settings Maintenance profile settings object.
|
||||
*/
|
||||
constructor(id, settings) {
|
||||
super(id, {
|
||||
name: settings.name || '',
|
||||
tags: settings.tags || []
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {MaintenanceProfileSettings}
|
||||
*/
|
||||
get settings() {
|
||||
return super.settings;
|
||||
}
|
||||
|
||||
static _entityName = "profiles";
|
||||
|
||||
/**
|
||||
* Read all maintenance profiles from the storage.
|
||||
*
|
||||
* @return {Promise<InstanceType<MaintenanceProfile>[]>}
|
||||
*/
|
||||
static async readAll() {
|
||||
return await EntitiesController.readAllEntities(
|
||||
this._entityName,
|
||||
MaintenanceProfile
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the changes and receive the new list of profiles when they change.
|
||||
*
|
||||
* @param {function(MaintenanceProfile[]): void} callback Callback to call when the profiles change. The new list of
|
||||
* profiles is passed as an argument.
|
||||
*
|
||||
* @return {function(): void} Unsubscribe function.
|
||||
*/
|
||||
static subscribe(callback) {
|
||||
return EntitiesController.subscribeToEntity(
|
||||
this._entityName,
|
||||
MaintenanceProfile,
|
||||
callback
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MaintenanceProfile;
|
||||
184
src/lib/web-components/TagEditorComponent.js
Normal file
@@ -0,0 +1,184 @@
|
||||
export default class TagEditorComponent extends HTMLElement {
|
||||
/**
|
||||
* Array of elements representing tags.
|
||||
* @type {HTMLElement[]}
|
||||
*/
|
||||
#tagElements = [];
|
||||
|
||||
/**
|
||||
* Generated input for adding new tags to the tag list. Will be rendered on connecting.
|
||||
* @type {HTMLInputElement|undefined}
|
||||
*/
|
||||
#tagInput;
|
||||
|
||||
/**
|
||||
* Cached list of tag names. Changing this value will not automatically change the actual tags.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
#tagsSet = new Set();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (!this.#tagInput) {
|
||||
this.#tagInput = document.createElement('input');
|
||||
this.appendChild(this.#tagInput);
|
||||
this.#tagInput.addEventListener('keydown', this.#onKeyDownDetectActions.bind(this));
|
||||
}
|
||||
|
||||
if (!this.#tagElements.length) {
|
||||
this.#renderTags();
|
||||
}
|
||||
|
||||
this.addEventListener('click', this.#onClickDetectTagRemoval.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the list of tag elements based on the tag attribute. Should be called every time tag attribute is changed.
|
||||
*/
|
||||
#renderTags() {
|
||||
const tags = this.getAttribute(TagEditorComponent.#tagsAttribute) || '';
|
||||
|
||||
const updatedTagsSet = new Set(
|
||||
tags.split(',')
|
||||
.map(tagName => tagName.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
this.#tagsSet = new Set(updatedTagsSet.values());
|
||||
|
||||
this.#tagElements = this.#tagElements.filter(tagElement => {
|
||||
const tagName = tagElement.dataset.tag;
|
||||
|
||||
if (!updatedTagsSet.has(tagName)) {
|
||||
tagElement.remove();
|
||||
return false;
|
||||
}
|
||||
|
||||
updatedTagsSet.delete(tagName);
|
||||
return true;
|
||||
});
|
||||
|
||||
for (let tagName of updatedTagsSet) {
|
||||
const tagElement = document.createElement('div');
|
||||
tagElement.classList.add('tag');
|
||||
tagElement.innerText = tagName;
|
||||
tagElement.dataset.tag = tagName;
|
||||
|
||||
const tagRemoveElement = document.createElement("span");
|
||||
tagRemoveElement.classList.add('remove');
|
||||
tagRemoveElement.innerText = 'x';
|
||||
|
||||
tagElement.appendChild(tagRemoveElement);
|
||||
|
||||
this.#tagInput.insertAdjacentElement('beforebegin', tagElement);
|
||||
this.#tagElements.push(tagElement);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect add/remove keyboard shortcuts on the input.
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
#onKeyDownDetectActions(event) {
|
||||
const isTagSubmit = event.key === 'Enter';
|
||||
const isTagRemove = event.key === 'Backspace' && !this.#tagInput.value.length;
|
||||
|
||||
if (!isTagSubmit && !isTagRemove) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTagSubmit) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const providedTagName = this.#tagInput.value.trim();
|
||||
|
||||
if (providedTagName && isTagSubmit) {
|
||||
if (!this.#tagsSet.has(providedTagName)) {
|
||||
this.setAttribute(
|
||||
TagEditorComponent.#tagsAttribute,
|
||||
[...this.#tagsSet, providedTagName].join(',')
|
||||
);
|
||||
}
|
||||
|
||||
this.#tagInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTagRemove && this.#tagsSet.size) {
|
||||
this.setAttribute(
|
||||
TagEditorComponent.#tagsAttribute,
|
||||
[...this.#tagsSet].slice(0, -1).join(',')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect clicks on the "remove" button inside tags.
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
#onClickDetectTagRemoval(event) {
|
||||
/** @type {HTMLElement} */
|
||||
const maybeRemoveTagElement = event.target;
|
||||
|
||||
if (!maybeRemoveTagElement.classList.contains('remove')) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {HTMLElement} */
|
||||
const tagElement = maybeRemoveTagElement.closest('.tag');
|
||||
|
||||
if (!tagElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tagName = tagElement.dataset.tag;
|
||||
|
||||
if (this.#tagsSet.has(tagName)) {
|
||||
this.#tagsSet.delete(tagName);
|
||||
this.setAttribute(
|
||||
TagEditorComponent.#tagsAttribute,
|
||||
[...this.#tagsSet].join(",")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} from
|
||||
* @param {string} to
|
||||
*/
|
||||
attributeChangedCallback(name, from, to) {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === TagEditorComponent.#tagsAttribute) {
|
||||
this.#renderTags();
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent(
|
||||
'change',
|
||||
{
|
||||
detail: [...this.#tagsSet.values()]
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return [this.#tagsAttribute];
|
||||
}
|
||||
|
||||
static #tagsAttribute = 'tags';
|
||||
}
|
||||
|
||||
if (!customElements.get('tags-editor')) {
|
||||
customElements.define('tags-editor', TagEditorComponent);
|
||||
} else {
|
||||
console.warn('Tags Component is attempting to initialize twice!');
|
||||
}
|
||||
1
src/routes/+layout.server.js
Normal file
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
17
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script>
|
||||
import "../styles/popup.scss";
|
||||
import Header from "$components/layout/Header.svelte";
|
||||
import Footer from "$components/layout/Footer.svelte";
|
||||
</script>
|
||||
|
||||
<Header/>
|
||||
<main>
|
||||
<slot/>
|
||||
</main>
|
||||
<Footer/>
|
||||
|
||||
<style lang="scss" global>
|
||||
main {
|
||||
padding: .5em 24px;
|
||||
}
|
||||
</style>
|
||||
1
src/routes/+page.server.js
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
10
src/routes/+page.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink href="/settings/maintenance">Manual Tags Maintenance</MenuLink>
|
||||
<hr>
|
||||
<MenuLink href="/about">About</MenuLink>
|
||||
</Menu>
|
||||
25
src/routes/about/+page.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink icon="arrow-left" href="/">Back</MenuLink>
|
||||
<hr>
|
||||
</Menu>
|
||||
<h1>
|
||||
Furbooru Tagging Assistant
|
||||
</h1>
|
||||
<p>
|
||||
This is a tool made to help tag images on Furbooru more efficiently. It is currently in development and is not yet
|
||||
ready for use, but it still can provide some useful functionality.
|
||||
</p>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuLink icon="globe" href="https://furbooru.org" target="_blank">
|
||||
Visit Furbooru
|
||||
</MenuLink>
|
||||
<MenuLink icon="info-circle" href="https://github.com/koloml/furbooru-tagging-assistant" target="_blank">
|
||||
GitHub Repo
|
||||
</MenuLink>
|
||||
</Menu>
|
||||
10
src/routes/settings/+page.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink href="/">Back</MenuLink>
|
||||
<hr>
|
||||
<MenuLink href="/settings/maintenance">Maintenance</MenuLink>
|
||||
</Menu>
|
||||
28
src/routes/settings/maintenance/+page.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
import {onDestroy} from "svelte";
|
||||
|
||||
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default[]} */
|
||||
let profiles = [];
|
||||
|
||||
const unsubscribeFromProfiles = maintenanceProfilesStore.subscribe(updatedProfiles => {
|
||||
profiles = updatedProfiles.sort(
|
||||
(a, b) => b.settings.name.localeCompare(a.settings.name)
|
||||
);
|
||||
});
|
||||
|
||||
onDestroy(unsubscribeFromProfiles);
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink icon="arrow-left" href="/">Back</MenuLink>
|
||||
<MenuLink icon="plus" href="/settings/maintenance/new/edit">Create New</MenuLink>
|
||||
{#if profiles.length}
|
||||
<hr>
|
||||
{/if}
|
||||
{#each profiles as profile}
|
||||
<MenuLink href="/settings/maintenance/{profile.id}">{profile.settings.name}</MenuLink>
|
||||
{/each}
|
||||
</Menu>
|
||||
61
src/routes/settings/maintenance/[id]/+page.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
import {page} from "$app/stores";
|
||||
import {goto} from "$app/navigation";
|
||||
|
||||
import {onDestroy} from "svelte";
|
||||
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
|
||||
const profileId = $page.params.id;
|
||||
/** @type {import('$lib/extension/entities/MaintenanceProfile.js').default|null} */
|
||||
let profile = null;
|
||||
|
||||
if (profileId === 'new') {
|
||||
goto('/maintenance/profiles/new/edit');
|
||||
}
|
||||
|
||||
const unsubscribeFromProfiles = maintenanceProfilesStore.subscribe(profiles => {
|
||||
const resolvedProfile = profiles.find(p => p.id === profileId);
|
||||
|
||||
if (resolvedProfile) {
|
||||
profile = resolvedProfile;
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`Profile ${profileId} not found.`);
|
||||
goto('/settings/maintenance');
|
||||
});
|
||||
|
||||
onDestroy(unsubscribeFromProfiles);
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink href="/settings/maintenance" icon="arrow-left">Back</MenuLink>
|
||||
<hr>
|
||||
</Menu>
|
||||
{#if profile}
|
||||
<div>
|
||||
<strong>Profile:</strong><br>
|
||||
{profile.settings.name}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Focused Tags:</strong>
|
||||
<div class="tags-list">
|
||||
{#each profile.settings.tags as tagName}
|
||||
<span class="tag">{tagName}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuLink icon="wrench" href="/settings/maintenance/{profileId}/edit">Edit Profile</MenuLink>
|
||||
</Menu>
|
||||
<style>
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
80
src/routes/settings/maintenance/[id]/edit/+page.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script>
|
||||
import Menu from "$components/ui/menu/Menu.svelte";
|
||||
import MenuLink from "$components/ui/menu/MenuLink.svelte";
|
||||
import TagsEditor from "$components/web-components/TagsEditor.svelte";
|
||||
import FormControl from "$components/ui/forms/FormControl.svelte";
|
||||
import TextField from "$components/ui/forms/TextField.svelte";
|
||||
import FormContainer from "$components/ui/forms/FormContainer.svelte";
|
||||
import {page} from "$app/stores";
|
||||
import {goto} from "$app/navigation";
|
||||
import {maintenanceProfilesStore} from "$stores/maintenance-profiles-store.js";
|
||||
import MaintenanceProfile from "$entities/MaintenanceProfile.js";
|
||||
import {onDestroy} from "svelte";
|
||||
|
||||
/** @type {string} */
|
||||
let profileId = $page.params.id;
|
||||
/** @type {MaintenanceProfile|null} */
|
||||
let targetProfile = null;
|
||||
|
||||
/** @type {string} */
|
||||
let profileName = '';
|
||||
/** @type {string[]} */
|
||||
let tagsList = [];
|
||||
|
||||
const unsubscribeFromProfiles = maintenanceProfilesStore.subscribe(profiles => {
|
||||
if (profileId === 'new') {
|
||||
targetProfile = new MaintenanceProfile(crypto.randomUUID(), {});
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeProfile = profiles.find(p => p.id === profileId);
|
||||
|
||||
if (!maybeProfile) {
|
||||
goto('/settings/maintenance');
|
||||
return;
|
||||
}
|
||||
|
||||
targetProfile = maybeProfile;
|
||||
|
||||
profileName = targetProfile.settings.name;
|
||||
tagsList = [...targetProfile.settings.tags];
|
||||
|
||||
queueMicrotask(() => {
|
||||
unsubscribeFromProfiles();
|
||||
})
|
||||
});
|
||||
|
||||
async function saveProfile() {
|
||||
if (!targetProfile) {
|
||||
console.warn('Attempting to save the profile, but the profile is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
targetProfile.settings.name = profileName;
|
||||
targetProfile.settings.tags = [...tagsList];
|
||||
|
||||
await targetProfile.save();
|
||||
await goto('/settings/maintenance/' + targetProfile.id);
|
||||
}
|
||||
|
||||
onDestroy(unsubscribeFromProfiles);
|
||||
</script>
|
||||
|
||||
<Menu>
|
||||
<MenuLink icon="arrow-left" href="/settings/maintenance{profileId === 'new' ? '' : '/' + profileId}">
|
||||
Back
|
||||
</MenuLink>
|
||||
<hr>
|
||||
</Menu>
|
||||
<FormContainer>
|
||||
<FormControl label="Profile Name">
|
||||
<TextField bind:value={profileName} placeholder="Profile Name"></TextField>
|
||||
</FormControl>
|
||||
<FormControl label="Tags">
|
||||
<TagsEditor bind:tags={tagsList}></TagsEditor>
|
||||
</FormControl>
|
||||
</FormContainer>
|
||||
<Menu>
|
||||
<hr>
|
||||
<MenuLink href="#" on:click={saveProfile}>Save Profile</MenuLink>
|
||||
</Menu>
|
||||
13
src/stores/maintenance-profiles-store.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import {writable} from "svelte/store";
|
||||
import MaintenanceProfile from "$lib/extension/entities/MaintenanceProfile.js";
|
||||
|
||||
/** @type {import('svelte/store').Writable<MaintenanceProfile[]>} */
|
||||
export const maintenanceProfilesStore = writable([]);
|
||||
|
||||
MaintenanceProfile.readAll().then(profiles => {
|
||||
maintenanceProfilesStore.set(profiles);
|
||||
});
|
||||
|
||||
MaintenanceProfile.subscribe(profiles => {
|
||||
maintenanceProfilesStore.set(profiles);
|
||||
});
|
||||
27
src/styles/colors.scss
Normal file
@@ -0,0 +1,27 @@
|
||||
$background: #15121a;
|
||||
|
||||
$text: #dadada;
|
||||
$text-gray: #7c7a7f;
|
||||
|
||||
$link: #9747cc;
|
||||
$link-hover: #dbbfed;
|
||||
|
||||
$header: #36274e;
|
||||
$header-toolbar: #251c36;
|
||||
$header-hover-background: #231933;
|
||||
$header-mobile-link-hover: #785b99;
|
||||
|
||||
$footer: #1d1924;
|
||||
$footer-text: $text-gray;
|
||||
|
||||
$block-header: #3a314e;
|
||||
$block-border: #332941;
|
||||
$block-background: #1d1924;
|
||||
$block-background-alternate: #16131b;
|
||||
|
||||
$tag-background: #1b3c21;
|
||||
$tag-count-background: #2d6236;
|
||||
$tag-text: #4aa158;
|
||||
|
||||
$input-background: #26232d;
|
||||
$input-border: #5c5a61;
|
||||
1
src/styles/content/listing.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import "../injectable/tag";
|
||||
39
src/styles/injectable/icons.scss
Normal file
@@ -0,0 +1,39 @@
|
||||
@mixin insert-icon($icon_src) {
|
||||
mask-image: url($icon_src);
|
||||
-webkit-mask-image: url($icon_src);
|
||||
}
|
||||
|
||||
.icon {
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.icon.icon-tag {
|
||||
@include insert-icon('/img/tag.svg');
|
||||
}
|
||||
|
||||
.icon.icon-paint-brush {
|
||||
@include insert-icon('/img/paint-brush.svg');
|
||||
}
|
||||
|
||||
.icon.icon-arrow-left {
|
||||
@include insert-icon('/img/arrow-left.svg');
|
||||
}
|
||||
|
||||
.icon.icon-info-circle {
|
||||
@include insert-icon('/img/info-circle.svg');
|
||||
}
|
||||
|
||||
.icon.icon-wrench {
|
||||
@include insert-icon('/img/wrench.svg');
|
||||
}
|
||||
|
||||
.icon.icon-globe {
|
||||
@include insert-icon('/img/globe.svg');
|
||||
}
|
||||
|
||||
.icon.icon-plus {
|
||||
@include insert-icon('/img/plus.svg');
|
||||
}
|
||||
11
src/styles/injectable/input.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
@use '../colors';
|
||||
|
||||
input {
|
||||
background: colors.$input-background;
|
||||
border: 1px solid colors.$input-border;
|
||||
color: colors.$text;
|
||||
|
||||
font-family: monospace;
|
||||
padding: 0 6px;
|
||||
line-height: 26px;
|
||||
}
|
||||
17
src/styles/injectable/tag.scss
Normal file
@@ -0,0 +1,17 @@
|
||||
@use '../colors';
|
||||
|
||||
.tag {
|
||||
background: colors.$tag-background;
|
||||
line-height: 28px;
|
||||
color: colors.$tag-text;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
|
||||
.remove {
|
||||
content: "x";
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
12
src/styles/injectable/tags-editor.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
@use '../colors';
|
||||
@import "input";
|
||||
|
||||
tags-editor {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
input {
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
35
src/styles/popup.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
@use './colors';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background-color: colors.$background;
|
||||
color: colors.$text;
|
||||
font-size: 16px;
|
||||
min-width: 320px;
|
||||
max-height: min(100vh, 320px);
|
||||
font-family: verdana, arial, helvetica, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: colors.$link;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover, &:focus {
|
||||
color: colors.$link-hover;
|
||||
}
|
||||
}
|
||||
|
||||
@import "injectable/tags-editor";
|
||||
@import "injectable/tag";
|
||||
@import "injectable/icons";
|
||||
5
static/img/arrow-left.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 -256 1472 1558" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1,0,0,-1,-64,1099)">
|
||||
<path d="m1536 640v-128q0-53-32.5-90.5t-84.5-37.5h-704l293-294q38-36 38-90t-38-90l-75-76q-37-37-90-37-52 0-91 37l-651 652q-37 37-37 90 0 52 37 91l651 650q38 38 91 38 52 0 90-38l75-74q38-38 38-91t-38-91l-293-293h704q52 0 84.5-37.5t32.5-90.5z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 427 B |
5
static/img/globe.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 -256 1536 1536" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1,0,0,-1,0,1152)">
|
||||
<path d="m1193 993q11 7 25 22v-1q0-2-9.5-10t-11.5-12q-1 1-4 1zm-6-1q-1 1-2.5 3t-1.5 3q3-2 10-5-6-4-6-1zm-459 183q-16 2-26 5 1 0 6.5-1t10.5-2 9-2zm45 37q7 4 13.5 2.5t7.5-7.5q-5 3-21 5zm-8-6-3 2q-2 3-5.5 5t-4.5 2q2-1 21-3-6-4-8-6zm-102 84v2q1-2 3-5.5t3-5.5zm-105-40q0-2-1-2l-1 2zm375-1044v-1zm-165 1202q209 0 385.5-103t279.5-279.5 103-385.5-103-385.5-279.5-279.5-385.5-103-385.5 103-279.5 279.5-103 385.5 103 385.5 279.5 279.5 385.5 103zm472-1246 5 5q-7 10-29 12 1 12-14 26.5t-27 15.5q0 4-10.5 11t-17.5 8q-9 2-27-9-7-3-4-5-3 3-12 11t-16 11q-2 1-7.5 1t-8.5 2q-1 1-6 4.5t-7 4.5-6.5 3-7.5 1.5-7.5-2.5-8.5-6-4.5-15.5-2.5-14.5q-8 6-0.5 20t1.5 20q-7 7-21 0.5t-21-15.5q-1-1-9.5-5.5t-11.5-7.5q-4-6-9-17.5t-6-13.5q0 2-2.5 6.5t-2.5 6.5q-12-2-16 3 5-16 8-17l-4 2q-1-6 3-15t4-11q1-5-1.5-13t-2.5-11q0-2 5-11 4-19-2-32 0-1-3.5-7t-6.5-11l-2-5-2 1q-1 1-2 0-1-6-9-13t-10-11q-15-23-9-38 3-8 10-10 3-1 3 2 1-9-11-27 1-1 4-3-17 0-10-14 202 36 352 181h-3zm-560 185q16 3 30.5-16t22.5-23q41-20 59-11 0-9 14-28 3-4 6.5-11.5t5.5-10.5q5-7 19-16t19-16q6 3 9 9 13-35 24-34 5 0 8 8 0-1-0.5-3t-1.5-3q7 15 5 26l6 4q5 4 5 5-6 6-9-3-30-14-48 22-2 3-4.5 8t-5 12-1.5 11.5 6 4.5q11 0 12.5 1.5t-2.5 6-4 7.5q-1 4-1.5 12.5t-1.5 12.5l-5 6q-5 6-11.5 13.5t-7.5 9.5q-4-10-16.5-8.5t-18.5 9.5q1-2-0.5-6.5t-1.5-6.5q-14 0-17 1 1 6 3 21t4 22q1 5 5.5 13.5t8 15.5 4.5 14-4.5 10.5-18.5 2.5q-20-1-29-22-1-3-3-11.5t-5-12.5-9-7q-8-3-27-2t-26 5q-14 8-24 30.5t-11 41.5q0 10 3 27.5t3 27-6 26.5q3 2 10 10.5t11 11.5q2 2 5 2h5t4 2 3 6q-1 1-4 3-3 3-4 3 4-3 19-1t19 2q0 1 22 0 17-13 24 2 0 1-2.5 10.5t-0.5 14.5q5-29 32-10 3-4 16.5-6t18.5-5q3-2 7-5.5t6-5 6-0.5 9 7q11-17 13-25 11-43 20-48 8-2 12.5-2t5 10.5 0 15.5-1.5 13l-2 37q-16 3-20 12.5t1.5 20 16.5 19.5q1 1 16.5 8t21.5 12q24 19 17 39 9-2 11 9l-5 3q-4 3-8 5.5t-5 1.5q11 7 2 18 5 3 8 11.5t9 11.5q9-14 22-3 8 9 2 18 5 8 22 11.5t20 9.5q5-1 7 0t2 4.5v7.5t1 8.5 3 7.5q4 6 16 10.5t14 5.5l19 12q4 4 0 4 18-2 32 11 13 12-5 23 2 7-4 10.5t-16 5.5q3 1 12 0.5t12 1.5q15 11-7 17-20 5-47-13-3-2-13-12t-17-11q15 18 5 22 8-1 22.5 9t15.5 11q4 2 10.5 2.5t8.5 1.5q71 25 92-1 8 11 11 15t9.5 9 15.5 8q21 7 23 9l1 23q-12-1-18 8t-7 22l-6-8q0 6-3.5 7.5t-7.5 0.5-9.5-2-7.5 0q-9 2-19.5 15.5t-14.5 16.5q9 0 9 5-2 5-10 8 1 6-2 8t-9 0q-2 12-1 13-6 1-11 11t-8 10q-2 0-4.5-2t-5-5.5l-5-7t-3.5-5.5l-2-2q-12 6-24-10-9 1-17-2 15 6 2 13-11 5-21 2 12 5 10 14t-12 16q1 0 4-1t4-1q-1 5-9.5 9.5t-19.5 9-14 6.5q-7 5-36 10.5t-36 1.5q-5-3-6-6t1.5-8.5 3.5-8.5q6-23 5-27-1-3-8.5-8t-5.5-12q1-4 11.5-10t12.5-12q5-13-4-25-4-5-15-11t-14-10q-5-5-3.5-11.5t0.5-9.5q1 1 1 2.5t1 2.5q0-13 11-22 8-6-16-18-20-11-20-4 1 8-7.5 16t-10.5 12-3.5 19-9.5 21q-6 4-19 4t-18-5q0 10-49 30-17 8-58 4 7 1 0 17-8 16-21 12-8 25-4 35 2 5 9 14t9 15q1 3 15.5 6t16.5 8q1 4-2.5 6.5t-9.5 4.5q53-6 63 18 5 9 3 14 0-1 2-1t2-1q12 3 7 17 19 8 26 8 5-1 11-6t10-5q17-3 21.5 10t-9.5 23q7-4 7 6-1 13-7 19-3 2-6.5 2.5t-6.5 0-7 0.5q-1 0-8 2-1-1-2-1h-8q-4-2-4-5v-1q-1-3 4-6l5-1 3-2q-1 0-2.5-2.5t-2.5-2.5q0-3 3-5-2-1-14-7.5t-17-10.5q-1-1-4-2.5t-4-2.5q-2-1-4 2t-4 9-4 11.5-4.5 10-5.5 4.5q-12 0-18-17 3 10-13 17.5t-25 7.5q20 15-9 30l-1 1q-30-4-45-7-2-6 3-12-1-7 6-9 0-1 0.5-1t0.5-1q0 1-0.5 1t-0.5 1q3-1 10.5-1.5t9.5-1.5q3-1 4.5-2l7.5-5t5.5-6-2.5-5q-2-1-9-4t-12.5-5.5-6.5-3.5q-3-5 0-16t-2-15q-5 5-10 18.5t-8 17.5q8-9-30-6l-8 1q-4 0-15-2t-16-1q-7 0-29 6 7 17 5 25 5 0 7 2l-6 3q-3-1-25-9 2-3 8-9.5t9-11.5q-22 6-27-2 0-1-9 0-25 1-24-7 1-4 9-12 0-9-1-9-27 22-30 23-172-83-276-248 1-2 2.5-11t3.5-8.5 11 4.5q9-9 3-21 2 2 36-21 56-40 22-53v5.5t1 6.5q-9-1-19 5-3-6 0.5-20t11.5-14q-8 0-10.5-17t-2.5-38.5-1-25.5l2-1q-3-13 6-37.5t24-20.5q-4-18 5-21-1-4 0-8t4.5-8.5 6-7l13.5-13.5q28-11 41-29 4-6 10.5-24.5t15.5-25.5q-2-6 10-21.5t11-25.5q-1 0-2.5-0.5t-2.5-0.5q3-8 16.5-16t16.5-14q2-3 2.5-10.5t3-12 8.5-2.5q3 24-26 68-16 27-18 31-3 5-5.5 16.5t-4.5 15.5q27-9 26-13-5-10 26-52 2-3 10-10t11-12q3-4 9.5-14.5t10.5-15.5q-1 0-3-2l-3-3q4-2 9-5t8-4.5 7.5-5 7.5-7.5q16-18 20-33 1-4 0.5-15.5t1.5-16.5q2-6 6-11t11.5-10 11.5-7 14.5-6.5 11.5-5.5q2-1 18-11t25-14q10-4 16.5-4.5t16 2.5 15.5 4z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
3
static/img/info-circle.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 0 496 496" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m248 0c-136.96 0-248 111.08-248 248 0 137 111.04 248 248 248s248-111 248-248c0-136.92-111.04-248-248-248zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 511 B |
3
static/img/paint-brush.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="32" height="32" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M167.02 309.34c-40.12 2.58-76.53 17.86-97.19 72.3-2.35 6.21-8 9.98-14.59 9.98-11.11 0-45.46-27.67-55.25-34.35C0 439.62 37.93 512 128 512c75.86 0 128-43.77 128-120.19 0-3.11-.65-6.08-.97-9.13l-88.01-73.34zM457.89 0c-15.16 0-29.37 6.71-40.21 16.45C213.27 199.05 192 203.34 192 257.09c0 13.7 3.25 26.76 8.73 38.7l63.82 53.18c7.21 1.8 14.64 3.03 22.39 3.03 62.11 0 98.11-45.47 211.16-256.46 7.38-14.35 13.9-29.85 13.9-45.99C512 20.64 486 0 457.89 0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 554 B |
5
static/img/plus.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="1408" height="1408" version="1.1" viewBox="0 -256 1408 1408" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1,0,0,-1,0,1152)">
|
||||
<path d="m1408 800v-192q0-40-28-68t-68-28h-416v-416q0-40-28-68t-68-28h-192q-40 0-68 28t-28 68v416h-416q-40 0-68 28t-28 68v192q0 40 28 68t68 28h416v416q0 40 28 68t68 28h192q40 0 68-28t28-68v-416h416q40 0 68-28t28-68z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 404 B |
5
static/img/tag.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 -256 1515 1515" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1,0,0,-1,0,1152)">
|
||||
<path d="m448 1088q0 53-37.5 90.5t-90.5 37.5-90.5-37.5-37.5-90.5 37.5-90.5 90.5-37.5 90.5 37.5 37.5 90.5zm1067-576q0-53-37-90l-491-492q-39-37-91-37-53 0-90 37l-715 716q-38 37-64.5 101t-26.5 117v416q0 52 38 90t90 38h416q53 0 117-26.5t102-64.5l715-714q37-39 37-91z" fill="currentColor"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 447 B |
5
static/img/wrench.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="32" height="32" version="1.1" viewBox="0 -256 1641 1643" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1,0,0,-1,-21,1152)">
|
||||
<path d="m384 64q0 26-19 45t-45 19-45-19-19-45 19-45 45-19 45 19 19 45zm644 420-682-682q-37-37-90-37-52 0-91 37l-106 108q-38 36-38 90 0 53 38 91l681 681q39-98 114.5-173.5t173.5-114.5zm634 435q0-39-23-106-47-134-164.5-217.5t-258.5-83.5q-185 0-316.5 131.5t-131.5 316.5 131.5 316.5 316.5 131.5q58 0 121.5-16.5t107.5-46.5q16-11 16-28t-16-28l-293-169v-224l193-107q5 3 79 48.5t135.5 81 70.5 35.5q15 0 23.5-10t8.5-25z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 577 B |
42
svelte.config.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import {vitePreprocess} from "@sveltejs/vite-plugin-svelte";
|
||||
import * as fs from "fs";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
// Can't use default _app, since "_" is reserved symbol in Chrome
|
||||
appDir: "assets/popup",
|
||||
adapter: adapter({
|
||||
strict: false
|
||||
}),
|
||||
version: {
|
||||
name: Date.now().toString(36)
|
||||
},
|
||||
alias: {
|
||||
"$components": "./src/components",
|
||||
"$styles": "./src/styles",
|
||||
"$stores": "./src/stores",
|
||||
"$entities": "./src/lib/api/entities",
|
||||
},
|
||||
},
|
||||
preprocess: [
|
||||
vitePreprocess({
|
||||
// SCSS is used by the project
|
||||
style: {
|
||||
postcss: true
|
||||
}
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
// Providing the version from package.json for rendering it in the UI.
|
||||
if (fs.existsSync('package.json')) {
|
||||
const packageInformation = JSON.parse(
|
||||
fs.readFileSync('package.json', 'utf8')
|
||||
);
|
||||
|
||||
config.kit.version.name = packageInformation.version;
|
||||
}
|
||||
|
||||
export default config;
|
||||
89
vite.config.extension.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import {defineConfig} from 'vite';
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import {load} from "cheerio";
|
||||
import crypto from "crypto";
|
||||
|
||||
const packageJsonPath = path.resolve(__dirname, 'package.json');
|
||||
const manifestJsonPath = path.resolve(__dirname, 'manifest.json');
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
listing: 'src/content/listing.js',
|
||||
},
|
||||
output: {
|
||||
dir: path.resolve(__dirname, 'build', 'assets', 'content'),
|
||||
inlineDynamicImports: false,
|
||||
entryFileNames: '[name].js',
|
||||
format: "iife"
|
||||
},
|
||||
},
|
||||
emptyOutDir: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"$lib": path.resolve(__dirname, 'src/lib'),
|
||||
"$styles": path.resolve(__dirname, 'src/styles'),
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: 'extract-inline-js',
|
||||
async buildEnd() {
|
||||
const buildPath = path.resolve(__dirname, 'build');
|
||||
const indexFilePath = path.resolve(buildPath, 'index.html');
|
||||
const indexHtml = fs.readFileSync(indexFilePath, 'utf8');
|
||||
|
||||
const ch = load(indexHtml);
|
||||
|
||||
ch('script').each((index, scriptElement) => {
|
||||
const $script = ch(scriptElement);
|
||||
const scriptContent = $script.text();
|
||||
|
||||
const entryHash = crypto.createHash('sha256')
|
||||
.update(scriptContent)
|
||||
.digest('base64url');
|
||||
|
||||
const scriptName = `init.${entryHash.slice(0, 8)}.js`;
|
||||
const scriptFilePath = path.resolve(buildPath, scriptName);
|
||||
const scriptPublicPath = `./${scriptName}`;
|
||||
|
||||
fs.writeFileSync(scriptFilePath, scriptContent);
|
||||
|
||||
$script.attr('src', scriptPublicPath);
|
||||
$script.text('');
|
||||
});
|
||||
|
||||
fs.writeFileSync(indexFilePath, ch.html());
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "bypass-manifest-extension",
|
||||
async buildEnd() {
|
||||
if (!fs.existsSync(manifestJsonPath)) {
|
||||
throw new Error(
|
||||
`The manifest.json file is missing from the root of the project.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
throw new Error(
|
||||
`The package.json file is missing from the root of the project.`
|
||||
);
|
||||
}
|
||||
|
||||
const packageInformation = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const manifestInformation = JSON.parse(fs.readFileSync(manifestJsonPath, 'utf8'));
|
||||
|
||||
manifestInformation.version = packageInformation.version;
|
||||
|
||||
fs.writeFileSync(
|
||||
path.resolve(__dirname, 'build', 'manifest.json'),
|
||||
JSON.stringify(manifestInformation, null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
8
vite.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import {sveltekit} from '@sveltejs/kit/vite';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
]
|
||||
});
|
||||