1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2025-12-23 14:52:59 +00:00

First draft version with Svelte for popup and additional build steps

This commit is contained in:
2024-03-21 23:56:47 +04:00
parent f213aa4b8c
commit f7160ba70a
52 changed files with 4922 additions and 1 deletions

12
.gitignore vendored
View File

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

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

18
jsconfig.json Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View 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
View 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
View 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>

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

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

View File

@@ -0,0 +1,11 @@
<form>
<slot></slot>
</form>
<style lang="scss">
form {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
</style>

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

View 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}>

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

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

View 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
View File

@@ -0,0 +1,3 @@
import "$styles/content/listing.scss";
import "$lib/web-components/TagEditorComponent.js";

8
src/hooks.js Normal file
View 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 "/";
}
}

View 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()
);
}
}

View 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;
}
}

View 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;

View 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);
}
}

View 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;

View 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;

View 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!');
}

View File

@@ -0,0 +1 @@
export const ssr = false;

17
src/routes/+layout.svelte Normal file
View 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>

View File

@@ -0,0 +1 @@
export const prerender = true;

10
src/routes/+page.svelte Normal file
View 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>

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

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

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

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

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

View 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
View 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;

View File

@@ -0,0 +1 @@
@import "../injectable/tag";

View 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');
}

View 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;
}

View 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;
}
}

View 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
View 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";

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

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
import {sveltekit} from '@sveltejs/kit/vite';
import {defineConfig} from 'vite';
export default defineConfig({
plugins: [
sveltekit(),
]
});