🔀 Merge pull request #595 from Lissy93/FEATURE/sub-items

[FEATURE] Sub-items and improved item performance + functionality
Closes #586 
Closes #581
This commit is contained in:
Alicia Sykes 2022-04-14 19:28:51 +01:00 committed by GitHub
commit cc1b9c823b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1454 additions and 493 deletions

View File

@ -1,5 +1,13 @@
# Changelog
## ⚡️ 2.0.7 Improves handling of Sections and Items [PR #595](https://github.com/Lissy93/dashy/pull/595)
- Adds functionality for sub-items / item-groups
- Creates an item mixin, for reusing functionality
- Item width calculated based on parent section width
- Improved mobile support, long-press for right-click
- Adds 2 new themes (`lissy` and `charry-blossom`)
- Adds 2 new widgets (`mullvad-status`, and `blacklist-check`)
## 🐛 2.0.6 Fixes user requested issues [PR #557](https://github.com/Lissy93/dashy/pull/557)
- Allows middle click open new tab, Re: #492
- Implements Max redirects for status checks, Re: #494

View File

@ -1,16 +1,7 @@
## 🐛 Fixes user requested issues [PR #557](https://github.com/Lissy93/dashy/pull/557)
- Allows middle click open new tab, Re: #492
- Implements Max redirects for status checks, Re: #494
- Adds Gitpod config for cloud-ready IDE, Re: #497
- Adss new screenshots to showcase, Re: #505
- Fixes excess space below footer, Re: #522
- Allows iframe content to be viewed full-screen, Re: #524
- Fixes Glances widgets with Authorization headers, Re: #546
- Adds target attribute to nav links, Re: #552
- Removes fixed max-width on wide-screens, Re: #554
- Adds missing type attribute to external CSS, Re: #560
- Updates path to Keycloak API, Re: #564
- Fixes link to @walkxhub homelab icons, Re #568
- Fixes local image path on sub-page, Re: #570
- Adds typecheck on edit item tags, Re: #575
- Fixes item size in config not honored, Re: #576
## ⚡️ 2.0.7 Improves handling of Sections and Items [PR #595](https://github.com/Lissy93/dashy/pull/595)
- Adds functionality for sub-items / item-groups
- Creates an item mixin, for reusing functionality
- Item width calculated based on parent section width
- Improved mobile support, long-press for right-click
- Adds 2 new themes (`lissy` and `charry-blossom`)
- Adds 2 new widgets (`mullvad-status`, and `blacklist-check`)

4
.github/SUPPORT.md vendored
View File

