Finish migration to tailwind, add new settings screen

This commit is contained in:
Tamius Han 2025-12-02 01:02:09 +01:00
parent b334707463
commit dc755dbe62
52 changed files with 2948 additions and 2199 deletions

1936
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -65,14 +65,15 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"mini-css-extract-plugin": "^2.9.4", "mini-css-extract-plugin": "^2.9.4",
"node-sass": "^9.0.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-import": "^16.1.1",
"postcss-nested": "^7.0.2",
"postcss-preset-env": "^10.4.0", "postcss-preset-env": "^10.4.0",
"postcss-strip-inline-comments": "^0.1.5", "postcss-strip-inline-comments": "^0.1.5",
"resolve-url-loader": "^5.0.0", "resolve-url-loader": "^5.0.0",
"sass-loader": "^16.0.6",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"ts-loader": "^9.5.4", "ts-loader": "^9.5.4",
"tsconfig-paths": "^4.2.0",
"vue-cli-plugin-vue-next": "~0.1.4", "vue-cli-plugin-vue-next": "~0.1.4",
"vue-loader": "^17.4.2", "vue-loader": "^17.4.2",
"vue-tsc": "^3.1.5", "vue-tsc": "^3.1.5",

View File

@ -1,6 +1,7 @@
module.exports = { module.exports = {
plugins: [ plugins: [
require('@tailwindcss/postcss'), require('@tailwindcss/postcss'),
require('postcss-nested'),
require('autoprefixer'), require('autoprefixer'),
], ],
}; };

View File

@ -14,7 +14,12 @@
</div> </div>
<div class="flex flex-row"> <div class="flex flex-row">
<small> <small>
<span style="text-transform: uppercase; font-size: 0.9rem">Enable extension: <span style="font-size: 0.9rem;" :style="getSiteEnabledColor('@global', 'enable')">{{ getSiteEnabledModes('@global', 'enable') }}</span></span>&nbsp;<br/> <span style="text-transform: uppercase; font-size: 0.9rem">
Enable extension: <span style="font-size: 0.9rem;" :style="getSiteEnabledColor('@global', 'enable')">{{ getSiteEnabledModes('@global', 'enable') }}</span>
{{getSiteEnabledModes('@global', 'enable') === 'disabled'}}
<span v-if="getSiteEnabledModes('@global', 'enable') === 'disabled'" class="text-[0.9em]">&nbsp;(except on whitelisted sites)</span>"
</span>&nbsp;
<br/>
Autodetection: <span :style="getSiteEnabledColor('@global', 'enableAard')"><small>{{ getSiteEnabledModes('@global', 'enableAard') }}</small></span>;&nbsp; Autodetection: <span :style="getSiteEnabledColor('@global', 'enableAard')"><small>{{ getSiteEnabledModes('@global', 'enableAard') }}</small></span>;&nbsp;
Keyboard shortcuts: <span :style="getSiteEnabledColor('@global', 'enableKeyboard')"><small>{{ getSiteEnabledModes('@global', 'enableKeyboard') }}</small></span>; Keyboard shortcuts: <span :style="getSiteEnabledColor('@global', 'enableKeyboard')"><small>{{ getSiteEnabledModes('@global', 'enableKeyboard') }}</small></span>;
In-player UI: <span :style="getSiteEnabledColor('@global', 'enableUI')"><small>{{ getSiteEnabledModes('@global', 'enableUI') }}</small></span>; In-player UI: <span :style="getSiteEnabledColor('@global', 'enableUI')"><small>{{ getSiteEnabledModes('@global', 'enableUI') }}</small></span>;

View File

