🔀 Merge pull request #298 from Lissy93/FEATURE/interactive-editor

[FEATURE] Interactive Config Editor
It is merged, the new config editor is finally here!! 🎉
This commit is contained in:
Alicia Sykes 2021-10-30 14:07:23 +01:00 committed by GitHub
commit 1b0bf787a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 3660 additions and 573 deletions

View File

@ -1,5 +1,11 @@
# Changelog
## ✨ 1.8.9 - All New Interactive Config Editor [PR #298](https://github.com/Lissy93/dashy/pull/298)
- Builds a new UI-based config editor
- Support for sections, items, app config and page info
- Live preview, and undoing of local changes
- Export config or write changes to disk through UI
## ✨ 1.8.8 - Improved Item Targets [PR #292](https://github.com/Lissy93/dashy/pull/292)
- Adds support for `_top` and `_parent` anchor targets on items, Re: #289
- Adds `appConfig.defaultOpeningMethod` option to specify default target

View File

@ -71,14 +71,14 @@
- 🔎 Instant search by name, domain and tags + customizable hotkeys & keyboard shortcuts
- 🎨 Multiple built-in color themes, with UI color editor and support for custom CSS
- 🧸 Many options for icons, including Font-Awesome, homelab icons, auto-fetching favicon, images and emojis
- 🚦 Service status feature for each of your apps / links, for basic availability and uptime monitoring
- 🧸 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
- 💂 Optional authentication with multi-user access, configurable privileges and SSO support
- 🌎 Multi-language support, with more languages being added regularly
- ☁ Optional encrypted off-site cloud backup and restore feature available
- 💼 A workspace view, for easily switching between multiple apps at once
- 🌎 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 at simultaneously
- 🛩️ A minimal view, for use as a fast-loading browser startpage
- 🖱️ Choose how to launch apps, either new tab, same tab, a pop-up modal or in the workspace view
- 🖱️ Choose app launch method, either new tab, same tab, a pop-up modal or in the workspace view
- 📏 Customizable layout, sizes, text, component visibility, sort order, behavior etc
- 🖼️ Option for 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
@ -87,7 +87,7 @@
- 🤏 Small bundle size, fully responsive UI and PWA for basic offline access
- 🆓 100% free and open source
- 🔐 Strong focus on privacy
- 🌈 Plus lots more...
- 🌈 Plus loads more...
## Demo ⚡
@ -534,7 +534,7 @@ If you're using Dashy, and would like to help support it's development, then tha
Several areas that we need a bit of help with at the moment are:
- Translating - Help make Dashy available to non-native English speakers by [adding youre language](./docs/multi-language-support.md#adding-a-new-language)
- Donate a small amount, by [Sponsoring @Lissy93 on GitHub](https://github.com/sponsors/Lissy93) and receive some extra perks!
- Complete a [short survey](https://n9fy6xak9yd.typeform.com/to/gl0L68ou), to have your say about future features
- Complete a [short survey](https://survey.typeform.com/to/gl0L68ou), to have your say about future features
- Share your dashboard in the [Showcase](https://github.com/Lissy93/dashy/blob/master/docs/showcase.md#dashy-showcase-), to provide inspiration for others
- Join the [discussion](https://github.com/Lissy93/dashy/discussions), help answer other users questions, suggest features, share tips and ask questions
- Spread the word, by sharing Dashy or a screenshot of your dashboard, to help new users discover it

View File

@ -13,7 +13,7 @@ If you speak another language, then adding translations would be really helpful,
## Take a 2-minute survey
Help improve Dashy by taking a very short, 6-question survey. This will give me a better understanding of what is important to you, so that I can make Dashy better in the future :)
[![Take the Survey](https://img.shields.io/badge/Take_the-Survey-%231a86fd?style=for-the-badge&logo=buddy)](https://n9fy6xak9yd.typeform.com/to/gl0L68ou)
[![Take the Survey](https://img.shields.io/badge/Take_the-Survey-%231a86fd?style=for-the-badge&logo=buddy)](https://survey.typeform.com/to/gl0L68ou)
## Share your dashboard
Dashy now has a [Showcase](https://github.com/Lissy93/dashy/blob/master/docs/showcase.md#dashy-showcase-) where you can show off a screenshot of your dashboard, and get inspiration from other users. I also really enjoy seeing how people are using Dashy. To [submit your dashboard](https://github.com/Lissy93/dashy/blob/master/docs/showcase.md#submitting-your-dashboard), please either open a PR or raise an issue.

View File

@ -1,6 +1,6 @@
# Management
_The following article explains aspects of app management, and is useful to know for when self-hosting. It covers everything from keeping the app up-to-date, secure, backed up, to other topics like auto-starting, monitoring, log management, web server configuration and using custom environments. Most of it is aimed at running the Dashy (or any other app) in a container, but some of it also applies to bare metal setups too. It's like a top-15 list of need-to-know knowledge for self-hosting._
_The following article explains aspects of app management, and is useful to know for when self-hosting. It covers everything from keeping the Dashy (or any other app) up-to-date, secure, backed up, to other topics like auto-starting, monitoring, log management, web server configuration and using custom environments. It's like a top-20 list of need-to-know knowledge for self-hosting._
## Contents
- [Providing Assets](#providing-assets)
@ -16,8 +16,11 @@ _The following article explains aspects of app management, and is useful to know
- [Managing with Compose](#managing-containers-with-docker-compose)
- [Environmental Variables](#passing-in-environmental-variables)
- [Securing Containers](#container-security)
- [Remote Access](#remote-access)
- [Custom Domain](#custom-domain)
- [Web Server Configuration](#web-server-configuration)
- [Running a Modified Apps](#running-a-modified-version-of-the-app)
- [Running a Modified App](#running-a-modified-version-of-the-app)
- [Building your Own Container](#building-your-own-container)
---
@ -37,9 +40,9 @@ In Dashy, commonly configured resources include:
---
## Running Commands
The project has a few commands that can be used for various tasks, you can find a list of these either in the [Developing Docs](/docs/developing.md#project-commands), or by looking at the [`package.json`](https://github.com/Lissy93/dashy/blob/master/package.json#L5). These can be used by running `yarn [command-name]`.
If you're running an app in Docker, then commands will need to be passed to the container to be executed. This can be done by preceding each command with `docker exec -it [container-id]`, where container ID can be found by running `docker ps`. For example `docker exec -it 26c156c467b4 yarn build`. You can also enter the container, with `docker exec -it [container-id] /bin/ash`, and navigate around it with normal Linux commands.
If you're using Docker, then you'll need to execute them within the container. This can be done by preceding each command with `docker exec -it [container-id]`, where container ID can be found by running `docker ps`. For example `docker exec -it 26c156c467b4 yarn build`. You can also enter the container, with `docker exec -it [container-id] /bin/ash`, and navigate around it with normal Linux commands.
Dashy has several commands that can be used for various tasks, you can find a list of these either in the [Developing Docs](/docs/developing.md#project-commands), or by looking at the [`package.json`](https://github.com/Lissy93/dashy/blob/master/package.json#L5). These can be used by running `yarn [command-name]`.
**[⬆️ Back to Top](#management)**
@ -48,7 +51,7 @@ The project has a few commands that can be used for various tasks, you can find
Healthchecks are configured to periodically check that Dashy is up and running correctly on the specified port. By default, the health script is called every 5 minutes, but this can be modified with the `--health-interval` option. You can check the current container health with: `docker inspect --format "{{json .State.Health }}" [container-id]`, and a summary of health status will show up under `docker ps`. You can also manually request the current application status by running `docker exec -it [container-id] yarn health-check`. You can disable healthchecks altogether by adding the `--no-healthcheck` flag to your Docker run command.
To restart unhealthy containers automatically, check out [Autoheal](https://hub.docker.com/r/willfarrell/autoheal/). This image watches for unhealthy containers, and automatically triggers a restart. (This is a stand in for Docker's `--exit-on-unhealthy` that was proposed, but [not merged](https://github.com/moby/moby/pull/22719)).
To restart unhealthy containers automatically, check out [Autoheal](https://hub.docker.com/r/willfarrell/autoheal/). This image watches for unhealthy containers, and automatically triggers a restart. (This is a stand in for Docker's `--exit-on-unhealthy` that was proposed, but [not merged](https://github.com/moby/moby/pull/22719)). There's also [Deunhealth](https://github.com/qdm12/deunhealth), which is super light-weight, and doesn't require network access.
```
docker run -d \
@ -271,6 +274,22 @@ If you've got many environmental variables, you might find it useful to put them
## Container Security
- [Keep Docker Up-To-Date](#keep-docker-up-to-date)
- [Set Resource Quotas](#set-resource-quotas)
- [Don't Run as Root](#dont-run-as-root)
- [Specify a User](#specify-a-user)
- [Limit Capabilities](#limit-capabilities)
- [Prevent new Privilages being Added](#prevent-new-privilages-being-added)
- [Disable Inter-Container Communication](#disable-inter-container-communication)
- [Don't Expose the Docker Daemon Socket](#dont-expose-the-docker-daemon-socket)
- [Use Read-Only Volumes](#use-read-only-volumes)
- [Set the Logging Level](#set-the-logging-level)
- [Verify Image before Pulling](#verify-image-before-pulling)
- [Specify the Tag](#specify-the-tag)
- [Container Security Scanning](#container-security-scanning)
- [Registry Security](#registry-security)
- [Security Modules](#security-modules)
### Keep Docker Up-To-Date
To prevent known container escape vulnerabilities, which typically end in escalating to root/administrator privileges, patching Docker Engine and Docker Machine is crucial. For more info, see the [Docker Installation Docs](https://docs.docker.com/engine/install/).
@ -392,6 +411,174 @@ Docker supports several modules that let you write your own security profiles.
[Seccomp](https://en.wikipedia.org/wiki/Seccomp) (Secure Computing Mode) is a sandboxing facility in the Linux kernel that acts like a firewall for system calls (syscalls). It uses Berkeley Packet Filter (BPF) rules to filter syscalls and control how they are handled. These filters can significantly limit a containers access to the Docker Hosts Linux kernel - especially for simple containers/applications. It requires a Linux-based Docker host, with secomp enabled, and you can check for this by running `docker info | grep seccomp`. A great resource for learning more about this is [DockerLabs](https://training.play-with-docker.com/security-seccomp/).
**[⬆️ Back to Top](#management)**
---
## Remote Access
- [WireGuard](#wireguard)
- [Reverse SSH Tunnel](#reverse-ssh-tunnel)
### WireGuard
Using a VPN is one of the easiest ways to provide secure, full access to your local network from remote locations. [WireGuard](https://www.wireguard.com/) is a reasonably new open source VPN protocol, that was designed with ease of use, performance and security in mind. Unlike OpenVPN, it doesn't need to recreate the tunnel whenever connection is dropped, and it's also much easier to setup, using shared keys instead.
- **Install Wireguard** - See the [Install Docs](https://www.wireguard.com/install/) for download links + instructions
- On Debian-based systems, it's `sudo apt install wireguard`
- **Generate a Private Key** - Run `wg genkey` on the Wireguard server, and copy it to somewhere safe for later
- **Create Server Config** - Open or create a file at `/etc/wireguard/wg0.conf` and under `[Interface]` add the following (see example below):
- `Address` - as a subnet of all desired IPs
- `PrivateKey` - that you just generated
- `ListenPort` - Default is `51820`, but can be anything
- **Get Client App** - Download the [WG client app](https://www.wireguard.com/install/) for your platform (Linux, Windows, MacOS, Android or iOS are all supported)
- **Create new Client Tunnel** - On your client app, there should be an option to create a new tunnel, when doing so a client private key will be generated (but if not, use the `wg genkey` command again), and keep it somewhere safe. A public key will also be generated, and this will go in our saver config
- **Add Clients to Server Config** - Head back to your `wg0.conf` file on the server, create a `[Peer]` section, and populate the following info
- `AllowedIPs` - List of IP address inside the subnet, the client should have access to
- `PublicKey` - The public key for the client you just generated
- **Start the Server** - You can now start the WG server, using: `wg-quick up wg0` on your server
- **Finish Client Setup** - Head back to your client device, and edit the config file, leave the private key as is, and add the following fields:
- `PublicKey` - The public key of the server
- `Address` - This should match the `AllowedIPs` section on the servers config file
- `DNS` - The DNS server that'll be used when accessing the network through the VPN
- `Endpoint` - The hostname or IP + Port where your WG server is running (you may need to forward this in your firewall's settings)
- **Done** - Your clients should now be able to connect to your WG server :) Depending on your networks firewall rules, you may need to port forward the address of your WG server
**Example Server Config**
```ini
# Server file
[Interface]
# Which networks does my interface belong to? Notice: /24 and /64
Address = 10.5.0.1/24, 2001:470:xxxx:xxxx::1/64
PrivateKey = xxx
ListenPort = 51820
# Peer 1
[Peer]
PublicKey = xxx
# Which source IPs can I expect from that peer? Notice: /32 and /128
AllowedIps = 10.5.0.35/32, 2001:470:xxxx:xxxx::746f:786f/128
# Peer 2
[Peer]
PublicKey = xxx
# Which source IPs can I expect from that peer? This one has a LAN which can
# access hosts/jails without NAT.
# Peer 2 has a single IP address inside the VPN: it's 10.5.0.25/32
AllowedIps = 10.5.0.25/32,10.21.10.0/24,10.21.20.0/24,10.21.30.0/24,10.31.0.0/24,2001:470:xxxx:xxxx::ca:571e/128
```
**Example Client Config**
```ini
[Interface]
# Which networks does my interface belong to? Notice: /24 and /64
Address = 10.5.0.35/24, 2001:470:xxxx:xxxx::746f:786f/64
PrivateKey = xxx
# Server
[Peer]
PublicKey = xxx
# I want to route everything through the server, both IPv4 and IPv6. All IPs are
# thus available through the Server, and I can expect packets from any IP to
# come from that peer.
AllowedIPs = 0.0.0.0/0, ::0/0
# Where is the server on the internet? This is a public address. The port
# (:51820) is the same as ListenPort in the [Interface] of the Server file above
Endpoint = 1.2.3.4:51820
# Usually, clients are behind NAT. to keep the connection running, keep alive.
PersistentKeepalive = 15
```
A useful tool for getting WG setup is [Algo](https://github.com/trailofbits/algo). It includes scripts and docs which cover almost all devices, platforms and clients, and has best practices implemented, and security features enabled. All of this is better explained in [this blog post](https://blog.trailofbits.com/2016/12/12/meet-algo-the-vpn-that-works/).
### Reverse SSH Tunnel
SSH (or [Secure Shell](https://en.wikipedia.org/wiki/Secure_Shell)) is a secure tunnel that allows you to connect to a remote host. Unlike the VPN methods, an SSH connection does not require an intermediary, and will not be affected by your IP changing. However it only allows you to access a single service at a time. SSH was really designed for terminal access, but because of the latter mentioned benefits it's useful to setup, as a fallback option.
Directly SSH'ing into your home, would require you to open a port (usually 22), which would be terrible for security, and is not recommended. However a reverse SSH connection is initiated from inside your network. Once the connection is established, the port is redirected, allowing you to use the established connection to SSH into your home network.
The issue you've probably spotted, is that most public, corporate, and institutional networks will block SSH connections. To overcome this, you'd have to establish a server outside of your homelab that your homelab's device could SSH into to establish the reverse SSH connection. You can then connect to that remote server (the _mothership_), which in turn connects to your home network.
Now all of this is starting to sound like quite a lot of work, but this is where services like [remot3.it](https://remote.it/) come in. They maintain the intermediary mothership server, and create the tunnel service for you. It's free for personal use, secure and easy. There are several similar services, such as [RemoteIoT](https://remoteiot.com/), or you could create your own on a cloud VPS (see [this tutorial](https://gist.github.com/nileshtrivedi/4c615e8d3c1bf053b0d31176b9e69e42) for more info on that).
Before getting started, you'll need to head over to [Remote.it](https://app.remote.it/auth/#/sign-up) and create an account.
Then setup your local device:
1. If you haven't already done so, you'll need to enable and configure SSH.
- This is out-of-scope of this article, but I've explained it in detail in [this post](https://notes.aliciasykes.com/22798/my-server-setup#configure-ssh).
2. Download the Remote.it install script from their [GitHub](https://github.com/remoteit/installer)
- `curl -LkO https://raw.githubusercontent.com/remoteit/installer/master/scripts/auto-install.sh`
3. Make it executable, with `chmod +x ./auto-install.sh`, and then run it with `sudo ./auto-install.sh`
4. Finally, configure your device, by running `sudo connectd_installer` and following the on-screen instructions
And when you're ready to connect to it:
1. Login to [app.remote.it](https://app.remote.it/), and select the name of your device
2. You should see a list of running services, click SSH
3. You'll then be presented with some SSH credentials that you can now use to securely connect to your home, via the Remote.it servers
Done :)
**[⬆️ Back to Top](#management)**
---
## Custom Domain
- [Using DNS](#using-nginx)
- [Using NGINX](#using-dns)
### Using DNS
For locally running services, a domain can be set up directly in the DNS records. This method is really quick and easy, and doesn't require you to purchase an actual domain. Just update your networks DNS resolver, to point your desired URL to the local IP where Dashy (or any other app) is running. For example, a line in your hosts file might look something like: `192.168.0.2 dashy.homelab.local`.
If you're using Pi-Hole, a similar thing can be done in the `/etc/dnsmasq.d/03-custom-dns.conf` file, add a line like: `address=/dashy.example.com/192.168.2.0` for each of your services.
If you're running OPNSense/ PfSense, then this can be done through the UI with Unbound, it's explained nicely in [this article](https://homenetworkguy.com/how-to/use-custom-domain-name-in-internal-network/), by Dustin Casto.
### Using NGINX
If you're using NGINX, then you can use your own domain name, with a config similar to the below example.
```
upstream dashy {
server 127.0.0.1:32400;
}
server {
listen 80;
server_name dashy.mydomain.com;
# Setup SSL
ssl_certificate /var/www/mydomain/sslcert.pem;
ssl_certificate_key /var/www/mydomain/sslkey.pem;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers 'EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH';
ssl_session_timeout 5m;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://dashy;
proxy_redirect off;
proxy_buffering off;
proxy_set_header host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
}
}
```
Similarly, a basic `Caddyfile` might look like:
```
dashy.example.com {
reverse_proxy / nginx:80
}
```
For more info, [this guide](https://thehomelab.wiki/books/dns-reverse-proxy/page/create-domain-records-to-point-to-your-home-server-on-cloudflare-using-nginx-progy-manager) on Setting up Domains with NGINX Proxy Manager and CloudFlare may be useful.
**[⬆️ Back to Top](#management)**
---
@ -411,6 +598,11 @@ Note, that if you choose not to use `server.js` to serve up the app, you will lo
- Writing config file to disk from the UI
- Website status indicators, and ping checks
Example Configs
- [NGINX](#nginx)
- [Apache](#apache)
- [cPanel](#cpanel)
### NGINX
Create a new file in `/etc/nginx/sites-enabled/dashy`
@ -475,9 +667,17 @@ Then restart Apache, with `sudo systemctl restart apache2`
If you'd like to make any code changes to the app, and deploy your modified version, this section briefly explains how.
The first step is to fork the project on GitHub, and clone it to your local system. Next, install the dependencies (`yarn`), and start the development server (`yarn dev`) and visit `localhost:8080` in your browser. You can then make changes to the codebase, and see the live app update in real-time. Once you've finished, running `yarn build` will build the app for production, and output the assets into `./dist` which can then be deployed using a web server, CDN or the built-in Node server with `yarn start`. For more info on all of this, take a look at the [Developing Docs](/docs/developing.md).
The first step is to fork the project on GitHub, and clone it to your local system. Next, install the dependencies (`yarn`), and start the development server (`yarn dev`) and visit `localhost:8080` in your browser. You can then make changes to the codebase, and see the live app update in real-time. Once you've finished, running `yarn build` will build the app for production, and output the assets into `./dist` which can then be deployed using a web server, CDN or the built-in Node server with `yarn start`. For more info on all of this, take a look at the [Developing Docs](/docs/developing.md). To build your own Docker container from the modified app, see [Building your Own Container](#building-your-own-container)
You probably want to deploy your app with Docker, and this can be done as follows:
**[⬆️ Back to Top](#management)**
---
## Building your Own Container
Similar to above, you'll first need to fork and clone Dashy to your local system, and then install dependencies.
Then, either use Dashy's default [`Dockerfile`](https://github.com/Lissy93/dashy/blob/master/Dockerfile) as is, or modify it according to your needs.
To build and deploy locally, first build the app with: `docker build -t dashy .`, and then start the app with `docker run -p 8080:80 --name my-dashboard dashy`. Or modify the `docker-compose.yml` file, replacing `image: lissy93/dashy` with `build: .` and run `docker compose up`.
@ -487,3 +687,5 @@ You may wish to upload your image to a container registry for easier access. Not
You can push your build image, by running: `docker push ghcr.io/OWNER/IMAGE_NAME:latest`. You will first need to authenticate, this can be done by running `echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin`, where `CR_PAT` is an environmental variable containing a token generated from your GitHub account. For more info, see the [Container Registry Docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry).
**[⬆️ Back to Top](#management)**
---

View File

@ -127,3 +127,15 @@ Don't have a server? No problem! You can run Dashy for free on Netlify (as well
2. [Log in](app.netlify.com/login/) to Netlify with GitHub
3. Click "New site from Git" and select your forked repo, then click **Deploy**!
4. You can then edit the config in `./public/conf.yml` in your repo, and Netlify will rebuild the app
---
## Alternative Deployment Method 3 - Cloud Services
Dashy supports 1-Click deployments on several popular cloud platforms. To spin up a new instance, just click a link below:
- [<img src="https://i.ibb.co/ZxtzrP3/netlify.png" width="18"/> Deploy to Netlify](https://app.netlify.com/start/deploy?repository=https://github.com/lissy93/dashy)
- [<img src="https://i.ibb.co/d2P1WZ7/heroku.png" width="18"/> Deploy to Heroku](https://heroku.com/deploy?template=https://github.com/Lissy93/dashy)
- [<img src="https://i.ibb.co/Ld2FZzb/vercel.png" width="18"/> Deploy to Vercel](https://vercel.com/new/project?template=https://github.com/lissy93/dashy)
- [<img src="https://i.ibb.co/xCHtzgh/render.png" width="18"/> Deploy to Render](https://render.com/deploy?repo=https://github.com/lissy93/dashy/tree/deploy_render)
- [<img src="https://i.ibb.co/J7MGymY/googlecloud.png" width="18"/> Deploy to GCP](https://deploy.cloud.run/?git_repo=https://github.com/lissy93/dashy.git)
- [<img src="https://i.ibb.co/HVWVYF7/docker.png" width="18"/> Deploy to PWD](https://labs.play-with-docker.com/?stack=https://raw.githubusercontent.com/Lissy93/dashy/master/docker-compose.yml)

View File

@ -78,6 +78,24 @@ auth:
---
## Config Not Updating
Dashy has the option to save settings and config locally, in browser storage. Anything here will take precedence over whatever is in your config file, sometimes with unintended consequences. If you've updated the config file manually, and are not seeing changes reflected in the UI, then try visiting the site in Incognito mode. If that works, then the solution is just to clear local storage. This can be done from the config menu, under "Clear Local Settings".
---
## Config Still not Updating
Sometimes your text editor updates files [inode](https://linuxhandbook.com/inode-linux/), meaning changes will not be picked up by the Docker container. This [article](https://medium.com/@jonsbun/why-need-to-be-careful-when-mounting-single-files-into-a-docker-container-4f929340834) explains things further.
---
## Styles and Assets not Updating
If you find that your styles and other visual assets work when visiting `ip:port` by not `dashy.domain.com`, then this is usually caused by caching. In your browser, do a hard-refresh (<kbd>Ctrl</kbd> + <kbd>F5</kbd>). If you use Cloudflare, then you can clear the cache through the management console, or set the cache level to Bypass for certain files, under the Rules tab.
---
## DockerHub `toomanyrequests`
This situation relates to error messages similar to one of the following, returned when pulling, updating or running the Docker container from Docker Hub.

View File

@ -16,6 +16,12 @@
STATUSKIT_SUPPORT_CONTACT_LINK = "https://github.com/lissy93/dashy"
STATUSKIT_RESOURCES_LINK = "https://dashy.to/docs"
# For router history mode, ensure pages land on index
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# Redirect the Node endpoints to serverless functions
[[redirects]]
from = "/status-check"

View File

@ -1,6 +1,6 @@
{
"name": "Dashy",
"version": "1.8.8",
"version": "1.8.9",
"license": "MIT",
"main": "server",
"scripts": {
@ -16,6 +16,7 @@
"dependency-audit": "npx improved-yarn-audit --ignore-dev-deps"
},
"dependencies": {
"@formschema/native": "^2.0.0-beta.5",
"@sentry/tracing": "^6.13.1",
"@sentry/vue": "^6.13.1",
"ajv": "^8.6.3",
@ -36,6 +37,7 @@
"vue": "^2.6.10",
"vue-i18n": "^8.25.1",
"vue-js-modal": "^2.0.0-rc.6",
"vue-json-tree-view": "^2.1.6",
"vue-material-tabs": "0.1.5",
"vue-router": "^3.0.3",
"vue-select": "^3.13.0",

View File

@ -19,6 +19,9 @@ const ajv = new Ajv(validatorOptions);
/* Message printed when validation was successful */
const successMsg = () => '\x1b[1m\x1b[32mNo issues found, your configuration is valid :)\x1b[0m\n';
/* Just a wrapper to system's console.log */
const logToConsole = (msg) => { console.log(msg); };
/* Formats error message. ready for printing to the console */
const errorMsg = (output) => {
const warningFont = '\x1b[103m\x1b[34m';
@ -46,14 +49,14 @@ const setIsValidVariable = (isValid) => {
/* Start the validation */
const validate = (config) => {
console.log('\nChecking config file against schema...');
logToConsole('\nChecking config file against schema...');
const valid = ajv.validate(schema, config);
if (valid) {
setIsValidVariable(true);
console.log(successMsg());
logToConsole(successMsg());
} else {
setIsValidVariable(false);
console.log(errorMsg(ajv.errors));
logToConsole(errorMsg(ajv.errors));
}
};
@ -62,11 +65,11 @@ try {
validate(config);
} catch (e) { // Something went very wrong...
setIsValidVariable(false);
console.log(bigError());
console.log('Please ensure that your config file is present, '
logToConsole(bigError());
logToConsole('Please ensure that your config file is present, '
+ 'has the correct access rights and is parsable. '
+ 'If this warning persists, it may be an issue with the '
+ 'validator function. Please raise an issue, and include the following stack trace:\n');
console.warn('\x1b[33mStack Trace for config-validator.js:\x1b[0m\n', e);
console.log('\n\n');
logToConsole('\n\n');
}

View File

@ -4,6 +4,10 @@ const currentVersion = require('../package.json').version;
const packageUrl = 'https://raw.githubusercontent.com/Lissy93/dashy/master/package.json';
const logToConsole = (msg) => {
console.log(msg); // eslint-disable-line no-console
};
const makeMsg = (latestVersion) => {
const parse = (version) => parseInt(version.replace(/\./g, ''), 10);
const difference = parse(latestVersion) - parse(currentVersion);
@ -20,9 +24,9 @@ const makeMsg = (latestVersion) => {
axios.get(packageUrl).then((response) => {
if (response && response.data && response.data.version) {
console.log(`\nUsing Dashy V-${currentVersion}. Update Check Complete`);
console.log(makeMsg(response.data.version));
logToConsole(`\nUsing Dashy V-${currentVersion}. Update Check Complete`);
logToConsole(makeMsg(response.data.version));
}
}).catch(() => {
console.log('Unable to check for updates');
logToConsole('Unable to check for updates');
});

View File

@ -1,5 +1,6 @@
<template>
<div id="dashy">
<EditModeTopBanner v-if="isEditMode" />
<LoadingScreen :isLoading="isLoading" v-if="shouldShowSplash" />
<Header :pageInfo="pageInfo" />
<router-view />
@ -10,6 +11,7 @@
import Header from '@/components/PageStrcture/Header.vue';
import Footer from '@/components/PageStrcture/Footer.vue';
import EditModeTopBanner from '@/components/InteractiveEditor/EditModeTopBanner.vue';
import LoadingScreen from '@/components/PageStrcture/LoadingScreen.vue';
import { welcomeMsg } from '@/utils/CoolConsole';
import ErrorHandler from '@/utils/ErrorHandler';
@ -27,6 +29,7 @@ export default {
Header,
Footer,
LoadingScreen,
EditModeTopBanner,
},
data() {
return {
@ -57,9 +60,12 @@ export default {
visibleComponents() {
return this.$store.getters.visibleComponents;
},
isEditMode() {
return this.$store.state.editMode;
},
},
created() {
this.$store.dispatch('initializeConfig');
this.$store.dispatch(Keys.INITIALIZE_CONFIG);
},
methods: {
/* Injects the users custom CSS as a style tag */

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="plus" class="svg-inline--fa fa-plus fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"></path></svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="cogs" class="svg-inline--fa fa-cogs fa-w-20" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M217.1 478.1c-23.8 0-41.6-3.5-57.5-7.5-10.6-2.7-18.1-12.3-18.1-23.3v-31.7c-9.4-4.4-18.4-9.6-26.9-15.6l-26.7 15.4c-9.6 5.6-21.9 3.8-29.5-4.3-35.4-37.6-44.2-58.6-57.2-98.5-3.6-10.9 1.1-22.7 11-28.4l26.8-15c-.9-10.3-.9-20.7 0-31.1L12.2 223c-10-5.6-14.6-17.5-11-28.4 13.1-40 21.9-60.9 57.2-98.5 7.6-8.1 19.8-9.9 29.5-4.3l26.7 15.4c8.5-6 17.5-11.2 26.9-15.6V61.4c0-11.1 7.6-20.8 18.4-23.3 44.2-10.5 70-10.5 114.3 0 10.8 2.6 18.4 12.2 18.4 23.3v30.4c9.4 4.4 18.4 9.6 26.9 15.6L346.2 92c9.7-5.6 21.9-3.7 29.6 4.4 26.1 27.9 48.4 58.5 56.8 100.3 2 9.8-2.4 19.8-10.9 25.1l-26.6 16.5c.9 10.3.9 20.7 0 31.1l26.6 16.5c8.4 5.2 12.9 15.2 10.9 24.9-8.1 40.5-29.6 71.3-56.9 100.6-7.6 8.1-19.8 9.9-29.5 4.3l-26.7-15.4c-8.5 6-17.5 11.2-26.9 15.6v31.7c0 11-7.4 20.6-18.1 23.3-15.8 3.8-33.6 7.2-57.4 7.2zm-27.6-50.7c18.3 2.9 36.9 2.9 55.1 0v-44.8l16-5.7c15.2-5.4 29.1-13.4 41.3-23.9l12.9-11 38.8 22.4c11.7-14.4 21-30.5 27.6-47.7l-38.8-22.4 3.1-16.7c2.9-15.9 2.9-32 0-47.9l-3.1-16.7 38.8-22.4c-6.6-17.2-15.9-33.3-27.6-47.7l-38.8 22.4-12.9-11c-12.3-10.5-26.2-18.6-41.3-23.9l-16-5.7V80c-18.3-2.9-36.9-2.9-55.1 0v44.8l-16 5.7c-15.2 5.4-29.1 13.4-41.3 23.9l-12.9 11L80.5 143c-11.7 14.4-21 30.5-27.6 47.7l38.8 22.4-3.1 16.7c-2.9 15.9-2.9 32 0 47.9l3.1 16.7-38.8 22.4c6.6 17.2 15.9 33.4 27.6 47.7l38.8-22.4 12.9 11c12.3 10.5 26.2 18.6 41.3 23.9l16 5.7v44.7zm27.1-85.1c-22.6 0-45.2-8.6-62.4-25.8-34.4-34.4-34.4-90.4 0-124.8 34.4-34.4 90.4-34.4 124.8 0 34.4 34.4 34.4 90.4 0 124.8-17.3 17.2-39.9 25.8-62.4 25.8zm0-128.4c-10.3 0-20.6 3.9-28.5 11.8-15.7 15.7-15.7 41.2 0 56.9 15.7 15.7 41.2 15.7 56.9 0 15.7-15.7 15.7-41.2 0-56.9-7.8-7.9-18.1-11.8-28.4-11.8zM638.5 85c-1-5.8-6-10-11.9-10h-16.1c-3.5-9.9-8.8-19-15.5-26.8l8-13.9c2.9-5.1 1.8-11.6-2.7-15.3C591 11.3 580.5 5.1 569 .8c-5.5-2.1-11.8.1-14.7 5.3l-8 13.9c-10.2-1.9-20.7-1.9-30.9 0l-8-13.9c-3-5.1-9.2-7.3-14.7-5.3-11.5 4.3-22.1 10.5-31.4 18.2-4.5 3.7-5.7 10.2-2.7 15.3l8 13.9c-6.7 7.8-12 16.9-15.5 26.8H435c-5.9 0-11 4.3-11.9 10.2-2 12.2-1.9 24.5 0 36.2 1 5.8 6 10 11.9 10h16.1c3.5 9.9 8.8 19 15.5 26.8l-8 13.9c-2.9 5.1-1.8 11.6 2.7 15.3 9.3 7.7 19.9 13.9 31.4 18.2 5.5 2.1 11.8-.1 14.7-5.3l8-13.9c10.2 1.9 20.7 1.9 30.9 0l8 13.9c3 5.1 9.2 7.3 14.7 5.3 11.5-4.3 22.1-10.5 31.4-18.2 4.5-3.7 5.7-10.2 2.7-15.3l-8-13.9c6.7-7.8 12-16.9 15.5-26.8h16.1c5.9 0 11-4.3 11.9-10.2 1.9-12.2 1.9-24.4-.1-36.2zm-107.8 50.2c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm107.8 255.4c-1-5.8-6-10-11.9-10h-16.1c-3.5-9.9-8.8-19-15.5-26.8l8-13.9c2.9-5.1 1.8-11.6-2.7-15.3-9.3-7.7-19.9-13.9-31.4-18.2-5.5-2.1-11.8.1-14.7 5.3l-8 13.9c-10.2-1.9-20.7-1.9-30.9 0l-8-13.9c-3-5.1-9.2-7.3-14.7-5.3-11.5 4.3-22.1 10.5-31.4 18.2-4.5 3.7-5.7 10.2-2.7 15.3l8 13.9c-6.7 7.8-12 16.9-15.5 26.8h-16.1c-5.9 0-11 4.3-11.9 10.2-2 12.2-1.9 24.5 0 36.2 1 5.8 6 10 11.9 10H451c3.5 9.9 8.8 19 15.5 26.8l-8 13.9c-2.9 5.1-1.8 11.6 2.7 15.3 9.3 7.7 19.9 13.9 31.4 18.2 5.5 2.1 11.8-.1 14.7-5.3l8-13.9c10.2 1.9 20.7 1.9 30.9 0l8 13.9c3 5.1 9.2 7.3 14.7 5.3 11.5-4.3 22.1-10.5 31.4-18.2 4.5-3.7 5.7-10.2 2.7-15.3l-8-13.9c6.7-7.8 12-16.9 15.5-26.8h16.1c5.9 0 11-4.3 11.9-10.2 2-12.1 2-24.4 0-36.2zm-107.8 50.2c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"></path></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="ban" class="svg-inline--fa fa-ban fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm141.421 106.579c73.176 73.175 77.05 187.301 15.964 264.865L132.556 98.615c77.588-61.105 191.709-57.193 264.865 15.964zM114.579 397.421c-73.176-73.175-77.05-187.301-15.964-264.865l280.829 280.829c-77.588 61.105-191.709 57.193-264.865-15.964z"></path></svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="copy" class="svg-inline--fa fa-copy fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M433.941 65.941l-51.882-51.882A48 48 0 0 0 348.118 0H176c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v320c0 26.51 21.49 48 48 48h224c26.51 0 48-21.49 48-48v-48h80c26.51 0 48-21.49 48-48V99.882a48 48 0 0 0-14.059-33.941zM266 464H54a6 6 0 0 1-6-6V150a6 6 0 0 1 6-6h74v224c0 26.51 21.49 48 48 48h96v42a6 6 0 0 1-6 6zm128-96H182a6 6 0 0 1-6-6V54a6 6 0 0 1 6-6h106v88c0 13.255 10.745 24 24 24h88v202a6 6 0 0 1-6 6zm6-256h-64V48h9.632c1.591 0 3.117.632 4.243 1.757l48.368 48.368a6 6 0 0 1 1.757 4.243V112z"></path></svg>

After

Width:  |  Height:  |  Size: 736 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fal" data-icon="pencil-alt" class="svg-inline--fa fa-pencil-alt fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M493.255 56.236l-37.49-37.49c-24.993-24.993-65.515-24.994-90.51 0L12.838 371.162.151 485.346c-1.698 15.286 11.22 28.203 26.504 26.504l114.184-12.687 352.417-352.417c24.992-24.994 24.992-65.517-.001-90.51zM164.686 347.313c6.249 6.249 16.379 6.248 22.627 0L368 166.627l30.059 30.059L174 420.745V386h-48v-48H91.255l224.059-224.059L345.373 144 164.686 324.687c-6.249 6.248-6.249 16.378 0 22.626zm-38.539 121.285l-58.995 6.555-30.305-30.305 6.555-58.995L63.255 366H98v48h48v34.745l-19.853 19.853zm344.48-344.48l-49.941 49.941-82.745-82.745 49.941-49.941c12.505-12.505 32.748-12.507 45.255 0l37.49 37.49c12.506 12.506 12.507 32.747 0 45.255z"></path></svg>

After

Width:  |  Height:  |  Size: 875 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="file-download" class="svg-inline--fa fa-file-download fa-w-12" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M224 136V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V160H248c-13.2 0-24-10.8-24-24zm76.45 211.36l-96.42 95.7c-6.65 6.61-17.39 6.61-24.04 0l-96.42-95.7C73.42 337.29 80.54 320 94.82 320H160v-80c0-8.84 7.16-16 16-16h32c8.84 0 16 7.16 16 16v80h65.18c14.28 0 21.4 17.29 11.27 27.36zM377 105L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128v-6.1c0-6.3-2.5-12.4-7-16.9z"></path></svg>

After

Width:  |  Height:  |  Size: 630 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="exchange" class="svg-inline--fa fa-exchange fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M0 168v-16c0-13.255 10.745-24 24-24h381.97l-30.467-27.728c-9.815-9.289-10.03-24.846-.474-34.402l10.84-10.84c9.373-9.373 24.568-9.373 33.941 0l82.817 82.343c12.497 12.497 12.497 32.758 0 45.255l-82.817 82.343c-9.373 9.373-24.569 9.373-33.941 0l-10.84-10.84c-9.556-9.556-9.341-25.114.474-34.402L405.97 192H24c-13.255 0-24-10.745-24-24zm488 152H106.03l30.467-27.728c9.815-9.289 10.03-24.846.474-34.402l-10.84-10.84c-9.373-9.373-24.568-9.373-33.941 0L9.373 329.373c-12.497 12.497-12.497 32.758 0 45.255l82.817 82.343c9.373 9.373 24.569 9.373 33.941 0l10.84-10.84c9.556-9.556 9.341-25.113-.474-34.402L106.03 384H488c13.255 0 24-10.745 24-24v-16c0-13.255-10.745-24-24-24z"></path></svg>

After

Width:  |  Height:  |  Size: 901 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="quote-right" class="svg-inline--fa fa-quote-right fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M200 32H72C32.3 32 0 64.3 0 104v112c0 39.7 32.3 72 72 72h56v8c0 22.1-17.9 40-40 40h-8c-26.5 0-48 21.5-48 48v48c0 26.5 21.5 48 48 48h8c101.5 0 184-82.5 184-184V104c0-39.7-32.3-72-72-72zm24 264c0 75-61 136-136 136h-8v-48h8c48.5 0 88-39.5 88-88v-56H72c-13.2 0-24-10.8-24-24V104c0-13.2 10.8-24 24-24h128c13.2 0 24 10.8 24 24v192zM504 32H376c-39.7 0-72 32.3-72 72v112c0 39.7 32.3 72 72 72h56v8c0 22.1-17.9 40-40 40h-8c-26.5 0-48 21.5-48 48v48c0 26.5 21.5 48 48 48h8c101.5 0 184-82.5 184-184V104c0-39.7-32.3-72-72-72zm24 264c0 75-61 136-136 136h-8v-48h8c48.5 0 88-39.5 88-88v-56H376c-13.2 0-24-10.8-24-24V104c0-13.2 10.8-24 24-24h128c13.2 0 24 10.8 24 24v192z"></path></svg>

After

Width:  |  Height:  |  Size: 895 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="trash-alt" class="svg-inline--fa fa-trash-alt fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M268 416h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12zM432 80h-82.41l-34-56.7A48 48 0 0 0 274.41 0H173.59a48 48 0 0 0-41.16 23.3L98.41 80H16A16 16 0 0 0 0 96v16a16 16 0 0 0 16 16h16v336a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128h16a16 16 0 0 0 16-16V96a16 16 0 0 0-16-16zM171.84 50.91A6 6 0 0 1 177 48h94a6 6 0 0 1 5.15 2.91L293.61 80H154.39zM368 464H80V128h288zm-212-48h24a12 12 0 0 0 12-12V188a12 12 0 0 0-12-12h-24a12 12 0 0 0-12 12v216a12 12 0 0 0 12 12z"></path></svg>

After

Width:  |  Height:  |  Size: 739 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="save" class="svg-inline--fa fa-save fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M433.941 129.941l-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941zM272 80v80H144V80h128zm122 352H54a6 6 0 0 1-6-6V86a6 6 0 0 1 6-6h42v104c0 13.255 10.745 24 24 24h176c13.255 0 24-10.745 24-24V83.882l78.243 78.243a6 6 0 0 1 1.757 4.243V426a6 6 0 0 1-6 6zM224 232c-48.523 0-88 39.477-88 88s39.477 88 88 88 88-39.477 88-88-39.477-88-88-88zm0 128c-22.056 0-40-17.944-40-40s17.944-40 40-40 40 17.944 40 40-17.944 40-40 40z"></path></svg>

After

Width:  |  Height:  |  Size: 747 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="memory" class="svg-inline--fa fa-memory fa-w-20" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M480 160h-64v128h64V160zm-128 0h-64v128h64V160zm-128 0h-64v128h64V160zm408 0h8V96c0-17.67-14.33-32-32-32H32C14.33 64 0 78.33 0 96v64h8c13.26 0 24 10.74 24 24 0 13.25-10.74 24-24 24H0v240h640V208h-8c-13.25 0-24-10.75-24-24 0-13.26 10.75-24 24-24zm-40 240h-64c0-8.84-7.16-16-16-16s-16 7.16-16 16h-96c0-8.84-7.16-16-16-16s-16 7.16-16 16h-96c0-8.84-7.16-16-16-16s-16 7.16-16 16h-96c0-8.84-7.16-16-16-16s-16 7.16-16 16H48v-48h544v48zm0-275.84c-19.29 12.93-32 34.93-32 59.84s12.71 46.91 32 59.84V320H48v-76.16c19.29-12.93 32-34.93 32-59.84s-12.71-46.91-32-59.84V112h544v12.16z"></path></svg>

After

Width:  |  Height:  |  Size: 802 B

View File

@ -37,7 +37,7 @@
"edit-config-tab": "Edit Config",
"custom-css-tab": "Custom Styles",
"heading": "Configuration Options",
"download-config-button": "Download Config",
"download-config-button": "View / Export Config",
"edit-config-button": "Edit Config",
"edit-css-button": "Edit Custom CSS",
"cloud-sync-button": "Enable Cloud Sync",
@ -112,6 +112,7 @@
"location-local-label": "Apply Locally",
"location-disk-label": "Write Changes to Config File",
"save-button": "Save Changes",
"preview-button": "Preview Changes",
"valid-label": "Config is Valid",
"status-success-msg": "Task Complete",
"status-fail-msg": "Task Failed",
@ -164,9 +165,78 @@
"restore-success-msg": "Config Restored Successfully"
},
"menu": {
"sametab": "Open in Current Tab",
"newtab": "Open in New Tab",
"modal": "Open in Pop-Up Modal",
"workspace": "Open in Workspace View"
"open-section-title": "Open In",
"sametab": "Current Tab",
"newtab": "New Tab",
"modal": "Pop-Up Modal",
"workspace": "Workspace View",
"options-section-title": "Options",
"edit-item": "Edit",
"move-item": "Copy or Move",
"remove-item": "Remove"
},
"context-menus": {
"item": {
"open-section-title": "Open In",
"sametab": "Current Tab",
"newtab": "New Tab",
"modal": "Pop-Up Modal",
"workspace": "Workspace View",
"options-section-title": "Options",
"edit-item": "Edit",
"move-item": "Copy or Move",
"remove-item": "Remove"
},
"section": {
"open-section": "Open Section",
"edit-section": "Edit",
"move-section": "Move To",
"remove-section": "Remove"
}
},
"interactive-editor": {
"menu": {
"start-editing-tooltip": "Enter the Interactive Editor",
"edit-site-data-subheading": "Edit Site Data",
"edit-page-info-btn": "Edit Page Info",
"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",
"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",
"save-disk-btn": "Save to Disk",
"save-disk-tooltip": "Save config to the conf.yml file on disk. This will backup, and then over-write your existing config",
"export-config-btn": "Export Config",
"export-config-tooltip": "View and export new config, either to a file, or to clipboard",
"cancel-changes-btn": "Cancel Edit",
"cancel-changes-tooltip": "Reset current modifications, and exit Edit Mode. This will not affect your saved config",
"edit-mode-name": "Edit Mode",
"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"
},
"edit-section": {
"edit-section-title": "Edit Section",
"add-section-title": "Add New Section",
"edit-tooltip": "Click to Edit, or right-click for more options",
"remove-confirm": "Are you sure you want to remove this section? This action can be undone later."
},
"edit-app-config": {
"warning-msg-title": "Proceed with Caution",
"warning-msg-l1": "The following options are for advanced app configuration.",
"warning-msg-l2": "If you are unsure about any of the fields, please reference the",
"warning-msg-docs": "documentation",
"warning-msg-l3": "to avoid unintended consequences."
},
"export": {
"export-title": "Export Config",
"copy-clipboard-btn": "Copy to Clipboard",
"copy-clipboard-tooltip": "Copy all app config to system clipboard, in YAML format",
"download-file-btn": "Download as File",
"download-file-tooltip": "Download all app config to your device, in a YAML file",
"view-title": "View Config"
}
}
}

View File

@ -164,9 +164,77 @@
"restore-success-msg": "Configuration restaurée avec succès"
},
"menu": {
"open-section-title": "Ouvrir ...",
"sametab": "Ouvrir dans l'onglet actuel",
"newtab": "Ouvrir dans un nouvel onglet",
"modal": "Ouvrir en mode fenêtré",
"workspace": "Ouvrir en plein écran"
"workspace": "Ouvrir en plein écran",
"options-section-title": "Options",
"edit-item": "Modifier",
"move-item": "Copier et Déplacer",
"remove-item": "Supprimer"
},
"context-menus": {
"item": {
"open-section-title": "Ouvrir ...",
"sametab": "Ouvrir dans l'onglet actuel",
"newtab": "Ouvrir dans un nouvel onglet",
"modal": "Ouvrir en mode fenêtré",
"workspace": "Ouvrir en plein écran",
"options-section-title": "Options",
"edit-item": "Modifier",
"move-item": "Copier et Déplacer",
"remove-item": "Supprimer"
},
"section": {
"open-section": "Ouvrir",
"edit-section": "Modifier",
"move-section": "Déplacer vers",
"remove-section": "Supprimer"
}
},
"interactive-editor": {
"menu": {
"start-editing-tooltip": "Entrer dans l'éditeur interactif",
"edit-site-data-subheading": "Modifier l'application",
"edit-page-info-btn": "Modifier les informations",
"edit-page-info-tooltip": "Titre de l'application, description, liens de navigation, texte de pied de page, etc.",
"edit-app-config-btn": "Modifier la configuration",
"edit-app-config-tooltip": "Toutes les autres options de configuration",
"config-save-methods-subheading": "Options de sauvegarde",
"save-locally-btn": "Enregistrer localement",
"save-locally-tooltip": "Enregistrez la configuration localement, dans le stockage du navigateur. Cela n'affectera pas votre fichier de configuration, mais les modifications ne seront présentes que sur cet appareil",
"save-disk-btn": "Enregistrer sur le disque",
"save-disk-tooltip": "Enregistrez la configuration dans le fichier conf.yml sur le disque. Cela sauvegardera, puis écrasera votre configuration existante",
"export-config-btn": "Exporter la configuration",
"export-config-tooltip": "Afficher et exporter la nouvelle configuration, soit dans un fichier, soit dans le presse-papier",
"cancel-changes-btn": "Annuler",
"cancel-changes-tooltip": "Réinitialisez les modifications en cours et quittez le mode d'édition. Cela n'affectera pas votre configuration enregistrée",
"edit-mode-name": "Éditeur interactif",
"edit-mode-subtitle": "Vous êtes en mode d'édition",
"edit-mode-description": "Vous pouvez apporter des modifications à votre configuration et prévisualiser les résultats, mais jusqu'à ce que vous sauvegardiez, aucune de vos modifications ne sera conservée.",
"save-stage-btn": "Enregistrer",
"cancel-stage-btn": "Annuler"
},
"edit-section": {
"edit-section-title": "Éditeur",
"edit-tooltip": "Cliquer pour modifier ou cliquer droit pour plus d'options",
"remove-confirm": "Voulez-vous vraiment supprimer cette section ? Cette action peut être annulée ultérieurement."
},
"edit-app-config": {
"warning-msg-title": "Procéder avec prudence",
"warning-msg-l1": "Les options suivantes concernent la configuration avancée de l'application.",
"warning-msg-l2": "Si vous n'êtes pas sûr de l'un des champs, veuillez consulter la",
"warning-msg-docs": "documentation",
"warning-msg-l3": "pour éviter des conséquences inattendues."
},
"export": {
"export-title": "Exporter la configuration",
"copy-clipboard-btn": "Copier dans le presse-papier",
"copy-clipboard-tooltip": "Copier la configuration complète de l'application sur votre appareil dans un fichier YAML",
"download-file-btn": "Télécharger",
"download-file-tooltip": "Téléchargez la configuration complète de l'application sur votre appareil dans un fichier YAML",
"view-title": "Afficher la configuration"
}
}
}

View File

@ -1,5 +1,6 @@
<template>
<div class="cloud-backup-restore-wrapper">
<!-- Intro text -->
<div class="section intro">
<h2>{{ $t('cloud-sync.title') }}</h2>
<p class="intro">
@ -11,6 +12,7 @@
<a href="https://github.com/Lissy93/dashy/blob/master/docs/backup-restore.md">docs</a>
</p>
</div>
<!-- Create or update a backup form -->
<div class="section backup-section">
<h3 v-if="backupId">{{ $t('cloud-sync.backup-title-setup') }}</h3>
<h3 v-else>{{ $t('cloud-sync.backup-title-setup') }}</h3>
@ -23,11 +25,9 @@
type="password"
/>
<Button :click="checkPass">
<template v-slot:text>
{{backupId
? $t('cloud-sync.backup-button-update') : $t('cloud-sync.backup-button-setup')}}
</template>
<template v-slot:icon><IconBackup /></template>
{{backupId
? $t('cloud-sync.backup-button-update') : $t('cloud-sync.backup-button-setup')}}
<IconBackup />
</Button>
<div class="results-view" v-if="backupId">
<span class="backup-id-label">{{ $t('cloud-sync.backup-id-label') }}: </span>
@ -35,6 +35,7 @@
<span class="backup-id-note">{{ $t('cloud-sync.backup-id-note') }}</span>
</div>
</div>
<!-- Restore from backup form -->
<div class="section restore-section">
<h3>{{ $t('cloud-sync.restore-title') }}</h3>
<Input
@ -49,32 +50,38 @@
type="password"
/>
<Button :click="restoreBackup">
<template v-slot:text>{{ $t('cloud-sync.restore-button') }}</template>
<template v-slot:icon><IconRestore /></template>
{{ $t('cloud-sync.restore-button') }}
<IconRestore />
</Button>
</div>
</div>
</template>
<script>
// Import libraries
import sha256 from 'crypto-js/sha256';
import ProgressBar from 'rsup-progress';
// Import form elements
import Button from '@/components/FormElements/Button';
import Input from '@/components/FormElements/Input';
import IconBackup from '@/assets/interface-icons/config-backup.svg';
import IconRestore from '@/assets/interface-icons/config-restore.svg';
// Import utils and constants
import StoreKeys from '@/utils/StoreMutations';
import { backup, update, restore } from '@/utils/CloudBackup';
import { localStorageKeys } from '@/utils/defaults';
import { InfoHandler, WarningInfoHandler } from '@/utils/ErrorHandler';
// Import Icons
import IconBackup from '@/assets/interface-icons/config-backup.svg';
import IconRestore from '@/assets/interface-icons/config-restore.svg';
export default {
name: 'CloudBackupRestore',
props: {
config: Object,
computed: {
config() { // Users config from store
return this.$store.state.config;
},
},
data() {
return {
return { // Store current form data (temp)
backupPassword: '',
restorePassword: '',
restoreCode: '',
@ -82,36 +89,26 @@ export default {
progress: new ProgressBar({ color: 'var(--progress-bar)' }),
};
},
components: {
components: { // UI components / icons
Button,
Input,
IconBackup,
IconRestore,
},
methods: {
/* Make request to server-side, then either show error, or proceed to restore */
restoreBackup() {
this.progress.start();
restore(this.restoreCode, this.restorePassword)
.then((response) => {
this.restoreFromBackup(response, this.restoreCode);
this.applyRestoredData(response, this.restoreCode);
this.progress.end();
}).catch((msg) => {
this.showErrorMsg(msg);
this.progress.end();
});
},
checkPass() {
const savedHash = localStorage[localStorageKeys.BACKUP_HASH] || undefined;
if (!this.backupPassword) {
this.showErrorMsg(this.$t('cloud-sync.backup-missing-password'));
} else if (!savedHash) {
this.makeBackup();
} else if (savedHash === this.makeHash(this.backupPassword)) {
this.makeUpdate();
} else {
this.showErrorMsg(this.$t('cloud-sync.backup-error-password'));
}
},
/* Send request to backup server, to upload a new backup */
makeBackup() {
this.progress.start();
backup(this.config, this.backupPassword)
@ -127,6 +124,7 @@ export default {
this.progress.end();
});
},
/* Send request to backup server, to update an existing backup */
makeUpdate() {
this.progress.start();
update(this.config, this.backupPassword, this.backupId)
@ -142,17 +140,36 @@ export default {
this.progress.end();
});
},
restoreFromBackup(config, backupId) {
/* For create / update a backup- checks pass is valid, then calls makeBackup */
checkPass() {
const savedHash = localStorage[localStorageKeys.BACKUP_HASH] || undefined;
if (!this.backupPassword) {
this.showErrorMsg(this.$t('cloud-sync.backup-missing-password'));
} else if (!savedHash) {
this.makeBackup();
} else if (savedHash === this.makeHash(this.backupPassword)) {
this.makeUpdate();
} else {
this.showErrorMsg(this.$t('cloud-sync.backup-error-password'));
}
},
/* 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);
}
// Save hashed token in local storage
this.setBackupIdLocally(backupId, this.restorePassword);
// Update the current state
this.$store.commit(StoreKeys.SET_CONFIG, config);
// Show success message
this.showSuccessMsg(this.$t('cloud-sync.restore-success-msg'));
setTimeout(() => { location.reload(); }, 1500); // eslint-disable-line no-restricted-globals
},
/* After backup/ update is made, then replace 'Make Backup' with 'Update Backup' */
updateUiAfterBackup(backupId, isUpdate = false) {
this.setBackupIdLocally(backupId, this.backupPassword);
this.showSuccessMsg(
@ -160,17 +177,21 @@ export default {
);
this.backupPassword = '';
},
/* If the server returns a warning, then show to user and log it */
showErrorMsg(errorMsg) {
WarningInfoHandler(errorMsg, 'Cloud Backup');
this.$toasted.show(errorMsg, { className: 'toast-error' });
},
/* When server returns success message, then show to user and log it */
showSuccessMsg(msg) {
InfoHandler(msg, 'Cloud Backup');
this.$toasted.show(msg, { className: 'toast-success' });
},
/* Call to hash function, to hash the users chosen/ entered password */
makeHash(pass) {
return sha256(pass).toString();
},
/* After backup is applied, hash the backup ID, and save in browser storage */
setBackupIdLocally(backupId, pass) {
this.backupId = backupId;
const hash = this.makeHash(pass);
@ -185,49 +206,48 @@ export default {
@import '@/styles/style-helpers.scss';
div.cloud-backup-restore-wrapper {
display: flex;
flex-direction: row;
flex-wrap: wrap;
flex-direction: row;
text-align: center;
overflow: auto;
height: 100%;
background: var(--config-settings-background);
color: var(--config-settings-color);
color: var(--cloud-backup-color);
background: var(--cloud-backup-background);
@extend .scroll-bar;
.section {
display: flex;
flex-direction: column;
width: fit-content;
margin: 0 auto 1rem auto;
padding: 0 0.5rem 1rem 0.5rem;
&:first-child {
border-bottom: 1px dashed var(--config-settings-color);
}
&.intro {
width: 100%;
height: fit-content;
a {
color: var(--config-settings-color);
}
}
}
h2 { font-size: 2rem; }
h3 { font-size: 1.6rem; }
/* Text styling */
h2, h3 { font-size: 1.6rem; }
p.intro {
text-align: left;
font-size: 1rem;
margin: 0.25rem;
padding: 0.25rem;
}
/* Main sections */
.section {
display: flex;
flex-direction: column;
width: fit-content;
margin: 0 auto 1rem auto;
padding: 0 0.5rem 1rem 0.5rem;
}
/* Intro section */
.section.intro {
width: 100%;
height: fit-content;
border-bottom: 1px dashed var(--cloud-backup-color);
a { color: var(--cloud-backup-color); }
}
}
/* Container to show backup ID result from server */
div.results-view {
width: 16rem;
margin: 0.5rem auto;
padding: 0.5rem 0.75rem;
box-sizing: border-box;
border: 1px dashed var(--config-settings-color);
border: 1px dashed var(--cloud-backup-color);
border-radius: var(--curve-factor);
text-align: left;
.backup-id-label, .backup-id-value {
@ -244,22 +264,19 @@ export default {
}
/* Overide form element colors, so that config menu can be themed by user */
input, button, {
color: var(--config-settings-color);
border: 1px solid var(--config-settings-color);
input, button {
color: var(--cloud-backup-color);
border: 1px solid var(--cloud-backup-color);
background: none;
width: 16rem;
}
input:focus {
box-shadow: 1px 1px 6px var(--config-settings-color);
box-shadow: 1px 1px 6px var(--cloud-backup-color);
}
button:hover {
color: var(--config-settings-background);
border: 1px solid var(--config-settings-background);
background: var(--config-settings-color);
}
h2, h3 {
margin: 1rem;
color: var(--cloud-backup-background);
border: 1px solid var(--cloud-backup-background);
background: var(--cloud-backup-color);
}
</style>

View File

@ -3,8 +3,8 @@
<TabItem :name="$t('config.main-tab')" class="main-tab">
<div class="main-options-container">
<div class="config-buttons">
<h2>Configuration Options</h2>
<a class="hyperlink-wrapper" @click="downloadConfigFile('conf.yml', yaml)">
<h2>{{ $t('config.heading') }}</h2>
<a class="hyperlink-wrapper" @click="openExportConfigModal()">
<button class="config-button center">
<DownloadIcon class="button-icon"/>
{{ $t('config.download-config-button') }}
@ -52,13 +52,13 @@
<RebuildApp />
</TabItem>
<TabItem :name="$t('config.edit-config-tab')">
<JsonEditor :config="config" />
<JsonEditor />
</TabItem>
<TabItem :name="$t('cloud-sync.title')">
<CloudBackupRestore :config="config" />
<CloudBackupRestore />
</TabItem>
<TabItem :name="$t('config.custom-css-tab')">
<CustomCssEditor :config="config" />
<CustomCssEditor />
</TabItem>
</Tabs>
</template>
@ -68,6 +68,7 @@
import JsonToYaml from '@/utils/JsonToYaml';
import { localStorageKeys, modalNames } from '@/utils/defaults';
import { getUsersLanguage } from '@/utils/ConfigHelpers';
import StoreKeys from '@/utils/StoreMutations';
import JsonEditor from '@/components/Configuration/JsonEditor';
import CustomCssEditor from '@/components/Configuration/CustomCss';
import CloudBackupRestore from '@/components/Configuration/CloudBackupRestore';
@ -134,33 +135,20 @@ export default {
openLanguageSwitchModal() {
this.$modal.show(modalNames.LANG_SWITCHER);
},
copyConfigToClipboard() {
navigator.clipboard.writeText(this.jsonParser(this.config));
this.$toasted.show(this.$t('config.data-copied-msg'));
openExportConfigModal() {
this.$modal.show(modalNames.EXPORT_CONFIG_MENU);
},
/* Checks that the user is sure, then resets site-wide local storage, and reloads page */
resetLocalSettings() {
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 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'));
setTimeout(() => {
location.reload(true); // eslint-disable-line no-restricted-globals
}, 1900);
this.$store.dispatch(StoreKeys.INITIALIZE_CONFIG);
}
},
/* Generates a new file, with the YAML contents, and triggers a download */
downloadConfigFile(filename, filecontents) {
const element = document.createElement('a');
element.setAttribute('href', `data:text/plain;charset=utf-8, ${encodeURIComponent(filecontents)}`);
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
},
getLanguage() {
const lang = getUsersLanguage();
return lang ? `${lang.flag} ${lang.name}` : '';

View File

@ -4,7 +4,7 @@
<div class="css-wrapper">
<h2 class="css-input-title">Custom CSS</h2>
<textarea class="css-editor" v-model="customCss" />
<Button class="save-button" :click="save">{{ $t('config.css-save-btn') }}</button>
<Button class="save-button" :click="save">{{ $t('config.css-save-btn') }}</Button>
<p class="quick-note">
<b>{{ $t('config.css-note-label') }}:</b>
{{ $t('config.css-note-l1') }} {{ $t('config.css-note-l2') }} {{ $t('config.css-note-l3') }}
@ -20,49 +20,65 @@
import CustomThemeMaker from '@/components/Settings/CustomThemeMaker';
import Button from '@/components/FormElements/Button';
import { getTheme } from '@/utils/ConfigHelpers';
import { localStorageKeys } from '@/utils/defaults';
import StoreKeys from '@/utils/StoreMutations';
import { InfoHandler } from '@/utils/ErrorHandler';
import { localStorageKeys, theme as defaultTheme } from '@/utils/defaults';
export default {
name: 'StyleEditor',
props: {
config: Object,
},
components: {
Button,
CustomThemeMaker,
},
computed: {
appConfig() {
return this.$store.getters.appConfig;
},
currentTheme() {
return this.appConfig.theme || defaultTheme;
},
},
data() {
return {
customCss: this.config.appConfig.customCss || '\n\n',
currentTheme: getTheme(),
customCss: '',
};
},
mounted() {
// Get existing custom styles (if present) from appConfig
this.customCss = this.appConfig.customCss || '\n\n';
},
methods: {
/* Save custom CSS in browser, call inject, and show success message */
/* Sanitizes input, saves to browser and store, applies to page and shows message */
save() {
// Get, and sanitize users CSS
const css = this.customCss.replace(/<\/?[^>]+(>|$)/g, '');
// Update app config, and apply settings locally
const appConfig = { ...this.config.appConfig };
appConfig.customCss = css;
localStorage.setItem(localStorageKeys.APP_CONFIG, JSON.stringify(appConfig));
// Immidiatley inject new CSS
this.inject(css);
// If reseting styles, then refresh the page
if (css === '') setTimeout(() => { location.reload(); }, 1500); // eslint-disable-line no-restricted-globals
// Show status message
InfoHandler('User syles has been saved', 'Custom CSS Update');
this.$toasted.show('Changes saved successfully');
this.$store.commit(StoreKeys.UPDATE_CUSTOM_CSS, css);
this.saveToBrowser(css);
this.injectToPage(css);
this.showSuccessMsg();
if (css === '') this.reloadPage();
},
/* Formats CSS, and applies it to page */
inject(userStyles) {
injectToPage(userStyles) {
const cleanedCss = userStyles.replace(/<\/?[^>]+(>|$)/g, '');
const style = document.createElement('style');
style.textContent = cleanedCss;
document.head.append(style);
},
/* Saves custom CSS local storage */
saveToBrowser(css) {
const localAppConfig = JSON.parse(localStorage.getItem(localStorageKeys.APP_CONFIG) || '{}');
localAppConfig.customCss = css;
localStorage.setItem(localStorageKeys.APP_CONFIG, JSON.stringify(localAppConfig));
},
/* Reload the page (only called if removing styles) */
reloadPage() {
setTimeout(() => { location.reload(); }, 1500); // eslint-disable-line no-restricted-globals
},
/* Show success toast and lot update */
showSuccessMsg() {
this.$toasted.show('Changes saved successfully');
InfoHandler('User syles has been saved', 'Custom CSS');
},
},
};
</script>

View File

@ -1,31 +1,24 @@
<template>
<div class="json-editor-outer">
<!-- Main JSON editor -->
<v-jsoneditor
v-model="jsonData"
:options="options"
/>
<v-jsoneditor v-model="jsonData" :options="options" />
<!-- Options raido, and save button -->
<div class="save-options">
<span class="save-option-title">{{ $t('config-editor.save-location-label') }}:</span>
<div class="option">
<input type="radio" id="local" value="local"
v-model="saveMode" class="radio-option" :disabled="!allowWriteToDisk" />
<label for="local" class="save-option-label">
{{ $t('config-editor.location-local-label') }}
</label>
</div>
<div class="option">
<input type="radio" id="file" value="file" v-model="saveMode" class="radio-option"
:disabled="!allowWriteToDisk" />
<label for="file" class="save-option-label">
{{ $t('config-editor.location-disk-label') }}
</label>
</div>
<Radio class="save-options"
v-model="saveMode"
:label="$t('config-editor.save-location-label')"
:options="saveOptions"
:initialOption="initialSaveMode"
:disabled="!allowWriteToDisk"
/>
<!-- Save Buttons -->
<div :class="`btn-container ${!isValid ? 'err' : ''}`">
<Button :click="save">
{{ $t('config-editor.save-button') }}
</Button>
<Button :click="startPreview">
{{ $t('config-editor.preview-button') }}
</Button>
</div>
<button :class="`save-button ${!isValid ? 'err' : ''}`" @click="save()">
{{ $t('config-editor.save-button') }}
</button>
<!-- List validation warnings -->
<p class="errors">
<ul>
@ -50,7 +43,6 @@
<p v-if="saveSuccess" class="response-output">
{{ $t('config-editor.success-note-l1') }}
{{ $t('config-editor.success-note-l2') }}
{{ $t('config-editor.success-note-l3') }}
</p>
<p class="note">{{ $t('config.backup-note') }}</p>
</div>
@ -61,25 +53,27 @@
import axios from 'axios';
import ProgressBar from 'rsup-progress';
import VJsoneditor from 'v-jsoneditor';
import jsYaml from 'js-yaml';
import ErrorHandler, { InfoHandler } from '@/utils/ErrorHandler';
import configSchema from '@/utils/ConfigSchema.json';
import JsonToYaml from '@/utils/JsonToYaml';
import { localStorageKeys, serviceEndpoints } from '@/utils/defaults';
import StoreKeys from '@/utils/StoreMutations';
import { localStorageKeys, serviceEndpoints, modalNames } from '@/utils/defaults';
import { isUserAdmin } from '@/utils/Auth';
import Button from '@/components/FormElements/Button';
import Radio from '@/components/FormElements/Radio';
export default {
name: 'JsonEditor',
props: {
config: Object,
},
components: {
VJsoneditor,
Button,
Radio,
},
data() {
return {
jsonData: this.config,
jsonData: {},
errorMessages: [],
saveMode: 'file',
saveMode: '',
options: {
schema: configSchema,
mode: 'tree',
@ -87,26 +81,36 @@ export default {
name: 'config',
onValidationError: this.validationErrors,
},
jsonParser: JsonToYaml,
responseText: '',
saveSuccess: undefined,
allowWriteToDisk: this.shouldAllowWriteToDisk(),
progress: new ProgressBar({ 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' },
],
};
},
computed: {
config() {
return this.$store.state.config;
},
isValid() {
return this.errorMessages.length < 1;
},
},
mounted() {
if (!this.allowWriteToDisk) this.saveMode = 'local';
},
methods: {
shouldAllowWriteToDisk() {
allowWriteToDisk() {
const { appConfig } = this.config;
return appConfig.allowConfigEdit !== false && isUserAdmin();
},
initialSaveMode() {
return this.allowWriteToDisk ? 'file' : 'local';
},
},
mounted() {
this.jsonData = this.config;
if (!this.allowWriteToDisk) this.saveMode = 'local';
},
methods: {
/* Calls appropriate save method, based on save-type radio selected */
save() {
if (this.saveMode === 'local' || !this.allowWriteToDisk) {
this.saveConfigLocally();
@ -116,9 +120,21 @@ export default {
this.$toasted.show(this.$t('config-editor.error-msg-save-mode'));
}
},
/* Applies changes to the local state, begins edit mode and closes modal */
startPreview() {
InfoHandler('Applying changes to local state...', 'Config Update');
const data = this.jsonData;
this.$store.commit(StoreKeys.SET_APP_CONFIG, data.appConfig);
this.$store.commit(StoreKeys.SET_PAGE_INFO, data.pageInfo);
this.$store.commit(StoreKeys.SET_SECTIONS, data.sections);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
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 = this.jsonParser(this.jsonData);
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}`;
@ -137,6 +153,7 @@ export default {
this.showToast(this.$t('config-editor.error-msg-cannot-save'), false);
}
InfoHandler('Config has been written to disk succesfully', 'Config Update');
this.$store.commit(StoreKeys.SET_CONFIG, this.jsonData);
this.progress.end();
})
.catch((error) => {
@ -147,6 +164,7 @@ export default {
this.progress.end();
});
},
/* Saves config to local browser storage */
saveConfigLocally() {
const data = this.jsonData;
if (data.sections) {
@ -165,11 +183,13 @@ export default {
InfoHandler('Config has succesfully been saved in browser storage', 'Config Update');
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) {
const errorMessages = [];
errors.forEach((error) => {
@ -197,6 +217,7 @@ export default {
});
this.errorMessages = errorMessages;
},
/* Shows toast message */
showToast(message, success) {
this.$toasted.show(message, { className: `toast-${success ? 'success' : 'error'}` });
},
@ -259,52 +280,59 @@ p.no-permission-note {
color: var(--config-settings-color);
}
button.save-button {
padding: 0.5rem 1rem;
margin: 0.25rem auto;
font-size: 1.2rem;
background: var(--config-settings-color);
color: var(--config-settings-background);
border: 1px solid var(--config-settings-background);
border-radius: var(--curve-factor);
cursor: pointer;
&:hover {
.btn-container {
display: flex;
align-items: center;
justify-content: center;
button {
padding: 0.5rem 1rem;
margin: 0.25rem;
font-size: 1.2rem;
background: var(--config-settings-background);
color: var(--config-settings-color);
border-color: var(--config-settings-color);
}
&.err {
opacity: 0.8;
cursor: default;
border: 1px solid var(--config-settings-color);
border-radius: var(--curve-factor);
&:hover {
background: var(--config-settings-color);
color: var(--config-settings-background);
border-color: var(--config-settings-background);
}
}
&.err button {
opacity: 0.8;
cursor: default;
&:hover {
background: var(--config-settings-background);
color: var(--config-settings-color);
border-color: var(--danger);
}
}
}
div.save-options {
div.save-options.radio-container {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: center;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--code-editor-background);
color: var(--code-editor-color);
margin: 0;
padding: 0;
border-top: 2px solid var(--config-settings-background);
@include tablet-down { flex-direction: column; }
.option {
@include tablet-up { margin-left: 2rem; }
background: var(--code-editor-background);
label.radio-label {
font-size: 1rem;
flex-grow: revert;
flex-basis: revert;
color: var(--code-editor-color);
padding-left: 1rem;
}
span.save-option-title {
cursor: default;
}
input.radio-option {
cursor: pointer;
}
label.save-option-label {
cursor: pointer;
.radio-wrapper {
margin: 0;
font-size: 1rem;
justify-content: space-around;
background: var(--code-editor-background);
color: var(--code-editor-color);
.radio-option:hover:not(.wrap-disabled) {
border: 1px solid var(--code-editor-color);
}
}
}

View File

@ -1,8 +1,11 @@
<template>
<button
@click="click ? click() : () => null"
:disabled="disabled"
:class="disallow ? 'disallowed': ''"
:type="type || 'button'"
:disabled="disabled"
v-tooltip="hoverText"
:title="tooltip"
>
<slot></slot>
<slot name="text"></slot>
@ -15,10 +18,21 @@
export default {
name: 'Button',
props: {
text: String,
click: Function,
disabled: Boolean,
disallow: Boolean,
text: String, // The text to be displayed in the button
click: Function, // Function to call when clicked
disabled: Boolean, // If true, button cannot be clicked
disallow: Boolean, // Show not-allowed cursor when true
type: String, // The html button type attribute
tooltip: String, // Text to be displayed on hover
},
computed: {
/* If tooltip prop specified, then return config for v-tooltip */
hoverText() {
const content = this.tooltip;
const trigger = 'hover focus';
const delay = { show: 350, hide: 100 };
return (content) ? { content, trigger, delay } : undefined;
},
},
};
</script>

View File

@ -1,6 +1,12 @@
<template>
<div :class="`input-container ${layout}`">
<label v-if="label" for="name">{{label}}</label>
<label
v-if="label"
for="name"
class="input-label"
>
{{label}}
</label>
<input
:type="type"
:value="value"
@ -8,7 +14,14 @@
:name="name"
:id="name"
:placeholder="placeholder"
class="input-field"
/>
<p
v-if="description"
class="input-description"
>
{{ description }}
</p>
</div>
</template>
@ -17,10 +30,11 @@
export default {
name: 'Input',
props: {
value: String, // The value bound to v-model
value: [String, Number], // The value bound to v-model
label: String, // An optional label to display above
name: String, // Required unique ID value, for accessibility
placeholder: String, // Optional placeholder value
description: String, // Optional info paragraph
type: {
default: 'text', // Input type, e.g. text, password, number
type: String,
@ -40,6 +54,8 @@ export default {
</script>
<style scoped lang="scss">
@import '@/styles/media-queries.scss';
div.input-container {
margin: 0.25rem auto;
display: flex;
@ -48,12 +64,23 @@ div.input-container {
flex-direction: column;
}
&.horizontal {
flex-direction: row;
justify-content: space-between;
label { margin-right: 0.25rem; }
@include tablet-up {
flex-direction: row;
justify-content: space-between;
align-items: center;
label.input-label,
input.input-field,
p.input-description {
margin: 0.25rem;
flex-basis: 8rem;
flex-grow: 1;
}
input.input-field { flex-grow: 2; }
p.input-description { flex-grow: 3; }
}
}
input {
input.input-field {
min-width: 10rem;
padding: 0.5rem 0.75rem;
margin: 0.5rem auto;
@ -68,6 +95,22 @@ div.input-container {
outline: none;
}
}
label.input-label {
text-transform: capitalize;
}
p.input-description {
opacity: var(--dimming-factor);
}
@include tablet-down {
flex-direction: column;
align-items: start;
input.input-field {
margin: 0.5rem;
}
}
}
</style>

View File

@ -0,0 +1,109 @@
<template>
<div class="radio-container">
<label v-if="label" class="radio-label">{{ label }}</label>
<div class="radio-wrapper">
<div v-for="radio in options" :key="radio.value"
:class="`radio-option ${disabled ? 'wrap-disabled' : ''}`">
<label :for="`id-${radio.value}`" class="option-label">{{ radio.label }}</label>
<input type="radio" class="radio-input"
:id=" `id-${radio.value}`"
:name="makeGroupName"
:value="radio.value"
:disabled="disabled || radio.disabled"
v-model="selectedRadio"
v-on:input="updateValue($event.target.value)"
/>
</div>
</div>
<p v-if="description" class="radio-description">{{ description }}</p>
</div>
</template>
<script>
export default {
name: 'Radio',
components: {},
props: {
options: Array, // Array of objects for available options
initialOption: String, // Optional default option
label: String, // Form label for element
description: String, // Optional description text
disabled: Boolean, // Disable all radio buttons
},
data() {
return {
selectedRadio: '', // The currently radio val
};
},
created() {
if (this.initialOption) {
this.updateValue(this.initialOption);
}
},
computed: {
makeGroupName() {
return this.label.toLowerCase().replace(/[^a-z]+/, '');
},
},
methods: {
updateValue(value) {
this.$emit('input', value);
this.selectedRadio = value;
},
},
};
</script>
<style scoped lang="scss">
div.radio-container {
margin: 0.25rem auto;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
label.radio-label,
.radio-wrapper,
p.radio-description {
margin: 0.25rem;
flex-basis: 8rem;
flex-grow: 1;
}
label.radio-label {
text-transform: capitalize;
}
p.radio-description {
flex-grow: 3;
opacity: var(--dimming-factor);
}
.radio-wrapper {
display: flex;
flex-grow: 2;
margin: 0.5rem auto;
font-size: 1.2rem;
color: var(--primary);
background: var(--background);;
border-radius: var(--curve-factor);
min-width: 8rem;
.radio-option {
margin: 0.2rem;
padding: 0.2rem;
cursor: pointer;
border: 1px solid transparent;
border-radius: var(--curve-factor);
&:hover:not(.wrap-disabled) {
border: 1px solid var(--primary);
}
&:disabled {
opacity: var(--dimming-factor);
}
label.option-label, input.radio-input {
cursor: pointer;
text-transform: capitalize;
margin: 0.2rem;
}
}
}
}
</style>

View File

@ -0,0 +1,128 @@
<template>
<div class="select-container">
<label v-if="label" class="select-label">{{ label }}</label>
<v-select
@input="updateValue"
:value="selectedOption"
:selectOnTab="true"
:options="options"
class="form-dropdown"
/>
<p v-if="description" class="select-description">{{ description }}</p>
</div>
</template>
<script>
export default {
name: 'Select',
components: {},
props: {
options: Array, // Array of available options
initialOption: String, // Optional default option
label: String, // Form label for element
description: String, // Optional description text
},
data() {
return {
selectedOption: '', // The currently selected val
};
},
created() {
if (this.initialOption) {
this.selectedOption = this.initialOption;
}
},
methods: {
updateValue(value) {
this.$emit('input', value);
this.selectedOption = value;
},
},
};
</script>
<style scoped lang="scss">
@import '@/styles/media-queries.scss';
div.select-container {
margin: 0.25rem auto;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
label.select-label,
.form-dropdown,
p.select-description {
margin: 0.25rem;
flex-basis: 8rem;
flex-grow: 1;
}
label.select-label {
text-transform: capitalize;
}
p.select-description {
flex-grow: 3;
opacity: var(--dimming-factor);
}
.form-dropdown {
flex-grow: 2;
min-width: 12rem;
margin: 0.5rem auto;
font-size: 1.2rem;
box-sizing: border-box;
color: var(--primary);
background: var(--background);;
border-radius: var(--curve-factor);
&:focus {
box-shadow: 1px 1px 6px var(--config-settings-color);
outline: none;
}
}
@include tablet-down {
flex-direction: column;
align-items: start;
label.select-label,
.form-dropdown,
p.select-description {
margin: 0.5rem;
flex-basis: auto;
}
}
}
</style>
<style lang="scss">
@import '@/styles/style-helpers.scss';
.form-dropdown {
margin: 1rem auto;
ul.vs__dropdown-menu {
max-height: 14rem;
@extend .scroll-bar;
}
input.vs__search {
color: var(--primary);
}
div.vs__dropdown-toggle {
padding: 0.2rem 0;
border-color: var(--primary);
background: var(--background);
.vs__actions svg {
height: 1.2rem;
width: 1.2rem;
border: none;
margin: 0;
padding: 0.2rem 0 0 0.2rem;
&:hover {
background: var(--primary);
path { fill: var(--background); }
}
}
}
div, input {
cursor: pointer;
}
}
</style>

View File

@ -0,0 +1,66 @@
<!-- Main homepage for default view -->
<template>
<div class="add-section">
<!-- When in edit mode, show Add New Section button -->
<div v-if="isEditMode" @click="openAddNewSectionMenu()" class="add-new-section">
<p> {{ $t('interactive-editor.edit-section.add-section-title') }}</p>
</div>
<!-- Add new section form -->
<EditSectionMenu
v-if="isEditMode && addNewSectionOpen"
:isAddNew="true"
@closeEditSection="closeEditSection"
/>
</div>
</template>
<script>
import EditSectionMenu from '@/components/InteractiveEditor/EditSection.vue';
import StoreKeys from '@/utils/StoreMutations';
import { modalNames } from '@/utils/defaults';
export default {
name: 'add-section-container',
components: {
EditSectionMenu,
},
data: () => ({
addNewSectionOpen: false,
}),
computed: {
isEditMode() {
return this.$store.state.editMode;
},
},
methods: {
openAddNewSectionMenu() {
this.addNewSectionOpen = true;
this.$modal.show(modalNames.EDIT_SECTION);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, true);
},
closeEditSection() {
this.addNewSectionOpen = false;
this.$modal.hide(modalNames.EDIT_SECTION);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
},
},
};
</script>
<style lang="scss" scoped>
.add-new-section {
border: 2px dashed var(--primary);
border-radius: var(--curve-factor);
padding: var(--item-group-padding);
background: var(--item-group-background);
color: var(--primary);
font-size: 1.2rem;
cursor: pointer;
text-align: center;
height: fit-content;
margin: 10px;
}
</style>

View File

@ -0,0 +1,134 @@
<template>
<modal
:name="modalName"
:resizable="true"
width="50%"
height="80%"
classes="dashy-modal edit-app-config"
@closed="modalClosed"
>
<div class="edit-app-config-inner">
<h3>{{ $t('interactive-editor.menu.edit-app-config-btn') }}</h3>
<!-- Show caution message -->
<div class="app-config-intro">
<p class="use-caution">
{{ $t('interactive-editor.edit-app-config.warning-msg-title') }}
</p>
{{ $t('interactive-editor.edit-app-config.warning-msg-l1') }}
{{ $t('interactive-editor.edit-app-config.warning-msg-l2') }}
<a href="https://dashy.to/docs/configuring#appconfig-optional">
{{ $t('interactive-editor.edit-app-config.warning-msg-docs') }}
</a>
{{ $t('interactive-editor.edit-app-config.warning-msg-l3') }}
</div>
<!-- Save Button, upper -->
<SaveCancelButtons :saveClick="saveToState" :cancelClick="cancelEditing" />
<!-- The main form -->
<FormSchema
:schema="schema"
v-model="formData"
@submit.prevent="saveToState"
:search="true"
class="app-config-form"
name="appConfigForm"
></FormSchema>
<!-- Save Button, lower -->
<SaveCancelButtons :saveClick="saveToState" :cancelClick="cancelEditing" />
</div>
</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';
export default {
name: 'EditAppConfig',
data() {
return {
formData: {},
schema: DashySchema.properties.appConfig,
modalName: modalNames.EDIT_APP_CONFIG,
};
},
props: {},
components: {
FormSchema,
SaveCancelButtons,
},
mounted() {
this.formData = this.appConfig;
},
computed: {
appConfig() {
return this.$store.getters.appConfig;
},
},
methods: {
/* When form submitteed, update VueX store with new appConfig, and close modal */
saveToState() {
const processedFormData = this.removeUndefinedValues(this.formData);
this.$store.commit(StoreKeys.SET_APP_CONFIG, processedFormData);
this.$modal.hide(this.modalName);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
this.$store.commit(StoreKeys.SET_EDIT_MODE, true);
},
cancelEditing() {
this.$modal.hide(this.modalName);
},
/* Called when modal manually closed, updates state to allow searching again */
modalClosed() {
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
},
/* Remove any attribute which has an undefined value before saving */
removeUndefinedValues(rawAppConfig) {
const raw = rawAppConfig;
const isEmpty = (value) => (value === undefined);
Object.keys(raw).forEach(key => isEmpty(raw[key]) && delete raw[key]);
return raw;
},
},
};
</script>
<style lang="scss">
@import '@/styles/style-helpers.scss';
@import '@/styles/media-queries.scss';
@import '@/styles/schema-editor.scss';
.edit-app-config-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;
}
.app-config-form {
@extend .schema-form;
border-top: 1px dashed var(--interactive-editor-color);
}
.app-config-intro {
padding: 0.5rem;
font-size: 0.9rem;
color: var(--interactive-editor-color);
background: var(--interactive-editor-background-darker);
border-radius: var(--interactive-editor-color);
p.use-caution {
color: var(--warning);
margin: 0;
font-weight: bold;
}
a {
color: var(--interactive-editor-color);
}
}
}
</style>

View File

@ -0,0 +1,341 @@
<template>
<modal
:name="modalName"
:resizable="true"
width="50%"
height="80%"
classes="dashy-modal edit-item"
@closed="modalClosed"
>
<div class="edit-item-inner">
<!-- Title and Item ID -->
<h3 class="title">Edit Item</h3>
<p class="sub-title">Editing {{item.title}} (ID: {{itemId}})</p>
<!-- If no elements added to form, show info message -->
<p class="warning-note" v-if="formData.length === 0">
No data configured yet. Click an attribute in the list below to add the field to the form.
</p>
<!-- For each data attribute, render the correct type of input field -->
<div class="row" v-for="(row, index) in formData" :key="row.name">
<!-- Text box, for text/ number/ raw input elements -->
<Input
v-if="row.type === 'text' || row.type === 'number'"
v-model="formData[index].value"
:description="row.description"
:label="row.title || row.name"
:type="row.type"
layout="horizontal"
/>
<!-- Radio button, used for True or False input -->
<Radio
v-else-if="row.type === 'boolean'"
v-model="formData[index].value"
:description="row.description"
:label="row.title || row.name"
:options="[ ...boolRadioOptions ]"
:initialOption="boolToStr(formData[index].value)"
/>
<!-- Select/ dropdown for enum multiple-choice input -->
<Select
v-else-if="row.type === 'select'"
v-model="formData[index].value"
:options="formData[index].enum"
:description="row.description"
:initialOption="formData[index].value"
:label="row.title || row.name"
class="edit-item-select"
/>
<!-- Warning note, for any other data types, that aren't yet supported -->
<div v-else>
{{ row.name }} cannot currently be edited through the UI.
</div>
<BinIcon @click="() => removeField(row.name)" />
</div>
<!-- Show Add chips, for adding more data elements to the form -->
<div class="add-more-inputs" v-if="additionalFormData.length > 0">
<h4>More Fields</h4>
<div class="more-fields">
<span
v-for="row in additionalFormData"
:key="row.name"
@click="() => appendNewField(row.name)"
class="add-field-tag">
<AddIcon /> {{ row.title || row.name }}
</span>
</div>
</div>
<!-- Save to state button -->
<SaveCancelButtons :saveClick="saveItem" :cancelClick="modalClosed" />
</div>
</modal>
</template>
<script>
import AddIcon from '@/assets/interface-icons/interactive-editor-add.svg';
import BinIcon from '@/assets/interface-icons/interactive-editor-remove.svg';
import SaveCancelButtons from '@/components/InteractiveEditor/SaveCancelButtons';
import Input from '@/components/FormElements/Input';
import Radio from '@/components/FormElements/Radio';
import Select from '@/components/FormElements/Select';
import StoreKeys from '@/utils/StoreMutations';
import DashySchema from '@/utils/ConfigSchema';
import { modalNames } from '@/utils/defaults';
export default {
name: 'EditItem',
data() {
return {
modalName: modalNames.EDIT_ITEM,
schema: DashySchema.properties.sections.items.properties.items.items.properties,
formData: [], // Array of form fields
additionalFormData: [], // Array of not-yet-used form fields
item: {},
boolRadioOptions: [
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
],
};
},
props: {
itemId: String,
isNew: Boolean,
parentSectionTitle: String, // If adding new item, which section to add it under
},
computed: {},
components: {
Input,
Radio,
Select,
AddIcon,
BinIcon,
SaveCancelButtons,
},
mounted() {
if (!this.isNew) { // Get existing item data
this.item = this.getItemFromState(this.itemId);
}
this.formData = this.makeInitialFormData();
this.$modal.show(modalNames.EDIT_ITEM);
},
methods: {
/* For a given item ID, return the item obj from store */
getItemFromState(id) {
return this.$store.getters.getItemById(id);
},
/* Using the schema, make data structure for the UI form fields to use */
makeRowData(property) {
return {
name: property,
description: this.schema[property].description,
value: this.item[property],
type: this.getInputType(this.schema[property]),
enum: this.schema[property].enum,
title: this.schema[property].title,
};
},
/* Make formatted data structure to be rendered as form elements */
makeInitialFormData() {
const formData = [];
const requiredFields = ['title', 'description', 'url', 'icon', 'target'];
const unneededFields = ['id'];
const isPrimaryField = (property) => (
this.item[property] || requiredFields.includes(property)
) && !unneededFields.includes(property);
Object.keys(this.schema).forEach((property) => {
const singleRow = this.makeRowData(property);
if (isPrimaryField(property)) {
formData.push(singleRow);
} else {
this.additionalFormData.push(singleRow);
}
});
return formData;
},
/* Convert boolean to string */
boolToStr(bool) {
if (bool) return 'true';
if (bool === false) return 'false';
return undefined;
},
/* Adds field from extras list to main form, then removes from extras list */
appendNewField(fieldId) {
Object.keys(this.schema).forEach((property) => {
if (property === fieldId) {
this.formData.push(this.makeRowData(property));
}
});
this.additionalFormData.forEach((elem, index) => {
if (elem.name === fieldId) {
this.additionalFormData.splice(index, 1);
}
});
},
/* On Remove Field click, removes field from main form, and adds to chip list */
removeField(fieldId) {
this.formData.forEach((elem, index) => {
if (elem.name === fieldId) {
this.formData.splice(index, 1);
this.additionalFormData.push(elem);
}
});
},
/* Use schema to determine type of form element to render, for a given attribute */
getInputType(schemaItem) {
const definedType = schemaItem.type;
if (definedType === 'text') {
return 'text';
} else if (definedType === 'number') {
return 'number';
} else if (definedType === 'boolean') {
return 'boolean';
} else if (schemaItem.enum) {
return 'select';
}
return 'text';
},
/* Saves the updated item to VueX Store */
saveItem() {
// Convert form data back into section.item data structure
const structured = {};
this.formData.forEach((row) => { structured[row.name] = row.value; });
// Some attributes need a little extra formatting
const newItem = this.formatBeforeSave(structured);
if (this.isNew) { // Insert new item into data store
newItem.id = `temp_${newItem.title}`;
const payload = { newItem, targetSection: this.parentSectionTitle };
this.$store.commit(StoreKeys.INSERT_ITEM, payload);
} else { // Update existing item from form data, in the store
this.$store.commit(StoreKeys.UPDATE_ITEM, { newItem, itemId: this.itemId });
}
// If we're not already in edit mode, enable it now
this.$store.commit(StoreKeys.SET_EDIT_MODE, true);
// Close edit menu
this.$emit('closeEditMenu');
},
/* Some fields require a bit of extra processing before they're saved */
formatBeforeSave(item) {
const newItem = item;
newItem.id = this.itemId;
if (newItem.hotkey) newItem.hotkey = parseInt(newItem.hotkey, 10);
const strToTags = (str) => {
const tagArr = str.split(',');
return tagArr.map((tag) => tag.trim().toLowerCase().replace(/[^a-z]+/, ''));
};
const strToBool = (str) => {
if (str === undefined) return undefined;
return str === 'true';
};
if (newItem.tags) newItem.tags = strToTags(newItem.tags);
if (newItem.statusCheck) newItem.statusCheck = strToBool(newItem.statusCheck);
// if (newItem.hotkey) newItem.hotkey = parseInt(newItem.hotkey, 10);
return newItem;
},
/* Clean up work, triggered when modal closed */
modalClosed() {
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
this.$emit('closeEditMenu');
},
},
};
</script>
<style lang="scss">
@import '@/styles/style-helpers.scss';
@import '@/styles/media-queries.scss';
.edit-item-inner {
padding: 1rem;
background: var(--interactive-editor-background);
color: var(--interactive-editor-color);
height: 100%;
overflow-y: auto;
@extend .svg-button;
h3.title {
font-size: 1.5rem;
margin: 0.25rem 0;
}
p.sub-title {
margin: 0.25rem 0;
font-size: 0.8rem;
font-style: italic;
opacity: var(--dimming-factor);
}
p.warning-note {
color: var(--warning);
}
.row {
display: flex;
padding: 0.5rem 0.25rem;
&:not(:last-child) {
border-bottom: 1px dotted var(--interactive-editor-color);
}
.input-container, .select-container {
width: 100%;
input.input-field {
font-size: 1rem;
padding: 0.35rem 0.5rem;
}
}
}
.more-fields {
display: flex;
flex-wrap: wrap;
span.add-field-tag {
margin: 0.2rem;
padding: 0.2rem 0.5rem;;
min-width: 2rem;
display: flex;
align-items: center;
cursor: pointer;
text-align: center;
border: 1px solid var(--interactive-editor-color);
border-radius: var(--curve-factor);
&:hover {
background: var(--interactive-editor-color);
color: var(--interactive-editor-background);
svg {
background: var(--interactive-editor-color);
path { fill: var(--interactive-editor-background); }
}
}
svg {
margin-right: 0.25rem;
border: none;
}
}
}
/* Override form element colors, with local CSS variables */
div.input-container input.input-field,
.radio-container div.radio-wrapper,
.form-dropdown div.vs__dropdown-toggle {
color: var(--interactive-editor-color);
border-color: var(--interactive-editor-color);
background: var(--interactive-editor-background);
}
svg {
path { fill: var(--interactive-editor-color); }
background: var(--interactive-editor-background);
&:hover, &.selected {
path { fill: var(--interactive-editor-background); }
background: var(--interactive-editor-color);
}
}
.edit-item-select .v-select {
input.vs__search { color: var(--interactive-editor-color); }
div.vs__dropdown-toggle {
border-color: var(--interactive-editor-color);
background: var(--interactive-editor-background);
span.vs__selected { color: var(--interactive-editor-color); }
.vs__actions svg {
background: var(--interactive-editor-background);
path { fill: var(--interactive-editor-color); }
&:hover {
background: var(--interactive-editor-color);
path { fill: var(--interactive-editor-background); }
}
}
}
}
}
</style>

View File

@ -0,0 +1,272 @@
<template>
<!-- Intro Info -->
<div class="edit-mode-bottom-banner">
<div class="edit-banner-section intro-container">
<p class="section-sub-title edit-mode-intro l-1">
{{ $t('interactive-editor.menu.edit-mode-subtitle') }}
</p>
<p class="edit-mode-intro l-2">
{{ $t('interactive-editor.menu.edit-mode-description') }}
</p>
</div>
<div class="edit-banner-section empty-space"></div>
<!-- Save Buttons -->
<div class="edit-banner-section save-buttons-container">
<p class="section-sub-title">
{{ $t('interactive-editor.menu.config-save-methods-subheading') }}
</p>
<Button
:click="openExportConfigMenu"
v-tooltip="tooltip($t('interactive-editor.menu.export-config-tooltip'))"
>
{{ $t('interactive-editor.menu.export-config-btn') }}
<ExportIcon />
</Button>
<Button
:click="reset"
v-tooltip="tooltip($t('interactive-editor.menu.cancel-changes-tooltip'))"
>
{{ $t('interactive-editor.menu.cancel-changes-btn') }}
<CancelIcon />
</Button>
<Button
:click="saveLocally"
v-tooltip="tooltip($t('interactive-editor.menu.save-locally-tooltip'))"
>
{{ $t('interactive-editor.menu.save-locally-btn') }}
<SaveLocallyIcon />
</Button>
<Button
:click="writeToDisk"
v-tooltip="tooltip($t('interactive-editor.menu.save-disk-tooltip'))"
>
{{ $t('interactive-editor.menu.save-disk-btn') }}
<SaveToDiskIcon />
</Button>
</div>
<!-- Open Modal Buttons -->
<div class="edit-banner-section edit-site-config-buttons">
<p class="section-sub-title">
{{ $t('interactive-editor.menu.edit-site-data-subheading') }}
</p>
<Button
:click="openEditPageInfo"
v-tooltip="tooltip($t('interactive-editor.menu.edit-page-info-tooltip'))"
>
{{ $t('interactive-editor.menu.edit-page-info-btn') }}
<PageInfoIcon />
</Button>
<Button
:click="openEditAppConfig"
v-tooltip="tooltip($t('interactive-editor.menu.edit-app-config-tooltip'))"
>
{{ $t('interactive-editor.menu.edit-app-config-btn') }}
<AppConfigIcon />
</Button>
</div>
<!-- Modals for editing appConfig + pageInfo -->
<EditPageInfo />
<EditAppConfig />
</div>
</template>
<script>
import axios from 'axios';
import jsYaml from 'js-yaml';
import ProgressBar from 'rsup-progress';
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 SaveLocallyIcon from '@/assets/interface-icons/interactive-editor-save-locally.svg';
import SaveToDiskIcon from '@/assets/interface-icons/interactive-editor-save-disk.svg';
import ExportIcon from '@/assets/interface-icons/interactive-editor-export-changes.svg';
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';
export default {
name: 'EditModeSaveMenu',
components: {
Button,
EditPageInfo,
SaveLocallyIcon,
SaveToDiskIcon,
ExportIcon,
CancelIcon,
AppConfigIcon,
PageInfoIcon,
EditAppConfig,
},
computed: {
config() {
return this.$store.state.config;
},
},
data() {
return {
saveSuccess: undefined,
responseText: '',
progress: new ProgressBar({ color: 'var(--progress-bar)' }),
};
},
methods: {
reset() {
this.$store.dispatch(StoreKeys.INITIALIZE_CONFIG);
this.$store.commit(StoreKeys.SET_EDIT_MODE, false);
},
openExportConfigMenu() {
this.$modal.show(modalNames.EXPORT_CONFIG_MENU);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, true);
},
openEditPageInfo() {
this.$modal.show(modalNames.EDIT_PAGE_INFO);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, true);
},
openEditAppConfig() {
this.$modal.show(modalNames.EDIT_APP_CONFIG);
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() {
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);
},
writeToDisk() {
// 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();
});
},
},
};
</script>
<style scoped lang="scss">
@import '@/styles/media-queries.scss';
div.edit-mode-bottom-banner {
position: fixed;
display: grid;
z-index: 5;
bottom: 0;
width: 100%;
padding: 0.25rem 0;
border-top: 2px solid var(--interactive-editor-color);
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 big-screen-up { grid-template-columns: 25% 50% 25%; }
/* Main sections */
.edit-banner-section {
padding: 0.5rem;
height: 100%;
/* Section sub-titles */
p.section-sub-title {
margin: 0;
color: var(--interactive-editor-color);
font-weight: bold;
cursor: default;
}
/* Intro-text container */
&.intro-container {
p.edit-mode-intro {
margin: 0;
color: var(--interactive-editor-color);
cursor: default;
}
}
/* Button containers */
&.edit-site-config-buttons,
&.save-buttons-container {
display: grid;
grid-template-columns: repeat(2, 1fr);
button {
margin: 0.25rem;
height: fit-content;
}
p.section-sub-title {
grid-column-start: span 2;
}
}
&.save-buttons-container {
grid-row-start: span 2;
}
}
/* Mobile layout */
@include tablet-down {
display: flex;
flex-direction: column;
.edit-banner-section,
.edit-banner-section.intro-container {
max-width: 90%;
width: 100%;
margin: 0.2rem auto;
flex-direction: column;
}
}
/* Set colors for buttons */
.edit-banner-section button {
color: var(--interactive-editor-color);
border-color: var(--interactive-editor-color);
background: var(--interactive-editor-background);
&:hover {
color: var(--interactive-editor-background);
border-color: var(--interactive-editor-color);
background: var(--interactive-editor-color);
}
}
}
</style>

View File

@ -0,0 +1,20 @@
<template>
<div class="edit-mode-top-banner">
<span>Edit Mode Enabled</span>
</div>
</template>
<style scoped lang="scss">
div.edit-mode-top-banner {
width: 100%;
text-align: center;
padding: 0.2rem 0;
background: var(--interactive-editor-color);
opacity: var(--dimming-factor);
span {
font-size: 1rem;
font-weight: bold;
color: var(--interactive-editor-background);
}
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<modal
:name="modalName" @closed="modalClosed"
:resizable="true" width="50%" height="80%"
classes="dashy-modal edit-page-info"
>
<div class="edit-page-info-inner">
<h3>{{ $t('interactive-editor.menu.edit-page-info-btn') }}</h3>
<FormSchema
:schema="schema"
v-model="formData"
@submit.prevent="saveToState"
class="page-info-form"
name="pageInfoForm"
>
<Button type="submit">
{{ $t('interactive-editor.menu.save-stage-btn') }}
<SaveIcon />
</Button>
</FormSchema>
</div>
</modal>
</template>
<script>
import FormSchema from '@formschema/native';
import DashySchema from '@/utils/ConfigSchema';
import StoreKeys from '@/utils/StoreMutations';
import { modalNames } from '@/utils/defaults';
import Button from '@/components/FormElements/Button';
import SaveIcon from '@/assets/interface-icons/save-config.svg';
export default {
name: 'EditPageInfo',
data() {
return {
formData: {},
schema: DashySchema.properties.pageInfo,
modalName: modalNames.EDIT_PAGE_INFO,
};
},
components: {
FormSchema,
Button,
SaveIcon,
},
mounted() {
this.formData = this.pageInfo;
},
computed: {
pageInfo() {
return this.$store.getters.pageInfo;
},
},
methods: {
/* When form submitteed, update VueX store with new pageInfo, and close modal */
saveToState() {
this.$store.commit(StoreKeys.SET_PAGE_INFO, 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);
},
},
};
</script>
<style lang="scss">
@import '@/styles/style-helpers.scss';
@import '@/styles/media-queries.scss';
@import '@/styles/schema-editor.scss';
.edit-page-info-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;
}
.page-info-form {
@extend .schema-form;
margin-bottom: 2.5rem;
}
}
</style>

View File

@ -0,0 +1,129 @@
<template>
<modal
:name="modalName" @closed="modalClosed"
:resizable="true" width="50%" height="80%"
classes="dashy-modal edit-section"
>
<div class="edit-section-inner">
<h3>
{{ $t(`interactive-editor.edit-section.${isAddNew ? 'add' : 'edit'}-section-title`) }}
</h3>
<FormSchema
:schema="customSchema"
v-model="sectionData"
name="editSectionForm"
class="edit-section-form"
/>
<SaveCancelButtons
:saveClick="saveSection"
:cancelClick="modalClosed"
/>
</div>
</modal>
</template>
<script>
import FormSchema from '@formschema/native';
import StoreKeys from '@/utils/StoreMutations';
import DashySchema from '@/utils/ConfigSchema';
import { modalNames } from '@/utils/defaults';
import SaveCancelButtons from '@/components/InteractiveEditor/SaveCancelButtons';
export default {
name: 'EditSection',
props: {
sectionIndex: Number,
isAddNew: Boolean,
},
components: {
SaveCancelButtons,
FormSchema,
},
data() {
return {
modalName: modalNames.EDIT_SECTION,
schema: DashySchema.properties.sections.items.properties,
sectionData: {},
};
},
computed: {
/* Make a custom schema object, using fields from ConfigSchema */
customSchema() {
const sectionSchema = this.schema;
const displayDataSchema = this.schema.displayData.properties;
return {
type: 'object',
properties: {
name: sectionSchema.name,
icon: sectionSchema.icon,
displayData: {
title: '',
description: '',
type: 'object',
properties: {
sortBy: displayDataSchema.sortBy,
rows: displayDataSchema.rows,
cols: displayDataSchema.cols,
collapsed: displayDataSchema.collapsed,
hideForGuests: displayDataSchema.hideForGuests,
},
},
},
};
},
},
mounted() {
this.sectionData = this.$store.getters.getSectionByIndex(this.sectionIndex);
this.$modal.show(modalNames.EDIT_SECTION);
},
methods: {
/* From the current index, return section data */
getSectionFromState(index) {
if (this.isAddNew) return {};
return this.$store.getters.getSectionByIndex(index);
},
/* Clean up work, triggered when modal closed */
modalClosed() {
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
this.$emit('closeEditSection');
},
/* Either update existing section, or insert new one, then close modal */
saveSection() {
const { sectionIndex, sectionData } = this;
if (this.isAddNew) {
this.$store.commit(StoreKeys.INSERT_SECTION, sectionData);
} else {
this.$store.commit(StoreKeys.UPDATE_SECTION, { sectionIndex, sectionData });
}
this.$store.commit(StoreKeys.SET_EDIT_MODE, true);
this.$emit('closeEditSection');
},
},
};
</script>
<style lang="scss">
@import '@/styles/style-helpers.scss';
@import '@/styles/media-queries.scss';
@import '@/styles/schema-editor.scss';
.edit-section-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;
}
.edit-section-form {
@extend .schema-form;
margin-bottom: 2.5rem;
}
.edit-section-save-btn {
margin-bottom: 2rem;
}
}
</style>

View File

@ -0,0 +1,127 @@
<template>
<modal
:name="modalName"
:resizable="true"
width="50%"
height="80%"
classes="dashy-modal edit-item"
@closed="modalClosed"
>
<div class="export-config-inner">
<!-- Download and Copy to CLipboard Buttons -->
<h3>{{ $t('interactive-editor.export.export-title') }}</h3>
<div class="download-button-container">
<Button :click="copyConfigToClipboard"
v-tooltip="tooltip($t('interactive-editor.export.copy-clipboard-tooltip'))">
{{ $t('interactive-editor.export.copy-clipboard-btn') }}
<CopyConfigIcon />
</Button>
<Button :click="downloadConfig"
v-tooltip="tooltip($t('interactive-editor.export.download-file-tooltip'))">
{{ $t('interactive-editor.export.download-file-btn') }}
<DownloadConfigIcon />
</Button>
</div>
<!-- View Config in Tree Mode Section -->
<h3>{{ $t('interactive-editor.export.view-title') }}</h3>
<tree-view :data="config" class="config-tree-view" />
</div>
</modal>
</template>
<script>
import JsYaml from 'js-yaml';
import Button from '@/components/FormElements/Button';
import StoreKeys from '@/utils/StoreMutations';
import { modalNames } from '@/utils/defaults';
import DownloadConfigIcon from '@/assets/interface-icons/config-download-file.svg';
import CopyConfigIcon from '@/assets/interface-icons/interactive-editor-copy-clipboard.svg';
import { InfoHandler } from '@/utils/ErrorHandler';
export default {
name: 'ExportConfigMenu',
components: {
Button,
CopyConfigIcon,
DownloadConfigIcon,
},
data() {
return {
modalName: modalNames.EXPORT_CONFIG_MENU,
};
},
props: {},
computed: {
config() {
return this.$store.state.config;
},
},
methods: {
convertJsonToYaml() {
return JsYaml.dump(this.config);
},
downloadConfig() {
const filename = 'dashy_conf.yml';
const config = this.convertJsonToYaml();
const element = document.createElement('a');
element.setAttribute('href', `data:text/plain;charset=utf-8, ${encodeURIComponent(config)}`);
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
InfoHandler('Config downloaded as YAML file', 'Interactive Editor');
},
copyConfigToClipboard() {
const config = this.convertJsonToYaml();
navigator.clipboard.writeText(config);
this.$toasted.show(this.$t('config.data-copied-msg'));
InfoHandler('Config copied to clipboard', 'Interactive Editor');
},
modalClosed() {
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
},
tooltip(content) {
return {
content, trigger: 'hover focus', delay: 250, classes: 'in-modal-tt',
};
},
},
};
</script>
<style lang="scss">
@import '@/styles/style-helpers.scss';
@import '@/styles/media-queries.scss';
.tooltip { z-index: 99; }
.export-config-inner {
padding: 1rem;
background: var(--interactive-editor-background);
color: var(--interactive-editor-color);
height: 100%;
overflow-y: auto;
h3 {
margin: 1rem 0;
}
.download-button-container {
display: flex;
justify-content: center;
padding: 0 0.5rem 1rem;
border-bottom: 1px dashed var(--interactive-editor-color);
button { margin: 0 1rem; }
}
.config-tree-view {
padding: 0.5rem;
font-family: var(--font-monospace);
color: var(--interactive-editor-color);
background: var(--interactive-editor-background-darker);
border-radius: var(--curve-factor);
box-shadow: 0px 0px 3px var(--interactive-editor-color);
margin-bottom: 1.5rem;
span {
font-family: var(--font-monospace);
}
}
}
</style>

View File

@ -0,0 +1,136 @@
<template>
<modal
:name="modalName" @closed="close"
:resizable="true" width="40%" height="40%" classes="dashy-modal">
<div class="move-menu-inner">
<!-- Title and item ID -->
<h3 class="move-title">Move or Copy Item</h3>
<p class="item-id">Editing {{ itemId }}</p>
<!-- Radio, for move or copy -->
<Radio
v-model="operation"
:options="operationRadioOptions"
label="Operation Type"
:initialOption="operation"
/>
<!-- Select destionation section -->
<Select
v-model="selectedSection"
:options="sectionList"
:initialOption="selectedSection"
label="Destination"
/>
<!-- Radio, for choosing append to beginning or end -->
<Radio
v-model="appendTo"
:options="appendToRadioOptions"
label="Append To"
:initialOption="appendTo"
/>
<!-- Save and cancel buttons -->
<SaveCancelButtons :saveClick="save" :cancelClick="close" />
</div>
</modal>
</template>
<script>
import Select from '@/components/FormElements/Select';
import Radio from '@/components/FormElements/Radio';
import SaveCancelButtons from '@/components/InteractiveEditor/SaveCancelButtons';
import StoreKeys from '@/utils/StoreMutations';
import { modalNames } from '@/utils/defaults';
export default {
name: 'MoveItemTo',
components: {
Select,
Radio,
SaveCancelButtons,
},
props: {
itemId: String, // Unique ID for item
initialSection: String, // The current section
},
data() {
return {
selectedSection: '',
operation: 'move',
appendTo: 'end',
modalName: `${modalNames.MOVE_ITEM_TO}-${this.itemId}`,
operationRadioOptions: [
{ label: 'Move', value: 'move' },
{ label: 'Copy', value: 'copy' },
],
appendToRadioOptions: [
{ label: 'Beginning', value: 'beginning' },
{ label: 'End', value: 'end' },
],
};
},
computed: {
sections() {
return this.$store.getters.sections;
},
sectionList() {
return this.sections.map((section) => section.name);
},
currentSection() {
let sectionName = '';
this.sections.forEach((section) => {
section.items.forEach((item) => {
if (item.id === this.itemId) sectionName = section.name;
});
});
return sectionName;
},
},
mounted() {
this.selectedSection = this.currentSection;
},
methods: {
save() {
const item = this.$store.getters.getItemById(this.itemId);
// Copy item to new section
const copyPayload = { item, toSection: this.selectedSection, appendTo: this.appendTo };
this.$store.commit(StoreKeys.COPY_ITEM, copyPayload);
// Remove item from previous section
if (this.operation === 'move') {
const payload = { itemId: this.itemId, sectionName: this.currentSection };
this.$store.commit(StoreKeys.REMOVE_ITEM, payload);
}
this.close();
},
close() {
this.$modal.hide(this.modalName);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
},
},
};
</script>
<style scoped lang="scss">
.move-menu-inner {
padding: 1rem;
background: var(--interactive-editor-background);
color: var(--interactive-editor-color);
height: 100%;
overflow-y: auto;
h3.move-title {
margin: 0.25rem 0;
}
p.item-id {
font-size: 1rem;
font-style: italic;
margin: 0.25rem 0;
opacity: var(--dimming-factor);
}
.button-wrapper {
display: flex;
width: fit-content;
margin: 1.5rem auto;
button {
margin: 0 0.5rem;
}
}
}
</style>

View File

@ -0,0 +1,61 @@
<template>
<div class="save-cancel-btn-container">
<Button class="save-app-config-btn" :click="saveClick">
{{ $t('interactive-editor.menu.save-stage-btn') }}
<SaveIcon />
</Button>
<Button class="save-app-config-btn" :click="cancelClick">
{{ $t('interactive-editor.menu.cancel-stage-btn') }}
<CancelIcon />
</Button>
</div>
</template>
<script>
import Button from '@/components/FormElements/Button';
import SaveIcon from '@/assets/interface-icons/save-config.svg';
import CancelIcon from '@/assets/interface-icons/config-close.svg';
export default {
name: 'SaveCancelButton',
props: {
saveClick: Function,
cancelClick: Function,
},
components: {
Button,
SaveIcon,
CancelIcon,
},
};
</script>
<style scoped lang="scss">
.save-cancel-btn-container {
display: flex;
margin: 0.5rem 0;
justify-content: center;
border-top: 1px dashed var(--interactive-editor-color);
button {
margin: 1rem 0.5rem;
color: var(--interactive-editor-color);
border-color: var(--interactive-editor-color);
background: var(--interactive-editor-background);
svg {
border: none;
width: 1.2rem;
height: 1.2rem;
}
&:hover {
color: var(--interactive-editor-background);
border-color: var(--interactive-editor-color);
background: var(--interactive-editor-color);
svg {
background: var(--interactive-editor-color);
path { fill: var(--interactive-editor-background); }
}
}
}
}
</style>

View File

@ -4,16 +4,19 @@
:style="`${color ? 'background: '+color : ''}; ${sanitizeCustomStyles(customStyles)};`"
>
<input
:id="`collapsible-${uniqueKey}`"
:id="sectionKey"
class="toggle"
type="checkbox"
:checked="getCollapseState()"
@change="collapseChanged"
tabIndex="-1"
>
<label :for="`collapsible-${uniqueKey}`" class="lbl-toggle" tabindex="-1">
<label :for="sectionKey" class="lbl-toggle" tabindex="-1"
@mouseup.right="openContextMenu" @contextmenu.prevent>
<Icon v-if="icon" :icon="icon" size="small" :url="title" class="section-icon" />
<h3>{{ title }}</h3>
<EditModeIcon v-if="isEditMode" @click="openEditModal"
v-tooltip="editTooltip()" class="edit-mode-item" />
</label>
<div class="collapsible-content">
<div class="content-inner">
@ -27,6 +30,7 @@
import { localStorageKeys } from '@/utils/defaults';
import Icon from '@/components/LinkItems/ItemIcon.vue';
import EditModeIcon from '@/assets/interface-icons/interactive-editor-edit-mode.svg';
export default {
name: 'CollapsableContainer',
@ -42,6 +46,16 @@ export default {
},
components: {
Icon,
EditModeIcon,
},
computed: {
isEditMode() {
return this.$store.state.editMode;
},
sectionKey() {
if (this.isEditMode) return undefined;
return `collapsible-${this.uniqueKey}`;
},
},
methods: {
/* Check that row & column span is valid, and not over the max */
@ -95,6 +109,16 @@ export default {
this.setCollapseState(this.uniqueKey.toString(), whatChanged.srcElement.checked);
}
},
openEditModal() {
this.$emit('openEditSection');
},
openContextMenu(e) {
this.$emit('openContextMenu', e);
},
editTooltip() {
const content = this.$t('interactive-editor.edit-section.edit-tooltip');
return { content, trigger: 'hover focus', delay: { show: 100, hide: 0 } };
},
},
};
</script>
@ -207,5 +231,13 @@ export default {
.collapsible-content .content-inner {
padding: 0.5rem;
}
.edit-mode-item {
width: 1rem;
height: 1rem;
float: right;
right: 0.5rem;
top: 0.5rem;
}
}
</style>

View File

@ -5,7 +5,7 @@
@contextmenu.prevent
:href="hyperLinkHref"
:target="anchorTarget"
:class="`item ${!icon? 'short': ''} size-${itemSize}`"
:class="`item ${!icon? 'short': ''} size-${itemSize} ${isAddNew ? 'add-new' : ''}`"
v-tooltip="getTooltipOptions()"
rel="noopener noreferrer" tabindex="0"
:id="`link-${id}`"
@ -30,15 +30,23 @@
:statusSuccess="statusResponse ? statusResponse.successStatus : undefined"
:statusText="statusResponse ? statusResponse.message : undefined"
/>
<EditModeIcon v-if="isEditMode" class="edit-mode-item" @click="openItemSettings()" />
</a>
<ContextMenu
:show="contextMenuOpen"
:show="contextMenuOpen && !isAddNew"
v-click-outside="closeContextMenu"
:posX="contextPos.posX"
:posY="contextPos.posY"
:id="`context-menu-${id}`"
@contextItemClick="contextItemClick"
@launchItem="launchItem"
@openItemSettings="openItemSettings"
@openMoveItemMenu="openMoveItemMenu"
@openDeleteItem="openDeleteItem"
/>
<MoveItemTo v-if="isEditMode" :itemId="id" />
<EditItem v-if="editMenuOpen" :itemId="id"
@closeEditMenu="closeEditMenu"
:isNew="isAddNew" :parentSectionTitle="parentSectionTitle" />
</div>
</template>
@ -48,13 +56,18 @@ import router from '@/router';
import Icon from '@/components/LinkItems/ItemIcon.vue';
import ItemOpenMethodIcon from '@/components/LinkItems/ItemOpenMethodIcon';
import StatusIndicator from '@/components/LinkItems/StatusIndicator';
import ContextMenu from '@/components/LinkItems/ContextMenu';
import EditItem from '@/components/InteractiveEditor/EditItem';
import MoveItemTo from '@/components/InteractiveEditor/MoveItemTo';
import ContextMenu from '@/components/LinkItems/ItemContextMenu';
import StoreKeys from '@/utils/StoreMutations';
import { targetValidator } from '@/utils/ConfigHelpers';
import EditModeIcon from '@/assets/interface-icons/interactive-editor-edit-mode.svg';
import {
localStorageKeys,
serviceEndpoints,
modalNames,
openingMethod as defaultOpeningMethod,
} from '@/utils/defaults';
import { targetValidator } from '@/utils/ConfigHelpers';
export default {
name: 'Item',
@ -73,22 +86,37 @@ export default {
type: String,
validator: targetValidator,
},
itemSize: String,
enableStatusCheck: Boolean,
statusCheckHeaders: Object,
statusCheckUrl: String,
statusCheckInterval: Number,
statusCheckAllowInsecure: Boolean,
itemSize: String, // Item size: small | medium | large
enableStatusCheck: Boolean, // Should run status checks
statusCheckHeaders: Object, // Custom status check headers
statusCheckUrl: String, // Custom URL for status check endpoint
statusCheckInterval: Number, // Num seconds beteween repeating checks
statusCheckAllowInsecure: Boolean, // Status check ignore SSL certs
parentSectionTitle: String, // Title of parent section (for add new)
isAddNew: Boolean, // Only set if 'fake' item used as Add New button
},
components: {
Icon,
ItemOpenMethodIcon,
StatusIndicator,
ContextMenu,
MoveItemTo,
EditItem,
EditModeIcon,
},
computed: {
appConfig() {
return this.$store.getters.appConfig;
},
isEditMode() {
return this.$store.state.editMode;
},
accumulatedTarget() {
return this.target || this.appConfig.defaultOpeningMethod || defaultOpeningMethod;
},
/* Convert config target value, into HTML anchor target attribute */
anchorTarget() {
if (this.isEditMode) return '_self';
const target = this.accumulatedTarget;
switch (target) {
case 'sametab': return '_self';
@ -98,10 +126,12 @@ export default {
default: return undefined;
}
},
/* Get the href value for the anchor, if not opening in modal/ workspace */
/* Get href for anchor, if not in edit mode, or opening in modal/ workspace */
hyperLinkHref() {
const nothing = '#';
if (this.isEditMode) return nothing;
const noAnchorNeeded = ['modal', 'workspace'];
return noAnchorNeeded.includes(this.accumulatedTarget) ? '#' : this.url;
return noAnchorNeeded.includes(this.accumulatedTarget) ? nothing : this.url;
},
},
data() {
@ -117,17 +147,17 @@ export default {
posX: undefined,
posY: undefined,
},
editMenuOpen: false,
};
},
components: {
Icon,
ItemOpenMethodIcon,
StatusIndicator,
ContextMenu,
},
methods: {
/* Called when an item is clicked, manages the opening of modal & resets the search field */
itemOpened(e) {
if (this.isEditMode) {
// If in edit mode, open settings, and don't launch app
this.openItemSettings();
return;
}
if (e.altKey || this.accumulatedTarget === 'modal') {
e.preventDefault();
this.$emit('triggerModal', this.url);
@ -164,8 +194,10 @@ export default {
const providerText = this.provider ? `<b>Provider</b>: ${this.provider}` : '';
const lb1 = description && providerText ? '<br>' : '';
const hotkeyText = this.hotkey ? `<br>Press '${this.hotkey}' to launch` : '';
const tooltipText = providerText + lb1 + description + hotkeyText;
const editText = this.$t('interactive-editor.edit-section.edit-tooltip');
return {
content: providerText + lb1 + description + hotkeyText,
content: (this.isEditMode ? editText : tooltipText),
trigger: 'hover focus',
hideOnTargetClick: true,
html: true,
@ -220,7 +252,7 @@ export default {
});
},
/* Handle navigation options from the context menu */
contextItemClick(method) {
launchItem(method) {
const { url } = this;
this.contextMenuOpen = false;
switch (method) {
@ -239,6 +271,19 @@ export default {
default: window.open(url, '_blank');
}
},
/* Open the Edit Item moal form */
openItemSettings() {
this.editMenuOpen = true;
this.contextMenuOpen = false;
this.$modal.show(modalNames.EDIT_ITEM);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, true);
},
/* Ensure conditional is updated, once menu closed */
closeEditMenu() {
this.editMenuOpen = false;
this.$modal.hide(modalNames.EDIT_ITEM);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
},
/* Used for smart-sort when sorting items by most used apps */
incrementMostUsedCount(itemId) {
const mostUsed = JSON.parse(localStorage.getItem(localStorageKeys.MOST_USED) || '{}');
@ -253,6 +298,19 @@ export default {
lastUsed[itemId] = new Date().getTime();
localStorage.setItem(localStorageKeys.LAST_USED, JSON.stringify(lastUsed));
},
/* Open the modal for moving/ copying item to other section */
openMoveItemMenu() {
this.$modal.show(`${modalNames.MOVE_ITEM_TO}-${this.id}`);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, true);
this.closeContextMenu();
},
/* Deletes the current item from the state */
openDeleteItem() {
const parentSection = this.$store.getters.getParentSectionOfItem(this.id);
const payload = { itemId: this.id, sectionName: parentSection.name };
this.$store.commit(StoreKeys.REMOVE_ITEM, payload);
this.closeContextMenu();
},
},
mounted() {
// If ststus checking is enabled, then check service status
@ -306,6 +364,9 @@ export default {
&.short:not(.size-large) {
height: 2rem;
}
&.add-new {
border: 2px dashed var(--primary) !important;
}
}
/* Text in tile */
@ -360,6 +421,15 @@ export default {
}
}
/* Edit Mode Icon */
.item .edit-mode-item {
width: 1rem;
height: 1rem;
position: absolute;
top: 0.2rem;
right: 0.2rem;
}
/* Specify layout for alternate sized icons */
.item {
/* Small Tile Specific Themes */

View File

@ -0,0 +1,156 @@
<template>
<transition name="slide">
<div class="context-menu" v-if="show && !isMenuDisabled"
:style="posX && posY ? `top:${posY}px;left:${posX}px;` : ''">
<!-- Open Options -->
<ul class="menu-section">
<li class="section-title">
{{ $t('context-menus.item.open-section-title') }}
</li>
<li @click="launch('sametab')">
<SameTabOpenIcon />
<span>{{ $t('context-menus.item.sametab') }}</span>
</li>
<li @click="launch('newtab')">
<NewTabOpenIcon />
<span>{{ $t('context-menus.item.newtab') }}</span>
</li>
<li @click="launch('modal')">
<IframeOpenIcon />
<span>{{ $t('context-menus.item.modal') }}</span>
</li>
<li @click="launch('workspace')">
<WorkspaceOpenIcon />
<span>{{ $t('context-menus.item.workspace') }}</span>
</li>
</ul>
<!-- Edit Options -->
<ul class="menu-section">
<li class="section-title">
{{ $t('context-menus.item.options-section-title') }}
</li>
<li @click="openSettings()">
<EditIcon />
<span>{{ $t('context-menus.item.edit-item') }}</span>
</li>
<li v-if="isEditMode" @click="openMoveMenu()">
<MoveIcon />
<span>{{ $t('context-menus.item.move-item') }}</span>
</li>
<li v-if="isEditMode" @click="openDeleteItem()">
<BinIcon />
<span>{{ $t('context-menus.item.remove-item') }}</span>
</li>
</ul>
</div>
</transition>
</template>
<script>
// Import icons for each element
import EditIcon from '@/assets/interface-icons/config-edit-json.svg';
import BinIcon from '@/assets/interface-icons/interactive-editor-remove.svg';
import MoveIcon from '@/assets/interface-icons/interactive-editor-move-to.svg';
import SameTabOpenIcon from '@/assets/interface-icons/open-current-tab.svg';
import NewTabOpenIcon from '@/assets/interface-icons/open-new-tab.svg';
import IframeOpenIcon from '@/assets/interface-icons/open-iframe.svg';
import WorkspaceOpenIcon from '@/assets/interface-icons/open-workspace.svg';
export default {
name: 'ContextMenu',
components: {
EditIcon,
MoveIcon,
BinIcon,
SameTabOpenIcon,
NewTabOpenIcon,
IframeOpenIcon,
WorkspaceOpenIcon,
},
props: {
posX: Number, // The X coordinate for positioning
posY: Number, // The Y coordinate for positioning
show: Boolean, // Should show or hide the menu
},
computed: {
isMenuDisabled() {
return !!this.$store.getters.appConfig.disableContextMenu;
},
isEditMode() {
return this.$store.state.editMode;
},
},
methods: {
/* Called on item click, emits an event up to Item */
/* in order to launch the current app to a given target */
launch(target) {
this.$emit('launchItem', target);
},
openSettings() {
this.$emit('openItemSettings');
},
openMoveMenu() {
this.$emit('openMoveItemMenu');
},
openDeleteItem() {
this.$emit('openDeleteItem');
},
},
};
</script>
<style lang="scss">
div.context-menu {
position: absolute;
margin: 0;
padding: 0;
z-index: 8;
background: var(--context-menu-background);
color: var(--context-menu-color);
border: 1px solid var(--context-menu-secondary-color);
border-radius: var(--curve-factor);
box-shadow: var(--context-menu-shadow);
opacity: 0.98;
ul.menu-section {
list-style-type: none;
margin: 0;
padding: 0;
&:not(:last-child) {
border-bottom: 1px solid var(--context-menu-color);
}
li {
cursor: pointer;
padding: 0.5rem 1rem;
display: flex;
flex-direction: row;
font-size: 1rem;
&:not(:last-child) {
border-bottom: 1px solid var(--context-menu-secondary-color);
}
&:hover:not(.section-title) {
background: var(--context-menu-secondary-color);
}
&.section-title {
cursor: default;
font-weight: bold;
justify-content: center;
}
svg {
width: 1rem;
margin-right: 0.5rem;
path { fill: currentColor; }
}
}
}
}
// Define enter and leave transitions
.slide-enter-active { animation: slide-in .1s; }
.slide-leave-active { animation: slide-in .1s reverse; }
@keyframes slide-in {
0% { transform: scaleY(0.5) scaleX(0.8) translateY(-50px); }
100% { transform: scaleY(1) translateY(0) translateY(0); }
}
</style>

View File

@ -8,18 +8,22 @@
:rows="displayData.rows"
:color="displayData.color"
:customStyles="displayData.customStyles"
@openEditSection="openEditSection"
@openContextMenu="openContextMenu"
>
<div v-if="!items || items.length < 1" class="no-items">
<!-- If no items, show message -->
<div v-if="(!items || items.length < 1) && !isEditMode" class="no-items">
No Items to Show Yet
</div>
<!-- Item Container -->
<div v-else
:class="`there-are-items ${isGridLayout? 'item-group-grid': ''} inner-size-${itemSize}`"
:style="gridStyle"
>
:style="gridStyle" :id="`section-${groupId}`"
> <!-- Show for each item -->
<Item
v-for="(item, index) in sortedItems"
:id="makeId(title, item.title, index)"
:key="makeId(title, item.title, index)"
v-for="(item) in sortedItems"
:id="item.id"
:key="item.id"
:url="item.url"
:title="item.title"
:description="item.description"
@ -32,28 +36,69 @@
:itemSize="newItemSize"
:hotkey="item.hotkey"
:provider="item.provider"
:parentSectionTitle="title"
:enableStatusCheck="shouldEnableStatusCheck(item.statusCheck)"
:statusCheckInterval="getStatusCheckInterval()"
:statusCheckAllowInsecure="item.statusCheckAllowInsecure"
@itemClicked="$emit('itemClicked')"
@triggerModal="triggerModal"
:isAddNew="false"
/>
<!-- When in edit mode, show additional item, for Add New item -->
<Item v-if="isEditMode"
:isAddNew="true"
:parentSectionTitle="title"
icon=":heavy_plus_sign:"
id="add-new"
title="Add New Item"
description="Click to add new item"
key="add-new"
class="add-new-item"
:itemSize="newItemSize"
/>
<div ref="modalContainer"></div>
</div>
<!-- Modal for opening in modal view -->
<IframeModal
:ref="`iframeModal-${groupId}`"
:name="`iframeModal-${groupId}`"
@closed="$emit('itemClicked')"
/>
<!-- Edit item menu -->
<EditSection
v-if="editMenuOpen"
@closeEditSection="closeEditSection"
:sectionIndex="index"
:isAddNew="false"
/>
<!-- Right-click item options context menu -->
<ContextMenu
:show="contextMenuOpen"
:posX="contextPos.posX"
:posY="contextPos.posY"
:id="`context-menu-${groupId}`"
v-click-outside="closeContextMenu"
@openEditSection="openEditSection"
@navigateToSection="navigateToSection"
@removeSection="removeSection"
/>
</Collapsable>
</template>
<script>
import { sortOrder as defaultSortOrder, localStorageKeys } from '@/utils/defaults';
import ErrorHandler from '@/utils/ErrorHandler';
import router from '@/router';
import Item from '@/components/LinkItems/Item.vue';
import Collapsable from '@/components/LinkItems/Collapsable.vue';
import IframeModal from '@/components/LinkItems/IframeModal.vue';
import EditSection from '@/components/InteractiveEditor/EditSection.vue';
import ContextMenu from '@/components/LinkItems/SectionContextMenu.vue';
import ErrorHandler from '@/utils/ErrorHandler';
import StoreKeys from '@/utils/StoreMutations';
import {
sortOrder as defaultSortOrder,
localStorageKeys,
modalNames,
} from '@/utils/defaults';
export default {
name: 'Section',
@ -64,11 +109,24 @@ export default {
displayData: Object,
items: Array,
itemSize: String,
index: Number,
},
components: {
Collapsable,
ContextMenu,
Item,
IframeModal,
EditSection,
},
data() {
return {
editMenuOpen: false,
contextMenuOpen: false,
contextPos: {
posX: undefined,
posY: undefined,
},
};
},
computed: {
appConfig() {
@ -113,14 +171,11 @@ export default {
}
return styles;
},
isEditMode() {
return this.$store.state.editMode;
},
},
methods: {
/* Returns a unique lowercase string, based on name, for section ID */
makeId(sectionStr, itemStr, index) {
const charSum = sectionStr.split('').map((a) => a.charCodeAt(0)).reduce((x, y) => x + y);
const itemTitleStr = itemStr.replace(/\s+/g, '-').replace(/[^a-zA-Z ]/g, '').toLowerCase();
return `${index}_${charSum}_${itemTitleStr}`;
},
/* Opens the iframe modal */
triggerModal(url) {
this.$refs[`iframeModal-${this.groupId}`].show(url);
@ -145,14 +200,14 @@ export default {
/* Sorts items by most used to least used, based on click-count */
sortByMostUsed(items) {
const usageCount = JSON.parse(localStorage.getItem(localStorageKeys.MOST_USED) || '{}');
const gmu = (item) => usageCount[this.makeId(this.title, item.title)] || 0;
const gmu = (item) => usageCount[item.id] || 0;
items.reverse().sort((a, b) => (gmu(a) < gmu(b) ? 1 : -1));
return items;
},
/* Sorts items by most recently used */
sortBLastUsed(items) {
const usageCount = JSON.parse(localStorage.getItem(localStorageKeys.LAST_USED) || '{}');
const glu = (item) => usageCount[this.makeId(this.title, item.title)] || 0;
const glu = (item) => usageCount[item.id] || 0;
items.reverse().sort((a, b) => (glu(a) < glu(b) ? 1 : -1));
return items;
},
@ -163,6 +218,50 @@ export default {
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value);
},
/* Navigate to the section's single-section view page */
navigateToSection() {
const parse = (section) => section.replace(' ', '-').toLowerCase().trim();
const sectionIdentifier = parse(this.title);
router.push({ path: `/home/${sectionIdentifier}` });
this.closeContextMenu();
},
/* Open the Section Edit Menu */
openEditSection() {
this.editMenuOpen = true;
this.$modal.show(modalNames.EDIT_SECTION);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, true);
this.closeContextMenu();
},
/* Close the section edit menu */
closeEditSection() {
this.editMenuOpen = false;
this.$modal.hide(modalNames.EDIT_SECTION);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
},
/* Deletes current section, in local state */
removeSection() {
const confirmMsg = this.$t('interactive-editor.edit-section.remove-confirm');
const youSure = confirm(confirmMsg); // eslint-disable-line no-alert, no-restricted-globals
if (youSure) {
const payload = { sectionIndex: this.index, sectionName: this.title };
this.$store.commit(StoreKeys.REMOVE_SECTION, payload);
}
this.closeContextMenu();
},
/* Open custom context menu, and set position */
openContextMenu(e) {
this.contextMenuOpen = true;
if (e && window) {
this.contextPos = {
posX: e.clientX + window.pageXOffset,
posY: e.clientY + window.pageYOffset,
};
}
},
/* Hide the right-click context menu */
closeContextMenu() {
this.contextMenuOpen = false;
},
},
};
</script>
@ -225,4 +324,10 @@ export default {
}
}
.add-new-item {
display: flex;
a {
border-style: dashed;
}
}
</style>

View File

@ -1,23 +1,20 @@
<template>
<transition name="slide">
<div class="context-menu" v-if="show && !isMenuDisabled()"
<div class="context-menu" v-if="show && !isMenuDisabled"
:style="posX && posY ? `top:${posY}px;left:${posX}px;` : ''">
<ul>
<li @click="launch('sametab')">
<!-- Open Options -->
<ul class="menu-section">
<li @click="openSection()">
<SameTabOpenIcon />
<span>{{ $t('menu.sametab') }}</span>
<span>{{ $t('context-menus.section.open-section') }}</span>
</li>
<li @click="launch('newtab')">
<NewTabOpenIcon />
<span>{{ $t('menu.newtab') }}</span>
<li @click="openEditSectionMenu">
<EditIcon />
<span>{{ $t('context-menus.section.edit-section') }}</span>
</li>
<li @click="launch('modal')">
<IframeOpenIcon />
<span>{{ $t('menu.modal') }}</span>
</li>
<li @click="launch('workspace')">
<WorkspaceOpenIcon />
<span>{{ $t('menu.workspace') }}</span>
<li v-if="isEditMode" @click="removeSection">
<BinIcon />
<span>{{ $t('context-menus.section.remove-section') }}</span>
</li>
</ul>
</div>
@ -26,18 +23,16 @@
<script>
// Import icons for each element
import EditIcon from '@/assets/interface-icons/config-edit-json.svg';
import BinIcon from '@/assets/interface-icons/interactive-editor-remove.svg';
import SameTabOpenIcon from '@/assets/interface-icons/open-current-tab.svg';
import NewTabOpenIcon from '@/assets/interface-icons/open-new-tab.svg';
import IframeOpenIcon from '@/assets/interface-icons/open-iframe.svg';
import WorkspaceOpenIcon from '@/assets/interface-icons/open-workspace.svg';
export default {
name: 'ContextMenu',
components: {
EditIcon,
BinIcon,
SameTabOpenIcon,
NewTabOpenIcon,
IframeOpenIcon,
WorkspaceOpenIcon,
},
props: {
posX: Number, // The X coordinate for positioning
@ -45,25 +40,30 @@ export default {
show: Boolean, // Should show or hide the menu
},
computed: {
appConfig() {
return this.$store.getters.appConfig;
isMenuDisabled() {
return !!this.$store.getters.appConfig.disableContextMenu;
},
isEditMode() {
return this.$store.state.editMode;
},
},
methods: {
/* Called on item click, emits an event up to Item */
/* in order to launch the current app to a given target */
launch(target) {
this.$emit('contextItemClick', target);
openSection() {
this.$emit('navigateToSection');
},
/* Checks if the user as disabled context menu in config */
isMenuDisabled() {
return !!this.appConfig.disableContextMenu;
openEditSectionMenu() {
this.$emit('openEditSection');
},
removeSection() {
this.$emit('removeSection');
},
},
};
</script>
<style lang="scss">
<style scoped lang="scss">
div.context-menu {
position: absolute;
@ -77,10 +77,13 @@ div.context-menu {
box-shadow: var(--context-menu-shadow);
opacity: 0.98;
ul {
ul.menu-section {
list-style-type: none;
margin: 0;
padding: 0;
&:not(:last-child) {
border-bottom: 1px solid var(--context-menu-color);
}
li {
cursor: pointer;
padding: 0.5rem 1rem;
@ -90,9 +93,6 @@ div.context-menu {
&:not(:last-child) {
border-bottom: 1px solid var(--context-menu-secondary-color);
}
&:hover {
background: var(--context-menu-secondary-color);
}
svg {
width: 1rem;
margin-right: 0.5rem;

View File

@ -1,14 +1,23 @@
<template>
<router-link to="/" class="page-titles">
<router-link to="/" class="page-titles" :disabled="isEditMode">
<!-- Optional page logo image -->
<img v-if="logo" :src="logo" class="site-logo" />
<!-- Page heading and sub-heading -->
<div class="text">
<h1>{{ title }}</h1>
<span class="subtitle">{{ description }}</span>
<h1>{{ title }}</h1>
<span class="subtitle">{{ description }}</span>
</div>
<!-- When in edit mode, show Edit Title button -->
<EditModeIcon v-if="isEditMode" @click="editTitle()"
class="edit-icon" v-tooltip="tooltip()" />
</router-link>
</template>
<script>
import EditModeIcon from '@/assets/interface-icons/interactive-editor-edit-mode.svg';
import StoreKeys from '@/utils/StoreMutations';
import { modalNames } from '@/utils/defaults';
export default {
name: 'PageTitle',
props: {
@ -16,6 +25,26 @@ export default {
description: String,
logo: String,
},
components: {
EditModeIcon,
},
computed: {
isEditMode() {
return this.$store.state.editMode;
},
},
methods: {
/* On edit button click, open the edit pageInfo modal */
editTitle() {
this.$modal.show(modalNames.EDIT_PAGE_INFO);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, true);
},
/* Edit button tooltip */
tooltip() {
const content = this.$t('interactive-editor.menu.edit-page-info-btn');
return { content, trigger: 'hover focus', delay: 250 };
},
},
};
</script>
@ -28,6 +57,7 @@ export default {
flex-direction: row;
align-items: center;
text-decoration: none;
position: relative;
h1 {
color: var(--heading-text-color);
font-size: 2.5rem;
@ -49,5 +79,21 @@ export default {
text-align: center;
padding: 0.25rem 0;
}
&[disabled] {
cursor: default;
}
svg.edit-icon {
width: 1rem;
height: 1rem;
right: 1rem;
top: 0.5rem;
padding: 0.25rem;
margin: 0.25rem;
cursor: pointer;
border: 1px solid var(--background-darker);
border-radius: var(--curve-factor);
path { fill: var(--primary); }
&:hover { border: 1px solid var(--primary); }
}
}
</style>

View File

@ -5,7 +5,10 @@
<div class="config-buttons">
<IconSpanner @click="showEditor()" tabindex="-2"
v-tooltip="tooltip($t('settings.config-launcher-tooltip'))" />
<IconViewMode @click="openChangeViewMenu()" tabindex="-2"
<IconInteractiveEditor @click="startInteractiveEditor()" tabindex="-2"
v-tooltip="tooltip(enterEditModeTooltip)"
:class="isEditMode ? 'disabled' : ''" />
<IconViewMode @click="openChangeViewMenu()" tabindex="-2"
v-tooltip="tooltip($t('alternate-views.alternate-view-heading'))" />
</div>
@ -44,12 +47,14 @@
</template>
<script>
// Import components, and store-key identifiers
import ConfigContainer from '@/components/Configuration/ConfigContainer';
import LanguageSwitcher from '@/components/Settings/LanguageSwitcher';
import { topLevelConfKeys, localStorageKeys, modalNames } from '@/utils/defaults';
import Keys from '@/utils/StoreMutations';
import { topLevelConfKeys, localStorageKeys, modalNames } from '@/utils/defaults';
// 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';
@ -67,6 +72,7 @@ export default {
ConfigContainer,
LanguageSwitcher,
IconSpanner,
IconInteractiveEditor,
IconViewMode,
IconHome,
IconWorkspaceView,
@ -82,6 +88,16 @@ export default {
pageInfo() {
return this.$store.getters.pageInfo;
},
isEditMode() {
return this.$store.state.editMode;
},
/* Tooltip text for Edit Mode button, to change depending on it in edit mode */
enterEditModeTooltip() {
return this.$t(
`interactive-editor.menu.${this.isEditMode
? 'edit-mode-subtitle' : 'start-editing-tooltip'}`,
);
},
},
methods: {
showEditor: function show() {
@ -109,34 +125,24 @@ export default {
closeViewSwitcher() {
this.viewSwitcherOpen = false;
},
startInteractiveEditor() {
if (!this.isEditMode) {
this.$store.commit(Keys.SET_EDIT_MODE, true);
}
},
},
};
</script>
<style scoped lang="scss">
@import '@/styles/style-helpers.scss';
.config-options {
@extend .svg-button;
display: flex;
flex-direction: column;
color: var(--settings-text-color);
min-width: 3.2rem;
svg {
path {
fill: var(--settings-text-color);
}
width: 1rem;
height: 1rem;
margin: 0.2rem;
padding: 0.2rem;
text-align: center;
background: var(--background);
border: 1px solid currentColor;
border-radius: var(--curve-factor);
cursor: pointer;
&:hover, &.selected {
background: var(--settings-text-color);
path { fill: var(--background); }
}
}
}
.view-switcher {

View File

@ -2,45 +2,48 @@
<div :class="`theme-configurator-wrapper ${showingAllVars ? 'showing-all' : ''}`">
<h3 class="configurator-title">{{ $t('theme-maker.title') }}</h3>
<div class="color-row-container">
<div class="color-row" v-for="colorName in Object.keys(customColors)" :key="colorName">
<label :for="`color-input-${colorName}`" class="color-name">
{{colorName.replaceAll('-', ' ')}}
</label>
<v-swatches
v-if="isColor(colorName, customColors[colorName])"
v-model="customColors[colorName]"
show-fallback
fallback-input-type="color"
popover-x="left"
:swatches="swatches"
@input="setVariable(colorName, customColors[colorName])"
>
<input
:id="`color-input-${colorName}`"
slot="trigger"
:value="customColors[colorName]"
class="swatch-input form__input__element"
readonly
:style="makeSwatchStyles(colorName)"
/>
</v-swatches>
<input v-else
:id="`color-input-${colorName}`"
:value="customColors[colorName]"
class="misc-input"
@input="setVariable(colorName, customColors[colorName])"
/>
</div> <!-- End of color list -->
<!-- Show color swatch input for each color -->
<div class="color-row" v-for="colorName in Object.keys(customColors)" :key="colorName">
<label :for="`color-input-${colorName}`" class="color-name">
{{colorName.replaceAll('-', ' ')}}
</label>
<v-swatches
v-if="isColor(colorName, customColors[colorName])"
v-model="customColors[colorName]"
show-fallback
fallback-input-type="color"
popover-x="left"
:swatches="swatches"
@input="setVariable(colorName, customColors[colorName])"
>
<input
:id="`color-input-${colorName}`"
slot="trigger"
:value="customColors[colorName]"
class="swatch-input form__input__element"
readonly
:style="makeSwatchStyles(colorName)"
/>
</v-swatches>
<input v-else
:id="`color-input-${colorName}`"
:value="customColors[colorName]"
class="misc-input"
@input="setVariable(colorName, customColors[colorName])"
/>
</div> <!-- End of color list -->
</div>
<!-- More options: Export, Reset, Show all -->
<p @click="exportToClipboard" class="action-text-btn">
{{ $t('theme-maker.export-button') }}
</p>
<p @click="resetAndSave" class="action-text-btn show-all-vars-btn">
<p @click="resetAndSave" class="action-text-btn">
{{ $t('theme-maker.reset-button') }} '{{ themeToEdit }}'
</p>
<p @click="findAllVariableNames" class="action-text-btn">
<p @click="findAllVariableNames" class="action-text-btn show-all-vars-btn">
{{ $t('theme-maker.show-all-button') }}
</p>
<!-- Save and cancel buttons -->
<div class="action-buttons">
<Button :click="saveChanges">
<SaveIcon /> {{ $t('theme-maker.save-button') }}
@ -55,8 +58,8 @@
<script>
import VSwatches from 'vue-swatches';
import 'vue-swatches/dist/vue-swatches.css';
import StoreKeys from '@/utils/StoreMutations';
import { localStorageKeys, mainCssVars, swatches } from '@/utils/defaults';
import Button from '@/components/FormElements/Button';
import SaveIcon from '@/assets/interface-icons/save-config.svg';
import CancelIcon from '@/assets/interface-icons/config-cancel.svg';
@ -88,11 +91,12 @@ export default {
setVariable(variable, value) {
document.documentElement.style.setProperty(`--${variable}`, value);
},
/* Saves the users omdified variables in local storage */
/* Updates browser storage, and srore with new color settings, and shows success msg */
saveChanges() {
const priorSettings = JSON.parse(localStorage[localStorageKeys.CUSTOM_COLORS] || '{}');
priorSettings[this.themeToEdit] = this.customColors;
localStorage.setItem(localStorageKeys.CUSTOM_COLORS, JSON.stringify(priorSettings));
this.$store.commit(StoreKeys.SET_CUSTOM_COLORS, priorSettings);
this.$toasted.show(this.$t('theme-maker.saved-toast', { theme: this.themeToEdit }));
this.$emit('closeThemeConfigurator');
},

View File

@ -25,6 +25,7 @@
</template>
<script>
import StoreKeys from '@/utils/StoreMutations';
import IconSmall from '@/assets/interface-icons/icon-size-small.svg';
import IconMedium from '@/assets/interface-icons/icon-size-medium.svg';
import IconLarge from '@/assets/interface-icons/icon-size-large.svg';
@ -46,7 +47,7 @@ export default {
},
methods: {
updateIconSize(iconSize) {
this.$emit('iconSizeUpdated', iconSize);
this.$store.commit(StoreKeys.SET_ITEM_SIZE, iconSize);
},
tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 };

View File

@ -56,7 +56,7 @@ export default {
/* The ISO code for the users language, synced with VueX store */
savedLanguage: {
get() {
return this.getIsoFromLangObj(this.$store.state.lang);
return this.getIsoFromLangObj(this.$store.getters.appConfig.lang);
},
set(newLang) {
this.$store.commit(Keys.SET_LANGUAGE, newLang.code);

View File

@ -25,17 +25,13 @@
</template>
<script>
import StoreKeys from '@/utils/StoreMutations';
import IconDeafault from '@/assets/interface-icons/layout-default.svg';
import IconHorizontal from '@/assets/interface-icons/layout-horizontal.svg';
import IconVertical from '@/assets/interface-icons/layout-vertical.svg';
export default {
name: 'LayoutSelector',
data() {
return {
input: '',
};
},
props: {
displayLayout: String,
},
@ -46,7 +42,7 @@ export default {
},
methods: {
updateDisplayLayout(layout) {
this.$emit('layoutUpdated', layout);
this.$store.commit(StoreKeys.SET_ITEM_LAYOUT, layout);
},
tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 };

View File

@ -6,10 +6,9 @@
/>
<div class="options-outer">
<div :class="`options-container ${!settingsVisible ? 'hide' : ''}`">
<ThemeSelector :externalThemes="externalThemes"
:confTheme="getInitialTheme()" :userThemes="getUserThemes()" />
<LayoutSelector :displayLayout="displayLayout" @layoutUpdated="updateDisplayLayout"/>
<ItemSizeSelector :iconSize="iconSize" @iconSizeUpdated="updateIconSize" />
<ThemeSelector />
<LayoutSelector :displayLayout="displayLayout" />
<ItemSizeSelector :iconSize="iconSize" />
<ConfigLauncher />
<AuthButtons v-if="userState != 'noone'" :userType="userState" />
</div>
@ -107,12 +106,6 @@ export default {
clearFilterInput() {
this.$refs.SearchBar.clearFilterInput();
},
updateDisplayLayout(layout) {
this.$emit('change-display-layout', layout);
},
updateIconSize(iconSize) {
this.$emit('change-icon-size', iconSize);
},
getInitialTheme() {
return this.appConfig.theme || '';
},

View File

@ -5,8 +5,10 @@
<v-select
:options="themeNames"
v-model="selectedTheme"
:value="$store.getters.theme"
class="theme-dropdown"
:tabindex="-2"
@input="themeChanged"
/>
</div>
<IconPalette
@ -36,62 +38,99 @@ import IconPalette from '@/assets/interface-icons/config-color-palette.svg';
export default {
name: 'ThemeSelector',
props: {
externalThemes: Object,
confTheme: String,
userThemes: Array,
},
components: {
CustomThemeMaker,
IconPalette,
},
watch: {
/* When the theme changes, then call the update method */
selectedTheme(newTheme) {
/* When theme in VueX store changes, then update theme */
themeFromStore(newTheme) {
this.selectedTheme = newTheme;
this.updateTheme(newTheme);
},
},
data() {
return {
selectedTheme: this.getInitialTheme(),
builtInThemes: [...Defaults.builtInThemes, ...this.userThemes],
themeHelper: new LoadExternalTheme(),
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 [...externalThemeNames, ...this.builtInThemes, ...specialThemes];
return [...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) {
if (this.appConfig.externalStyleSheet) {
const externals = this.appConfig.externalStyleSheet;
if (Array.isArray(externals)) {
externals.forEach((ext, i) => {
availibleThemes[`External Stylesheet ${i + 1}`] = ext;
});
} else {
availibleThemes['External Stylesheet'] = this.appConfig.externalStyleSheet;
}
}
}
availibleThemes.Default = '#';
return availibleThemes;
},
},
created() {
mounted() {
const initialTheme = this.getInitialTheme();
this.selectedTheme = initialTheme;
// Pass all user custom stylesheets to the themehelper
const added = Object.keys(this.externalThemes).map(
name => this.themeHelper.add(name, this.externalThemes[name]),
);
// Quicker loading, if the theme is local we can apply it immidiatley
if (this.isThemeLocal(this.selectedTheme)) {
this.updateTheme(this.selectedTheme);
if (this.isThemeLocal(initialTheme)) {
this.updateTheme(initialTheme);
// If it's an external stylesheet, then wait for promise to resolve
} else if (this.selectedTheme !== Defaults.theme) {
} else if (initialTheme !== Defaults.theme) {
Promise.all(added).then(() => {
this.updateTheme(this.selectedTheme);
this.updateTheme(initialTheme);
});
}
},
methods: {
/* Get default theme */
/* Called when dropdown changed
* Updates store, which will in turn update theme through watcher
*/
themeChanged() {
this.$store.commit(Keys.SET_THEME, this.selectedTheme);
},
/* Returns the initial theme */
getInitialTheme() {
return localStorage[localStorageKeys.THEME] || this.confTheme || Defaults.theme;
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) {
return this.builtInThemes.includes(themeToCheck);
const localThemes = [...Defaults.builtInThemes, ...this.extraThemeNames];
return localThemes.includes(themeToCheck);
},
/* Opens the theme color configurator popup */
openThemeConfigurator() {

View File

@ -10,6 +10,7 @@ import VModal from 'vue-js-modal'; // Modal component
import VSelect from 'vue-select'; // Select dropdown component
import VTabs from 'vue-material-tabs'; // Tab view component, used on the config page
import Toasted from 'vue-toasted'; // Toast component, used to show confirmation notifications
import TreeView from 'vue-json-tree-view';
// Import base Dashy components and utils
import Dashy from '@/App.vue'; // Main Dashy Vue app
@ -27,6 +28,7 @@ Vue.use(VueI18n);
Vue.use(VTooltip, tooltipOptions);
Vue.use(VModal);
Vue.use(VTabs);
Vue.use(TreeView);
Vue.use(Toasted, toastedOptions);
Vue.component('v-select', VSelect);
Vue.directive('clickOutside', clickOutside);

View File

@ -4,16 +4,43 @@ import Vuex from 'vuex';
import Keys from '@/utils/StoreMutations';
import ConfigAccumulator from '@/utils/ConfigAccumalator';
import { componentVisibility } from '@/utils/ConfigHelpers';
import { applyItemId } from '@/utils/MiscHelpers';
import filterUserSections from '@/utils/CheckSectionVisibility';
import { InfoHandler, InfoKeys } from '@/utils/ErrorHandler';
Vue.use(Vuex);
const { UPDATE_CONFIG, SET_MODAL_OPEN, SET_LANGUAGE } = Keys;
const {
INITIALIZE_CONFIG,
SET_CONFIG,
SET_MODAL_OPEN,
SET_LANGUAGE,
SET_ITEM_LAYOUT,
SET_ITEM_SIZE,
SET_THEME,
SET_CUSTOM_COLORS,
UPDATE_ITEM,
SET_EDIT_MODE,
SET_PAGE_INFO,
SET_APP_CONFIG,
SET_SECTIONS,
UPDATE_SECTION,
INSERT_SECTION,
REMOVE_SECTION,
COPY_ITEM,
REMOVE_ITEM,
INSERT_ITEM,
UPDATE_CUSTOM_CSS,
} = Keys;
const editorLog = (logMessage) => {
InfoHandler(logMessage, InfoKeys.EDITOR);
};
const store = new Vuex.Store({
state: {
config: {},
lang: '', // The users language, auto-detected or read from local storage / config
editMode: false, // While true, the user can drag and edit items + sections
modalOpen: false, // KB shortcut functionality will be disabled when modal is open
},
getters: {
@ -26,6 +53,9 @@ const store = new Vuex.Store({
appConfig(state) {
return state.config.appConfig || {};
},
theme(state) {
return state.config.appConfig.theme;
},
sections(state) {
return filterUserSections(state.config.sections || []);
},
@ -35,24 +65,181 @@ const store = new Vuex.Store({
visibleComponents(state, getters) {
return componentVisibility(getters.appConfig);
},
// eslint-disable-next-line arrow-body-style
getSectionByIndex: (state, getters) => (index) => {
return getters.sections[index];
},
getItemById: (state, getters) => (id) => {
let item;
getters.sections.forEach(sec => {
const foundItem = sec.items.find((itm) => itm.id === id);
if (foundItem) item = foundItem;
});
return item;
},
getParentSectionOfItem: (state, getters) => (itemId) => {
let foundSection;
getters.sections.forEach((section) => {
section.items.forEach((item) => {
if (item.id === itemId) foundSection = section;
});
});
return foundSection;
},
layout(state) {
return state.config.appConfig.layout || 'auto';
},
iconSize(state) {
return state.config.appConfig.iconSize || 'medium';
},
},
mutations: {
[UPDATE_CONFIG](state, config) {
[SET_CONFIG](state, config) {
state.config = config;
},
[SET_LANGUAGE](state, lang) {
state.lang = lang;
const newConfig = state.config;
newConfig.appConfig.language = lang;
state.config = newConfig;
},
[SET_MODAL_OPEN](state, modalOpen) {
state.modalOpen = modalOpen;
},
[SET_EDIT_MODE](state, editMode) {
if (editMode !== state.editMode) {
editorLog(editMode ? 'Edit session started' : 'Edit session ended');
state.editMode = editMode;
}
},
[UPDATE_ITEM](state, payload) {
const { itemId, newItem } = payload;
const newConfig = { ...state.config };
newConfig.sections.forEach((section, secIndex) => {
section.items.forEach((item, itemIndex) => {
if (item.id === itemId) {
newConfig.sections[secIndex].items[itemIndex] = newItem;
editorLog('Item updated');
}
});
});
state.config = newConfig;
},
[SET_PAGE_INFO](state, newPageInfo) {
const newConfig = state.config;
newConfig.pageInfo = newPageInfo;
state.config = newConfig;
editorLog('Page info updated');
},
[SET_APP_CONFIG](state, newAppConfig) {
const newConfig = state.config;
newConfig.appConfig = newAppConfig;
state.config = newConfig;
editorLog('App config updated');
},
[SET_SECTIONS](state, newSections) {
const newConfig = state.config;
newConfig.sections = newSections;
state.config = newConfig;
editorLog('Sections updated');
},
[UPDATE_SECTION](state, payload) {
const { sectionIndex, sectionData } = payload;
const newConfig = { ...state.config };
newConfig.sections[sectionIndex] = sectionData;
state.config = newConfig;
editorLog('Section updated');
},
[INSERT_SECTION](state, newSection) {
const newConfig = { ...state.config };
newSection.items = [];
newConfig.sections.push(newSection);
state.config = newConfig;
editorLog('New section added');
},
[REMOVE_SECTION](state, payload) {
const { sectionIndex, sectionName } = payload;
const newConfig = { ...state.config };
if (newConfig.sections[sectionIndex].name === sectionName) {
newConfig.sections.splice(sectionIndex, 1);
editorLog('Section removed');
}
state.config = newConfig;
},
[INSERT_ITEM](state, payload) {
const { newItem, targetSection } = payload;
const config = { ...state.config };
config.sections.forEach((section) => {
if (section.name === targetSection) {
section.items.push(newItem);
editorLog('New item added');
}
});
config.sections = applyItemId(config.sections);
state.config = config;
},
[COPY_ITEM](state, payload) {
const { item, toSection, appendTo } = payload;
const config = { ...state.config };
const newItem = { ...item };
config.sections.forEach((section) => {
if (section.name === toSection) {
if (appendTo === 'beginning') {
section.items.unshift(newItem);
} else {
section.items.push(newItem);
}
editorLog('Item copied');
}
});
config.sections = applyItemId(config.sections);
state.config = config;
},
[REMOVE_ITEM](state, payload) {
const { itemId, sectionName } = payload;
const config = { ...state.config };
config.sections.forEach((section) => {
if (section.name === sectionName) {
section.items.forEach((item, index) => {
if (item.id === itemId) {
section.items.splice(index, 1);
editorLog('Item removed');
}
});
}
});
state.config = config;
},
[SET_THEME](state, theme) {
const newConfig = { ...state.config };
newConfig.appConfig.theme = theme;
state.config = newConfig;
InfoHandler('Theme updated', InfoKeys.VISUAL);
},
[SET_CUSTOM_COLORS](state, customColors) {
const newConfig = { ...state.config };
newConfig.appConfig.customColors = customColors;
state.config = newConfig;
InfoHandler('Color palette updated', InfoKeys.VISUAL);
},
[SET_ITEM_LAYOUT](state, layout) {
state.config.appConfig.layout = layout;
InfoHandler('Layout updated', InfoKeys.VISUAL);
},
[SET_ITEM_SIZE](state, iconSize) {
state.config.appConfig.iconSize = iconSize;
InfoHandler('Item size updated', InfoKeys.VISUAL);
},
[UPDATE_CUSTOM_CSS](state, customCss) {
state.config.appConfig.customCss = customCss;
InfoHandler('Custom colors updated', InfoKeys.VISUAL);
},
},
actions: {
/* Called when app first loaded. Reads config and sets state */
initializeConfig({ commit }) {
const Accumulator = new ConfigAccumulator();
const config = Accumulator.config();
commit(UPDATE_CONFIG, config);
[INITIALIZE_CONFIG]({ commit }) {
const deepCopy = (json) => JSON.parse(JSON.stringify(json));
const config = deepCopy(new ConfigAccumulator().config());
commit(SET_CONFIG, config);
},
},
modules: {},

View File

@ -48,15 +48,23 @@
--item-group-outer-background: var(--primary);
--item-group-heading-text-color: var(--item-group-background);
--item-group-heading-text-color-hover: var(--background);
// Settings and config
--settings-background: var(--background);
// Homepage settings
--settings-text-color: var(--primary);
--config-code-background: #fff;
--config-code-color: var(--background);
--settings-background: var(--background);
// Config menu
--config-settings-color: var(--primary);
--config-settings-background: var(--background-darker);
--config-code-color: var(--background);
--config-code-background: #fff;
--code-editor-color: var(--black);
--code-editor-background: var(--white);
// Interactive editor
--interactive-editor-color: var(--primary);
--interactive-editor-background: var(--background);
--interactive-editor-background-darker: var(--background-darker);
// Cloud backup/ restore menu
--cloud-backup-color: var(--config-settings-color);
--cloud-backup-background: var(--config-settings-background);
// Search bar (on homepage)
--search-container-background: var(--background-darker);
--search-field-background: var(--background);

View File

@ -217,6 +217,9 @@ html[data-theme='material-original'] {
--item-group-heading-text-color-hover: #01579b;
--config-settings-background: #01579b;
--config-settings-color: #fff;
--interactive-editor-background: #01579b;
--interactive-editor-color: #fff;
--interactive-editor-background-darker: #29B6F6;
--heading-text-color: #fff;
--status-check-tooltip-background: #f2f2f2;
--status-check-tooltip-color: #01579b;
@ -263,6 +266,9 @@ html[data-theme='material-dark-original'] {
--welcome-popup-text-color: var(--primary);
--config-settings-background: #131a1f;
--config-settings-color: #41e2ed;
--interactive-editor-color: #41e2ed;
--interactive-editor-background: #131a1f;
--interactive-editor-background-darker: #092b3a;
--scroll-bar-color: #08B0BB;
--scroll-bar-background: #131a1f;
--status-check-tooltip-background: #131a1f;
@ -407,6 +413,13 @@ html[data-theme='material'], html[data-theme='material-dark'] {
}
}
}
.item-wrapper.add-new-item {
flex-grow: inherit;
}
.add-new-item a {
flex-grow: inherit;
flex-basis: inherit;
}
.tooltip.item-description-tooltip:not(.tooltip-is-small) {
display: none !important;
}
@ -504,6 +517,9 @@ html[data-theme='material'] {
--config-code-color: #363636;
--config-settings-background: #f5f5f5;
--config-settings-color: #473f3f;
--interactive-editor-color: #473f3f;
--interactive-editor-background: #f5f5f5;
--interactive-editor-background-darker: #fff;
--heading-text-color: #fff;
--curve-factor: 3px;
--curve-factor-navbar: 8px;
@ -511,6 +527,7 @@ html[data-theme='material'] {
--welcome-popup-text-color: #f5f5f5;
--footer-text-color: #f5f5f5cc;
// --login-form-background-secondary: #f5f5f5cc;
--context-menu-background: #fff;
--context-menu-secondary-color: #f5f5f5;
--transparent-white-50: #00000080;
--status-check-tooltip-background: #fff;
@ -567,11 +584,6 @@ html[data-theme='material'] {
.item:focus {
outline-color: #4285f4cc;
}
div.context-menu {
border: none;
background: var(--white);
ul li:hover { svg path { fill: var(--background-darker); }}
}
}
html[data-theme='material-dark'] {
@ -612,13 +624,16 @@ html[data-theme='material-dark'] {
--description-tooltip-color: #e0e0e0;
--curve-factor: 2px;
--curve-factor-navbar: 0;
--side-bar-background: #131a1f;
--welcome-popup-background: #131a1f;
--welcome-popup-text-color: var(--primary);
--config-settings-background: #131a1f;
--config-settings-color: #41e2ed;
--interactive-editor-background: #242a2f;
--interactive-editor-background-darker: #131a1f;
--interactive-editor-color: #41e2ed;
--scroll-bar-color: #08B0BB;
--scroll-bar-background: #131a1f;
// --login-form-color: #131a1f;
@ -633,6 +648,7 @@ html[data-theme='material-dark'] {
// --minimal-view-search-color: var(--primary);
// --minimal-view-group-color: var(--primary);
--minimal-view-group-background: #131a1f;
--context-menu-secondary-color: #131a1f;
div.minimal-section-heading h3, div.minimal-section-heading.selected h3 {
color: #d5d5d5;
@ -651,13 +667,6 @@ html[data-theme='material-dark'] {
background: #131a1f !important;
}
}
div.context-menu {
border: none;
background: var(--background);
ul li:hover {
background: #131a1f;
}
}
}
html[data-theme='minimal-light'] {
@ -919,6 +928,9 @@ html[data-theme='glow'], html[data-theme=glow-colorful] {
--nav-link-border-color-hover: var(--blue);
--config-settings-background: var(--blue);
--config-settings-color: var(--pink);
--interactive-editor-background: var(--blue);
--interactive-editor-background-darker: var(--blue);
--interactive-editor-color: var(--pink);
--search-label-color: var(--blue);
--item-group-background: var(--blue);
--item-text-color: var(--pale);

View File

@ -154,7 +154,6 @@ html {
margin: var(--tooltip-arrow-size) 0;
}
}
&[aria-hidden='true'] {
visibility: hidden;
opacity: 0;
@ -165,4 +164,5 @@ html {
opacity: 1;
transition: opacity .15s;
}
&.in-modal-tt { z-index: 999; }
}

View File

@ -0,0 +1,101 @@
/* Form elements in the auto-schema form */
.schema-form {
fieldset {
border: none;
display: flex;
flex-direction: column;
> div {
border-bottom: 1px dashed var(--interactive-editor-color);
margin: 0.5rem 0;
label {
font-size: 1rem;
text-decoration: underline;
}
}
div[data-fs-wrapper] {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
flex-direction: row-reverse;
justify-content: space-between;
padding: 0.5rem 0;
@include tablet-down {
flex-direction: column-reverse;
}
span {
font-style: italic;
margin-right: 0.5rem;
max-width: 20rem;
opacity: var(--dimming-factor);
}
input {
min-width: 15rem;
padding: 0.5rem 0.75rem;
margin: 0.5rem auto;
font-size: 1rem;
box-sizing: border-box;
color: var(--interactive-editor-color);
background: var(--interactive-editor-background);
border: 1px solid var(--interactive-editor-color);
border-radius: var(--curve-factor);
&[type=text]:focus, &[type=number]:focus {
box-shadow: 1px 1px 6px var(--interactive-editor-color);
outline: none;
}
}
input[type=checkbox] {
width: 1.2rem;
height: 1.2rem;
cursor: pointer;
}
input[type=radio] {
width: 1rem;
height: 1rem;
cursor: pointer;
}
div[data-fs-input=object], div[data-fs-input=array] {
width: 100%;
padding-left: 0.5rem;
border-left: 1px dashed var(--interactive-editor-color);
}
div[data-fs-kind="radio"] {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
label {
text-decoration: none;
text-transform: capitalize;
}
}
select {
width: 15rem;
height: 2rem;
padding: 0.2rem;
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);
&:focus {
box-shadow: 1px 1px 6px var(--interactive-editor-color);
}
}
div[data-fs-input=array] button {
font-size: 1rem;
margin: 0.25rem;
border-radius: var(--curve-factor);
color: var(--interactive-editor-color);
background: var(--interactive-editor-background);
border: 1px solid var(--interactive-editor-color);
&:hover {
color: var(--interactive-editor-background);
background: var(--interactive-editor-color);
}
&:focus {
box-shadow: 1px 1px 6px var(--interactive-editor-color);
}
}
}
}
}

View File

@ -1,3 +1,4 @@
@import '@/styles/media-queries.scss';
/* Fancy scrollbar */
.scroll-bar {
@ -34,6 +35,15 @@
background: var(--settings-text-color);
path { fill: var(--background); }
}
&.disabled {
opacity: var(--dimming-factor);
cursor: not-allowed;
&:hover {
border: 1px solid currentColor;
background: var(--background);
path { fill: var(--settings-text-color); }
}
}
}
}
@ -49,7 +59,6 @@
}
}
/* Single-style helpers */
.bold { font-weight: bold; }
.light { font-weight: lighter; }

View File

@ -13,6 +13,7 @@ import {
layout as defaultLayout,
} from '@/utils/defaults';
import ErrorHandler from '@/utils/ErrorHandler';
import { applyItemId } from '@/utils/MiscHelpers';
import conf from '../../public/conf.yml';
export default class ConfigAccumulator {
@ -57,18 +58,24 @@ export default class ConfigAccumulator {
/* Sections */
sections() {
let sections = [];
// If the user has stored sections in local storage, return those
const localSections = localStorage[localStorageKeys.CONF_SECTIONS];
if (localSections) {
try {
const json = JSON.parse(localSections);
if (json.length >= 1) return json;
if (json.length >= 1) sections = json;
} catch (e) {
ErrorHandler('Malformed section data in local storage');
}
}
// If the function hasn't yet returned, then return the config file sections
return this.conf ? this.conf.sections || [] : [];
// If sections were not set from local data, then use config file instead
if (sections.length === 0) {
sections = this.conf ? this.conf.sections || [] : [];
}
// Apply a unique ID to each item
sections = applyItemId(sections);
return sections;
}
/* Complete config */

View File

@ -9,16 +9,19 @@
"type": "object",
"properties": {
"title": {
"title": "Title",
"type": "string",
"description": "Title and heading for the app"
},
"description": {
"title": "Description",
"type": "string",
"description": "Sub-title, displayed in header"
},
"navLinks": {
"type": "array",
"maxItems": 6,
"title": "Navigation Links",
"description": "Quick access links, displayed in header",
"items": {
"type": "object",
@ -38,12 +41,15 @@
}
},
"footerText": {
"title": "Footer Text",
"description": "Content to display within the global page footer",
"type": "string"
},
"logo": {
"title": "App Logo",
"type": "string",
"description": "Path to an optional image asset, to be displayed in the header",
"pattern": "^(http|/)",
"pattern": "^(http|/)(.*?)",
"examples": [
"/web-icons/dashy-logo.png",
"https://i.ibb.co/yhbt6CY/dashy.png"
@ -57,17 +63,10 @@
},
"appConfig": {
"type": "object",
"description": "Application configuration",
"properties": {
"backgroundImg": {
"type": "string",
"description": "A URL to an image asset to be displayed as background"
},
"language": {
"type": "string",
"description": "The ISO code of your desired language, must have translations present, check docs for more info"
},
"startingView": {
"title": "Starting View",
"type": "string",
"enum": [
"default",
"minimal",
@ -77,6 +76,8 @@
"description": "Which page to load by default, and on the base page or domain root. You can still switch to different views from within the UI"
},
"defaultOpeningMethod": {
"title": "Default Opening Method",
"type": "string",
"enum": [
"newtab",
"sametab",
@ -88,7 +89,25 @@
"default": "newtab",
"description": "The default opening method for items. Only used if no item.target is specified"
},
"statusCheck": {
"title": "Enable Status Checks",
"type": "boolean",
"default": false,
"description": "Displays an online/ offline status for each of your services"
},
"statusCheckInterval": {
"title": "Status Check Interval",
"type": "number",
"default": 0,
"description": "How often to recheck statuses. If set to 0, status will only be checked on page load"
},
"language": {
"title": "Language",
"type": "string",
"description": "The ISO code of your desired language, must have translations present, check docs for more info"
},
"theme": {
"title": "Theme",
"type": "string",
"default": "callisto",
"description": "A theme to be applied by default on first load",
@ -116,17 +135,14 @@
"high-contrast-light"
]
},
"enableFontAwesome": {
"type": "boolean",
"default": true,
"description": "Should load font-awesome assets"
},
"fontAwesomeKey": {
"backgroundImg": {
"title": "Background Image",
"type": "string",
"pattern": "^[a-z0-9]{10}$",
"description": "API key for font-awesome"
"description": "A URL to an image asset to be displayed as background"
},
"faviconApi": {
"title": "Favicon API",
"type": "string",
"enum": [
"local",
"faviconkit",
@ -139,6 +155,8 @@
"description": "Which service to use to resolve favicons. Set to local to do this locally instead"
},
"layout": {
"title": "Default Layout",
"type": "string",
"enum": [
"horizontal",
"vertical",
@ -149,6 +167,8 @@
"description": "Specifies sections layout orientation on the home screen"
},
"iconSize": {
"title": "Default Icon Size",
"type": "string",
"enum": [
"small",
"medium",
@ -158,48 +178,15 @@
"description": "The size of each link item / icon"
},
"colCount": {
"title": "Column Count",
"type": "number",
"minimum": 1,
"maximum": 8,
"description": "Number of section columns for homepage. Leave blank for column count to be responsively calculated based on screen size"
},
"hideComponents": {
"type": "object",
"description": "Hide individual parts of the page. If not set, all components are visible by default",
"properties": {
"hideHeading": {
"type": "boolean",
"default": "false",
"description": "If set to true, the page heading & subtitle will be hidden"
},
"hideNav": {
"type": "boolean",
"default": "false",
"description": "If set to true, the navigation menu will be hidden"
},
"hideSearch": {
"type": "boolean",
"default": "false",
"description": "If set to true, the search bar will be hidden"
},
"hideSettings": {
"type": "boolean",
"default": "false",
"description": "If set to true, the settings buttons will be hidden"
},
"hideFooter": {
"type": "boolean",
"default": "false",
"description": "If set to true, the page footer will be hidden"
},
"hideSplashScreen": {
"type": "boolean",
"default": "true",
"description": "If set to true, the loading / splash screen will not be shown"
}
}
},
"routingMode": {
"title": "Routing Mode",
"type": "string",
"enum": [
"hash",
"history"
@ -207,52 +194,31 @@
"default": "history",
"description": "The Vue routing mode to use, history mode will remove the annoying hash from the URL, but requires some extra config on some systems"
},
"cssThemes": {
"type": "array",
"description": "Theme names to be added to the dropdown",
"items": {
"type": "string"
}
},
"customColors": {
"type": "object",
"description": "Set a custom color palette for any theme"
},
"externalStyleSheet": {
"description": "URL or URLs of external stylesheets to add to dropdown/ load",
"type": [
"string",
"array"
],
"items": {
"type": "string"
}
},
"customCss": {
"workspaceLandingUrl": {
"title": "Workspace Landing URL",
"type": "string",
"description": "Any custom CSS overides, must be minified"
"description": "The URL of an app, service or website to render when the Workspace view is opened"
},
"statusCheck": {
"enableMultiTasking": {
"title": "Enable Multi-Tasking",
"type": "boolean",
"default": false,
"description": "Displays an online/ offline status for each of your services"
},
"statusCheckInterval": {
"type": "number",
"default": 0,
"description": "How often to recheck statuses. If set to 0, status will only be checked on page load"
"description": "If set to true, will keep apps opened in the workspace open in the background. Useful for switching between sites, but comes at the cost of performance"
},
"webSearch": {
"title": "Web Search",
"type": "object",
"description": "Configure options for web search",
"additionalProperties": false,
"properties": {
"disableWebSearch": {
"title": "Disable Web Search?",
"type": "boolean",
"default": "false",
"description": "If set to true, web search will be disabled all together"
},
"searchEngine": {
"title": "Search Engine",
"type": "string",
"default": "duckduckgo",
"description": "Set your default search engine. Reference provider by key, see docs for all supported search engines, or set to custom to use your own",
@ -274,10 +240,13 @@
]
},
"customSearchEngine": {
"title": "Custom Search Engine",
"type": "string",
"description": "Set the URL of a self-hosted or custom search engine, including GET query params. You must also set searchEngine: custom"
},
"openingMethod": {
"title": "Search Opening Method",
"type": "string",
"enum": [
"newtab",
"sametab",
@ -288,6 +257,7 @@
"description": "Set where you would like search results to open to"
},
"searchBangs": {
"title": "Search Bangs",
"type": "object",
"additionalProperties": true,
"examples": [
@ -300,17 +270,101 @@
}
}
},
"enableFontAwesome": {
"title": "Enable Font-Awesome?",
"type": "boolean",
"default": true,
"description": "Should load font-awesome assets"
},
"fontAwesomeKey": {
"title": "Font-Awesome API Key",
"type": "string",
"pattern": "^[a-z0-9]{10}$",
"description": "API key for font-awesome"
},
"cssThemes": {
"title": "Additional CSS Themes",
"type": "array",
"description": "Theme names to be added to the dropdown, once added you can then add custom CSS to style your theme",
"items": {
"type": "string"
}
},
"customColors": {
"title": "Custom Colors",
"type": "object",
"description": "Set a custom color palette for any theme, see docs for more info"
},
"externalStyleSheet": {
"title": "External Stylesheets",
"description": "List of URLs of external stylesheets to add to dropdown/ load",
"type": "array",
"items": {
"type": "string"
}
},
"customCss": {
"title": "Custom CSS",
"type": "string",
"description": "Any custom CSS overides to be applied globally, should be minified"
},
"hideComponents": {
"title": "Hidden Components",
"type": "object",
"description": "Hide individual parts of the page. If not set, all components are visible by default",
"properties": {
"hideHeading": {
"title": "Hide Heading?",
"type": "boolean",
"default": "false",
"description": "If set to true, the page heading & subtitle will be hidden"
},
"hideNav": {
"title": "Hide Nav Bar?",
"type": "boolean",
"default": "false",
"description": "If set to true, the navigation menu will be hidden"
},
"hideSearch": {
"title": "Hide Search Bar?",
"type": "boolean",
"default": "false",
"description": "If set to true, the search bar will be hidden"
},
"hideSettings": {
"title": "Hide Settings?",
"type": "boolean",
"default": "false",
"description": "If set to true, the settings buttons will be hidden"
},
"hideFooter": {
"title": "Hide Footer?",
"type": "boolean",
"default": "false",
"description": "If set to true, the page footer will be hidden"
},
"hideSplashScreen": {
"title": "Hide Splash Screen?",
"type": "boolean",
"default": "true",
"description": "If set to true, the loading / splash screen will not be shown"
}
}
},
"auth": {
"title": "Authentication",
"type": "object",
"description": "Settings for enabling authentication",
"additionalProperties": false,
"properties": {
"enableGuestAccess": {
"title": "Enable Guest Mode?",
"type": "boolean",
"default": false,
"description": "If set to true, an unauthenticated user will be able to have read-only access to dashboard, without needing to login. Requires auth to be configured."
},
"users": {
"title": "Users",
"type": "array",
"description": "Usernames and hashed credentials for frontend authentication",
"items": {
@ -322,16 +376,20 @@
],
"properties": {
"user": {
"title": "Username",
"type": "string",
"description": "The username for a user"
},
"hash": {
"title": "Hashed Pass",
"type": "string",
"description": "A SHA-256 hashed password for that user",
"minLength": 64,
"maxLength": 64
},
"type": {
"title": "Privileges",
"type": "string",
"enum": [
"admin",
"normal"
@ -343,6 +401,7 @@
}
},
"enableKeycloak": {
"title": "Enable Keycloak?",
"type": "boolean",
"default": false,
"description": "If set to true, and auth.keycloak is also configured, then Keycloak will be used for app auth"
@ -358,14 +417,17 @@
],
"properties": {
"serverUrl": {
"title": "Server URL",
"type": "string",
"description": "The URL (or URL/ IP + Port) where your keycloak server is running"
},
"realm": {
"title": "Realm",
"type": "string",
"description": "The name of the realm (must already be created) that you want to use"
},
"clientId": {
"title": "Client ID",
"type": "string",
"description": "The Client ID of the client you created for use with Dashy"
}
@ -373,48 +435,46 @@
}
}
},
"enableMultiTasking": {
"type": "boolean",
"default": false,
"description": "If set to true, will keep apps opened in the workspace open in the background. Useful for switching between sites, but comes at the cost of performance"
},
"allowConfigEdit": {
"title": "Allow Config Editing",
"type": "boolean",
"default": true,
"description": "Can user write changes to conf.yml file from the UI. If set to false, preferences are only stored locally"
},
"enableServiceWorker": {
"title": "Enable Service Worker",
"type": "boolean",
"default": false,
"description": "If set to true, then service workers will be used to cache page contents"
},
"disableContextMenu": {
"title": "Disable Context Menus",
"type": "boolean",
"default": false,
"description": "If set to true, custom right-click context menu will be disabled"
},
"disableUpdateChecks": {
"title": "Disable Update Checks",
"type": "boolean",
"default": false,
"description": "Prevents Dashy from checking for updates"
},
"disableSmartSort": {
"title": "Disable Smart-Sort",
"type": "boolean",
"default": false,
"description": "Prevents the app storing local click count, required for the last-used and most-used sort orders"
},
"enableErrorReporting": {
"title": "Enable Error Reporting",
"type": "boolean",
"default": false,
"description": "Enable anonymous crash reports. This helps bugs be found and fixed, in order to make Dashy more stable. Reporting is off by default, and no data will EVER be collected without your explicit and active concent."
},
"sentryDsn": {
"title": "Custom Sentry DSN",
"type": "string",
"description": "The DSN to your self-hosted Sentry server, if you need to collect bug reports. Only used if enableErrorReporting is enabled"
},
"workspaceLandingUrl": {
"type": "string",
"description": "The URL of an app, service or website to render when the Workspace view is opened"
}
},
"additionalProperties": false
@ -423,6 +483,7 @@
"type": "array",
"description": "Array of sections, containing items",
"items": {
"title": "Items",
"type": "object",
"required": [
"name",
@ -431,19 +492,24 @@
"additionalProperties": false,
"properties": {
"name": {
"title": "Section Name",
"type": "string",
"description": "Title/ heading for a section"
},
"icon": {
"title": "Section Icon",
"type": "string",
"description": "Icon will be displayed next to title"
},
"displayData": {
"title": "Display Data",
"type": "object",
"additionalProperties": false,
"description": "Optional meta data for customizing a section",
"properties": {
"sortBy": {
"title": "Sort By",
"type": "string",
"enum": [
"default",
"most-used",
@ -456,19 +522,24 @@
"description": "How to sort items within the section. By default items are displayed in the order in which they are listed in within the config"
},
"collapsed": {
"title": "Is Collapsed?",
"type": "boolean",
"default": false,
"description": "If true, section needs to be clicked to open"
},
"color": {
"title": "Color",
"type": "string",
"description": "Hex code, or HTML color for section fill"
},
"customStyles": {
"title": "Custom Styles",
"type": "string",
"description": "CSS overides for section container"
},
"itemSize": {
"title": "Item Size",
"type": "string",
"enum": [
"small",
"medium",
@ -478,6 +549,7 @@
"description": "Size of items within the section"
},
"rows": {
"title": "Num Rows",
"type": "number",
"minimum": 1,
"maximum": 5,
@ -485,6 +557,7 @@
"description": "The amount of space that the section spans vertically"
},
"cols": {
"title": "Num Cols",
"type": "number",
"minimum": 1,
"maximum": 5,
@ -492,6 +565,8 @@
"description": "The amount of space that the section spans horizontally"
},
"sectionLayout": {
"title": "Layout Type",
"type": "string",
"enum": [
"grid",
"auto"
@ -500,18 +575,21 @@
"description": "If set to grid, items have uniform width, and itemCount can be set"
},
"itemCountX": {
"title": "Item Count X",
"type": "number",
"minimum": 1,
"maximum": 12,
"description": "Number of items per column"
},
"itemCountY": {
"title": "Item Count Y",
"type": "number",
"minimum": 1,
"maximum": 12,
"description": "Number of items per row"
},
"hideForUsers": {
"title": "Hide for Users",
"type": "array",
"description": "Section will be visible to all users, except for those specified in this list",
"items": {
@ -520,6 +598,7 @@
}
},
"showForUsers": {
"title": "Show for Users",
"type": "array",
"description": "Section will be hidden from all users, except for those specified in this list",
"items": {
@ -528,6 +607,7 @@
}
},
"hideForGuests": {
"title": "Hide for Guests?",
"type": "boolean",
"default": false,
"description": "If set to true, section will be visible for logged in users, but not for guests"
@ -535,6 +615,7 @@
}
},
"items": {
"title": "Items",
"type": "array",
"description": "Array of items to display with a section",
"items": {
@ -545,24 +626,30 @@
],
"properties": {
"title": {
"title": "Item Text",
"type": "string",
"description": "Text shown on the item"
"description": "Title of the item"
},
"description": {
"title": "Description",
"type": "string",
"nullable": true,
"description": "Short description, shown on hover or in a tooltip"
},
"icon": {
"title": "Icon",
"type": "string",
"nullable": true,
"description": "An icon, either as a font-awesome identifier, local or remote URL, or the word favicon or generative"
"description": "An icon, either as a font-awesome, simple-icon or mdi identifier, emoji, favicon, generative or the URL/ path to a local or remote icon asset"
},
"url": {
"title": "Service URL",
"type": "string",
"description": "The destination to navigate to when item is clicked"
"description": "The destination to navigate to when item is clicked, expressed as a valid URL, IP or hostname"
},
"target": {
"title": "Opening Method",
"type": "string",
"enum": [
"newtab",
"sametab",
@ -572,45 +659,58 @@
"workspace"
],
"default": "newtab",
"description": "Opening method, when item is clicked"
"description": "Where / how the item is opened when it's clicked"
},
"hotkey": {
"title": "Hot Key",
"type": "number",
"description": "A numeric shortcut key, between 0 and 9. Useful for quickly launching frequently used applications"
},
"tags": {
"title": "Tags",
"type": "array",
"description": "Tags, which can be used for improved search",
"description": "A list of tags for improved search. Separate using a comma",
"maxItems": 12,
"items": {
"type": "string"
}
},
"color": {
"type": "string",
"description": "A custom fill color of the item"
},
"provider": {
"title": "Provider",
"type": "string",
"description": "Provider name, e.g. Microsoft"
"description": "Provider name, e.g. Microsoft, Nebucasa, DigitalOcean, etc"
},
"statusCheck": {
"title": "Enable Status Check",
"type": "boolean",
"default": false,
"description": "Whether or not to display online/ offline status for this service. Will override appConfig.statusCheck"
},
"statusCheckUrl": {
"title": "Status Check URL",
"type": "string",
"description": "If you've enabled statusCheck, and want to use a different URL to what is defined under the item, then specify it here"
"description": "Custom status check endpoint for this item. Useful if the default URL doesn't return 200, or if your service has a dedicated status check endpoint"
},
"statusCheckHeaders": {
"title": "Status Check Headers",
"type": "object",
"description": " If you're endpoint requires any specific headers for the status checking, then define them here"
"description": " Custom headers for status checking, useful if your service requires authorization headers to return a 200"
},
"statusCheckAllowInsecure": {
"title": "Status Check Disable SSL",
"type": "boolean",
"default": false,
"description": "Allows for running status checks on insecure content/ non-HTTPS apps"
"description": "Allows for running status checks on insecure content/ non-HTTPS apps. Prevents checks failing for non-SSL sites"
},
"color": {
"title": "Custom Color",
"type": "string",
"description": "A custom fill color of the item, expressed either as hex code or color name"
},
"id": {
"title": "Item ID",
"type": "string",
"description": "Unique ID for each item. Generated automatically, shouldn't need to be set manually."
}
}
}

View File

@ -38,4 +38,10 @@ export const WarningInfoHandler = (msg, title, log) => {
statusErrorMsg(title || 'Warning', msg, log);
};
/* Titles for info logging */
export const InfoKeys = {
EDITOR: 'Interactive Editor',
VISUAL: 'Layout & Styles',
};
export default ErrorHandler;

View File

@ -25,3 +25,24 @@ export const sanitize = (string) => {
const reg = /[&<>"'/]/ig;
return string.replace(reg, (match) => (map[match]));
};
/* Based on section title, item name and index, return a string value for ID */
const makeItemId = (sectionStr, itemStr, index) => {
const charSum = sectionStr.split('').map((a) => a.charCodeAt(0)).reduce((x, y) => x + y);
const itemTitleStr = itemStr.replace(/\s+/g, '-').replace(/[^a-zA-Z ]/g, '').toLowerCase();
return `${index}_${charSum}_${itemTitleStr}`;
};
/* Given an array of sections, apply a unique ID to each item, and return modified array */
export const applyItemId = (inputSections) => {
const sections = inputSections || [];
sections.forEach((sec, secIdx) => {
if (sec.items) {
sec.items.forEach((item, itemIdx) => {
sections[secIdx].items[itemIdx].id = makeItemId(sec.name, item.title, itemIdx);
// TODO: Check if ID already exists, and if so, modify it
});
}
});
return sections;
};

View File

@ -1,8 +1,25 @@
// A list of mutation names
const KEY_NAMES = [
'UPDATE_CONFIG',
'INITIALIZE_CONFIG',
'SET_CONFIG',
'SET_MODAL_OPEN',
'SET_LANGUAGE',
'SET_EDIT_MODE',
'SET_ITEM_LAYOUT',
'SET_ITEM_SIZE',
'SET_THEME',
'SET_CUSTOM_COLORS',
'UPDATE_ITEM',
'SET_PAGE_INFO',
'SET_APP_CONFIG',
'SET_SECTIONS',
'UPDATE_SECTION',
'INSERT_SECTION',
'REMOVE_SECTION',
'COPY_ITEM',
'REMOVE_ITEM',
'INSERT_ITEM',
'UPDATE_CUSTOM_CSS',
];
// Convert array of key names into an object, and export

View File

@ -119,11 +119,15 @@ module.exports = {
/* Unique IDs of modals within the app */
modalNames: {
CONF_EDITOR: 'CONF_EDITOR',
CLOUD_BACKUP: 'CLOUD_BACKUP',
REBUILD_APP: 'REBUILD_APP',
THEME_MAKER: 'THEME_MAKER',
ABOUT_APP: 'ABOUT_APP',
LANG_SWITCHER: 'LANG_SWITCHER',
EDIT_ITEM: 'EDIT_ITEM',
EDIT_SECTION: 'EDIT_SECTION',
EDIT_PAGE_INFO: 'EDIT_PAGE_INFO',
EDIT_APP_CONFIG: 'EDIT_APP_CONFIG',
EXPORT_CONFIG_MENU: 'EXPORT_CONFIG_MENU',
MOVE_ITEM_TO: 'MOVE_ITEM_TO',
},
/* Key names for the top-level objects in conf.yml */
topLevelConfKeys: {

View File

@ -4,8 +4,6 @@
<!-- Search bar, layout options and settings -->
<SettingsContainer ref="filterComp"
@user-is-searchin="searching"
@change-display-layout="setLayoutOrientation"
@change-icon-size="setItemSize"
@change-modal-visibility="updateModalVisibility"
:displayLayout="layout"
:iconSize="itemSizeBound"
@ -31,6 +29,7 @@
<Section
v-for="(section, index) in filteredTiles"
:key="index"
:index="index"
:title="section.name"
:icon="section.icon || undefined"
:displayData="getDisplayData(section)"
@ -43,11 +42,17 @@
:class="
(searchValue && filterTiles(section.items, searchValue).length === 0) ? 'no-results' : ''"
/>
<!-- Show add new section button, in edit mode -->
<AddNewSection v-if="isEditMode" />
</div>
<!-- Show message when there's no data to show -->
<div v-if="checkIfResults()" class="no-data">
{{searchValue ? $t('home.no-results') : $t('home.no-data')}}
</div>
<!-- Show banner at bottom of screen, for Saving config changes -->
<EditModeSaveMenu v-if="isEditMode" />
<!-- Modal for viewing and exporting configuration file -->
<ExportConfigMenu />
</div>
</template>
@ -55,8 +60,12 @@
import SettingsContainer from '@/components/Settings/SettingsContainer.vue';
import Section from '@/components/LinkItems/Section.vue';
import EditModeSaveMenu from '@/components/InteractiveEditor/EditModeSaveMenu.vue';
import ExportConfigMenu from '@/components/InteractiveEditor/ExportConfigMenu.vue';
import AddNewSection from '@/components/InteractiveEditor/AddNewSectionLauncher.vue';
import { searchTiles } from '@/utils/Search';
import Defaults, { localStorageKeys, iconCdns } from '@/utils/defaults';
import StoreKeys from '@/utils/StoreMutations';
import Defaults, { localStorageKeys, iconCdns, modalNames } from '@/utils/defaults';
import ErrorHandler from '@/utils/ErrorHandler';
import BackIcon from '@/assets/interface-icons/back-arrow.svg';
@ -64,6 +73,9 @@ export default {
name: 'home',
components: {
SettingsContainer,
EditModeSaveMenu,
ExportConfigMenu,
AddNewSection,
Section,
BackIcon,
},
@ -71,6 +83,7 @@ export default {
searchValue: '',
layout: '',
itemSizeBound: '',
addNewSectionOpen: false,
}),
computed: {
sections() {
@ -88,6 +101,9 @@ export default {
singleSectionView() {
return this.findSingleSection(this.$store.getters.sections, this.$route.params.section);
},
isEditMode() {
return this.$store.state.editMode;
},
/* Get class for num columns, if specified by user */
colCount() {
let { colCount } = this.appConfig;
@ -96,36 +112,28 @@ export default {
if (colCount > 8) colCount = 8;
return colCount;
},
/* Combines sections from config file, with those in local storage */
allSections() {
// If the user has stored sections in local storage, return those
const localSections = localStorage[localStorageKeys.CONF_SECTIONS];
if (localSections) {
const json = JSON.parse(localSections);
if (json.length >= 1) return json;
}
// Otherwise, return the usuall data from conf.yml
return this.sections;
},
/* Return all sections, that match users search term */
filteredTiles() {
const sections = this.singleSectionView || this.allSections;
const sections = this.singleSectionView || this.sections;
return sections.filter((section) => this.filterTiles(section.items, this.searchValue));
},
/* Updates layout (when button clicked), and saves in local storage */
layoutOrientation: {
get() { return this.appConfig.layout || Defaults.layout; },
set: function setLayout(layout) {
localStorage.setItem(localStorageKeys.LAYOUT_ORIENTATION, layout);
this.layout = layout;
},
layoutOrientation() {
return this.$store.getters.layout;
},
/* Updates icon size (when button clicked), and saves in local storage */
iconSize: {
get() { return this.appConfig.iconSize || Defaults.iconSize; },
set: function setIconSize(iconSize) {
localStorage.setItem(localStorageKeys.ICON_SIZE, iconSize);
this.itemSizeBound = iconSize;
},
iconSize() {
return this.$store.getters.iconSize;
},
},
watch: {
layoutOrientation(layout) {
localStorage.setItem(localStorageKeys.LAYOUT_ORIENTATION, layout);
this.layout = layout;
},
iconSize(size) {
localStorage.setItem(localStorageKeys.ICON_SIZE, size);
this.itemSizeBound = size;
},
},
methods: {
@ -150,24 +158,26 @@ export default {
getDisplayData(section) {
return !section.displayData ? {} : section.displayData;
},
/* Sets layout attribute, which is used by Section */
setLayoutOrientation(layout) {
this.layoutOrientation = layout;
},
/* Sets item size attribute, which is used by Section */
setItemSize(itemSize) {
this.iconSize = itemSize;
},
/* Update data when modal is open (so that key bindings can be disabled) */
updateModalVisibility(modalState) {
this.$store.commit('SET_MODAL_OPEN', modalState);
},
openAddNewSectionMenu() {
this.addNewSectionOpen = true;
this.$modal.show(modalNames.EDIT_SECTION);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, true);
},
closeEditSection() {
this.addNewSectionOpen = false;
this.$modal.hide(modalNames.EDIT_SECTION);
this.$store.commit(StoreKeys.SET_MODAL_OPEN, false);
},
/* If on sub-route, and section exists, then return only that section */
findSingleSection: (allSectios, sectionTitle) => {
findSingleSection: (allSections, sectionTitle) => {
if (!sectionTitle) return undefined;
let sectionToReturn;
const parse = (section) => section.replace(' ', '-').toLowerCase().trim();
allSectios.forEach((section) => {
const parse = (section) => section.replaceAll(' ', '-').toLowerCase().trim();
allSections.forEach((section) => {
if (parse(sectionTitle) === parse(section.name)) {
sectionToReturn = [section];
}
@ -196,8 +206,8 @@ export default {
/* Checks if any sections or items use icons from a given CDN */
checkIfIconLibraryNeeded(prefix) {
let isNeeded = false;
if (!this.allSections) return false;
this.allSections.forEach((section) => {
if (!this.sections) return false;
this.sections.forEach((section) => {
if (section.icon && section.icon.includes(prefix)) isNeeded = true;
section.items.forEach((item) => {
if (item.icon && item.icon.includes(prefix)) isNeeded = true;
@ -236,10 +246,10 @@ export default {
},
/* Returns true if there is more than 1 sub-result visible during searching */
checkIfResults() {
if (!this.allSections) return false;
if (!this.sections) return false;
else {
let itemsFound = true;
this.allSections.forEach((section) => {
this.sections.forEach((section) => {
if (this.filterTiles(section.items, this.searchValue).length > 0) itemsFound = false;
});
return itemsFound;
@ -344,6 +354,18 @@ export default {
&.single-section-view {
display: block;
}
.add-new-section {
border: 2px dashed var(--primary);
border-radius: var(--curve-factor);
padding: var(--item-group-padding);
background: var(--item-group-background);
color: var(--primary);
font-size: 1.2rem;
cursor: pointer;
text-align: center;
height: fit-content;
margin: 10px;
}
}
/* Custom styles only applied when there is no sections in config */

View File

@ -1023,6 +1023,11 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@formschema/native@^2.0.0-beta.5":
version "2.0.0-beta.5"
resolved "https://registry.yarnpkg.com/@formschema/native/-/native-2.0.0-beta.5.tgz#7a6cb6850c439893ca033eeb6a5ea15f98944a89"
integrity sha512-zu4sdtq9o4qQtsEEpSuzQJKzcLk6WH2bfwZuop82hI/H0IltjaLWJTMdjv4c9MXsS7tNvg7ytby/OnT4GzOj5w==
"@hapi/address@2.x.x":
version "2.1.4"
resolved "https://registry.yarnpkg.com/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5"
@ -6347,7 +6352,7 @@ lodash.uniq@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.3:
lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.3, lodash@^4.17.4:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@ -9809,6 +9814,14 @@ vue-js-modal@^2.0.0-rc.6:
dependencies:
resize-observer-polyfill "^1.5.1"
vue-json-tree-view@^2.1.6:
version "2.1.6"
resolved "https://registry.yarnpkg.com/vue-json-tree-view/-/vue-json-tree-view-2.1.6.tgz#a153ac1b18432b8671c65930f545ce134e38cf2d"
integrity sha512-gs7VDd1dC5SFQmyMdIq3O0w2IsITZfzyNFe6lsTeQwhFkh0nwWxhKWrS6opNmdOYFYmy52D9DVto78v8mF7IXQ==
dependencies:
lodash "^4.17.4"
vue "^2.5.16"
"vue-loader-v16@npm:vue-loader@^16.1.0":
version "16.8.1"
resolved "https://registry.yarnpkg.com/vue-loader/-/vue-loader-16.8.1.tgz#354f12bc0897954158b71590f800295713a7792d"
@ -9890,7 +9903,7 @@ vue-toasted@^1.1.28:
resolved "https://registry.yarnpkg.com/vue-toasted/-/vue-toasted-1.1.28.tgz#dbabb83acc89f7a9e8765815e491d79f0dc65c26"
integrity sha512-UUzr5LX51UbbiROSGZ49GOgSzFxaMHK6L00JV8fir/CYNJCpIIvNZ5YmS4Qc8Y2+Z/4VVYRpeQL2UO0G800Raw==
vue@^2.6.10:
vue@^2.5.16, vue@^2.6.10:
version "2.6.14"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235"
integrity sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==