@ -12,4 +12,6 @@ If you'd like to help support Dashy's future development, see **[Contributing](h
To get in contact with the author, email me at **`alicia at omg dot lol`** **[[PGP]](https://keybase.io/aliciasykes/pgp_keys.asc?fingerprint=0688f8d34587d954e9e51fb8fedb68f55c0283a7)**.
-Thank you
-Thank you
> <sub>Prior to raising a ticket, please check the [docs](https://github.com/Lissy93/dashy/tree/master/docs#readme), [troubleshooting guide](https://github.com/Lissy93/dashy/blob/master/docs/troubleshooting.md) and [previous issues](https://github.com/Lissy93/dashy/issues?q=is%3Aissue).</sub><br><sup>If you're new here, consider also staring the repo before submitting your ticket.</sup>

View File

@ -1,17 +1,25 @@
# Privacy & Security
Dashy was built with privacy in mind.
Self-hosting your own apps and services is a great way to protect yourself from the mass data collection employed by big tech companies, and Dashy was designed to keep your local services organized and accessible from a single place.
Self-hosting your own apps and services is a great way to protect yourself from the mass data collection employed by big tech companies, and Dashy was designed to make self-hosting easier, by keeping your local services organized and accessible from a single place. The [management docs](https://github.com/Lissy93/dashy/blob/master/docs/management.md) contains a though guide on the steps you can take to secure your homelab.
It's fully open source, and I've tried to keep to code as clear and thoroughly documented as possible, which will make it easy for you to understand exactly how it works, and what goes on behind the scenes.
Dashy operates on the premise, that no external data requests should ever be made, unless explicitly enabled by the user. In the interest of transparency, the code is 100% open source and clearly documented throughout.
For privacy and security tips, check out another project of mine: **[Personal Security Checklist](https://github.com/Lissy93/personal-security-checklist)**.
| 🔐 For privacy and security tips, check out another project of mine: **[Personal Security Checklist](https://github.com/Lissy93/personal-security-checklist)** |
|-|
### Contents
- [External Requests](#external-requests)
- [Themes](#themes)
- [Icons](#icons)
- [Features](#features)
- [Themes](#themes)
- [Widgets](#widgets)
- [Features](#features)
- [Status Checking](#status-checking)
- [Update Checks](#update-checks)
- [Cloud Backup](#cloud-backup)
- [Web Search](#web-search)
- [Error Reporting](#anonymous-error-reporting)
- [Browser Storage](#browser-storage)
- [App Dependencies](#dependencies)
- [Security Features](#security-features)
@ -25,8 +33,6 @@ By default, Dashy will not make any external requests, unless you configure it t
The following section outlines all network requests that are made when certain features are enabled.
### Themes
### Icons
#### Font Awesome
@ -46,13 +52,14 @@ If an item has the icon set to `generative`, then an external request it made to
As a fallback, if Dicebear fails, then [Evatar](https://evatar.io/) is used.
#### Other Icons
Section icons, item icons and app icons are able to accept a URL to a raw image, if the image is hosted online then an external request will be made. To avoid the need to make external requests for icon assets, you can either use a self-hosted CDN, or store your images within `./public/item-icons` (which can be mounted as a volume if you're using Docker).
#### Web Assets
By default, all assets required by Dashy come bundled within the source, and so no external requests are made. If you add an additional font, which is imported from a CDN, then that will incur an external request. The same applies for other web assets, like external images, scripts or styles.
---
### Features
#### Status Checking
@ -63,6 +70,18 @@ Dashy will ping your services directly, and does not rely on any third party. If
#### Update Checks
When the application loads, it checks for updates. The results of which are displayed in the config menu of the UI. This was implemented because using a very outdated version of Dashy may have unfixed issues. Your version is fetched from the source (local request), but the latest version is fetched from GitHub, which is an external request. This can be disabled by setting `appConfig.disableUpdateChecks: true`
#### Cloud Backup
Dashy has an optional End-to-End encrypted [cloud backup feature](https://github.com/Lissy93/dashy/blob/master/docs/backup-restore.md). No data is ever transimtted unless you actively enable this feature through the UI.
All data is encrypted before being sent to the backend. This is done in [`CloudBackup.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/CloudBackup.js), using [crypto.js](https://github.com/brix/crypto-js)'s AES method, using the users chosen password as the key. The data is then sent to a [Cloudflare worker](https://developers.cloudflare.com/workers/learning/how-workers-works) (a platform for running serverless functions), and stored in a [KV](https://developers.cloudflare.com/workers/learning/how-kv-works) data store.
Your selected password never leaves your device, and is hashed before being compared. It is only possible to restore a configuration if you have both the backup ID and decryption password. Because the data is encrypted on the client-side (before being sent to the cloud), it is not possible for a man-in-the-middle, government entity, website owner, or even Cloudflare to be able read any of your data.
#### Web Search
Dashy has a primitive [web search feature](https://github.com/Lissy93/dashy/blob/master/docs/searching.md#web-search). No external requests are made, instead you are redirected to your chosen search engine (defaults to DuckDuckGo), using your chosen opening method.
This feature can be disabled under appConfig, with `webSearch: { disableWebSearch: true }`
#### Anonymous Error Reporting
Error reporting is disabled by default, and no data will ever be sent without your explicit consent. In fact, the error tracking code isn't even imported unless you have actively enabled it. [Sentry](https://github.com/getsentry/sentry) is used for this, it's an open source error tracking and performance monitoring tool, used to identify any issues which occur in the production app (if you enable it).
@ -72,9 +91,16 @@ Enabling anonymous error reporting helps me to discover bugs I was unaware of, a
If you need to monitor bugs yourself, then you can [self-host your own Sentry Server](https://develop.sentry.dev/self-hosted/), and use it by setting `appConfig.sentryDsn` to your Sentry instances [Data Source Name](https://docs.sentry.io/product/sentry-basics/dsn-explainer/), then just enable error reporting in Dashy.
---
### Themes
Certain themes may use external assets (such as fonts or images). Currently, this only applies the Adventure theme.
---
### Widgets
Dashy supports [Widgets](/docs/widgets.md) for displaying dynamic content. The following widgets make external data requests:
Dashy supports [Widgets](/docs/widgets.md) for displaying dynamic content. Below is a list of all widgets that make external data requests, along with the endpoint they call and a link to the Privacy Policy of that service.
- **[Weather](/docs/widgets.md#weather)** and **[Weather Forecast](/docs/widgets.md#weather-forecast)**: `https://api.openweathermap.org`
- [OWM Privacy Policy](https://openweather.co.uk/privacy-policy)
@ -83,10 +109,12 @@ Dashy supports [Widgets](/docs/widgets.md) for displaying dynamic content. The f
- **[IP Address](/docs/widgets.md#public-ip)**: `https://ipapi.co/json` or `http://ip-api.com/json`
- [IPGeoLocation Privacy Policy](https://ipgeolocation.io/privacy.html)
- [IP-API Privacy Policy](https://ip-api.com/docs/legal)
- **[IP Blacklist](/docs/widgets.md#ip-blacklist)**: `https://api.blacklistchecker.com`
- [Blacklist Checker Privacy Policy](https://blacklistchecker.com/privacy)
- **[Crypto Watch List](/docs/widgets.md#crypto-watch-list)** and **[Token Price History](/docs/widgets.md#crypto-token-price-history)**: `https://api.coingecko.com`
- [CoinGecko Privacy Policy](https://www.coingecko.com/en/privacy)
- **[Wallet Balance](/docs/widgets.md#wallet-balance)**: `https://api.blockcypher.com/`
- BlockCypher Privacy Policy](https://www.blockcypher.com/privacy.html)
- [BlockCypher Privacy Policy](https://www.blockcypher.com/privacy.html)
- **[Code::Stats](/docs/widgets.md#code-stats)**: `https://codestats.net`
- [Code::Stats Privacy Policy](https://codestats.net/tos#privacy)
- **[AnonAddy](/docs/widgets.md#anonaddy)**: `https://app.anonaddy.com`
@ -103,6 +131,8 @@ Dashy supports [Widgets](/docs/widgets.md) for displaying dynamic content. The f
- No Policy Availible
- **[News Headlines](/docs/widgets.md#news-headlines)**: `https://api.currentsapi.services`
- [CurrentsAPI Privacy Policy](https://currentsapi.services/privacy)
- **[Mullvad Status](/docs/widgets.md#mullvad-status)**: `https://am.i.mullvad.net`
- [Mullvad Privacy Policy](https://mullvad.net/en/help/privacy-policy/)
- **[TFL Status](/docs/widgets.md#tfl-status)**: `https://api.tfl.gov.uk`
- [TFL Privacy Policy](https://tfl.gov.uk/corporate/privacy-and-cookies/)
- **[Stock Price History](/docs/widgets.md#stock-price-history)**: `https://alphavantage.co`
@ -112,7 +142,7 @@ Dashy supports [Widgets](/docs/widgets.md) for displaying dynamic content. The f
- **[Joke](/docs/widgets.md#joke)**: `https://v2.jokeapi.dev`
- [SV443's Privacy Policy](https://sv443.net/privacypolicy/en)
- **[Flight Data](/docs/widgets.md#flight-data)**: `https://aerodatabox.p.rapidapi.com`
- [AeroDataBox](https://www.aerodatabox.com/#h.p_CXtIYZWF_WQd)
- [AeroDataBox Privacy Policy](https://www.aerodatabox.com/#h.p_CXtIYZWF_WQd)
- **[Astronomy Picture of the Day](/docs/widgets.md#astronomy-picture-of-the-day)**: `https://apodapi.herokuapp.com`
- [NASA's Privacy Policy](https://www.nasa.gov/about/highlights/HP_Privacy.html)
- **[GitHub Trending](/docs/widgets.md#github-trending)** and **[GitHub Profile Stats](/docs/widgets.md#github-profile-stats)**: `https://api.github.com`
@ -124,12 +154,13 @@ Dashy supports [Widgets](/docs/widgets.md) for displaying dynamic content. The f
## Browser Storage
In order for user preferences to be persisted between sessions, certain data needs to be stored in the browsers local storage. No personal info is kept here, none of this data can be accessed by other domains, and no data is ever sent to any server without your prior consent.
You can view your browsers session storage by opening up the dev tools (F12) --> Application --> Storage.
The following section outlines all data that is stored in the browsers, as cookies or local storage.
You can view and delete stored data by opening up the dev tools: <kbd>F12</kbd> --> `Application` --> `Storage`.
The following section outlines all data that is stored in the browsers, as cookies, session storage or local storage.
#### Cookies
> Cookies have a pre-defined lifetime
> [Cookies](https://en.wikipedia.org/wiki/HTTP_cookie) will expire after their pre-defined lifetime
- `AUTH_TOKEN` - A unique token, generated from a hash of users credentials, to verify they are authenticated. Only used when auth is enabled
@ -176,6 +207,8 @@ Note that packages listed under `devDependencies` section are only used for buil
## Securing your Environment
Running your self-hosted applications in individual, containerized environments (such as containers or VMs) helps keep them isolated, and prevent an exploit in one service effecting another.
If you're running Dashy in a container, see [Management Docs --> Container Security](https://github.com/Lissy93/dashy/blob/master/docs/management.md#container-security) for step-by-step security guide.
There is very little complexity involved with Dashy, and therefore the attack surface is reasonably small, but it is still important to follow best practices and employ monitoring for all your self-hosted apps. A couple of things that you should look at include:
- Use SSL for securing traffic in transit
- Configure [authentication](/docs/authentication.md#alternative-authentication-methods) to prevent unauthorized access
@ -219,7 +252,7 @@ You may wish to disable features that you don't want to use, if they involve sto
---
## Reporting a Security Issue
If you think you've found a critical issue with Dashy, please send an email to `security@mail.alicia.omg.lol`. You can encrypt it, using [`0688 F8D3 4587 D954 E9E5 1FB8 FEDB 68F5 5C02 83A7`](https://keybase.io/aliciasykes/pgp_keys.asc?fingerprint=0688f8d34587d954e9e51fb8fedb68f55c0283a7). You should receive a response within 48 hours.
If you think you've found a critical issue with Dashy, please send an email to `security@mail.alicia.omg.lol`. You can encrypt it, using [`0688 F8D3 4587 D954 E9E5 1FB8 FEDB 68F5 5C02 83A7`](https://keybase.io/aliciasykes/pgp_keys.asc?fingerprint=0688f8d34587d954e9e51fb8fedb68f55c0283a7). You should receive a response within 48 hours. For more information, see [SECURITY.md](https://github.com/Lissy93/dashy/blob/master/.github/SECURITY.md).
All non-critical issues can be raised as a ticket.

View File

@ -76,6 +76,12 @@
---
### Browser Startpage
![screenshot-startpage](https://i.ibb.co/rs07dS1/startpage.png)
---
### CFT Toolbox
![screenshot-cft-toolbox](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/3-cft-toolbox.png)

View File

@ -32,7 +32,11 @@
---
## `Refused to Connect` in Modal or Workspace View
This is not an issue with Dashy, but instead caused by the target app preventing direct access through embedded elements. It can be fixed by setting the [`X-Frame-Options`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options) HTTP header set to `ALLOW [path to Dashy]` or `SAMEORIGIN`, as defined in [RFC-7034](https://datatracker.ietf.org/doc/html/rfc7034). These settings are usually set in the config file for the web server that's hosting the target application, here are some examples of how to enable cross-origin access with common web servers:
This is not an issue with Dashy, but instead caused by the target app preventing direct access through embedded elements.
As defined in [RFC-7034](https://datatracker.ietf.org/doc/html/rfc7034), for any web content to be accessed through an embedded element, it must have the [`X-Frame-Options`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options) HTTP header set to `ALLOW`. If you are getting a `Refused to Connect` error then this header is set to `DENY` (or `SAMEORIGIN` and it's on a different host). Thankfully, for self-hosted services, it is easy to set these headers.
These settings are usually set in the config file for the web server that's hosting the target application, here are some examples of how to enable cross-origin access with common web servers:
### NGINX
In NGINX, you can use the [`add_header`](https://nginx.org/en/docs/http/ngx_http_headers_module.html) module within the app block.
@ -62,6 +66,12 @@ In Apache, you can use the [`mod_headers`](https://httpd.apache.org/docs/current
Header set X-Frame-Options: "ALLOW-FROM http://[dashy-location]/"
```
### LightHttpd
```
Content-Security-Policy: frame-ancestors 'self' https://[dashy-location]/
```
---
## 404 On Static Hosting

View File

@ -14,10 +14,12 @@ Dashy has support for displaying dynamic content in the form of widgets. There a
- [RSS Feed](#rss-feed)
- [Image](#image)
- [Public IP Address](#public-ip)
- [IP Blacklist Checker](#ip-blacklist)
- [Crypto Watch List](#crypto-watch-list)
- [Crypto Price History](#crypto-token-price-history)
- [Crypto Wallet Balance](#wallet-balance)
- [Code Stats](#code-stats)
- [Mullvad Status](#mullvad-status)
- [Email Aliases (AnonAddy)](#anonaddy)
- [Vulnerability Feed](#vulnerability-feed)
- [Exchange Rates](#exchange-rates)
@ -285,6 +287,37 @@ Or
---
### IP Blacklist
Notice certain web pages aren't loading? This widget quickly shows which blacklists your IP address (or host, or email) appears on, using data from [blacklistchecker.com](https://blacklistchecker.com/).
<p align="center"><img width="600" src="https://i.ibb.co/hX0fp5Z/ip-blacklist.png" /></p>
##### Options
**Field** | **Type** | **Required** | **Description**
--- | --- | --- | ---
**`ipAddress`** | `string` | _Optional_ | The IP to check. This can also be a domain/ host name or even an email address. If left blank, Dashy will use your current public IP address.
**`apiKey`** | `string` | Required | You can get your free API key from [blacklistchecker.com](https://blacklistchecker.com/keys)
##### Example
```yaml
- type: blacklist-check
options:
apiKey: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ipAddress: 1.1.1.1
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🔴 Required
- **Price**: 🟠 Free Plan
- **Host**: Managed Instance Only
- **Privacy**: _See [BlacklistChecker Privacy Policy](https://blacklistchecker.com/privacy)_
---
### Crypto Watch List
Keep track of price changes of your favorite crypto assets. Data is fetched from [CoinGecko](https://www.coingecko.com/). All fields are optional.
@ -433,6 +466,31 @@ Display your coding summary. [Code::Stats](https://codestats.net/) is a free and
---
### Mullvad Status
Shows your Mullvad VPN connection status, as well as server info. Fetched from [am.i.mullvad.net](https://mullvad.net/en/check/)
<p align="center"><img width="400" src="https://i.ibb.co/3BCb2YV/mullvad-check.png" /></p>
##### Options
_No Options_
##### Example
```yaml
- type: mullvad-status
```
##### Info
- **CORS**: 🟢 Enabled
- **Auth**: 🟢 Not Required
- **Price**: 🟢 Free
- **Host**: Managed
- **Privacy**: _See [Mullvad Privacy Policy](https://mullvad.net/en/help/privacy-policy/)_
---
### AnonAddy
[AnonAddy](https://anonaddy.com/) is a free and open source mail forwarding service. Use it to protect your real email address, by using a different alias for each of your online accounts, and have all emails land in your normal inbox(es). Supports custom domains, email replies, PGP-encryption, multiple recipients and more

View File

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

Binary file not shown.

View File

@ -10,6 +10,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="web-icons/favicon-32x32.png">
<link rel="icon" href="/favicon.ico" />
<link rel="icon" type="image/png" href="<%= BASE_URL %>favicon.ico" />
<link rel="stylesheet" type="text/css" href="<%= BASE_URL %>loading-screen.css" />
<!-- Default Page Title -->
<title>Dashy</title>
</head>
@ -18,46 +19,35 @@
<!-- built files will be auto injected -->
<div id="app">
<!-- Loading screen, will be replaced when app loaded -->
<div class="loading-placeholder" id="loader"><h1>Dashy</h1><p>Loading...</p></div>
<div class="loading-placeholder" id="loader">
<h1>Dashy</h1>
<p class="loading">Loading... </p>
<!-- Error message, only visible if app not mounted within 5 secs -->
<div class="catastrophic-error" id="err-wrap" style="display:none;">
<p class="err-l1">It looks like something's gone wrong...</p>
<p class="err-l2">
This is likely caused by the app source not being found at the current domain
</p>
<p class="err-l2">
If you need additional support, check the browser console then
<a href="https://github.com/Lissy93/dashy/blob/master/.github/SUPPORT.md">
raise a ticket
</a>
</p>
</div>
</div>
</div>
<!-- Devices without JS enabled -->
<noscript>
<strong>Sorry, JavaScript needs to be enabled to run Dashy 😥</strong>
</noscript>
<!-- Styles for loading screen -->
<style type="text/css">
body { margin: 0; }
#app .loading-placeholder {
position: absolute;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: progress;
background: #121212;
}
#app .loading-placeholder h1 {
font-size: 20vh;
font-family: Tahoma, monospace;
cursor: progress;
color: #0c0c0c;
text-shadow: 0px 4px 4px #090909, 0 0 0 #000, 0px 2px 2px #000000;
}
@media (max-width: 780px) {
.loading-placeholder h1 { font-size: 12vh !important; }
}
#app .loading-placeholder p {
font-size: 2rem;
font-family: monospace;
cursor: progress;
color: #0c0c0c;
text-shadow: 0 1px 1px #090909, 0 0 0 #000, 0 1px 1px #000000;
}
::selection { background-color: #db78fc; color: #121212; }
</style>
<!-- Show error message if app not mounted within reasonable time frame -->
<script>
setTimeout(() => {
const loaderElem = document.getElementById('loader');
if (loaderElem) loaderElem.classList.add('still-not-loaded');
}, 7500);
</script>
</body>
</html>
</html>

63
public/loading-screen.css Normal file
View File

@ -0,0 +1,63 @@
/* Styles applied to index.html for the loading screen, prior to the app being injected */
/* Dashy - Licensed under MIT, (C) Alicia Sykes 2022 */
body { margin: 0; }
#app .loading-placeholder {
position: absolute;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: progress;
background: #121212;
}
#app .loading-placeholder h1 {
font-size: 20vh;
margin: 1rem auto;
font-family: Tahoma, monospace;
cursor: progress;
color: #0c0c0c;
text-shadow: 0px 4px 4px #090909, 0 0 0 #000, 0px 2px 2px #000000;
}
#app .loading-placeholder p.loading {
font-size: 2rem;
opacity: 0.75;
font-family: monospace;
cursor: progress;
color: #0c0c0c;
display: flex;
flex-direction: column;
align-items: center;
text-shadow: 0 1px 1px #090909, 0 0 0 #000, 0 1px 1px #000000;
}
#app .loading-placeholder .catastrophic-error p {
color: #e11a4bfc;
margin: 0.5rem 0;
font-weight: bold;
font-size: 4vh;
text-align: center;
font-family: monospace;
text-shadow: 1px 2px 1px #090909, 0 0 0 #000, 0 1px 1px #000000
}
#app .loading-placeholder .catastrophic-error p.err-l2 {
opacity: 0.75;
font-size: 2vh;
font-weight: normal;
padding: 0 1rem;
}
#app .loading-placeholder .catastrophic-error p.err-l2 a {
color: #e11a4bfc;
}
#app .loading-placeholder.still-not-loaded { cursor: default; }
#app .loading-placeholder.still-not-loaded p.loading { display: none; }
#app .loading-placeholder.still-not-loaded .catastrophic-error { display: block !important; }
@media (max-width: 780px) {
.loading-placeholder h1 { font-size: 12vh !important; }
#app .loading-placeholder .catastrophic-error p { font-size: 2.5vh !important; }
#app .loading-placeholder .catastrophic-error p.err-l2 { font-size: 1.2vh !important; }
}
::selection { background-color: #e11a4bfc; color: #121212; }

View File

@ -29,7 +29,6 @@ const makeErrorMessage2 = (data) => '❌ Service Error - '
/* Kicks of a HTTP request, then formats and renders results */
const makeRequest = (url, options, render) => {
console.log(options);
const {
headers, enableInsecure, acceptCodes, maxRedirects,
} = options;

View File

@ -61,6 +61,7 @@ export default {
text-align: center;
height: fit-content;
margin: 10px;
min-width: 250px;
}
</style>

View File

@ -12,7 +12,8 @@
tabIndex="-1"
>
<label :for="sectionKey" class="lbl-toggle" tabindex="-1"
@mouseup.right="openContextMenu" @contextmenu.prevent>
@mouseup.right="openContextMenu" @contextmenu.prevent
@long-press="openContextMenu" v-longPress="500">
<Icon v-if="icon" :icon="icon" size="small" :url="title" class="section-icon" />
<h3>{{ title }}</h3>
<EditModeIcon v-if="isEditMode" @click="openEditModal"
@ -29,7 +30,7 @@
</template>
<script>
import longPress from '@/directives/LongPress';
import { localStorageKeys } from '@/utils/defaults';
import Icon from '@/components/LinkItems/ItemIcon.vue';
import EditModeIcon from '@/assets/interface-icons/interactive-editor-edit-mode.svg';
@ -53,6 +54,9 @@ export default {
EditModeIcon,
OpenIcon,
},
directives: {
longPress,
},
computed: {
isEditMode() {
return this.$store.state.editMode;

View File

@ -1,29 +1,32 @@
<template ref="container">
<div :class="`item-wrapper wrap-size-${itemSize}`">
<a @click="itemOpened"
@mouseup.right="openContextMenu"
<div :class="`item-wrapper wrap-size-${size} span-${makeColumnCount}`" >
<a @click="itemClicked"
@long-press="openContextMenu"
@contextmenu.prevent
:href="url"
@mouseup.right="openContextMenu"
v-longPress="true"
:href="item.url"
:target="anchorTarget"
:class="`item ${makeClassList}`"
v-tooltip="getTooltipOptions()"
rel="noopener noreferrer" tabindex="0"
:id="`link-${id}`"
:style="`--open-icon: ${getUnicodeOpeningIcon()}; color: ${color}; ${customStyles}`"
:id="`link-${item.id}`"
:style="customStyle"
>
<!-- Item Text -->
<div :class="`tile-title ${!icon? 'bounce no-icon': ''}`" :id="`tile-${id}`" >
<span class="text">{{ title }}</span>
<p class="description">{{ description }}</p>
<div :class="`tile-title ${!item.icon? 'bounce no-icon': ''}`" :id="`tile-${item.id}`" >
<span class="text">{{ item.title }}</span>
<p class="description">{{ item.description }}</p>
</div>
<!-- Item Icon -->
<Icon :icon="icon" :url="url" :size="itemSize" :color="color"
<Icon :icon="item.icon" :url="item.url" :size="size" :color="item.color"
v-bind:style="customStyles" class="bounce" />
<!-- Small icon, showing opening method on hover -->
<ItemOpenMethodIcon class="opening-method-icon" :isSmall="!icon || itemSize === 'small'"
<ItemOpenMethodIcon class="opening-method-icon"
:isSmall="!item.icon || size === 'small'"
:openingMethod="accumulatedTarget" position="bottom right"
:hotkey="hotkey" />
<!-- Status indicator dot (if enabled) showing weather srevice is availible -->
:hotkey="item.hotkey" />
<!-- Status indicator dot (if enabled) showing weather service is available -->
<StatusIndicator
class="status-indicator"
v-if="enableStatusCheck"
@ -39,23 +42,21 @@
v-click-outside="closeContextMenu"
:posX="contextPos.posX"
:posY="contextPos.posY"
:id="`context-menu-${id}`"
:id="`context-menu-${item.id}`"
@launchItem="launchItem"
@openItemSettings="openItemSettings"
@openMoveItemMenu="openMoveItemMenu"
@openDeleteItem="openDeleteItem"
/>
<!-- Edit and move item menu modals -->
<MoveItemTo v-if="isEditMode" :itemId="id" />
<EditItem v-if="editMenuOpen" :itemId="id"
<MoveItemTo v-if="isEditMode" :itemId="item.id" />
<EditItem v-if="editMenuOpen" :itemId="item.id"
@closeEditMenu="closeEditMenu"
:isNew="isAddNew" :parentSectionTitle="parentSectionTitle" />
</div>
</template>
<script>
import axios from 'axios';
import router from '@/router';
import Icon from '@/components/LinkItems/ItemIcon.vue';
import ItemOpenMethodIcon from '@/components/LinkItems/ItemOpenMethodIcon';
import StatusIndicator from '@/components/LinkItems/StatusIndicator';
@ -63,42 +64,20 @@ 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 ItemMixin from '@/mixins/ItemMixin';
// 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 { modalNames } from '@/utils/defaults';
export default {
name: 'Item',
mixins: [ItemMixin],
props: {
id: String, // The unique ID of a tile (e.g. 001)
title: String, // The main text of tile, required
subtitle: String, // Optional sub-text
description: String, // Optional tooltip hover text
icon: String, // Optional path to icon, within public/img/tile-icons
color: String, // Optional text and icon color, specified in hex code
backgroundColor: String, // Optional item background color
url: String, // URL to the resource, optional but recommended
provider: String, // Optional provider name, for external apps
hotkey: Number, // Shortcut for quickly launching app
target: { // Where resource will open, either 'newtab', 'sametab' or 'modal'
type: String,
validator: targetValidator,
},
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
statusCheckAcceptCodes: String, // Allow status checks to pass with a code other than 200
statusCheckMaxRedirects: Number, // Specify max number of redirects
itemSize: String,
parentSectionTitle: String, // Title of parent section (for add new)
isAddNew: Boolean, // Only set if 'fake' item used as Add New button
sectionWidth: Number, // Width of parent section
sectionDisplayData: Object,
},
components: {
Icon,
@ -110,121 +89,24 @@ export default {
EditModeIcon,
},
computed: {
appConfig() {
return this.$store.getters.appConfig;
},
isEditMode() {
return this.$store.state.editMode;
},
accumulatedTarget() {
return this.target || this.appConfig.defaultOpeningMethod || defaultOpeningMethod;
makeColumnCount() {
if ((this.sectionDisplayData || {}).itemCountX) return this.sectionDisplayData.itemCountX;
if (this.sectionWidth < 380) return 1;
if (this.sectionWidth < 520) return 2;
if (this.sectionWidth < 730) return 3;
if (this.sectionWidth < 1000) return 4;
if (this.sectionWidth < 1300) return 5;
return 0;
},
/* Based on item props, adjust class names */
makeClassList() {
const {
icon, itemSize, isAddNew, isEditMode,
} = this;
return `size-${itemSize} ${!icon ? 'short' : ''} `
+ `${isAddNew ? 'add-new' : ''} ${isEditMode ? 'is-edit-mode' : ''}`;
},
/* 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';
case 'newtab': return '_blank';
case 'parent': return '_parent';
case 'top': return '_top';
default: return undefined;
}
},
},
data() {
return {
contextMenuOpen: false,
getId: this.id,
customStyles: {
color: this.color,
background: this.backgroundColor,
},
statusResponse: undefined,
contextPos: {
posX: undefined,
posY: undefined,
},
editMenuOpen: false,
};
},
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
e.preventDefault();
this.openItemSettings();
return;
}
// For certain opening methods, prevent default and manually navigate
if (e.ctrlKey) {
e.preventDefault();
window.open(this.url, '_blank');
} else if (e.altKey || this.accumulatedTarget === 'modal') {
e.preventDefault();
this.$emit('triggerModal', this.url);
} else if (this.accumulatedTarget === 'workspace') {
e.preventDefault();
router.push({ name: 'workspace', query: { url: this.url } });
} else if (this.accumulatedTarget === 'clipboard') {
e.preventDefault();
navigator.clipboard.writeText(this.url);
this.$toasted.show(this.$t('context-menus.item.copied-toast'));
}
// Emit event to clear search field, etc
this.$emit('itemClicked');
// Update the most/ last used ledger, for smart-sorting
if (!this.appConfig.disableSmartSort) {
this.incrementMostUsedCount(this.id);
this.incrementLastUsedCount(this.id);
}
},
/* Open custom context menu, and set position */
openContextMenu(e) {
this.contextMenuOpen = !this.contextMenuOpen;
if (e && window) {
// Calculate placement based on cursor and scroll position
this.contextPos = {
posX: e.clientX + window.pageXOffset,
posY: e.clientY + window.pageYOffset,
};
}
},
/* Closes the context menu, called when user clicks literally anywhere */
closeContextMenu() {
this.contextMenuOpen = false;
},
/* Returns configuration object for the tooltip */
getTooltipOptions() {
if (!this.description && !this.provider) return {}; // If no description, then skip
const description = this.description ? this.description : '';
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: (this.isEditMode ? editText : tooltipText),
trigger: 'hover focus',
hideOnTargetClick: true,
html: true,
placement: this.statusResponse ? 'left' : 'auto',
delay: { show: 600, hide: 200 },
classes: `item-description-tooltip tooltip-is-${this.itemSize}`,
};
const { isAddNew, isEditMode, size } = this;
const { icon } = this.item;
return `size-${size} ${!icon ? 'short' : ''} `
+ `${isAddNew ? 'add-new' : ''} ${isEditMode ? 'is-edit-mode' : ''}`;
},
/* Used by certain themes (material), to show animated CSS icon */
getUnicodeOpeningIcon() {
unicodeOpeningIcon() {
switch (this.accumulatedTarget) {
case 'newtab': return '"\\f360"';
case 'sametab': return '"\\f24d"';
@ -236,72 +118,32 @@ export default {
default: return '"\\f054"';
}
},
/* Pulls together all user options, returns URL + Get params for ping endpoint */
makeApiUrl() {
const {
url,
statusCheckUrl,
statusCheckHeaders,
statusCheckAllowInsecure,
statusCheckAcceptCodes,
statusCheckMaxRedirects,
} = this;
const encode = (str) => encodeURIComponent(str);
this.statusResponse = undefined;
// Find base URL, where the API is hosted
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
// Find correct URL to check, and encode
const urlToCheck = `?&url=${encode(statusCheckUrl || url)}`;
// Get, stringify and encode any headers
const headers = statusCheckHeaders
? `&headers=${encode(JSON.stringify(statusCheckHeaders))}` : '';
// Deterimine if user disabled security
const enableInsecure = statusCheckAllowInsecure ? '&enableInsecure=true' : '';
const acceptCodes = statusCheckAcceptCodes ? `&acceptCodes=${statusCheckAcceptCodes}` : '';
const maxRedirects = statusCheckMaxRedirects ? `&maxRedirects=${statusCheckMaxRedirects}` : '';
// Construct the full API endpoint's URL with GET params
return `${baseUrl}${serviceEndpoints.statusCheck}/${urlToCheck}`
+ `${headers}${enableInsecure}${acceptCodes}${maxRedirects}`;
},
data() {
return {
editMenuOpen: false,
};
},
methods: {
/* Returns configuration object for the tooltip */
getTooltipOptions() {
if (!this.item.description && !this.item.provider) return {}; // If no description, then skip
const description = this.item.description || '';
const providerText = this.item.provider ? `<b>Provider</b>: ${this.item.provider}` : '';
const lb1 = description && providerText ? '<br>' : '';
const hotkeyText = this.item.hotkey ? `<br>Press '${this.item.hotkey}' to launch` : '';
const tooltipText = providerText + lb1 + description + hotkeyText;
const editText = this.$t('interactive-editor.edit-section.edit-tooltip');
return {
content: (this.isEditMode ? editText : tooltipText),
trigger: 'hover focus',
hideOnTargetClick: true,
html: true,
placement: this.statusResponse ? 'left' : 'auto',
delay: { show: 600, hide: 200 },
classes: `item-description-tooltip tooltip-is-${this.size}`,
};
},
/* Checks if a given service is currently online */
checkWebsiteStatus() {
const endpoint = this.makeApiUrl();
axios.get(endpoint)
.then((response) => {
if (response.data) this.statusResponse = response.data;
})
.catch(() => { // Something went very wrong.
this.statusResponse = {
statusText: 'Failed to make request',
statusSuccess: false,
};
});
},
/* Handle navigation options from the context menu */
launchItem(method) {
const { url } = this;
this.contextMenuOpen = false;
switch (method) {
case 'newtab':
window.open(url, '_blank');
break;
case 'sametab':
window.open(url, '_self');
break;
case 'modal':
this.$emit('triggerModal', url);
break;
case 'workspace':
router.push({ name: 'workspace', query: { url } });
break;
case 'clipboard':
navigator.clipboard.writeText(url);
this.$toasted.show(this.$t('context-menus.item.copied-toast'));
break;
default: window.open(url, '_blank');
}
},
/* Open the Edit Item moal form */
openItemSettings() {
this.editMenuOpen = true;
this.contextMenuOpen = false;
@ -314,30 +156,16 @@ export default {
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) || '{}');
let counter = mostUsed[itemId] || 0;
counter += 1;
mostUsed[itemId] = counter;
localStorage.setItem(localStorageKeys.MOST_USED, JSON.stringify(mostUsed));
},
/* Used for smart-sort when sorting by last used apps */
incrementLastUsedCount(itemId) {
const lastUsed = JSON.parse(localStorage.getItem(localStorageKeys.LAST_USED) || '{}');
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.$modal.show(`${modalNames.MOVE_ITEM_TO}-${this.item.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 };
const parentSection = this.$store.getters.getParentSectionOfItem(this.item.id);
const payload = { itemId: this.item.id, sectionName: parentSection.name };
this.$store.commit(StoreKeys.REMOVE_ITEM, payload);
this.closeContextMenu();
},
@ -361,6 +189,17 @@ export default {
&.wrap-size-large {
flex-basis: 12rem;
}
&.wrap-size-small {
flex-grow: revert;
&.span-1 { min-width: 100%; }
&.span-2 { min-width: 50%; }
&.span-3 { min-width: 33%; }
&.span-4 { min-width: 25%; }
&.span-5 { min-width: 20%; }
&.span-6 { min-width: 16%; }
&.span-7 { min-width: 14%; }
&.span-8 { min-width: 12.5%; }
}
}
.item {
@ -383,20 +222,16 @@ export default {
box-shadow: var(--item-hover-shadow);
background: var(--item-background-hover);
color: var(--item-text-color-hover);
// position: relative;
// .tile-title span.text {
// white-space: pre-wrap;
// }
}
&:focus {
outline: 2px solid var(--primary);
}
&.short:not(.size-large) {
height: 2rem;
}
&.add-new {
border: 2px dashed var(--primary) !important;
}
&.short:not(.size-large) {
height: 2rem;
}
}
/* Text in tile */
@ -445,13 +280,13 @@ export default {
}
}
/* Apply transofmation of icons on hover */
/* Apply transformation of icons on hover */
.tile-icon, .tile-svg {
filter: var(--item-icon-transform-hover);
}
}
/* Edit Mode Icon */
/* Edit icon, visible in edit mode */
.item .edit-mode-item {
width: 1rem;
height: 1rem;
@ -460,6 +295,10 @@ export default {
right: 0.2rem;
}
p.description {
display: none; // By default, we don't show the description
}
/* Specify layout for alternate sized icons */
.item {
/* Small Tile Specific Themes */
@ -469,8 +308,8 @@ export default {
justify-content: flex-end;
align-items: center;
height: 2rem;
padding-top: 4px;
max-width: 14rem;
padding-top: 0.25rem;
padding-left: 0.5rem;
div img {
width: 2rem;
}
@ -541,9 +380,6 @@ export default {
}
}
}
p.description {
display: none; // By default, we don't show the description
}
&:before { // Certain themes (e.g. material) show css animated fas icon on hover
display: none;
font-family: FontAwesome;

View File

@ -77,6 +77,7 @@ export default {
posX: Number, // The X coordinate for positioning
posY: Number, // The Y coordinate for positioning
show: Boolean, // Should show or hide the menu
disableEdit: Boolean, // Disable editing for certain items
},
computed: {
isMenuDisabled() {
@ -86,6 +87,7 @@ export default {
return this.$store.state.editMode;
},
isEditAllowed() {
if (this.disableEdit) return false;
return this.$store.getters.permissions.allowViewConfig;
},
},

View File

@ -1,5 +1,5 @@
<template>
<Collapsable
<Collapsable
:title="title"
:icon="icon"
:uniqueKey="groupId"
@ -11,6 +11,8 @@
:cutToHeight="displayData.cutToHeight"
@openEditSection="openEditSection"
@openContextMenu="openContextMenu"
:id="sectionRef"
:ref="sectionRef"
>
<!-- If no items, show message -->
<div v-if="isEmpty" class="no-items">
@ -21,42 +23,41 @@
:class="`there-are-items ${isGridLayout? 'item-group-grid': ''} inner-size-${itemSize}`"
:style="gridStyle" :id="`section-${groupId}`"
> <!-- Show for each item -->
<Item
v-for="(item) in sortedItems"
:id="item.id"
:key="item.id"
:url="item.url"
:title="item.title"
:description="item.description"
:icon="item.icon"
:target="item.target"
:color="item.color"
:backgroundColor="item.backgroundColor"
:statusCheckUrl="item.statusCheckUrl"
:statusCheckHeaders="item.statusCheckHeaders"
:itemSize="itemSize"
:hotkey="item.hotkey"
:provider="item.provider"
:parentSectionTitle="title"
:enableStatusCheck="item.statusCheck !== undefined ? item.statusCheck : enableStatusCheck"
:statusCheckInterval="statusCheckInterval"
:statusCheckAllowInsecure="item.statusCheckAllowInsecure"
:statusCheckAcceptCodes="item.statusCheckAcceptCodes"
:statusCheckMaxRedirects="item.statusCheckMaxRedirects"
@itemClicked="$emit('itemClicked')"
@triggerModal="triggerModal"
:isAddNew="false"
/>
<template v-for="(item) in sortedItems">
<SubItemGroup
v-if="item.subItems"
:key="item.id"
:itemId="item.id"
:title="item.title"
:subItems="item.subItems"
@triggerModal="triggerModal"
/>
<Item
v-else
:item="item"
:key="item.id"
:itemSize="itemSize"
:parentSectionTitle="title"
@itemClicked="$emit('itemClicked')"
@triggerModal="triggerModal"
:isAddNew="false"
:sectionWidth="sectionWidth"
:sectionDisplayData="displayData"
/>
</template>
<!-- When in edit mode, show additional item, for Add New item -->
<Item v-if="isEditMode"
:item="{
icon: ':heavy_plus_sign:',
title: 'Add New Item',
description: 'Click to add new item',
id: 'add-new',
}"
: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"
:sectionWidth="sectionWidth"
:itemSize="itemSize"
/>
</div>
@ -101,6 +102,7 @@
<script>
import router from '@/router';
import Item from '@/components/LinkItems/Item.vue';
import SubItemGroup from '@/components/LinkItems/SubItemGroup.vue';
import WidgetBase from '@/components/Widgets/WidgetBase';
import Collapsable from '@/components/LinkItems/Collapsable.vue';
import IframeModal from '@/components/LinkItems/IframeModal.vue';
@ -130,6 +132,7 @@ export default {
Collapsable,
ContextMenu,
Item,
SubItemGroup,
WidgetBase,
IframeModal,
EditSection,
@ -142,6 +145,8 @@ export default {
posX: undefined,
posY: undefined,
},
sectionWidth: 0,
resizeObserver: null,
};
},
computed: {
@ -167,6 +172,9 @@ export default {
isEmpty() {
return !this.hasItems && !this.hasWidgets;
},
sectionRef() {
return `section-outer-${this.groupId}`;
},
/* If the sortBy attribute is specified, then return sorted data */
sortedItems() {
let { items } = this;
@ -200,18 +208,6 @@ export default {
}
return styles;
},
/* Determines if user has enabled online status checks */
enableStatusCheck() {
return this.appConfig.statusCheck || false;
},
/* Determine how often to re-fire status checks */
statusCheckInterval() {
let interval = this.appConfig.statusCheckInterval;
if (!interval) return 0;
if (interval > 60) interval = 60;
if (interval < 1) interval = 0;
return interval;
},
},
methods: {
/* Opens the iframe modal */
@ -279,18 +275,35 @@ export default {
},
/* 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,
};
}
this.contextMenuOpen = true; // Open context menu
// If mouse position not set, use section coordinates
const sectionOuterId = `section-outer-${this.groupId}`;
const sectionPosition = document.getElementById(sectionOuterId).getBoundingClientRect();
this.contextPos = {
posX: (e.clientX || sectionPosition.right - 10) + window.pageXOffset,
posY: (e.clientY || sectionPosition.top + 30) + window.pageYOffset,
};
},
/* Hide the right-click context menu */
closeContextMenu() {
this.contextMenuOpen = false;
},
/* Calculate width of section, used to dynamically set number of columns */
calculateSectionWidth() {
const secElem = this.$refs[this.sectionRef];
if (secElem) this.sectionWidth = secElem.$el.clientWidth;
},
},
mounted() {
// Set the section width, and recalculate when section resized
this.resizeObserver = new ResizeObserver(this.calculateSectionWidth)
.observe(this.$refs[this.sectionRef].$el);
},
beforeDestroy() {
// If resize observer set, and element still present, then de-register
if (this.resizeObserver && this.$refs[this.sectionRef]) {
this.resizeObserver.unobserve(this.$refs[this.sectionRef].$el);
}
},
};
</script>

View File

@ -0,0 +1,76 @@
<template ref="container">
<div class="sub-item-wrapper">
<a @click="itemClicked"
@contextmenu.prevent
@long-press="openContextMenu"
@mouseup.right="openContextMenu"
v-longPress="true"
:href="hyperLinkHref"
:target="anchorTarget"
v-tooltip="subItemTooltip"
rel="noopener noreferrer" tabindex="0"
:id="`link-${id}`"
class="sub-item-link item"
>
<!-- Item Icon -->
<Icon :icon="item.icon" :url="item.url"
size="small" v-bind:style="customStyles" class="sub-icon-img bounce" />
</a>
<!-- Right-click context menu -->
<ContextMenu
:show="contextMenuOpen && !isAddNew"
v-click-outside="closeContextMenu"
:posX="contextPos.posX"
:posY="contextPos.posY"
:id="`context-menu-${id}`"
:disableEdit="true"
@launchItem="launchItem"
/>
</div>
</template>
<script>
import Icon from '@/components/LinkItems/ItemIcon.vue';
import ContextMenu from '@/components/LinkItems/ItemContextMenu';
import ItemMixin from '@/mixins/ItemMixin';
// import { targetValidator } from '@/utils/ConfigHelpers';
export default {
name: 'Item',
mixins: [ItemMixin],
props: {
id: String, // The unique ID of a tile (e.g. 001)
item: Object,
},
components: {
Icon,
ContextMenu,
},
computed: {
subItemTooltip() {
return this.title;
},
},
data() {
return {};
},
methods: {},
};
</script>
<style lang="scss">
.sub-item-wrapper {
flex-grow: 1;
flex-basis: 6rem;
display: flex;
a.sub-item-link {
margin: 0.2rem;
.sub-icon-img {
margin: 0;
}
}
&.wrap-size-large {
flex-basis: 12rem;
}
}
</style>

View File

@ -0,0 +1,67 @@
<template>
<div class="sub-items-group" :style="`--sub-item-col-count: ${columnCount}`">
<p v-if="title" class="sub-item-group-title">{{ title }}</p>
<SubItem
v-for="(subItem, subIndex) in subItems"
:key="subIndex"
:id="`${itemId}-sub-${subIndex}`"
:item="subItem"
@triggerModal="triggerModal"
/>
</div>
</template>
<script>
import SubItem from '@/components/LinkItems/SubItem.vue';
export default {
props: {
itemId: String,
subItems: Array,
title: String,
subItemGridSize: Number,
},
components: {
SubItem,
},
computed: {
/* Determine number of columns to split items into, depending on number of items */
columnCount() {
if (this.subItemGridSize) return this.subItemGridSize;
const numItems = this.subItems.length;
if (numItems >= 10) return 4;
if (numItems >= 5) return 3;
if (numItems >= 2) return 2;
if (numItems === 1) return 1;
return 2;
},
},
methods: {
/* Pass open modal emit event up */
triggerModal(url) {
this.$emit('triggerModal', url);
},
},
};
</script>
<style scoped lang="scss">
.sub-items-group {
display: grid;
margin: 0.5rem;
padding: 0.1rem;
flex-grow: 1;
flex-basis: 6rem;
grid-template-columns: repeat(var(--sub-item-col-count, 3), minmax(0, 1fr));
color: var(--item-text-color);
border: 1px solid var(--outline-color);
border-radius: var(--curve-factor);
text-decoration: none;
transition: all 0.2s ease-in-out 0s;
p.sub-item-group-title {
margin: 0 auto;
cursor: default;
grid-column-start: span var(--sub-item-col-count, 3);
}
}
</style>

View File

@ -1,29 +1,27 @@
<template>
<div :class="`minimal-section-inner ${selected ? 'selected' : ''} ${showAll ? 'show-all': ''}`">
<div :class="`minimal-section-inner ${selected ? 'selected' : ''} ${showAll ? 'show-all': ''}`">
<div class="section-items" v-if="items && (selected || showAll)">
<Item
v-for="(item, index) in items"
:id="`${index}_${makeId(item.title)}`"
:key="`${index}_${makeId(item.title)}`"
:url="item.url"
:title="item.title"
:description="item.description"
:icon="item.icon"
:target="item.target"
:color="item.color"
:backgroundColor="item.backgroundColor"
:statusCheckUrl="item.statusCheckUrl"
:statusCheckHeaders="item.statusCheckHeaders"
:itemSize="itemSize"
:hotkey="item.hotkey"
:enableStatusCheck="shouldEnableStatusCheck(item.statusCheck)"
:statusCheckAllowInsecure="item.statusCheckAllowInsecure"
:statusCheckAcceptCodes="item.statusCheckAcceptCodes"
:statusCheckMaxRedirects="item.statusCheckMaxRedirects"
:statusCheckInterval="getStatusCheckInterval()"
@itemClicked="$emit('itemClicked')"
@triggerModal="triggerModal"
/>
<template v-for="(item) in items">
<SubItemGroup
v-if="item.subItems"
:key="item.id"
:itemId="item.id"
:title="item.title"
:subItems="item.subItems"
@triggerModal="triggerModal"
/>
<Item
v-else
:item="item"
:key="item.id"
:itemSize="itemSize"
:parentSectionTitle="title"
@itemClicked="$emit('itemClicked')"
@triggerModal="triggerModal"
:isAddNew="false"
:sectionDisplayData="displayData"
/>
</template>
</div>
<div v-if="widgets && (selected && !showAll)" class="minimal-widget-wrap">
<WidgetBase
@ -34,6 +32,9 @@
@navigateToSection="navigateToSection"
/>
</div>
<div v-if="selected && !showAll && !widgets && items.length < 1" class="empty-section">
<p>{{ $t('home.no-items-section') }}</p>
</div>
<IframeModal
:ref="`iframeModal-${groupId}`"
:name="`iframeModal-${groupId}`"
@ -46,6 +47,7 @@
import router from '@/router';
import Item from '@/components/LinkItems/Item.vue';
import WidgetBase from '@/components/Widgets/WidgetBase';
import SubItemGroup from '@/components/LinkItems/SubItemGroup.vue';
import IframeModal from '@/components/LinkItems/IframeModal.vue';
export default {
@ -71,6 +73,7 @@ export default {
components: {
Item,
WidgetBase,
SubItemGroup,
IframeModal,
},
methods: {
@ -79,6 +82,7 @@ export default {
},
/* Returns a unique lowercase string, based on name, for section ID */
makeId(str) {
if (!str) return 'unnamed-item';
return str.replace(/\s+/g, '-').replace(/[^a-zA-Z ]/g, '').toLowerCase();
},
/* Opens the iframe modal */
@ -101,7 +105,6 @@ export default {
const parse = (section) => section.replace(' ', '-').toLowerCase().trim();
const sectionIdentifier = parse(this.title);
router.push({ path: `/home/${sectionIdentifier}` });
this.closeContextMenu();
},
},
};
@ -131,9 +134,18 @@ export default {
.minimal-widget-wrap {
padding: 1rem;
}
.empty-section {
padding: 1rem;
margin: 0.5rem auto;
color: var(--minimal-view-group-color);
font-size: 1rem;
font-style: italic;
opacity: var(--dimming-factor);
}
&.selected {
border: 1px solid var(--minimal-view-group-color);
grid-column-start: span var(--col-count, 3);
&:not(.show-all) { min-height: 300px; }
}
&.show-all {
border: none;

View File

@ -1,5 +1,5 @@
<template>
<div class="nav-outer">
<div class="nav-outer" v-if="links && links.length > 0">
<IconBurger
:class="`burger ${!navVisible ? 'visible' : ''}`"
@click="navVisible = !navVisible"

View File

@ -1,5 +1,5 @@
<template>
<section>
<section v-bind:class="{ 'settings-hidden': !settingsVisible }">
<SearchBar ref="SearchBar"
@user-is-searchin="userIsTypingSomething"
v-if="searchVisible"

View File

@ -0,0 +1,137 @@
<template>
<div class="blacklist-check-wrapper" v-if="blacklisted">
<p v-if="message" class="summary-msg">{{ message }}</p>
<template v-if="showAll || (blacklisted && blacklistFiltered.length > 0)">
<div v-for="blacklist in blacklistFiltered" :key="blacklist.id" class="blacklist-row">
<span v-if="blacklist.detected" class="status detected"></span>
<span v-else class="status not-detected"></span>
<span>{{ blacklist.name }}</span>
</div>
</template>
<div v-else class="all-clear">
<p>No Detections Found</p>
<span class="tick"></span>
</div>
<p class="toggle-view-all" @click="showAll = !showAll">
{{ showAll ? $t('widgets.general.show-less') : $t('widgets.general.show-more') }}
</p>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
computed: {
version() {
return this.options.version || 'v2';
},
ipAddress() {
if (this.autoIp) return this.autoIp;
if (!this.options.apiKey) this.error('Missing IP Address');
return this.options.ipAddress;
},
apiKey() {
if (!this.options.apiKey) this.error('Missing API Key');
return this.options.apiKey;
},
endpoint() {
return `${widgetApiEndpoints.blacklistCheck}/${this.ipAddress}`;
},
blacklistFiltered() {
return this.showAll ? this.blacklisted : this.blacklisted.filter(bl => (bl.detected));
},
},
data() {
return {
blacklisted: null,
message: '',
showAll: false,
autoIp: null,
};
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
if (!this.ipAddress) {
this.getUsersIpAddress(); return;
}
this.defaultTimeout = 20000;
const options = { Authorization: `Basic ${this.apiKey}` };
this.makeRequest(this.endpoint, options).then(this.processData);
},
/* Assign data variables to the returned data */
processData(blResponse) {
this.message = `${blResponse.detections} detections found for ${blResponse.ip_address}`;
this.blacklisted = blResponse.blacklists;
},
getUsersIpAddress() {
this.makeRequest(widgetApiEndpoints.publicIp)
.then((ipInfo) => {
this.autoIp = ipInfo.ip;
this.fetchData();
});
},
},
};
</script>
<style scoped lang="scss">
.blacklist-check-wrapper {
color: var(--widget-text-color);
padding: 0.25rem;
cursor: default;
max-height: 2800px;
overflow: auto;
.blacklist-row {
display: flex;
align-items: center;
.status {
width: 1rem;
height: 1rem;
padding: 0 0.25rem 0.5rem 0.25rem;
margin: 0.1rem 0.5rem 0.1rem 0.1rem;
border: 1px solid var(--widget-text-color);
border-radius: 1rem;
text-align: center;
&.detected { color: var(--danger); border-color: var(--danger); }
&.not-detected { color: var(--success); border-color: var(--success); }
}
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
p.summary-msg {
font-size: 0.85rem;
margin: 0.2rem auto;
font-style: italic;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
}
p.toggle-view-all {
text-align: center;
margin: 0.5rem auto;
padding: 0.5rem;
border-radius: var(--curve-factor);
border: 1px dashed transparent;
background: var(--widget-accent-color);
cursor: pointer;
&:hover {
border-color: var(--widget-text-color);
}
}
.all-clear {
color: var(--success);
text-align: center;
.tick {
font-size: 2rem;
margin: 0 auto;
border-radius: 1.5rem;
padding: 0.3rem 0.8rem;
background: var(--success);
color: var(--white);
}
}
}
</style>

View File

@ -0,0 +1,111 @@
<template>
<div class="mullvad-wrapper" v-if="mullvadInfo">
<p v-if="mullvadInfo.isMullvad" class="status connected"><span></span> Connected</p>
<p v-else class="status not-connected"><span></span> Not Connected</p>
<div class="connection-info">
<p><span class="lbl">IP</span><span class="val">{{ mullvadInfo.ip }}</span></p>
<p v-if="mullvadInfo.host">
<span class="lbl">Host</span><span class="val">{{ mullvadInfo.host }}</span>
</p>
<p><span class="lbl">Owner</span><span class="val">{{ mullvadInfo.ownedBy }}</span></p>
<p v-if="mullvadInfo.serverType">
<span class="lbl">Type</span><span class="val">{{ mullvadInfo.serverType }}</span>
</p>
<p><span class="lbl">Location</span><span class="val">{{ mullvadInfo.location }}</span></p>
<p>
<span class="lbl">Blacklisted?</span>
<span class="val">{{ mullvadInfo.isBlacklisted ? '✘ Yes' : '✔ No' }}</span>
</p>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
computed: {
endpoint() {
return widgetApiEndpoints.mullvad;
},
},
data() {
return {
mullvadInfo: null,
};
},
methods: {
/* Make GET request to Mullvad API endpoint */
fetchData() {
this.makeRequest(this.endpoint).then(this.processData);
},
/* Assign data variables to the returned data */
processData(mullvad) {
this.mullvadInfo = {
ip: mullvad.ip,
isMullvad: mullvad.mullvad_exit_ip,
host: mullvad.mullvad_exit_ip_hostname,
serverType: mullvad.mullvad_server_type,
ownedBy: mullvad.organization,
location: `${mullvad.city}, ${mullvad.country}`,
isBlacklisted: mullvad.blacklisted.blacklisted,
};
},
},
};
</script>
<style scoped lang="scss">
.mullvad-wrapper {
color: var(--widget-text-color);
cursor: default;
.status {
display: flex;
max-width: 250px;
font-size: 1.5rem;
font-weight: bold;
align-items: center;
margin: 0.25rem auto;
justify-content: space-evenly;
span {
font-size: 1.5rem;
border-radius: 1.5rem;
padding: 0.3rem 0.7rem;
border: 1px solid;
color: var(--background);
}
&.not-connected {
color: var(--danger);
span { background: var(--danger); }
}
&.connected {
color: var(--success);
span { background: var(--success); }
}
}
.connection-info {
p {
display: flex;
max-width: 250px;
font-size: 0.9rem;
padding: 0.2rem;
margin: 0.2rem auto;
justify-content: space-between;
opacity: var(--dimming-factor);
span {
&.lbl {
font-weight: bold;
}
&.val {
font-family: monospace;
}
}
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
}
}
</style>

View File

@ -34,6 +34,13 @@
@error="handleError"
:ref="widgetRef"
/>
<BlacklistCheck
v-else-if="widgetType === 'blacklist-check'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<Clock
v-else-if="widgetType === 'clock'"
:options="widgetOptions"
@ -244,6 +251,13 @@
@error="handleError"
:ref="widgetRef"
/>
<MullvadStatus
v-else-if="widgetType === 'mullvad-status'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<NdCpuHistory
v-else-if="widgetType === 'nd-cpu-history'"
:options="widgetOptions"
@ -402,6 +416,7 @@ export default {
// Register widget components
AnonAddy: () => import('@/components/Widgets/AnonAddy.vue'),
Apod: () => import('@/components/Widgets/Apod.vue'),
BlacklistCheck: () => import('@/components/Widgets/BlacklistCheck.vue'),
Clock: () => import('@/components/Widgets/Clock.vue'),
CodeStats: () => import('@/components/Widgets/CodeStats.vue'),
CovidStats: () => import('@/components/Widgets/CovidStats.vue'),
@ -432,6 +447,7 @@ export default {
IframeWidget: () => import('@/components/Widgets/IframeWidget.vue'),
ImageWidget: () => import('@/components/Widgets/ImageWidget.vue'),
Jokes: () => import('@/components/Widgets/Jokes.vue'),
MullvadStatus: () => import('@/components/Widgets/MullvadStatus.vue'),
NdCpuHistory: () => import('@/components/Widgets/NdCpuHistory.vue'),
NdLoadHistory: () => import('@/components/Widgets/NdLoadHistory.vue'),
NdRamHistory: () => import('@/components/Widgets/NdRamHistory.vue'),
@ -476,7 +492,7 @@ export default {
/* Returns users specified widget options, or empty object */
widgetOptions() {
const options = this.widget.options || {};
const timeout = this.widget.timeout || 2500;
const timeout = this.widget.timeout || null;
const useProxy = this.appConfig.widgetsAlwaysUseProxy || !!this.widget.useProxy;
const updateInterval = this.widget.updateInterval !== undefined
? this.widget.updateInterval : null;
@ -520,6 +536,8 @@ export default {
.widget-base {
position: relative;
padding: 0.75rem 0.5rem 0.5rem 0.5rem;
background: var(--widget-base-background);
box-shadow: var(--widget-base-shadow, none);
// Refresh and full-page action buttons
button.action-btn {
height: 1rem;

View File

@ -1,6 +1,7 @@
<template>
<nav class="side-bar">
<div v-for="(section, index) in sections" :key="index" class="side-bar-section">
<!-- Section button -->
<div @click="openSection(index)" class="side-bar-item-container">
<SideBarItem
class="item"
@ -8,6 +9,7 @@
:title="section.name"
/>
</div>
<!-- Section inner -->
<transition name="slide">
<SideBarSection
v-if="isOpen[index]"
@ -16,6 +18,7 @@
/>
</transition>
</div>
<!-- Show links for switching back to Home / Minimal views -->
<div class="switch-view-buttons">
<router-link to="/home">
<IconHome class="view-icon" v-tooltip="$t('alternate-views.default')" />
@ -66,8 +69,12 @@ export default {
if (!this.initUrl) return;
const process = (url) => (url ? url.replace(/[^\w\s]/gi, '').toLowerCase() : undefined);
const compare = (item) => (process(item.url) === process(this.initUrl));
this.sections.forEach((section, secIndex) => {
if (section.items && section.items.findIndex(compare) !== -1) this.openSection(secIndex);
this.sections.forEach((section, secIndx) => {
if (!section.items) return; // Cancel if no items
if (section.items.findIndex(compare) !== -1) this.openSection(secIndx);
section.items.forEach((item) => { // Do the same for sub-items, if set
if (item.subItems && item.subItems.findIndex(compare) !== -1) this.openSection(secIndx);
});
});
},
},

View File

@ -2,6 +2,7 @@
<div class="sub-side-bar">
<div v-for="(item, index) in items" :key="index">
<SideBarItem
v-if="!item.subItems"
class="item"
:icon="item.icon"
:title="item.title"
@ -9,6 +10,18 @@
:target="item.target"
@launch-app="launchApp"
/>
<div v-if="item.subItems" class="sub-item-group">
<SideBarItem
v-for="(subItem, subIndex) in item.subItems"
:key="subIndex"
class="item sub-item"
:icon="subItem.icon"
:title="subItem.title"
:url="subItem.url"
:target="subItem.target"
@launch-app="launchApp"
/>
</div>
</div>
</div>
</template>
@ -46,8 +59,10 @@ div.sub-side-bar {
color: var(--side-bar-color);
text-align: center;
z-index: 3;
.item:not(:last-child) {
border-bottom: 1px dashed var(--side-bar-color);
.sub-item-group {
border: 1px dotted var(--side-bar-color);
border-radius: 4px;
background: #00000033;
}
}

View File

@ -33,7 +33,7 @@ export default {
width: calc(100% - var(--side-bar-width));
.workspace-widget {
max-width: 800px;
margin: 0 auto;
margin: 0.5rem auto 1rem auto;
}
}
</style>

View File

@ -0,0 +1,41 @@
/**
* A Vue directive to trigger an event when the user
* clicks anywhere other than the specified elements
* Used to close context menus popup modals and tips
* Dashy: Licensed under MIT - (C) Alicia Sykes 2022
*/
const instances = []; // List of click event instances
/* Trigger action when click anywhere, except target elem */
function onDocumentClick(event, elem, action) {
const { target } = event;
if (elem !== target && !elem.contains(target)) {
action(event);
}
}
export default {
/* Add event listeners */
bind(element, binding) {
const elem = element;
elem.dataset.outsideClickIndex = instances.length;
const action = binding.value;
const click = (event) => {
onDocumentClick(event, elem, action);
};
document.addEventListener('click', click);
document.addEventListener('touchstart', click);
instances.push(click);
},
/* Remove event listeners */
unbind(elem) {
if (!elem.dataset) return;
const index = elem.dataset.outsideClickIndex;
const handler = instances[index];
document.removeEventListener('click', handler);
instances.splice(index, 1);
},
};

View File

@ -0,0 +1,62 @@
/**
* A Vue directive to call event when element is long-pressed
* Used to open context menus on touch-enabled devices
* Inspired by: FeliciousX/vue-directive-long-press
* Dashy: Licensed under MIT - (C) Alicia Sykes 2022
*/
const LONG_PRESS_DEFAULT_DELAY = 750;
const longPressEvent = new CustomEvent('long-press');
let startTime = null;
export default {
bind(element, binding, vnode) {
const el = element;
el.dataset.longPressTimeout = null;
const swallowClick = (e) => {
el.removeEventListener('click', swallowClick);
if (!el.dataset.elapsed) return true;
const totalTime = Date.now() - startTime;
// If was long press, then cancel original action
if (totalTime > LONG_PRESS_DEFAULT_DELAY) {
e.preventDefault();
e.stopPropagation();
}
return false;
};
/* Emit event to component */
const triggerEvent = () => {
if (vnode.componentInstance) vnode.componentInstance.$emit('long-press');
else el.dispatchEvent(longPressEvent);
el.dataset.elapsed = true;
};
const onPointerUp = () => {
clearTimeout(parseInt(el.dataset.longPressTimeout, 10));
document.removeEventListener('pointerup', onPointerUp);
};
const onPointerDown = (e) => {
// If event was right-click, then immediately trigger
if (e.button === 2) return;
startTime = Date.now();
document.addEventListener('pointerup', onPointerUp);
el.addEventListener('click', swallowClick);
const timeoutDuration = LONG_PRESS_DEFAULT_DELAY;
const timeout = setTimeout(triggerEvent, timeoutDuration);
el.dataset.elapsed = false;
el.dataset.longPressTimeout = timeout;
e.preventDefault();
};
el.$longPressHandler = onPointerDown;
el.addEventListener('pointerdown', onPointerDown);
},
unbind(el) {
startTime = null;
clearTimeout(parseInt(el.dataset.longPressTimeout, 10));
el.removeEventListener('pointerdown', el.$longPressHandler);
},
};

View File

@ -16,9 +16,9 @@ import Dashy from '@/App.vue'; // Main Dashy Vue app
import router from '@/router'; // Router, for navigation
import store from '@/store'; // Store, for local state management
import serviceWorker from '@/utils/InitServiceWorker'; // Service worker initialization
import clickOutside from '@/utils/ClickOutside'; // Directive for closing popups, modals, etc
import { messages } from '@/utils/languages'; // Language texts
import ErrorReporting from '@/utils/ErrorReporting'; // Error reporting initializer (off)
import clickOutside from '@/directives/ClickOutside'; // Directive for closing popups, modals, etc
import { toastedOptions, tooltipOptions, language as defaultLanguage } from '@/utils/defaults';
import { initKeycloakAuth, isKeycloakEnabled } from '@/utils/KeycloakAuth';

217
src/mixins/ItemMixin.js Normal file
View File

@ -0,0 +1,217 @@
/** Reusable mixin for items */
import axios from 'axios';
import router from '@/router';
import longPress from '@/directives/LongPress';
import {
openingMethod as defaultOpeningMethod,
serviceEndpoints,
localStorageKeys,
iconSize as defaultSize,
} from '@/utils/defaults';
export default {
directives: {
longPress,
},
props: {
item: Object,
isAddNew: Boolean,
},
data() {
return {
statusResponse: undefined,
contextMenuOpen: false,
contextPos: {
posX: undefined,
posY: undefined,
},
customStyles: {
color: this.item.color,
background: this.item.backgroundColor,
},
};
},
computed: {
appConfig() {
return this.$store.getters.appConfig;
},
isEditMode() {
return this.$store.state.editMode;
},
size() {
const validSizes = ['small', 'medium', 'large'];
if (this.itemSize && validSizes.includes(this.itemSize)) return this.itemSize;
return this.appConfig.iconSize || defaultSize;
},
/* Determines if user has enabled online status checks */
enableStatusCheck() {
const globalPref = this.appConfig.statusCheck || false;
const itemPref = this.item.statusCheck || false;
return itemPref || globalPref;
},
/* Determine how often to re-fire status checks */
statusCheckInterval() {
let interval = this.item.statusCheckInterval || this.appConfig.statusCheckInterval;
if (!interval) return 0;
if (interval > 60) interval = 60;
if (interval < 1) interval = 0;
return interval;
},
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';
case 'newtab': return '_blank';
case 'parent': return '_parent';
case 'top': return '_top';
default: return undefined;
}
},
/* Get href for anchor, if not in edit mode, or opening in modal/ workspace */
hyperLinkHref() {
const nothing = '#';
const url = this.url || this.item.url || nothing;
if (this.isEditMode) return nothing;
const noAnchorNeeded = ['modal', 'workspace', 'clipboard'];
return noAnchorNeeded.includes(this.accumulatedTarget) ? nothing : url;
},
/* Pulls together all user options, returns URL + Get params for ping endpoint */
statusCheckApiUrl() {
const {
url,
statusCheckUrl,
statusCheckHeaders,
statusCheckAllowInsecure,
statusCheckAcceptCodes,
statusCheckMaxRedirects,
} = this.item;
const encode = (str) => encodeURIComponent(str);
this.statusResponse = undefined;
// Find base URL, where the API is hosted
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
// Find correct URL to check, and encode
const urlToCheck = `?&url=${encode(statusCheckUrl || url)}`;
// Get, stringify and encode any headers
const headers = statusCheckHeaders
? `&headers=${encode(JSON.stringify(statusCheckHeaders))}` : '';
// Deterimine if user disabled security
const enableInsecure = statusCheckAllowInsecure ? '&enableInsecure=true' : '';
const acceptCodes = statusCheckAcceptCodes ? `&acceptCodes=${statusCheckAcceptCodes}` : '';
const maxRedirects = statusCheckMaxRedirects ? `&maxRedirects=${statusCheckMaxRedirects}` : '';
// Construct the full API endpoint's URL with GET params
return `${baseUrl}${serviceEndpoints.statusCheck}/${urlToCheck}`
+ `${headers}${enableInsecure}${acceptCodes}${maxRedirects}`;
},
customStyle() {
return `--open-icon:${this.unicodeOpeningIcon};`
+ `color:${this.item.color};`
+ `background:${this.item.backgroundColor}`;
},
},
methods: {
/* Checks if a given service is currently online */
checkWebsiteStatus() {
const endpoint = this.statusCheckApiUrl;
axios.get(endpoint)
.then((response) => {
if (response.data) this.statusResponse = response.data;
})
.catch(() => { // Something went very wrong.
this.statusResponse = {
statusText: 'Failed to make request',
statusSuccess: false,
};
});
},
/* Called when an item is clicked, manages the opening of modal & resets the search field */
itemClicked(e) {
const url = this.url || this.item.url;
if (this.isEditMode) {
// If in edit mode, open settings, and don't launch app
e.preventDefault();
this.openItemSettings();
return;
}
// For certain opening methods, prevent default and manually navigate
if (e.ctrlKey) {
e.preventDefault();
window.open(url, '_blank');
} else if (e.altKey || this.accumulatedTarget === 'modal') {
e.preventDefault();
this.$emit('triggerModal', url);
} else if (this.accumulatedTarget === 'workspace') {
e.preventDefault();
router.push({ name: 'workspace', query: { url } });
} else if (this.accumulatedTarget === 'clipboard') {
e.preventDefault();
navigator.clipboard.writeText(url);
this.$toasted.show(this.$t('context-menus.item.copied-toast'));
}
// Emit event to clear search field, etc
this.$emit('itemClicked');
// Update the most/ last used ledger, for smart-sorting
if (!this.appConfig.disableSmartSort) {
this.incrementMostUsedCount(this.id);
this.incrementLastUsedCount(this.id);
}
},
/* Open item, using specified method */
launchItem(method, link) {
const url = link || this.item.url;
this.contextMenuOpen = false;
switch (method) {
case 'newtab':
window.open(url, '_blank');
break;
case 'sametab':
window.open(url, '_self');
break;
case 'modal':
this.$emit('triggerModal', url);
break;
case 'workspace':
router.push({ name: 'workspace', query: { url } });
break;
case 'clipboard':
navigator.clipboard.writeText(url);
this.$toasted.show(this.$t('context-menus.item.copied-toast'));
break;
default: window.open(url, '_blank');
}
},
/* Open custom context menu, and set position */
openContextMenu(e) {
this.contextMenuOpen = !this.contextMenuOpen;
if (e && window) {
// Calculate placement based on cursor and scroll position
this.contextPos = {
posX: e.clientX + window.pageXOffset,
posY: e.clientY + window.pageYOffset,
};
}
},
/* Closes the context menu, called when user clicks literally anywhere */
closeContextMenu() {
this.contextMenuOpen = false;
},
/* Used for smart-sort when sorting items by most used apps */
incrementMostUsedCount(itemId) {
const mostUsed = JSON.parse(localStorage.getItem(localStorageKeys.MOST_USED) || '{}');
let counter = mostUsed[itemId] || 0;
counter += 1;
mostUsed[itemId] = counter;
localStorage.setItem(localStorageKeys.MOST_USED, JSON.stringify(mostUsed));
},
/* Used for smart-sort when sorting by last used apps */
incrementLastUsedCount(itemId) {
const lastUsed = JSON.parse(localStorage.getItem(localStorageKeys.LAST_USED) || '{}');
lastUsed[itemId] = new Date().getTime();
localStorage.setItem(localStorageKeys.LAST_USED, JSON.stringify(lastUsed));
},
},
};

View File

@ -20,6 +20,7 @@ const WidgetMixin = {
overrideUpdateInterval: null,
disableLoader: false, // Prevent ever showing the loader
updater: null, // Stores interval
defaultTimeout: 1000,
}),
/* When component mounted, fetch initial data */
mounted() {
@ -106,7 +107,7 @@ const WidgetMixin = {
const CustomHeaders = options || null;
const headers = this.useProxy
? { 'Target-URL': endpoint, CustomHeaders: JSON.stringify(CustomHeaders) } : CustomHeaders;
const timeout = this.options.timeout || 500;
const timeout = this.options.timeout || this.defaultTimeout;
const requestConfig = {
method, url, headers, data, timeout,
};

View File

@ -280,7 +280,7 @@ const store = new Vuex.Store({
/* Called when app first loaded. Reads config and sets state */
async [INITIALIZE_CONFIG]({ commit }) {
// Get the config file from the server and store it for use by the accumulator
commit(SET_REMOTE_CONFIG, yaml.load((await axios.get('conf.yml')).data));
commit(SET_REMOTE_CONFIG, yaml.load((await axios.get('/conf.yml')).data));
const deepCopy = (json) => JSON.parse(JSON.stringify(json));
const config = deepCopy(new ConfigAccumulator().config());
commit(SET_CONFIG, config);

View File

@ -65,6 +65,7 @@
--widget-text-color: var(--primary);
--widget-background-color: var(--background-darker);
--widget-accent-color: var(--background);
--widget-base-background: transparent;
// Interactive editor
--interactive-editor-color: var(--primary);
--interactive-editor-background: var(--background);

View File

@ -25,24 +25,42 @@ html[data-theme='thebe'] {
}
html[data-theme='dracula'] {
--font-headings: 'Allerta Stencil', sans-serif;
--font-headings: 'Shrikhand', sans-serif;
--primary: #98ace9;
--background: #44475a;
--background-darker: #282a36;
--item-group-background: var(--background-darker);
--item-background: none;
--item-background: var(--background-darker);
--item-background-hover: #191b22;
--item-shadow: none;
--item-shadow: 1px 1px 3px #000000e6;
--item-hover-shadow: none;
--settings-text-color: var(--primary);
--config-settings-color: var(--primary);
--nav-link-background-color: var(--background);
--nav-link-border-color: none;
--nav-link-border-color-hover: none;
--item-group-outer-background: var(--background-darker);
--login-form-background: var(--background-darker);
.item { border: 1px solid var(--primary); }
.collapsable:nth-child(1n) label { color: #8be9fd; }
.collapsable:nth-child(2n) label { color: #50fa7b; }
.collapsable:nth-child(3n) label { color: #ffb86c; }
.collapsable:nth-child(4n) label { color: #ff79c6; }
.collapsable:nth-child(4n) label { color: #bd93f9; }
h1, h2, h3 { font-weight: normal; }
.collapsable, .nav a.nav-item {
&:nth-child(1n) { --index-color: #8be9fd; }
&:nth-child(2n) { --index-color: #50fa7b; }
&:nth-child(3n) { --index-color: #ffb86c; }
&:nth-child(4n) { --index-color: #ff79c6; }
&:nth-child(5n) { --index-color: #bd93f9; }
--item-group-heading-text-color: var(--index-color);
--item-group-heading-text-color-hover: var(--index-color);
--item-group-shadow: inset 0 2px 1px var(--index-color), 1px 1px 2px #000000cc;
--item-hover-shadow: 0 0 2px var(--index-color);
--item-text-color-hover: var(--index-color);
--nav-link-text-color-hover: var(--index-color);
--nav-link-shadow-hover: inset 0 2px 1px var(--index-color), 1px 1px 2px #000000cc;
.item:hover { border-color: var(--index-color); }
}
}
html[data-theme='bee'] {
@ -328,24 +346,32 @@ html[data-theme='colorful'] {
--item-group-heading-text-color-hover: var(--primary);
--item-hover-shadow: 1px 4px 6px var(--black);
--nav-link-background-color: var(--background);
.item-wrapper:nth-child(1n) { .item { color: #eb5cad; border: 1px solid #eb5cad; } }
.item-wrapper:nth-child(2n) { .item { color: #985ceb; border: 1px solid #985ceb; } }
.item-wrapper:nth-child(3n) { .item { color: #5c90eb; border: 1px solid #5c90eb; } }
.item-wrapper:nth-child(4n) { .item { color: #5cdfeb; border: 1px solid #5cdfeb; } }
.item-wrapper:nth-child(5n) { .item { color: #5ceb8d; border: 1px solid #5ceb8d; } }
.item-wrapper:nth-child(6n) { .item { color: #afeb5c; border: 1px solid #afeb5c; } }
.item-wrapper:nth-child(7n) { .item { color: #ebb75c; border: 1px solid #ebb75c; } }
.item-wrapper:nth-child(8n) { .item { color: #eb615c; border: 1px solid #eb615c; } }
.item:hover, .item:focus {
opacity: 0.85;
outline: none;
background: currentColor;
span.text, p.description { color: var(--background-darker); }
i.fas, i.fab, i.far, i.fal, i.fad {
filter: drop-shadow(1px 3px 2px var(--transparent-50));
color: var(--background-darker);
--outline-color: none;
.item-wrapper, .sub-item-wrapper {
&:nth-child(1n) .item { --current-color: #eb5cad; }
&:nth-child(2n) .item { --current-color: #985ceb; }
&:nth-child(3n) .item { --current-color: #5c90eb; }
&:nth-child(4n) .item { --current-color: #5cdfeb; }
&:nth-child(5n) .item { --current-color: #5ceb8d; }
&:nth-child(6n) .item { --current-color: #afeb5c; }
&:nth-child(7n) .item { --current-color: #ebb75c; }
&:nth-child(8n) .item { --current-color: #eb615c; }
.item {
color: var(--current-color);
border: 1px solid var(--current-color);
&:hover, &:focus {
opacity: 0.85;
outline: none;
background: currentColor;
span.text, p.description { color: var(--background-darker); }
i.fas, i.fab, i.far, i.fal, i.fad {
filter: drop-shadow(1px 3px 2px var(--transparent-50));
color: var(--background-darker);
}
svg path { fill: var(--background-darker); }
}
}
svg path { fill: var(--background-darker); }
}
h1, h2, h3, h4 {
font-weight: normal;
@ -991,8 +1017,10 @@ html[data-theme='glow'], html[data-theme=glow-colorful] {
--background: var(--blue);
--background-darker: var(--pink);
--heading-text-color: var(--blue);
--nav-link-background-color: var(--blue);
--nav-link-background-color-hover: var(--blue);
--nav-link-text-color-hover: var(--pink);
--nav-link-text-color: var(--pink);
--nav-link-text-color-hover: var(--gold);
--nav-link-border-color-hover: var(--blue);
--config-settings-background: var(--blue);
--config-settings-color: var(--pink);
@ -1034,7 +1062,7 @@ html[data-theme="oblivion-scotch"] {
--item-group-heading-text-color: var(--primary);
--about-page-background: var(--background);
--about-page-color: var(--primary);
div.item-wrapper a.item {
div.item-wrapper a.item, a.sub-item-link.item {
border: 1px solid #313d4f;
}
section.filter-container form input#filter-tiles, .widget-base {
@ -1373,3 +1401,185 @@ html[data-theme="oblivion-lemon"] {
html[data-theme="oblivion-scotch"] {
--primary: #d69e3a;
}
@import url('https://fonts.googleapis.com/css2?family=Shrikhand&display=swap');
html[data-theme='lissy'] {
// --primary: #f0f;
--primary: #ffffffcc;
--background: #25282c;
--background-darker: #191c20;
--item-group-background: var(--background-darker);
--item-group-outer-background: var(--background-darker);
--item-group-heading-text-color: var(--primary);
--item-group-heading-text-color-hover: var(--primary);
--item-group-shadow: none;
--item-background: var(--background);
--item-background-hover: #101215;
--item-shadow: 1px 1px 1px #00000080;
--item-hover-shadow: 2px 2px 3px #00000099;
--font-headings: 'Shrikhand';
--curve-factor: 6px;
h1, h3.section-title {
font-weight: normal;
}
.side-bar-item-container {
--item-hover-shadow: none;
--item-shadow: none;
}
.collapsable, .side-bar-section, .workspace-widget {
&:nth-child(1n) { --index-color: #f81392e6; }
&:nth-child(2n) { --index-color: #e026ffe6; }
&:nth-child(3n) { --index-color: #4c64ffe6; }
&:nth-child(4n) { --index-color: #38d9fde6; }
&:nth-child(5n) { --index-color: #15f4a3e6; }
&:nth-child(6n) { --index-color: #e8ff47e6; }
&:nth-child(7n) { --index-color: #ff6c47e6; }
--item-group-heading-text-color: var(--index-color);
--item-group-shadow: inset 0 2px 1px var(--index-color);
--item-hover-shadow: 0 0 5px var(--index-color);
--side-bar-item-color: var(--index-color);
transition: all 0.2s ease-in-out 0s;
border: 1px solid #0000004d;
label.lbl-toggle h3 {
font-weight: normal;
}
a.item p.description {
opacity: 0.75;
color: var(--index-color);
}
&:hover, &.workspace-widget {
box-shadow: inset 0px 3px 1px var(--index-color), 1px 1px 5px #00000080;
}
}
.workspace-widget-view .workspace-widget {
background: var(--background-darker);
padding: 1rem;
margin: 1rem auto;
border-radius: var(--curve-factor);
}
.home, .options-container, .options-outer, #dashy {
background-color: var(--background);
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%2314171c' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
}
.minimal-home {
background: none;
}
.home {
padding-bottom: 1.5rem;
}
footer {
margin-top: 0;
}
}
html[data-theme='cherry-blossom'] {
--primary: #e1e8ee;
--background: #11171d;
--background-darker: #070a0d;
--item-background: #00000040;
--widget-base-background: #00000040;
--widget-base-shadow: var(--item-shadow);
--item-background-hover: #ffffff1a;
--item-group-outer-background: none;
--item-group-background: none;
--item-group-shadow: 1px 1px 2px #080a0d;
--item-group-heading-text-color: var(--background);
--minimal-view-section-heading-color: var(--background-darker);
--minimal-view-group-background: #1b242d;
--minimal-view-group-color: none;
--heading-text-color: var(--background);
--nav-link-text-color: var(--background);
--nav-link-text-color-hover: var(--background);
--nav-link-border-color-hover: #11171d40;
--nav-link-background-color: #00000040;
--search-container-background: none;
--search-field-background: var(--background-darker);
--font-headings: 'Cutive Mono', monospace;
.collapsable, .side-bar-section, .workspace-widget, .minimal-section-heading, .minimal-section-inner {
&:nth-child(1n) { --top-color: #d7c1ed; --back-color: #2a2c37; }
&:nth-child(2n) { --top-color: #96cdfb; --back-color: #222e39; }
&:nth-child(3n) { --top-color: #b5e8e0; --back-color: #263135; }
&:nth-child(4n) { --top-color: #f28fad; --back-color: #2d262f; }
--item-group-outer-background: var(--back-color);
--item-hover-shadow: 0 0 5px var(--top-color);
--side-bar-item-color: var(--top-color);
--minimal-view-section-heading-background: var(--top-color);
label.lbl-toggle {
background: var(--top-color);
padding: 0.5rem 0.25rem;
min-height: 1.5rem;
border-bottom-left-radius: var(--curve-factor) !important;
border-bottom-right-radius: var(--curve-factor) !important;
}
transition: all 0.2s ease-in-out 0s;
border: 1px solid #0000004d;
a.item p.description {
opacity: 0.75;
color: var(--top-color);
}
&.workspace-widget {
box-shadow: inset 0px 3px 1px var(--top-color), 1px 1px 5px #00000080;
}
}
header {
border-radius: 8px;
max-width: 1200px;
margin: 1rem auto 0 auto;
padding: 0.25rem;
justify-content: space-around;
background-image: linear-gradient(to right, #D7C1ED, #96CDFB, #B5E8E0, #F28FAD);
.subtitle { display: none; }
.page-titles img.site-logo {
margin-right: 0.5rem;
}
.nav-outer nav .nav-item {
padding: 0.5rem;
}
}
.settings-outer {
background: none;
max-width: 1200px;
margin: 0 auto;
}
.collapsable {
max-width: 1200px;
margin: 0.5rem auto;
}
.widget-base {
border-radius: var(--curve-factor);
margin: 0.5rem auto;
padding: 0.2rem;
}
.home .item-group-container {
gap: 0.5rem 1rem;
}
.item-icon svg { border-radius: 6px; }
.work-space nav.side-bar, .work-space .web-content iframe {
border-radius: 8px;
}
section.settings-outer.settings-hidden form {
width: 100%;
display: flex;
justify-content: center;
.search-wrap {
width: fit-content;
input {
margin-top: 1rem;
width: 400px;
}
}
}
}

View File

@ -42,11 +42,7 @@ html {
}
}
/* Optional fonts for specific themes */
/* These fonts are loaded from ./public and therefore not bundled within the apps source */
@font-face { // Used by Dracula. Credit to Matt McInerney
font-family: 'Allerta Stencil';
src: url('/fonts/AllertaStencil-Regular.ttf');
}
/* These fonts are loaded from ./public so not bundled within dist */
@font-face { // Used by body text in Matrix and Hacker themes. Credit to the late Vernon Adams, RIP
font-family: 'Cutive Mono';
src: url('/fonts/CutiveMono-Regular.ttf');
@ -71,9 +67,11 @@ html {
font-family: 'VT323';
src: url('/fonts/VT323-Regular.ttf');
}
@font-face { // Used by cyberpunk theme. Credit to Astigmatic
font-family: 'Audiowide';
src: url('/fonts/Audiowide-Regular.ttf');
}
@font-face { // Used by Dracula, Lissy themes. Credit to Jonny Pinhorn
font-family: 'Shrikhand';
src: url('/fonts/Shrikhand-Regular.ttf');
}

View File

@ -1,37 +0,0 @@
/**
* A simple Vue directive to trigger an event when the user
* clicks anywhere other than the specified element.
* Used to close context menu's popup menus and tips.
*/
const instances = [];
function onDocumentClick(e, el, fn) {
const { target } = e;
if (el !== target && !el.contains(target)) {
fn(e);
}
}
export default {
bind(element, binding) {
const el = element;
el.dataset.outsideClickIndex = instances.length;
const fn = binding.value;
const click = (e) => {
onDocumentClick(e, el, fn);
};
document.addEventListener('click', click);
document.addEventListener('touchstart', click);
instances.push(click);
},
unbind(el) {
if (!el.dataset) return;
const index = el.dataset.outsideClickIndex;
const handler = instances[index];
document.removeEventListener('click', handler);
instances.splice(index, 1);
},
};

View File

@ -46,6 +46,12 @@ export const timestampToDateTime = (timestamp) => {
return `${timestampToDate(timestamp)} at ${timestampToTime(timestamp)}`;
};
/* Given a 2-letter country ISO code, return the countries name */
export const getCountryFromIso = (iso) => {
const regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
return regionNames.of(iso);
};
/* Given a 2-digit country code, return path to flag image from Flagpedia */
export const getCountryFlag = (countryCode, dimens) => {
const protocol = 'https';

View File

@ -48,28 +48,30 @@ module.exports = {
/* List of built-in themes, to be displayed within the theme-switcher dropdown */
builtInThemes: [
'callisto',
'oblivion',
'material',
'material-dark',
'dashy-docs',
'colorful',
'one-dark',
'dracula',
'adventure',
'one-dark',
'lissy',
'cherry-blossom',
'nord-frost',
'nord',
'oblivion',
'adventure',
'minimal-dark',
'minimal-light',
'thebe',
'cyberpunk',
'matrix',
'matrix-red',
'color-block',
'glow',
'raspberry-jam',
'bee',
'tiger',
'glow',
'vaporware',
'cyberpunk',
'material-original',
'material-dark-original',
'high-contrast-dark',
@ -150,7 +152,6 @@ module.exports = {
PAGE_INFO: 'pageInfo',
APP_CONFIG: 'appConfig',
SECTIONS: 'sections',
WIDGETS: 'widgets',
},
/* Amount of time to show splash screen, when enabled, in milliseconds */
splashScreenTime: 1000,
@ -208,6 +209,7 @@ module.exports = {
widgetApiEndpoints: {
anonAddy: 'https://app.anonaddy.com',
astronomyPictureOfTheDay: 'https://apodapi.herokuapp.com/api',
blacklistCheck: 'https://api.blacklistchecker.com/check',
codeStats: 'https://codestats.net/',
covidStats: 'https://disease.sh/v3/covid-19',
cryptoPrices: 'https://api.coingecko.com/api/v3/coins/',
@ -222,6 +224,7 @@ module.exports = {
holidays: 'https://kayaposoft.com/enrico/json/v2.0/?action=getHolidaysForDateRange',
jokes: 'https://v2.jokeapi.dev/joke/',
news: 'https://api.currentsapi.services/v1/latest-news',
mullvad: 'https://am.i.mullvad.net/json',
publicIp: 'https://ipapi.co/json',
publicIp2: 'https://api.ipgeolocation.io/ipgeo',
publicIp3: 'http://ip-api.com/json',

View File

@ -47,7 +47,7 @@
/>
</template>
<!-- Show add new section button, in edit mode -->
<AddNewSection v-if="isEditMode" />
<AddNewSection v-if="isEditMode && !singleSectionView" />
</div>
<!-- Show message when there's no data to show -->
<div v-if="checkIfResults() && !isEditMode" class="no-data">
@ -127,7 +127,7 @@ export default {
methods: {
/* Clears input field, once a searched item is opened */
finishedSearching() {
this.$refs.filterComp.clearFilterInput();
if (this.$refs.filterComp) this.$refs.filterComp.clearFilterInput();
},
/* Returns optional section display preferences if available */
getDisplayData(section) {

View File

@ -99,7 +99,7 @@ export default {
},
/* Clears input field, once a searched item is opened */
finishedSearching() {
this.$refs.filterComp.clearMinFilterInput();
if (this.$refs.filterComp) this.$refs.filterComp.clearMinFilterInput();
},
/* Returns true if there is more than 1 sub-result visible during searching */
checkIfResults() {