@ -1,117 +1,40 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tailwindcss/utilities";
@tailwind base; @theme {
@tailwind components; --color-primary: #ff872c;
@tailwind utilities; --color-primary-50: #ffddbe;
--color-primary-100: #ffcda0;
@font-face { --color-primary-200: #ffbd83;
font-family: 'Overpass'; --color-primary-300: #ffac66;
src: url('/ui/res/fonts/overpass-webfont/overpass-thin.woff2') format('woff2'); /* Super Modern Browsers */ --color-primary-400: #ff9a4a;
font-weight: 200; --color-primary-500: #ff872c;
font-style: normal; --color-primary-600: #de7622;
--color-primary-700: #be6518;
--color-primary-800: #9f540e;
--color-primary-900: #824406;
--color-primary-950: #663400;
} }
@font-face { @import './ui/res/styles/forms.css';
font-family: 'Overpass'; @import './ui/res/styles/fonts.css';
src: url('/ui/res/fonts/overpass-webfont/overpass-thin-italic.woff2') format('woff2'); @import './ui/res/styles/buttons.css';
font-weight: 200;
font-style: italic; html, body {
font-size: 16px;
}
@media (max-width: 999px) {
html, body {
font-size: 12px;
}
} }
@font-face { @layer base {
font-family: 'Overpass'; body {
src: url('/ui/res/fonts/overpass-webfont/overpass-extralight.woff2') format('woff2'); @apply font-sans text-stone-300;
font-weight: 300; }
font-style: normal;
}
@font-face { h1, h2, h3, h4, h5, h6 {
font-family: 'Overpass'; @apply font-mono;
src: url('/ui/res/fonts/overpass-webfont/overpass-extralight-italic.woff2') format('woff2'); }
font-weight: 300;
font-style: italic;
}
@font-face {
font-family: 'Overpass';
src: url('/ui/res/fonts/overpass-webfont/overpass-light.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Overpass';
src: url('/ui/res/fonts/overpass-webfont/overpass-light-italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
}
@font-face {
font-family: 'Overpass';
src: url('/ui/res/fonts/overpass-webfont/overpass-regular.woff2') format('woff2');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Overpass';
src: url('/ui/res/fonts/overpass-webfont/overpass-italic.woff2') format('woff2');
font-weight: 500;
font-style: italic;
}
@font-face {
font-family: 'Overpass';
src: url('/ui/res/fonts/overpass-webfont/overpass-semibold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
}
@font-face {
font-family: 'Overpass';
src: url('/ui/res/fonts/overpass-webfont/overpass-semibold-italic.woff2') format('woff2');
font-weight: 600;
font-style: italic;
}
@font-face {
font-family: 'Overpass';
src: url('/ui/res/fonts/overpass-webfont/overpass-bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'Overpass';
src: url('/ui/res/fonts/overpass-webfont/overpass-bold-italic.woff2') format('woff2');
font-weight: 700;
font-style: italic;
}
@font-face {
font-family: 'Overpass mono';
src: url('/ui/res/fonts/overpass-mono-webfont/overpass-mono-light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
}
@font-face {
font-family: 'Overpass mono';
src: url('/ui/res/fonts/overpass-mono-webfont/overpass-mono-regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: 'Overpass mono';
src: url('/ui/res/fonts/overpass-mono-webfont/overpass-mono-semibold.woff2') format('woff2');
font-weight: 500;
font-style: normal;
}
@font-face {
font-family: 'Overpass mono';
src: url('/ui/res/fonts/overpass-mono-webfont/overpass-mono-bold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
} }

View File

@ -0,0 +1,310 @@
<template>
<div class="flex flex-row w-full h-full">
<div class="flex flex-col w-full">
<!-- TAB ROW -->
<div class="flex flex-row">
<div
class="tab"
:class="{'active': tab === 'siteSettings'}"
@click="setTab('siteSettings')"
>
Current site<br/>
<small>{{ site }}</small>
</div>
<div
v-if="hosts"
class="tab"
:class="{'active': tab === 'embeddedSites'}"
@click="setTab(tab = 'embeddedSites')"
>
Embedded content ({{hosts?.length}} {{hosts?.length === 1 ? 'site' : 'sites'}})
</div>
<div
class="tab"
:class="{'active': tab === 'otherSites'}"
@click="setTab(tab = 'otherSites')"
>
Defaults & other sites
</div>
</div>
<template v-if="tab === 'siteSettings' && siteSettings">
<SiteExtensionSettings
v-if="settings"
:settings="settings"
:siteSettings="siteSettings"
:isDefaultConfiguration="false"
></SiteExtensionSettings>
</template>
<template v-if="hosts && tab === 'embeddedSites' && globalSettings">
<FrameSiteSettings
v-if="settings"
:parentHost="site"
:hosts="hosts"
:settings="settings"
></FrameSiteSettings>
</template>
<template v-if="tab === 'otherSites'">
<OtherSiteSettings
v-if="settings"
:settings="settings"
>
</OtherSiteSettings>
</template>
<!-- Reset options -->
<div class="flex flex-col" style="margin-top: 2rem">
<h2>Reset and backup</h2>
<p><small>Settings import must be done from in-player UI.</small></p>
<div class="flex flex-row w-full">
<UploadJsonFileButton
class="flex-grow"
@importedJson="handleImportedSettings"
@error="handleSettingsImportError"
>
Import settings
</UploadJsonFileButton>
<Popup
v-if="importSettingDialogConfig.visible"
title="Overwrite existing settings?"
message="Importing settings from a file will overwrite existing settings. Continue?"
confirmButtonText="Import settings"
cancelButtonText="Cancel"
@onConfirm="importSettingDialogConfig.confirm"
@onCancel="importSettingDialogConfig.reject"
>
</Popup>
<button class="flex-grow" @click="exportSettings()">Export settings</button>
</div>
<div></div>
<ConfirmButton
dialogType="danger"
@onConfirmed="resetSettings"
>
Reset settings
</ConfirmButton>
<div v-if="enableSettingsEditor" class="field">
<div class="label">Show developer options</div>
<input
type="checkbox"
v-model="settings.active.ui.devMode"
@change="settings.saveWithoutReload"
>
</div>
</div>
</div>
<div v-if="enableSettingsEditor && settings.active.ui.devMode" class="h-full grow">
<h2>Settings editor</h2>
<div class="flex flex-row w-full">
<div class="flex flex-row items-center">
<div>Enable save button:</div>
<input v-model="allowSettingsEditing" type="checkbox">
</div>
<div class="grow">
</div>
<div>
<div v-if="editorSaveFinished">Settings saved ...</div>
<button v-else
class="danger"
:class="{'disabled': !allowSettingsEditing}"
:disabled="!allowSettingsEditing"
@click="() => saveSettingsChanges()"
>
Save
</button>
</div>
<button @click="resetSettingsEditor">
Cancel
</button>
</div>
<div>
<JsonEditor
v-model="settingsJson"
>
</JsonEditor>
</div>
<h2>Settings snapshots</h2>
<div class="flex flex-col">
<div v-for="(snapshot, index) of settingsSnapshots" :key="snapshot.createdAt">
<small>{{new Date(snapshot.createdAt).toISOString()}}</small>
<div class="flex flex-row">
<div class="grow">
{{snapshot.name}}
</div>
<div v-if="settings.isAutomatic">(auto)</div>
<div v-if="settings.isAutomatic && settings.isProtected">(protected)</div>
<div v-if="settings.default">(default)</div>
</div>
<div>
<button @click="() => markDefaultSnapshot(index)"><template v-if="settings.isDefault">Revoke default</template><template v-else>Make default</template></button>
<button v-if="settings.isAutomatic" @click="() => toggleSnapshotProtection(index)">Toggle protection</button>
<button @click="() => deleteSnapshot(index)">Delete snapshot</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import SiteExtensionSettings from '@components/ExtensionSettings/Panels/SiteExtensionSettings.vue';
import FrameSiteSettings from '@components/ExtensionSettings/Panels/FrameSiteSettings.vue';
import OtherSiteSettings from '@components/ExtensionSettings/Panels/OtherSiteSettings.vue';
import Popup from '@csui/src/components/Popup';
import ConfirmButton from '@csui/src/components/ConfirmButton';
import UploadJsonFileButton from '@csui/src/components/UploadJsonFileButton';
import JsonEditor from '@csui/src/components/JsonEditor';
export default {
components: {
SiteExtensionSettings,
OtherSiteSettings,
Popup,
ConfirmButton,
UploadJsonFileButton,
JsonEditor,
FrameSiteSettings,
},
mixins: [],
props: [
'settings',
'site',
'enableSettingsEditor',
'hosts',
],
data() {
return {
tab: 'siteSettings',
importSettingDialogConfig: {visible: false},
allowSettingsEditing: false,
editorSaveFinished: false,
settingsJson: {},
settingsSnapshots: []
}
},
computed: {
globalSettings() {
return this.settings?.getSiteSettings('@global') ?? null;
},
siteSettings() {
if (this.site) {
return this.settings?.getSiteSettings({site: this.site}) ?? null;
}
return null;
},
},
mounted() {
this.resetSettingsEditor();
this.loadSettingsSnapshots();
},
methods: {
setTab(tab) {
this.tab = tab;
},
//#region settings management
/**
* Exports extension settings into a json file.
*/
exportSettings() {
const settingBlob = new Blob(
[ JSON.stringify(this.settings.active, null, 2) ],
{ type: "application/json"}
);
const url = window.URL.createObjectURL(settingBlob);
const a = document.createElement("a");
a.href = url;
a.download = "ultrawidify-settings.json";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
handleImportedSettings(newSettings) {
this.importSettingDialogConfig = {
visible: true,
confirm: () => {
this.settings.active = newSettings;
this.settings.saveWithoutReload();
this.importSettingDialogConfig = {visible: false};
this.resetSettingsEditor();
},
reject: () => {
this.importSettingDialogConfig = {visible: false};
}
}
},
handleSettingsImportError(error) {
console.error('Error importing settings:', error);
},
/**
* Resets settings to default
*/
resetSettings() {
this.settings.active = JSON.parse(JSON.stringify(this.settings.default));
this.settings.saveWithoutReload();
this.resetSettingsEditor();
},
async saveSettingsChanges() {
if (this.allowSettingsEditing) {
const currentVersion = this.settings.active?.version;
this.settings.active = this.settingsJson;
if (currentVersion !== this.settingsJson.version) {
await this.settings.save({forcePreserveVersion: true});
return;
} else {
await this.settings.saveWithoutReload();
}
this.resetSettingsEditor();
this.editorSaveFinished = true;
setTimeout(() => {
this.editorSaveFinished = false;
}, 3000);
}
},
resetSettingsEditor() {
this.settingsJson = JSON.parse(JSON.stringify(this.settings?.active ?? {}));
},
//#endregion
//#region settings snapshot management
async loadSettingsSnapshots() {
this.settingsSnapshots = await this.settings.snapshotManager.listSnapshots();
},
async markDefaultSnapshot(index) {
await this.settings.snapshotManager.setDefaultSnapshot(index, !this.settingsSnapshots[index].isDefault);
await this.loadSettingsSnapshots();
this.settings.active.dev.loadFromSnapshot = this.settingsSnapshots[index].isDefault;
this.saveSettingsChanges();
},
async toggleSnapshotProtection(index) {
await this.settings.snapshotManager.setSnapshotAsProtected(index, !this.settingsSnapshots[index].isProtected);
await this.loadSettingsSnapshots();
},
async deleteSnapshot(index) {
await this.settings.snapshotManager.deleteSnapshot(index);
await this.loadSettingsSnapshots();
},
}
//#endregion
}
</script>

View File

@ -0,0 +1,109 @@
<template>
<div class="">
<template v-if="!selectedSite">
<div style="margin: 1rem 0rem" class="w-full">
<div class="flex flex-row items-baseline">
<div style="margin-right: 1rem">Search for site:</div>
<div class="input flex-grow">
<input v-model="siteFilter" />
</div>
</div>
</div>
<div v-for="host of hosts" :key="host" @click="selectedSite = host" class="flex flex-col container pointer hoverable" style="margin-top: 4px; padding: 0.5rem 1rem;">
<SiteListItem
:parentHost="parentHost"
:host="host"
:settings="settings"
></SiteListItem>
</div>
</template>
<template v-if="selectedSite">
<div class="flex flex-row container" style="align-items: center; color: #dedede; margin-top: 1rem;">
<div @click="selectedSite = null" class="pointer button-hover" style=" font-size: 2em; padding: 0.5rem; margin-right: 1em;">
</div>
<div>
Editing {{ selectedSite === '@global' ? 'default settings' : selectedSite }}
</div>
</div>
<div>
<SiteExtensionSettings
v-if="selectedSiteSettings"
:settings="settings"
:siteSettings="selectedSiteSettings"
:isDefaultConfiguration="selectedSite === '@global'"
></SiteExtensionSettings>
</div>
</template>
</div>
</template>
<script>
import SiteExtensionSettings from './SiteExtensionSettings.vue';
import SiteListItem from './SiteListItem.vue';
export default {
components: {
SiteExtensionSettings,
SiteListItem,
},
props: [
'settings',
'parentHost',
'hosts',
],
data() {
return {
selectedSite: null,
siteFilter: '',
filteredSites: []
}
},
computed: {
sites() {
if (!this.settings?.active?.sites) {
return [];
} else {
const sites = [];
for (const siteKey in this.settings.active.sites) {
if (!siteKey.startsWith('@') && (!this.siteFilter.trim() || siteKey.includes(this.siteFilter))) {
sites.push({
key: siteKey,
...this.settings.active.sites[siteKey]
})
}
};
sites.sort((a, b) => {
const cmpa = a.key.replace('www.', '');
const cmpb = b.key.replace('www.', '');
return cmpa < cmpb ? -1 : 1;
});
return sites;
}
},
selectedSiteSettings() {
return this.settings?.getSiteSettings({site: this.selectedSite}) ?? null;
}
},
methods: {
}
}
</script>
<style lang="scss" src="../../../../res/css/flex.scss" scoped></style>
<style lang="scss" src="@csui/src/res-common/panels.scss" scoped></style>
<style lang="scss" src="@csui/src/res-common/common.scss" scoped></style>
<style lang="scss" scoped>
.hoverable {
border: 1px solid #333;
&:hover {
border: 1px solid #fa6;
color: rgb(255, 231, 212);
background-color: rgba(#fa6, 0.125);
}
}
</style>

View File

@ -0,0 +1,160 @@
<template>
<div class="flex flex-row gap-8">
<div v-if="!selectedSite || !isCompact">
<div
class="flex flex-col cursor-pointer mt-1 px-4 py-2 bg-black border-1 border-stone-500"
:class="{'opacity-50 hover:opacity-100': selectedSite && selectedSite !== '@global' }"
@click="selectedSite = '@global'"
>
<div class="flex flex-row">
<div class="flex-grow pointer">
<b style="color: #fa6">Default settings</b>
</div>
<div>Edit</div>
</div>
<div class="flex flex-row">
<small>
<span style="text-transform: uppercase; font-size: 0.9rem">
Enable extension: <span style="font-size: 0.9rem;" :style="getSiteEnabledColor('@global', 'enable')">{{ getSiteEnabledModes('@global', 'enable') }}</span>
<span v-if="getSiteEnabledModes('@global', 'enable') === 'disabled'" class="text-[0.75em] lowercase text-stone-400">&nbsp;(except on whitelisted sites)</span>
</span>&nbsp;<br/>
Autodetection: <span :style="getSiteEnabledColor('@global', 'enableAard')"><small>{{ getSiteEnabledModes('@global', 'enableAard') }}</small></span>;&nbsp;
Keyboard shortcuts: <span :style="getSiteEnabledColor('@global', 'enableKeyboard')"><small>{{ getSiteEnabledModes('@global', 'enableKeyboard') }}</small></span>;
In-player UI: <span :style="getSiteEnabledColor('@global', 'enableUI')"><small>{{ getSiteEnabledModes('@global', 'enableUI') }}</small></span>;
</small>
</div>
</div>
<div style="margin-top: 1rem; margin-bottom: 1rem;">
<div class="border border-blue-500 text-blue-500 bg-slate-950 px-4 py-2" style="float: none">
<b>NOTE:</b> Sites not on this list use default extension settings.
</div>
</div>
<div class="w-full text-center" style="margin-bottom: -1.25rem">
<b>Other sites</b>
</div>
<div style="margin: 1rem 0rem" class="w-full">
<div class="flex flex-row items-baseline">
<div style="margin-right: 1rem">Search for site:</div>
<div class="input flex-grow">
<input v-model="siteFilter" />
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div v-for="site of sites" :key="site.key" :class="{'opacity-50 hover:opacity-100': selectedSite && selectedSite !== site.key }">
<SiteListItem
:host="site.key"
:settings="settings"
:isActive="selectedSite === site.key"
@edit="selectedSite = site.key"
></SiteListItem>
</div>
</div>
</div>
<div v-if="selectedSite">
<div class="flex flex-row text-[1.5em] items-center gap-2">
<div @click="selectedSite = null" class="cursor-pointer bg-black rounded-full px-2">
</div>
<div>
Editing {{ selectedSite === '@global' ? 'default settings' : selectedSite }}
</div>
</div>
<div class="border-t-1 border-t-stone-800 mt-4 pt-2">
<SiteExtensionSettings
v-if="selectedSiteSettings"
:settings="settings"
:siteSettings="selectedSiteSettings"
:isDefaultConfiguration="selectedSite === '@global'"
></SiteExtensionSettings>
</div>
</div>
</div>
</template>
<script>
import ExtensionMode from '@src/common/enums/ExtensionMode.enum';
import SiteExtensionSettings from './SiteExtensionSettings.vue';
import SiteListItem from './SiteListItem.vue';
export default {
data() {
return {
selectedSite: null,
siteFilter: '',
filteredSites: []
}
},
props: [
'settings',
'isCompact',
],
components: {
SiteExtensionSettings,
SiteListItem,
},
computed: {
sites() {
if (!this.settings?.active?.sites) {
return [];
} else {
const sites = [];
for (const siteKey in this.settings.active.sites) {
if (!siteKey.startsWith('@') && (!this.siteFilter.trim() || siteKey.includes(this.siteFilter))) {
sites.push({
key: siteKey,
...this.settings.active.sites[siteKey]
})
}
};
sites.sort((a, b) => {
const cmpa = a.key.replace('www.', '');
const cmpb = b.key.replace('www.', '');
return cmpa < cmpb ? -1 : 1;
});
return sites;
}
},
selectedSiteSettings() {
return this.settings?.getSiteSettings({site: this.selectedSite}) ?? null;
}
},
methods: {
getSiteTypeColor(siteType) {
switch (siteType) {
case 'official': return 'color: #fa6';
case 'community': return 'color: rgb(114, 114, 218)';
case 'officially-disabled': return 'color: #f00';
case 'testing': return 'color: #d81';
default: return 'color: rgb(138, 65, 126)'
};
},
getSiteEnabledColor(site, component) {
const status = this.getSiteEnabledModes(site, component);
return status === 'disabled' ? 'color: #f00' : 'color: #1f8';
},
getSiteEnabledModes(site, component) {
if (this.settings?.getSiteSettings({site: site}).data[component]?.normal === ExtensionMode.Enabled) {
return 'always';
}
if (this.settings?.getSiteSettings({site: site}).data[component]?.theater === ExtensionMode.Enabled) {
return 'T + FS';
}
if (this.settings?.getSiteSettings({site: site}).data[component]?.fullscreen === ExtensionMode.Enabled) {
return 'fullscreen';
}
return 'disabled';
}
}
}
</script>
<style lang="postcss" scoped>
.container {
@apply: bg-[#000] px-4 py-2 border border-primary-500;
}
</style>

View File

@ -0,0 +1,656 @@
<template>
<div>
<!-- Enable extension -->
<div class="field">
<div class="label">
Enable <span class="color-emphasis">extension</span>
<span class="sub-label"><br/>under the following conditions:</span>
</div>
<div class="select">
<select
:value="simpleExtensionSettings.enable"
@click="setExtensionMode('enable', $event)"
>
<option
v-if="simpleExtensionSettings.enable === 'complex'"
value="complex"
>
(Site uses advanced settings)
</option>
<template v-if="isDefaultConfiguration">
<option value="disabled">
Disabled by default
</option>
</template>
<template v-else>
<option value="default">
Use default ({{simpleDefaultSettings.enable}})
</option>
<option value="disabled">
Never
</option>
</template>
<option value="fs">
Fullscreen only
</option>
<option value="theater">
Fullscreen and theater mode
</option>
<option value="enabled">
Always
</option>
</select>
</div>
</div>
<!-- The rest of the menu is disabled when extension is disabled -->
<div :class="{disabled: simpleEffectiveSettings.enable === 'disabled' && !isDefaultConfiguration}">
<!-- Enable AARD -->
<div class="field">
<div class="label">
Enable <span class="color-emphasis">automatic aspect ratio detection</span>
<span class="sub-label"><br/>under the following conditions:</span>
</div>
<div class="select">
<select
:value="simpleExtensionSettings.enableAard"
@click="setExtensionMode('enableAard', $event)"
>
<option
v-if="simpleExtensionSettings.enable === 'complex'"
value="complex"
>
(Site uses advanced settings)
</option>
<template v-if="isDefaultConfiguration">
<option value="disabled">
Disabled by default
</option>
</template>
<template v-else>
<option value="default">
Use default ({{simpleDefaultSettings.enableAard}})
</option>
<option value="disabled">
Never
</option>
</template>
<option value="fs">
Fullscreen only
</option>
<option value="theater">
Fullscreen and theater mode
</option>
<option value="enabled">
Always
</option>
</select>
</div>
</div>
<!-- Enable keyboard -->
<div class="field">
<div class="label">
Enable <span class="color-emphasis">keyboard shortcuts</span>
<span class="sub-label"><br/>under the following conditions:</span>
</div>
<div class="select">
<select
:value="simpleExtensionSettings.enableKeyboard"
@click="setExtensionMode('enableKeyboard', $event)"
>
<option
v-if="simpleExtensionSettings.enable === 'complex'"
value="complex"
>
(Site uses advanced settings)
</option>
<template v-if="isDefaultConfiguration">
<option value="disabled">
Disabled by default
</option>
</template>
<template v-else>
<option value="default">
Use default ({{simpleDefaultSettings.enableKeyboard}})
</option>
<option value="disabled">
Never
</option>
</template>
<option value="fs">
Fullscreen only
</option>
<option value="theater">
Fullscreen and theater mode
</option>
<option value="enabled">
Always
</option>
</select>
</div>
</div>
<!-- Enable UI -->
<div class="field">
<div class="label">
Enable <span class="color-emphasis">in-player UI</span>
<span class="sub-label"><br/>under the following conditions:</span>
</div>
<div class="select">
<select
:value="simpleExtensionSettings.enableUI"
@click="setExtensionMode('enableUI', $event)"
>
<template v-if="isDefaultConfiguration">
<option value="disabled">
Disabled by default
</option>
</template>
<template v-else>
<option value="default">
Use default ({{simpleDefaultSettings.enableUI}})
</option>
<option value="disabled">
Never
</option>
</template>
<option value="fs">
Fullscreen only
</option>
<option value="theater">
Always where possible
</option>
</select>
</div>
</div>
<div class="field">
<div class="label">
Use these settings for <span class="color-emphasis">embedded content</span>?
<!-- <span class="sub-label"><br/>under the following conditions:</span> -->
</div>
<div class="select">
<select
:value="siteDefaultForEmbedded"
@click="setSiteOption('applyToEmbeddedContent', $event)"
>
<option :value="EmbeddedContentSettingsOverridePolicy.Always">
Always(-ish)
</option>
<option :value="EmbeddedContentSettingsOverridePolicy.UseAsDefault">
Use as default
</option>
<option :value="EmbeddedContentSettingsOverridePolicy.Never">
Never
</option>
</select>
</div>
</div>
<div class="field">
<div class="label">
Force these settings <span class="color-emphasis">when embedded</span>?
<!-- <span class="sub-label"><br/>under the following conditions:</span> -->
</div>
<div class="select">
<select
:value="siteDefaultOverrideEmbedded"
@click="setSiteOption('overrideWhenEmbedded', $event)"
>
<option :value="true">
Yes
</option>
<option :value="false">
No
</option>
</select>
</div>
</div>
<!-- Default crop -->
<div class="field">
<div class="label">Default crop:</div>
<div class="select">
<select
:value="siteDefaultCrop"
@change="setOption('defaults.crop', $event)"
>
<option
v-if="!isDefaultConfiguration"
:value="JSON.stringify({useDefault: true})"
>
Use default ({{getCommandValue(settings?.active.commands.crop, siteSettings.data.defaults.crop)}})
</option>
<option
v-for="(command, index) of settings?.active.commands.crop"
:key="index"
:value="JSON.stringify(command.arguments)"
>
{{command.label}}
</option>
</select>
</div>
</div>
<div class="hint">This is how extension will crop video if/when autodetection is disabled. Pick 'Reset' option to keep aspect ratio as-is by default.</div>
<!-- Default stretch -->
<div class="field">
<div class="label">Default stretch:</div>
<div class="select">
<select
v-model="siteDefaultStretch"
@change="setOption('defaults.stretch', $event)"
>
<option
v-if="!isDefaultConfiguration"
:value="JSON.stringify({useDefault: true})"
>
Use default ({{getCommandValue(settings?.active.commands.stretch, siteSettings.data.defaults.stretch)}})
</option>
<option
v-for="(command, index) of settings?.active.commands.stretch"
:key="index"
:value="JSON.stringify(command.arguments)"
>
{{command.label}}
</option>
</select>
</div>
</div>
<!-- Default alignment -->
<div class="field">
<div class="label">Default alignment:</div>
<div class="select">
<select
v-model="siteDefaultAlignment"
@change="setOption('defaults.alignment', $event)"
>
<option
v-if="!isDefaultConfiguration"
:value="JSON.stringify({useDefault: true})"
>
Use default ({{getAlignmentLabel(siteSettings.data.defaults.alignment)}})
</option>
<option
v-for="(command, index) of alignmentOptions"
:key="index"
:value="JSON.stringify(command.arguments)"
>
{{command.label}}
</option>
</select>
</div>
</div>
<!-- Crop, et. al. Persistence -->
<div class="field">
<div class="label">Persist crop, stretch, and alignment between videos</div>
<div class="select">
<select
v-model="siteDefaultCropPersistence"
@click="setOption('persistCSA', $event)"
>
<option
v-if="!isDefaultConfiguration"
:value="CropModePersistence.Default"
>
Use default ({{defaultPersistanceLabel}})
</option>
<option :value="CropModePersistence.Disabled">Disabled</option>
<option :value="CropModePersistence.UntilPageReload">Until page reload</option>
<option :value="CropModePersistence.CurrentSession">Current session</option>
<option :value="CropModePersistence.Forever">Always persist</option>
</select>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import ExtensionMode from '@src/common/enums/ExtensionMode.enum';
import VideoAlignmentType from '@src/common/enums/VideoAlignmentType.enum';
import CropModePersistence from '@src/common/enums/CropModePersistence.enum';
import EmbeddedContentSettingsOverridePolicy from '@src/common/enums/EmbeddedContentSettingsOverridePolicy.enum';
export default defineComponent({
data() {
return {
CropModePersistence: CropModePersistence,
ExtensionMode,
EmbeddedContentSettingsOverridePolicy,
alignmentOptions: [
{label: 'Top left', arguments: {x: VideoAlignmentType.Left, y: VideoAlignmentType.Top}},
{label: 'Top center', arguments: {x: VideoAlignmentType.Center, y: VideoAlignmentType.Top}},
{label: 'Top right', arguments: {x: VideoAlignmentType.Right, y: VideoAlignmentType.Top}},
{label: 'Left', arguments: {x: VideoAlignmentType.Left, y: VideoAlignmentType.Center}},
{label: 'Center', arguments: {x: VideoAlignmentType.Center, y: VideoAlignmentType.Center}},
{label: 'Right', arguments: {x: VideoAlignmentType.Right, y: VideoAlignmentType.Center}},
{label: 'Bottom left', arguments: {x: VideoAlignmentType.Left, y: VideoAlignmentType.Bottom}},
{label: 'Bottom center', arguments: {x: VideoAlignmentType.Center, y: VideoAlignmentType.Bottom}},
{label: 'Bottom right', arguments: {x: VideoAlignmentType.Right, y: VideoAlignmentType.Bottom}}
]
}
},
mixins: [
],
props: [
'settings',
'siteSettings',
'isDefaultConfiguration'
],
components: {
},
computed: {
simpleExtensionSettings() {
return {
enable: this.compileSimpleSettings('enable'),
enableAard: this.compileSimpleSettings('enableAard'),
enableKeyboard: this.compileSimpleSettings('enableKeyboard'),
enableUI: this.compileSimpleSettings('enableUI')
}
},
simpleEffectiveSettings() {
return {
enable: this.compileSimpleSettings('enable', 'site-effective'),
enableAard: this.compileSimpleSettings('enableAard', 'site-effective'),
enableKeyboard: this.compileSimpleSettings('enableKeyboard', 'site-effective'),
enableUI: this.compileSimpleSettings('enableUI', 'site-effective')
}
},
simpleDefaultSettings() {
return {
enable: this.getDefaultOptionLabel('enable'),
enableAard: this.getDefaultOptionLabel('enableAard'),
enableKeyboard: this.getDefaultOptionLabel('enableKeyboard'),
enableUI: this.getDefaultOptionLabel('enableUI')
};
},
siteDefaultForEmbedded() {
return this.siteSettings.raw?.applyToEmbeddedContent;
},
siteDefaultOverrideEmbedded() {
return this.siteSettings.raw?.overrideWhenEmbedded ?? false;
},
siteDefaultCrop() {
return this.siteSettings.raw?.defaults?.crop ? JSON.stringify(this.siteSettings.raw?.defaults?.crop) : JSON.stringify({useDefault: true});
},
siteDefaultStretch() {
return this.siteSettings.raw?.defaults?.stretch ? JSON.stringify(this.siteSettings.raw?.defaults?.stretch) : JSON.stringify({useDefault: true});
},
siteDefaultAlignment() {
return this.siteSettings.raw?.defaults?.alignment ? JSON.stringify(this.siteSettings.raw?.defaults?.alignment) : JSON.stringify({useDefault: true});
},
siteDefaultCropPersistence() {
return this.siteSettings.raw?.persistCSA ?? undefined;
},
defaultPersistanceLabel() {
switch (this.siteSettings.defaultSettings.persistCSA) {
case CropModePersistence.CurrentSession:
return 'current session';
case CropModePersistence.Disabled:
return 'disabled';
case CropModePersistence.UntilPageReload:
return 'until page reload';
case CropModePersistence.Forever:
return 'Always persist';
}
return '??';
}
},
mounted() {
this.forceRefreshPage();
},
methods: {
/**
* Compiles our extension settings into more user-friendly options
*/
compileSimpleSettings(component, getFor = 'site') {
let settingsData;
switch (getFor) {
case 'site':
settingsData = this.siteSettings?.raw;
break;
case 'site-effective':
settingsData = this.siteSettings?.data;
break;
case 'default':
settingsData = this.settings.active.sites['@global'];
break;
}
try {
if (
( settingsData?.[component]?.normal === ExtensionMode.Disabled || component === 'enableUI')
&& settingsData?.[component]?.theater === ExtensionMode.Disabled
&& settingsData?.[component]?.fullscreen === ExtensionMode.Disabled
) {
return 'disabled';
}
if (
( settingsData?.[component]?.normal === ExtensionMode.Default || component === 'enableUI')
&& settingsData?.[component]?.theater === ExtensionMode.Default
&& settingsData?.[component]?.fullscreen === ExtensionMode.Default
) {
// console.log(
// component, 'is set to default because:\n',
// `\nsettingsData[${component}].normal: ${settingsData?.[component]?.normal} || component is enableUI?`, component,
// `\nsettingsData[${component}].theater: ${settingsData?.[component]?.normal}`,
// `\nsettingsData[${component}].fullscreen: ${settingsData?.[component]?.normal}`,
// `\n\n(expected values:`, ExtensionMode.Default
// )
return 'default';
}
if (
( settingsData?.[component]?.normal === ExtensionMode.Disabled || component === 'enableUI')
&& settingsData?.[component]?.theater === ExtensionMode.Disabled
&& settingsData?.[component]?.fullscreen === ExtensionMode.Enabled
) {
return 'fs';
}
if (
( settingsData?.[component]?.normal === ExtensionMode.Disabled || component === 'enableUI')
&& settingsData?.[component]?.theater === ExtensionMode.Enabled
&& settingsData?.[component]?.fullscreen === ExtensionMode.Enabled
) {
return 'theater';
}
if (
( settingsData?.[component]?.normal === ExtensionMode.Enabled || component === 'enableUI')
&& settingsData?.[component]?.theater === ExtensionMode.Enabled
&& settingsData?.[component]?.fullscreen === ExtensionMode.Enabled
) {
return 'enabled';
}
return 'complex';
} catch (e) {
return 'loading';
}
},
getDefaultOptionLabel(component) {
const componentValue = this.compileSimpleSettings(component, 'default');
if (componentValue === 'loading') {
return componentValue;
}
if (component === 'enableUI') {
switch (componentValue) {
case 'fs':
return 'fullscreen only';
case 'enabled':
case 'theater':
return 'where possible';
case 'disabled':
return 'disabled';
}
}
switch (componentValue) {
case 'fs':
return 'Fullscreen only';
case 'theater':
return 'theater & FS';
case 'enabled':
return 'always';
case 'disabled':
return 'disabled';
case 'default':
return 'complex'
}
},
getCommandValue(availableCommands, command) {
for (const cmd of availableCommands) {
if (JSON.stringify(cmd.arguments) === JSON.stringify(command)) {
return cmd.label;
}
}
return 'Unknown command';
},
getAlignmentLabel(alignment) {
// in case default settings don't have this set
if (!alignment) {
return 'Center'
}
let x, y;
if (alignment.x === VideoAlignmentType.Center) {
x = 'center';
} else if (alignment.x === VideoAlignmentType.Left) {
x = 'left';
} else if (alignment.x === VideoAlignmentType.Right) {
x = 'right';
} else {
x = '??'
}
if (alignment.y === VideoAlignmentType.Center) {
y = 'center';
} else if (alignment.y === VideoAlignmentType.Bottom) {
y = 'bottom';
} else if (alignment.y === VideoAlignmentType.Top) {
y = 'top';
} else {
y = '???'
};
if (x === y) {
return x;
}
return `${y} ${x}`;
},
getOption(option) {
},
async setOption(option, $event) {
const value = $event.target.value;
let commandArguments;
// if argument is json, parse json. Otherwise, pass the value as-is
try {
commandArguments = value !== undefined ? JSON.parse(value) : undefined;
} catch(e) {
commandArguments = value;
}
if (commandArguments.useDefault) {
commandArguments = undefined;
}
await this.siteSettings.set(option, commandArguments, {reload: false});
// we also need to force re-compute all watchers, otherwise UI will lag behind
// actual state of settings until reload
this.forceRefreshPage();
},
forceRefreshPage() {
this._computedWatchers?.simpleExtensionSettings?.run();
this._computedWatchers?.simpleDefaultSettings?.run();
this._computedWatchers?.siteDefaultCrop?.run();
this._computedWatchers?.siteDefaultStretch?.run();
this._computedWatchers?.siteDefaultAlignment?.run();
this._computedWatchers?.siteDefaultCropPersistence?.run();
this._computedWatchers?.defaultPersistanceLabel?.run();
this.$nextTick( () => this.$forceUpdate());
},
setSiteOption(optionPath, event) {
const value = event.target.value;
this.siteSettings.set(optionPath, value);
},
setExtensionMode(component, event) {
const option = event.target.value;
if (option === 'complex') {
return;
}
if (component === 'enable' && !this.isDefaultConfiguration) {
this.setExtensionMode('enableAard', event);
this.setExtensionMode('enableKeyboard', event);
// in enableUI, 'enabled' is unused and 'theater' uses its place
if (option === 'enabled') {
this.setExtensionMode('enableUI', {target: {value: 'theater'}});
} else {
this.setExtensionMode('enableUI', event);
}
}
if (option === 'default') {
return this.siteSettings.set(component, {
normal: ExtensionMode.Default,
theater: ExtensionMode.Default,
fullscreen: ExtensionMode.Default
});
}
if (option === 'disabled') {
return this.siteSettings.set(component, {
normal: ExtensionMode.Disabled,
theater: ExtensionMode.Disabled,
fullscreen: ExtensionMode.Disabled
});
}
if (option === 'enabled') {
return this.siteSettings.set(component, {
normal: ExtensionMode.Enabled,
theater: ExtensionMode.Enabled,
fullscreen: ExtensionMode.Enabled
});
}
if (option === 'theater') {
return this.siteSettings.set(component, {
normal: ExtensionMode.Disabled,
theater: ExtensionMode.Enabled,
fullscreen: ExtensionMode.Enabled
});
}
if (option === 'fs') {
return this.siteSettings.set(component, {
normal: ExtensionMode.Disabled,
theater: ExtensionMode.Disabled,
fullscreen: ExtensionMode.Enabled
});
}
}
}
});
</script>
<style scoped>
</style>

View File

@ -0,0 +1,79 @@
<template>
<div
class="p-2 border border-stone-600 hover:border-primary-400 bg-black hover:bg-stone-900"
:class="{'!border-primary-400 text-primary-300': isActive}"
>
<div class="flex flex-row cursor-default w-full">
<div class="grow">
<b>{{ host }}</b>
<span :style="getSiteTypeColor(siteSettings?.data?.type)">
(config: {{siteSettings?.data?.type ?? 'unknown'}})
</span>
</div>
<div class="cursor-pointer" @click="triggerEdit()">Edit</div>
</div>
<div v-if="this.siteSettings?.usesSettingsFor">
<div v-if="this.siteSettings.usesSettingsFor === '@global'">Uses default settings</div>
<div v-else>Uses settings for: <span class="info-color">{{this.siteSettings.usesSettingsFor}}</span></div>
</div>
<div class="flex flex-row">
<small>
Enabled: <span :style="getSiteEnabledColor(host, 'enable')"><small>{{ getSiteEnabledModes(host, 'enable') }}</small></span>;&nbsp;
Autodetect: <span :style="getSiteEnabledColor(host, 'enableAard')"><small>{{ getSiteEnabledModes(host, 'enableAard') }}</small></span>;&nbsp;
kbd: <span :style="getSiteEnabledColor(host, 'enableKeyboard')"><small>{{ getSiteEnabledModes(host, 'enableKeyboard') }}</small></span>
UI: <span :style="getSiteEnabledColor(host, 'enableUI')"><small>{{ getSiteEnabledModes(host, 'enableUI') }}</small></span>
</small>
</div>
</div>
</template>
<script>
import ExtensionMode from '@src/common/enums/ExtensionMode.enum';
export default {
data() {
return {
siteSettings: undefined,
supportType: undefined
}
},
props: [
'settings',
'parentHost',
'host',
'isActive',
],
created() {
this.siteSettings = this.settings.getSiteSettings({site: this.host, isIframe: this.parentHost && this.host !== this.parentHost, parentHostname: this.parentHost});
},
methods: {
getSiteTypeColor(siteType) {
switch (siteType) {
case 'official': return 'color: #fa6';
case 'community': return 'color: rgb(114, 114, 218)';
case 'officially-disabled': return 'color: #f00';
case 'testing': return 'color: #d81';
default: return 'color: rgb(138, 65, 126)'
};
},
getSiteEnabledColor(site, component) {
const status = this.getSiteEnabledModes(site, component);
return status === 'disabled' ? 'color: #f00' : 'color: #1f8';
},
getSiteEnabledModes(site, component) {
if (this.siteSettings?.normal === ExtensionMode.Enabled) {
return 'always';
}
if (this.siteSettings?.data[component]?.theater === ExtensionMode.Enabled) {
return 'T + FS';
}
if (this.siteSettings?.data[component]?.fullscreen === ExtensionMode.Enabled) {
return 'fullscreen';
}
return '❌';
},
triggerEdit() {
this.$emit('edit');
}
}
}
</script>

View File

@ -0,0 +1,192 @@
<template>
<div class="flex flex-row items-center">
<div
class="shortcut-button"
@click="editShortcut()"
>
<template v-if="!editing">
<span v-if="!command?.shortcut" class="italic text-[0.9em] text-stone-500">
(not assigned)
</span>
<span v-else>
{{shortcutDisplay}}
</span>
</template>
<template v-else>
{{currentKeypress ?? 'Press a key'}}
<input ref="input"
class="hidden-input"
@keyup.capture="keyup($event)"
@keydown.capture="keydown($event)"
@input.prevent=""
@blur="editing = false;"
>
</template>
</div>
</div>
</template>
<script lang="ts">
import KeyboardShortcutParser from '@src/common/js/KeyboardShortcutParser';
import { defineComponent } from 'vue';
export default defineComponent({
props: {
command: Object,
settings: Object,
},
data() {
return {
currentKeypress: undefined,
currentKey: undefined,
editing: false,
}
},
computed: {
shortcutDisplay() {
if (!this.command?.shortcut) {
return '(no shortcut)'
}
return KeyboardShortcutParser.parseShortcut(this.command.shortcut);
}
},
methods: {
editShortcut() {
this.editing = true;
this.currentKeypress = undefined;
this.currentKey = undefined;
// input doesn't exist now, but will exist on the next tick
this.$nextTick(()=> this.$refs.input.focus());
},
/**
* Updates currently pressed keypress for display
*/
keydown(event) {
// event.repeat is set to 'true' when key is being held down, but not on
// first keydown. We don't need to process subsequent repeats of a keypress
// we already processed.
if (event.repeat) {
return;
}
const shortcut = KeyboardShortcutParser.generateShortcutFromKeypress(event);
const fixedShortcut = this.handleModifierKeypress(shortcut);
if (this.currentKey === undefined) {
this.currentKey = fixedShortcut;
} else {
// here's a fun fact. Keydown doesn't do modifier keys the way we want
// notably, A-Z0-9 keys are returned without modifier state (all modifiers)
// are set to false in keydown events. That means we need to keep track of
// modifiers ourselves.
if (fixedShortcut.notModifier) {
this.currentKey.key = fixedShortcut.key;
this.currentKey.code = fixedShortcut.code;
} else {
this.currentKey = {
...fixedShortcut,
key: this.currentKey.key,
code: this.currentKey.code
}
}
}
// update display
this.currentKeypress = KeyboardShortcutParser.parseShortcut(this.currentKey);
},
/**
* Emits shortcutChanged when shortcut is considered changed
*/
keyup(event) {
const shortcut = KeyboardShortcutParser.generateShortcutFromKeypress(event);
const fixedShortcut = this.handleModifierKeypress(shortcut);
if (fixedShortcut.notModifier) {
this.command.shortcut = this.currentKey;
this.settings?.saveWithoutReload();
this.editing = false;
this.$emit('shortcutChanged', this.currentKey);
} else {
// if none of the modifiers are pressed and if no other key is being held down,
// we need to reset label back to 'pls press key'
if (!fixedShortcut.altKey && !fixedShortcut.ctrlKey && !fixedShortcut.metaKey && !fixedShortcut.shiftKey && !fixedShortcut.code) {
this.currentKeypress = undefined;
this.currentKey = undefined;
} else {
this.currentKey = shortcut;
this.currentKeypress = KeyboardShortcutParser.parseShortcut(this.currentKey);
}
}
},
/**
* Handles current keypress if event.keyCode is a modifier key.
* Returns true if current event was a modifier key, false if
* if was a regular A-Z 0-9 key.
*/
handleModifierKeypress(event) {
const modifierPressed = event.type === 'keydown';
switch (event.code) {
case 'ShiftLeft':
case 'ShiftRight':
return {
...event,
key: '…',
code: null,
shiftKey: modifierPressed
}
case 'ControlLeft':
case 'ControlRight':
return {
...event,
key: '…',
code: null,
controlKey: modifierPressed
};
case 'MetaLeft':
case 'MetaRight':
return {
...event,
key: '…',
code: null,
metaKey: modifierPressed
};
case 'AltLeft':
case 'AltRight':
return {
...event,
key: '…',
code: null,
altKey: modifierPressed
};
}
return {
...event,
notModifier: true,
}
}
}
});
</script>
<style lang="scss" scoped>
@import './KeyboardShortcutEntry.css';
.center-text {
text-align: center;
}
.dark {
opacity: 50%;
}
.hidden-input {
position: absolute;
z-index: -9999;
opacity: 0;
}
</style>

View File

@ -0,0 +1,30 @@
@import '../../../../main.css'; /** postcss processor doesn't support aliases */
.keyboard-shortcut-row {
@apply py-2 px-4
even:bg-black/25
flex flex-row items-center gap-4
border-y-1 border-y-transparent hover:border-y-stone-700 hover:bg-stone-900;
&:hover {
.hover-hide {
@apply hidden;
}
}
&:not(:hover) {
.hover-show {
@apply hidden;
}
}
}
.keyboard-shortcut-row, .hover-shortcut {
.shortcut-button {
@apply px-4 text-[0.9em] border-1 border-transparent
cursor-pointer select-none;
&:hover {
@apply bg-black border-1 border-stone-500 rounded-sm
}
}
}

View File

@ -0,0 +1,97 @@
<template>
<div class="keyboard-shortcut-row">
<div class="w-[12em] text-stone-300">
<span v-if="indent" :style="{width: indent + 'rem'}" class="inline-block"></span>{{command.label}}
</div>
<div class="w-[18em] flex flex-row items-center">
<EditShortcutButton
:settings="settings"
:command="command"
>
</EditShortcutButton>
</div>
<div v-if="state !== 'removing'" class="justify-self-end cursor-pointer hover-show flex flex-row items-center text-[0.8em]" @click="clearShortcut(command)">
<mdicon class="text-red-500" name="close" :size="12"></mdicon> <span class="pl-1">Clear</span>
</div>
<div v-if="removable && state !== 'removing'" class="justify-self-end text-red-500 cursor-pointer hover-show flex flex-row items-center text-[0.8em]" @click="removeAction(command)">
<mdicon name="delete" :size="12"></mdicon> <span class="pl-1">Remove action</span>
</div>
<div v-if="state === 'removing'" class="justify-self-end flex flex-row items-center gap-2 text-[0.8em]">
<div>Remove option?</div>
<div @click="cancelRemoval()" class="cursor-pointer flex flex-row items-center gap-1">
<mdicon name="close" :size="12"></mdicon> <span>No, do not remove</span>
</div>
<div @click="confirmRemoval(command)" class="cursor-pointer text-red-500 flex flex-row items-center gap-1">
<mdicon name="remove" :size="12"></mdicon> <span>Yes</span>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import KeyboardShortcutParserMixin from '../../../../csui/src/utils/KeyboardShortcutParserMixin';
import EditShortcutButton from './EditShortcutButton.vue';
export default defineComponent({
components: {
EditShortcutButton,
},
mixins: [
KeyboardShortcutParserMixin
],
props: [
'settings', // not used only passed on
'command',
'type',
'indent',
'removable',
],
data() {
return {
state: null,
}
},
methods: {
clearShortcut(command) {
command.shortcut = undefined;
this.settings.saveWithoutReload();
},
removeAction(command) {
if (!this.removable) {
return;
}
this.state = 'removing';
},
confirmRemoval(command) {
console.info('confirmong removal:', command)
const commandsArray = this.settings.active.commands[this.type];
let index = commandsArray.indexOf(command);
let reload = false;
// let's try fallback
if (index === -1) {
index = commandsArray.findIndex(x => JSON.stringify(x) === JSON.stringify(command))
reload = true;
}
console.log('command index:', index, this.settings.active.commands[this.type]);
if (index !== -1) {
commandsArray.splice(index, 1);
if (reload) {
this.settings.save();
} else {
this.settings.saveWithoutReload();
}
}
this.state = null;
},
cancelRemoval() {
this.state = null;
}
}
});
</script>
<style lang="postcss" scoped>
@import './KeyboardShortcutEntry.css';
</style>

View File

@ -0,0 +1,109 @@
<template>
<div class="compact-form">
<div class="field">
<div class="label"></div>
<div class="text-[0.9em] font-semibold">
Add new ratio:
</div>
</div>
<div class="field">
<div class="label">Aspect ratio:</div>
<div class="input">
<input
v-model="command.aspectRatio"
placeholder="Enter aspect ratio ..."
@blur="updateLabel()"
>
</div>
</div>
<div class="field">
<div class="label">Label:</div>
<div class="input">
<input
v-model="command.label"
placeholder="Button label"
>
</div>
</div>
<div class="field">
<div class="label">Shortcut:</div>
<div class="hover-shortcut">
<EditShortcutButton
:command="command"
></EditShortcutButton>
</div>
</div>
<div class="flex flex-row gap-1 justify-end -mt-2 text-[0.9em] text-stone-400 font-bold hover:font-bold">
<div
class="flex flex-row items-center px-2 py-1 hover:text-white cursor-pointer"
:class="{'opacity-50 pointer-events-none': !command.aspectRatio || !command.label}"
@click="add()"
>
<mdicon name="content-save" :size="16"></mdicon>
Add
</div>
<div
class="flex flex-row items-center px-2 py-1 hover:text-red-500 cursor-pointer"
@click="cancel()"
>
<mdicon name="close" :size="16"></mdicon>
Cancel
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import EditShortcutButton from './EditShortcutButton.vue';
export default defineComponent({
components: {
EditShortcutButton,
},
data() {
return {
command: {
aspectRatio: '',
label: '',
shortcut: undefined as any,
}
}
},
methods: {
updateLabel() {
this.command.label = this.command.aspectRatio;
},
add() {
console.log('emitting add', this.command);
this.$emit('add', this.command);
},
cancel() {
this.$emit('cancel');
}
}
});
</script>
<style lang="postcss" scoped>
@import './KeyboardShortcutEntry.css';
.compact-form {
@apply flex flex-col gap-1;
.field {
@apply flex flex-row items-center gap-2 mx-0 my-0 px-0 py-0;
.label {
@apply w-24 text-right;
}
.input {
@apply flex-1;
input {
@apply w-full placeholder:text-[0.8rem] placeholder:text-stone-500;
}
}
}
}
</style>

View File

@ -0,0 +1,259 @@
<template>
<div class="flex flex-col relative w-full">
<h2 class="text-[1.75em]">Keyboard shortcuts</h2>
<p class="text-stone-500 text-[0.8em]">You can edit keyboard shortcuts here. If the aspect ratio you're looking for doesn't appear on the list, you can add it.</p>
<div class="flex flex-row gap-2 w-full justify-end mt-4">
<button @click="clearShortcuts()"><mdicon name="close" :size="16"></mdicon>Clear all shortcuts</button>
<button @click="resetToDefault()"><mdicon name="reload" :size="16"></mdicon>Reset to default</button>
</div>
<div class="flex flex-col w-full">
<div class="keyboard-shortcut-row bg-stone-900 mt-4 pt-2 pb-1 border-b-1 border-b-stone-700">
<div class="w-[12em] text-center border-r-1 border-r-stone-700">Command</div>
<div class="w-[12em] text-center">Keystroke</div>
</div>
<!-- CROP -->
<div
class="keyboard-shortcut-row"
>
<div
class="w-[32em] flex flex-row gap-2 items-center"
@click="toggleGroupState('crop')"
>
<mdicon
v-if="!groupVisible.crop"
name="chevron-right"
:size="16"
/>
<mdicon
v-if="groupVisible.crop"
name="chevron-down"
:size="16"
/>
<div>Crop<br/>
<p class="text-[0.8em] text-stone-500">Safe crop options. Prefers to leave empty space instead of cutting off video edges.</p>
</div>
</div>
</div>
<div>
<!-- Here just to break even/odd -->
</div>
<template v-if="groupVisible.crop">
<KeyboardShortcutEntry
v-for="command in settings.active.commands.crop"
:key="command"
type="crop"
:indent="2"
:command="command"
:settings="settings"
:removable="command?.arguments?.type === AspectRatioType.Fixed"
></KeyboardShortcutEntry>
<div
v-if="state !== 'add-crop'"
class="ml-12 w-[16em] flex flex-row text-[0.8rem] gap-2 my-2 font-bold items-center hover:text-white cursor-pointer"
@click="startAdding('crop')"
>
<mdicon name="plus" :size="16"></mdicon><div>Add new ratio</div>
</div>
<template v-else>
<NewAspectRatioForm
type="crop"
:settings="settings"
@cancel="state = null"
@add="addAction('crop', $event)"
></NewAspectRatioForm>
</template>
</template>
<!-- ZOOM -->
<div
class="keyboard-shortcut-row"
>
<div
class="w-[32em] flex flex-row gap-2 items-center"
@click="toggleGroupState('zoom')"
>
<mdicon
v-if="!groupVisible.zoom"
name="chevron-right"
:size="16"
/>
<mdicon
v-if="groupVisible.zoom"
name="chevron-down"
:size="16"
/>
<div>Zoom<br/>
<p class="text-[0.8em] text-stone-500">Controls zoom. Will cut off parts of video in order to achieve desired zoom.</p>
</div>
</div>
</div>
<template v-if="groupVisible.zoom">
<KeyboardShortcutEntry
v-for="command in settings.active.commands.zoom"
:key="command"
type="zoom"
:indent="2"
:command="command"
:settings="settings"
:removable="command?.arguments?.type === AspectRatioType.Fixed"
></KeyboardShortcutEntry>
<div class="ml-12 w-[16em] flex flex-row text-[0.8rem] gap-2 my-2 font-bold items-center hover:text-white cursor-pointer">
<mdicon name="plus" :size="16"></mdicon><div>Add new</div>
</div>
</template>
<!-- STRETCH -->
<div
class="keyboard-shortcut-row"
>
<div
class="w-[32em] flex flex-row gap-2 items-center"
@click="toggleGroupState('stretch')"
>
<mdicon
v-if="!groupVisible.stretch"
name="chevron-right"
:size="16"
/>
<mdicon
v-if="groupVisible.stretch"
name="chevron-down"
:size="16"
/>
<div>Stretch<br/>
<p class="text-[0.8em] text-stone-500">Control stretching.</p>
</div>
</div>
</div>
<template v-if="groupVisible.stretch">
<KeyboardShortcutEntry
v-for="command in settings.active.commands.stretch"
:key="command"
type="stretch"
:indent="2"
:command="command"
:settings="settings"
:removable="command?.arguments?.type === StretchType.FixedSource"
></KeyboardShortcutEntry>
<div class="ml-12 w-[16em] flex flex-row text-[0.8rem] gap-2 my-2 font-bold items-center hover:text-white cursor-pointer">
<mdicon name="plus" :size="16"></mdicon><div>Add new</div>
</div>
</template>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import KeyboardShortcutEntry from './Components/KeyboardShortcutEntry.vue';
import AspectRatioType from '@src/common/enums/AspectRatioType.enum';
import StretchType from '@src/common/enums/StretchType.enum';
import NewAspectRatioForm from './Components/NewAspectRatioForm.vue';
import BrowserDetect from '@src/ext/conf/BrowserDetect';
import CropOptionsPanel from '@csui/src/PlayerUiPanels/PanelComponents/VideoSettings/CropOptionsPanel.vue'
import StretchOptionsPanel from '@csui/src/PlayerUiPanels/PanelComponents/VideoSettings/StretchOptionsPanel.vue'
import ZoomOptionsPanel from '@csui/src/PlayerUiPanels/PanelComponents/VideoSettings/ZoomOptionsPanel.vue'
export default defineComponent({
components: {
KeyboardShortcutEntry,
NewAspectRatioForm,
CropOptionsPanel,
StretchOptionsPanel,
ZoomOptionsPanel,
},
data() {
return {
AspectRatioType: AspectRatioType,
StretchType: StretchType,
groupVisible: {
crop: true,
stretch: true,
zoom: true,
},
state: null,
}
},
mixins: [
],
props: [
'settings', // required for buttons and actions, which are global
],
mounted() {
this.ghettoComputed = {
minEnabledWidth: this.optionalToFixed(this.settings.active.ui.inPlayer.minEnabledWidth * 100, 0),
minEnabledHeight: this.optionalToFixed(this.settings.active.ui.inPlayer.minEnabledHeight * 100, 0),
}
},
methods: {
toggleGroupState(groupName: string) {
this.groupVisible[groupName] = !this.groupVisible[groupName];
},
forcePositiveNumber(value) {
// Change EU format to US if needed
// | remove everything after second period if necessary
// | | | remove non-numeric characters
// | | | |
return value.replaceAll(',', '.').split('.', 2).join('.').replace(/[^0-9.]/g, '');
},
optionalToFixed(v, n) {
if ((`${v}`.split('.')[1]?.length ?? 0) > n) {
return v.toFixed(n);
}
return v;
},
addAction(segment: 'stretch' | 'crop' | 'zoom', newCommand) {
const actionMap = {
stretch: 'set-stretch',
crop: 'set-ar',
zoom: 'set-ar-zoom',
};
const typeMap = {
stretch: StretchType.FixedSource,
crop: AspectRatioType.Fixed,
zoom: AspectRatioType.Fixed,
};
const command = {
action: actionMap[segment],
label: newCommand.label,
arguments: {
type: typeMap[segment],
ratio: newCommand.ratio
},
shortcut: newCommand.shortcut,
};
this.settings.active.commands[segment].push(command);
this.settings.saveWithoutReload();
this.state = null;
},
clearShortcuts() {
for (const commandList of ['stretch', 'crop', 'zoom']) {
for (const command of this.settings.active.commands[commandList]) {
command.shortcut = undefined;
}
}
this.settings.saveWithoutReload();
},
resetToDefault() {
this.settings.active.commands = JSON.parse(JSON.stringify(this.settings.default.commands));
this.settings.save();
},
startAdding(type: string) {
this.state = `add-${type}`;
},
}
});
</script>
<style lang="postcss" scoped>
@import './Components/KeyboardShortcutEntry.css';
</style>

View File

@ -1,9 +1,9 @@
<template> <template>
<div <div
class="popup-panel flex flex-col uw-clickable h-full main-window relative" class="flex flex-col uw-clickable w-full h-full main-window relative"
> >
<div class="tab-main flex flex-row"> <div class="flex flex-row">
<div class="tab-row flex flex-col grow-0 shrink-0"> <div class="settings-categories">
<div <div
v-for="tab of tabs" v-for="tab of tabs"
:key="tab.id" :key="tab.id"
@ -17,6 +17,9 @@
}" }"
@click="selectTab(tab.id)" @click="selectTab(tab.id)"
> >
<div class="label">
{{tab.label}}
</div>
<div class="icon-container"> <div class="icon-container">
<mdicon <mdicon
v-if="tab.icon" v-if="tab.icon"
@ -24,9 +27,6 @@
:size="32" :size="32"
/> />
</div> </div>
<div class="label">
{{tab.label}}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -57,6 +57,18 @@
:eventBus="eventBus" :eventBus="eventBus"
:site="site" :site="site"
></VideoSettings> --> ></VideoSettings> -->
<OtherSiteSettings
v-if="selectedTab === 'extensionSettings'"
:settings="settings"
:enableSettingsEditor="true"
></OtherSiteSettings>
<KeyboardShortcutSettings
v-if="selectedTab === 'keyboardShortcuts'"
:settings="settings"
:eventBus="eventBus"
></KeyboardShortcutSettings>
<PlayerDetectionPanel <PlayerDetectionPanel
v-if="selectedTab === 'playerDetection'" v-if="selectedTab === 'playerDetection'"
:siteSettings="siteSettings" :siteSettings="siteSettings"
@ -71,13 +83,6 @@
:eventBus="eventBus" :eventBus="eventBus"
> >
</PlayerUiSettings> </PlayerUiSettings>
<BaseExtensionSettings
v-if="selectedTab === 'extensionSettings'"
:settings="settings"
:siteSettings="siteSettings"
:site="site"
:enableSettingsEditor="true"
></BaseExtensionSettings>
<AutodetectionSettingsPanel <AutodetectionSettingsPanel
v-if="selectedTab === 'autodetectionSettings'" v-if="selectedTab === 'autodetectionSettings'"
:settings="settings" :settings="settings"
@ -110,24 +115,29 @@
</div> </div>
</div> </div>
</template> </template>
<script> <script lang="ts">
import DebugPanel from './PlayerUiPanels/DebugPanel.vue' import { defineComponent } from 'vue';
import AutodetectionSettingsPanel from './PlayerUiPanels/AutodetectionSettingsPanel.vue' import OtherSiteSettings from '@components/ExtensionSettings/Panels/OtherSiteSettings.vue';
import BaseExtensionSettings from './PlayerUiPanels/BaseExtensionSettings.vue' import KeyboardShortcutSettings from '@components/KeyboardShortcuts/KeyboardShortcutSettings.vue';
import PlayerDetectionPanel from './PlayerUiPanels/PlayerDetectionPanel.vue'
import VideoSettings from './PlayerUiPanels/VideoSettings.vue' import DebugPanel from '../../csui/src/PlayerUiPanels/DebugPanel.vue'
import AutodetectionSettingsPanel from '../../csui/src/PlayerUiPanels/AutodetectionSettingsPanel.vue'
import PlayerDetectionPanel from '../../csui/src/PlayerUiPanels/PlayerDetectionPanel.vue'
import VideoSettings from '../../csui/src/PlayerUiPanels/VideoSettings.vue'
import BrowserDetect from '../../ext/conf/BrowserDetect' import BrowserDetect from '../../ext/conf/BrowserDetect'
import ChangelogPanel from './PlayerUiPanels/ChangelogPanel.vue' import ChangelogPanel from '../../csui/src/PlayerUiPanels/ChangelogPanel.vue'
import AboutPanel from '@csui/src/PlayerUiPanels/AboutPanel.vue' import AboutPanel from '@csui/src/PlayerUiPanels/AboutPanel.vue'
import PlayerUiSettings from './PlayerUiPanels/PlayerUiSettings.vue' import PlayerUiSettings from '../../csui/src/PlayerUiPanels/PlayerUiSettings.vue'
import ResetBackupPanel from './PlayerUiPanels/ResetBackupPanel.vue' import ResetBackupPanel from '../../csui/src/PlayerUiPanels/ResetBackupPanel.vue'
import SupportLevelIndicator from '@csui/src/components/SupportLevelIndicator.vue' import SupportLevelIndicator from '@csui/src/components/SupportLevelIndicator.vue'
export default { export default defineComponent({
components: { components: {
OtherSiteSettings,
KeyboardShortcutSettings,
VideoSettings, VideoSettings,
PlayerDetectionPanel, PlayerDetectionPanel,
BaseExtensionSettings,
AutodetectionSettingsPanel, AutodetectionSettingsPanel,
DebugPanel, DebugPanel,
PlayerUiSettings, PlayerUiSettings,
@ -146,9 +156,10 @@ export default {
tabs: [ tabs: [
// {id: 'videoSettings', label: 'Video settings', icon: 'crop'}, // {id: 'videoSettings', label: 'Video settings', icon: 'crop'},
{id: 'extensionSettings', label: 'Site and Extension options', icon: 'cogs' }, {id: 'extensionSettings', label: 'Site and Extension options', icon: 'cogs' },
{id: 'playerUiSettings', label: 'UI and keyboard', icon: 'movie-cog-outline' },
{id: 'playerDetection', label: 'Player detection', icon: 'television-play'},
{id: 'autodetectionSettings', label: 'Autodetection options', icon: 'auto-fix'}, {id: 'autodetectionSettings', label: 'Autodetection options', icon: 'auto-fix'},
{id: 'playerUiSettings', label: 'UI settings', icon: 'movie-cog-outline' },
{id: 'keyboardShortcuts', label: 'Keyboard shortcuts', icon: 'keyboard-outline' },
{id: 'playerDetection', label: 'Player detection', icon: 'television-play'},
// {id: 'advancedOptions', label: 'Advanced options', icon: 'cogs' }, // {id: 'advancedOptions', label: 'Advanced options', icon: 'cogs' },
{id: 'changelog', label: 'What\'s new', icon: 'alert-decagram' }, {id: 'changelog', label: 'What\'s new', icon: 'alert-decagram' },
{id: 'about', label: 'About', icon: 'information-outline'}, {id: 'about', label: 'About', icon: 'information-outline'},
@ -164,7 +175,7 @@ export default {
'settings', 'settings',
'eventBus', 'eventBus',
'logger', 'logger',
'in-player', 'inPlayer',
'site', 'site',
'defaultTab' 'defaultTab'
], ],
@ -185,7 +196,7 @@ export default {
this.siteSettings = this.settings.getSiteSettings({site: this.site}); this.siteSettings = this.settings.getSiteSettings({site: this.site});
this.tabs.find(x => x.id === 'changelog').highlight = !this.settings.active?.whatsNewChecked; this.tabs.find(x => x.id === 'changelog').highlight = !this.settings.active?.whatsNewChecked;
this.eventBus.subscribe( this.eventBus?.subscribe(
'uw-show-ui', 'uw-show-ui',
{ {
source: this, source: this,
@ -200,7 +211,7 @@ export default {
}, },
destroyed() { destroyed() {
this.settings.removeListenerAfterChange(this.setDebugTabVisibility); this.settings.removeListenerAfterChange(this.setDebugTabVisibility);
this.eventBus.unsubscribeAll(this); this.eventBus?.unsubscribeAll(this);
}, },
methods: { methods: {
/** /**
@ -210,6 +221,7 @@ export default {
return BrowserDetect.getURL(url); return BrowserDetect.getURL(url);
}, },
selectTab(tab) { selectTab(tab) {
console.log("Selecting tab", tab);
this.selectedTab = tab; this.selectedTab = tab;
}, },
setPreventClose(bool) { setPreventClose(bool) {
@ -223,182 +235,34 @@ export default {
} }
} }
} }
} });
</script> </script>
<style lang="scss" scoped> <style lang="postcss" scoped>
/* @import '../../main.css'; /** postcss processor doesn't support aliases */
@import '../res/css/uwui-base.scss';
@import '../res/css/colors.scss';
@import '../res/css/font/overpass.css';
@import '../res/css/font/overpass-mono.css';
@import '../res/css/common.scss';
@import '../src/res-common/_variables';
*/
.tab-row { .settings-categories {
width: 22rem; @apply w-[20em] max-w-[20em] flex flex-col grow-0 shrink-0 mr-4 border-r border-r-stone-800 text-right;
flex-grow: 0;
flex-shrink: 0;
}
.tab-main { .tab {
flex-grow: 1; @apply flex flex-row gap-4 px-4 py-2 justify-end items-center
flex-shrink: 1; text-[1.125em] text-stone-300 text-right font-mono
overflow: hidden; cursor-pointer
} hover:bg-stone-800
hover:text-primary-200;
.content {
flex-grow: 1;
.warning-area {
flex-grow: 0;
flex-shrink: 0;
}
.panel-content {
flex-grow: 1;
flex-shrink: 1;
overflow-y: auto;
padding: 1rem;
}
}
.warning-box {
background: rgb(255, 174, 107);
color: #000;
margin: 1rem;
padding: 1rem;
display: flex;
flex-direction: row;
align-items: center;
.icon-container {
margin-right: 1rem;
flex-shrink: 0;
flex-grow: 0;
}
a {
color: rgba(0,0,0,0.7);
cursor: pointer;
}
}
.site-support-info {
display: flex;
flex-direction: row;
align-items: bottom;
.site-support-site {
font-size: 1.5em;
}
}
.popup-panel {
background-color: rgba(0,0,0,0.50);
color: #fff;
overflow-y: auto;
.popup-window-header {
padding: 1rem;
background-color: rgba(5,5,5, 0.75);
display: flex;
flex-direction: row;
.header-title {
flex: 1 1;
}
.header-buttons {
flex: 0 0;
display: flex;
flex-direction: row;
.header-button {
cursor: pointer;
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
justify-content: center;
align-items: center;
&.button-active {
background-color: #fa6;
color: #000;
&:hover {
color: #ccc;
}
}
&.close-button {
color: #f00;
}
&:hover {
color: #fa6;
}
}
}
}
.tab-row {
background-color: rgba(11,11,11, 0.75);
.tab {
display: flex;
flex-direction: row;
align-items: center;
padding: 2rem;
font-size: 1.5rem;
height: 6rem;
border-bottom: 1px solid rgba(128, 128, 128, 0.5);
border-top: 1px solid rgba(128, 128, 128, 0.5);
opacity: 0.5;
&:hover {
opacity: 1;
}
&.active {
opacity: 1.0;
background-color: $primaryBg;
color: rgb(255, 174, 107);
border-bottom: 1px solid rgba(116, 78, 47, 0.5);
border-top: 1px solid rgba(116, 78, 47, 0.5);
}
.icon-container {
width: 64px;
flex-grow: 0;
flex-shrink: 0;
}
.label { .label {
flex-grow: 1; @apply grow-0;
flex-shrink: 1;
padding: 0 !important;
} }
&.highlight-tab { &.active {
opacity: 0.9; @apply !bg-transparent !text-primary-300 bg-gradient-to-r from-transparent to-black border-none hover:!text-primary-200;
color: #eee;
}
} }
} }
.popup-title, .popup-title h1 {
font-size: 48px !important;
}
} }
pre { pre {
white-space: pre-wrap; white-space: pre-wrap;
} }
</style> </style>

View File

@ -0,0 +1,296 @@
<template>
<div class="flex flex-col" style="position: relative; width: 100%;">
<!-- The rest of the tab is under 'edit ratios and shortcuts' row -->
<div class="flex flex-col" style="width: 100%">
<h2>Player UI options</h2>
<div class="flex flex-col compact-form">
<div v-if="!siteSettings.data.enableUI.fullscreen">
UI is disabled for this site.
</div>
<div
class="flex flex-col field-group compact-form"
:class="{disabled: !siteSettings.data.enableUI.fullscreen}"
>
<div class="field disabled">
<div class="label">
Popup activator position:
</div>
<div class="select">
<select
v-model="settings.active.ui.inPlayer.popupAlignment"
@change="saveSettings()"
>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
</div>
</div>
<div class="field">
<div class="label">
Activate in-player UI:
</div>
<div class="select">
<select
v-model="settings.active.ui.inPlayer.activation"
@change="saveSettings()"
>
<option value="player">
When mouse hovers over player
</option>
<option value="trigger-zone">
When mouse hovers over trigger zone
</option>
</select>
</div>
</div>
<div class="field" :class="{'disabled': settings.active.ui.inPlayer.activation !== 'trigger-zone'}">
<div class="label">Edit trigger zone:</div>
<button @click="startTriggerZoneEdit()">Edit</button>
</div>
<div class="field">
<div class="label">
Do not show in-player UI when video player is narrower than
</div>
<div class="input range-input">
<input
:value="settings.active.ui.inPlayer.minEnabledWidth"
class="slider"
type="range"
min="0"
max="1"
step="0.01"
@input="(event) => setPlayerRestrictions('minEnabledWidth', event.target.value)"
@change="(event) => saveSettings()"
>
<input
style="margin-right: 0.6rem;"
:value="ghettoComputed.minEnabledWidth"
@input="(event) => setPlayerRestrictions('minEnabledWidth', event.target.value, true)"
@change="(event) => saveSettings(true)"
>
<div class="unit">% of screen</div>
</div>
</div>
<div class="field">
<div class="label">
Do not show in-player UI when video player is shorter than
</div>
<div class="input range-input">
<input
:value="settings.active.ui.inPlayer.minEnabledHeight"
class="slider"
type="range"
min="0"
max="1"
step="0.01"
@input="(event) => setPlayerRestrictions('minEnabledHeight', event.target.value)"
@change="(event) => saveSettings()"
>
<input
style="margin-right: 0.6rem;"
:value="ghettoComputed.minEnabledHeight"
@input="(event) => setPlayerRestrictions('minEnabledHeight', event.target.value, true)"
@change="(event) => saveSettings(true)"
>
<div class="unit">% of screen</div>
</div>
</div>
</div>
</div>
<h2 class="mt2r">Menu options and keyboard shortcuts</h2>
<div>
Click 'add new' to add a new option. Click a button to edit or remove the keyboard shortcut.
</div>
<div class="keyboard-settings">
<!-- CROP OPTIONS -->
<div>
<div class="flex flex-row">
<h3 class="mth3">CROP OPTIONS</h3>
</div>
<CropOptionsPanel
:settings="settings"
:eventBus="eventBus"
:isEditing="true"
>
</CropOptionsPanel>
</div>
<!-- STRETCH OPTIONS -->
<div>
<div class="flex flex-row">
<h3 class="mth3">STRETCH OPTIONS</h3>
</div>
<StretchOptionsPanel
:settings="settings"
:eventBus="eventBus"
:isEditing="true"
></StretchOptionsPanel>
</div>
<!-- ZOOM OPTIONS -->
<div>
<div class="flex flex-row">
<h3 class="mth3">ZOOM OPTIONS</h3>
</div>
<ZoomOptionsPanel
:settings="settings"
:eventBus="eventBus"
:isEditing="true"
></ZoomOptionsPanel>
</div>
</div>
</div>
</div>
</template>
<script>
import Button from '@csui/src/components/Button.vue'
import BrowserDetect from '@src/ext/conf/BrowserDetect';
import CropOptionsPanel from '@csui/src/PlayerUiPanels/PanelComponents/VideoSettings/CropOptionsPanel.vue'
import StretchOptionsPanel from '@csui/src/PlayerUiPanels/PanelComponents/VideoSettings/StretchOptionsPanel.vue'
import ZoomOptionsPanel from '@csui/src/PlayerUiPanels/PanelComponents/VideoSettings/ZoomOptionsPanel.vue'
export default {
components: {
Button,
CropOptionsPanel,
StretchOptionsPanel,
ZoomOptionsPanel,
},
data() {
return {
ghettoComputed: { }
}
},
mixins: [
],
props: [
'settings', // required for buttons and actions, which are global
'siteSettings',
'eventBus',
],
mounted() {
this.ghettoComputed = {
minEnabledWidth: this.optionalToFixed(this.settings.active.ui.inPlayer.minEnabledWidth * 100, 0),
minEnabledHeight: this.optionalToFixed(this.settings.active.ui.inPlayer.minEnabledHeight * 100, 0),
}
},
methods: {
forcePositiveNumber(value) {
// Change EU format to US if needed
// | remove everything after second period if necessary
// | | | remove non-numeric characters
// | | | |
return value.replaceAll(',', '.').split('.', 2).join('.').replace(/[^0-9.]/g, '');
},
optionalToFixed(v, n) {
if ((`${v}`.split('.')[1]?.length ?? 0) > n) {
return v.toFixed(n);
}
return v;
},
setPlayerRestrictions(key, value, isTextInput) {
if (isTextInput) {
value = (+this.forcePositiveNumber(value) / 100);
}
if (isNaN(+value)) {
value = 0.5;
}
this.settings.active.ui.inPlayer[key] = value;
if (isTextInput) {
this.ghettoComputed[key] = this.optionalToFixed(value, 0);
} else {
this.ghettoComputed[key] = this.optionalToFixed(value * 100, 0);
}
},
saveSettings(forceRefresh) {
this.settings.saveWithoutReload();
if (forceRefresh) {
this.$nextTick( () => this.$forceRefresh() );
}
},
startTriggerZoneEdit() {
this.eventBus.send('start-trigger-zone-edit');
},
async openOptionsPage() {
BrowserDetect.runtime.openOptionsPage();
},
}
}
</script>
<style lang="scss" src="../../res/css/flex.scss" scoped module></style>
<style lang="scss" src="@csui/src/res-common/panels.scss" scoped module></style>
<style lang="scss" src="@csui/src/res-common/common.scss" scoped module></style>
<style lang="scss" scoped>
.justify-center {
justify-content: center;
}
.items-center {
align-items: center;
}
.mt-4{
margin-top: 1rem;
}
.input {
max-width: 24rem;
}
.trigger-zone-editor {
background-color: rgba(0,0,0,0.25);
padding-bottom: 2rem;
.field {
margin-bottom: -1em;
}
}
.disabled {
pointer-events: none;
/* color: #666; */
filter: contrast(50%) brightness(40%) grayscale(100%);
}
.compact-form {
> .field, > .field-group {
margin-top: 0;
margin-bottom: 0;
}
}
.keyboard-settings {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 1rem;
> * {
width: calc(33% - 0.5rem);
}
}
.mt2r {
margin-top: 2rem;
margin-bottom: 0.5rem;
}
.mth3 {
margin-top: 1.5rem;
}
</style>

View File

@ -0,0 +1,15 @@
import KeyboardShortcutParser from '@src/common/js/KeyboardShortcutParser';
export default {
methods: {
/**
* Parses command's keyboard shortcut into human-readable label
*/
getKeyboardShortcutLabel(command): string {
if (! command.shortcut) {
return '';
}
return KeyboardShortcutParser.parseShortcut(command.shortcut);
},
}
}

View File

@ -1,5 +1,23 @@
<template> <template>
this will be a settings page. <div class="w-full flex flex-row w-full h-full justify-center items-center py-4 px-16">
<!-- page content -->
<div class="w-full max-w-[1920px]">
<h1 class="text-[3em]">Ultrawidify settings</h1>
<div v-if="!settingsInitialized" class="flex flex-row w-full justify-center items-center">
Loading settings...
</div>
<SettingsWindowContent v-else
:settings="settings"
:logger="logger"
:inPlayer="false"
:site="null"
>
</SettingsWindowContent>
</div>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
@ -8,8 +26,12 @@ import BrowserDetect from '../../../ext/conf/BrowserDetect';
import { LogAggregator } from '@src/ext/lib/logging/LogAggregator'; import { LogAggregator } from '@src/ext/lib/logging/LogAggregator';
import { ComponentLogger } from '@src/ext/lib/logging/ComponentLogger'; import { ComponentLogger } from '@src/ext/lib/logging/ComponentLogger';
import Settings from '@src/ext/lib/settings/Settings'; import Settings from '@src/ext/lib/settings/Settings';
import SettingsWindowContent from '@components/SettingsWindowContent.vue';
export default defineComponent({ export default defineComponent({
components: {
SettingsWindowContent
},
data () { data () {
return { return {
settings: undefined as Settings | undefined, settings: undefined as Settings | undefined,
@ -29,8 +51,6 @@ export default defineComponent({
await this.settings.init(); await this.settings.init();
this.settingsInitialized = true; this.settingsInitialized = true;
}, },
components: {
},
methods: { methods: {
updateConfig() { updateConfig() {
this.settings.init(); this.settings.init();

View File

@ -10,7 +10,6 @@
<% } %> <% } %>
</head> </head>
<body class="uw-ultrawidify-container-root"> <body class="uw-ultrawidify-container-root">
settings index — app will be loaded below
<div id="app"></div> <div id="app"></div>
<script src="settings.js"></script> <script src="settings.js"></script>
</body> </body>

BIN
src/ui/res/fonts/Heebo.ttf Normal file

Binary file not shown.

View File

@ -1,50 +0,0 @@
## License
Copyright 2015 Red Hat, Inc.,
This Font Software is dual licensed and available under the SIL Open Font License, Version 1.1. and also the LGPL 2.1
The Open Font license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
The GNU LGPL can be found at http://www.gnu.org/licenses/old-licenses/lgpl-2.1.en.html
#### SIL OPEN FONT LICENSE
Version 1.1 - 26 February 2007
---
#### PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
#### DEFINITIONS
“Font Software” refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
“Reserved Font Name” refers to any names specified as such after the copyright statement(s).
“Original Version” refers to the collection of Font Software components as distributed by the Copyright Holder(s).
“Modified Version” refers to any derivative made by adding to, deleting, or substituting—in part or in whole—any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
“Author” refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
#### PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
#### TERMINATION
This license becomes null and void if any of the above conditions are not met.
#### DISCLAIMER
THE FONT SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,93 @@
Copyright 2014 The Heebo Project Authors (https://github.com/OdedEzer/heebo)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,93 @@
Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,18 @@
@import "tailwindcss";
@import "tailwindcss/utilities";
@layer base {
button {
@apply
flex flex-row items-center gap-1
px-4 py-1
bg-black
text-white font-medium
border-1 border-stone-700
cursor-pointer;
&:hover {
@apply border-primary-500 text-primary-300;
}
}
}

View File

@ -0,0 +1,25 @@
@font-face {
font-family: "Heebo";
src: url("../fonts/Heebo.ttf") format("truetype-variations");
font-weight: 100 900;
font-stretch: 75% 125%;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Source Code Pro";
src: url("../fonts/SourceCodePro.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Source Code Pro";
src: url("../fonts/SourceCodePro-Italic.ttf") format("truetype-variations");
font-weight: 200 900;
font-style: italic;
font-display: swap;
}

135
src/ui/res/styles/forms.css Normal file
View File

@ -0,0 +1,135 @@
@import "tailwindcss";
@import "tailwindcss/utilities";
.input, .range-input {
@apply py-1 px-2 bg-black/25 border-b border-white/50 w-full relative;
&:active, &:focus, &:focus-within {
@apply border-b-1 border-b-primary-400;
}
&.no-bg {
background-color: transparent;
border-color: transparent;
}
input {
width: 100%;
outline: none;
border: 1px solid transparent;
background-color: transparent;
color: #fff;
}
.unit {
position: absolute;
right: 0px;
pointer-events: none;
opacity: 0.69;
font-size: 0.8rem;
top: 0;
transform: translateY(69%);
}
}
.range-input {
@apply flex flex-row;
* {
@apply mx-2;
}
input {
max-width: 5rem;
}
input[type=range] {
max-width: none;
}
}
.field {
@apply flex flex-row w-full my-2 items-center;
&.l2 {
margin-left: 4rem;
}
.label {
flex: 0 0 25%;
min-width: 16rem;
text-align: right;
padding-right: 1rem;
.color-emphasis {
@apply text-primary-400;
}
.sub-label {
font-size: 0.9em;
opacity: 0.69;
}
}
.input, .range-input {
flex-grow: 1;
flex-shrink: 1;
max-width: 24rem;
min-width: 16rem;
}
.has-hint {
display: flex;
flex-direction: column;
}
.select {
flex-grow: 1;
flex-shrink: 1;
min-width: 12px;
max-width: 24rem;
select {
@apply bg-stone-950 border border-primary-800 text-white px-4 py-1;
min-width: 12px;
max-width: 100%;
outline: none;
font: inherit;
font-size: inherit;
option {
@apply bg-stone-950 text-white;
&:checked {
@apply text-primary-400;
}
&:before {
content: ">";
display: none;
background-color: #f41 !important;
font-size: 5rem;;
}
&:hover {
@apply text-black bg-primary-400;
}
}
}
}
}
.hint {
font-size: 0.8rem;
opacity: 0.7;
margin-top: 0.25rem;
margin-bottom: 0.75rem;
margin-left: 5rem;
box-sizing:border-box;
&.error {
color: #f41;
}
}

View File

@ -1,10 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{vue,js,ts,jsx,tsx}',
],
theme: {
extend: {},
},
plugins: [],
};

28
tailwind.config.json Normal file
View File

@ -0,0 +1,28 @@
{
"content": [
"./src/**/*.{vue,js,ts,jsx,tsx}"
],
"theme": {
"extend": {
"colors": {
"primary": {
"50": "#ffddbe",
"100": "#ffcda0",
"200": "#ffbd83",
"300": "#ffac66",
"400": "#ff9a4a",
"500": "#ff872c",
"600": "#de7622",
"700": "#be6518",
"800": "#9f540e",
"900": "#824406",
"950": "#663400"
}
},
"fontFamily": {
"sans": ["Heebo", "ui-sans-serif", "system-ui"],
"mono": ["Source Code Pro", "ui-monospace", "monospace"]
}
}
}
}

View File

@ -78,7 +78,7 @@ const config = {
] ]
}, },
{ {
test: /\.(sc|c)ss$/, test: /\.(sc|c|postc)ss$/,
use: [ use: [
'vue-style-loader', 'vue-style-loader',
{ {
@ -90,19 +90,7 @@ const config = {
// } // }
} }
}, },
{ 'postcss-loader',
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
require('postcss-strip-inline-comments')(),
require('postcss-preset-env') ({
stage: 1,
})
]
}
}
}
] ]
}, },
{ {