🔀 Merge pull request #617 from Lissy93/FEATURE/multi-page-support-2

[FEATURE] Multi-Page Support
This commit is contained in:
Alicia Sykes 2022-05-01 22:50:17 +01:00 committed by GitHub
commit 40d1236b2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 887 additions and 259 deletions

View File

@ -1,5 +1,10 @@
# Changelog
## ✨ 2.0.8 Adds Multi-Page Support [PR #617](https://github.com/Lissy93/dashy/pull/617)
- Adds support for multiple pages per-dashboard
- Adds new attribute at root of main config file: `pages`
- Updates router and nav-bar to automatically create paths for both local and remote configs
## ⚡️ 2.0.7 Improves handling of Sections and Items [PR #595](https://github.com/Lissy93/dashy/pull/595)
- Adds functionality for sub-items / item-groups
- Creates an item mixin, for reusing functionality

View File

@ -1,7 +1,4 @@
## ⚡️ 2.0.7 Improves handling of Sections and Items [PR #595](https://github.com/Lissy93/dashy/pull/595)
- Adds functionality for sub-items / item-groups
- Creates an item mixin, for reusing functionality
- Item width calculated based on parent section width
- Improved mobile support, long-press for right-click
- Adds 2 new themes (`lissy` and `charry-blossom`)
- Adds 2 new widgets (`mullvad-status`, and `blacklist-check`)
## ✨ 2.0.8 Adds Multi-Page Support [PR #617](https://github.com/Lissy93/dashy/pull/617)
- Adds support for multiple pages per-dashboard
- Adds new attribute at root of main config file: `pages`
- Updates router and nav-bar to automatically create paths for both local and remote configs

View File

