Merge branch 'FEAT/Dashy-V3' into FEATURE/environment-variables

This commit is contained in:
Alicia Sykes
2024-04-14 18:22:28 +01:00
committed by GitHub
111 changed files with 6128 additions and 4976 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

@@ -115,7 +115,9 @@ export default {
},
},
mounted() {
this.jsonData = this.config;
const jsonData = { ...this.config };
jsonData.sections = jsonData.sections.map(({ filteredItems, ...section }) => section);
this.jsonData = jsonData;
if (!this.allowWriteToDisk) this.saveMode = 'local';
},
methods: {
@@ -141,7 +143,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

@@ -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"
>
@@ -258,6 +259,9 @@ export default {
overflow: hidden;
span.text {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: block;
}
}

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

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

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

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

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

View File

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

@@ -0,0 +1,242 @@
<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() {
const { apiKey } = this.options;
return apiKey;
},
/* Get instance URL */
url() {
const { url } = this.options;
return 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

@@ -115,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',
@@ -205,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;
@@ -223,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 {
@@ -245,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 {
@@ -256,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;
@@ -270,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 {
@@ -285,5 +300,4 @@ export default {
}
}
}
</style>