Merge branch 'master' into update-simple-icons

This commit is contained in:
Alicia Sykes
2024-04-29 00:54:02 +01:00
committed by GitHub
168 changed files with 7153 additions and 4891 deletions

View File

@@ -30,7 +30,7 @@
<!-- License -->
<h3>{{ $t('app-info.license') }}</h3>
{{ $t('app-info.license-under') }} <a href="https://github.com/Lissy93/dashy/blob/master/LICENSE">MIT X11</a>.
Copyright <a href="https://aliciasykes.com">Alicia Sykes</a> © 2021.<br>
Copyright <a href="https://aliciasykes.com">Alicia Sykes</a> © {{new Date().getFullYear()}}.<br>
{{ $t('app-info.licence-third-party') }} <a href="https://github.com/Lissy93/dashy/blob/master/.github/LEGAL.md">{{ $t('app-info.licence-third-party-link') }}</a>.<br>
{{ $t('app-info.list-contributors') }} <a href="https://github.com/Lissy93/dashy/blob/master/docs/credits.md">{{ $t('app-info.list-contributors-link') }}</a>.
<!-- App Version -->

View File

@@ -155,12 +155,23 @@ export default {
},
/* When restored data is revieved, then save to local storage, and apply it in state */
applyRestoredData(config, backupId) {
// Store restored data in local storage
localStorage.setItem(localStorageKeys.CONF_SECTIONS, JSON.stringify(config.sections));
localStorage.setItem(localStorageKeys.APP_CONFIG, JSON.stringify(config.appConfig));
localStorage.setItem(localStorageKeys.PAGE_INFO, JSON.stringify(config.pageInfo));
if (config.appConfig.theme) {
localStorage.setItem(localStorageKeys.THEME, config.appConfig.theme);
const isSubPage = !!this.$store.state.currentConfigInfo.confId;
if (isSubPage) { // Apply to sub-page only
const subConfigId = this.$store.state.currentConfigInfo.confId;
const sectionStorageKey = `${localStorageKeys.CONF_SECTIONS}-${subConfigId}`;
const pageInfoStorageKey = `${localStorageKeys.PAGE_INFO}-${subConfigId}`;
const themeStoreKey = `${localStorageKeys.THEME}-${subConfigId}`;
localStorage.setItem(sectionStorageKey, JSON.stringify(config.sections));
localStorage.setItem(pageInfoStorageKey, JSON.stringify(config.pageInfo));
localStorage.setItem(themeStoreKey, config.appConfig.theme);
} else { // Apply to main config
localStorage.setItem(localStorageKeys.CONF_SECTIONS, JSON.stringify(config.sections));
localStorage.setItem(localStorageKeys.APP_CONFIG, JSON.stringify(config.appConfig));
localStorage.setItem(localStorageKeys.PAGE_INFO, JSON.stringify(config.pageInfo));
localStorage.setItem(localStorageKeys.CONF_PAGES, JSON.stringify(config.pages || []));
if (config.appConfig.theme) {
localStorage.setItem(localStorageKeys.THEME, config.appConfig.theme);
}
}
// Save hashed token in local storage
this.setBackupIdLocally(backupId, this.restorePassword);

View File

@@ -47,16 +47,17 @@
</Button>
<!-- Display app version and language -->
<p class="language">{{ getLanguage() }}</p>
<p v-if="$store.state.currentConfigInfo" class="config-location">
Using Config From<br>
{{ $store.state.currentConfigInfo.confPath }}
<!-- Display location of config file -->
<p class="config-location">
Using config from
<a :href="configPath">{{ configPath }}</a>
</p>
<AppVersion />
</div>
<!-- Display note if Config disabled, or if on mobile -->
<p v-if="!enableConfig" class="config-disabled-note">{{ $t('config.disabled-note') }}</p>
<p class="small-screen-note" style="display: none;">{{ $t('config.small-screen-note') }}</p>
<div class="config-note">
<div class="config-note" @click="openExportConfigModal">
<span>{{ $t('config.backup-note') }}</span>
</div>
</div>
@@ -116,6 +117,11 @@ export default {
enableConfig() {
return this.$store.getters.permissions.allowViewConfig;
},
configPath() {
return this.$store.state.currentConfigInfo?.confPath
|| process.env.VUE_APP_CONFIG_PATH
|| '/conf.yml';
},
},
components: {
Button,
@@ -248,8 +254,12 @@ a.hyperlink-wrapper {
p.app-version, p.language, p.config-location {
margin: 0.5rem auto;
font-size: 1rem;
color: var(--transparent-white-50);
color: var(--config-settings-color);
cursor: default;
opacity: var(--dimming-factor);
a {
color: var(--config-settings-color);
}
}
div.code-container {

View File

@@ -115,7 +115,10 @@ export default {
},
},
mounted() {
this.jsonData = this.config;
const jsonData = { ...this.config };
jsonData.sections = (jsonData.sections || []).map(({ filteredItems, ...section }) => section);
if (!jsonData.pageInfo) jsonData.pageInfo = { title: 'Dashy' };
this.jsonData = jsonData;
if (!this.allowWriteToDisk) this.saveMode = 'local';
},
methods: {
@@ -141,7 +144,11 @@ export default {
this.$modal.hide(modalNames.CONF_EDITOR);
},
writeToDisk() {
this.writeConfigToDisk(this.config);
const newData = this.jsonData;
this.writeConfigToDisk(newData);
// this.$store.commit(StoreKeys.SET_APP_CONFIG, newData.appConfig);
this.$store.commit(StoreKeys.SET_PAGE_INFO, newData.pageInfo);
this.$store.commit(StoreKeys.SET_SECTIONS, newData.sections);
},
saveLocally() {
const msg = this.$t('interactive-editor.menu.save-locally-warning');

View File

@@ -94,6 +94,7 @@ export default {
const raw = rawAppConfig;
const isEmptyObject = (obj) => (typeof obj === 'object' && Object.keys(obj).length === 0);
const isEmpty = (value) => (value === undefined || isEmptyObject(value));
// Delete empty values
Object.keys(raw).forEach(key => {
if (isEmpty(raw[key])) delete raw[key];

View File

@@ -22,6 +22,14 @@
<DownloadConfigIcon />
</Button>
</div>
<!-- Show path to which config file is being used -->
<div class="config-path-info">
<h3>Config Location</h3>
<p>
The base config file you are currently using is
<a :href="configPath">{{ configPath }}</a>
</p>
</div>
<!-- View Config in Tree Mode Section -->
<h3>{{ $t('interactive-editor.export.view-title') }}</h3>
<tree-view :data="config" class="config-tree-view" />
@@ -61,6 +69,11 @@ export default {
allowViewConfig() {
return this.$store.getters.permissions.allowViewConfig;
},
configPath() {
return this.$store.state.currentConfigInfo?.confPath
|| process.env.VUE_APP_CONFIG_PATH
|| '/conf.yml';
},
},
methods: {
convertJsonToYaml() {
@@ -121,6 +134,13 @@ export default {
border-bottom: 1px dashed var(--interactive-editor-color);
button { margin: 0 1rem; }
}
.config-path-info {
p, a {
color: var(--interactive-editor-color);
font-size: 1.2rem;
}
border-bottom: 1px dashed var(--interactive-editor-color);
}
.config-tree-view {
padding: 0.5rem;
font-family: var(--font-monospace);

View File

@@ -64,7 +64,6 @@ export default {
return this.$store.state.editMode;
},
sectionKey() {
if (this.isEditMode) return undefined;
return `collapsible-${this.uniqueKey}`;
},
collapseClass() {
@@ -104,12 +103,23 @@ export default {
watch: {
checkboxState(newState) {
this.isExpanded = newState;
this.updateLocalStorage(); // Save every change immediately
},
uniqueKey() {
this.checkboxState = this.isExpanded;
uniqueKey(newVal, oldVal) {
if (newVal !== oldVal) {
this.refreshCollapseState(); // Refresh state when key changes
}
},
},
methods: {
refreshCollapseState() {
this.checkboxState = this.isExpanded;
},
updateLocalStorage() {
const collapseState = this.locallyStoredCollapseStates();
collapseState[this.uniqueKey] = this.checkboxState;
localStorage.setItem(localStorageKeys.COLLAPSE_STATE, JSON.stringify(collapseState));
},
/* Either expand or collapse section, based on it's current state */
toggle() {
this.checkboxState = !this.checkboxState;

View File

@@ -9,7 +9,8 @@
:target="anchorTarget"
:class="`item ${makeClassList}`"
v-tooltip="getTooltipOptions()"
rel="noopener noreferrer" tabindex="0"
:rel="`${item.rel || 'noopener noreferrer'}`"
tabindex="0"
:id="`link-${item.id}`"
:style="customStyle"
>
@@ -255,8 +256,12 @@ export default {
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
word-break: keep-all;
overflow: hidden;
span.text {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: block;
}
}
@@ -385,6 +390,7 @@ p.description {
font-size: .9em;
line-height: 1rem;
height: 2rem;
overflow: hidden;
}
}
}

View File

@@ -74,8 +74,8 @@
</div>
<!-- Modal for opening in modal view -->
<IframeModal
:ref="`iframeModal`"
:name="`iframeModal`"
:ref="`iframeModal-${groupId}`"
:name="`iframeModal-${groupId}`"
@closed="$emit('itemClicked')"
/>
<!-- Edit item menu -->
@@ -213,7 +213,7 @@ export default {
methods: {
/* Opens the iframe modal */
triggerModal(url) {
this.$refs.iframeModal.show(url);
this.$refs[`iframeModal-${this.groupId}`].show(url);
},
/* Sorts items alphabetically using the title attribute */
sortAlphabetically(items) {

View File

@@ -0,0 +1,153 @@
<template>
<div class="critical-error-wrap" v-if="shouldShow">
<button class="close" title="Close Warning" @click="close">🗙</button>
<h3>{{ $t('critical-error.title') }}</h3>
<p>{{ $t('critical-error.subtitle') }}</p>
<h4>{{ $t('critical-error.sub-ensure-that') }}</h4>
<ul>
<li>The configuration file can be found at the specified location</li>
<li>There are no CORS rules preventing client-side access</li>
<li>The YAML is valid, parsable and matches the schema</li>
</ul>
<h4>{{ $t('critical-error.sub-error-details') }}</h4>
<pre>{{ this.$store.state.criticalError }}</pre>
<h4>{{ $t('critical-error.sub-next-steps') }}</h4>
<ul>
<li>Check the browser console for more details
(<a href="https://github.com/Lissy93/dashy/blob/master/docs/troubleshooting.md#how-to-open-browser-console">see how</a>)
</li>
<li>View the
<a href="https://github.com/Lissy93/dashy/blob/master/docs/troubleshooting.md">Troubleshooting Guide</a>
and <a href="https://dashy.to/docs/">Docs</a>
</li>
<li>
If you've verified the config is present, accessible and valid, and cannot find the solution
in the troubleshooting, docs or GitHub issues,
then <a href="https://github.com/Lissy93/dashy/issues/new/choose">open a ticket on GitHub</a>
</li>
<li>Click 'Ignore Critical Errors' below to not show this warning again</li>
</ul>
<button class="user-doesnt-care" @click="ignoreWarning">
{{ $t('critical-error.ignore-button') }}
</button>
</div>
</template>
<script>
import { localStorageKeys } from '@/utils/defaults';
import Keys from '@/utils/StoreMutations';
export default {
name: 'CriticalError',
computed: {
/* Determines if we should show this component.
* If error present AND user hasn't disabled */
shouldShow() {
return this.$store.state.criticalError
&& !localStorage[localStorageKeys.DISABLE_CRITICAL_WARNING];
},
},
methods: {
/* Ignore all future errors, by putting a key in local storage */
ignoreWarning() {
localStorage.setItem(localStorageKeys.DISABLE_CRITICAL_WARNING, true);
this.close();
},
/* Close this dialog, by removing this error from the local store */
close() {
this.$store.commit(Keys.CRITICAL_ERROR_MSG, null);
},
},
};
</script>
<style scoped lang="scss">
@import '@/styles/media-queries.scss';
.critical-error-wrap {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 3;
max-width: 50rem;
background: var(--background-darker);
padding: 1rem;
border-radius: var(--curve-factor);
color: var(--danger);
border: 2px solid var(--danger);
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s ease-in-out;
@include tablet-down {
top: 50%;
width: 85vw;
}
p, ul, h4, a {
margin: 0;
color: var(--white);
}
pre {
color: var(--warning);
font-size: 0.8rem;
overflow: auto;
background: var(--transparent-white-10);
padding: 0.5rem;
border-radius: var(--curve-factor);
}
h4 {
margin: 0.5rem 0 0 0;
font-size: 1.2rem;
}
h3 {
font-size: 2.2rem;
text-align: center;
background: var(--danger);
color: var(--white);
margin: -1rem -1rem 1rem -1rem;
padding: 0.5rem;
}
ul {
padding-left: 1rem;
}
.user-doesnt-care {
background: var(--background-darker);
color: var(--white);
border-radius: var(--curve-factor);
border: none;
text-decoration: underline;
padding: 0.25rem 0.5rem;
cursor: pointer;
width: fit-content;
margin: 0 auto;
transition: all 0.2s ease-in-out;
&:hover {
background: var(--danger);
color: var(--background-darker);
text-decoration: none;
}
}
.close {
position: absolute;
top: 1rem;
right: 1rem;
width: 1.5rem;
height: 1.5rem;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
font-size: 1rem;
background: var(--background);
color: var(--primary);
border: none;
border-radius: var(--curve-factor);
transition: all 0.2s ease-in-out;
&:hover {
background: var(--primary);
color: var(--background);
}
}
}
</style>

View File

@@ -1,16 +1,13 @@
<template>
<!-- User Footer -->
<footer v-if="text && text !== '' && visible" v-html="text"></footer>
<!-- Default Footer -->
<footer v-else-if="visible">
<span v-if="$store.state.currentConfigInfo" class="path-to-config">
Using: {{ $store.state.currentConfigInfo.confPath }}
</span>
<span>
{{ $t('footer.dev-by') }} <a :href="authorUrl">{{authorName}}</a>.
{{ $t('footer.licensed-under') }} <a :href="licenseUrl">{{license}}</a>
{{ showCopyright? '©': '' }} {{date}}.
{{ $t('footer.get-the') }} <a :href="repoUrl">{{ $t('footer.source-code') }}</a>.
<footer v-if="visible">
<!-- User-defined footer -->
<span v-if="text" v-html="text"></span>
<!-- Default footer -->
<span v-else>
<a :href="defaultInfo.projectUrl">Dashy</a> is free & open source
- licensed under <a :href="defaultInfo.licenseUrl">{{defaultInfo.license}}</a>,
© <a :href="defaultInfo.authorUrl">{{defaultInfo.authorName}}</a> {{defaultInfo.date}}.
Get support on GitHub, at <a :href="defaultInfo.repoUrl">{{defaultInfo.repoName}}</a>.
</span>
</footer>
</template>
@@ -23,13 +20,20 @@ export default {
name: 'Footer',
props: {
text: String,
authorName: { type: String, default: 'Alicia Sykes' },
authorUrl: { type: String, default: 'https://aliciasykes.com' },
license: { type: String, default: 'MIT' },
licenseUrl: { type: String, default: 'https://gist.github.com/Lissy93/143d2ee01ccc5c052a17' },
date: { type: String, default: `${new Date().getFullYear()}` },
showCopyright: { type: Boolean, default: true },
repoUrl: { type: String, default: 'https://github.com/lissy93/dashy' },
},
data() {
return {
defaultInfo: {
authorName: 'Alicia Sykes',
authorUrl: 'https://as93.net',
license: 'MIT',
licenseUrl: 'https://gist.github.com/Lissy93/143d2ee01ccc5c052a17',
date: `${new Date().getFullYear()}`,
repoUrl: 'https://github.com/lissy93/dashy',
repoName: 'Lissy93/Dashy',
projectUrl: 'https://dashy.to',
},
};
},
computed: {
visible() {
@@ -56,7 +60,7 @@ footer {
display: none;
}
span.path-to-config {
float: right;
float: left;
font-size: 0.75rem;
margin: 0.1rem 0.5rem 0 0;
opacity: var(--dimming-factor);

View File

@@ -66,7 +66,7 @@ export default {
span.subtitle {
color: var(--heading-text-color);
font-style: italic;
text-shadow: 1px 1px 2px #130f23;
text-shadow: 1px 1px 2px #130f2347;
opacity: var(--dimming-factor);
}
img.site-logo {

View File

@@ -319,6 +319,10 @@ div.action-buttons {
min-width: 6rem;
padding: 0.25rem 0.5rem;
margin: 1rem 0.5rem 0.5rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
}

View File

@@ -37,8 +37,10 @@ export default {
input: '',
};
},
props: {
iconSize: String,
computed: {
iconSize() {
return this.$store.getters.iconSize;
},
},
components: {
IconSmall,

View File

@@ -5,19 +5,19 @@
<IconDeafault
@click="updateDisplayLayout('auto')"
v-tooltip="tooltip($t('settings.layout-auto'))"
:class="`layout-icon ${displayLayout === 'auto' ? 'selected' : ''}`"
:class="`layout-icon ${layout === 'auto' ? 'selected' : ''}`"
tabindex="-2"
/>
<IconHorizontal
@click="updateDisplayLayout('horizontal')"
v-tooltip="tooltip($t('settings.layout-horizontal'))"
:class="`layout-icon ${displayLayout === 'horizontal' ? 'selected' : ''}`"
:class="`layout-icon ${layout === 'horizontal' ? 'selected' : ''}`"
tabindex="-2"
/>
<IconVertical
@click="updateDisplayLayout('vertical')"
v-tooltip="tooltip($t('settings.layout-vertical'))"
:class="`layout-icon ${displayLayout === 'vertical' ? 'selected' : ''}`"
:class="`layout-icon ${layout === 'vertical' ? 'selected' : ''}`"
tabindex="-2"
/>
</div>
@@ -40,6 +40,11 @@ export default {
IconHorizontal,
IconVertical,
},
computed: {
layout() {
return this.$store.getters.layout;
},
},
methods: {
updateDisplayLayout(layout) {
this.$store.commit(StoreKeys.SET_ITEM_LAYOUT, layout);

View File

@@ -1,36 +1,70 @@
<template>
<transition name="slide-fade">
<div class="kb-sc-info" v-if="!shouldHide">
<h5>There are keyboard shortcuts! 🙌</h5>
<h5>{{ popupContent.title }}</h5>
<div class="close" title="Hide forever [Esc]" @click="hideWelcomeHelper()">x</div>
<p title="Press [Esc] to hide this tip forever. See there's even a shortcut for that! 🚀">
Just start typing to filter. Then use the tab key to cycle through results,
and press enter to launch the selected item, or alt + enter to open in a modal.
You can hit Esc at anytime to clear the search. Easy 🥳
</p>
<p :title="popupContent.hoverText">{{ popupContent.message }}</p>
<p :title="popupContent.hoverText">{{ popupContent.messageContinued }}</p>
<div class="action-buttons">
<button @click="exportConfig">Export Local Config</button>
<button @click="saveConfig">Save Changes to Disk</button>
<button @click="resetLocalConfig">Reset Local Changes</button>
<button @click="hideWelcomeHelper">Dismiss this Notification</button>
</div>
</div>
</transition>
</template>
<script>
import { localStorageKeys } from '@/utils/defaults';
import { localStorageKeys, modalNames } from '@/utils/defaults';
import StoreKeys from '@/utils/StoreMutations';
import configSavingMixin from '@/mixins/ConfigSaving';
export default {
name: 'KeyboardShortcutInfo',
mixins: [configSavingMixin],
data() {
return {
shouldHide: true, // False = show/ true = hide. Intuitive, eh?
timeDelay: 3000, // Short delay in ms before popup appears
timeDelay: 2000, // Short delay in ms before popup appears
popupContent: {
title: '⚠️ You\'re using a local config',
message: `This means that your settings are saved in this browser only,
and won't persist across devices.`,
messageContinued: `To ensure you don't loose your changes,
it's recommended to download a copy of your config, so you can restore it later.`,
hoverText: 'Press [Esc] to hide this warning',
},
};
},
methods: {
exportConfig() {
this.$modal.show(modalNames.EXPORT_CONFIG_MENU);
this.shouldHide = true;
},
saveConfig() {
const localConfig = this.$store.state.config;
this.writeConfigToDisk(localConfig);
this.shouldHide = true;
},
resetLocalConfig() {
const msg = `${this.$t('config.reset-config-msg-l1')} `
+ `${this.$t('config.reset-config-msg-l2')}\n\n${this.$t('config.reset-config-msg-l3')}`;
const isTheUserSure = confirm(msg); // eslint-disable-line no-alert, no-restricted-globals
if (isTheUserSure) {
localStorage.clear();
this.$toasted.show(this.$t('config.data-cleared-msg'));
this.$store.dispatch(StoreKeys.INITIALIZE_CONFIG);
this.shouldHide = true;
}
},
/**
* Returns true if the key exists in session storage, otherwise false
* And the !! just converts 'false' to false, as strings resolve to true
*/
shouldHideWelcomeMessage() {
return !!localStorage[localStorageKeys.HIDE_WELCOME_BANNER];
return !!localStorage[localStorageKeys.HIDE_INFO_NOTIFICATION];
},
/**
* Update session storage, so that it won't be shown again
@@ -38,7 +72,7 @@ export default {
*/
hideWelcomeHelper() {
this.shouldHide = true;
localStorage.setItem(localStorageKeys.HIDE_WELCOME_BANNER, true);
localStorage.setItem(localStorageKeys.HIDE_INFO_NOTIFICATION, true);
window.removeEventListener('keyup', this.keyPressEvent);
},
/* Passed to window function, to add/ remove event listener */
@@ -114,6 +148,23 @@ export default {
}
}
}
.action-buttons {
display: flex;
justify-content: space-around;
margin-top: 1em;
button {
padding: 0.2rem;
background: var(--welcome-popup-background);
color: var(--welcome-popup-text-color);
border: 1px solid var(--welcome-popup-text-color);
border-radius: var(--curve-factor);
transition: all 0.2s ease-in-out;
&:hover {
background: var(--welcome-popup-text-color);
color: var(--welcome-popup-background);
}
}
}
/* Animations, animations everywhere */
.slide-fade-enter-active {
transition: all 1s ease;

View File

@@ -95,7 +95,8 @@ export default {
},
/* If configured, launch specific app when hotkey pressed */
handleHotKey(key) {
const usersHotKeys = this.getCustomKeyShortcuts();
const sections = this.$store.getters.sections || [];
const usersHotKeys = this.getCustomKeyShortcuts(sections);
usersHotKeys.forEach((hotkey) => {
if (hotkey.hotkey === parseInt(key, 10)) {
if (hotkey.url) window.open(hotkey.url, '_blank');

View File

@@ -7,7 +7,7 @@
<div class="options-outer">
<div :class="`options-container ${!settingsVisible ? 'hide' : ''}`">
<ThemeSelector />
<LayoutSelector :displayLayout="displayLayout" />
<LayoutSelector :displayLayout="$store.getters.layout" />
<ItemSizeSelector :iconSize="iconSize" />
<ConfigLauncher />
<AuthButtons v-if="userState !== 0" :userType="userState" />

View File

@@ -8,7 +8,7 @@
:value="$store.getters.theme"
class="theme-dropdown"
:tabindex="-2"
@input="themeChanged"
@input="themeChangedInUI"
/>
</div>
<IconPalette
@@ -28,18 +28,13 @@
<script>
import CustomThemeMaker from '@/components/Settings/CustomThemeMaker';
import {
LoadExternalTheme,
ApplyLocalTheme,
ApplyCustomVariables,
} from '@/utils/ThemeHelper';
import Defaults, { localStorageKeys } from '@/utils/defaults';
import Keys from '@/utils/StoreMutations';
import ErrorHandler from '@/utils/ErrorHandler';
import IconPalette from '@/assets/interface-icons/config-color-palette.svg';
import ThemingMixin from '@/mixins/ThemingMixin';
export default {
name: 'ThemeSelector',
mixins: [ThemingMixin],
props: {
hidePallete: Boolean,
},
@@ -47,101 +42,16 @@ export default {
CustomThemeMaker,
IconPalette,
},
watch: {
/* When theme in VueX store changes, then update theme */
themeFromStore(newTheme) {
this.selectedTheme = newTheme;
this.updateTheme(newTheme);
},
},
data() {
return {
selectedTheme: '',
themeConfiguratorOpen: false, // Control the opening of theme config popup
themeHelper: new LoadExternalTheme(),
ApplyLocalTheme,
ApplyCustomVariables,
};
},
computed: {
/* Get appConfig from store */
appConfig() {
return this.$store.getters.appConfig;
},
/* Get users theme from store */
themeFromStore() {
return this.$store.getters.theme;
},
/* Combines all theme names (builtin and user defined) together */
themeNames: function themeNames() {
const externalThemeNames = Object.keys(this.externalThemes);
const specialThemes = ['custom'];
return [...this.extraThemeNames, ...externalThemeNames,
...Defaults.builtInThemes, ...specialThemes];
},
extraThemeNames() {
const userThemes = this.appConfig.cssThemes || [];
if (typeof userThemes === 'string') return [userThemes];
return userThemes;
},
/* Returns an array of links to external CSS from the Config */
externalThemes() {
const availibleThemes = {};
if (this.appConfig && this.appConfig.externalStyleSheet) {
const externals = this.appConfig.externalStyleSheet;
if (Array.isArray(externals)) {
externals.forEach((ext, i) => {
availibleThemes[`External Stylesheet ${i + 1}`] = ext;
});
} else if (typeof externals === 'string') {
availibleThemes['External Stylesheet'] = this.appConfig.externalStyleSheet;
} else {
ErrorHandler('External stylesheets must be of type string or string[]');
}
}
// availibleThemes.Default = '#';
return availibleThemes;
},
},
computed: {},
mounted() {
const initialTheme = this.getInitialTheme();
this.selectedTheme = initialTheme;
// Quicker loading, if the theme is local we can apply it immidiatley
if (this.isThemeLocal(initialTheme)) {
this.updateTheme(initialTheme);
}
// If it's an external stylesheet, then wait for promise to resolve
if (this.externalThemes && Object.entries(this.externalThemes).length > 0) {
const added = Object.keys(this.externalThemes).map(
name => this.themeHelper.add(name, this.externalThemes[name]),
);
// Once, added, then apply users initial theme
Promise.all(added).then(() => {
this.updateTheme(initialTheme);
});
}
this.initializeTheme();
},
methods: {
/* Called when dropdown changed
* Updates store, which will in turn update theme through watcher
*/
themeChanged() {
const pageId = this.$store.state.currentConfigInfo?.pageId || null;
this.$store.commit(Keys.SET_THEME, { theme: this.selectedTheme, pageId });
this.updateTheme(this.selectedTheme);
},
/* Returns the initial theme */
getInitialTheme() {
const localTheme = localStorage[localStorageKeys.THEME];
if (localTheme && localTheme !== 'undefined') return localTheme;
return this.appConfig.theme || Defaults.theme;
},
/* Determines if a given theme is local / not a custom user stylesheet */
isThemeLocal(themeToCheck) {
const localThemes = [...Defaults.builtInThemes, ...this.extraThemeNames];
return localThemes.includes(themeToCheck);
},
/* Opens the theme color configurator popup */
openThemeConfigurator() {
this.$store.commit(Keys.SET_MODAL_OPEN, true);
@@ -154,24 +64,6 @@ export default {
this.themeConfiguratorOpen = false;
}
},
/* Updates theme. Checks if the new theme is local or external,
and calls appropirate updating function. Updates local storage */
updateTheme(newTheme) {
if (newTheme === 'Default') {
this.resetToDefault();
this.themeHelper.theme = 'Default';
} else if (this.isThemeLocal(newTheme)) {
this.ApplyLocalTheme(newTheme);
} else {
this.themeHelper.theme = newTheme;
}
this.ApplyCustomVariables(newTheme);
// localStorage.setItem(localStorageKeys.THEME, newTheme);
},
/* Removes any applied themes */
resetToDefault() {
document.getElementsByTagName('html')[0].removeAttribute('data-theme');
},
},
};
</script>

View File

@@ -26,7 +26,7 @@ export default {
/* URL/ IP or hostname to the AdGuardHome instance, without trailing slash */
hostname() {
if (!this.options.hostname) this.error('You must specify the path to your AdGuard server');
return this.options.hostname;
return this.parseAsEnvVar(this.options.hostname);
},
showFullInfo() {
return this.options.showFullInfo;
@@ -39,7 +39,9 @@ export default {
},
authHeaders() {
if (this.options.username && this.options.password) {
const encoded = window.btoa(`${this.options.username}:${this.options.password}`);
const password = this.parseAsEnvVar(this.options.password);
const username = this.parseAsEnvVar(this.options.username);
const encoded = window.btoa(`${username}:${password}`);
return { Authorization: `Basic ${encoded}` };
}
return {};

View File

@@ -38,7 +38,7 @@ export default {
/* URL/ IP or hostname to the AdGuardHome instance, without trailing slash */
hostname() {
if (!this.options.hostname) this.error('You must specify the path to your AdGuard server');
return this.options.hostname;
return this.parseAsEnvVar(this.options.hostname);
},
showOnOffStatusOnly() {
return this.options.showOnOffStatusOnly;
@@ -48,7 +48,9 @@ export default {
},
authHeaders() {
if (this.options.username && this.options.password) {
const encoded = window.btoa(`${this.options.username}:${this.options.password}`);
const username = this.parseAsEnvVar(this.options.username);
const password = this.parseAsEnvVar(this.options.password);
const encoded = window.btoa(`${username}:${password}`);
return { Authorization: `Basic ${encoded}` };
}
return {};

View File

@@ -20,14 +20,16 @@ export default {
/* URL/ IP or hostname to the AdGuardHome instance, without trailing slash */
hostname() {
if (!this.options.hostname) this.error('You must specify the path to your AdGuard server');
return this.options.hostname;
return this.parseAsEnvVar(this.options.hostname);
},
endpoint() {
return `${this.hostname}/control/stats`;
},
authHeaders() {
if (this.options.username && this.options.password) {
const encoded = window.btoa(`${this.options.username}:${this.options.password}`);
const username = this.parseAsEnvVar(this.options.username);
const password = this.parseAsEnvVar(this.options.password);
const encoded = window.btoa(`${username}:${password}`);
return { Authorization: `Basic ${encoded}` };
}
return {};

View File

@@ -36,11 +36,13 @@ export default {
/* URL/ IP or hostname to the AdGuardHome instance, without trailing slash */
hostname() {
if (!this.options.hostname) this.error('You must specify the path to your AdGuard server');
return this.options.hostname;
return this.parseAsEnvVar(this.options.hostname);
},
authHeaders() {
if (this.options.username && this.options.password) {
const encoded = window.btoa(`${this.options.username}:${this.options.password}`);
const username = this.parseAsEnvVar(this.options.username);
const password = this.parseAsEnvVar(this.options.password);
const encoded = window.btoa(`${username}:${password}`);
return { Authorization: `Basic ${encoded}` };
}
return {};

View File

@@ -113,7 +113,7 @@ export default {
},
computed: {
hostname() {
return this.options.hostname || widgetApiEndpoints.anonAddy;
return this.parseAsEnvVar(this.options.hostname) || widgetApiEndpoints.anonAddy;
},
apiVersion() {
return this.options.apiVersion || 'v1';
@@ -132,7 +132,7 @@ export default {
},
apiKey() {
if (!this.options.apiKey) this.error('An apiKey is required');
return this.options.apiKey;
return this.parseAsEnvVar(this.options.apiKey);
},
hideMeta() {
return this.options.hideMeta;

View File

@@ -35,7 +35,7 @@ export default {
},
apiKey() {
if (!this.options.apiKey) this.error('Missing API Key');
return this.options.apiKey;
return this.parseAsEnvVar(this.options.apiKey);
},
endpoint() {
return `${widgetApiEndpoints.blacklistCheck}/${this.ipAddress}`;

View File

@@ -38,12 +38,12 @@ export default {
/* The username to fetch data from - REQUIRED */
username() {
if (!this.options.username) this.error('You must specify a username');
return this.options.username;
return this.parseAsEnvVar(this.options.username);
},
/* Optionally override hostname, if using a self-hosted instance */
hostname() {
if (this.options.hostname) return this.options.hostname;
return widgetApiEndpoints.codeStats;
return this.parseAsEnvVar(widgetApiEndpoints.codeStats);
},
hideMeta() {
return this.options.hideMeta || false;

View File

@@ -63,11 +63,11 @@ export default {
computed: {
apiKey() {
if (!this.options.apiKey) this.error('Missing API Key');
return this.options.apiKey;
return this.parseAsEnvVar(this.options.apiKey);
},
domain() {
if (!this.options.domain) this.error('Missing Domain Name Key');
return this.options.domain;
return this.parseAsEnvVar(this.options.domain);
},
endpoint() {
return `${widgetApiEndpoints.domainMonitor}/?domain=${this.domain}&r=whois&apikey=${this.apiKey}`;

View File

@@ -106,7 +106,7 @@ export default {
if (!this.options.apiKey) {
this.error('An API key is required, please see the docs for more info');
}
return this.options.apiKey;
return this.parseAsEnvVar(this.options.apiKey);
},
},
methods: {

View File

@@ -45,7 +45,7 @@ export default {
computed: {
/* The users API key for exchangerate-api.com */
apiKey() {
return this.options.apiKey;
return this.parseAsEnvVar(this.options.apiKey);
},
/* The currency to convert results into */
inputCurrency() {

View File

@@ -71,7 +71,7 @@ export default {
this.error('An API key must be supplied');
return '';
}
return usersChoice;
return this.parseAsEnvVar(usersChoice);
},
/* The direction of flights: Arrival, Departure or Both */
direction() {

View File

@@ -1,9 +1,15 @@
<template>
<div class="readme-stats">
<img class="stats-card" v-if="!hideProfileCard" :src="profileCard" alt="Profile Card" />
<img class="stats-card" v-if="!hideLanguagesCard" :src="topLanguagesCard" alt="Languages" />
<a v-if="!hideProfileCard" :href="profileCardLink" target="_blank">
<img class="stats-card" :src="profileCard" alt="Profile Card" />
</a>
<a v-if="!hideLanguagesCard" :href="profileCardLink" target="_blank">
<img class="stats-card" :src="topLanguagesCard" alt="Languages" />
</a>
<template v-if="repos">
<img class="stats-card" v-for="(repo, i) in repoCards" :key="i" :src="repo" :alt="repo" />
<a v-for="(repo, i) in repoCards" :key="i" :href="repo.cardHref" target="_blank">
<img class="stats-card" :src="repo.cardSrc" :alt="repo" />
</a>
</template>
</div>
</template>
@@ -61,6 +67,9 @@ export default {
profileCard() {
return `${widgetApiEndpoints.readMeStats}?username=${this.username}${this.cardConfig}`;
},
profileCardLink() {
return `https://github.com/${this.username}`;
},
topLanguagesCard() {
return `${widgetApiEndpoints.readMeStats}/top-langs/?username=${this.username}`
+ `${this.cardConfig}&langs_count=12`;
@@ -70,8 +79,11 @@ export default {
this.repos.forEach((repo) => {
const username = repo.split('/')[0];
const repoName = repo.split('/')[1];
cards.push(`${widgetApiEndpoints.readMeStats}/pin/?username=${username}&repo=${repoName}`
+ `${this.cardConfig}&show_owner=true`);
cards.push({
cardSrc: `${widgetApiEndpoints.readMeStats}/pin/?username=${username}`
+ `&repo=${repoName}${this.cardConfig}&show_owner=true`,
cardHref: `https://github.com/${username}/${repoName}`,
});
});
return cards;
},

View File

@@ -112,6 +112,7 @@ export default {
cursor: pointer;
font-size: 0.9rem;
text-align: center;
width: 100%;
width: fit-content;
margin: 0.25rem auto;
padding: 0.1rem 0.25rem;

View File

@@ -0,0 +1,150 @@
<template>
<div class="glances-cpu-gauge-wrapper">
<GaugeChart class="gl-speedometer" :value="gaugeValue"
:baseColor="baseColor" :shadowColor="shadowColor" :gaugeColor="gaugeColor"
:startAngle="startAngle" :endAngle="endAngle" :innerRadius="innerRadius"
:separatorThickness="separatorThickness">
<p class="percentage">{{ gaugeValue }}%</p>
</GaugeChart>
<p class="show-more-btn" @click="toggleMoreInfo">
{{ showMoreInfo ? $t('widgets.general.show-less') : $t('widgets.general.cpu-details') }}
</p>
<div class="more-info" v-if="moreInfo && showMoreInfo">
<div class="more-info-row" v-for="(info, key) in moreInfo" :key="key">
<p class="label">{{ info.label }}</p>
<p class="value">{{ info.value }}</p>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import GaugeChart from '@/components/Charts/Gauge';
import { capitalize } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin],
components: {
GaugeChart,
},
data() {
return {
gaugeValue: 0,
baseColor: '#101010ED',
shadowColor: '#00000000',
gaugeColor: [
{ offset: 0, color: '#20e253' },
{ offset: 35, color: '#f6f000' },
{ offset: 65, color: '#fca016' },
{ offset: 90, color: '#f80363' },
],
showMoreInfo: false,
moreInfo: null,
startAngle: -135,
endAngle: 135,
innerRadius: 80,
separatorThickness: 0,
};
},
computed: {
endpoint() {
return this.makeGlancesUrl('cpu');
},
},
methods: {
processData(cpuData) {
this.gaugeValue = cpuData.total;
const moreInfo = [];
const ignore = ['total', 'cpucore', 'time_since_update',
'interrupts', 'soft_interrupts', 'ctx_switches', 'syscalls'];
Object.keys(cpuData).forEach((key) => {
if (!ignore.includes(key) && cpuData[key]) {
moreInfo.push({ label: capitalize(key), value: `${cpuData[key].toFixed(1)}%` });
}
});
this.moreInfo = moreInfo;
},
toggleMoreInfo() {
this.showMoreInfo = !this.showMoreInfo;
},
},
created() {
this.overrideUpdateInterval = 2;
},
};
</script>
<style scoped lang="scss">
.glances-cpu-gauge-wrapper {
max-width: 15rem;
margin: 0rem auto;
p.percentage {
color: var(--widget-text-color);
text-align: center;
position: absolute;
font-size: 1.3rem;
margin: 3.5rem 0;
width: 100%;
bottom: 0;
}
.more-info {
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
padding: 0.25rem 0.5rem;
margin: 0.5rem auto;
.more-info-row {
display: flex;
justify-content: space-between;
align-items: center;
p.label, p.value {
color: var(--widget-text-color);
margin: 0.25rem 0;
}
p.value {
font-family: var(--font-monospace);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
p.show-more-btn {
cursor: pointer;
font-size: 0.9rem;
text-align: center;
width: fit-content;
margin: -1.1rem auto 0 auto;
padding: 0.1rem 0.25rem;
border: 1px solid transparent;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
border-radius: var(--curve-factor);
&:hover {
border: 1px solid var(--widget-text-color);
}
&:focus,
&:active {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
}
}
</style>
<style>
/* global override for the Guage tick lines */
.gl-speedometer svg line {
stroke: var(--widget-text-color);
opacity: .3;
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<div class="glances-cpu-gauge-wrapper">
<GaugeChart class="gl-speedometer" :value="gaugeValue"
:baseColor="baseColor" :shadowColor="shadowColor" :gaugeColor="gaugeColor"
:startAngle="startAngle" :endAngle="endAngle" :innerRadius="innerRadius"
:separatorThickness="separatorThickness">
<p class="percentage">{{ gaugeValue }}%</p>
</GaugeChart>
<p class="show-more-btn" @click="toggleMoreInfo">
{{ showMoreInfo ? $t('widgets.general.show-less') : $t('widgets.general.mem-details') }}
</p>
<div class="more-info" v-if="moreInfo && showMoreInfo">
<div class="more-info-row" v-for="(info, key) in moreInfo" :key="key">
<p class="label">{{ info.label }}</p>
<p class="value">{{ info.value }}</p>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import GaugeChart from '@/components/Charts/Gauge';
import { capitalize, convertBytes } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin],
components: {
GaugeChart,
},
data() {
return {
gaugeValue: 0,
baseColor: '#101010ED',
shadowColor: '#00000000',
gaugeColor: [
{ offset: 0, color: '#20e253' },
{ offset: 35, color: '#f6f000' },
{ offset: 65, color: '#fca016' },
{ offset: 90, color: '#f80363' },
],
showMoreInfo: false,
moreInfo: null,
startAngle: -135,
endAngle: 135,
innerRadius: 80,
separatorThickness: 0,
};
},
computed: {
endpoint() {
return this.makeGlancesUrl('mem');
},
},
methods: {
processData(memData) {
this.gaugeValue = memData.percent;
const moreInfo = [];
const ignore = ['percent'];
Object.keys(memData).forEach((key) => {
if (!ignore.includes(key) && memData[key]) {
moreInfo.push({ label: capitalize(key), value: convertBytes(memData[key]) });
}
});
this.moreInfo = moreInfo;
},
toggleMoreInfo() {
this.showMoreInfo = !this.showMoreInfo;
},
},
created() {
this.overrideUpdateInterval = 2;
},
};
</script>
<style scoped lang="scss">
.glances-cpu-gauge-wrapper {
max-width: 15rem;
margin: 0rem auto;
p.percentage {
color: var(--widget-text-color);
text-align: center;
position: absolute;
font-size: 1.3rem;
margin: 3.5rem 0;
width: 100%;
bottom: 0;
}
.more-info {
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
padding: 0.25rem 0.5rem;
margin: 0.5rem auto;
.more-info-row {
display: flex;
justify-content: space-between;
align-items: center;
p.label,
p.value {
color: var(--widget-text-color);
margin: 0.25rem 0;
}
p.value {
font-family: var(--font-monospace);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
p.show-more-btn {
cursor: pointer;
font-size: 0.9rem;
text-align: center;
width: fit-content;
margin: -1.1rem auto 0 auto;
padding: 0.1rem 0.25rem;
border: 1px solid transparent;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
border-radius: var(--curve-factor);
&:hover {
border: 1px solid var(--widget-text-color);
}
&:focus,
&:active {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
}
}
</style>
<style>
/* global override for the Guage tick lines */
.gl-speedometer svg line {
stroke: var(--widget-text-color);
opacity: .3;
}
</style>

View File

@@ -58,7 +58,7 @@ export default {
},
hostname() {
if (!this.options.hostname) this.error('`hostname` is required');
return this.options.hostname;
return this.parseAsEnvVar(this.options.hostname);
},
},
methods: {

View File

@@ -56,7 +56,7 @@ export default {
this.error('An API key is required, please see the docs for more info');
}
if (typeof this.options.apiKey === 'string') {
return [this.options.apiKey];
return [this.parseAsEnvVar(this.options.apiKey)];
}
return this.options.apiKey;
},

View File

@@ -74,6 +74,7 @@ export default {
this.jokeLine2 = data.delivery;
} else if (this.jokeType === 'single') {
this.jokeLine1 = data.joke;
this.jokeLine2 = null;
}
},
},

View File

@@ -30,11 +30,11 @@ export default {
computed: {
endpoint() {
if (!this.options.host) this.error('linkgding Host is required');
return `${this.options.host}/api/bookmarks`;
return `${this.parseAsEnvVar(this.options.host)}/api/bookmarks`;
},
apiKey() {
if (!this.options.apiKey) this.error('linkgding apiKey is required');
return this.options.apiKey;
return this.parseAsEnvVar(this.options.apiKey);
},
filtertags() {
return this.options.tags;

View File

@@ -29,7 +29,7 @@ export default {
computed: {
apiKey() {
if (!this.options.apiKey) this.error('An API key is required, see docs for more info');
return this.options.apiKey;
return this.parseAsEnvVar(this.options.apiKey);
},
country() {
return this.options.country ? `&country=${this.options.country}` : '';

View File

@@ -22,7 +22,7 @@
</span>
<span v-if="canDeleteNotification('delete')">
<a @click="deleteNotification(notification.notification_id)"
class="action secondary">{{ tt('delete-notification') }}</a>
class="action secondary">{{ tt('delete-notification') }}</a>
</span>
</p>
</div>

View File

@@ -44,7 +44,7 @@
<em v-html="formatNumber(shares.num_shares)"></em>
<strong>{{ tt('local') }}</strong> <small> {{ tt('and') }}</small>
<em v-html="formatNumber(shares.num_fed_shares_sent
+ shares.num_fed_shares_received)"></em>
+ shares.num_fed_shares_received)"></em>
<strong>
{{ tt('federated-shares') }}
</strong>

View File

@@ -36,13 +36,14 @@ export default {
computed: {
/* Let user select which comic to display: random, latest or a specific number */
hostname() {
const usersChoice = this.options.hostname;
const usersChoice = this.parseAsEnvVar(this.options.hostname);
if (!usersChoice) this.error('You must specify the hostname for your Pi-Hole server');
return usersChoice || 'http://pi.hole';
},
apiKey() {
if (!this.options.apiKey) this.error('API Key is required, please see the docs');
return this.options.apiKey;
const usersChoice = this.parseAsEnvVar(this.options.apiKey);
if (!usersChoice) this.error('API Key is required, please see the docs');
return usersChoice;
},
endpoint() {
return `${this.hostname}/admin/api.php?summary&auth=${this.apiKey}`;

View File

@@ -34,22 +34,22 @@ export default {
computed: {
clusterUrl() {
if (!this.options.cluster_url) this.error('The cluster URL is required.');
return this.options.cluster_url || '';
return this.parseAsEnvVar(this.options.cluster_url) || '';
},
userName() {
if (!this.options.user_name) this.error('The user name is required.');
return this.options.user_name || '';
return this.parseAsEnvVar(this.options.user_name) || '';
},
tokenName() {
if (!this.options.token_name) this.error('The token name is required.');
return this.options.token_name || '';
return this.parseAsEnvVar(this.options.token_name) || '';
},
tokenUuid() {
if (!this.options.token_uuid) this.error('The token uuid is required.');
return this.options.token_uuid || '';
return this.parseAsEnvVar(this.options.token_uuid) || '';
},
node() {
return this.options.node || '';
return this.parseAsEnvVar(this.options.node) || '';
},
nodeData() {
return this.options.node_data || false;
@@ -94,7 +94,7 @@ export default {
}
},
processData(data) {
this.data = data.data.sort((a, b) => a.vmid > b.vmid);
this.data = data.data.sort((a, b) => Number(a.vmid) > Number(b.vmid));
if (this.hideTemplates) {
this.data = this.data.filter(item => item.template !== 1);
}

View File

@@ -90,7 +90,8 @@ export default {
const formatType = (ht) => capitalize(ht.replaceAll('_', ' '));
holidays.forEach((holiday) => {
results.push({
name: holiday.name.filter(p => p.lang == this.options.lang)[0].text || holiday.name[0].text,
name: holiday.name
.filter(p => p.lang === this.options.lang)[0].text || holiday.name[0].text,
date: makeDate(holiday.date),
type: formatType(holiday.holidayType),
observed: holiday.observedOn ? makeDate(holiday.observedOn) : '',

View File

@@ -35,7 +35,7 @@ export default {
},
provider() {
// Can be either `ip-api`, `ipapi.co` or `ipgeolocation`
return this.options.provider || 'ipapi.co';
return this.parseAsEnvVar(this.options.provider) || 'ipapi.co';
},
},
data() {

View File

@@ -51,7 +51,7 @@ export default {
return this.options.rssUrl || '';
},
apiKey() {
return this.options.apiKey;
return this.parseAsEnvVar(this.options.apiKey);
},
parseLocally() {
return this.options.parseLocally;

View File

@@ -93,7 +93,7 @@ export default {
return this.options.leagueId;
},
apiKey() {
return this.options.apiKey || '50130162';
return this.parseAsEnvVar(this.options.apiKey) || '50130162';
},
limit() {
return this.options.limit || 20;

View File

@@ -29,7 +29,7 @@ export default {
},
/* The users API key for AlphaVantage */
apiKey() {
return this.options.apiKey;
return this.parseAsEnvVar(this.options.apiKey);
},
/* The formatted GET request API endpoint to fetch stock data from */
endpoint() {

View File

@@ -45,15 +45,15 @@ export default {
computed: {
hostname() {
if (!this.options.hostname) this.error('A hostname is required');
return this.options.hostname;
return this.parseAsEnvVar(this.options.hostname);
},
username() {
if (!this.options.username) this.error('A username is required');
return this.options.username;
return this.parseAsEnvVar(this.options.username);
},
password() {
if (!this.options.password) this.error('A password is required');
return this.options.password;
return this.parseAsEnvVar(this.options.password);
},
endpointLogin() {
return `${this.hostname}/webapi/auth.cgi?api=SYNO.API.Auth&version=3&method=login&account=${this.username}&passwd=${this.password}&session=DownloadStation&format=sid`;

View File

@@ -0,0 +1,238 @@
<template>
<div>
<template v-if="monitors">
<div v-for="(monitor, index) in monitors" :key="index" class="item-wrapper">
<div class="item monitor-row">
<div class="title-title"><span class="text">{{ monitor.name }}</span></div>
<div class="monitors-container">
<div class="status-container">
<span class="status-pill" :class="[monitor.statusClass]">{{ monitor.status }}</span>
</div>
<div class="status-container">
<span class="response-time">{{ monitor.responseTime }}ms</span>
</div>
</div>
</div>
</div>
</template>
<template v-if="errorMessage">
<div class="error-message">
<span class="text">{{ errorMessage }}</span>
</div>
</template>
</div>
</template>
<script>
/**
* A simple example which you can use as a template for creating your own widget.
* Takes two optional parameters (`text` and `count`), and fetches a list of images
* from dummyapis.com, then renders the results to the UI.
*/
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
monitors: null,
errorMessage: null,
errorMessageConstants: {
missingApiKey: 'No API key set',
missingUrl: 'No URL set',
},
};
},
mounted() {
this.fetchData();
},
computed: {
/* Get API key for access to instance */
apiKey() {
return this.parseAsEnvVar(this.options.apiKey);
},
/* Get instance URL */
url() {
return this.parseAsEnvVar(this.options.url);
},
/* Create authorisation header for the instance from the apiKey */
authHeaders() {
if (!this.options.apiKey) {
return {};
}
const encoded = window.btoa(`:${this.options.apiKey}`);
return { Authorization: `Basic ${encoded}` };
},
},
methods: {
/* The update() method extends mixin, used to update the data.
* It's called by parent component, when the user presses update
*/
update() {
this.startLoading();
this.fetchData();
},
/* Make the data request to the computed API endpoint */
fetchData() {
const { authHeaders, url } = this;
if (!this.optionsValid({ authHeaders, url })) {
return;
}
this.makeRequest(url, authHeaders)
.then(this.processData);
},
/* Convert API response data into a format to be consumed by the UI */
processData(response) {
const monitorRows = this.getMonitorRows(response);
const monitors = new Map();
for (let index = 0; index < monitorRows.length; index += 1) {
const row = monitorRows[index];
this.processRow(row, monitors);
}
this.monitors = Array.from(monitors.values());
},
getMonitorRows(response) {
return response.split('\n').filter(row => row.startsWith('monitor_'));
},
processRow(row, monitors) {
const dataType = this.getRowDataType(row);
const monitorName = this.getRowMonitorName(row);
if (!monitors.has(monitorName)) {
monitors.set(monitorName, { name: monitorName });
}
const monitor = monitors.get(monitorName);
const value = this.getRowValue(row);
const updated = this.setMonitorValue(dataType, monitor, value);
monitors.set(monitorName, updated);
},
setMonitorValue(key, monitor, value) {
const copy = { ...monitor };
switch (key) {
case 'monitor_cert_days_remaining': {
copy.certDaysRemaining = value;
break;
}
case 'monitor_cert_is_valid': {
copy.certValid = value;
break;
}
case 'monitor_response_time': {
copy.responseTime = value;
break;
}
case 'monitor_status': {
copy.status = value === '1' ? 'Up' : 'Down';
copy.statusClass = copy.status.toLowerCase();
break;
}
default:
break;
}
return copy;
},
getRowValue(row) {
return this.getValueWithRegex(row, /\b\d+\b$/);
},
getRowMonitorName(row) {
return this.getValueWithRegex(row, /monitor_name="([^"]+)"/);
},
getRowDataType(row) {
return this.getValueWithRegex(row, /^(.*?)\{/);
},
getValueWithRegex(string, regex) {
const result = string.match(regex);
const isArray = Array.isArray(result);
if (!isArray) {
return result;
}
return result.length > 1 ? result[1] : result[0];
},
optionsValid({ url, authHeaders }) {
const errors = [];
if (url === undefined) {
errors.push(this.errorMessageConstants.missingUrl);
}
if (authHeaders === undefined) {
errors.push(this.errorMessageConstants.missingApiKey);
}
if (errors.length === 0) { return true; }
this.errorMessage = errors.join('\n');
return false;
},
},
};
</script>
<style scoped lang="scss">
.status-pill {
border-radius: 50em;
box-sizing: border-box;
font-size: 0.75em;
display: inline-block;
font-weight: 700;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
padding: .35em .65em;
margin: 1em 0.5em;
min-width: 64px;
&.up {
background-color: rgb(92, 221, 139);
color: black;
}
&.down {
background-color: rgb(220, 53, 69);
color: white;
}
}
div.item.monitor-row:hover {
background-color: var(--item-background);
color: var(--current-color);
opacity: 1;
div.title-title>span.text {
color: var(--current-color);
}
}
.monitors-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-around;
width: 50%;
}
.monitor-row {
display: flex;
justify-content: space-between;
padding: 0.35em 0.5em;
align-items: center;
}
.title-title {
font-weight: bold;
}
</style>

View File

@@ -53,7 +53,7 @@ export default {
},
address() {
if (!this.options.address) this.error('You must specify a public address');
return this.options.address;
return this.parseAsEnvVar(this.options.address);
},
network() {
return this.options.network || 'main';

View File

@@ -46,8 +46,12 @@ export default {
return this.options.units || 'metric';
},
endpoint() {
const { apiKey, city } = this.options;
return `${widgetApiEndpoints.weather}?q=${city}&appid=${apiKey}&units=${this.units}`;
const apiKey = this.parseAsEnvVar(this.options.apiKey);
const { city, lat, lon } = this.options;
const params = (lat && lon)
? `lat=${lat}&lon=${lon}&appid=${apiKey}&units=${this.units}`
: `q=${city}&appid=${apiKey}&units=${this.units}`;
return `${widgetApiEndpoints.weather}?${params}`;
},
tempDisplayUnits() {
switch (this.units) {
@@ -106,7 +110,11 @@ export default {
checkProps() {
const ops = this.options;
if (!ops.apiKey) this.error('Missing API key for OpenWeatherMap');
if (!ops.city) this.error('A city name is required to fetch weather');
if ((!ops.lat || !ops.lon) && !ops.city) {
this.error('A city name or lat + lon is required to fetch weather');
}
if (ops.units && ops.units !== 'metric' && ops.units !== 'imperial') {
this.error('Invalid units specified, must be either \'metric\' or \'imperial\'');
}

View File

@@ -67,12 +67,14 @@ const COMPAT = {
'gl-alerts': 'GlAlerts',
'gl-current-cores': 'GlCpuCores',
'gl-current-cpu': 'GlCpuGauge',
'gl-cpu-speedometer': 'GlCpuSpeedometer',
'gl-cpu-history': 'GlCpuHistory',
'gl-disk-io': 'GlDiskIo',
'gl-disk-space': 'GlDiskSpace',
'gl-ip-address': 'GlIpAddress',
'gl-load-history': 'GlLoadHistory',
'gl-current-mem': 'GlMemGauge',
'gl-mem-speedometer': 'GlMemSpeedometer',
'gl-mem-history': 'GlMemHistory',
'gl-network-interfaces': 'GlNetworkInterfaces',
'gl-network-traffic': 'GlNetworkTraffic',
@@ -113,6 +115,7 @@ const COMPAT = {
'synology-download': 'SynologyDownload',
'system-info': 'SystemInfo',
'tfl-status': 'TflStatus',
'uptime-kuma': 'UptimeKuma',
'wallet-balance': 'WalletBalance',
weather: 'Weather',
'weather-forecast': 'WeatherForecast',
@@ -203,14 +206,16 @@ export default {
</script>
<style scoped lang="scss">
@import '@/styles/media-queries.scss';
@import "@/styles/media-queries.scss";
.widget-base {
position: relative;
padding: 0.75rem 0.5rem 0.5rem 0.5rem;
background: var(--widget-base-background);
box-shadow: var(--widget-base-shadow, none);
// Refresh and full-page action buttons
button.action-btn {
button.action-btn {
height: 1rem;
min-width: auto;
width: 1.75rem;
@@ -221,21 +226,26 @@ export default {
border: none;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
&:hover {
opacity: 1;
color: var(--widget-background-color);
}
&.update-btn {
right: -0.25rem;
}
&.open-btn {
right: 1.75rem;
}
}
// Optional widget label
.widget-label {
color: var(--widget-text-color);
}
// Actual widget container
.widget-wrap {
&.has-error {
@@ -243,9 +253,11 @@ export default {
opacity: 0.5;
border-radius: var(--curve-factor);
background: #ffff0040;
&:hover { background: none; }
}
}
// Error message output
.widget-error {
p.error-msg {
@@ -254,12 +266,14 @@ export default {
font-size: 1rem;
margin: 0 auto 0.5rem auto;
}
p.error-output {
font-family: var(--font-monospace);
color: var(--widget-text-color);
font-size: 0.85rem;
margin: 0.5rem auto;
}
p.retry-link {
cursor: pointer;
text-decoration: underline;
@@ -268,14 +282,17 @@ export default {
margin: 0;
}
}
// Loading spinner
.loading {
margin: 0.2rem auto;
text-align: center;
svg.loader {
width: 100px;
}
}
// Hide widget contents while loading
&.is-loading {
.widget-wrap {
@@ -283,5 +300,4 @@ export default {
}
}
}
</style>

View File

@@ -8,7 +8,6 @@
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
@@ -41,11 +40,17 @@ export default {
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
fetch(this.endpoint)
.then(response => {
if (!response.ok) {
this.error('Network response was not ok');
}
return response.json();
})
.catch((dataFetchError) => {
.then(data => {
this.processData(data);
})
.catch(dataFetchError => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
@@ -71,7 +76,7 @@ export default {
<style scoped lang="scss">
.xkcd-wrapper {
.xkcd-title {
.xkcd-title {
font-size: 1.2rem;
margin: 0.25rem auto;
color: var(--widget-text-color);