@ -48,6 +48,7 @@
- [⚙️ Config Editor](#config-editor-)
- [☁ Cloud Backup & Sync](#cloud-backup--sync-)
- [🌎 Language Switching](#language-switching-)
- [📃 Multi-Page Support](#multi-page-support-)
- **Community**
- [📊 System Requirements](#system-requirements-)
- [🙋‍♀️ Support](#support-)
@ -64,18 +65,18 @@
</details>
## Features 🌈
- 📃 Support for multiple pages
- 🚦 Real-time status monitoring for each of your apps/links
- 📊 Use widgets to display info and dynamic content from self-hosted services
- 🔎 Instant search by name, domain, or tags + customizable hotkeys & keyboard shortcuts
- 🎨 Many built-in color themes, with UI color editor and support for custom CSS
- 🧸 Many icon options - Font-Awesome, homelab icons, auto-fetching Favicon, images, emojis, etc.
- 🚦 Status monitoring for each of your apps/links for basic availability and uptime checking
- 📊 Use widgets to display info and dynamic content from self-hosted services
- 💂 Optional authentication with multi-user access, configurable privileges, and SSO support
- 🌎 Multi-language support, with 10+ human-translated languages, and more on the way
- ☁ Optional, encrypted, free off-site cloud backup and restore feature available
- 💼 A workspace view, for easily switching between multiple apps simultaneously
- 🛩️ A minimal view, for use as a fast-loading browser Startpage
- 🖱️ Choose app launch method, either new tab, same tab, a pop-up modal, or in the workspace view
- 🖱️ Choose app launch methods: new tab, same tab, clipboard, pop-up modal, or open in workspace view
- 📏 Customizable layout, sizes, text, component visibility, sort order, behavior, etc.
- 🖼️ Options for a full-screen background image, custom nav-bar links, HTML footer, title, etc.
- 🚀 Easy to setup with Docker, or on bare metal, or with 1-Click cloud deployment
@ -413,11 +414,11 @@ Dashy supports multiple languages and locales. When available, your language sho
- 🇸🇮 **Slovenian**: `sl` - Contributed by **[@UrekD](https://github.com/UrekD)**
- 🇸🇪 **Swedish**: `sv` - Contributed by **[@BOZG](https://github.com/BOZG)**
- 🇮🇹 **Italian**: `it` - Contributed by **[@alexdelprete](https://github.com/alexdelprete)**
- 🇵🇹 **Portuguese**: `pt` - Machine Translated *(awaiting human review)*
- 🇵🇹 **Portuguese**: `pt` - Contributed by **[@LeoColman](https://github.com/LeoColman)**
- 🇷🇺 **Russian**: `ru` - Contributed by Anon
- 🇦🇪 **Arabic**: `ar` - Contributed by Anon
- 🇮🇳 **Hindi**: `hi` - Contributed by Anon
- 🇯🇵 **Japanese**: `ja` - Contributed by Anon
- 🇦🇪 **Arabic**: `ar`
- 🇮🇳 **Hindi**: `hi`
- 🇯🇵 **Japanese**: `ja`
#### Add your Language
I would love Dashy to be available to everyone without language being a barrier to entry. If you've got a few minutes to spare, consider adding translations for your language. It's a quick task, and all text is in [a single JSON file](https://github.com/Lissy93/dashy/tree/master/src/assets/locales). Since any missing text will fall back to English, you don't need to translate it all.
@ -426,6 +427,34 @@ I would love Dashy to be available to everyone without language being a barrier
---
## Multi-Page Support 📃
> For full multi-page documentation, see: [**Pages & Sections**](./docs/pages-and-sections.md)
Within your dashboard, you can have as many sub-pages as you require. To load additional pages, specify a name, and path to a config file under `pages`. The config file can be either local (stored in `/public`), or remote (located anywhere accessible).
```yaml
pages:
- name: Networking Services
path: 'networking.yml'
- name: Work Stuff
path: 'work.yml'
```
Or
```yaml
pages:
- name: Getting Started
path: 'https://snippet.host/tvcw/raw'
- name: Homelab
path: 'https://snippet.host/tetp/raw'
- name: Browser Startpage
path: 'https://snippet.host/zcom/raw'
```
---
## System Requirements 📊
If running on bare metal, Dashy requires [Node](https://nodejs.org/en/) V 16.0.0 or later, LTS (16.13.2) is recommended.

View File

@ -27,6 +27,7 @@ The following file provides a reference of all supported configuration options.
- [**`pageInfo`**](#pageinfo) - Header text, footer, title, navigation, etc
- [`navLinks`](#pageinfonavlinks-optional) - Links to display in the navigation bar
- [**`pages`**](#pages-optional) - List of additional config files, for multi-page dashboards
- [**`appConfig`**](#appconfig-optional) - Main application settings
- [`webSearch`](#appconfigwebsearch-optional) - Configure web search engine options
- [`hideComponents`](#appconfighidecomponents-optional) - Show/ hide page components
@ -56,6 +57,7 @@ The following file provides a reference of all supported configuration options.
**`pageInfo`** | `object` | Required | Basic meta data like title, description, nav bar links, footer text. See [`pageInfo`](#pageinfo)
**`appConfig`** | `object` | _Optional_ | Settings related to how the app functions, including API keys and global styles. See [`appConfig`](#appconfig-optional)
**`sections`** | `array` | Required | An array of sections, each containing an array of items, which will be displayed as links. See [`section`](#section)
**`pages`** | `array` | _Optional_ | An array additional config files, used for multi-page dashboards. See [`pages`](#pages-optional)
**[⬆️ Back to Top](#configuring)**
@ -81,6 +83,15 @@ The following file provides a reference of all supported configuration options.
**[⬆️ Back to Top](#configuring)**
### `pages[]` _(optional)_
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`name`** | `string` | Required | A unique name for that page
**`path`** | `string` | Required | The path (local or remote) to the config file to use.<br>For files located within `/public`, you only need to specify filename, for externally hosted files you must include the full URL
**[⬆️ Back to Top](#configuring)**
### `appConfig` _(optional)_
**Field** | **Type** | **Required**| **Description**

View File

@ -0,0 +1,58 @@
# Pages and Sections
## Multi-Page Support
You can have additional pages within your dashboard, with each having it's own config file. The config files for sub-pages can either be stored locally, or hosted separately. A link to each additional page will be displayed in the navigation bar.
You can edit additional pages using the interactive editor, exactly the same was as your primary page (so long as it's local). But please save changes to one page, before you start editing the next.
### Using Local Sub-Pages
To get started, create a new `.yml` config file for your sub-page, placing it within `/app/public`. Then within your primary `conf.yml`, choose a name, and specify the path to the new file.
For example:
```yaml
pages:
- name: Networking Services
path: 'networking.yml'
- name: Work Stuff
path: 'work.yml'
```
If you're sub-page is located within `/app/public`, then you only need to specify the filename, but if it's anywhere else, then the full path is required.
### Using Remote Sub-Pages
Config files don't need to be local, you can store them anywhere, and data will be imported as sub-pages on page load.
For example:
```yaml
pages:
- name: Getting Started
path: 'https://snippet.host/tvcw/raw'
- name: Homelab
path: 'https://snippet.host/tetp/raw'
- name: Browser Startpage
path: 'https://snippet.host/zcom/raw'
```
There are many options of how this can be used. You could store your config within a Git repository, in order to easily track and rollback changes. Or host your config on your NAS, to have it backed up with the rest of your files. Or use a hosted paste service, for example [snippet.host](https://snippet.host/), which supports never-expiring CORS-enabled pastes, which can also be edited later.
You will obviously not be able to write updates to remote configs directly through the UI editor, but you can still make and preview changes, then use the export menu to get a copy of the new config, which can then be pasted to the remote source manually.
The config file must, of course be accessible from within Dashy. If your config contains sensitive info (like API keys, credentials, secret URLs, etc), take care not to expose it to the internet.
The following example shows creating a config, publishing it as a [Gist](https://gist.github.com/), copying the URL to the raw file, and using it within your dashboard.
<p align="center">
<img width="700" alt="Public config in a gist demo"
src="https://i.ibb.co/55jm3LG/how-to-use-remote-config-sub-page.gif"
/>
</p>
### Restrictions
Only top-level fields supported by sub-pages are `pageInfo` and `sections`. The `appConfig` and `pages` will always be inherited from your main `conf.yml` file. Other than that, sub-pages behave exactly the same as your default view, and can contain sections, items, widgets and page info like nav links, title and logo.
Note that since page paths are required by the router, they are set at build-time, not run-time, and so a rebuild (happens automatically) is required for changes to page paths to take effect (this only applies to changes to the `pages` array, rebuild isn't required for editing page content).

View File

@ -21,6 +21,7 @@
- [Backup & Restore](/docs/backup-restore.md) - Guide to backing up config with Dashy's cloud sync feature
- [Icons](/docs/icons.md) - Outline of all available icon types for sections and items, with examples
- [Language Switching](/docs/multi-language-support.md) - Details on how to switch language, or add a new locale
- [Pages and Sections](/docs/pages-and-sections.md) - Multi-page support, sections, items and sub-items
- [Status Indicators](/docs/status-indicators.md) - Using Dashy to monitor uptime and status of your apps
- [Searching & Shortcuts](/docs/searching.md) - Searching, launching methods + keyboard shortcuts
- [Theming](/docs/theming.md) - Complete guide to applying, writing and modifying themes + styles

View File

@ -69,6 +69,16 @@ Custom CSS can be developed, tested and applied directly through the UI. Althoug
This can be done from the Config menu (spanner icon in the top-right), under the Custom Styles tab. This is then associated with `appConfig.customCss` in local storage. Styles can also be directly applied to this attribute in the config file, but this may get messy very quickly if you have a lot of CSS.
### Page-Specific Styles
If you've got multiple pages within your dashboard, you can choose to target certain styles to specific pages. The top-most element within `<body>` will have a class name specific to the current sub-page. This is usually the page's name, all lowercase, with dashes instead of spaces, but you can easily check this yourself within the dev tools.
For example, if the pages name was "CFT Toolbox", and you wanted to target `.item`s, you would do:
```css
.cft-toolbox .item { border: 4px solid yellow; }
```
### Loading External Stylesheets
The URI of a stylesheet, either local or hosted on a remote CDN can be passed into the config file. The attribute `appConfig.externalStyleSheet` accepts either a string, or an array of strings. You can also pass custom font stylesheets here, they must be in a CSS format (for example, `https://fonts.googleapis.com/css2?family=Cutive+Mono`).

View File

@ -8,6 +8,7 @@
- [Refused to Connect in Web Content View](#refused-to-connect-in-modal-or-workspace-view)
- [404 On Static Hosting](#404-on-static-hosting)
- [Yarn Build or Run Error](#yarn-error)
- [Remote Config Not Loading](#remote-config-not-loading)
- [Auth Validation Error: "should be object"](#auth-validation-error-should-be-object)
- [App Not Starting After Update to 2.0.4](#app-not-starting-after-update-to-204)
- [Keycloak Redirect Error](#keycloak-redirect-error)
@ -104,6 +105,21 @@ Alternatively, as a workaround, you have several options:
---
## Remote Config Not Loading
If you've got a multi-page dashboard, and are hosting the additional config files yourself, then CORS rules will apply. A CORS error will look something like:
```
Access to XMLHttpRequest at 'https://example.com/raw/my-config.yml' from origin 'http://dashy.local' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
```
The solution is to add the appropriate headers onto the target server, to allow it to accept requests from the origin where you're running Dashy.
If it is a remote service, that you do not have admin access to, then another option is to proxy the request. Either host your own, or use a publicly accessible service, like [allorigins.win](https://allorigins.win), e.g: `https://api.allorigins.win/raw?url=https://pastebin.com/raw/4tZpaJV5`. For git-based services specifically, there's [raw.githack.com](https://raw.githack.com/)
---
## Auth Validation Error: "should be object"
In V 1.6.5 an update was made that in the future will become a breaking change. You will need to update you config to reflect this before V 2.0.0 is released. In the meantime, your previous config will continue to function normally, but you will see a validation warning. The change means that the structure of the `appConfig.auth` object is now an object, which has a `users` property.

View File

@ -1,6 +1,6 @@
{
"name": "Dashy",
"version": "2.0.7",
"version": "2.0.8",
"license": "MIT",
"main": "server",
"author": "Alicia Sykes <alicia@omg.lol> (https://aliciasykes.com)",

View File

@ -8,9 +8,8 @@
<!-- Favicon + App Icon -->
<link rel="icon" type="image/png" sizes="64x64" href="<%= BASE_URL %>/web-icons/favicon-64x64.png">
<link rel="icon" type="image/png" sizes="32x32" href="web-icons/favicon-32x32.png">
<link rel="icon" href="/favicon.ico" />
<link rel="icon" type="image/png" href="<%= BASE_URL %>favicon.ico" />
<link rel="stylesheet" type="text/css" href="<%= BASE_URL %>loading-screen.css" />
<link rel="icon" type="image/png" href="/favicon.ico" />
<link rel="stylesheet" type="text/css" href="/loading-screen.css" />
<!-- Default Page Title -->
<title>Dashy</title>
</head>

View File

@ -70,7 +70,7 @@ const app = express()
.use(sslServer.middleware)
// Serves up static files
.use(express.static(path.join(__dirname, 'dist')))
.use(express.static(path.join(__dirname, 'public')))
.use(express.static(path.join(__dirname, 'public'), { index: 'initialization.html' }))
// Load middlewares for parsing JSON, and supporting HTML5 history routing
.use(express.json({ limit: '1mb' }))
.use(history())

View File

@ -7,6 +7,14 @@
const fsPromises = require('fs').promises;
module.exports = async (newConfig, render) => {
/* Either returns nothing (if using default path), or strips navigational characters from path */
const makeSafeFileName = (configObj) => {
if (!configObj || !configObj.filename) return undefined;
return configObj.filename.replaceAll('/', '').replaceAll('..', '');
};
const usersFileName = makeSafeFileName(newConfig);
// Define constants for the config file
const settings = {
defaultLocation: './public/',
@ -16,11 +24,11 @@ module.exports = async (newConfig, render) => {
};
// Make the full file name and path to save the backup config file
const backupFilePath = `${settings.defaultLocation}${settings.filename}-`
const backupFilePath = `${settings.defaultLocation}${usersFileName || settings.filename}-`
+ `${Math.round(new Date() / 1000)}${settings.backupDenominator}`;
// The path where the main conf.yml should be read and saved to
const defaultFilePath = settings.defaultLocation + settings.defaultFile;
const defaultFilePath = settings.defaultLocation + (usersFileName || settings.defaultFile);
// Returns a string confirming successful job
const getSuccessMessage = () => `Successfully backed up ${settings.defaultFile} to`
@ -36,16 +44,14 @@ module.exports = async (newConfig, render) => {
});
// Makes a backup of the existing config file
await fsPromises.copyFile(defaultFilePath, backupFilePath)
.catch((error) => {
render(getRenderMessage(false, `Unable to backup conf.yml: ${error}`));
});
await fsPromises
.copyFile(defaultFilePath, backupFilePath)
.catch((error) => render(getRenderMessage(false, `Unable to backup conf.yml: ${error}`)));
// Writes the new content to the conf.yml file
await fsPromises.writeFile(defaultFilePath, newConfig.config.toString(), writeFileOptions)
.catch((error) => {
render(getRenderMessage(false, `Unable to write changes to conf.yml: ${error}`));
});
await fsPromises
.writeFile(defaultFilePath, newConfig.config.toString(), writeFileOptions)
.catch((error) => render(getRenderMessage(false, `Unable to write to conf.yml: ${error}`)));
// If successful, then render hasn't yet been called- call it
await render(getRenderMessage(true));

View File

@ -1,5 +1,5 @@
<template>
<div id="dashy" :style="topLevelStyleModifications">
<div id="dashy" :style="topLevelStyleModifications" :class="subPageClassName">
<EditModeTopBanner v-if="isEditMode" />
<LoadingScreen :isLoading="isLoading" v-if="shouldShowSplash" />
<Header :pageInfo="pageInfo" />
@ -72,6 +72,10 @@ export default {
isEditMode() {
return this.$store.state.editMode;
},
subPageClassName() {
const currentSubPage = this.$store.state.currentConfigInfo;
return (currentSubPage && currentSubPage.pageId) ? currentSubPage.pageId : '';
},
topLevelStyleModifications() {
const vc = this.visibleComponents;
if (!vc.footer && !vc.pageTitle) {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M429.3 93.38l-74.63-74.64C342.6 6.742 326.3 0 309.4 0H160C124.7 0 96 28.65 96 64l.0098 288c0 35.34 28.65 64 64 64h224C419.2 416 448 387.2 448 352V138.6C448 121.7 441.3 105.4 429.3 93.38zM400 352c0 8.836-7.164 16-16 16H160c-8.838 0-16-7.164-16-16L144 64.13c0-8.836 7.164-16 16-16h128V128c0 17.67 14.33 32 32 32h79.99V352zM328 512h-208C53.83 512 0 458.2 0 392v-272C0 106.8 10.75 96 24 96S48 106.8 48 120v272c0 39.7 32.3 72 72 72h208c13.25 0 24 10.75 24 24S341.3 512 328 512z"/></svg>

After

Width:  |  Height:  |  Size: 720 B

View File

@ -207,6 +207,8 @@
"edit-page-info-tooltip": "App title, description, nav links, footer text, etc.",
"edit-app-config-btn": "Edit App Config",
"edit-app-config-tooltip": "All other app configuration options",
"edit-pages-btn": "Edit Pages",
"edit-pages-tooltip": "Add or remove additional views",
"config-save-methods-subheading": "Config Saving Options",
"save-locally-btn": "Save Locally",
"save-locally-tooltip": "Save config locally, to browser storage. This will not affect your config file, but changes will only be saved on this device",
@ -224,7 +226,8 @@
"edit-mode-subtitle": "You are in Edit Mode",
"edit-mode-description": "This means you can make modifications to your config, and preview the results, but until you save, none of your changes will be preserved.",
"save-stage-btn": "Save",
"cancel-stage-btn": "Cancel"
"cancel-stage-btn": "Cancel",
"save-locally-warning": "If you proceed, changes will be saved only in your browser. You should export a copy of your config for use on other machines. Would you like to continue?"
},
"edit-item": {
"missing-title-err": "An item title is required"
@ -301,4 +304,4 @@
"down": "Down"
}
}
}
}

View File

@ -47,6 +47,10 @@
</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 }}
</p>
<AppVersion />
</div>
<!-- Display note if Config disabled, or if on mobile -->
@ -245,7 +249,7 @@ a.hyperlink-wrapper {
width: 100%;
}
p.app-version, p.language {
p.app-version, p.language, p.config-location {
margin: 0.5rem auto;
font-size: 1rem;
color: var(--transparent-white-50);
@ -310,7 +314,7 @@ div.code-container {
display: flex;
flex-direction: column;
background: var(--config-settings-background);
height: calc(100% - 2rem);
height: calc(100% + 1rem);
width: fit-content;
margin: 0 auto;
padding: 2rem 1rem 0;
@ -322,13 +326,14 @@ div.code-container {
.config-note {
width: 80%;
bottom: 1rem;
max-width: 700px;
left: 10%;
bottom: 1rem;
margin: 0.5rem auto;
padding: 0.5rem 0.75rem;
text-align: center;
border: 1px dashed var(--config-settings-color);
border-radius: var(--curve-factor);
text-align: left;
opacity: var(--dimming-factor);
color: var(--config-settings-color);
background: var(--config-settings-background);

View File

@ -51,20 +51,19 @@
<script>
import axios from 'axios';
import { Progress } from 'rsup-progress';
import VJsoneditor from 'v-jsoneditor';
import jsYaml from 'js-yaml';
import ErrorHandler, { InfoHandler, InfoKeys } from '@/utils/ErrorHandler';
import ConfigSavingMixin from '@/mixins/ConfigSaving';
import { InfoHandler, InfoKeys } from '@/utils/ErrorHandler';
import configSchema from '@/utils/ConfigSchema.json';
import StoreKeys from '@/utils/StoreMutations';
import { localStorageKeys, serviceEndpoints, modalNames } from '@/utils/defaults';
import { modalNames } from '@/utils/defaults';
import Button from '@/components/FormElements/Button';
import Radio from '@/components/FormElements/Radio';
import AccessError from '@/components/Configuration/AccessError';
export default {
name: 'JsonEditor',
mixins: [ConfigSavingMixin],
components: {
VJsoneditor,
Button,
@ -83,9 +82,6 @@ export default {
name: 'config',
onValidationError: this.validationErrors,
},
responseText: '',
saveSuccess: undefined,
progress: new Progress({ color: 'var(--progress-bar)' }),
saveOptions: [
{ label: this.$t('config-editor.location-disk-label'), value: 'file' },
{ label: this.$t('config-editor.location-local-label'), value: 'local' },
@ -126,9 +122,9 @@ export default {
/* Calls appropriate save method, based on save-type radio selected */
save() {
if (this.saveMode === 'local' || !this.allowWriteToDisk) {
this.saveConfigLocally();
this.saveLocally();
} else if (this.saveMode === 'file') {
this.writeConfigToDisk();
this.writeToDisk();
} else {
this.$toasted.show(this.$t('config-editor.error-msg-save-mode'));
}
@ -144,67 +140,15 @@ export default {
this.$store.commit(StoreKeys.SET_EDIT_MODE, true);
this.$modal.hide(modalNames.CONF_EDITOR);
},
/* Converts config to YAML, and writes it to disk */
writeConfigToDisk() {
// 1. Convert JSON into YAML
const yaml = jsYaml.dump(this.config);
// 2. Prepare the request
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
const endpoint = `${baseUrl}${serviceEndpoints.save}`;
const headers = { 'Content-Type': 'text/plain' };
const body = { config: yaml, timestamp: new Date() };
const request = axios.post(endpoint, body, headers);
// 3. Make the request, and handle response
this.progress.start();
request.then((response) => {
this.saveSuccess = response.data.success || false;
this.responseText = response.data.message;
if (this.saveSuccess) {
this.carefullyClearLocalStorage();
this.showToast(this.$t('config-editor.success-msg-disk'), true);
} else {
this.showToast(this.$t('config-editor.error-msg-cannot-save'), false);
}
InfoHandler('Config has been written to disk succesfully', InfoKeys.RAW_EDITOR);
this.$store.commit(StoreKeys.SET_CONFIG, this.jsonData);
this.progress.end();
})
.catch((error) => {
this.saveSuccess = false;
this.responseText = error;
this.showToast(error, false);
ErrorHandler(`Failed to save config. ${error}`);
this.progress.end();
});
writeToDisk() {
this.writeConfigToDisk(this.config);
},
/* Saves config to local browser storage */
saveConfigLocally() {
if (!this.allowSaveLocally) {
ErrorHandler('Unable to save changes locally, this feature has been disabled');
return;
saveLocally() {
const msg = this.$t('interactive-editor.menu.save-locally-warning');
const youSure = confirm(msg); // eslint-disable-line no-alert, no-restricted-globals
if (youSure) {
this.saveConfigLocally(this.jsonData);
}
const data = this.jsonData;
if (data.sections) {
localStorage.setItem(localStorageKeys.CONF_SECTIONS, JSON.stringify(data.sections));
}
if (data.pageInfo) {
localStorage.setItem(localStorageKeys.PAGE_INFO, JSON.stringify(data.pageInfo));
}
if (data.appConfig) {
data.appConfig.auth = this.config.appConfig.auth || {};
localStorage.setItem(localStorageKeys.APP_CONFIG, JSON.stringify(data.appConfig));
}
if (data.appConfig.theme) {
localStorage.setItem(localStorageKeys.THEME, data.appConfig.theme);
}
InfoHandler('Config has successfully been saved in browser storage', InfoKeys.RAW_EDITOR);
this.showToast(this.$t('config-editor.success-msg-local'), true);
},
/* Clears config from browser storage, only removing relevant items */
carefullyClearLocalStorage() {
localStorage.removeItem(localStorageKeys.PAGE_INFO);
localStorage.removeItem(localStorageKeys.APP_CONFIG);
localStorage.removeItem(localStorageKeys.CONF_SECTIONS);
},
/* Convert error messages into readable format for UI */
validationErrors(errors) {

View File

@ -51,10 +51,11 @@
</Button>
</div>
<!-- Open Modal Buttons -->
<div class="edit-banner-section edit-site-config-buttons">
<div class="edit-banner-section edit-config-buttons-container">
<p class="section-sub-title">
{{ $t('interactive-editor.menu.edit-site-data-subheading') }}
</p>
<!-- Button to open pageInfo editor -->
<Button
:click="openEditPageInfo"
:disallow="!permissions.allowViewConfig"
@ -63,6 +64,7 @@
{{ $t('interactive-editor.menu.edit-page-info-btn') }}
<PageInfoIcon />
</Button>
<!-- Button to open appConfig editor -->
<Button
:click="openEditAppConfig"
:disallow="!permissions.allowViewConfig"
@ -71,24 +73,31 @@
{{ $t('interactive-editor.menu.edit-app-config-btn') }}
<AppConfigIcon />
</Button>
<!-- Button to open pages editor -->
<Button
:click="openEditMultiPages"
:disallow="!permissions.allowViewConfig"
v-tooltip="tooltip($t('interactive-editor.menu.edit-pages-tooltip'))"
>
{{ $t('interactive-editor.menu.edit-pages-btn') }}
<MultiPagesIcon />
</Button>
</div>
<!-- Modals for editing appConfig + pageInfo -->
<!-- Modals for editing appConfig, pageInfo and pages -->
<EditPageInfo />
<EditAppConfig />
<EditMultiPages />
</div>
</template>
<script>
import axios from 'axios';
import jsYaml from 'js-yaml';
import { Progress } from 'rsup-progress';
import ConfigSavingMixin from '@/mixins/ConfigSaving';
import Button from '@/components/FormElements/Button';
import StoreKeys from '@/utils/StoreMutations';
import EditPageInfo from '@/components/InteractiveEditor/EditPageInfo';
import EditAppConfig from '@/components/InteractiveEditor/EditAppConfig';
import { modalNames, localStorageKeys, serviceEndpoints } from '@/utils/defaults';
import ErrorHandler, { InfoHandler } from '@/utils/ErrorHandler';
import EditMultiPages from '@/components/InteractiveEditor/EditMultiPages';
import { modalNames } from '@/utils/defaults';
import AccessError from '@/components/Configuration/AccessError';
import SaveLocallyIcon from '@/assets/interface-icons/interactive-editor-save-locally.svg';
@ -97,19 +106,23 @@ import ExportIcon from '@/assets/interface-icons/interactive-editor-export-chang
import CancelIcon from '@/assets/interface-icons/interactive-editor-cancel-changes.svg';
import AppConfigIcon from '@/assets/interface-icons/interactive-editor-app-config.svg';
import PageInfoIcon from '@/assets/interface-icons/interactive-editor-page-info.svg';
import MultiPagesIcon from '@/assets/interface-icons/config-pages.svg';
export default {
name: 'EditModeSaveMenu',
mixins: [ConfigSavingMixin],
components: {
Button,
EditPageInfo,
EditAppConfig,
EditMultiPages,
SaveLocallyIcon,
SaveToDiskIcon,
ExportIcon,
CancelIcon,
AppConfigIcon,
PageInfoIcon,
EditAppConfig,
MultiPagesIcon,
AccessError,
},
computed: {
@ -124,13 +137,6 @@ export default {
return this.permissions.allowWriteToDisk || this.permissions.allowSaveLocally;
},
},
data() {
return {
saveSuccess: undefined,
responseText: '',
progress: new Progress({ color: 'var(--progress-bar)' }),
};
},
methods: {
reset() {
this.$store.dispatch(StoreKeys.INITIALIZE_CONFIG);
@ -148,69 +154,25 @@ export default {
this.$modal.show(modalNames.EDIT_APP_CONFIG);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, true);
},
openEditMultiPages() {
this.$modal.show(modalNames.EDIT_MULTI_PAGES);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, true);
},
tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 };
},
showToast(message, success) {
this.$toasted.show(message, { className: `toast-${success ? 'success' : 'error'}` });
},
carefullyClearLocalStorage() {
localStorage.removeItem(localStorageKeys.PAGE_INFO);
localStorage.removeItem(localStorageKeys.APP_CONFIG);
localStorage.removeItem(localStorageKeys.CONF_SECTIONS);
},
saveLocally() {
if (!this.permissions.allowSaveLocally) {
ErrorHandler('Unable to save changes locally, this feature has been disabled');
return;
const msg = this.$t('interactive-editor.menu.save-locally-warning');
const youSure = confirm(msg); // eslint-disable-line no-alert, no-restricted-globals
if (youSure) {
this.saveConfigLocally(this.config);
}
const data = this.config;
localStorage.setItem(localStorageKeys.CONF_SECTIONS, JSON.stringify(data.sections));
localStorage.setItem(localStorageKeys.PAGE_INFO, JSON.stringify(data.pageInfo));
localStorage.setItem(localStorageKeys.APP_CONFIG, JSON.stringify(data.appConfig));
if (data.appConfig.theme) {
localStorage.setItem(localStorageKeys.THEME, data.appConfig.theme);
}
InfoHandler('Config has succesfully been saved in browser storage', 'Config Update');
this.showToast(this.$t('config-editor.success-msg-local'), true);
this.$store.commit(StoreKeys.SET_EDIT_MODE, false);
},
writeToDisk() {
if (this.config.appConfig.preventWriteToDisk) {
ErrorHandler('Unable to write changed to disk, as this functionality is disabled');
return;
}
// 1. Convert JSON into YAML
const yamlOptions = {};
const yaml = jsYaml.dump(this.config, yamlOptions);
// 2. Prepare the request
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
const endpoint = `${baseUrl}${serviceEndpoints.save}`;
const headers = { 'Content-Type': 'text/plain' };
const body = { config: yaml, timestamp: new Date() };
const request = axios.post(endpoint, body, headers);
// 3. Make the request, and handle response
this.progress.start();
request.then((response) => {
this.saveSuccess = response.data.success || false;
this.responseText = response.data.message;
if (this.saveSuccess) {
this.carefullyClearLocalStorage();
this.showToast(this.$t('config-editor.success-msg-disk'), true);
} else {
this.showToast(this.$t('config-editor.error-msg-cannot-save'), false);
}
InfoHandler('Config has been written to disk succesfully', 'Config Update');
this.progress.end();
this.$store.commit(StoreKeys.SET_EDIT_MODE, false);
})
.catch((error) => {
this.saveSuccess = false;
this.responseText = error;
this.showToast(error, false);
ErrorHandler(`Failed to save config. ${error}`);
this.progress.end();
});
this.writeConfigToDisk(this.config);
},
},
};
@ -230,14 +192,15 @@ div.edit-mode-bottom-banner {
background: var(--interactive-editor-background-darker);
box-shadow: 0 -5px 7px var(--transparent-50);
grid-template-columns: 45% 10% 45%;
@include laptop-up { grid-template-columns: 40% 20% 40%; }
@include monitor-up { grid-template-columns: 30% 40% 30%; }
@include laptop-up { grid-template-columns: 50% 10% 40%; }
@include monitor-up { grid-template-columns: 40% 30% 30%; }
@include big-screen-up { grid-template-columns: 25% 50% 25%; }
/* Main sections */
.edit-banner-section {
padding: 0.5rem;
height: 90%;
display: grid;
/* Section sub-titles */
p.section-sub-title {
margin: 0;
@ -258,22 +221,24 @@ div.edit-mode-bottom-banner {
padding: 0 0.5rem;
}
}
button {
margin: 0.25rem;
height: stretch;
max-height: 3rem;
}
/* Button containers */
&.edit-site-config-buttons,
&.save-buttons-container {
display: grid;
grid-template-columns: repeat(2, 1fr);
button {
margin: 0.25rem;
height: stretch;
max-height: 3rem;
}
&.edit-config-buttons-container {
grid-template-columns: repeat(3, 1fr);
p.section-sub-title {
grid-column-start: span 2;
grid-column-start: span 3;
}
}
&.save-buttons-container {
grid-row-start: span 2;
grid-template-columns: repeat(2, 1fr);
p.section-sub-title {
grid-column-start: span 2;
}
}
}

View File

@ -0,0 +1,131 @@
<template>
<modal
:name="modalName" @closed="modalClosed"
:resizable="true" width="50%" height="80%"
classes="dashy-modal edit-multi-pages"
>
<div class="edit-multi-pages-inner" v-if="allowViewConfig">
<h3>{{ $t('interactive-editor.menu.edit-page-info-btn') }}</h3>
<FormSchema
:schema="schema"
v-model="formData"
@submit.prevent="saveToState"
class="multi-page-form"
name="multiPageForm"
>
<SaveCancelButtons :saveClick="saveToState" :cancelClick="cancelEditing" />
</FormSchema>
</div>
<AccessError v-else />
</modal>
</template>
<script>
import FormSchema from '@formschema/native';
import DashySchema from '@/utils/ConfigSchema';
import StoreKeys from '@/utils/StoreMutations';
import { modalNames } from '@/utils/defaults';
import SaveCancelButtons from '@/components/InteractiveEditor/SaveCancelButtons';
import AccessError from '@/components/Configuration/AccessError';
export default {
name: 'EditPageInfo',
data() {
return {
formData: {},
schema: DashySchema.properties.pages,
modalName: modalNames.EDIT_MULTI_PAGES,
};
},
components: {
FormSchema,
SaveCancelButtons,
AccessError,
},
mounted() {
this.formData = this.pages;
},
computed: {
pages() {
return this.$store.getters.pages;
},
allowViewConfig() {
return this.$store.getters.permissions.allowViewConfig;
},
},
methods: {
/* When form submitted, update VueX store with new pageInfo, and close modal */
saveToState() {
this.$store.commit(StoreKeys.SET_PAGES, this.formData);
this.$modal.hide(this.modalName);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
this.$store.commit(StoreKeys.SET_EDIT_MODE, true);
},
/* Called when modal manually closed, updates state to allow searching again */
modalClosed() {
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
},
cancelEditing() {
this.$modal.hide(this.modalName);
},
},
};
</script>
<style lang="scss">
@import '@/styles/style-helpers.scss';
@import '@/styles/media-queries.scss';
@import '@/styles/schema-editor.scss';
.edit-multi-pages-inner {
padding: 1rem;
background: var(--interactive-editor-background);
color: var(--interactive-editor-color);
height: 100%;
overflow-y: auto;
@extend .scroll-bar;
h3 {
font-size: 1.4rem;
margin: 0.5rem;
}
.multi-page-form {
@extend .schema-form;
margin-bottom: 2.5rem;
fieldset div[data-fs-wrapper], fieldset div[data-fs-kind=object] {
flex-direction: row;
}
[name=multiPageForm] button {
width: 8rem;
margin: 0 0.5rem 0.5rem 0.5rem;
padding: 0.25rem 0.5rem;
&[data-fs-button=push]::after { content: " Add New"; }
&[data-fs-button=moveUp]::after { content: " Move Up"; }
&[data-fs-button=moveDown]::after { content: " Move Down"; }
&[data-fs-button=delete]::after { content: " Delete"; }
}
div[data-fs-type=object] div[data-fs-type=object] {
div[data-fs-input=object] { border: none; }
label { display: none; }
div[data-fs-input=object] label { display: block; }
}
fieldset div[data-fs-kind=object] span {
text-align: right;
}
fieldset button[data-fs-button=push] {
min-width: 15rem;
padding: 0.5rem 0.75rem;
margin: 0.5rem 0;
font-size: 1rem;
color: var(--interactive-editor-color);
background: var(--interactive-editor-background);
border: 1px solid var(--interactive-editor-color);
border-radius: var(--curve-factor);
&:hover {
color: var(--interactive-editor-background);
background: var(--interactive-editor-color);
}
}
}
}
</style>

View File

@ -56,6 +56,9 @@ export default {
path { fill: var(--interactive-editor-background); }
}
}
&:focus {
box-shadow: 1px 1px 6px var(--interactive-editor-color);
}
}
}
</style>

View File

@ -142,12 +142,13 @@ export default {
},
/* Get favicon URL, for items which use the favicon as their icon */
getFavicon(fullUrl, specificApi) {
const fullUrlTrue = fullUrl || '';
const faviconApi = specificApi || this.appConfig.faviconApi || defaultFaviconApi;
if (this.shouldUseDefaultFavicon(fullUrl) || faviconApi === 'local') { // Check if we should use local icon
const urlParts = fullUrl.split('/');
if (this.shouldUseDefaultFavicon(fullUrlTrue) || faviconApi === 'local') { // Check if we should use local icon
const urlParts = fullUrlTrue.split('/');
if (urlParts.length >= 2) return `${urlParts[0]}/${urlParts[1]}/${urlParts[2]}/${iconCdns.faviconName}`;
} else if (fullUrl.includes('http')) { // Service is running publicly
const host = this.getHostName(fullUrl);
} else if (fullUrlTrue.includes('http')) { // Service is running publicly
const host = this.getHostName(fullUrlTrue);
const endpoint = faviconApiEndpoints[faviconApi];
return endpoint.replace('$URL', host);
}
@ -223,7 +224,7 @@ export default {
/* Called when initial icon has resulted in 404. Attempts to find new icon */
getFallbackIcon() {
if (this.attemptedFallback) return undefined; // If this is second attempt, then give up
const { iconType } = this;
const iconType = this.iconType || '';
const markAsAttempted = () => { this.broken = false; this.attemptedFallback = true; };
if (iconType.includes('favicon')) { // Specify fallback for favicon-based icons
markAsAttempted();

View File

@ -3,10 +3,15 @@
<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>
Developed by <a :href="authorUrl">{{authorName}}</a>.
Licensed under <a :href="licenseUrl">{{license}}</a>
{{ showCopyright? '©': '' }} {{date}}.
Get the <a :href="repoUrl">Source Code</a>.
</span>
</footer>
</template>
@ -50,6 +55,17 @@ footer {
@include tablet-down {
display: none;
}
span.path-to-config {
float: right;
font-size: 0.75rem;
margin: 0.1rem 0.5rem 0 0;
opacity: var(--dimming-factor);
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
max-height: 1rem;
}
}
footer a{

View File

@ -1,12 +1,12 @@
<template>
<div class="nav-outer" v-if="links && links.length > 0">
<div class="nav-outer" v-if="allLinks && allLinks.length > 0">
<IconBurger
:class="`burger ${!navVisible ? 'visible' : ''}`"
@click="navVisible = !navVisible"
/>
<nav id="nav" v-if="navVisible">
<!-- Render either router-link or anchor, depending if internal / external link -->
<template v-for="(link, index) in links">
<template v-for="(link, index) in allLinks">
<router-link v-if="!isUrl(link.path)"
:key="index"
:to="link.path"
@ -28,6 +28,7 @@
<script>
import IconBurger from '@/assets/interface-icons/burger-menu.svg';
import { makePageSlug } from '@/utils/ConfigHelpers';
export default {
name: 'Nav',
@ -41,6 +42,17 @@ export default {
navVisible: true,
isMobile: false,
}),
computed: {
/* Get links to sub-pages, and combine with nav-links */
allLinks() {
const subPages = this.$store.getters.pages.map((subPage) => ({
path: makePageSlug(subPage.name, 'home'),
title: subPage.name,
}));
const navLinks = this.links || [];
return [...navLinks, ...subPages];
},
},
created() {
this.navVisible = !this.detectMobile();
this.isMobile = this.detectMobile();

View File

@ -25,24 +25,7 @@
</modal>
<!-- Menu for switching view -->
<div v-if="viewSwitcherOpen" class="view-switcher">
<ul>
<li>
<router-link to="/home">
<IconHome /><span>{{ $t('alternate-views.default') }}</span>
</router-link>
</li>
<li>
<router-link to="/minimal">
<IconMinimalView /><span>{{ $t('alternate-views.minimal') }}</span>
</router-link>
<li>
<router-link to="/workspace">
<IconWorkspaceView /><span>{{ $t('alternate-views.workspace') }}</span>
</router-link>
</li>
</ul>
</div>
<ViewSwitcher v-if="viewSwitcherOpen" />
</div>
</template>
@ -52,13 +35,11 @@ import ConfigContainer from '@/components/Configuration/ConfigContainer';
import LanguageSwitcher from '@/components/Settings/LanguageSwitcher';
import Keys from '@/utils/StoreMutations';
import { topLevelConfKeys, localStorageKeys, modalNames } from '@/utils/defaults';
import ViewSwitcher from '@/components/Settings/ViewSwitcher';
// Import icons for config launcher buttons
import IconSpanner from '@/assets/interface-icons/config-editor.svg';
import IconInteractiveEditor from '@/assets/interface-icons/interactive-editor-edit-mode.svg';
import IconViewMode from '@/assets/interface-icons/application-change-view.svg';
import IconHome from '@/assets/interface-icons/application-home.svg';
import IconWorkspaceView from '@/assets/interface-icons/open-workspace.svg';
import IconMinimalView from '@/assets/interface-icons/application-minimal.svg';
export default {
name: 'ConfigLauncher',
@ -71,12 +52,10 @@ export default {
components: {
ConfigContainer,
LanguageSwitcher,
ViewSwitcher,
IconSpanner,
IconInteractiveEditor,
IconViewMode,
IconHome,
IconWorkspaceView,
IconMinimalView,
},
computed: {
sections() {
@ -149,37 +128,4 @@ export default {
min-width: 3.2rem;
}
.view-switcher {
position: absolute;
right: 1rem;
margin-top: 3rem;
z-index: 5;
background: var(--background);
border: 1px solid var(--settings-text-color);
border-radius: var(--curve-factor);
box-shadow: var(--settings-container-shadow);
ul {
list-style: none;
margin: 0;
padding: 0;
li {
cursor: pointer;
padding: 0.25rem 0.75rem;
a {
color: var(--settings-text-color);
text-decoration: none;
display: flex;
align-items: center;
}
&:hover {
background: var(--settings-text-color);
a { color: var(--background); }
}
svg {
margin: 0 0.25rem 0 0;
border: none;
}
}
}
}
</style>

View File

@ -0,0 +1,77 @@
<template>
<div class="view-switcher">
<ul>
<li>
<router-link :to="`/home/${subPagePath}`">
<IconHome /><span>{{ $t('alternate-views.default') }}</span>
</router-link>
</li>
<li>
<router-link :to="`/minimal/${subPagePath}`">
<IconMinimalView /><span>{{ $t('alternate-views.minimal') }}</span>
</router-link>
<li>
<router-link :to="`/workspace/${subPagePath}`">
<IconWorkspaceView /><span>{{ $t('alternate-views.workspace') }}</span>
</router-link>
</li>
</ul>
</div>
</template>
<script>
import IconHome from '@/assets/interface-icons/application-home.svg';
import IconWorkspaceView from '@/assets/interface-icons/open-workspace.svg';
import IconMinimalView from '@/assets/interface-icons/application-minimal.svg';
export default {
components: {
IconHome,
IconWorkspaceView,
IconMinimalView,
},
computed: {
subPagePath() {
return this.$route.path.split('/').pop() || '';
},
},
};
</script>
<style scoped lang="scss">
.view-switcher {
position: absolute;
right: 1rem;
margin-top: 3rem;
z-index: 5;
background: var(--background);
border: 1px solid var(--settings-text-color);
border-radius: var(--curve-factor);
box-shadow: var(--settings-container-shadow);
ul {
list-style: none;
margin: 0;
padding: 0;
li {
cursor: pointer;
padding: 0.25rem 0.75rem;
a {
color: var(--settings-text-color);
text-decoration: none;
display: flex;
align-items: center;
}
&:hover {
background: var(--settings-text-color);
a { color: var(--background); }
}
svg {
margin: 0 0.5rem 0 0;
width: 1rem;
border: none;
}
}
}
}
</style>

View File

@ -0,0 +1,82 @@
import axios from 'axios';
import jsYaml from 'js-yaml';
import { Progress } from 'rsup-progress';
import ErrorHandler, { InfoHandler } from '@/utils/ErrorHandler';
import { localStorageKeys, serviceEndpoints } from '@/utils/defaults';
import StoreKeys from '@/utils/StoreMutations';
export default {
data() {
return {
saveSuccess: undefined,
responseText: '',
progress: new Progress({ color: 'var(--progress-bar)' }),
};
},
methods: {
writeConfigToDisk(config) {
if (config.appConfig.preventWriteToDisk) {
ErrorHandler('Unable to write changed to disk, as this functionality is disabled');
return;
}
// 1. Get the config, and strip appConfig if is sub-page
const isSubPag = !!this.$store.state.currentConfigInfo;
const jsonConfig = config;
if (isSubPag) delete jsonConfig.appConfig;
// 2. Convert JSON into YAML
const yamlOptions = {};
const yaml = jsYaml.dump(jsonConfig, yamlOptions);
// 3. Prepare the request
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
const endpoint = `${baseUrl}${serviceEndpoints.save}`;
const headers = { 'Content-Type': 'text/plain' };
const filename = isSubPag
? (this.$store.state.currentConfigInfo.confPath || '') : '';
const body = { config: yaml, timestamp: new Date(), filename };
const request = axios.post(endpoint, body, headers);
// 4. Make the request, and handle response
this.progress.start();
request.then((response) => {
this.saveSuccess = response.data.success || false;
this.responseText = response.data.message;
if (this.saveSuccess) {
this.carefullyClearLocalStorage();
this.showToast(this.$t('config-editor.success-msg-disk'), true);
} else {
this.showToast(this.$t('config-editor.error-msg-cannot-save'), false);
}
InfoHandler('Config has been written to disk successfully', 'Config Update');
this.progress.end();
this.$store.commit(StoreKeys.SET_EDIT_MODE, false);
})
.catch((error) => { // fucking hell
this.saveSuccess = false;
this.responseText = error;
this.showToast(error, false);
ErrorHandler(`Failed to save config. ${error}`);
this.progress.end();
});
},
saveConfigLocally(config) {
if (!this.permissions.allowSaveLocally) {
ErrorHandler('Unable to save changes locally, this feature has been disabled');
return;
}
localStorage.setItem(localStorageKeys.CONF_SECTIONS, JSON.stringify(config.sections));
localStorage.setItem(localStorageKeys.PAGE_INFO, JSON.stringify(config.pageInfo));
localStorage.setItem(localStorageKeys.APP_CONFIG, JSON.stringify(config.appConfig));
if (config.appConfig.theme) {
localStorage.setItem(localStorageKeys.THEME, config.appConfig.theme);
}
InfoHandler('Config has succesfully been saved in browser storage', 'Config Update');
this.showToast(this.$t('config-editor.success-msg-local'), true);
this.$store.commit(StoreKeys.SET_EDIT_MODE, false);
},
carefullyClearLocalStorage() {
localStorage.removeItem(localStorageKeys.PAGE_INFO);
localStorage.removeItem(localStorageKeys.APP_CONFIG);
localStorage.removeItem(localStorageKeys.CONF_SECTIONS);
},
},
};

View File

@ -3,10 +3,13 @@
*/
import Defaults, { localStorageKeys, iconCdns } from '@/utils/defaults';
import Keys from '@/utils/StoreMutations';
import { searchTiles } from '@/utils/Search';
const HomeMixin = {
props: {},
props: {
subPageInfo: Object,
},
computed: {
sections() {
return this.$store.getters.sections;
@ -27,7 +30,23 @@ const HomeMixin = {
data: () => ({
searchValue: '',
}),
async mounted() {
await this.getConfigForRoute();
},
watch: {
async $route() {
await this.getConfigForRoute();
},
},
methods: {
async getConfigForRoute() {
this.$store.commit(Keys.SET_CURRENT_SUB_PAGE, this.subPageInfo);
if (this.subPageInfo && this.subPageInfo.confPath) { // Get config for sub-page
await this.$store.dispatch(Keys.INITIALIZE_MULTI_PAGE_CONFIG, this.subPageInfo.confPath);
} else { // Otherwise, use main config
this.$store.commit(Keys.USE_MAIN_CONFIG);
}
},
updateModalVisibility(modalState) {
this.$store.commit('SET_MODAL_OPEN', modalState);
},

View File

@ -15,9 +15,12 @@ import ConfigAccumulator from '@/utils/ConfigAccumalator';
// Import helper functions, config data and defaults
import { isAuthEnabled, isLoggedIn, isGuestAccessEnabled } from '@/utils/Auth';
import { makePageSlug, makePageName } from '@/utils/ConfigHelpers';
import { metaTagData, startingView, routePaths } from '@/utils/defaults';
import ErrorHandler from '@/utils/ErrorHandler';
import { pages } from '../public/conf.yml';
Vue.use(Router);
const progress = new Progress({ color: 'var(--progress-bar)' });
@ -34,6 +37,7 @@ const getConfig = () => {
return {
appConfig: Accumulator.appConfig(),
pageInfo: Accumulator.pageInfo(),
pages: Accumulator.pages(),
};
};
@ -61,6 +65,53 @@ const makeMetaTags = (defaultTitle) => ({
metaTags: metaTagData,
});
const makeSubConfigPath = (rawPath) => {
if (!rawPath) return '';
if (rawPath.startsWith('/') || rawPath.startsWith('http')) return rawPath;
else return `/${rawPath}`;
};
/* For each additional config file, create routes for home, minimal and workspace views */
const makeMultiPageRoutes = (userPages) => {
if (!userPages) return [];
const multiPageRoutes = [];
userPages.forEach((page) => {
if (!page.name || !page.path) {
ErrorHandler('Additional pages must have both a `name` and `path`');
}
// Props to be passed to home mixin
const subPageInfo = {
subPageInfo: {
confPath: makeSubConfigPath(page.path),
pageId: makePageName(page.name),
pageTitle: page.name,
},
};
// Create route for default homepage
multiPageRoutes.push({
path: makePageSlug(page.name, 'home'),
name: `${subPageInfo.subPageInfo.pageId}-home`,
component: Home,
props: subPageInfo,
});
// Create route for the workspace view
multiPageRoutes.push({
path: makePageSlug(page.name, 'workspace'),
name: `${subPageInfo.subPageInfo.pageId}-workspace`,
component: () => import('./views/Workspace.vue'),
props: subPageInfo,
});
// Create route for the minimal view
multiPageRoutes.push({
path: makePageSlug(page.name, 'minimal'),
name: `${subPageInfo.subPageInfo.pageId}-minimal`,
component: () => import('./views/Minimal.vue'),
props: subPageInfo,
});
});
return multiPageRoutes;
};
/* Routing mode, can be either 'hash', 'history' or 'abstract' */
const mode = appConfig.routingMode || 'history';
@ -68,6 +119,7 @@ const mode = appConfig.routingMode || 'history';
const router = new Router({
mode,
routes: [
...makeMultiPageRoutes(pages),
{ // The default view can be customized by the user
path: '/',
name: `landing-page-${getStartingView()}`,

View File

@ -8,15 +8,17 @@ import ConfigAccumulator from '@/utils/ConfigAccumalator';
import { componentVisibility } from '@/utils/ConfigHelpers';
import { applyItemId } from '@/utils/SectionHelpers';
import filterUserSections from '@/utils/CheckSectionVisibility';
import { InfoHandler, InfoKeys } from '@/utils/ErrorHandler';
import ErrorHandler, { InfoHandler, InfoKeys } from '@/utils/ErrorHandler';
import { isUserAdmin } from '@/utils/Auth';
Vue.use(Vuex);
const {
INITIALIZE_CONFIG,
INITIALIZE_MULTI_PAGE_CONFIG,
SET_CONFIG,
SET_REMOTE_CONFIG,
SET_CURRENT_SUB_PAGE,
SET_MODAL_OPEN,
SET_LANGUAGE,
SET_ITEM_LAYOUT,
@ -24,10 +26,12 @@ const {
SET_THEME,
SET_CUSTOM_COLORS,
UPDATE_ITEM,
USE_MAIN_CONFIG,
SET_EDIT_MODE,
SET_PAGE_INFO,
SET_APP_CONFIG,
SET_SECTIONS,
SET_PAGES,
UPDATE_SECTION,
INSERT_SECTION,
REMOVE_SECTION,
@ -40,10 +44,11 @@ const {
const store = new Vuex.Store({
state: {
config: {},
config: {}, // The current config, rendered to the UI
remoteConfig: {}, // The configuration stored on the server
editMode: false, // While true, the user can drag and edit items + sections
modalOpen: false, // KB shortcut functionality will be disabled when modal is open
currentConfigInfo: undefined, // For multi-page support, will store info about config file
navigateConfToTab: undefined, // Used to switch active tab in config modal
},
getters: {
@ -59,6 +64,9 @@ const store = new Vuex.Store({
sections(state) {
return filterUserSections(state.config.sections || []);
},
pages(state) {
return state.remoteConfig.pages || [];
},
theme(state) {
return state.config.appConfig.theme;
},
@ -172,6 +180,12 @@ const store = new Vuex.Store({
state.config = newConfig;
InfoHandler('App config updated', InfoKeys.EDITOR);
},
[SET_PAGES](state, multiPages) {
const newConfig = state.config;
newConfig.pages = multiPages;
state.config = newConfig;
InfoHandler('Pages updated', InfoKeys.EDITOR);
},
[SET_SECTIONS](state, newSections) {
const newConfig = state.config;
newConfig.sections = newSections;
@ -275,6 +289,16 @@ const store = new Vuex.Store({
[CONF_MENU_INDEX](state, index) {
state.navigateConfToTab = index;
},
[SET_CURRENT_SUB_PAGE](state, subPageObject) {
state.currentConfigInfo = subPageObject;
},
[USE_MAIN_CONFIG](state) {
if (state.remoteConfig) {
state.config = state.remoteConfig;
} else {
this.dispatch(Keys.INITIALIZE_CONFIG);
}
},
},
actions: {
/* Called when app first loaded. Reads config and sets state */
@ -285,6 +309,16 @@ const store = new Vuex.Store({
const config = deepCopy(new ConfigAccumulator().config());
commit(SET_CONFIG, config);
},
/* Fetch config for a sub-page (sections and pageInfo only) */
async [INITIALIZE_MULTI_PAGE_CONFIG]({ commit, state }, configPath) {
axios.get(configPath).then((response) => {
const subConfig = yaml.load(response.data);
subConfig.appConfig = state.config.appConfig; // Always use parent appConfig
commit(SET_CONFIG, subConfig);
}).catch((err) => {
ErrorHandler(`Unable to load config from '${configPath}'`, err);
});
},
},
modules: {},
});

View File

@ -63,6 +63,32 @@ html[data-theme='dracula'] {
}
}
html[data-theme='crayola'] {
--primary: #7fd8e7;
--background: #191d2e;
--background-darker: #070912;
--font-headings: 'Sniglet', cursive;
--curve-factor: 8px;
--nav-link-border-color-hover: transparent;
.collapsable, .nav a.nav-item {
&:nth-child(1n) { --index-color: #9b5de5; }
&:nth-child(2n) { --index-color: #f15bb5; }
&:nth-child(3n) { --index-color: #fee440; }
&:nth-child(4n) { --index-color: #00bbf9; }
&:nth-child(5n) { --index-color: #00f5d4; }
--item-group-outer-background: var(--index-color);
--item-text-color: var(--index-color);
--widget-text-color: var(--index-color);
--primary: var(--index-color);
--item-group-shadow: inset 0 2px 1px var(--index-color), 1px 1px 2px #000000cc;
--item-hover-shadow: 0 0 2px var(--index-color);
--item-text-color-hover: var(--index-color);
--nav-link-text-color-hover: var(--index-color);
--nav-link-shadow-hover: inset 0 2px 1px var(--index-color), 1px 1px 2px #000000cc;
.item:hover { background: var(--index-color); color: var(--background); }
}
}
html[data-theme='bee'] {
--primary: #c3eb5c;
--background: #0b1021;
@ -198,7 +224,119 @@ html[data-theme='nord'] {
.collapsable:nth-child(4n) { background: #A3BE8C; }
}
html[data-theme='nord-frost'] {
html[data-theme='basic'],
html[data-theme='whimsy'],
html[data-theme='argon'],
html[data-theme='deep-ocean'],
html[data-theme='fallout'] {
--primary: #aabbc3;
--secondary: #aabbc3;
--item-background: none;
--outline-color: none;
--item-shadow: none;
--item-hover-shadow: 2px 3px 5px var(--background-darker);
--item-text-color-hover: var(--secondary);
--item-group-background: none;
--item-group-outer-background: none;
--item-group-heading-text-color: var(--primary);
--item-group-heading-text-color-hover: var(--secondary);
--nav-link-shadow: none;
--nav-link-border-color: transparent;
--nav-link-background-color: none;
--nav-link-shadow-hover: none;
--nav-link-border-color-hover: var(--secondary);
--nav-link-background-color-hover: none;
--font-body: 'Roboto', serif;
--curve-factor-navbar: 10px;
/* Use secondary color for item description */
.tile-title p.description {
color: var(--secondary);
}
/* Add line to bottom of settings row */
section.settings-outer {
.options-container {
border-top: var(--accent-line-width, 1px) solid var(--secondary);
}
form.normal {
border-bottom: var(--accent-line-width, 1px) solid var(--secondary);
border-right: var(--accent-line-width, 1px) solid var(--secondary);
margin-top: 0.5rem;
}
}
/* Display line between sections (depending on orientation) */
.orientation-horizontal .collapsable {
border-radius: 1px;
&:not(:last-child) { border-bottom: var(--accent-line-width, 1px) solid var(--secondary); }
}
.orientation-vertical .collapsable {
border-radius: 1px;
&:not(:last-child) { border-right: var(--accent-line-width, 1px) solid var(--secondary); }
}
.orientation-auto .collapsable .collapsible-content {
border-top: var(--accent-line-width, 1px) solid var(--secondary);
}
}
html[data-theme='fallout'] {
--primary: #aabbc3;
--background: #263238;
--background-darker: #1f282c;
--secondary: #ade900cc;
}
html[data-theme='whimsy'] {
--primary: #aabbc3;
--background: #232138;
--background-darker: #161529;
--secondary: #ed597c;
--item-background-hover: #49476d;
--accent-line-width: 2px;
--curve-factor: 4px;
}
html[data-theme='deep-ocean'] {
--primary: #aabbc3;
--background: #151e2d;
--background-darker: #151c29;
--secondary: #4afcffb3;
--item-background-hover: #4afcff40;
--accent-line-width: 1px;
--curve-factor: 4px;
.home, .options-container {
background-color: #151e2d;
background-image: url("data:image/svg+xml,%3Csvg width='100' height='20' viewBox='0 0 100 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M21.184 20c.357-.13.72-.264 1.088-.402l1.768-.661C33.64 15.347 39.647 14 50 14c10.271 0 15.362 1.222 24.629 4.928.955.383 1.869.74 2.75 1.072h6.225c-2.51-.73-5.139-1.691-8.233-2.928C65.888 13.278 60.562 12 50 12c-10.626 0-16.855 1.397-26.66 5.063l-1.767.662c-2.475.923-4.66 1.674-6.724 2.275h6.335zm0-20C13.258 2.892 8.077 4 0 4V2c5.744 0 9.951-.574 14.85-2h6.334zM77.38 0C85.239 2.966 90.502 4 100 4V2c-6.842 0-11.386-.542-16.396-2h-6.225zM0 14c8.44 0 13.718-1.21 22.272-4.402l1.768-.661C33.64 5.347 39.647 4 50 4c10.271 0 15.362 1.222 24.629 4.928C84.112 12.722 89.438 14 100 14v-2c-10.271 0-15.362-1.222-24.629-4.928C65.888 3.278 60.562 2 50 2 39.374 2 33.145 3.397 23.34 7.063l-1.767.662C13.223 10.84 8.163 12 0 12v2z' fill='%231a3f57' fill-opacity='0.18' fill-rule='evenodd'/%3E%3C/svg%3E");
}
}
html[data-theme='argon'] {
--primary: #aabbc3;
--background: #15131f;
--background-darker: #0c0a11;
--nav-link-border-color-hover: transparent;
.collapsable, .nav a.nav-item {
&:nth-child(1n) { --index-color: #fd7293; }
&:nth-child(2n) { --index-color: #2af9ae; }
&:nth-child(3n) { --index-color: #fff874; }
&:nth-child(4n) { --index-color: #21c0fc; }
&:nth-child(5n) { --index-color: #dd98fb; }
&:nth-child(6n) { --index-color: #89ccfc; }
--secondary: var(--index-color);
--item-group-heading-text-color: var(--index-color);
// --item-text-color: var(--index-color);
--widget-text-color: var(--index-color);
--primary: var(--index-color);
--item-text-color-hover: var(--index-color);
--nav-link-text-color-hover: var(--index-color);
--nav-link-shadow-hover: inset 0 2px 1px var(--index-color), 1px 1px 2px #000000cc;
.item:hover {
background: var(--index-color);
color: var(--background);
p.description { color: var(--background); }}
}
}
html[data-theme='nord-frost'] {
--primary: #D8DEE9;
--background: #3B4252;
--background-darker: #2E3440;

View File

@ -12,7 +12,7 @@
text-decoration: underline;
}
}
div[data-fs-wrapper] {
div[data-fs-wrapper], div[data-fs-kind=object] {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
@ -81,7 +81,7 @@
box-shadow: 1px 1px 6px var(--interactive-editor-color);
}
}
div[data-fs-input=array] button {
div[data-fs-input=array] button, div[data-fs-buttons] button {
font-size: 1rem;
margin: 0.25rem;
border-radius: var(--curve-factor);

View File

@ -23,6 +23,10 @@ export default class ConfigAccumulator {
this.conf = $store.state.remoteConfig;
}
pages() {
return this.conf.pages;
}
/* App Config */
appConfig() {
let appConfigFile = {};
@ -88,6 +92,7 @@ export default class ConfigAccumulator {
appConfig: this.appConfig(),
pageInfo: this.pageInfo(),
sections: this.sections(),
pages: this.pages(),
};
}
}

View File

@ -10,6 +10,22 @@ import {
import ErrorHandler from '@/utils/ErrorHandler';
import ConfigSchema from '@/utils/ConfigSchema.json';
/* Given a page name, converts to lowercase, removes special characters and extension */
export const makePageName = (pageName) => {
if (!pageName) return 'unnamed-page';
return pageName
.toLowerCase()
.replaceAll(' ', '-')
.replace('.yml', '')
.replace(/[^\w\s-]/gi, '');
};
/* For a given sub-page, and page type, return the URL */
export const makePageSlug = (pageName, pageType) => {
const formattedName = makePageName(pageName);
return `/${pageType}/${formattedName}`;
};
/**
* Initiates the Accumulator class and generates a complete config object
* Self-executing function, returns the full user config as a JSON object

View File

@ -5,6 +5,29 @@
],
"additionalProperties": false,
"properties": {
"pages": {
"title": "Page List",
"type": "array",
"description": "List of additional config files to load as extra pages",
"items": {
"title": "Pages",
"type": "object",
"required": ["name", "path"],
"additionalProperties": false,
"properties": {
"name": {
"title": "Name",
"type": "string",
"description": "Unique page identifier"
},
"path": {
"title": "Path",
"type": "string",
"description": "The file name, or path. If in public directory, use just `file-name.yml`"
}
}
}
},
"pageInfo": {
"type": "object",
"properties": {

View File

@ -33,7 +33,7 @@ const setSwStatus = (swStateToSet) => {
* Or disable if user specified to disable
*/
const shouldEnableServiceWorker = async () => {
const conf = yaml.load((await axios.get('conf.yml')).data);
const conf = yaml.load((await axios.get('/conf.yml')).data);
if (conf && conf.appConfig && conf.appConfig.enableServiceWorker) {
setSwStatus({ disabledByUser: false });
return true;

View File

@ -3,7 +3,15 @@
import { hideFurnitureOn } from '@/utils/defaults';
/* Returns false if page furniture should be hidden on said route */
export const shouldBeVisible = (routeName) => !hideFurnitureOn.includes(routeName);
export const shouldBeVisible = (routeName) => {
let shouldShow = true;
if (!routeName) return shouldShow; // Route name not specified.
hideFurnitureOn.forEach((hideOn) => {
// If route name on the no-show list, set visibility to false
if (routeName.includes(hideOn)) shouldShow = false;
});
return shouldShow;
};
/* Based on section title, item name and index, return a string value for ID */
const makeItemId = (sectionStr, itemStr, index) => {

View File

@ -1,8 +1,10 @@
// A list of mutation names
const KEY_NAMES = [
'INITIALIZE_CONFIG',
'INITIALIZE_MULTI_PAGE_CONFIG',
'SET_CONFIG',
'SET_REMOTE_CONFIG',
'SET_CURRENT_SUB_PAGE',
'SET_MODAL_OPEN',
'SET_LANGUAGE',
'SET_EDIT_MODE',
@ -10,9 +12,11 @@ const KEY_NAMES = [
'SET_ITEM_SIZE',
'SET_THEME',
'SET_CUSTOM_COLORS',
'USE_MAIN_CONFIG',
'UPDATE_ITEM',
'SET_PAGE_INFO',
'SET_APP_CONFIG',
'SET_PAGES',
'SET_SECTIONS',
'UPDATE_SECTION',
'INSERT_SECTION',

View File

@ -58,8 +58,13 @@ module.exports = {
'cherry-blossom',
'nord-frost',
'nord',
'argon',
'fallout',
'whimsy',
'oblivion',
'adventure',
'crayola',
'deep-ocean',
'minimal-dark',
'minimal-light',
'thebe',
@ -76,6 +81,7 @@ module.exports = {
'material-dark-original',
'high-contrast-dark',
'high-contrast-light',
'basic',
],
/* Default color options for the theme configurator swatches */
swatches: [
@ -144,6 +150,7 @@ module.exports = {
EDIT_SECTION: 'EDIT_SECTION',
EDIT_PAGE_INFO: 'EDIT_PAGE_INFO',
EDIT_APP_CONFIG: 'EDIT_APP_CONFIG',
EDIT_MULTI_PAGES: 'EDIT_MULTI_PAGES',
EXPORT_CONFIG_MENU: 'EXPORT_CONFIG_MENU',
MOVE_ITEM_TO: 'MOVE_ITEM_TO',
},