1
0
mirror of https://github.com/koloml/furbooru-tagging-assistant.git synced 2025-12-24 07:12:57 +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

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