1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00

Add-Ons Loader System (#606)

Implements an Add-on Loader so that other add-ons, such as the FFZ Add-on Pack, can be loaded directly by FFZ without requiring the user to install multiple extensions into their browser.
This commit is contained in:
Mike 2019-06-01 02:11:22 -04:00 committed by GitHub
parent d9f252ee4e
commit a305d03b2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 716 additions and 11 deletions

View file

@ -28,7 +28,8 @@ module.exports = {
"import": false,
"require": false,
"__webpack_hash__": false,
"__git_commit__": false
"__git_commit__": false,
"FrankerFaceZ": false
},
"rules": {
"accessor-pairs": ["error"],

334
src/addons.js Normal file
View file

@ -0,0 +1,334 @@
'use strict';
// ============================================================================
// Add-On System
// ============================================================================
import Module from 'utilities/module';
import { DEBUG, SERVER } from 'utilities/constants';
import { createElement } from 'utilities/dom';
import { timeout, has } from 'utilities/object';
const fetchJSON = (url, options) => {
return fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null);
}
// ============================================================================
// AddonManager
// ============================================================================
export default class AddonManager extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('experiments');
this.inject('settings');
this.inject('i18n');
this.reload_required = false;
this.addons = {};
this.enabled_addons = [];
}
async onEnable() {
if ( ! this.experiments.getAssignment('addons') )
return;
this.settings.addUI('add-ons', {
path: 'Add-Ons @{"description": "Add-Ons are additional modules, often written by other people, that can be loaded automatically by FrankerFaceZ to add new capabilities and behaviors to the extension and Twitch."}',
component: 'add-ons',
title: 'Add-Ons',
no_filter: true,
getExtraSearch: () => Object.values(this.addons).map(addon => addon.search_terms),
isReady: () => this.enabled,
getAddons: () => Object.values(this.addons),
hasAddon: id => this.hasAddon(id),
getVersion: id => this.getVersion(id),
isAddonEnabled: id => this.isAddonEnabled(id),
isAddonExternal: id => this.isAddonExternal(id),
enableAddon: id => this.enableAddon(id),
disableAddon: id => this.disableAddon(id),
isReloadRequired: () => this.reload_required,
refresh: () => window.location.reload(),
on: (...args) => this.on(...args),
off: (...args) => this.off(...args)
});
this.settings.add('addons.dev.server', {
default: false,
ui: {
path: 'Add-Ons >> Development',
title: 'Use Local Development Server',
description: 'Attempt to load add-ons from local development server on port 8001.',
component: 'setting-check-box'
}
});
this.on('i18n:update', this.rebuildAddonSearch, this);
this.settings.provider.on('changed', this.onProviderChange, this);
await this.loadAddonData();
this.enabled_addons = this.settings.provider.get('addons.enabled', []);
// We do not await enabling add-ons because that would delay the
// main script's execution.
for(const id of this.enabled_addons)
if ( this.hasAddon(id) )
this._enableAddon(id);
this.emit(':ready');
}
onProviderChange(key, value) {
if ( key != 'addons.enabled' )
return;
if ( ! value )
value = [];
const old_enabled = [...this.enabled_addons];
// Add-ons to disable
for(const id of old_enabled)
if ( ! value.includes(id) )
this.disableAddon(id, false);
// Add-ons to enable
for(const id of value)
if ( ! old_enabled.includes(id) )
this.enableAddon(id, false);
}
async loadAddonData() {
const [cdn_data, local_data] = await Promise.all([
fetchJSON(`${SERVER}/script/addons.json?_=${FrankerFaceZ.version_info}`),
this.settings.get('addons.dev.server') ?
fetchJSON(`https://localhost:8001/script/addons.json?_=${Date.now()}`) : null
]);
if ( Array.isArray(cdn_data) )
for(const addon of cdn_data )
this.addAddon(addon, false);
if ( Array.isArray(local_data) )
for(const addon of local_data)
this.addAddon(addon, true);
this.rebuildAddonSearch();
}
addAddon(addon, is_dev = false) {
const old = this.addons[addon.id];
this.addons[addon.id] = addon;
addon.name_i18n = addon.name_i18n || `addon.${addon.id}.name`;
addon.short_name_i18n = addon.short_name_i18n || `addon.${addon.id}.short_name`;
addon.description_i18n = addon.description_i18n || `addon.${addon.id}.description`;
addon.author_i18n = addon.author_i18n || `addon.${addon.id}.author`;
addon.dev = is_dev;
addon.requires = addon.requires || [];
addon.required_by = Array.isArray(old) ? old : old && old.required_by || [];
addon._search = addon.search_terms;
for(const id of addon.requires) {
const target = this.addons[id];
if ( Array.isArray(target) )
target.push(addon.id);
else if ( target )
target.required_by.push(addon.id);
else
this.addons[id] = [addon.id];
}
this.emit(':added-addon');
}
rebuildAddonSearch() {
for(const addon of Object.values(this.addons)) {
const terms = new Set([
addon._search,
addon.name,
addon.short_name,
addon.author,
addon.description,
]);
if ( this.i18n.locale !== 'en' ) {
terms.add(this.i18n.t(addon.name_i18n, addon.name));
terms.add(this.i18n.t(addon.short_name_i18n, addon.short_name));
terms.add(this.i18n.t(addon.author_i18n, addon.author));
terms.add(this.i18n.t(addon.description_i18n, addon.description));
}
addon.search_terms = [...terms].map(term => term ? term.toLocaleLowerCase() : '').join('\n');
}
}
isAddonEnabled(id) {
return this.enabled_addons.includes(id);
}
getAddon(id) {
const addon = this.addons[id];
return Array.isArray(addon) ? null : addon;
}
hasAddon(id) {
return this.getAddon(id) != null;
}
getVersion(id) {
const addon = this.getAddon(id);
if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`);
const module = this.resolve(`addon.${id}`);
if ( module ) {
if ( has(module, 'version') )
return module.version;
else if ( module.constructor && has(module.constructor, 'version') )
return module.constructor.version;
}
return addon.version;
}
isAddonExternal(id) {
if ( ! this.hasAddon(id) )
throw new Error(`Unknown add-on id: ${id}`);
const module = this.resolve(`addon.${id}`);
// If we can't find it, assume it isn't.
if ( ! module )
return false;
// Check for one of our script tags. If we didn't load
// it ourselves, then it's external.
const script = document.head.querySelector(`script#ffz-loaded-addon-${id}`);
if ( ! script )
return true;
// Finally, let the module flag itself as external.
return module.external || (module.constructor && module.constructor.external);
}
async _enableAddon(id) {
const addon = this.getAddon(id);
if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`);
await this.loadAddon(id);
const module = this.resolve(`addon.${id}`);
if ( module && ! module.enabled )
await module.enable();
}
async loadAddon(id) {
const addon = this.getAddon(id);
if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`);
let module = this.resolve(`addon.${id}`);
if ( module ) {
if ( ! module.loaded )
await module.load();
this.emit(':addon-loaded', id);
return;
}
document.head.appendChild(createElement('script', {
id: `ffz-loaded-addon-${addon.id}`,
type: 'text/javascript',
src: addon.src || `https://${addon.dev ? 'localhost:8001' : SERVER}/script/addons/${addon.id}/script.js`,
crossorigin: 'anonymous'
}));
// Error if this takes more than 5 seconds.
await timeout(this.waitFor(`addon.${id}:registered`), 5000);
module = this.resolve(`addon.${id}`);
if ( module && ! module.loaded )
await module.load();
this.emit(':addon-loaded', id);
}
unloadAddon(id) {
const module = this.resolve(`addon.${id}`);
if ( module )
return module.unload();
}
enableAddon(id, save = true) {
const addon = this.getAddon(id);
if( ! addon )
throw new Error(`Unknown add-on id: ${id}`);
if ( this.isAddonEnabled(id) )
return;
if ( Array.isArray(addon.requires) ) {
for(const id of addon.requires) {
if ( ! this.hasAddon(id) )
throw new Error(`Unknown add-on id: ${id}`);
this.enableAddon(id);
}
}
this.emit(':addon-enabled', id);
this.enabled_addons.push(id);
if ( save )
this.settings.provider.set('addons.enabled', this.enabled_addons);
// Actually load it.
this._enableAddon(id);
}
async disableAddon(id, save = true) {
const addon = this.getAddon(id);
if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`);
if ( this.isAddonExternal(id) )
throw new Error(`Cannot disable external add-on with id: ${id}`);
if ( ! this.isAddonEnabled(id) )
return;
if ( Array.isArray(addon.required_by) ) {
const promises = [];
for(const id of addon.required_by)
promises.push(this.disableAddon(id));
await Promise.all(promises);
}
this.emit(':addon-disabled', id);
this.enabled_addons.splice(this.enabled_addons.indexOf(id), 1);
if ( save )
this.settings.provider.set('addons.enabled', this.enabled_addons);
// Try disabling loaded modules.
try {
const module = this.resolve(`addon.${id}`);
if ( module )
await module.disable();
} catch(err) {
this.reload_required = true;
this.emit(':reload-required');
}
}
}

45
src/addons.json Normal file
View file

@ -0,0 +1,45 @@
[
{
"id": "ffzap-bttv",
"requires": ["ffzap-core"],
"version": "3.1.8",
"icon": "https://cdn.betterttv.net/assets/logos/mascot.png",
"shortname": "FFZ:AP BTTV",
"name": "BetterTTV Emotes",
"author": "The FrankerFaceZ Add-On Pack",
"description": "This is the BetterTTV module of The FrankerFaceZ Add-On Pack. It includes BetterTTV Global Emotes, Channel Emotes and Pro Emotes as well as various other functionality.",
"website": "https://ffzap.com/",
"settings": "add_ons.ffz_ap.better_ttv"
},
{
"id": "ffzap-liriklive",
"requires": ["ffzap-core"],
"version": "3.1.4",
"icon": "https://cdn.ffzap.com/liriklive/icon.png",
"shortname": "FFZ:AP LirikLIVE",
"name": "LirikLIVE",
"author": "The FrankerFaceZ Add-On Pack",
"description": "The LirikLIVE extension!",
"settings": "add_ons.ffz_ap.lirik_live"
},
{
"id": "ffzap-core",
"requires": [],
"version": "3.1.4",
"icon": "https://ffzap.com/img/logo.png",
"shortname": "FFZ:AP Core",
"name": "The FrankerFaceZ Add-On Pack - Core",
"author": "The FrankerFaceZ Add-On Pack",
"description": "This is the core functionality for The FrankerFaceZ Add-On Pack. It includes FFZ:AP supporter functionality and various other required utilities for the FFZ:AP modules.",
"website": "https://ffzap.com/"
}
]

View file

@ -4,7 +4,7 @@
// Experiments
// ============================================================================
import {SERVER} from 'utilities/constants';
import {DEBUG, SERVER} from 'utilities/constants';
import Module from 'utilities/module';
import {has, deep_copy} from 'utilities/object';
@ -75,7 +75,7 @@ export default class ExperimentManager extends Module {
let data;
try {
data = await fetch(`${SERVER}/script/experiments.json?_=${Date.now()}`).then(r =>
data = await fetch(DEBUG ? EXPERIMENTS : `${SERVER}/script/experiments.json?_=${Date.now()}`).then(r =>
r.ok ? r.json() : null);
} catch(err) {

View file

@ -1,4 +1,12 @@
{
"addons": {
"name": "Add-Ons Loader",
"description": "Enable the new Add-Ons system in development.",
"groups": [
{"value": true, "weight": 0},
{"value": false, "weight": 100}
]
},
"api_load": {
"name": "New API Stress Testing",
"description": "Send duplicate requests to the new API server for load testing.",

View file

@ -5,16 +5,17 @@ import RavenLogger from './raven';
import Logger from 'utilities/logging';
import Module from 'utilities/module';
import { timeout } from 'utilities/object';
import {DEBUG} from 'utilities/constants';
import SettingsManager from './settings/index';
import AddonManager from './addons';
import ExperimentManager from './experiments';
import {TranslationManager} from './i18n';
import SocketClient from './socket';
import Site from 'site';
import Vue from 'utilities/vue';
import { timeout } from './utilities/object';
class FrankerFaceZ extends Module {
constructor() {
@ -51,6 +52,7 @@ class FrankerFaceZ extends Module {
this.inject('i18n', TranslationManager);
this.inject('socket', SocketClient);
this.inject('site', Site);
this.inject('addons', AddonManager);
this.register('vue', Vue);
@ -158,6 +160,7 @@ const VER = FrankerFaceZ.version_info = {
FrankerFaceZ.utilities = {
addon: require('utilities/addon'),
dom: require('utilities/dom'),
color: require('utilities/color'),
events: require('utilities/events'),

View file

@ -0,0 +1,231 @@
<template lang="html">
<div class="ffz--addons tw-border-t tw-pd-y-1">
<div v-if="reload" class="tw-mg-y-1 tw-c-background-accent tw-c-text-overlay tw-pd-1">
<h4 class="ffz-i-attention">
{{ t('addon.refresh-needed', 'You must refresh your Twitch pages for some changes to take effect.') }}
</h4>
<button
class="tw-button tw-button--hollow tw-mg-t-05"
@click="item.refresh()"
>
<span class="tw-button__icon tw-button__icon--left">
<figure class="ffz-i-arrows-cw" />
</span>
<span class="tw-button__text">
{{ t('addon.refresh', 'Refresh') }}
</span>
</button>
</div>
<div v-if="! ready" class="tw-align-center tw-pd-1">
<h1 class="tw-mg-5 ffz-i-zreknarf loading" />
</div>
<div v-else>
<div
v-for="addon in sorted_addons"
v-if="shouldShow(addon)"
:key="addon.id"
class="ffz--addon-info tw-elevation-1 tw-c-background-base tw-border tw-pd-1 tw-mg-b-1 tw-flex tw-flex-nowrap"
>
<div class="tw-flex tw-flex-column tw-align-center tw-flex-shrink-0 tw-mg-r-1">
<div class="tw-card-img--size-6 tw-overflow-hidden tw-mg-b-1">
<img :src="addon.icon" class="tw-image">
</div>
<div v-if="enabled[addon.id]" class="tw-mg-b-05 tw-pill ffz--pill-enabled">
{{ t('addon.enabled', 'Enabled') }}
</div>
<div v-if="addon.dev" class="tw-mg-b-05 tw-pill">
{{ t('addon.dev', 'Developer') }}
</div>
<div v-if="item.isAddonExternal(addon.id)" class="tw-mg-b-05 tw-pill">
{{ t('addon.external', 'External') }}
</div>
</div>
<div class="tw-flex-grow-1">
<div class="tw-border-b tw-mg-b-05">
<h4>{{ t(addon.name_i18n, addon.name) }} <span class="tw-c-text-alt-2 tw-font-size-6">({{ addon.id }})</span></h4>
<span class="tw-c-text-alt tw-mg-r-1">
{{ t('addon.author', 'By: {author}', {
author: t(addon.author_i18n, addon.author)
}) }}
</span>
<span v-if="item.getVersion(addon.id)" class="tw-c-text-alt">
{{ t('addon.version', 'Version {version}', {version: item.getVersion(addon.id)}) }}
</span>
</div>
<markdown :source="t(addon.description_i18n, addon.description)" />
<div class="tw-mg-t-1 tw-pd-t-1 tw-border-t">
<template v-if="enabled[addon.id]">
<button
v-if="item.isAddonExternal(addon.id)"
disabled
class="tw-button tw-button--hollow tw-button--disabled tw-tooltip-wrapper tw-mg-r-1"
>
<span class="tw-button__icon tw-button__icon--left">
<figure class="ffz-i-trash" />
</span>
<span class="tw-button__text">
{{ t('addon.disable', 'Disable') }}
</span>
<div class="tw-tooltip tw-tooltip--up tw-tooltip--align-left">
{{ t('addon.external.description', 'This add-on has been loaded by an external script and cannot be disabled here.') }}
</div>
</button>
<button
v-else
class="tw-button tw-button--hollow ffz--button-disable tw-mg-r-1"
@click="item.disableAddon(addon.id)"
>
<span class="tw-button__icon tw-button__icon--left">
<figure class="ffz-i-trash" />
</span>
<span class="tw-button__text">
{{ t('addon.disable', 'Disable') }}
</span>
</button>
<button
v-if="addon.settings"
class="tw-button tw-button--hollow tw-mg-r-1"
@click="openSettings(addon)"
>
<span class="tw-button__icon tw-button__icon--left">
<figure class="ffz-i-cog" />
</span>
<span class="tw-button__text">
{{ t('addon.settings', 'Settings') }}
</span>
</button>
</template>
<template v-else>
<button
class="tw-button tw-button--hollow ffz--button-enable tw-mg-r-1"
@click="item.enableAddon(addon.id)"
>
<span class="tw-button__icon tw-button__icon--left">
<figure class="ffz-i-download" />
</span>
<span class="tw-button__text">
{{ t('addon.enable', 'Enable') }}
</span>
</button>
</template>
<a
v-if="addon.website"
:href="addon.website"
:title="addon.website"
class="tw-button tw-button--hollow tw-mg-r-1"
target="_blank"
rel="noopener"
>
<span class="tw-button__icon tw-button__icon--left">
<figure class="ffz-i-link-ext" />
</span>
<span class="tw-button__text">
{{ t('addon.website', 'Website') }}
</span>
</a>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['item', 'context', 'filter'],
data() {
const enabled = {};
for(const addon of this.item.getAddons())
enabled[addon.id] = this.item.isAddonEnabled(addon.id);
return {
ready: this.item.isReady(),
reload: this.item.isReloadRequired(),
enabled
}
},
computed: {
sorted_addons() {
const addons = this.item.getAddons();
addons.sort((a, b) => {
if ( a.sort < b.sort ) return -1;
if ( b.sort < a.sort ) return 1;
const a_n = a.name.toLowerCase(),
b_n = b.name.toLowerCase();
if ( a_n < b_n ) return -1;
if ( b_n < a_n ) return 1;
return 0;
});
return addons;
}
},
created() {
this.item.on(':ready', this.onReady, this);
this.item.on(':addon-enabled', this.onEnabled, this);
this.item.on(':addon-disabled', this.onDisabled, this);
this.item.on(':reload-required', this.onReload, this);
},
destroyed() {
this.item.off(':ready', this.onReady, this);
this.item.off(':addon-enabled', this.onEnabled, this);
this.item.off(':addon-disabled', this.onDisabled, this);
this.item.off(':reload-required', this.onReload, this);
},
methods: {
shouldShow(addon) {
if ( ! this.filter || ! this.filter.length )
return true;
return addon.search_terms.includes(this.filter)
},
onReady() {
this.ready = true;
// Refresh the enabled cache.
for(const addon of this.item.getAddons())
this.enabled[addon.id] = this.item.isAddonEnabled(addon.id);
},
onEnabled(id) {
this.enabled[id] = true;
},
onDisabled(id) {
this.enabled[id] = false;
},
onReload() {
this.reload = true;
},
openSettings(addon) {
let key;
if ( typeof addon.settings === 'string' )
key = addon.settings;
else
key = `add_ons.${addon.id}`;
this.$emit('navigate', key);
}
}
}
</script>

View file

@ -48,7 +48,7 @@
:key="idx"
:selected="i.value === exp.value"
>
{{ t('setting.experiments.entry', '{value} (weight: {weight})', i) }}
{{ t('setting.experiments.entry', '{value,tostring} (weight: {weight,tostring})', i) }}
</option>
</select>
@ -122,7 +122,7 @@
:key="idx"
:selected="i.value === exp.value"
>
{{ i.value }} (weight: {{ i.weight }})
{{ t('setting.experiments.entry', '{value,tostring} (weight: {weight,tostring})', i) }}
</option>
</select>

View file

@ -18,6 +18,7 @@
:context="context"
:item="i"
:filter="filter"
@navigate="navigate"
/>
</div>
</div>
@ -41,6 +42,10 @@ export default {
},
methods: {
navigate(...args) {
this.$emit('navigate', ...args);
},
shouldShow(item) {
if ( ! this.filter || ! this.filter.length || ! item.search_terms )
return true;

View file

@ -93,7 +93,7 @@ export default {
if ( ! this.filter || ! this.filter.length || ! item.search_terms )
return true;
return item.search_terms.includes(this.filter);
return item.no_filter || item.search_terms.includes(this.filter);
},
markSeen(item) {

View file

@ -135,6 +135,9 @@ export default class MainMenu extends Module {
async onEnable() {
await this.site.awaitElement(Dialog.EXCLUSIVE);
this.on('addons:added', this.scheduleUpdate, this);
this.on('i18n:update', this.scheduleUpdate, this);
this.dialog.on('show', () => {
this.opened = true;
this.updateButtonUnseen();
@ -362,7 +365,7 @@ export default class MainMenu extends Module {
tok.default = def.default;
}
const terms = [
let terms = [
setting_key,
this.i18n.t(tok.i18n_key, tok.title, tok, true)
];
@ -377,6 +380,9 @@ export default class MainMenu extends Module {
terms.push(this.i18n.t(tok.desc_i18n_key, tok.description, tok));
}
if ( tok.getExtraTerms )
terms = terms.concat(tok.getExtraTerms());
tok.search_terms = terms.map(format_term).join('\n');
if ( settings_seen ) {
@ -712,7 +718,7 @@ export default class MainMenu extends Module {
}
return {
const out = {
context,
query: '',
@ -757,6 +763,8 @@ export default class MainMenu extends Module {
},
version: window.FrankerFaceZ.version_info,
}
};
return out;
}
}

View file

@ -243,6 +243,10 @@ export default class Metadata extends Module {
return Object.keys(this.definitions);
}
define(key, definition) {
this.definitions[key] = definition;
this.updateMetadata(key);
}
updateMetadata(keys) {
const bar = this.resolve('site.channel_bar');

View file

@ -140,7 +140,8 @@ export default class RavenLogger extends Module {
'Access is denied.',
'Zugriff verweigert',
'freed script',
'ffzenhancing'
'ffzenhancing',
'dead object'
],
sanitizeKeys: [
/Token$/

View file

@ -1,3 +1,4 @@
.player-streaminfo__picture img[src],
.side-nav-card__avatar.tw-border-radius-rounded,
.tw-avatar .tw-border-radius-rounded {
.tw-root--theme-dark &,

20
src/utilities/addon.js Normal file
View file

@ -0,0 +1,20 @@
import Module from 'utilities/module';
export class Addon extends Module {
constructor(...args) {
super(...args);
this.inject('i18n');
this.inject('settings');
}
static register(id, info) {
const ffz = FrankerFaceZ.get();
ffz.register(`addon.${id}`, this);
if ( info ) {
info.id = id;
ffz.addons.addAddon(info);
}
}
}

View file

@ -46,6 +46,8 @@ export class Module extends EventEmitter {
this.__load_state = this.onLoad ? State.UNLOADED : State.LOADED;
this.__state = this.onLoad || this.onEnable ?
State.DISABLED : State.ENABLED;
this.emit(':registered');
}

View file

@ -204,6 +204,8 @@ export class Tooltip {
this.elements.add(target);
// Set this early in case content uses it early.
tip._promises = [];
tip.waitForDom = () => tip.element ? Promise.resolve() : new Promise(s => {tip._promises.push(s)});
tip.update = () => tip._update(); // tip.popper && tip.popper.scheduleUpdate();
tip.show = () => this.show(tip);
tip.hide = () => this.hide(tip);
@ -277,6 +279,11 @@ export class Tooltip {
}
}
for(const fn of tip._promises)
fn();
tip._promises = null;
if ( content instanceof Promise || (content.then && content.toString() === '[object Promise]') ) {
inner.innerHTML = '<div class="ffz-i-zreknarf loader"></div>';
content.then(content => {

View file

@ -16,6 +16,10 @@ import {duration_to_string} from 'utilities/time';
// ============================================================================
export const DEFAULT_TYPES = {
tostring(val) {
return `${val}`
},
select(val, node, locale, out, ast, data) {
const sub_ast = node.o && (node.o[val] || node.o.other);
if ( ! sub_ast )

View file

@ -7,6 +7,7 @@
@import "./widgets/menu-tree.scss";
@import "./widgets/profile-selector.scss";
@import "./widgets/badge-visibility.scss";
@import "./widgets/add-ons.scss";
@import "./widgets/color-picker.scss";
@ -229,4 +230,29 @@
background-color: rgba(255,255,255,0.05);
}
}
}
@mixin button-colors($color, $text, $shadow) {
&:hover, &:focus {
background: $color !important;
border-color: $color !important;
color: $text !important;
}
&:focus {
box-shadow: 0 0 $shadow $color !important;
}
}
.ffz--pill-enabled {
background-color: #007600 !important;
color: #fff !important;
}
.ffz--button-enable {
@include button-colors(#007600, #fff, 6px);
}
.ffz--button-disable {
@include button-colors(#bd0f0f, #fff, 6px);
}

View file

@ -0,0 +1,5 @@
.ffz--add-on-info {
.ffz-logo-section {
max-width: 8rem;
}
}