🔀 Merge pull request #382 from Lissy93/FEATURE/basic-widget-support

[FEATURE] Widget Support
This commit is contained in:
Alicia Sykes 2021-12-29 14:50:57 +00:00 committed by GitHub
commit 1f5d3f45fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 10000 additions and 2424 deletions

View File

@ -1,5 +1,10 @@
# Changelog
## ✨ 1.9.4 - Widget Support [PR #382](https://github.com/Lissy93/dashy/pull/382)
- Adds support for dynamic content, through widgets
- Adds 30+ pre-built widgets for general info and self-hosted services
- Writes docs on widget usage
## ⚡️ 1.9.2 - Native SSL Support + Performance Improvements [PR #326](https://github.com/Lissy93/dashy/pull/326)
- Updates the server to use Express, removing serve-static, connect and body-parser
- Adds native support for passing in self-signed SSL certificates and updates docs

View File

@ -1,62 +1,64 @@
name: Question 🤷‍♂️
description: Got a question about Dashy, deployment, development or usage?
title: '[QUESTION] <title>'
labels: ['🤷‍♂️ Question']
body:
# Filed 1 - Intro Text
- type: markdown
attributes:
value: >
Thanks for using Dashy! Questions are welcome, but in the future will be moving over to
[Discussions](https://github.com/Lissy93/dashy/discussions) page.
Quick questions should be asked [here](https://github.com/Lissy93/dashy/discussions/148) instead.
validations:
required: false
# Field 2 - The actual question
- type: textarea
id: question
attributes:
label: Question
description: Outline your question in a clear and concise manner
validations:
required: true
# Field 3 - Category
- type: dropdown
id: category
attributes:
label: Category
description: What part of the application does this relate to?
options:
- Setup and Deployment
- Configuration
- App Usage
- Development
- Documentation
- Alternate Views
- Authentication
- Using Icons
- Language Support
- Search & Shortcuts
- Status Checking
- Theming & Layout
validations:
required: true
# Field 4 - User has RTFM first, and agrees to code of conduct, etc
- type: checkboxes
id: idiot-check
attributes:
label: Please tick the boxes
description: Before submitting, please ensure that
options:
- label: You are using a [supported](https://github.com/Lissy93/dashy/blob/master/.github/SECURITY.md#supported-versions) version of Dashy (check the first two digits of the version number)
required: true
- label: You've checked that this [question hasn't already been raised](https://github.com/Lissy93/dashy/issues?q=is%3Aissue)
required: true
- label: You've checked the [docs](https://github.com/Lissy93/dashy/tree/master/docs#readme) and [troubleshooting](https://github.com/Lissy93/dashy/blob/master/docs/troubleshooting.md#troubleshooting) guide
required: true
- label: You agree to the [code of conduct](https://github.com/Lissy93/dashy/blob/master/.github/CODE_OF_CONDUCT.md#contributor-covenant-code-of-conduct)
required: true
name: Question 🤷‍♂️
description: Got a question about Dashy, deployment, development or usage?
title: '[QUESTION] <title>'
labels: ['🤷‍♂️ Question']
body:
# Filed 1 - Intro Text
- type: markdown
attributes:
value: >
Thanks for using Dashy! Questions are welcome, but in the future will be moving over to
[Discussions](https://github.com/Lissy93/dashy/discussions) page.
Quick questions should be asked [here](https://github.com/Lissy93/dashy/discussions/148) instead.
validations:
required: false
# Field 2 - The actual question
- type: textarea
id: question
attributes:
label: Question
description: Outline your question in a clear and concise manner
validations:
required: true
# Field 3 - Category
- type: dropdown
id: category
attributes:
label: Category
description: What part of the application does this relate to?
options:
- Setup and Deployment
- Configuration
- App Usage
- Development
- Documentation
- Alternate Views
- Authentication
- Using Icons
- Widgets
- Actions
- Language Support
- Search & Shortcuts
- Status Checking
- Theming & Layout
validations:
required: true
# Field 4 - User has RTFM first, and agrees to code of conduct, etc
- type: checkboxes
id: idiot-check
attributes:
label: Please tick the boxes
description: Before submitting, please ensure that
options:
- label: You are using a [supported](https://github.com/Lissy93/dashy/blob/master/.github/SECURITY.md#supported-versions) version of Dashy (check the first two digits of the version number)
required: true
- label: You've checked that this [question hasn't already been raised](https://github.com/Lissy93/dashy/issues?q=is%3Aissue)
required: true
- label: You've checked the [docs](https://github.com/Lissy93/dashy/tree/master/docs#readme) and [troubleshooting](https://github.com/Lissy93/dashy/blob/master/docs/troubleshooting.md#troubleshooting) guide
required: true
- label: You agree to the [code of conduct](https://github.com/Lissy93/dashy/blob/master/.github/CODE_OF_CONDUCT.md#contributor-covenant-code-of-conduct)
required: true

View File

@ -40,6 +40,7 @@
- [🎨 Theming](#theming-)
- [🧸 Icons](#icons-)
- [🚦 Status Indicators](#status-indicators-)
- [📊 Widgets](#widgets-)
- [💂 Authentication](#authentication-)
- [🖱️ Opening Methods](#opening-methods-%EF%B8%8F)
- [👓 Alternate Views](#alternate-views-)
@ -70,6 +71,7 @@
- 🎨 Multiple built-in color themes, with UI color editor and support for custom CSS
- 🧸 Many icon options - Font-Awesome, homelab icons, auto-fetching Favicon, images, emojis, etc.
- 🚦 Status monitoring for each of your apps/links for basic availability and uptime checking
- 📊 Use widgets to display info and dynamic content from self-hosted services
- 💂 Optional authentication with multi-user access, configurable privileges, and SSO support
- 🌎 Multi-language support, with 10+ human-translated languages, and more on the way
- ☁ Optional, encrypted, free off-site cloud backup and restore feature available
@ -235,6 +237,22 @@ Status indicators can be globally enabled by setting `appConfig.statusCheck: tru
<img alt="Status Checks demo" src="https://raw.githubusercontent.com/Lissy93/dashy/master/docs/assets/status-check-demo.gif" width="600" />
</p>
**[⬆️ Back to Top](#dashy)**
---
## Widgets 📊
> For full widget documentation, see: [**Widgets**](./docs/widgets.md)
You can display dynamic content from services in the form of widgets. There are several pre-built widgets availible for showing useful info, and integrations with commonly self-hosted services, but you can also easily create your own for almost any app.
<p align="center">
<img width="600" src="https://i.ibb.co/GFjXVHy/dashy-widgets.png" />
</p>
**[⬆️ Back to Top](#dashy)**
---

View File

@ -1,221 +1,431 @@
# Development Guides
A series of short tutorials, to guide you through the most common development tasks.
Sections:
- [Creating a new theme](#creating-a-new-theme)
- [Writing Translations](#writing-translations)
- [Adding a new option in the config file](#adding-a-new-option-in-the-config-file)
- [Updating Dependencies](#updating-dependencies)
## Creating a new theme
Adding a new theme is really easy. There's two things you need to do: Pass the theme name to Dashy, so that it can be added to the theme selector dropdown menu, and then write some styles!
##### 1. Add Theme Name
Choose a snappy name for you're theme, and add it to the `builtInThemes` array inside [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js#L27).
##### 2. Write some Styles!
Put your theme's styles inside [`color-themes.scss`](https://github.com/Lissy93/dashy/blob/master/src/styles/color-themes.scss).
Create a new block, and make sure that `data-theme` matches the theme name you chose above. For example:
```css
html[data-theme='tiger'] {
--primary: #f58233;
--background: #0b1021;
}
```
Then you can go ahead and write you're own custom CSS. Although all CSS is supported here, the best way to define you're theme is by setting the CSS variables. You can find a [list of all CSS variables, here](https://github.com/Lissy93/dashy/blob/master/docs/theming.md#css-variables).
For a full guide on styling, see [Theming Docs](./theming.md).
Note that if you're theme is just for yourself, and you're not submitting a PR, then you can instead just pass it under `appConfig.cssThemes` inside your config file. And then put your theme in your own stylesheet, and pass it into the Docker container - [see how](https://github.com/Lissy93/dashy/blob/master/docs/theming.md#adding-your-own-theme).
## Writing Translations
For full docs about Dashy's multi-language support, see [Multi-Language Support](./multi-language-support.md)
Dashy is using [vue-i18n](https://vue-i18n.intlify.dev/guide/) to manage multi-language support.
Adding a new language is pretty straightforward, with just three steps:
##### 1. Create a new Language File
Create a new JSON file in `./src/assets/locales` name is a 2-digit [ISO-639 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language, E.g. for German `de.json`, French `fr.json` or Spanish `es.json` - You can find a list of all ISO codes at [iso.org](https://www.iso.org/obp/ui).
##### 2. Translate!
Using [`en.json`](https://github.com/Lissy93/dashy/tree/master/src/assets/locales/en.json) as an example, translate the JSON values to your language, while leaving the keys as they are. It's fine to leave out certain items, as if they're missing they will fall-back to English. If you see any attribute which include curly braces (`{xxx}`), then leave the inner value of these braces as is, as this is for variables.
```json
{
"theme-maker": {
"export-button": "Benutzerdefinierte Variablen exportieren",
"reset-button": "Stile zurücksetzen für",
"show-all-button": "Alle Variablen anzeigen",
"save-button": "Speichern",
"cancel-button": "Abbrechen",
"saved-toast": "{theme} Erfolgreich aktualisiert",
"reset-toast": "Benutzerdefinierte Farben für {theme} entfernt"
},
}
```
##### 3. Add your file to the app
In [`./src/utils/languages.js`](https://github.com/Lissy93/dashy/tree/master/src/utils/languages.js), you need to do 2 small things:
First import your new translation file, do this at the top of the page.
E.g. `import de from '@/assets/locales/de.json';`
Second, add it to the array of languages, e.g:
```javascript
export const languages = [
{
name: 'English',
code: 'en',
locale: en,
flag: '🇬🇧',
},
{
name: 'German', // The name of your language
code: 'de', // The ISO code of your language
locale: de, // The name of the file you imported (no quotes)
flag: '🇩🇪', // An optional flag emoji
},
];
```
You can also add your new language to the readme, under the [Language Switching](https://github.com/Lissy93/dashy#language-switching-) section, and optionally include your name/ username if you'd like to be credited for your work. Done!
If you are not comfortable with making pull requests, or do not want to modify the code, then feel free to instead send the translated file to me, and I can add it into the application. I will be sure to credit you appropriately.
# Adding a new option in the config file
This section is for, if you're adding a new component or setting, that requires an additional item to be added to the users config file.
All of the users config is specified in `./public/conf.yml` - see [Configuring Docs](./configuring.md) for info.
Before adding a new option in the config file, first ensure that there is nothing similar available, that is is definitely necessary, it will not conflict with any other options and most importantly that it will not cause any breaking changes. Ensure that you choose an appropriate and relevant section to place it under.
Next decide the most appropriate place for your attribute:
- Application settings should be located under `appConfig`
- Page info (such as text and metadata) should be under `pageInfo`
- Data relating to specific sections should be under `section[n].displayData`
- And for setting applied to specific items, it should be under `item[n]`
In order for the user to be able to add your new attribute using the Config Editor, and for the build validation to pass, your attribute must be included within the [ConfigSchema](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigSchema.js). You can read about how to do this on the [ajv docs](https://ajv.js.org/json-schema.html). Give your property a type and a description, as well as any other optional fields that you feel are relevant. For example:
```json
"fontAwesomeKey": {
"type": "string",
"pattern": "^[a-z0-9]{10}$",
"description": "API key for font-awesome",
"example": "0821c65656"
}
```
or
```json
"iconSize": {
"enum": [ "small", "medium", "large" ],
"default": "medium",
"description": "The size of each link item / icon"
}
```
Next, if you're property should have a default value, then add it to [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js). This ensures that nothing will break if the user does not use your property, and having all defaults together keeps things organised and easy to manage.
If your property needs additional logic for fetching, setting or processing, then you can add a helper function within [`ConfigHelpers.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigHelpers.js).
Finally, add your new property to the [`configuring.md`](./configuring.md) API docs. Put it under the relevant section, and be sure to include field name, data type, a description and mention that it is optional. If your new feature needs more explaining, then you can also document it under the relevant section elsewhere in the documentation.
Checklist:
- [ ] Ensure the new attribute is actually necessary, and nothing similar already exists
- [ ] Update the [Schema](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigSchema.js) with the parameters for your new option
- [ ] Set a default value (if required) within [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js)
- [ ] Document the new value in [`configuring.md`](./configuring.md)
- [ ] Test that the reading of the new attribute is properly handled, and will not cause any errors when it is missing or populated with an unexpected value
---
## Updating Dependencies
Running `yarn upgrade` will updated all dependencies based on the ranges specified in the `package.json`. The `yarn.lock` file will be updated, as will the contents of `./node_modules`, for more info, see the [yarn upgrade documentation](https://classic.yarnpkg.com/en/docs/cli/upgrade/). It is important to thoroughly test after any big dependency updates.
---
## Developing Netlify Cloud Functions
When Dashy is deployed to Netlify, it is effectively running as a static app, and therefore the server-side code for the Node.js endpoints is not available. However Netlify now supports serverless cloud lambda functions, which can be used to replace most functionality.
#### 1. Run Netlify Dev Server
First off, install the Netlify CLI: `npm install netlify-cli -g`
Then, from within the root of Dashy's directory, start the server, by running: `netlify dev`
#### 2. Create a lambda function
This should be saved it in the [`./services/serverless-functions`](https://github.com/Lissy93/dashy/tree/master/services/serverless-functions) directory
```javascript
exports.handler = async () => ({
statusCode: 200,
body: 'Return some data here...',
});
```
#### 3. Redirect the Node endpoint to the function
In the [`netlify.toml`](https://github.com/Lissy93/dashy/blob/FEATURE/serverless-functions/netlify.toml) file, add a 301 redirect, with the path to the original Node.js endpoint, and the name of your cloud function
```toml
[[redirects]]
from = "/status-check"
to = "/.netlify/functions/cloud-status-check"
status = 301
force = true
```
---
## Hiding Page Furniture on Certain Routes
For some pages (such as the login page, the minimal start page, etc) the basic page furniture, (like header, footer, nav, etc) is not needed. This section explains how you can hide furniture on a new view (step 1), or add a component that should be hidden on certain views (step 2).
##### 1. Add the route name to the should hide array
In [`./src/utils/defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js), there's an array called `hideFurnitureOn`. Append the name of the route (the same as it appears in [`router.js`](https://github.com/Lissy93/dashy/blob/master/src/router.js)) here.
##### 2. Add the conditional to the structural component to hide
First, import the helper function:
```javascript
import { shouldBeVisible } from '@/utils/MiscHelpers';
```
Then you can create a computed value, that calls this function, passing in the route name:
```javascript
export default {
...
computed: {
...
isVisible() {
return shouldBeVisible(this.$route.name);
},
},
};
```
Finally, in the markup of your component, just add a `v-if` statement, referencing your computed value
```vue
<header v-if="isVisible">
...
</header>
```
---
## Adding / Using Environmental Variables
All environmental variables are optional. Currently there are not many environmental variables used, as most of the user preferences are stored under `appConfig` in the `conf.yml` file.
You can set variables either in your environment, or using the [`.env`](https://github.com/Lissy93/dashy/blob/master/.env) file.
Any environmental variables used by the frontend are preceded with `VUE_APP_`. Vue will merge the contents of your `.env` file into the app in a similar way to the ['dotenv'](https://github.com/motdotla/dotenv) package, where any variables that you set on your system will always take preference over the contents of any `.env` file.
If add any new variables, ensure that there is always a fallback (define it in [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js)), so as to not cause breaking changes. Don't commit the contents of your `.env` file to git, but instead take a few moments to document what you've added under the appropriate section. Try and follow the concepts outlined in the [12 factor app](https://12factor.net/config).
# Development Guides
A series of short tutorials, to guide you through the most common development tasks.
Sections:
- [Creating a new theme](#creating-a-new-theme)
- [Writing Translations](#writing-translations)
- [Adding a new option in the config file](#adding-a-new-option-in-the-config-file)
- [Updating Dependencies](#updating-dependencies)
- [Writing Netlify Cloud Functions](#developing-netlify-cloud-functions)
- [Hiding Page Furniture](#hiding-page-furniture-on-certain-routes)
- [Adding / Using Environmental Variables](#adding--using-environmental-variables)
- [Building a Widget](#building-a-widget)
## Creating a new theme
Adding a new theme is really easy. There's two things you need to do: Pass the theme name to Dashy, so that it can be added to the theme selector dropdown menu, and then write some styles!
##### 1. Add Theme Name
Choose a snappy name for you're theme, and add it to the `builtInThemes` array inside [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js#L27).
##### 2. Write some Styles!
Put your theme's styles inside [`color-themes.scss`](https://github.com/Lissy93/dashy/blob/master/src/styles/color-themes.scss).
Create a new block, and make sure that `data-theme` matches the theme name you chose above. For example:
```css
html[data-theme='tiger'] {
--primary: #f58233;
--background: #0b1021;
}
```
Then you can go ahead and write you're own custom CSS. Although all CSS is supported here, the best way to define you're theme is by setting the CSS variables. You can find a [list of all CSS variables, here](https://github.com/Lissy93/dashy/blob/master/docs/theming.md#css-variables).
For a full guide on styling, see [Theming Docs](./theming.md).
Note that if you're theme is just for yourself, and you're not submitting a PR, then you can instead just pass it under `appConfig.cssThemes` inside your config file. And then put your theme in your own stylesheet, and pass it into the Docker container - [see how](https://github.com/Lissy93/dashy/blob/master/docs/theming.md#adding-your-own-theme).
## Writing Translations
For full docs about Dashy's multi-language support, see [Multi-Language Support](./multi-language-support.md)
Dashy is using [vue-i18n](https://vue-i18n.intlify.dev/guide/) to manage multi-language support.
Adding a new language is pretty straightforward, with just three steps:
##### 1. Create a new Language File
Create a new JSON file in `./src/assets/locales` name is a 2-digit [ISO-639 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language, E.g. for German `de.json`, French `fr.json` or Spanish `es.json` - You can find a list of all ISO codes at [iso.org](https://www.iso.org/obp/ui).
##### 2. Translate!
Using [`en.json`](https://github.com/Lissy93/dashy/tree/master/src/assets/locales/en.json) as an example, translate the JSON values to your language, while leaving the keys as they are. It's fine to leave out certain items, as if they're missing they will fall-back to English. If you see any attribute which include curly braces (`{xxx}`), then leave the inner value of these braces as is, as this is for variables.
```json
{
"theme-maker": {
"export-button": "Benutzerdefinierte Variablen exportieren",
"reset-button": "Stile zurücksetzen für",
"show-all-button": "Alle Variablen anzeigen",
"save-button": "Speichern",
"cancel-button": "Abbrechen",
"saved-toast": "{theme} Erfolgreich aktualisiert",
"reset-toast": "Benutzerdefinierte Farben für {theme} entfernt"
},
}
```
##### 3. Add your file to the app
In [`./src/utils/languages.js`](https://github.com/Lissy93/dashy/tree/master/src/utils/languages.js), you need to do 2 small things:
First import your new translation file, do this at the top of the page.
E.g. `import de from '@/assets/locales/de.json';`
Second, add it to the array of languages, e.g:
```javascript
export const languages = [
{
name: 'English',
code: 'en',
locale: en,
flag: '🇬🇧',
},
{
name: 'German', // The name of your language
code: 'de', // The ISO code of your language
locale: de, // The name of the file you imported (no quotes)
flag: '🇩🇪', // An optional flag emoji
},
];
```
You can also add your new language to the readme, under the [Language Switching](https://github.com/Lissy93/dashy#language-switching-) section, and optionally include your name/ username if you'd like to be credited for your work. Done!
If you are not comfortable with making pull requests, or do not want to modify the code, then feel free to instead send the translated file to me, and I can add it into the application. I will be sure to credit you appropriately.
# Adding a new option in the config file
This section is for, if you're adding a new component or setting, that requires an additional item to be added to the users config file.
All of the users config is specified in `./public/conf.yml` - see [Configuring Docs](./configuring.md) for info.
Before adding a new option in the config file, first ensure that there is nothing similar available, that is is definitely necessary, it will not conflict with any other options and most importantly that it will not cause any breaking changes. Ensure that you choose an appropriate and relevant section to place it under.
Next decide the most appropriate place for your attribute:
- Application settings should be located under `appConfig`
- Page info (such as text and metadata) should be under `pageInfo`
- Data relating to specific sections should be under `section[n].displayData`
- And for setting applied to specific items, it should be under `item[n]`
In order for the user to be able to add your new attribute using the Config Editor, and for the build validation to pass, your attribute must be included within the [ConfigSchema](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigSchema.js). You can read about how to do this on the [ajv docs](https://ajv.js.org/json-schema.html). Give your property a type and a description, as well as any other optional fields that you feel are relevant. For example:
```json
"fontAwesomeKey": {
"type": "string",
"pattern": "^[a-z0-9]{10}$",
"description": "API key for font-awesome",
"example": "0821c65656"
}
```
or
```json
"iconSize": {
"enum": [ "small", "medium", "large" ],
"default": "medium",
"description": "The size of each link item / icon"
}
```
Next, if you're property should have a default value, then add it to [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js). This ensures that nothing will break if the user does not use your property, and having all defaults together keeps things organised and easy to manage.
If your property needs additional logic for fetching, setting or processing, then you can add a helper function within [`ConfigHelpers.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigHelpers.js).
Finally, add your new property to the [`configuring.md`](./configuring.md) API docs. Put it under the relevant section, and be sure to include field name, data type, a description and mention that it is optional. If your new feature needs more explaining, then you can also document it under the relevant section elsewhere in the documentation.
Checklist:
- [ ] Ensure the new attribute is actually necessary, and nothing similar already exists
- [ ] Update the [Schema](https://github.com/Lissy93/dashy/blob/master/src/utils/ConfigSchema.js) with the parameters for your new option
- [ ] Set a default value (if required) within [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js)
- [ ] Document the new value in [`configuring.md`](./configuring.md)
- [ ] Test that the reading of the new attribute is properly handled, and will not cause any errors when it is missing or populated with an unexpected value
---
## Updating Dependencies
Running `yarn upgrade` will updated all dependencies based on the ranges specified in the `package.json`. The `yarn.lock` file will be updated, as will the contents of `./node_modules`, for more info, see the [yarn upgrade documentation](https://classic.yarnpkg.com/en/docs/cli/upgrade/). It is important to thoroughly test after any big dependency updates.
---
## Developing Netlify Cloud Functions
When Dashy is deployed to Netlify, it is effectively running as a static app, and therefore the server-side code for the Node.js endpoints is not available. However Netlify now supports serverless cloud lambda functions, which can be used to replace most functionality.
#### 1. Run Netlify Dev Server
First off, install the Netlify CLI: `npm install netlify-cli -g`
Then, from within the root of Dashy's directory, start the server, by running: `netlify dev`
#### 2. Create a lambda function
This should be saved it in the [`./services/serverless-functions`](https://github.com/Lissy93/dashy/tree/master/services/serverless-functions) directory
```javascript
exports.handler = async () => ({
statusCode: 200,
body: 'Return some data here...',
});
```
#### 3. Redirect the Node endpoint to the function
In the [`netlify.toml`](https://github.com/Lissy93/dashy/blob/FEATURE/serverless-functions/netlify.toml) file, add a 301 redirect, with the path to the original Node.js endpoint, and the name of your cloud function
```toml
[[redirects]]
from = "/status-check"
to = "/.netlify/functions/cloud-status-check"
status = 301
force = true
```
---
## Hiding Page Furniture on Certain Routes
For some pages (such as the login page, the minimal start page, etc) the basic page furniture, (like header, footer, nav, etc) is not needed. This section explains how you can hide furniture on a new view (step 1), or add a component that should be hidden on certain views (step 2).
##### 1. Add the route name to the should hide array
In [`./src/utils/defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js), there's an array called `hideFurnitureOn`. Append the name of the route (the same as it appears in [`router.js`](https://github.com/Lissy93/dashy/blob/master/src/router.js)) here.
##### 2. Add the conditional to the structural component to hide
First, import the helper function:
```javascript
import { shouldBeVisible } from '@/utils/SectionHelpers';
```
Then you can create a computed value, that calls this function, passing in the route name:
```javascript
export default {
...
computed: {
...
isVisible() {
return shouldBeVisible(this.$route.name);
},
},
};
```
Finally, in the markup of your component, just add a `v-if` statement, referencing your computed value
```vue
<header v-if="isVisible">
...
</header>
```
---
## Adding / Using Environmental Variables
All environmental variables are optional. Currently there are not many environmental variables used, as most of the user preferences are stored under `appConfig` in the `conf.yml` file.
You can set variables either in your environment, or using the [`.env`](https://github.com/Lissy93/dashy/blob/master/.env) file.
Any environmental variables used by the frontend are preceded with `VUE_APP_`. Vue will merge the contents of your `.env` file into the app in a similar way to the ['dotenv'](https://github.com/motdotla/dotenv) package, where any variables that you set on your system will always take preference over the contents of any `.env` file.
If add any new variables, ensure that there is always a fallback (define it in [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js)), so as to not cause breaking changes. Don't commit the contents of your `.env` file to git, but instead take a few moments to document what you've added under the appropriate section. Try and follow the concepts outlined in the [12 factor app](https://12factor.net/config).
---
## Building a Widget
### Step 0 - Prerequisites
If this is your first time working on Dashy, then the [Developing Docs](https://github.com/Lissy93/dashy/blob/master/docs/developing.md) instructions for project setup and running. In short, you just need to clone the project, cd into it, install dependencies (`yarn`) and then start the development server (`yarn dev`).
To build a widget, you'll also need some basic knowledge of Vue.js. The [official Vue docs](https://vuejs.org/v2/guide/) provides a good starting point, as does [this guide](https://www.taniarascia.com/getting-started-with-vue/) by Tania Rascia
If you just want to jump straight in, then [here](https://github.com/Lissy93/dashy/commit/3da76ce2999f57f76a97454c0276301e39957b8e) is a complete implementation of a new example widget, or take a look at the [`XkcdComic.vue`](https://github.com/Lissy93/dashy/blob/master/src/components/Widgets/XkcdComic.vue) widget, which is pretty simple.
### Step 1 - Create Widget
Firstly, create a new `.vue` file under [`./src/components/Widgets`](https://github.com/Lissy93/dashy/tree/master/src/components/Widgets).
```vue
<template>
<div class="example-wrapper">
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
data() {
return {};
},
computed: {},
methods: {
fetchData() {
// TODO: Make Data Request
},
},
};
</script>
<style scoped lang="scss">
</style>
```
All widgets extend from the [Widget](https://github.com/Lissy93/dashy/blob/master/src/mixins/WidgetMixin.js) mixin. This provides some basic functionality that is shared by all widgets. The mixin includes the following `options`, `startLoading()`, `finishLoading()`, `error()` and `update()`.
- **Getting user options: `options`**
- Any user-specific config can be accessed with `this.options.something` (where something is the data key your accessing)
- **Loading state: `startLoading()` and `finishLoading()`**
- You can show the loader with `this.startLoading()`, then when your data request completes, hide it again with `this.finishLoading()`
- **Error handling: `error()`**
- If something goes wrong (such as API error, or missing user parameters), then call `this.error()` to show message to user
- **Updating data: `update()`**
- When the user clicks the update button, or if continuous updates are enabled, then the `update()` method within your widget will be called
### Step 2 - Adding Functionality
**Accessing User Options**
If your widget is going to accept any parameters from the user, then we can access these with `this.options.[parmName]`. It's best to put these as computed properties, which will enable us to check it exists, is valid, and if needed format it. For example, if we have an optional property called `count` (to determine number of results), we can do the following, and then reference it within our component with `this.count`
```javascript
computed: {
count() {
if (!this.options.count) {
return 5;
}
return this.options.count;
},
...
},
```
**Adding an API Endpoint**
If your widget makes a data request, then add the URL for the API under point to the `widgetApiEndpoints` array in [`defaults.js`](https://github.com/Lissy93/dashy/blob/master/src/utils/defaults.js#L207)
```javascript
widgetApiEndpoints: {
...
exampleEndpoint: 'https://hub.dummyapis.com/ImagesList',
},
```
Then in your widget file:
```javascript
import { widgetApiEndpoints } from '@/utils/defaults';
```
For GET requests, you may need to add some parameters onto the end of the URL. We can use another computed property for this, for example:
```javascript
endpoint() {
return `${widgetApiEndpoints.exampleEndpoint}?count=${this.count}`;
},
```
**Making an API Request**
Axios is used for making data requests, so import it into your component: `import axios from 'axios';`
Under the `methods` block, we'll create a function called `fetchData`, here we can use Axios to make a call to our endpoint.
```javascript
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
```
There are three things happening here:
- If the response completes successfully, we'll pass the results to another function that will handle them
- If there's an error, then we call `this.error()`, which will show a message to the user
- Whatever the result, once the request has completed, we call `this.finishLoading()`, which will hide the loader
**Processing Response**
In the above example, we call the `processData()` method with the result from the API, so we need to create that under the `methods` section. How you handle this data will vary depending on what's returned by the API, and what you want to render to the user. But however you do it, you will likely need to create a data variable to store the response, so that it can be easily displayed in the HTML.
```javascript
data() {
return {
myResults: null,
};
},
```
And then, inside your `processData()` method, you can set the value of this, with:
```javascript
`this.myResults = 'whatever'`
```
**Rendering Response**
Now that the results are in the correct format, and stored as data variables, we can use them within the `<template>` to render results to the user. Again, how you do this will depend on the structure of your data, and what you want to display, but at it's simplest, it might look something like this:
```vue
<p class="results">{{ myResults }}</p>
```
**Styling**
Styles can be written your your widget within the `<style>` block.
There are several color variables used by widgets, which extend from the base pallete. Using these enables users to override colors to theme their dashboard, if they wish. The variables are: `--widget-text-color`, `--widget-background-color` and `--widget-accent-color`
```vue
<style scoped lang="scss">
p.results {
color: var(--widget-text-color);
}
</style>
```
For examples of finished widget components, see the [Widgets](https://github.com/Lissy93/dashy/tree/master/src/components/Widgets) directory. Specifically, the [`XkcdComic.vue`](https://github.com/Lissy93/dashy/blob/master/src/components/Widgets/XkcdComic.vue) widget is quite minimal, so would make a good example, as will [this example implementation](https://github.com/Lissy93/dashy/commit/3da76ce2999f57f76a97454c0276301e39957b8e).
### Step 3 - Register
Next, import and register your new widget, in [`WidgetBase.vue`](https://github.com/Lissy93/dashy/blob/master/src/components/Widgets/WidgetBase.vue). In this file, you'll need to add the following:
Import your widget file
```javascript
import ExampleWidget from '@/components/Widgets/ExampleWidget.vue';
```
Then register the component
```javascript
components: {
...
ExampleWidget,
},
```
Finally, add the markup to render it. The only attribute you need to change here is, setting `widgetType === 'example'` to your widget's name.
```vue
<ExampleWidget
v-else-if="widgetType === 'example'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
```
### Step 4 - Docs
Finally, add some documentation for your widget in the [Widget Docs](https://github.com/Lissy93/dashy/blob/master/docs/widgets.md), so that others know hoe to use it. Include the following information: Title, short description, screenshot, config options and some example YAML.
**Summary**: For a complete example of everything discussed here, see: [`3da76ce`](https://github.com/Lissy93/dashy/commit/3da76ce2999f57f76a97454c0276301e39957b8e)

View File

@ -18,12 +18,13 @@
### Feature Docs
- [Authentication](/docs/authentication.md) - Guide to setting up authentication to protect your dashboard
- [Alternate Views](/docs/alternate-views.md) - Outline of available pages / views and item opening methods
- [Backup & Restore](/docs/backup-restore.md) - Guide to Dashy's cloud sync feature
- [Icons](/docs/icons.md) - Outline of all available icon types for sections and items
- [Backup & Restore](/docs/backup-restore.md) - Guide to backing up config with Dashy's cloud sync feature
- [Icons](/docs/icons.md) - Outline of all available icon types for sections and items, with examples
- [Language Switching](/docs/multi-language-support.md) - Details on how to switch language, or add a new locale
- [Status Indicators](/docs/status-indicators.md) - Using Dashy to monitor uptime and status of your apps
- [Searching & Shortcuts](/docs/searching.md) - Finding and launching your apps, and using keyboard shortcuts
- [Theming](/docs/theming.md) - Complete guide to applying, writing and modifying themes and styles
- [Searching & Shortcuts](/docs/searching.md) - Searching, launching methods + keyboard shortcuts
- [Theming](/docs/theming.md) - Complete guide to applying, writing and modifying themes + styles
- [Widgets](/docs/widgets.md) - List of all dynamic content widgets, with usage guides and examples
### Misc
- [Privacy & Security](/docs/privacy.md) - List of requests, potential issues, and security resources

View File

@ -1,139 +1,147 @@
# *Dashy Showcase* 🌟
| 💗 Do you use Dashy? Got a sweet dashboard? Submit it to the showcase! 👉 [See How](#submitting-your-dashboard) |
|-|
### Home Lab 2.0
![screenshot-homelab](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/1-home-lab-material.png)
---
### Ratty222
> By [@ratty222](https://github.com/ratty222) ([#384](https://github.com/Lissy93/dashy/discussions/384))
![screenshot-ratty222-dashy](https://user-images.githubusercontent.com/1862727/147582551-4c655d37-8bcc-4f95-ab41-164a9d0d6a07.png)
---
### Networking Services
> By [@Lissy93](https://github.com/lissy93)
![screenshot-networking-services](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/2-networking-services-minimal-dark.png)
---
### Homelab & VPS dashboard
> By [@shadowking001](https://github.com/shadowking001)
![screenshot-shadowking001-dashy](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/8-shadowking001s-dashy.png)
---
### EVO Dashboard
> By [@EVOTk](https://github.com/EVOTk)
![screenshot-evo-dashboard](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/12-evo-dashboard.png)
---
### NAS Home Dashboard
> By [@cerealconyogurt](https://github.com/cerealconyogurt)
![screenshot-networking-services](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/6-nas-home-dashboard.png)
---
### Dashy Live
> By [@Lissy93](https://github.com/lissy93)
> A dashboard I made to manage all project development links from one place. View demo at [live.dashy.to](https://live.dashy.to/).
![screenshot-dashy-live](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/10-dashy-live.png)
### CFT Toolbox
![screenshot-cft-toolbox](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/3-cft-toolbox.png)
---
### Bookmarks
![screenshot-bookmarks](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/4-bookmarks-colourful.png)
---
### Project Management
![screenshot-project-managment](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/5-project-managment.png)
---
### Dashy Example
> An example dashboard, by [@Lissy93](https://github.com/lissy93). View live at [demo.dashy.to](https://demo.dashy.to/).
![screenshot-dashy-example](https://i.ibb.co/YbzqPK7/demo-dashy.png)
---
### First Week of Self-Hosting
> By [u//RickyCZ](https://www.reddit.com/user/RickyCZ)
![screenshot-week-of-self-hosting](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/11-ricky-cz.png)
---
### HomeLAb 3.0
> By [@skoogee](https://github.com/skoogee) (http://zhrn.cc)
> Dashy, is the most complete dashboard I ever tried, has all the features, and it sets itself apart from the rest. It is my default homepage now. I am thankful to the developer @Lissy93 for sharing such a wonderful creation.
[![screenshot-12-skoogee-homelab-3](https://i.ibb.co/F5yBTsT/12-skoogee-homelab-3.png)](https://ibb.co/album/ynSwzm)
---
### Ground Control
> By [@dtctek](https://github.com/dtctek)
![screenshot-ground-control](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/7-ground-control-dtctek.png)
---
### Yet Another Homelab
![screenshot-yet-another-homelab](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/9-home-lab-oblivion.png)
---
## Submitting your Dashboard
#### How to Submit
- [Open an Issue](https://git.io/JEtgM)
- [Open a PR](https://github.com/Lissy93/dashy/compare)
#### What to Include
Please include the following information:
- A single high-quality screenshot of your Dashboard
- A short title (it doesn't have to be particularly imaginative)
- An optional description, you could include details on anything interesting or unique about your dashboard, or say how you use it, and why it's awesome
- Optionally leave your name or username, with a link to your GitHub, Twitter or Website
#### Template
If you're submitting a pull request, please use a format similar to this:
```
### [Dashboard Name] (required)
> Submitted by [@username](https://github.com/user) (optional)
![dashboard-screenshot](/docs/showcase/screenshot-name.jpg) (required)
[An optional text description, or any interesting details] (optional)
---
```
# *Dashy Showcase* 🌟
| 💗 Do you use Dashy? Got a sweet dashboard? Submit it to the showcase! 👉 [See How](#submitting-your-dashboard) |
|-|
### Home Lab 2.0
![screenshot-homelab](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/1-home-lab-material.png)
---
### Ratty222
> By [@ratty222](https://github.com/ratty222) ([#384](https://github.com/Lissy93/dashy/discussions/384))
![screenshot-ratty222-dashy](https://user-images.githubusercontent.com/1862727/147582551-4c655d37-8bcc-4f95-ab41-164a9d0d6a07.png)
---
### Networking Services
> By [@Lissy93](https://github.com/lissy93)
![screenshot-networking-services](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/2-networking-services-minimal-dark.png)
---
### Homelab & VPS dashboard
> By [@shadowking001](https://github.com/shadowking001)
![screenshot-shadowking001-dashy](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/8-shadowking001s-dashy.png)
---
### EVO Dashboard
> By [@EVOTk](https://github.com/EVOTk)
![screenshot-evo-dashboard](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/12-evo-dashboard.png)
---
### NAS Home Dashboard
> By [@cerealconyogurt](https://github.com/cerealconyogurt)
![screenshot-networking-services](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/6-nas-home-dashboard.png)
---
### Dashy Live
> By [@Lissy93](https://github.com/lissy93)
> A dashboard I made to manage all project development links from one place. View demo at [live.dashy.to](https://live.dashy.to/).
![screenshot-dashy-live](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/10-dashy-live.png)
### CFT Toolbox
![screenshot-cft-toolbox](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/3-cft-toolbox.png)
---
### Bookmarks
![screenshot-bookmarks](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/4-bookmarks-colourful.png)
---
### Project Management
![screenshot-project-managment](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/5-project-managment.png)
---
### Dashy Example
> An example dashboard, by [@Lissy93](https://github.com/lissy93). View live at [demo.dashy.to](https://demo.dashy.to/).
![screenshot-dashy-example](https://i.ibb.co/YbzqPK7/demo-dashy.png)
---
### First Week of Self-Hosting
> By [u//RickyCZ](https://www.reddit.com/user/RickyCZ)
![screenshot-week-of-self-hosting](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/11-ricky-cz.png)
---
### HomeLAb 3.0
> By [@skoogee](https://github.com/skoogee) (http://zhrn.cc)
> Dashy, is the most complete dashboard I ever tried, has all the features, and it sets itself apart from the rest. It is my default homepage now. I am thankful to the developer @Lissy93 for sharing such a wonderful creation.
[![screenshot-12-skoogee-homelab-3](https://i.ibb.co/F5yBTsT/12-skoogee-homelab-3.png)](https://ibb.co/album/ynSwzm)
---
### Ground Control
> By [@dtctek](https://github.com/dtctek)
![screenshot-ground-control](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/7-ground-control-dtctek.png)
---
### Crypto Dash
> Example usage of widgets to monitor cryptocurrencies news, prices and data. Config is [available here](https://gist.github.com/Lissy93/000f712a5ce98f212817d20bc16bab10#file-example-8-dashy-crypto-widgets-conf-yml)
![screenshot-crypto-dash](https://user-images.githubusercontent.com/1862727/147394584-352fe3bf-740d-4624-a01b-9003a97bc832.png)
---
### Yet Another Homelab
![screenshot-yet-another-homelab](https://raw.githubusercontent.com/Lissy93/dashy/master/docs/showcase/9-home-lab-oblivion.png)
---
## Submitting your Dashboard
#### How to Submit
- [Open an Issue](https://git.io/JEtgM)
- [Open a PR](https://github.com/Lissy93/dashy/compare)
#### What to Include
Please include the following information:
- A single high-quality screenshot of your Dashboard
- A short title (it doesn't have to be particularly imaginative)
- An optional description, you could include details on anything interesting or unique about your dashboard, or say how you use it, and why it's awesome
- Optionally leave your name or username, with a link to your GitHub, Twitter or Website
#### Template
If you're submitting a pull request, please use a format similar to this:
```
### [Dashboard Name] (required)
> Submitted by [@username](https://github.com/user) (optional)
![dashboard-screenshot](/docs/showcase/screenshot-name.jpg) (required)
[An optional text description, or any interesting details] (optional)
---
```

1256
docs/widgets.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "Dashy",
"version": "1.9.3",
"version": "1.9.4",
"license": "MIT",
"main": "server",
"author": "Alicia Sykes <alicia@omg.lol> (https://aliciasykes.com)",
@ -25,6 +25,7 @@
"connect-history-api-fallback": "^1.6.0",
"crypto-js": "^4.1.1",
"express": "^4.17.1",
"frappe-charts": "^1.6.2",
"js-yaml": "^4.1.0",
"keycloak-js": "^15.0.2",
"register-service-worker": "^1.6.2",
@ -95,4 +96,4 @@
"> 1%",
"last 2 versions"
]
}
}

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,69 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<font id="WeatherIcons" horiz-adv-x="514.451">
<font-face font-family="WeatherIcons"
units-per-em="512" ascent="512"
descent="0" />
<missing-glyph horiz-adv-x="0" />
<glyph glyph-name="01d"
unicode="&#xEA02;"
horiz-adv-x="512" d=" M256 132.414C324.414 132.414 379.586 188.028 379.586 256C379.586 323.972 323.972 379.586 256 379.586C188.028 379.586 132.414 323.972 132.414 256C132.414 188.028 187.586 132.414 256 132.414zM256 343.834C304.552 343.834 343.834 304.552 343.834 256C343.834 207.448 304.552 168.166 256 168.166C207.448 168.166 168.166 207.448 168.166 256C168.166 304.552 207.448 343.834 256 343.834z M274.097 436.966L274.097 493.903C274.097 504.055 266.152 512 256 512C245.848 512 237.903 504.055 237.903 493.903L237.903 436.966C237.903 426.814 245.848 418.8690000000001 256 418.8690000000001C266.152 418.8690000000001 274.097 426.814 274.097 436.966z M237.903 75.034L237.903 18.097C237.903 7.945 245.848 0 256 0C266.152 0 274.097 7.945 274.097 18.097L274.097 75.034C274.097 85.186 266.152 93.131 256 93.131C245.848 93.131 237.903 85.186 237.903 75.034z M396.8 371.2L436.966 411.366C444.028 418.428 444.028 429.903 436.966 436.966C429.903 444.028 418.428 444.028 411.366 436.966L371.2 396.8C364.138 389.738 364.138 378.262 371.2 371.2C374.731 367.669 379.145 365.903 384 365.903C388.855 365.903 393.269 367.669 396.8 371.2L396.8 371.2z M75.034 75.034C78.566 71.503 82.979 69.738 87.834 69.738C92.248 69.738 97.103 71.503 100.634 75.034L140.8 115.2C147.862 122.262 147.862 133.738 140.8 140.8C133.738 147.862 122.262 147.862 115.2 140.8L75.034 100.634C67.972 93.572 67.972 82.097 75.034 75.034z M512 256C512 266.152 504.055 274.097 493.903 274.097L436.966 274.097C426.814 274.097 418.8690000000001 266.152 418.8690000000001 256C418.8690000000001 245.848 426.814 237.903 436.966 237.903L493.903 237.903C503.614 237.903 512 245.848 512 256z M18.097 237.903L75.034 237.903C85.186 237.903 93.131 245.848 93.131 256C93.131 266.152 85.186 274.097 75.034 274.097L18.097 274.097C7.945 274.097 0 266.152 0 256C0 245.848 8.386 237.903 18.097 237.903z M424.166 69.738C428.579 69.738 433.4340000000001 71.503 436.966 75.034C444.028 82.097 444.028 93.572 436.966 100.634L396.8 140.8C389.738 147.862 378.262 147.862 371.2 140.8C364.138 133.738 364.138 122.262 371.2 115.2L411.366 75.034C414.897 71.503 419.752 69.738 424.166 69.738L424.166 69.738z M140.8 371.2C147.862 378.262 147.862 389.738 140.8 396.8L100.634 436.966C93.572 444.028 82.097 444.028 75.034 436.966C67.972 429.903 67.972 418.428 75.034 411.366L115.2 371.2C118.731 367.669 123.145 365.903 128 365.903C132.855 365.903 137.269 367.669 140.8 371.2z" />
<glyph glyph-name="01n"
unicode="&#xEA01;"
horiz-adv-x="512" d=" M337.556 322.748L332.533 293.456C331.759 288.958 333.572 284.499 337.256 281.828C339.3 280.341 341.716 279.555 344.246 279.555C346.168 279.555 348.082 280.029 349.778 280.921L376.089 294.752L402.386 280.926C404.087 280.029 406.004 279.554 407.93 279.554C410.451 279.554 412.863 280.336 414.918 281.823C418.608 284.505 420.421 288.962 419.647 293.45L414.623 322.747L435.907 343.495C439.1739999999999 346.68 440.3259999999999 351.353 438.9149999999999 355.676C437.5079999999999 360.013 433.8319999999999 363.115 429.318 363.77L399.902 368.045L386.7459999999999 394.705C384.728 398.788 380.644 401.325 376.0899999999999 401.325C371.529 401.325 367.4439999999999 398.785 365.431 394.699L352.274 368.044L322.8589999999999 363.769C318.353 363.115 314.6769999999999 360.017 313.2609999999999 355.677C311.8549999999999 351.347 313.0059999999999 346.681 316.2699999999999 343.492L337.556 322.748zM358.816 352.627C361.455 353.01 363.735 354.6670000000001 364.916 357.058L376.089 379.6950000000001L387.26 357.0590000000001C388.438 354.668 390.72 353.011 393.359 352.6280000000001L418.343 348.997L400.266 331.376C398.358 329.515 397.486 326.8350000000001 397.936 324.207L402.203 299.326L379.858 311.074C378.678 311.694 377.384 312.004 376.089 312.004C374.794 312.004 373.499 311.694 372.318 311.072L349.974 299.326L354.24 324.206C354.691 326.834 353.82 329.515 351.911 331.376L333.832 348.996L358.816 352.627z M445.893 82.507C437.604 81.607 429.239 81.152 421.029 81.152C366.835 81.152 314.324 100.501 273.1670000000001 135.635C269.7650000000001 138.539 264.651 138.134 261.7480000000001 134.733C258.8440000000001 131.33 259.2480000000001 126.217 262.6500000000001 123.313C306.737 85.677 362.9840000000001 64.95 421.029 64.95C421.66 64.95 422.293 64.953 422.925 64.958C381.373 33.367 330.735 16.199 277.985 16.199C145.759 16.199 38.185 123.774 38.185 256C38.185 383.344 137.957 487.8209999999999 263.437 495.3639999999999C208.891 449.204 176.967 381.203 176.967 309.01C176.967 246.365 200.665 186.814 243.695 141.3229999999999C246.768 138.0729999999999 251.894 137.9309999999999 255.146 141.005C258.396 144.0799999999999 258.538 149.2049999999999 255.464 152.456C215.291 194.925 193.167 250.524 193.167 309.01C193.167 384.148 230.215 454.4299999999999 292.272 497.015C295.15 498.99 296.439 502.5889999999999 295.47 505.943C294.501 509.297 291.49 511.653 288.002 511.788C284.296 511.931 281.02 512 277.986 512C209.604 512 145.317 485.371 96.966 437.02C48.614 388.668 21.986 324.381 21.986 256.001S48.614 123.333 96.966 74.982C145.317 26.629 209.605 0 277.986 0C342.874 0 404.773 24.335 452.282 68.523C454.837 70.9 455.585 74.645 454.133 77.8200000000001C452.683 80.995 449.3690000000001 82.882 445.893 82.507z M327.465 186.76C327.465 168.623 342.222 153.867 360.36 153.867C378.497 153.867 393.253 168.6229999999999 393.253 186.76C393.253 204.898 378.497 219.654 360.3599999999999 219.654C342.222 219.654 327.465 204.898 327.465 186.76zM377.053 186.76C377.053 177.555 369.564 170.067 360.36 170.067C351.154 170.067 343.665 177.556 343.665 186.76S351.1550000000001 203.454 360.36 203.454S377.053 195.966 377.053 186.76z M472.699 255.998C463.138 255.998 455.386 248.248 455.386 238.684C455.386 229.121 463.137 221.369 472.699 221.369C482.264 221.369 490.014 229.121 490.014 238.684C490.014 248.249 482.264 255.998 472.699 255.998z" />
<glyph glyph-name="02d"
unicode="&#xEA03;"
horiz-adv-x="512" d=" M326.4 467.2C315.795 467.2 307.2 458.604 307.2 448V435.2C307.2 424.596 315.795 416 326.4 416C337.002 416 345.6 424.596 345.6 435.2V448C345.6 458.604 337.002 467.2 326.4 467.2z M422.4 281.6C422.4 334.534 379.334 377.6 326.4 377.6C305.455 377.6 285.548 370.971 268.829 358.428C258.714 350.839 250.172 341.233 243.746 330.3930000000001C229.643 336.058 214.314 339.148 198.398 339.148C133.21 339.148 79.838 287.588 76.926 223.106C33.499 216.934 0 179.506 0 134.4C0 84.993 40.195 44.8 89.6 44.8H313.6C373.5920000000001 44.8 422.4000000000001 93.608 422.4000000000001 153.6C422.4000000000001 178.482 413.985 201.423 399.877 219.773C414.363 236.897 422.4 258.565 422.4 281.6zM313.6 83.2H89.6C61.37 83.2 38.4 106.168 38.4 134.4S61.37 185.6 89.6 185.6H96C106.605 185.6 115.2 194.196 115.2 204.8V217.549C115.2 263.427 152.525 300.749 198.4 300.749C236.37 300.749 269.5 275.112 278.967 238.405C281.155 229.925 288.803 224 297.559 224H313.6C352.42 224 384 192.419 384 153.6C384 114.781 352.42 83.2 313.6 83.2zM371.443 245.6840000000001C354.673 256.256 334.848 262.4 313.6 262.4H311.468C303.927 281.4600000000001 291.814 297.923 276.625 310.669C286.755 328.1330000000001 305.445 339.2000000000001 326.4 339.2000000000001C358.161 339.2000000000001 384 313.362 384 281.6C384 268.303 379.525 255.77 371.443 245.6840000000001z M492.8 300.8H480C469.395 300.8 460.8 292.204 460.8 281.6S469.395 262.4000000000001 480 262.4000000000001H492.8C503.402 262.4000000000001 512 270.9960000000001 512 281.6S503.402 300.8 492.8 300.8z M457.637 412.841C450.14 420.337 437.983 420.337 430.484 412.838L421.434 403.787C413.937 396.289 413.937 384.131 421.437 376.633C425.185 372.885 430.097 371.01 435.011 371.01C439.924 371.01 444.839 372.885 448.588 376.636L457.638 385.687C465.138 393.187 465.138 405.344 457.637 412.841z M231.364 403.789L222.314 412.839C214.814 420.336 202.66 420.337 195.159 412.839C187.662 405.342 187.662 393.1860000000001 195.159 385.6860000000001L204.21 376.636C207.96 372.886 212.873 371.01 217.787 371.01C222.7 371.01 227.615 372.885 231.361 376.634C238.861 384.132 238.861 396.288 231.364 403.789z" />
<glyph glyph-name="02n"
unicode="&#xEA04;"
horiz-adv-x="514.451" d=" M513.493 206.586C511.776 210.732 507.979 213.648 503.53 214.24C462.581 219.674 427.344 246.707 411.568 284.791C395.793 322.876 401.592 366.911 426.705 399.708C429.433 403.271 430.057 408.019 428.34 412.166C426.623 416.312 422.826 419.228 418.377 419.818C393.88 423.072 368.837 419.781 345.96 410.306C302.692 392.384 272.2200000000001 355.1040000000001 262.326 311.304C242.283 333.942 212.747 347.953 181.04 347.953C121.795 347.953 73.595 300.707 73.595 242.636C73.595 242.431 73.595 242.229 73.597 242.024C33.858 239.0350000000001 2.451 206.35 2.451 166.602C2.451 124.894 37.032 90.963 79.539 90.963L286.34 90.963C323.599 90.963 356.057 111.472 372.609 141.578C381.7320000000001 139.756 390.917 138.833 400.082 138.833C418.364 138.833 436.552 142.455 453.926 149.651C476.803 159.128 496.835 174.508 511.86 194.13C514.588 197.691 515.212 202.44 513.493 206.586zM286.338 116.113L79.539 116.113C50.9 116.113 27.601 138.763 27.601 166.602C27.601 194.443 50.9 217.095 79.539 217.095C81.306 217.095 83.272 216.963 85.719 216.676C89.604 216.217 93.472 217.598 96.197 220.402C98.92 223.203 100.186 227.113 99.622 230.981C99.041 234.9700000000001 98.745 238.892 98.745 242.636C98.745 286.8400000000001 135.662 322.803 181.04 322.803C217.033 322.803 249.352 299.426 259.776 265.915C259.9100000000001 265.444 260.073 264.986 260.259 264.54C262.447 259.1190000000001 268.138 255.86 273.993 256.852C278.05 257.533 282.203 257.877 286.338 257.877C326.476 257.877 359.129 226.079 359.129 186.996C359.129 147.911 326.476 116.113 286.338 116.113zM444.3 172.887C424.226 164.572 402.8450000000001 162.085 381.772 165.486C383.394 172.405 384.281 179.598 384.281 186.994C384.281 239.947 340.344 283.025 286.34 283.025C285.575 283.025 284.811 282.973 284.046 282.957C285.136 328.456 312.724 369.315 355.586 387.068C367.95 392.19 381.085 395.111 394.352 395.751C374.641 358.833 372.078 314.409 388.335 275.165C404.588 235.926 437.817 206.324 477.856 194.157C468.019 185.229 456.666 178.009 444.3 172.887z" />
<glyph glyph-name="03d"
unicode="&#xEA05;"
horiz-adv-x="512" d=" M320 384C372.562 384 415.375 341.562 416 289.187C415.75 287.249 415.562 285.312 415.5 283.312L414.688 259.812L436.938 252.062C462.688 243.094 480 218.938 480 192C480 156.688 451.312 128 416 128H96C60.719 128 32 156.688 32 192C32 226.938 60.188 255.438 95 256C96.5 255.781 98.063 255.594 99.625 255.5L123.938 253.906L131.938 276.875C140.938 302.687 165.063 320 192 320C195.125 320 198.563 319.625 203.188 318.812L225.594 314.781L236.75 334.625C253.875 365.062 285.75 384 320 384M320 416C272.062 416 230.781 389.312 208.844 350.312C203.375 351.281 197.781 352 192 352C150.062 352 114.781 324.937 101.719 287.437C99.813 287.562 97.969 288 96 288C43 288 0 245 0 192S43 96 96 96H416C469 96 512 139 512 192C512 233.938 484.938 269.25 447.438 282.313C447.5 284.25 448 286.062 448 288C448 358.687 390.688 416 320 416L320 416z" />
<glyph glyph-name="03n"
unicode="&#xEA06;"
horiz-adv-x="512" d=" M320 384C372.562 384 415.375 341.562 416 289.187C415.75 287.249 415.562 285.312 415.5 283.312L414.688 259.812L436.938 252.062C462.688 243.094 480 218.938 480 192C480 156.688 451.312 128 416 128H96C60.719 128 32 156.688 32 192C32 226.938 60.188 255.438 95 256C96.5 255.781 98.063 255.594 99.625 255.5L123.938 253.906L131.938 276.875C140.938 302.687 165.063 320 192 320C195.125 320 198.563 319.625 203.188 318.812L225.594 314.781L236.75 334.625C253.875 365.062 285.75 384 320 384M320 416C272.062 416 230.781 389.312 208.844 350.312C203.375 351.281 197.781 352 192 352C150.062 352 114.781 324.937 101.719 287.437C99.813 287.562 97.969 288 96 288C43 288 0 245 0 192S43 96 96 96H416C469 96 512 139 512 192C512 233.938 484.938 269.25 447.438 282.313C447.5 284.25 448 286.062 448 288C448 358.687 390.688 416 320 416L320 416z" />
<glyph glyph-name="04d"
unicode="&#xEA07;"
horiz-adv-x="512" d=" M320 384C372.562 384 415.375 341.562 416 289.187C415.75 287.249 415.562 285.312 415.5 283.312L414.688 259.812L436.938 252.062C462.688 243.094 480 218.938 480 192C480 156.688 451.312 128 416 128H96C60.719 128 32 156.688 32 192C32 226.938 60.188 255.438 95 256C96.5 255.781 98.063 255.594 99.625 255.5L123.938 253.906L131.938 276.875C140.938 302.687 165.063 320 192 320C195.125 320 198.563 319.625 203.188 318.812L225.594 314.781L236.75 334.625C253.875 365.062 285.75 384 320 384M320 416C272.062 416 230.781 389.312 208.844 350.312C203.375 351.281 197.781 352 192 352C150.062 352 114.781 324.937 101.719 287.437C99.813 287.562 97.969 288 96 288C43 288 0 245 0 192S43 96 96 96H416C469 96 512 139 512 192C512 233.938 484.938 269.25 447.438 282.313C447.5 284.25 448 286.062 448 288C448 358.687 390.688 416 320 416L320 416z" />
<glyph glyph-name="04n"
unicode="&#xEA08;"
horiz-adv-x="512" d=" M320 384C372.562 384 415.375 341.562 416 289.187C415.75 287.249 415.562 285.312 415.5 283.312L414.688 259.812L436.938 252.062C462.688 243.094 480 218.938 480 192C480 156.688 451.312 128 416 128H96C60.719 128 32 156.688 32 192C32 226.938 60.188 255.438 95 256C96.5 255.781 98.063 255.594 99.625 255.5L123.938 253.906L131.938 276.875C140.938 302.687 165.063 320 192 320C195.125 320 198.563 319.625 203.188 318.812L225.594 314.781L236.75 334.625C253.875 365.062 285.75 384 320 384M320 416C272.062 416 230.781 389.312 208.844 350.312C203.375 351.281 197.781 352 192 352C150.062 352 114.781 324.937 101.719 287.437C99.813 287.562 97.969 288 96 288C43 288 0 245 0 192S43 96 96 96H416C469 96 512 139 512 192C512 233.938 484.938 269.25 447.438 282.313C447.5 284.25 448 286.062 448 288C448 358.687 390.688 416 320 416L320 416z" />
<glyph glyph-name="09d"
unicode="&#xEA09;"
horiz-adv-x="512" d=" M431.401 370.45C431.989 375.45 432.55 381.241 432.55 385.488C432.55 455.248 376.363 512 307.313 512C263.363 512 222.816 488.572 200.274 451.19C186.281 460.87 169.727 466.063 152.416 466.063C105.527 466.063 67.377 427.522 67.377 380.149C67.377 374.937 67.853 369.7390000000001 68.762 364.635C28.498 349.899 1.294 311.3350000000001 1.294 267.078C1.294 210.755 45.48 168.292 104.082 168.292L398.3210000000001 168.292C454.998 168.292 501.109 214.865 501.109 272.111C501.114 316.603 472.376 356.326 431.401 370.45zM398.326 192.054L104.082 192.054C58.889 192.054 24.816 224.319 24.816 267.083C24.816 304.371 49.835 336.396 85.659 344.988C88.884 345.746 91.638 347.846 93.239 350.775C94.849 353.703 95.151 357.173 94.105 360.337C91.977 366.693 90.904 373.35 90.904 380.149C90.904 414.419 118.498 442.282 152.406 442.282C168.701 442.282 184.068 435.907 195.66 424.335C198.4 421.618 202.284 420.366 206.065 421.1190000000001C209.86 421.806 213.056 424.358 214.648 427.875C231.107 464.533 267.492 488.215 307.308 488.215C363.382 488.215 409.009 442.145 409.009 385.488C409.009 379.659 407.366 367.776 406.74 363.689C405.784 357.4840000000001 409.739 351.603 415.784 350.233C451.594 342.131 477.597 309.2820000000001 477.597 272.111C477.588 227.972 442.041 192.054 398.326 192.054zM132.185 135.241C125.725 136.131 119.798 131.621 118.889 125.133L111.036 68.998C110.132 62.505 114.619 56.483 121.041 55.561C121.606 55.485 122.161 55.462 122.707 55.462C128.484 55.462 133.512 59.727 134.351 65.664L142.19 121.799C143.098 128.292 138.607 134.309 132.185 135.241zM213.565 135.241C207.105 136.131 201.178 131.621 200.269 125.133L184.577 13.55C183.668 7.057 188.146 1.036 194.568 0.113C195.133 0.014 195.688 0 196.239 0C202.006 0 207.035 4.27 207.873 10.198L223.565 121.785C224.474 128.292 220.006 134.281 213.565 135.241zM297.855 135.241C291.404 136.131 285.449 131.621 284.545 125.133L268.867 13.55C267.944 7.057 272.421 1.036 278.853 0.113C279.4220000000001 0.014 279.968 0 280.519 0C286.287 0 291.324 4.27 292.153 10.198L307.841 121.785C308.749 128.292 304.281 134.281 297.855 135.241zM379.225 135.241C372.742 136.131 366.838 131.621 365.929 125.133L358.076 68.998C357.182 62.505 361.654 56.483 368.09 55.561C368.641 55.485 369.192 55.462 369.743 55.462C375.525 55.462 380.548 59.727 381.391 65.664L389.235 121.799C390.129 128.292 385.642 134.309 379.225 135.241z" />
<glyph glyph-name="09n"
unicode="&#xEA0A;"
horiz-adv-x="512" d=" M431.401 370.45C431.989 375.45 432.55 381.241 432.55 385.488C432.55 455.248 376.363 512 307.313 512C263.363 512 222.816 488.572 200.274 451.19C186.281 460.87 169.727 466.063 152.416 466.063C105.527 466.063 67.377 427.522 67.377 380.149C67.377 374.937 67.853 369.7390000000001 68.762 364.635C28.498 349.899 1.294 311.3350000000001 1.294 267.078C1.294 210.755 45.48 168.292 104.082 168.292L398.3210000000001 168.292C454.998 168.292 501.109 214.865 501.109 272.111C501.114 316.603 472.376 356.326 431.401 370.45zM398.326 192.054L104.082 192.054C58.889 192.054 24.816 224.319 24.816 267.083C24.816 304.371 49.835 336.396 85.659 344.988C88.884 345.746 91.638 347.846 93.239 350.775C94.849 353.703 95.151 357.173 94.105 360.337C91.977 366.693 90.904 373.35 90.904 380.149C90.904 414.419 118.498 442.282 152.406 442.282C168.701 442.282 184.068 435.907 195.66 424.335C198.4 421.618 202.284 420.366 206.065 421.1190000000001C209.86 421.806 213.056 424.358 214.648 427.875C231.107 464.533 267.492 488.215 307.308 488.215C363.382 488.215 409.009 442.145 409.009 385.488C409.009 379.659 407.366 367.776 406.74 363.689C405.784 357.4840000000001 409.739 351.603 415.784 350.233C451.594 342.131 477.597 309.2820000000001 477.597 272.111C477.588 227.972 442.041 192.054 398.326 192.054zM132.185 135.241C125.725 136.131 119.798 131.621 118.889 125.133L111.036 68.998C110.132 62.505 114.619 56.483 121.041 55.561C121.606 55.485 122.161 55.462 122.707 55.462C128.484 55.462 133.512 59.727 134.351 65.664L142.19 121.799C143.098 128.292 138.607 134.309 132.185 135.241zM213.565 135.241C207.105 136.131 201.178 131.621 200.269 125.133L184.577 13.55C183.668 7.057 188.146 1.036 194.568 0.113C195.133 0.014 195.688 0 196.239 0C202.006 0 207.035 4.27 207.873 10.198L223.565 121.785C224.474 128.292 220.006 134.281 213.565 135.241zM297.855 135.241C291.404 136.131 285.449 131.621 284.545 125.133L268.867 13.55C267.944 7.057 272.421 1.036 278.853 0.113C279.4220000000001 0.014 279.968 0 280.519 0C286.287 0 291.324 4.27 292.153 10.198L307.841 121.785C308.749 128.292 304.281 134.281 297.855 135.241zM379.225 135.241C372.742 136.131 366.838 131.621 365.929 125.133L358.076 68.998C357.182 62.505 361.654 56.483 368.09 55.561C368.641 55.485 369.192 55.462 369.743 55.462C375.525 55.462 380.548 59.727 381.391 65.664L389.235 121.799C390.129 128.292 385.642 134.309 379.225 135.241z" />
<glyph glyph-name="10d"
unicode="&#xEA0B;"
horiz-adv-x="512" d=" M98.59 109.285C92.277 110.12 86.348 105.687 85.446 99.264L79.423 56.097C78.53 49.646 82.959 43.665 89.324 42.754C89.875 42.683 90.421 42.64 90.967 42.64C96.658 42.64 101.647 46.898 102.468 52.789L108.482 95.956C109.375 102.407 104.946 108.359 98.59 109.285zM160.972 109.285C154.649 110.182 148.735 105.715 147.819 99.264L135.795 13.452C134.903 7.001 139.322 1.03 145.678 0.119C146.238 0.019 146.784 0 147.335 0C153.026 0 158.015 4.244 158.831 10.125L170.855 95.956C171.757 102.378 167.328 108.359 160.972 109.285zM225.566 109.285C219.21 110.182 213.305 105.715 212.413 99.264L200.389 13.452C199.487 7.001 203.902 1.03 210.272 0.119C210.818 0.019 211.378 0 211.914 0C217.615 0 222.595 4.244 223.425 10.125L235.449 95.956C236.341 102.378 231.922 108.359 225.566 109.285zM287.924 109.285C281.592 110.12 275.673 105.687 274.776 99.264L268.766 56.097C267.86 49.646 272.288 43.665 278.649 42.754C279.209 42.683 279.75 42.64 280.291 42.64C285.992 42.64 290.981 46.898 291.807 52.789L297.8210000000001 95.956C298.718 102.407 294.28 108.359 287.924 109.285zM417.914 341.216C408.715 393.91 358.96 429.169 306.983 419.884C288.029 416.49 271.026 407.676 257.35 394.29C249.836 396.236 242.004 397.38 233.901 397.38C201.604 397.38 171.733 380.6430000000001 154.365 353.738C144.164 360.094 132.378 363.502 120.117 363.502C84.022 363.502 54.664 333.735 54.664 297.166C54.664 293.976 54.877 290.81 55.319 287.692C25.851 275.7820000000001 6.114 246.86 6.114 213.808C6.114 170.58 39.854 137.96 84.601 137.96L300.769 137.96C344.04 137.96 379.252 173.656 379.252 217.535C379.252 226.216 377.795 234.651 375.1600000000001 242.598C407.301 263.4360000000001 424.683 302.449 417.914 341.216zM300.769 161.523L84.601 161.523C53.111 161.523 29.378 183.999 29.378 213.808C29.378 239.882 46.803 262.2870000000001 71.757 268.2920000000001C74.947 269.0560000000001 77.662 271.13 79.257 274.031C80.837 276.9360000000001 81.151 280.391 80.116 283.534C78.649 287.891 77.928 292.486 77.928 297.176C77.928 320.748 96.858 339.939 120.117 339.939C131.295 339.939 141.828 335.558 149.793 327.579C152.494 324.892 156.339 323.677 160.07 324.389C163.82 325.082 166.986 327.603 168.557 331.101C180.163 357.0320000000001 205.82 373.793 233.901 373.793C273.461 373.793 305.634 341.197 305.634 301.101C305.634 297.442 304.642 289.771 303.987 285.5080000000001C303.033 279.366 306.954 273.5320000000001 312.93 272.175C337.879 266.483 355.998 243.504 355.998 217.53C355.998 186.643 331.224 161.523 300.769 161.523zM364.504 263.678C355.556 276.361 343.044 286.41 328.291 292.049C328.623 295.192 328.884 298.481 328.884 301.097C328.884 336.522 309.864 367.456 281.687 384.179C290.35 390.445 300.266 394.717 311.032 396.644C350.453 403.741 388.062 377.002 395.006 337.096C400.014 308.525 387.644 279.741 364.504 263.678zM323.834 455.309L323.905 455.309C330.332 455.362 335.496 460.669 335.463 467.195L335.249 500.29C335.197 506.76 330.004 512 323.62 512L323.544 512C317.117 511.953 311.948 506.641 311.981 500.119L312.194 467.024C312.247 460.545 317.435 455.309 323.834 455.309zM494.257 323.3590000000001L494.166 323.3590000000001L461.513 323.145C455.086 323.093 449.9220000000001 317.786 449.955 311.288C450.007 304.785 455.195 299.573 461.585 299.573L461.661 299.573L494.323 299.7870000000001C500.75 299.834 505.919 305.146 505.886 311.644C505.839 318.142 500.65 323.3590000000001 494.257 323.3590000000001zM427.963 413.12C430.963 413.12 433.963 414.283 436.2320000000001 416.642L459.178 440.1910000000001C463.692 444.814 463.635 452.286 459.074 456.857C454.517 461.428 447.154 461.409 442.635 456.762L419.694 433.19C415.171 428.5660000000001 415.218 421.0950000000001 419.789 416.524C422.054 414.264 425.011 413.12 427.963 413.12zM203.294 416.642C205.568 414.283 208.568 413.12 211.568 413.12C214.52 413.12 217.473 414.264 219.737 416.528C224.318 421.1 224.356 428.5710000000001 219.841 433.194L196.9 456.767C192.377 461.414 185.019 461.428 180.462 456.862C175.891 452.29 175.844 444.819 180.358 440.1960000000001L203.294 416.642zM423.435 223.402C418.921 228.049 411.544 228.091 406.997 223.501C402.411 218.925 402.373 211.478 406.892 206.831L429.838 183.258C432.112 180.923 435.1070000000001 179.76 438.1070000000001 179.76C441.06 179.76 444.012 180.875 446.276 183.163C450.857 187.734 450.895 195.187 446.381 199.834L423.435 223.402z" />
<glyph glyph-name="10n"
unicode="&#xEA0C;"
horiz-adv-x="512" d=" M114.55 115.983C107.778 116.872 101.386 112.191 100.421 105.329L93.953 59.512C92.989 52.679 97.755 46.349 104.584 45.374C105.178 45.275 105.763 45.256 106.347 45.256C112.482 45.256 117.833 49.761 118.722 56.01L125.176 101.831C126.145 108.689 121.393 115.004 114.55 115.983zM181.589 115.983C174.779 116.934 168.421 112.191 167.451 105.357L154.53 14.276C153.551 7.413 158.308 1.098 165.147 0.119C165.745 0.048 166.339 0 166.924 0C173.035 0 178.386 4.505 179.285 10.759L192.206 101.841C193.175 108.665 188.433 115.004 181.589 115.983zM251.015 115.983C244.224 116.934 237.846 112.191 236.882 105.357L223.96 14.276C222.981 7.413 227.738 1.098 234.577 0.119C235.171 0.048 235.755 0 236.349 0C242.466 0 247.817 4.505 248.715 10.759L261.636 101.841C262.61 108.665 257.853 115.004 251.015 115.983zM318.031 115.983C311.254 116.872 304.863 112.191 303.907 105.329L297.454 59.512C296.4700000000001 52.679 301.246 46.349 308.066 45.374C308.664 45.275 309.2440000000001 45.256 309.843 45.256C315.969 45.256 321.32 49.761 322.2080000000001 56.01L328.662 101.831C329.631 108.689 324.874 115.004 318.031 115.983zM481.779 393.424C481.779 393.9940000000001 481.75 394.564 481.708 395.111C481.608 400.381 478.262 405.005 473.301 406.7440000000001C468.316 408.46 462.827 406.887 459.515 402.814C446.741 387.127 427.836 378.117 407.682 378.117C370.819 378.117 340.818 408.151 340.818 445.052C340.818 462.098 347.305 478.38 359.062 490.892C362.674 494.708 363.496 500.378 361.139 505.102C358.772 509.797 353.64 512.582 348.517 511.897C285.606 504.479 243.35 460.24 243.35 401.769C243.35 400.9940000000001 243.445 400.243 243.578 399.502C215.473 396.94 190.02 381.743 174.58 358.666C164.762 364.483 153.518 367.581 141.827 367.581C106.214 367.581 77.225 338.55 77.225 302.889C77.225 300.099 77.392 297.334 77.748 294.62C49.244 282.815 30.221 254.977 30.221 223.18C30.221 181.127 63.425 149.42 107.454 149.42L316.734 149.42C359.318 149.42 393.962 184.107 393.962 226.729C393.962 245.159 387.252 262.462 376.032 276.024C434.423 286.327 481.779 336.7630000000001 481.779 393.424zM316.724 174.46L107.445 174.46C77.672 174.46 55.218 195.407 55.218 223.184C55.218 247.544 71.703 268.449 95.317 274.076C98.739 274.889 101.657 277.103 103.358 280.202C105.064 283.257 105.387 286.921 104.261 290.262C102.916 294.264 102.222 298.531 102.222 302.894C102.222 324.754 119.981 342.5610000000001 141.818 342.5610000000001C152.306 342.5610000000001 162.21 338.483 169.671 331.07C172.574 328.209 176.699 326.897 180.729 327.6860000000001C184.759 328.423 188.162 331.093 189.858 334.814C200.893 359.174 225.282 374.909 251.989 374.909C289.589 374.909 320.174 344.305 320.174 306.6670000000001C320.174 303.231 319.21 295.818 318.616 291.93C317.594 285.401 321.8 279.227 328.2200000000001 277.773C351.819 272.4840000000001 368.956 251.004 368.956 226.739C368.966 197.912 345.532 174.46 316.724 174.46zM354.238 299.101C351.962 299.101 349.847 298.4410000000001 348.008 297.376C346.915 297.871 345.832 298.379 344.715 298.8210000000001C344.986 301.558 345.19 304.372 345.19 306.6620000000001C345.19 352.683 311.692 390.915 267.847 398.48C268.142 399.535 268.342 400.623 268.342 401.769C268.342 437.52 289.28 466.461 322.683 479.834C318.197 468.913 315.831 457.142 315.831 445.057C315.831 394.355 357.052 353.092 407.701 353.092C423.165 353.092 438.077 356.9310000000001 451.289 364.079C437.189 327.178 397.8450000000001 299.101 354.238 299.101z" />
<glyph glyph-name="11d"
unicode="&#xEA0D;"
horiz-adv-x="512" d=" M411.247 366.3300000000001C405.951 439.213 342.843 496.943 266.038 496.943C205.107 496.943 151.411 460.914 129.988 406.511C128.499 406.565 126.998 406.5900000000001 125.489 406.5900000000001C56.295 406.593 0 350.299 0 281.101C0 211.903 56.295 155.609 125.492 155.609L143.733 155.609L141.236 149.226C140.031 146.138 140.422 142.652 142.295 139.907C144.167 137.168 147.275 135.529 150.589 135.529L250.981 135.529L250.981 25.097C250.981 20.401 254.236 16.332 258.814 15.302C259.549 15.136 260.295 15.057 261.019 15.057C264.8330000000001 15.057 268.401 17.233 270.087 20.792L334.27 156.113C335.092 155.896 335.892 155.606 336.7850000000001 155.606C361.735 155.606 411.599 155.733 412.089 155.75C468.107 158.633 512 204.873 512 261.019C512.003 317.586 467.221 363.886 411.247 366.3300000000001zM271.058 69.693L271.058 145.572C271.061 151.115 266.5710000000001 155.612 261.021 155.612L165.294 155.612L230.902 323.27L230.902 215.603C230.902 210.059 235.392 205.563 240.942 205.563L335.501 205.563L271.058 69.693zM411.543 175.818C411.543 175.818 369.739 175.711 343.556 175.691L360.443 211.295C361.924 214.409 361.698 218.055 359.854 220.966C358.021 223.875 354.815 225.637 351.374 225.637L250.981 225.637L250.981 376.476C250.981 381.305 247.549 385.4460000000001 242.805 386.339C238.059 387.24 233.354 384.627 231.589 380.134L151.591 175.694L127.119 175.703L125.492 175.694C67.365 175.694 20.08 222.982 20.08 281.107C20.08 339.231 67.365 386.519 125.492 386.519C128.992 386.519 132.463 386.347 135.884 386.015C140.501 385.534 144.924 388.389 146.394 392.857C162.991 443.104 211.081 476.869 266.041 476.869C335.239 476.869 391.533 422.824 391.533 356.397C391.533 350.9550000000001 395.866 346.503 401.3090000000001 346.363C402.545 346.337 403.758 346.343 404.9940000000001 346.349L406.593 346.36C453.642 346.36 491.926 308.081 491.926 261.027C491.926 215.572 456.404 178.141 411.543 175.818z" />
<glyph glyph-name="11n"
unicode="&#xEA0E;"
horiz-adv-x="512" d=" M411.247 366.3300000000001C405.951 439.213 342.843 496.943 266.038 496.943C205.107 496.943 151.411 460.914 129.988 406.511C128.499 406.565 126.998 406.5900000000001 125.489 406.5900000000001C56.295 406.593 0 350.299 0 281.101C0 211.903 56.295 155.609 125.492 155.609L143.733 155.609L141.236 149.226C140.031 146.138 140.422 142.652 142.295 139.907C144.167 137.168 147.275 135.529 150.589 135.529L250.981 135.529L250.981 25.097C250.981 20.401 254.236 16.332 258.814 15.302C259.549 15.136 260.295 15.057 261.019 15.057C264.8330000000001 15.057 268.401 17.233 270.087 20.792L334.27 156.113C335.092 155.896 335.892 155.606 336.7850000000001 155.606C361.735 155.606 411.599 155.733 412.089 155.75C468.107 158.633 512 204.873 512 261.019C512.003 317.586 467.221 363.886 411.247 366.3300000000001zM271.058 69.693L271.058 145.572C271.061 151.115 266.5710000000001 155.612 261.021 155.612L165.294 155.612L230.902 323.27L230.902 215.603C230.902 210.059 235.392 205.563 240.942 205.563L335.501 205.563L271.058 69.693zM411.543 175.818C411.543 175.818 369.739 175.711 343.556 175.691L360.443 211.295C361.924 214.409 361.698 218.055 359.854 220.966C358.021 223.875 354.815 225.637 351.374 225.637L250.981 225.637L250.981 376.476C250.981 381.305 247.549 385.4460000000001 242.805 386.339C238.059 387.24 233.354 384.627 231.589 380.134L151.591 175.694L127.119 175.703L125.492 175.694C67.365 175.694 20.08 222.982 20.08 281.107C20.08 339.231 67.365 386.519 125.492 386.519C128.992 386.519 132.463 386.347 135.884 386.015C140.501 385.534 144.924 388.389 146.394 392.857C162.991 443.104 211.081 476.869 266.041 476.869C335.239 476.869 391.533 422.824 391.533 356.397C391.533 350.9550000000001 395.866 346.503 401.3090000000001 346.363C402.545 346.337 403.758 346.343 404.9940000000001 346.349L406.593 346.36C453.642 346.36 491.926 308.081 491.926 261.027C491.926 215.572 456.404 178.141 411.543 175.818z" />
<glyph glyph-name="1232n"
unicode="&#xEA0F;"
horiz-adv-x="512" d=" M467.4 213C459.6 221.1 450.8 227.9 441.2 233.4C443.4 235.3 445.5 237.3 447.6 239.4C470.8 262.6 485.1 291.4 490.4 321.5C493.1 336.6 474.7 346.1 463.8 335.3C445.6 317.2 420.3 306.2 392.5 306.8C340.2 307.9 297.5 350.5 296.4 402.9C296 430.7 307 456 325.1 474.2C335.9 485.1 326.4 503.4 311.3 500.8C281.2 495.5 252.4 481.2 229.2 458C201.4 430.2 186.4 394.4 184.2 358C140.9 336.6 108.9 295.3 100.6 245.7C48.4 232.5 10.9 185.5 10.9 130.2C10.8999999999999 64.5 64.3 11 130 11H381.7C447.3999999999999 11 500.8 64.5 500.8 130.2C500.9 161.2 488.9999999999999 190.7 467.4 213zM262.6 432.7C260.7 423.8 259.7 414.5 259.7 405C259.7 330.4 320.1 270 394.7 270C404.2 270 413.5 271 422.4 272.9C401.7 250 371.7 235.6 338.4 235.6C275.9 235.6 225.2 286.3 225.2 348.8C225.2 382 239.6 411.9 262.6 432.7000000000001zM381.7 52.1H130C86.9999999999999 52.1 51.9 87.1 51.9 130.1999999999999C51.9 170.0999999999999 81.8 203.5 121.4 207.8L138.5999999999999 209.6999999999999L139.6999999999999 227C141.8999999999999 262.7 160.5999999999999 293.7 188.0999999999999 312.7C194.5 285.8999999999999 208.1999999999999 260.3999999999999 229.1 239.5C274.8999999999999 193.6999999999999 342.3 182.6999999999999 398.5 206.3999999999999C433.8 198.6999999999999 459.6999999999999 167.5999999999999 459.6999999999999 130.1999999999999C459.8 87.0999999999999 424.8 52.0999999999999 381.7 52.0999999999999z" />
<glyph glyph-name="13d"
unicode="&#xEA10;"
horiz-adv-x="512" d=" M426.315 326.183C415.245 409.819 361.61 470.955 276.757 470.955C205.714 470.955 166.387 428.141 142.143 361.692C133.286 363.851 124.181 364.93 114.993 364.93C51.532 364.929 0 309.661 0 246.229C0 185.536 49.678 141.504 110.703 137.851L124.541 137.851L124.541 165.527L110.703 165.527C66.643 168.156 27.677 202.141 27.677 246.229C27.677 292.337 68.885 333.517 114.993 333.517C125.786 333.517 136.387 331.415 146.488 327.3450000000001L163.176 320.5370000000001L167.687 337.9460000000001C183.296 398.666 214.099 442.863 276.757 442.863C350.346 442.863 392.939 383.443 396.233 309.882L396.953 293.913L416.05 295.2970000000001C451.64 295.2970000000001 484.353 267.732 484.353 232.197C484.353 196.633 450.754 165.553 415.163 165.553L373.65 165.553L373.65 137.878L415.135 137.878C468.051 137.878 512 179.336 512 232.197C511.999 281.128 474.637 321.0080000000001 426.315 326.183zM334.848 216.921L321.121 208.59L322.366 222.705L300.668 224.836L297.928 194.558L260.012 171.588L260.012 217.53L283.62 235.159L271 253.84L260.012 245.62L260.012 262.28L238.149 262.28L238.149 245.62L227.19 253.84L214.542 235.132L238.177 217.503L238.177 171.534L200.262 194.504L197.522 224.782L175.824 222.651L177.069 208.537L163.342 216.867L152.355 197.078L166.11 188.748L153.85 182.853L162.955 162.014L189.302 174.689L227.218 151.691L189.302 128.692L162.955 141.368L153.85 120.528L166.11 114.633L152.355 106.303L163.314 86.432L177.041 94.762L175.796 80.648L197.493 78.516L200.234 108.794L238.149 131.792L238.149 85.824L214.514 68.194L227.162 49.486L238.121 57.678L238.121 41.045L259.984 41.045L259.984 57.678L270.944 49.486L283.592 68.194L259.984 85.824L259.984 131.792L297.9 108.794L300.64 78.516L322.337 80.648L321.093 94.762L334.82 86.432L345.724 106.303L331.997 114.606L344.257 120.501L335.152 141.341L308.777 128.665L270.862 151.664L308.777 174.662L335.152 161.987L344.257 182.826L331.969 188.722L345.724 197.052L334.848 216.921z" />
<glyph glyph-name="13n"
unicode="&#xEA11;"
horiz-adv-x="512" d=" M426.315 326.183C415.245 409.819 361.61 470.955 276.757 470.955C205.714 470.955 166.387 428.141 142.143 361.692C133.286 363.851 124.181 364.93 114.993 364.93C51.532 364.929 0 309.661 0 246.229C0 185.536 49.678 141.504 110.703 137.851L124.541 137.851L124.541 165.527L110.703 165.527C66.643 168.156 27.677 202.141 27.677 246.229C27.677 292.337 68.885 333.517 114.993 333.517C125.786 333.517 136.387 331.415 146.488 327.3450000000001L163.176 320.5370000000001L167.687 337.9460000000001C183.296 398.666 214.099 442.863 276.757 442.863C350.346 442.863 392.939 383.443 396.233 309.882L396.953 293.913L416.05 295.2970000000001C451.64 295.2970000000001 484.353 267.732 484.353 232.197C484.353 196.633 450.754 165.553 415.163 165.553L373.65 165.553L373.65 137.878L415.135 137.878C468.051 137.878 512 179.336 512 232.197C511.999 281.128 474.637 321.0080000000001 426.315 326.183zM334.848 216.921L321.121 208.59L322.366 222.705L300.668 224.836L297.928 194.558L260.012 171.588L260.012 217.53L283.62 235.159L271 253.84L260.012 245.62L260.012 262.28L238.149 262.28L238.149 245.62L227.19 253.84L214.542 235.132L238.177 217.503L238.177 171.534L200.262 194.504L197.522 224.782L175.824 222.651L177.069 208.537L163.342 216.867L152.355 197.078L166.11 188.748L153.85 182.853L162.955 162.014L189.302 174.689L227.218 151.691L189.302 128.692L162.955 141.368L153.85 120.528L166.11 114.633L152.355 106.303L163.314 86.432L177.041 94.762L175.796 80.648L197.493 78.516L200.234 108.794L238.149 131.792L238.149 85.824L214.514 68.194L227.162 49.486L238.121 57.678L238.121 41.045L259.984 41.045L259.984 57.678L270.944 49.486L283.592 68.194L259.984 85.824L259.984 131.792L297.9 108.794L300.64 78.516L322.337 80.648L321.093 94.762L334.82 86.432L345.724 106.303L331.997 114.606L344.257 120.501L335.152 141.341L308.777 128.665L270.862 151.664L308.777 174.662L335.152 161.987L344.257 182.826L331.969 188.722L345.724 197.052L334.848 216.921z" />
<glyph glyph-name="50d"
unicode="&#xEA12;"
horiz-adv-x="512" d=" M0 158.202H477.483V123.685H0V158.202z M0 388.315H477.483V353.798H0V388.315z M0 273.2580000000001H477.483V238.741H0V273.258z M34.517 330.7870000000001H126.562V296.27H34.517V330.7870000000001z M161.079 330.7870000000001H512V296.27H161.079V330.7870000000001z M419.955 215.73H512V181.213H419.955V215.73z M34.517 215.73H385.438V181.213H34.517V215.73z" />
<glyph glyph-name="50n"
unicode="&#xEA13;"
horiz-adv-x="512" d=" M0 158.202H477.483V123.685H0V158.202z M0 388.315H477.483V353.798H0V388.315z M0 273.2580000000001H477.483V238.741H0V273.258z M34.517 330.7870000000001H126.562V296.27H34.517V330.7870000000001z M161.079 330.7870000000001H512V296.27H161.079V330.7870000000001z M419.955 215.73H512V181.213H419.955V215.73z M34.517 215.73H385.438V181.213H34.517V215.73z" />
</font>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -24,7 +24,9 @@ require('./services/config-validator'); // Include and kicks off the config file
const statusCheck = require('./services/status-check'); // Used by the status check feature, uses GET
const saveConfig = require('./services/save-config'); // Saves users new conf.yml to file-system
const rebuild = require('./services/rebuild-app'); // A script to programmatically trigger a build
const sslServer = require('./services/ssl-server');
const systemInfo = require('./services/system-info'); // Basic system info, for resource widget
const sslServer = require('./services/ssl-server'); // TLS-enabled web server
const corsProxy = require('./services/cors-proxy'); // Enables API requests to CORS-blocked services
/* Helper functions, and default config */
const printMessage = require('./services/print-message'); // Function to print welcome msg on start
@ -91,6 +93,24 @@ const app = express()
}).catch((response) => {
res.end(JSON.stringify(response));
});
})
// GET endpoint to return system info, for widget
.use(ENDPOINTS.systemInfo, (req, res) => {
try {
const results = systemInfo();
systemInfo.success = true;
res.end(JSON.stringify(results));
} catch (e) {
res.end(JSON.stringify({ success: false, message: e }));
}
})
// GET for accessing non-CORS API services
.use(ENDPOINTS.corsProxy, (req, res) => {
try {
corsProxy(req, res);
} catch (e) {
res.end(JSON.stringify({ success: false, message: e }));
}
});
/* Create HTTP server from app on port, and print welcome message */

47
services/cors-proxy.js Normal file
View File

@ -0,0 +1,47 @@
/**
* A simple CORS proxy, for accessing API services which aren't CORS-enabled.
* Receives requests from frontend, applies correct access control headers,
* makes request to endpoint, then responds to the frontend with the response
*/
const axios = require('axios');
module.exports = (req, res) => {
// Apply allow-all response headers
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, PUT, PATCH, POST, DELETE');
if (req.header('access-control-request-headers')) {
res.header('Access-Control-Allow-Headers', req.header('access-control-request-headers'));
}
// Pre-flight
if (req.method === 'OPTIONS') {
res.send();
return;
}
// Get desired URL, from Target-URL header
const targetURL = req.header('Target-URL');
if (!targetURL) {
res.send(500, { error: 'There is no Target-Endpoint header in the request' });
return;
}
// Apply any custom headers, if needed
const headers = req.header('CustomHeaders') ? JSON.parse(req.header('CustomHeaders')) : {};
// Prepare the request
const requestConfig = {
method: req.method,
url: targetURL + req.url,
json: req.body,
headers,
};
// Make the request, and respond with result
axios.request(requestConfig)
.then((response) => {
res.send(200, response.data);
}).catch((error) => {
res.send(500, { error });
});
};

24
services/system-info.js Normal file
View File

@ -0,0 +1,24 @@
/**
* Gets basic system info, for the resource usage widget
*/
const os = require('os');
module.exports = () => {
const meta = {
timestamp: new Date(),
uptime: os.uptime(),
hostname: os.hostname(),
username: os.userInfo().username,
system: `${os.version()} (${os.platform()})`,
};
const memory = {
total: `${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB`,
freePercent: (os.freemem() / os.totalmem()).toFixed(2),
};
const loadAv = os.loadavg();
const load = { one: loadAv[0], five: loadAv[1], fifteen: loadAv[2] };
return { meta, memory, load };
};

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="sync" class="svg-inline--fa fa-sync fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M500 8h-27.711c-6.739 0-12.157 5.548-11.997 12.286l2.347 98.575C418.212 52.043 342.256 8 256 8 134.813 8 33.933 94.924 12.296 209.824 10.908 217.193 16.604 224 24.103 224h28.576c5.674 0 10.542-3.982 11.737-9.529C83.441 126.128 161.917 60 256 60c79.545 0 147.942 47.282 178.676 115.302l-126.39-3.009c-6.737-.16-12.286 5.257-12.286 11.997V212c0 6.627 5.373 12 12 12h192c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12zm-12.103 280h-28.576c-5.674 0-10.542 3.982-11.737 9.529C428.559 385.872 350.083 452 256 452c-79.546 0-147.942-47.282-178.676-115.302l126.39 3.009c6.737.16 12.286-5.257 12.286-11.997V300c0-6.627-5.373-12-12-12H12c-6.627 0-12 5.373-12 12v192c0 6.627 5.373 12 12 12h27.711c6.739 0 12.157-5.548 11.997-12.286l-2.347-98.575C93.788 459.957 169.744 504 256 504c121.187 0 222.067-86.924 243.704-201.824 1.388-7.369-4.308-14.176-11.807-14.176z"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,248 +1,275 @@
{
"home": {
"no-results": "No Search Results",
"no-data": "No Data Configured"
},
"search": {
"search-label": "Search",
"search-placeholder": "Start typing to filter",
"clear-search-tooltip": "Clear Search",
"enter-to-search-web": "Press enter to search the web"
},
"login": {
"title": "Dashy",
"username-label": "Username",
"password-label": "Password",
"login-button": "Login",
"remember-me-label": "Remember me for",
"remember-me-never": "Never",
"remember-me-hour": "4 Hours",
"remember-me-day": "1 Day",
"remember-me-week": "1 Week",
"remember-me-long-time": "A long time",
"error-missing-username": "Missing Username",
"error-missing-password": "Missing Password",
"error-incorrect-username": "User not found",
"error-incorrect-password": "Incorrect Password",
"success-message": "Logging in...",
"logout-message": "Logged Out",
"already-logged-in-title": "Already Logged In",
"already-logged-in-text": "You're logged in as",
"proceed-to-dashboard": "Proceed to Dashboard",
"log-out-button": "Logout",
"proceed-guest-button": "Proceed as Guest"
},
"config": {
"main-tab": "Main Menu",
"view-config-tab": "View Config",
"edit-config-tab": "Edit Config",
"custom-css-tab": "Custom Styles",
"heading": "Configuration Options",
"download-config-button": "View / Export Config",
"edit-config-button": "Edit Config",
"edit-css-button": "Edit Custom CSS",
"cloud-sync-button": "Enable Cloud Sync",
"edit-cloud-sync-button": "Edit Cloud Sync",
"rebuild-app-button": "Rebuild Application",
"change-language-button": "Change App Language",
"reset-settings-button": "Reset Local Settings",
"app-info-button": "App Info",
"backup-note": "It is recommend to make a backup of your configuration before making changes.",
"reset-config-msg-l1": "This will remove all user settings from local storage, but won't effect your 'conf.yml' file.",
"reset-config-msg-l2": "You should first backup any changes you've made locally, if you want to use them in the future.",
"reset-config-msg-l3": "Are you sure you want to proceed?",
"data-cleared-msg": "Data cleared successfully",
"actions-label": "Actions",
"copy-config-label": "Copy Config",
"data-copied-msg": "Config has been copied to clipboard",
"reset-config-label": "Reset Config",
"css-save-btn": "Save Changes",
"css-note-label": "Note",
"css-note-l1": "You will need to refresh the page for your changes to take effect.",
"css-note-l2": "Styles overrides are only stored locally, so it is recommended to make a copy of your CSS.",
"css-note-l3": "To remove all custom styles, delete the contents and hit Save Changes"
},
"alternate-views": {
"alternate-view-heading": "Switch View",
"default": "Default",
"workspace": "Workspace",
"minimal": "Minimal"
},
"settings": {
"theme-label": "Theme",
"layout-label": "Layout",
"layout-auto": "Auto",
"layout-horizontal": "Horizontal",
"layout-vertical": "Vertical",
"item-size-label": "Item Size",
"item-size-small": "Small",
"item-size-medium": "Medium",
"item-size-large": "Large",
"config-launcher-label": "Config",
"config-launcher-tooltip": "Update Configuration",
"sign-out-tooltip": "Sign Out",
"sign-in-tooltip": "Log In",
"sign-in-welcome": "Hello {username}!"
},
"updates": {
"app-version-note": "Dashy version",
"up-to-date": "Up-to-Date",
"out-of-date": "Update Available",
"unsupported-version-l1": "You are using an unsupported version of Dashy",
"unsupported-version-l2": "For the best experience, and recent security patches, please update to"
},
"language-switcher": {
"title": "Change Application Language",
"dropdown-label": "Select a Language",
"save-button": "Save",
"success-msg": "Language Updated to"
},
"theme-maker": {
"title": "Theme Configurator",
"export-button": "Export Custom Variables",
"reset-button": "Reset Styles for",
"show-all-button": "Show All Variables",
"change-fonts-button": "Change Fonts",
"save-button": "Save",
"cancel-button": "Cancel",
"saved-toast": "{theme} Updated Successfully",
"copied-toast": "Theme data for {theme} copied to clipboard",
"reset-toast": "Custom Colors for {theme} Removed"
},
"config-editor": {
"save-location-label": "Save Location",
"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",
"success-msg-disk": "Config file written to disk successfully",
"success-msg-local": "Local changes saved successfully",
"success-note-l1": "The app should rebuild automatically.",
"success-note-l2": "This may take up to a minute.",
"success-note-l3": "You will need to refresh the page for changes to take effect.",
"error-msg-save-mode": "Please select a Save Mode: Local or File",
"error-msg-cannot-save": "An error occurred saving config",
"error-msg-bad-json": "Error in JSON, possibly malformed",
"warning-msg-validation": "Validation Warning",
"not-admin-note": "You cannot write changed to disk, because you are not logged in as an admin"
},
"app-rebuild": {
"title": "Rebuild Application",
"rebuild-note-l1": "A rebuild is required for changes written to the conf.yml file to take effect.",
"rebuild-note-l2": "This should happen automatically, but if it hasn't, you can manually trigger it here.",
"rebuild-note-l3": "This is not required for modifications stored locally.",
"rebuild-button": "Start Build",
"rebuilding-status-1": "Building...",
"rebuilding-status-2": "This may take a few minutes",
"error-permission": "You don't have permission to trigger this action",
"success-msg": "Build completed successfully",
"fail-msg": "Build operation failed",
"reload-note": "A page reload is now required for changes to take effect",
"reload-button": "Reload Page"
},
"cloud-sync": {
"title": "Cloud Backup & Restore",
"intro-l1": "Cloud backup and restore is an optional feature, that enables you to upload your config to the internet, and then restore it on any other device or instance of Dashy.",
"intro-l2": "All data is fully end-to-end encrypted with AES, using your password as the key.",
"intro-l3": "For more info, please see the",
"backup-title-setup": "Make a Backup",
"backup-title-update": "Update Backup",
"password-label-setup": "Choose a Password",
"password-label-update": "Enter your Password",
"backup-button-setup": "Backup",
"backup-button-update": "Update Backup",
"backup-id-label": "Your Backup ID",
"backup-id-note": "This is used to restore from backups later. So keep it, along with your password somewhere safe.",
"restore-title": "Restore a Backup",
"restore-id-label": "Restore ID",
"restore-password-label": "Password",
"restore-button": "Restore",
"backup-missing-password": "Missing Password",
"backup-error-unknown": "Unable to process request",
"backup-error-password": "Incorrect password. Please enter your current password.",
"backup-success-msg": "Completed Successfully",
"restore-success-msg": "Config Restored Successfully"
},
"menu": {
"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",
"cloud-backup-btn": "Backup to Cloud",
"cloud-backup-tooltip": "Save encrypted backup of configuration to cloud",
"edit-raw-config-btn": "Edit Raw Config",
"edit-raw-config-tooltip": "View and modify raw config via JSON editor",
"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"
}
}
}
{
"home": {
"no-results": "No Search Results",
"no-data": "No Data Configured",
"no-items-section": "No Items to Show Yet"
},
"search": {
"search-label": "Search",
"search-placeholder": "Start typing to filter",
"clear-search-tooltip": "Clear Search",
"enter-to-search-web": "Press enter to search the web"
},
"login": {
"title": "Dashy",
"username-label": "Username",
"password-label": "Password",
"login-button": "Login",
"remember-me-label": "Remember me for",
"remember-me-never": "Never",
"remember-me-hour": "4 Hours",
"remember-me-day": "1 Day",
"remember-me-week": "1 Week",
"remember-me-long-time": "A long time",
"error-missing-username": "Missing Username",
"error-missing-password": "Missing Password",
"error-incorrect-username": "User not found",
"error-incorrect-password": "Incorrect Password",
"success-message": "Logging in...",
"logout-message": "Logged Out",
"already-logged-in-title": "Already Logged In",
"already-logged-in-text": "You're logged in as",
"proceed-to-dashboard": "Proceed to Dashboard",
"log-out-button": "Logout",
"proceed-guest-button": "Proceed as Guest"
},
"config": {
"main-tab": "Main Menu",
"view-config-tab": "View Config",
"edit-config-tab": "Edit Config",
"custom-css-tab": "Custom Styles",
"heading": "Configuration Options",
"download-config-button": "View / Export Config",
"edit-config-button": "Edit Config",
"edit-css-button": "Edit Custom CSS",
"cloud-sync-button": "Enable Cloud Sync",
"edit-cloud-sync-button": "Edit Cloud Sync",
"rebuild-app-button": "Rebuild Application",
"change-language-button": "Change App Language",
"reset-settings-button": "Reset Local Settings",
"app-info-button": "App Info",
"backup-note": "It is recommend to make a backup of your configuration before making changes.",
"reset-config-msg-l1": "This will remove all user settings from local storage, but won't effect your 'conf.yml' file.",
"reset-config-msg-l2": "You should first backup any changes you've made locally, if you want to use them in the future.",
"reset-config-msg-l3": "Are you sure you want to proceed?",
"data-cleared-msg": "Data cleared successfully",
"actions-label": "Actions",
"copy-config-label": "Copy Config",
"data-copied-msg": "Config has been copied to clipboard",
"reset-config-label": "Reset Config",
"css-save-btn": "Save Changes",
"css-note-label": "Note",
"css-note-l1": "You will need to refresh the page for your changes to take effect.",
"css-note-l2": "Styles overrides are only stored locally, so it is recommended to make a copy of your CSS.",
"css-note-l3": "To remove all custom styles, delete the contents and hit Save Changes"
},
"alternate-views": {
"alternate-view-heading": "Switch View",
"default": "Default",
"workspace": "Workspace",
"minimal": "Minimal"
},
"settings": {
"theme-label": "Theme",
"layout-label": "Layout",
"layout-auto": "Auto",
"layout-horizontal": "Horizontal",
"layout-vertical": "Vertical",
"item-size-label": "Item Size",
"item-size-small": "Small",
"item-size-medium": "Medium",
"item-size-large": "Large",
"config-launcher-label": "Config",
"config-launcher-tooltip": "Update Configuration",
"sign-out-tooltip": "Sign Out",
"sign-in-tooltip": "Log In",
"sign-in-welcome": "Hello {username}!"
},
"updates": {
"app-version-note": "Dashy version",
"up-to-date": "Up-to-Date",
"out-of-date": "Update Available",
"unsupported-version-l1": "You are using an unsupported version of Dashy",
"unsupported-version-l2": "For the best experience, and recent security patches, please update to"
},
"language-switcher": {
"title": "Change Application Language",
"dropdown-label": "Select a Language",
"save-button": "Save",
"success-msg": "Language Updated to"
},
"theme-maker": {
"title": "Theme Configurator",
"export-button": "Export Custom Variables",
"reset-button": "Reset Styles for",
"show-all-button": "Show All Variables",
"change-fonts-button": "Change Fonts",
"save-button": "Save",
"cancel-button": "Cancel",
"saved-toast": "{theme} Updated Successfully",
"copied-toast": "Theme data for {theme} copied to clipboard",
"reset-toast": "Custom Colors for {theme} Removed"
},
"config-editor": {
"save-location-label": "Save Location",
"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",
"success-msg-disk": "Config file written to disk successfully",
"success-msg-local": "Local changes saved successfully",
"success-note-l1": "The app should rebuild automatically.",
"success-note-l2": "This may take up to a minute.",
"success-note-l3": "You will need to refresh the page for changes to take effect.",
"error-msg-save-mode": "Please select a Save Mode: Local or File",
"error-msg-cannot-save": "An error occurred saving config",
"error-msg-bad-json": "Error in JSON, possibly malformed",
"warning-msg-validation": "Validation Warning",
"not-admin-note": "You cannot write changed to disk, because you are not logged in as an admin"
},
"app-rebuild": {
"title": "Rebuild Application",
"rebuild-note-l1": "A rebuild is required for changes written to the conf.yml file to take effect.",
"rebuild-note-l2": "This should happen automatically, but if it hasn't, you can manually trigger it here.",
"rebuild-note-l3": "This is not required for modifications stored locally.",
"rebuild-button": "Start Build",
"rebuilding-status-1": "Building...",
"rebuilding-status-2": "This may take a few minutes",
"error-permission": "You don't have permission to trigger this action",
"success-msg": "Build completed successfully",
"fail-msg": "Build operation failed",
"reload-note": "A page reload is now required for changes to take effect",
"reload-button": "Reload Page"
},
"cloud-sync": {
"title": "Cloud Backup & Restore",
"intro-l1": "Cloud backup and restore is an optional feature, that enables you to upload your config to the internet, and then restore it on any other device or instance of Dashy.",
"intro-l2": "All data is fully end-to-end encrypted with AES, using your password as the key.",
"intro-l3": "For more info, please see the",
"backup-title-setup": "Make a Backup",
"backup-title-update": "Update Backup",
"password-label-setup": "Choose a Password",
"password-label-update": "Enter your Password",
"backup-button-setup": "Backup",
"backup-button-update": "Update Backup",
"backup-id-label": "Your Backup ID",
"backup-id-note": "This is used to restore from backups later. So keep it, along with your password somewhere safe.",
"restore-title": "Restore a Backup",
"restore-id-label": "Restore ID",
"restore-password-label": "Password",
"restore-button": "Restore",
"backup-missing-password": "Missing Password",
"backup-error-unknown": "Unable to process request",
"backup-error-password": "Incorrect password. Please enter your current password.",
"backup-success-msg": "Completed Successfully",
"restore-success-msg": "Config Restored Successfully"
},
"menu": {
"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",
"cloud-backup-btn": "Backup to Cloud",
"cloud-backup-tooltip": "Save encrypted backup of configuration to cloud",
"edit-raw-config-btn": "Edit Raw Config",
"edit-raw-config-tooltip": "View and modify raw config via JSON editor",
"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"
}
},
"widgets": {
"general": {
"loading": "Loading...",
"show-more": "Expand Details",
"show-less": "Show Less",
"open-link": "Continue Reading"
},
"pi-hole": {
"status-heading": "Status"
},
"stat-ping": {
"up": "Online",
"down": "Offline"
},
"system-info": {
"uptime": "Uptime"
},
"flight-data": {
"arrivals": "Arrivals",
"departures": "Departures"
},
"tfl-status": {
"good-service-all": "Good Service on all Lines",
"good-service-rest": "Good Service on all other Lines"
}
}
}

View File

@ -7,16 +7,27 @@
<pre v-if="errorLog" class="logs"><code>{{ errorLog }}</code></pre>
<p v-else>No recent errors detected :)</p>
<hr />
<!-- Help Links -->
<!-- Getting Help -->
<h3>Help & Support</h3>
For getting support with running or configuring Dashy, see the <a href="https://github.com/Lissy93/dashy/discussions">Discussions</a>
<!-- Please help out :) -->
<h3>Supporting Dashy</h3>
For ways that you can get involved, check out the <a href="https://github.com/Lissy93/dashy/blob/master/docs/contributing.md">Contributing</a> page.
<!-- Bug Reports -->
<h3>Report a Bug</h3>
If you think you've found a bug, then please <a href="https://github.com/Lissy93/dashy/issues/new/choose">raise an Issue</a>.
<!-- Source and Docs Links -->
<h3>More Info</h3>
Source: <a href="https://github.com/lissy93/dashy">github.com/lissy93/dashy</a><br>
Documentation: <a href="https://dashy.to/docs">dashy.to/docs</a>
<!-- Privacy & Security -->
<h3>Privacy & Security</h3>
For a break-down of how your data is managed by Dashy, see
the <a href="https://github.com/Lissy93/dashy/blob/master/docs/privacy.md">Privacy Policy</a>.<br>
For advise in securing your dashboard, you can reference the
<a href="https://github.com/Lissy93/dashy/blob/master/docs/management.md">Management Docs</a>.<br>
If you've found a potential security issue, report it following our
<a href="https://github.com/Lissy93/dashy/blob/master/.github/SECURITY.md">Security Policy</a>
<!-- License -->
<h3>License</h3>
Licensed under MIT X11. Copyright <a href="https://aliciasykes.com">Alicia Sykes</a> © 2021.<br>

View File

@ -77,7 +77,7 @@ export default {
currentSection() {
let sectionName = '';
this.sections.forEach((section) => {
section.items.forEach((item) => {
(section.items || []).forEach((item) => {
if (item.id === this.itemId) sectionName = section.name;
});
});

View File

@ -9,7 +9,7 @@
v-tooltip="getTooltipOptions()"
rel="noopener noreferrer" tabindex="0"
:id="`link-${id}`"
:style="`--open-icon: ${getUnicodeOpeningIcon()}; ${customStyles}`"
:style="`--open-icon: ${getUnicodeOpeningIcon()}; color: ${color}; ${customStyles}`"
>
<!-- Item Text -->
<div :class="`tile-title ${!icon? 'bounce no-icon': ''}`" :id="`tile-${id}`" >
@ -452,7 +452,7 @@ export default {
height: 2rem;
padding-top: 4px;
max-width: 14rem;
div img, div svg.missing-image {
div img {
width: 2rem;
}
.tile-title {
@ -473,7 +473,7 @@ export default {
flex-direction: column;
align-items: center;
height: auto;
div img, div svg.missing-image {
div img {
width: 2.5rem;
margin-bottom: 0.25rem;
}

View File

@ -1,13 +1,14 @@
<template>
<div :class="`item-icon wrapper-${size}`">
<div v-if="icon" :class="`item-icon wrapper-${size}`">
<!-- Font-Awesome Icon -->
<i v-if="iconType === 'font-awesome'" :class="`${icon} ${size}`" ></i>
<!-- Emoji Icon -->
<i v-else-if="iconType === 'emoji'" :class="`emoji-icon ${size}`" >{{getEmoji(iconPath)}}</i>
<!-- Material Design Icon -->
<span v-else-if="iconType === 'mdi'" :class=" `mdi ${icon} ${size}`"></span>
<span v-else-if="iconType === 'mdi'" :class=" `mdi ${icon} ${size}`"></span>
<!-- Simple-Icons -->
<svg v-else-if="iconType === 'si'" :class="`simple-icons ${size}`" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<svg v-else-if="iconType === 'si' && !broken" :class="`simple-icons ${size}`"
role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path :d="getSimpleIcon(icon)" />
</svg>
<!-- Standard image asset icon -->
@ -15,7 +16,7 @@
:class="`tile-icon ${size} ${broken ? 'broken' : ''}`"
/>
<!-- Icon could not load/ broken url -->
<BrokenImage v-if="broken" class="missing-image" />
<BrokenImage v-if="broken" :class="`missing-image ${size}`" />
</div>
</template>
@ -25,8 +26,8 @@ import BrokenImage from '@/assets/interface-icons/broken-icon.svg';
import ErrorHandler from '@/utils/ErrorHandler';
import EmojiUnicodeRegex from '@/utils/EmojiUnicodeRegex';
import emojiLookup from '@/utils/emojis.json';
import { faviconApi as defaultFaviconApi, faviconApiEndpoints, iconCdns } from '@/utils/defaults';
import { asciiHash } from '@/utils/MiscHelpers';
import { faviconApi as defaultFaviconApi, faviconApiEndpoints, iconCdns } from '@/utils/defaults';
export default {
name: 'Icon',
@ -36,7 +37,7 @@ export default {
size: String, // Either small, medium or large
},
components: {
BrokenImage,
BrokenImage, // Used when the desired image returns a 404
},
computed: {
/* Get appConfig from store */
@ -60,6 +61,39 @@ export default {
};
},
methods: {
/* Determine icon type, e.g. local or remote asset, SVG, favicon, font-awesome, etc */
determineImageType(img) {
let imgType = '';
if (!img) imgType = 'none';
else if (this.isUrl(img)) imgType = 'url';
else if (this.isImage(img)) imgType = 'img';
else if (img.includes('fa-')) imgType = 'font-awesome';
else if (img.includes('mdi-')) imgType = 'mdi';
else if (img.includes('si-')) imgType = 'si';
else if (img.includes('hl-')) imgType = 'home-lab-icons';
else if (img.includes('favicon-')) imgType = 'custom-favicon';
else if (img === 'favicon') imgType = 'favicon';
else if (img === 'generative') imgType = 'generative';
else if (this.isEmoji(img).isEmoji) imgType = 'emoji';
else imgType = 'none';
return imgType;
},
/* Return the path to icon asset, depending on icon type */
getIconPath(img, url) {
switch (this.determineImageType(img)) {
case 'url': return img;
case 'img': return this.getLocalImagePath(img);
case 'favicon': return this.getFavicon(url);
case 'custom-favicon': return this.getCustomFavicon(url, img);
case 'generative': return this.getGenerativeIcon(url);
case 'mdi': return img; // Material design icons
case 'simple-icons': return this.getSimpleIcon(img);
case 'home-lab-icons': return this.getHomeLabIcon(img);
case 'svg': return img; // Local SVG icon
case 'emoji': return img; // Emoji/ unicode
default: return '';
}
},
/* Check if a string is in a URL format. Used to identify tile icon source */
isUrl(str) {
const pattern = new RegExp(/(http|https):\/\/(\w+:{0,1}\w*)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%!\-/]))?/);
@ -73,7 +107,7 @@ export default {
if (splitPath.length >= 1) return validImgExtensions.includes(splitPath[1]);
return false;
},
/* Determins if a given string is an emoji, and if so what type it is */
/* Determines if a given string is an emoji, and if so what type it is */
isEmoji(img) {
if (EmojiUnicodeRegex.test(img) && img.match(/./gu).length) { // Is a unicode emoji
return { isEmoji: true, emojiType: 'glyph' };
@ -84,15 +118,27 @@ export default {
}
return { isEmoji: false, emojiType: '' };
},
/* Formats and gets emoji from unicode or shortcode */
/* Returns the corresponding emoji for a shortcode, or shows error if not found */
getShortCodeEmoji(emojiCode) {
if (emojiLookup[emojiCode]) {
return emojiLookup[emojiCode];
} else {
this.imageNotFound(`No emoji found with name '${emojiCode}'`);
return null;
}
},
/* Formats and gets emoji from either unicode, shortcode or glyph */
getEmoji(emojiCode) {
const { emojiType } = this.isEmoji(emojiCode);
if (emojiType === 'shortcode') {
if (emojiLookup[emojiCode]) return emojiLookup[emojiCode];
} else if (emojiType === 'unicode') {
if (emojiType === 'shortcode') { // Short code emoji
return this.getShortCodeEmoji(emojiCode);
} else if (emojiType === 'unicode') { // Unicode emoji
return String.fromCodePoint(parseInt(emojiCode.substr(2), 16));
} else if (emojiType === 'glyph') { // Emoji is a glyph
return emojiCode;
}
return emojiCode; // Emoji is a glyph already, just return
this.imageNotFound(`Unrecognized emoji: '${emojiCode}'`);
return null;
},
/* Get favicon URL, for items which use the favicon as their icon */
getFavicon(fullUrl, specificApi) {
@ -109,16 +155,17 @@ export default {
},
/* Get the URL for a favicon, but using the non-default favicon API */
getCustomFavicon(fullUrl, faviconIdentifier) {
let errorMsg = '';
const faviconApi = faviconIdentifier.split('favicon-')[1];
if (!faviconApi) {
ErrorHandler('Favicon API not specified');
} else if (!Object.keys(faviconApiEndpoints).includes(faviconApi)) {
ErrorHandler(`The specified favicon API, '${faviconApi}' cannot be found.`);
errorMsg = 'Favicon API not specified';
} else if (!Object.keys(faviconApiEndpoints).includes(faviconApi) && faviconApi !== 'local') {
errorMsg = `The specified favicon API, '${faviconApi}' cannot be found.`;
} else {
return this.getFavicon(fullUrl, faviconApi);
}
// Error encountered, favicon service not found
this.broken = true;
this.imageNotFound(errorMsg);
return undefined;
},
/* If using favicon for icon, and if service is running locally (determined by local IP) */
@ -140,69 +187,53 @@ export default {
getSimpleIcon(img) {
const imageName = img.replace('si-', '');
const icon = simpleIcons.Get(imageName);
if (!icon) {
this.imageNotFound(`No icon was found for '${imageName}' in Simple Icons`);
return null;
}
return icon.path;
},
/* Gets home-lab icon from GitHub */
getHomeLabIcon(img) {
getHomeLabIcon(img, cdn) {
const imageName = img.replace('hl-', '').toLocaleLowerCase();
return iconCdns.homeLabIcons.replace('{icon}', imageName);
},
/* Checks if the icon is from a local image, remote URL, SVG or font-awesome */
getIconPath(img, url) {
switch (this.determineImageType(img)) {
case 'url': return img;
case 'img': return this.getLocalImagePath(img);
case 'favicon': return this.getFavicon(url);
case 'custom-favicon': return this.getCustomFavicon(url, img);
case 'generative': return this.getGenerativeIcon(url);
case 'mdi': return img; // Material design icons
case 'simple-icons': return this.getSimpleIcon(img);
case 'home-lab-icons': return this.getHomeLabIcon(img);
case 'svg': return img; // Local SVG icon
case 'emoji': return img; // Emoji/ unicode
default: return '';
}
},
/* Checks if the icon is from a local image, remote URL, SVG or font-awesome */
determineImageType(img) {
let imgType = '';
if (!img) imgType = 'none';
else if (this.isUrl(img)) imgType = 'url';
else if (this.isImage(img)) imgType = 'img';
else if (img.includes('fa-')) imgType = 'font-awesome';
else if (img.includes('mdi-')) imgType = 'mdi';
else if (img.includes('si-')) imgType = 'si';
else if (img.includes('hl-')) imgType = 'home-lab-icons';
else if (img.includes('favicon-')) imgType = 'custom-favicon';
else if (img === 'favicon') imgType = 'favicon';
else if (img === 'generative') imgType = 'generative';
else if (this.isEmoji(img).isEmoji) imgType = 'emoji';
else imgType = 'none';
return imgType;
return (cdn || iconCdns.homeLabIcons).replace('{icon}', imageName);
},
/* For a given URL, return the hostname only. Used for favicon and generative icons */
getHostName(url) {
try { return new URL(url).hostname; } catch (e) { return url; }
try {
return new URL(url).hostname.split('.').slice(-2).join('.');
} catch (e) {
ErrorHandler('Unable to format URL');
return url;
}
},
/* Called when the path to the image cannot be resolved */
imageNotFound() {
imageNotFound(errorMsg) {
let outputMessage = '';
if (errorMsg && typeof errorMsg === 'string') {
outputMessage = errorMsg;
} else if (!this.icon) {
outputMessage = 'Icon not specified';
} else {
outputMessage = `The path to '${this.icon}' could not be resolved`;
}
ErrorHandler(outputMessage);
this.broken = true;
ErrorHandler(`The path to '${this.icon}' could not be resolved`);
},
/* Called when initial icon has resulted in 404. Attempts to find new icon */
getFallbackIcon() {
if (this.attemptedFallback) return undefined; // If this is second attempt, then give up
const { iconType } = this;
const markAsSttempted = () => {
this.broken = false;
this.attemptedFallback = true;
};
const markAsAttempted = () => { this.broken = false; this.attemptedFallback = true; };
if (iconType.includes('favicon')) { // Specify fallback for favicon-based icons
markAsSttempted();
markAsAttempted();
return this.getFavicon(this.url, 'local');
} else if (iconType === 'generative') {
markAsSttempted();
markAsAttempted();
return this.getGenerativeIcon(this.url, iconCdns.generativeFallback);
} else if (iconType === 'home-lab-icons') {
markAsAttempted();
return this.getHomeLabIcon(this.icon, iconCdns.homeLabIconsFallback);
}
return undefined;
},
@ -290,7 +321,13 @@ export default {
}
/* Icon Not Found */
.missing-image {
width: 3.5rem;
width: 2rem;
&.small {
width: 1.5rem !important;
}
&.large {
width: 2.5rem;
}
path {
fill: currentColor;
}

View File

@ -12,11 +12,11 @@
@openContextMenu="openContextMenu"
>
<!-- If no items, show message -->
<div v-if="(!items || items.length < 1) && !isEditMode" class="no-items">
No Items to Show Yet
<div v-if="sectionType === 'empty'" class="no-items">
{{ $t('home.no-items-section') }}
</div>
<!-- Item Container -->
<div v-else
<div v-else-if="sectionType === 'item'"
:class="`there-are-items ${isGridLayout? 'item-group-grid': ''} inner-size-${itemSize}`"
:style="gridStyle" :id="`section-${groupId}`"
> <!-- Show for each item -->
@ -33,12 +33,12 @@
:backgroundColor="item.backgroundColor"
:statusCheckUrl="item.statusCheckUrl"
:statusCheckHeaders="item.statusCheckHeaders"
:itemSize="newItemSize"
:itemSize="itemSize"
:hotkey="item.hotkey"
:provider="item.provider"
:parentSectionTitle="title"
:enableStatusCheck="shouldEnableStatusCheck(item.statusCheck)"
:statusCheckInterval="getStatusCheckInterval()"
:enableStatusCheck="item.statusCheck !== undefined ? item.statusCheck : enableStatusCheck"
:statusCheckInterval="statusCheckInterval"
:statusCheckAllowInsecure="item.statusCheckAllowInsecure"
@itemClicked="$emit('itemClicked')"
@triggerModal="triggerModal"
@ -54,9 +54,19 @@
description="Click to add new item"
key="add-new"
class="add-new-item"
:itemSize="newItemSize"
:itemSize="itemSize"
/>
</div>
<div
v-else-if="sectionType === 'widget'"
:class="`widget-list ${isWide? 'wide' : ''}`">
<WidgetBase
v-for="(widget, widgetIndx) in widgets"
:key="widgetIndx"
:widget="widget"
:index="index"
@navigateToSection="navigateToSection"
/>
<div ref="modalContainer"></div>
</div>
<!-- Modal for opening in modal view -->
<IframeModal
@ -88,6 +98,7 @@
<script>
import router from '@/router';
import Item from '@/components/LinkItems/Item.vue';
import WidgetBase from '@/components/Widgets/WidgetBase';
import Collapsable from '@/components/LinkItems/Collapsable.vue';
import IframeModal from '@/components/LinkItems/IframeModal.vue';
import EditSection from '@/components/InteractiveEditor/EditSection.vue';
@ -108,13 +119,15 @@ export default {
icon: String,
displayData: Object,
items: Array,
itemSize: String,
widgets: Array,
index: Number,
isWide: Boolean,
},
components: {
Collapsable,
ContextMenu,
Item,
WidgetBase,
IframeModal,
EditSection,
},
@ -132,9 +145,21 @@ export default {
appConfig() {
return this.$store.getters.appConfig;
},
isEditMode() {
return this.$store.state.editMode;
},
itemSize() {
return this.$store.getters.iconSize;
},
sortOrder() {
return this.displayData.sortBy || defaultSortOrder;
},
/* A section can contain either items or widgets */
sectionType() {
if (this.widgets && this.widgets.length > 0) return 'widget';
if (this.items && this.items.length > 0) return 'item';
return 'empty';
},
/* If the sortBy attribute is specified, then return sorted data */
sortedItems() {
let { items } = this;
@ -146,7 +171,7 @@ export default {
} else if (this.sortOrder === 'most-used') {
items = this.sortByMostUsed(items);
} else if (this.sortOrder === 'last-used') {
items = this.sortBLastUsed(items);
items = this.sortByLastUsed(items);
} else if (this.sortOrder === 'random') {
items = this.sortRandomly(items);
} else if (this.sortOrder && this.sortOrder !== 'default') {
@ -154,9 +179,6 @@ export default {
}
return items;
},
newItemSize() {
return this.displayData.itemSize || this.itemSize;
},
isGridLayout() {
return this.displayData.sectionLayout === 'grid'
|| !!(this.displayData.itemCountX || this.displayData.itemCountY);
@ -171,8 +193,17 @@ export default {
}
return styles;
},
isEditMode() {
return this.$store.state.editMode;
/* 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: {
@ -180,19 +211,6 @@ export default {
triggerModal(url) {
this.$refs[`iframeModal-${this.groupId}`].show(url);
},
/* Determines if user has enabled online status checks */
shouldEnableStatusCheck(itemPreference) {
const globalPreference = this.appConfig.statusCheck || false;
return itemPreference !== undefined ? itemPreference : globalPreference;
},
/* Determine how often to re-fire status checks */
getStatusCheckInterval() {
let interval = this.appConfig.statusCheckInterval;
if (!interval) return 0;
if (interval > 60) interval = 60;
if (interval < 1) interval = 0;
return interval;
},
/* Sorts items alphabetically using the title attribute */
sortAlphabetically(items) {
return items.sort((a, b) => (a.title.toLowerCase() > b.title.toLowerCase() ? 1 : -1));
@ -205,7 +223,7 @@ export default {
return items;
},
/* Sorts items by most recently used */
sortBLastUsed(items) {
sortByLastUsed(items) {
const usageCount = JSON.parse(localStorage.getItem(localStorageKeys.LAST_USED) || '{}');
const glu = (item) => usageCount[item.id] || 0;
items.reverse().sort((a, b) => (glu(a) < glu(b) ? 1 : -1));
@ -330,4 +348,17 @@ export default {
border-style: dashed;
}
}
.widget-list {
&.wide {
display: flex;
align-items: flex-start;
justify-content: space-around;
.widget-base {
min-width: 10rem;
width: -webkit-fill-available;
}
}
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div :class="`minimal-section-inner ${selected ? 'selected' : ''} ${showAll ? 'show-all': ''}`">
<div class="section-items" v-if="selected || showAll">
<div class="section-items" v-if="items && (selected || showAll)">
<Item
v-for="(item, index) in items"
:id="`${index}_${makeId(item.title)}`"
@ -22,6 +22,15 @@
@triggerModal="triggerModal"
/>
</div>
<div v-if="widgets && (selected || showAll)">
<WidgetBase
v-for="(widget, widgetIndx) in widgets"
:key="widgetIndx"
:widget="widget"
:index="widgetIndx"
@navigateToSection="navigateToSection"
/>
</div>
<IframeModal
:ref="`iframeModal-${groupId}`"
:name="`iframeModal-${groupId}`"
@ -31,7 +40,9 @@
</template>
<script>
import router from '@/router';
import Item from '@/components/LinkItems/Item.vue';
import WidgetBase from '@/components/Widgets/WidgetBase';
import IframeModal from '@/components/LinkItems/IframeModal.vue';
export default {
@ -42,6 +53,7 @@ export default {
icon: String,
displayData: Object,
items: Array,
widgets: Array,
itemSize: String,
modalOpen: Boolean,
index: Number,
@ -55,6 +67,7 @@ export default {
},
components: {
Item,
WidgetBase,
IframeModal,
},
methods: {
@ -80,6 +93,13 @@ export default {
if (interval < 1) interval = 0;
return interval;
},
/* 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();
},
},
};
</script>

View File

@ -12,7 +12,7 @@
<script>
import { shouldBeVisible } from '@/utils/MiscHelpers';
import { shouldBeVisible } from '@/utils/SectionHelpers';
export default {
name: 'Footer',

View File

@ -13,7 +13,7 @@
<script>
import PageTitle from '@/components/PageStrcture/PageTitle.vue';
import Nav from '@/components/PageStrcture/Nav.vue';
import { shouldBeVisible } from '@/utils/MiscHelpers';
import { shouldBeVisible } from '@/utils/SectionHelpers';
export default {
name: 'Header',

View File

@ -0,0 +1,112 @@
<template>
<div class="apod-wrapper" v-if="image">
<a :href="link" class="title" target="__blank" title="View Article">
{{ title }}
</a>
<a :href="hdImage" title="View HD Image" class="picture" target="__blank">
<img :src="image" :alt="title" />
</a>
<p class="copyright">{{ copyright }}</p>
<p class="description">{{ truncatedDescription }}</p>
<p @click="toggleShowFull" class="expend-details-btn">
{{ showFullDesc ? $t('widgets.general.show-less') : $t('widgets.general.show-more') }}
</p>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
data() {
return {
title: null,
image: null,
hdImage: null,
link: null,
description: null,
copyright: null,
showFullDesc: false,
};
},
computed: {
truncatedDescription() {
return this.showFullDesc ? this.description : `${this.description.substring(0, 100)}...`;
},
},
methods: {
fetchData() {
axios.get(widgetApiEndpoints.astronomyPictureOfTheDay)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
processData(data) {
this.title = data.title;
this.image = data.url;
this.hdImage = data.hdurl;
this.link = data.apod_site;
this.description = data.description;
this.copyright = data.copyright;
},
toggleShowFull() {
this.showFullDesc = !this.showFullDesc;
},
},
};
</script>
<style scoped lang="scss">
.apod-wrapper {
a.title {
font-size: 1.5rem;
margin: 0.5rem 0;
color: var(--widget-text-color);
text-decoration: none;
&:hover { text-decoration: underline; }
}
a.picture img {
width: 100%;
margin: 0.5rem auto;
border-radius: var(--curve-factor);
}
p.copyright {
font-size: 0.8rem;
margin: 0.2rem 0;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
}
p.description {
color: var(--widget-text-color);
font-size: 1rem;
margin: 0.5rem 0;
}
p.expend-details-btn {
cursor: pointer;
float: right;
margin: 0;
padding: 0.1rem 0.25rem;
border: 1px solid transparent;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
border-radius: var(--curve-factor);
&:hover {
border: 1px solid var(--widget-text-color);
}
&:focus, &:active {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
}
}
</style>

View File

@ -0,0 +1,23 @@
<template>
<div class="placeholder-widget">
<p>Error finding and mounting specified widget</p>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
mounted() {
this.error('Unable to render widget of specified type');
},
};
</script>
<style scoped lang="scss">
.placeholder-widget p {
color: var(--danger);
font-weight: bold;
}
</style>

View File

@ -0,0 +1,110 @@
<template>
<div class="clock">
<div class="upper" v-if="!options.hideDate">
<p class="city">{{ timeZone | getCity }}</p>
<p class="date">{{ date }}</p>
</div>
<p class="time">{{ time }}</p>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
data() {
return {
timeUpdateInterval: null, // Stores setInterval function
time: null, // Current time string
date: null, // Current date string
};
},
computed: {
/* Get time zone, either specified by user or calculated from browser */
timeZone() {
if (this.options.timeZone) return this.options.timeZone;
return Intl.DateTimeFormat().resolvedOptions().timeZone;
},
/* Get date/time format specification, either user choice, or from browser lang */
timeFormat() {
if (this.options.format) return this.options.format;
return navigator.language;
},
},
filters: {
/* For a given time zone, return just the city name */
getCity(timeZone) {
return timeZone.split('/')[1].replaceAll('_', ' ');
},
},
methods: {
update() {
this.setTime();
this.setDate();
},
/* Get and format the current time */
setTime() {
this.time = Intl.DateTimeFormat(this.timeFormat, {
timeZone: this.timeZone,
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
}).format();
},
/* Get and format the date */
setDate() {
this.date = new Date().toLocaleDateString(this.timeFormat, {
weekday: 'long', day: 'numeric', year: 'numeric', month: 'short',
});
},
},
created() {
// Set initial date and time
this.update();
// Update the date every hour, and the time each second
this.timeUpdateInterval = setInterval(() => {
this.setTime();
const now = new Date();
if (now.getMinutes() === 0 && now.getSeconds() === 0) {
this.setDate();
}
}, 1000);
},
beforeDestroy() {
clearInterval(this.timeUpdateInterval);
},
};
</script>
<style scoped lang="scss">
@font-face {
font-family: 'Digital';
src: url('/fonts/Digital-Regular.ttf');
}
.clock {
padding: 0.5rem 0;
.upper {
display: flex;
justify-content: space-between;
border-radius: var(--curve-factor);
padding: 0.5rem;
opacity: 0.85;
font-size: 0.8rem;
background: var(--widget-accent-color);
}
p {
color: var(--widget-text-color);
cursor: default;
margin: 0;
}
.time {
font-size: 4rem;
padding: 0.5rem;
text-align: center;
font-family: Digital, var(--font-monospace);
}
}
</style>

View File

@ -0,0 +1,244 @@
<template>
<div class="code-stats-wrapper">
<!-- User Info -->
<div class="user-meta" v-if="basicInfo && !hideMeta">
<div class="user-info-wrap">
<p class="username">{{ basicInfo.username }}</p>
<p class="user-level">{{ basicInfo.level }}</p>
</div>
<div class="total-xp-wrap">
<p class="total-xp">{{ basicInfo.totalXp | formatTotalXp }}</p>
<p class="new-xp">{{ basicInfo.newXp | formatNewXp }}</p>
</div>
</div>
<!-- XP History Heatmap -->
<div :id="`xp-history-${chartId}`" class="xp-heat-chart"></div>
<!-- Language Breakdown -->
<div :id="`languages-${chartId}`" class="language-pie-chart"></div>
<!-- Machines Percentage -->
<div :id="`machines-${chartId}`" class="machine-percentage-chart"></div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { putCommasInBigNum, showNumAsThousand } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, ChartingMixin],
data() {
return {
basicInfo: null,
};
},
computed: {
/* The username to fetch data from - REQUIRED */
username() {
if (!this.options.username) this.error('You must specify a username');
return this.options.username;
},
/* Optionally override hostname, if using a self-hosted instance */
hostname() {
if (this.options.hostname) return this.options.hostname;
return widgetApiEndpoints.codeStats;
},
hideMeta() {
return this.options.hideMeta || false;
},
hideHistory() {
return this.options.hideHistory || false;
},
hideLanguages() {
return this.options.hideLanguages || false;
},
hideMachines() {
return this.options.hideMachines || false;
},
monthsToShow() {
return this.options.monthsToShow || 5;
},
endpoint() {
return `${this.hostname}/api/users/${this.username}`;
},
chartStartDate() {
const now = new Date();
return new Date((now.setMonth(now.getMonth() - this.monthsToShow)));
},
},
filters: {
formatTotalXp(bigNum) {
return showNumAsThousand(bigNum);
},
formatNewXp(newXp) {
return `+${putCommasInBigNum(newXp)} XP`;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data from CodeStats.net', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
// Make basic info data
if (!this.hideMeta) {
this.basicInfo = {
username: data.user,
level: this.makeLevel(data.total_xp),
totalXp: data.total_xp,
newXp: data.new_xp,
};
}
// Make language breakdown pie chart data
if (!this.hideLanguages) {
const langLabels = [];
const langXpValues = [];
Object.keys(data.languages).forEach((lang) => {
langLabels.push(lang);
langXpValues.push(data.languages[lang].xps);
});
const languagesPieData = {
labels: langLabels,
datasets: [{ values: langXpValues }],
};
this.drawLanguagePieChart(languagesPieData);
}
// Make day-by-day historical XP heat chart data
if (!this.hideHistory) {
const xpHistoryChartData = {};
Object.keys(data.dates).forEach((date) => {
const timestamp = Math.round(new Date(date).getTime() / 1000);
xpHistoryChartData[timestamp] = data.dates[date];
});
this.drawXpHistoryChart(xpHistoryChartData);
}
// Make machine proportion percentage chart data
if (!this.hideMachines) {
const machinesLabels = [];
const machinesXpValues = [];
Object.keys(data.machines).forEach((machine) => {
machinesLabels.push(machine);
machinesXpValues.push(data.machines[machine].xps);
});
const machinesPercentageData = {
labels: machinesLabels,
datasets: [{ values: machinesXpValues }],
};
this.drawMachinesPercentageChart(machinesPercentageData);
}
},
drawLanguagePieChart(languagesPieData) {
return new this.Chart(`#languages-${this.chartId}`, {
title: 'Languages',
type: 'donut',
data: languagesPieData,
height: 250,
strokeWidth: 15,
tooltipOptions: {
formatTooltipY: d => showNumAsThousand(d),
},
});
},
drawXpHistoryChart(xpHistoryData) {
return new this.Chart(`#xp-history-${this.chartId}`, {
title: 'Historical XP',
type: 'heatmap',
data: {
dataPoints: xpHistoryData,
start: this.chartStartDate,
end: new Date(),
},
discreteDomains: 0,
radius: 2,
colors: ['#caf0f8', '#48cae4', '#0077b6', '#023e8a', '#090a79'],
});
},
drawMachinesPercentageChart(machineChartData) {
return new this.Chart(`#machines-${this.chartId}`, {
title: 'Machines',
type: 'percentage',
data: machineChartData,
height: 180,
strokeWidth: 15,
tooltipOptions: {
formatTooltipY: d => showNumAsThousand(d),
},
colors: ['#f9c80e', '#43bccd', '#ea3546', '#662e9b', '#f86624'],
});
},
/* Given a users XP score, return text level */
makeLevel(xp) {
if (xp < 100) return 'New Joiner';
if (xp < 1000) return 'Noob';
if (xp < 10000) return 'Intermediate';
if (xp < 50000) return 'Code ninja in the making';
if (xp < 100000) return 'Expert Developer';
if (xp < 500000) return 'Ultra Expert Developer';
if (xp < 1000000) return 'Code Super Hero';
if (xp < 1500000) return 'Super Epic Code Hero';
if (xp >= 15000000) return 'God Level';
return xp;
},
},
};
</script>
<style scoped lang="scss">
.code-stats-wrapper {
p {
margin: 0;
font-size: 1rem;
color: var(--widget-text-color);
}
.user-meta {
display: flex;
margin: 0.5rem;
padding: 0.5rem 0;
border-bottom: 1px dashed var(--widget-text-color);
justify-content: space-between;
.user-info-wrap {
.username {
font-size: 1.4rem;
text-transform: capitalize;
}
.user-level {
font-size: 0.8rem;
text-transform: capitalize;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
}
}
.total-xp-wrap {
display: flex;
align-items: flex-start;
.total-xp {
font-size: 1.4rem;
font-family: var(--font-monospace);
}
.new-xp {
font-size: 0.8rem;
margin: 0 0 0 0.5rem;
color: var(--success);
font-family: var(--font-monospace);
}
}
}
.xp-heat-chart,
.language-pie-chart,
.machine-percentage-chart {
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
}
</style>

View File

@ -0,0 +1,178 @@
<template>
<div class="crypto-price-chart" :id="chartId"></div>
</template>
<script>
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm';
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
chartData: null,
chartDom: null,
};
},
computed: {
/* The crypto asset to fetch price data for */
asset() {
const userChoice = this.options.asset;
if (typeof userChoice === 'string') return userChoice;
return 'bitcoin';
},
/* Number of days worth of history to fetch and display */
numDays() {
const userChoice = this.options.numDays;
if (typeof usersChoice === 'number' && userChoice < 30 && userChoice > 0.15) {
return userChoice;
}
return 7;
},
/* The fiat currency to calculate price data in */
currency() {
const userChoice = this.options.currency;
if (typeof userChoice === 'string') return userChoice;
return 'USD';
},
/* The number of data points to render on the chart */
dataPoints() {
const userChoice = this.options.dataPoints;
if (typeof usersChoice === 'number' && userChoice < 100 && userChoice > 5) {
return userChoice;
}
return 30;
},
/* The formatted GET request API endpoint to fetch crypto data from */
endpoint() {
return `${widgetApiEndpoints.cryptoPrices}${this.asset}/`
+ `market_chart?vs_currency=${this.currency}&days=${this.numDays}`;
},
/* A sudo-random ID for the chart DOM element */
chartId() {
return `crypto-price-chart-${Math.round(Math.random() * 10000)}`;
},
},
methods: {
/* Create new chart, using the crypto data */
generateChart() {
return new Chart(`#${this.chartId}`, {
title: `${this.asset} Price Chart`,
data: this.chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${d} ${this.currency}`,
},
});
},
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
try {
this.processData(response.data);
} catch (chartingError) {
this.error('Unable to plot results on chart', chartingError);
}
})
.catch((dataFetchError) => {
this.error('Unable to fetch crypto data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Generate price history in a format that can be consumed by the chart
* To improve efficiency, only a certain amount of data points are plotted
* depending on user preference. An average is then calculated between points
*/
processData(data) {
const priceChartData = [];
const priceLabels = [];
const interval = Math.round(data.prices.length / this.dataPoints);
const showTime = this.numDays < 5;
// Counters for calculating averages between data points
let tmpCounter = 0; let tmpTotal = 0;
const incrementAverage = (add) => {
tmpCounter += 1; tmpTotal += add;
if (add === null) { tmpCounter = 0; tmpTotal = 0; }
};
// For each data point, calc average, and if interval is right, then append
data.prices.forEach((priceGroup, index) => {
incrementAverage(priceGroup[1]); // Increment averages
if (index % interval === 0) {
const price = this.formatPrice(tmpTotal / tmpCounter);
priceLabels.push(this.formatDate(priceGroup[0], showTime));
priceChartData.push(price);
incrementAverage(null); // Reset counter
}
});
// Combine results with chart config
this.chartData = {
labels: priceLabels,
datasets: [
{
name: 'Price',
type: 'bar',
values: priceChartData,
},
],
};
// Call chart render function
this.renderChart();
},
/* Uses class data to render the line chart */
renderChart() {
this.chartDom = this.generateChart();
},
/* Format the date for a given time stamp, also include time if required */
formatDate(timestamp, includeTime) {
const localFormat = navigator.language;
const dateFormat = { weekday: 'short', day: 'numeric', month: 'short' };
const timeFormat = { hour: 'numeric', minute: 'numeric', second: 'numeric' };
const date = new Date(timestamp).toLocaleDateString(localFormat, dateFormat);
const time = Intl.DateTimeFormat(localFormat, timeFormat).format(timestamp);
return `${date} ${includeTime ? time : ''}`;
},
/* Format the price, rounding to given number of decimal places */
formatPrice(price) {
let numDecimals = 0;
if (price < 10) numDecimals = 1;
if (price < 1) numDecimals = 2;
if (price < 0.1) numDecimals = 3;
if (price < 0.01) numDecimals = 4;
if (price < 0.001) numDecimals = 5;
return price.toFixed(numDecimals);
},
},
};
</script>
<style lang="scss">
.crypto-price-chart .chart-container {
text.title {
text-transform: capitalize;
color: var(--widget-text-color);
}
.axis, .chart-label {
fill: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover { opacity: 1; }
}
}
</style>

View File

@ -0,0 +1,164 @@
<template>
<div class="wallet-balance">
<template v-if="cryptoData">
<div
v-for="(asset, index) in cryptoData"
:key="index"
class="asset-wrapper"
v-tooltip="tooltip(asset.info)"
>
<img class="icon" :src="asset.image" :alt="`${asset} icon`" />
<p class="name">{{ asset.name }}</p>
<p class="price">{{ asset.price | formatPrice }}</p>
<p :class="`percent ${asset.percentChange > 0 ? 'up' : 'down'}`">
{{ asset.percentChange | formatPercentage }}
</p>
</div>
</template>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import {
findCurrencySymbol, timestampToDate, roundPrice, putCommasInBigNum,
} from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
cryptoData: null,
};
},
computed: {
/* The crypto assets to fetch price data for */
assets() {
const usersChoice = this.options.assets;
if (!usersChoice) return '';
return usersChoice.join(',');
},
/* The fiat currency to calculate price data in */
currency() {
const userChoice = this.options.currency;
if (typeof userChoice === 'string') return userChoice;
return 'USD';
},
limit() {
const userChoice = this.options.limit;
if (userChoice && userChoice > 0) return userChoice;
return 100;
},
/* How results should be sorted */
order() {
const userChoice = this.options.sortBy;
switch (userChoice) {
case ('alphabetical'): return 'id_asc';
case ('volume'): return 'volume_desc';
case ('marketCap'): return 'market_cap_desc';
default: return 'market_cap_desc';
}
},
/* The formatted GET request API endpoint to fetch crypto data from */
endpoint() {
return `${widgetApiEndpoints.cryptoWatchList}?`
+ `ids=${this.assets}&vs_currency=${this.currency}&order=${this.order}&per_page=${this.limit}`;
},
},
filters: {
/* Append currency symbol to price */
formatPrice(price) {
return `${findCurrencySymbol('usd')}${putCommasInBigNum(roundPrice(price))}`;
},
/* Append percentage symbol, and up/ down arrow */
formatPercentage(change) {
if (!change) return '';
const symbol = change > 0 ? '↑' : '↓';
return `${symbol} ${change.toFixed(2)}%`;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((error) => {
this.error('Unable to fetch crypto watch list', error);
})
.finally(() => {
this.finishLoading();
});
},
/* Convert response data into JSON to be consumed by the UI */
processData(data) {
const results = [];
data.forEach((token) => {
results.push({
name: token.name,
image: token.image,
price: token.current_price,
percentChange: token.price_change_percentage_24h,
info: {
symbol: token.symbol,
rank: token.market_cap_rank,
marketCap: token.market_cap,
supply: token.circulating_supply,
maxSupply: token.max_supply,
allTimeHigh: token.ath,
allTimeHighDate: token.ath_date,
},
});
});
this.cryptoData = results;
},
/* Show additional info as a tooltip on hover */
tooltip(info) {
const maxSupply = info.maxSupply ? ` out of max supply of <b>${info.maxSupply}</b>` : '';
const content = `Rank: <b>${info.rank}</b> with market cap of `
+ `<b>${this.$options.filters.formatPrice(info.marketCap)}</b>`
+ `<br>Circulating Supply: <b>${info.supply} ${info.symbol.toUpperCase()}</b>${maxSupply}`
+ `<br>All-time-high of <b>${info.allTimeHigh}</b> `
+ `at <b>${timestampToDate(info.allTimeHighDate)}</b>`;
return {
content, html: true, trigger: 'hover focus', delay: 250,
};
},
},
};
</script>
<style scoped lang="scss">
.asset-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--widget-text-color);
font-size: 0.9rem;
.icon {
width: 2rem;
height: 2rem;
border-radius: 1rem;
}
.name {
font-weight: bold;
}
.percent, .price {
font-family: var(--font-monospace);
&.up { color: var(--success); }
&.down { color: var(--danger); }
}
p {
width: 28%;
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
</style>

View File

@ -0,0 +1,236 @@
<template>
<div class="cve-wrapper" v-if="cveList">
<div v-for="cve in cveList" :key="cve.id" class="cve-row">
<a class="upper" :href="cve.url" target="_blank">
<p :class="`score ${makeScoreColor(cve.score)}`">{{ cve.score }}</p>
<div class="title-wrap">
<p class="title">{{ cve.id }}</p>
<span class="date">{{ cve.publishDate | formatDate }}</span>
<span class="last-updated">Last Updated: {{ cve.updateDate | formatDate }}</span>
<span :class="`exploit-count ${makeExploitColor(cve.numExploits)}`">
{{ cve.numExploits | formatExploitCount }}
</span>
</div>
</a>
<p class="description">
{{ cve.description | formatDescription }}
<a v-if="cve.description.length > 350" class="read-more" :href="cve.url" target="_blank">
{{ $t('widgets.general.open-link') }}
</a>
</p>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { timestampToDate, truncateStr } from '@/utils/MiscHelpers';
import { widgetApiEndpoints, serviceEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
cveList: null,
};
},
filters: {
formatDate(date) {
return timestampToDate(date);
},
formatDescription(description) {
return truncateStr(description, 350);
},
formatExploitCount(numExploits) {
if (!numExploits) return 'Number of exploits not known';
if (numExploits === '0') return 'No published exploits';
return `${numExploits} known exploit${numExploits !== '1' ? 's' : ''}`;
},
},
computed: {
/* Get sort order, defaults to publish date */
sortBy() {
const usersChoice = this.options.sortBy;
let sortCode;
switch (usersChoice) {
case ('publish-date'): sortCode = 1; break;
case ('last-update'): sortCode = 2; break;
case ('cve-code'): sortCode = 3; break;
default: sortCode = 1;
}
return `&orderby=${sortCode}`;
},
/* The minimum CVE score to fetch/ show, defaults to 4 */
minScore() {
const usersChoice = this.options.minScore;
let minScoreVal = 4;
if (usersChoice && (usersChoice >= 0 || usersChoice <= 10)) {
minScoreVal = usersChoice;
}
return `&cvssscoremin=${minScoreVal}`;
},
vendorId() {
return (this.options.vendorId) ? `&vendor_id=${this.options.vendorId}` : '';
},
productId() {
return (this.options.productId) ? `&product_id=${this.options.productId}` : '';
},
/* Should only show results with exploits, defaults to false */
hasExploit() {
const shouldShow = this.options.hasExploit ? 1 : 0;
return `&hasexp=${shouldShow}`;
},
/* The number of results to fetch/ show, defaults to 10 */
limit() {
const usersChoice = this.options.limit;
let numResults = 10;
if (usersChoice && (usersChoice >= 5 || usersChoice <= 30)) {
numResults = usersChoice;
}
return `&numrows=${numResults}`;
},
endpoint() {
return `${widgetApiEndpoints.cveVulnerabilities}?${this.sortBy}${this.limit}`
+ `${this.minScore}${this.vendorId}${this.hasExploit}`;
},
proxyReqEndpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
return `${baseUrl}${serviceEndpoints.corsProxy}`;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.request({
method: 'GET',
url: this.proxyReqEndpoint,
headers: { 'Target-URL': this.endpoint },
})
.then((response) => {
this.processData(response.data);
}).catch((error) => {
this.error('Unable to fetch CVE data', error);
}).finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const cveList = [];
data.forEach((cve) => {
cveList.push({
id: cve.cve_id,
score: cve.cvss_score,
url: cve.url,
description: cve.summary,
numExploits: cve.exploit_count,
publishDate: cve.publish_date,
updateDate: cve.update_date,
});
});
this.cveList = cveList;
},
makeExploitColor(numExploits) {
if (!numExploits || Number.isNaN(parseInt(numExploits, 10))) return 'fg-grey';
const count = parseInt(numExploits, 10);
if (count === 0) return 'fg-green';
if (count === 1) return 'fg-orange';
if (count > 1) return 'fg-red';
return 'fg-grey';
},
makeScoreColor(inputScore) {
if (!inputScore || Number.isNaN(parseFloat(inputScore))) return 'bg-grey';
const score = parseFloat(inputScore);
if (score >= 9) return 'bg-red';
if (score >= 7) return 'bg-orange';
if (score >= 4) return 'bg-yellow';
if (score >= 0.1) return 'bg-green';
return 'bg-blue';
},
},
};
</script>
<style scoped lang="scss">
.cve-wrapper {
.cve-row {
p, span, a {
font-size: 1rem;
margin: 0.5rem 0;
color: var(--widget-text-color);
&.bg-green { background: var(--success); }
&.bg-yellow { background: var(--warning); }
&.bg-orange { background: var(--error); }
&.bg-red { background: var(--danger); }
&.bg-blue { background: var(--info); }
&.bg-grey { background: var(--neutral); }
&.fg-green { color: var(--success); }
&.fg-yellow { color: var(--warning); }
&.fg-orange { color: var(--error); }
&.fg-red { color: var(--danger); }
&.fg-blue { color: var(--info); }
&.fg-grey { color: var(--neutral); }
}
a.upper {
display: flex;
margin: 0.25rem 0 0 0;
align-items: center;
text-decoration: none;
}
.score {
font-size: 1.1rem;
font-weight: bold;
padding: 0.45rem 0.25rem 0.25rem 0.25rem;
margin-right: 0.5rem;
border-radius: 30px;
font-family: var(--font-monospace);
background: var(--widget-text-color);
color: var(--widget-background-color);
}
.title {
font-family: var(--font-monospace);
font-size: 1.2rem;
font-weight: bold;
margin: 0;
}
.date, .last-updated {
font-size: 0.8rem;
margin: 0;
opacity: var(--dimming-factor);
padding-right: 0.5rem;
}
.exploit-count {
display: none;
font-size: 0.8rem;
margin: 0;
padding-left: 0.5rem;
opacity: var(--dimming-factor);
border-left: 1px solid var(--widget-text-color);
}
.seperator {
font-size: 0.8rem;
margin: 0;
opacity: var(--dimming-factor);
}
.description {
margin: 0 0.25rem 0.5rem 0.25rem;
}
a.read-more {
opacity: var(--dimming-factor);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
.last-updated {
display: none;
}
&:hover {
.date { display: none; }
.exploit-count, .last-updated { display: inline; }
}
}
}
</style>

View File

@ -0,0 +1,84 @@
<template>
<div class="html-widget">
<div :id="elementId" />
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
computed: {
/* Optional HTML markup to be rendered */
html() {
return this.options.html || '';
},
/* Optional CSS styles to be applied */
css() {
return this.options.css || '';
},
/* Optional raw JavaScript to be executed */
script() {
return this.options.script || '';
},
/* Optional path to JS script to be fetched */
scriptSrc() {
return this.options.scriptSrc || '';
},
/* Unique element ID */
elementId() {
return `elem-${Math.round(Math.random() * 10000)}`;
},
},
mounted() {
this.initiate();
},
beforeDestroy() {
window.removeEventListener('load', this.injectHtml);
},
methods: {
/* Injects users content */
injectHtml() {
if (this.html) {
const element = document.getElementById(this.elementId);
element.innerHTML = this.html;
}
if (this.css) {
const styleElem = document.createElement('style');
styleElem.textContent = this.css;
document.head.append(styleElem);
}
if (this.script) {
const scriptElem = document.createElement('script');
scriptElem.text = this.script;
document.head.append(scriptElem);
}
if (this.scriptSrc) {
const scriptElem = document.createElement('script');
scriptElem.src = this.scriptSrc;
document.head.append(scriptElem);
}
},
/* What for the DOM to finish loading, before proceeding */
initiate() {
if (document.readyState === 'complete' || document.readyState === 'loaded') {
this.injectHtml();
} else {
window.addEventListener('load', this.injectHtml);
}
},
update() {
this.injectHtml();
},
},
};
</script>
<style scoped lang="scss">
.html-widget {
width: 100%;
min-height: 240px;
}
</style>

View File

@ -0,0 +1,177 @@
<template>
<div class="exchange-rate-wrapper">
<template v-if="exchangeRates">
<p class="exchange-base-currency">Value of 1 {{ newInputCurrency || inputCurrency }}</p>
<p class="reset" v-if="newInputCurrency" @click="updateInputCurrency(inputCurrency)">
Reset back to {{ inputCurrency }}
</p>
<div
v-for="(exchange, index) in exchangeRates" :key="index"
v-tooltip="tooltip(makeInverse(exchange))"
class="exchange-rate-row"
>
<p class="country" @click="updateInputCurrency(exchange.currency)">
<img :src="exchange.currency | flagUrl" alt="Flag" class="flag" />
{{ exchange.currency }}
</p>
<p class="value">
<span class="input-currency">
{{ 1 | applySymbol(newInputCurrency || inputCurrency) }} =
</span>
{{ exchange.value | applySymbol(exchange.currency) }}
</p>
</div>
<p class="last-updated">Updated on {{ lastUpdated }}</p>
</template>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { findCurrencySymbol, getCurrencyFlag, timestampToDate } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
exchangeRates: null,
newInputCurrency: null,
lastUpdated: null,
};
},
computed: {
/* The users API key for exchangerate-api.com */
apiKey() {
return this.options.apiKey;
},
/* The currency to convert results into */
inputCurrency() {
return this.options.inputCurrency || 'USD';
},
/* An array of currencies to display */
outputCurrencies() {
return this.options.outputCurrencies || [];
},
endpoint() {
const currency = this.newInputCurrency || this.inputCurrency;
return `${widgetApiEndpoints.exchangeRates}${this.apiKey}/latest/${currency}`;
},
},
filters: {
/* Appends currency symbol onto price */
applySymbol(price, inputCurrency) {
return `${findCurrencySymbol(inputCurrency)}${price}`;
},
flagUrl(currency) {
return getCurrencyFlag(currency);
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then(response => {
this.processData(response.data);
}).catch(error => {
this.error('Unable to fetch or process exchange rate data', error);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const results = [];
const rates = data.conversion_rates;
Object.keys(rates).forEach((currency) => {
if (this.outputCurrencies.includes(currency)) {
results.push({ currency, value: rates[currency] });
}
});
this.exchangeRates = results;
this.lastUpdated = timestampToDate(data.time_last_update_unix * 1000);
},
updateInputCurrency(newCurrency) {
this.startLoading();
if (newCurrency === this.inputCurrency) {
this.newInputCurrency = null;
} else {
this.newInputCurrency = newCurrency;
}
this.fetchData();
},
makeInverse(exchange) {
return `1 ${exchange.currency} = ${(1 / exchange.value).toFixed(2)}`
+ ` ${this.newInputCurrency || this.inputCurrency}`;
},
},
};
</script>
<style scoped lang="scss">
.exchange-rate-wrapper {
max-width: 380px;
margin: 0 auto;
p.exchange-base-currency {
margin: 0.25rem 0;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
p.reset {
opacity: var(--dimming-factor);
color: var(--widget-text-color);
margin: 0.25rem 0;
font-size: 0.8rem;
text-decoration: underline;
cursor: pointer;
&:hover { opacity: 1; }
}
.exchange-rate-row {
display: flex;
justify-content: space-between;
margin: 0.25rem auto;
padding: 0.25rem;
p {
margin: 0;
color: var(--widget-text-color);
}
p.country {
cursor: pointer;
display: flex;
align-items: center;
img.flag {
border-radius: var(--curve-factor);
margin-right: 0.5rem;
max-width: 40px;
}
}
p.value {
display: flex;
align-items: center;
font-family: var(--font-monospace);
span.input-currency {
display: none;
opacity: var(--dimming-factor);
font-size: 0.8rem;
margin-right: 0.5rem;
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
&:hover {
p.value span.input-currency { display: block; }
}
}
p.last-updated {
opacity: var(--dimming-factor);
color: var(--widget-text-color);
font-family: var(--font-monospace);
margin: 0.2rem 0;
font-size: 0.6rem;
}
}
</style>

View File

@ -0,0 +1,189 @@
<template>
<div class="flight-wrapper">
<!-- Info -->
<p class="flight-intro">
Live {{ direction !== 'both' ? direction: 'flight' }} data from {{ airport }}
</p>
<!-- Departures -->
<div v-if="departures.length > 0" class="flight-group">
<h3 class="flight-type-subtitle" v-if="direction === 'both'">
{{ $t('widgets.flight-data.departures') }}
</h3>
<div v-for="flight in departures" :key="flight.number" class="flight" v-tooltip="tip(flight)">
<p class="info flight-time">{{ flight.time | formatDate }}</p>
<p class="info flight-number">{{ flight.number }}</p>
<p class="info flight-airport">{{ flight.airport }}</p>
</div>
</div>
<!-- Arrivals -->
<div v-if="arrivals.length > 0" class="flight-group">
<h3 class="flight-type-subtitle" v-if="direction === 'both'">
{{ $t('widgets.flight-data.arrivals') }}
</h3>
<div v-for="flight in arrivals" :key="flight.number" class="flight" v-tooltip="tip(flight)">
<p class="info flight-time">{{ flight.time | formatDate }}</p>
<p class="info flight-number">{{ flight.number }}</p>
<p class="info flight-airport">{{ flight.airport }}</p>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
departures: [],
arrivals: [],
};
},
filters: {
formatDate(date) {
const d = new Date(date);
if (Number.isNaN(d.getHours())) return '[UNKNOWN]';
return `${d.getHours()}:${d.getMinutes()}:${d.getSeconds()}`;
},
},
computed: {
/* The users desired airport, specified as a 4-digit ICAO-code */
airport() {
const usersChoice = this.options.airport;
if (!usersChoice) {
this.error('A valid airport must be specified');
return '';
}
const formattedAirport = usersChoice.toUpperCase().trim();
if (!(/[A-Z]{4}/).test(formattedAirport)) {
this.error('Incorrect airport format, must be a valid 4-digit ICAO-code');
return '';
}
return formattedAirport;
},
apiKey() {
const usersChoice = this.options.apiKey;
if (!usersChoice) {
this.error('An API key must be supplied');
return '';
}
return usersChoice;
},
/* The direction of flights: Arrival, Departure or Both */
direction() {
const usersChoice = this.options.direction;
if (!usersChoice || typeof usersChoice !== 'string') return 'both';
const options = ['arrival', 'departure', 'both'];
if (options.includes(usersChoice.toLowerCase())) return usersChoice;
return 'both';
},
limit() {
const usersChoice = this.options.limit;
if (usersChoice) return usersChoice;
return 8;
},
/* The starting date, right now, in ISO String format */
fromDate() {
const now = new Date();
return new Date(`${now.toString().split('GMT')[0]} UTC`).toISOString().split('.')[0];
},
/* The ending date, 12 hours from now, in ISO string format */
toDate() {
const now = new Date(new Date().setSeconds(0));
const tomorrow = new Date(new Date(now).setHours(now.getHours() + 12));
return new Date(`${tomorrow.toString().split('GMT')[0]} UTC`).toISOString().split('.')[0];
},
endpoint() {
return `${widgetApiEndpoints.flights}${this.airport}/${this.fromDate}/${this.toDate}`;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
const requestConfig = {
method: 'GET',
url: this.endpoint,
params: {
withCargo: 'true',
withPrivate: 'true',
withLocation: 'false',
},
headers: {
'x-rapidapi-host': 'aerodatabox.p.rapidapi.com',
'x-rapidapi-key': this.apiKey,
},
};
axios.request(requestConfig)
.then((response) => {
this.processData(response.data);
}).catch((error) => {
this.error('Unable to fetch flight data', error);
}).finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
this.arrivals = this.makeFlightList(data.arrivals).slice(0, this.limit);
this.departures = this.makeFlightList(data.departures).slice(0, this.limit);
},
/* Gets the useful flight info out of departures or arrivals */
makeFlightList(flights) {
const results = [];
flights.forEach((flight) => {
results.push({
number: flight.number,
airline: flight.airline.name,
aircraft: flight.aircraft.model,
airport: flight.movement.airport.name,
time: flight.movement.actualTimeUtc,
});
});
return results;
},
tip(flight) {
const content = `${flight.aircraft} | ${flight.airline}`;
return {
content, trigger: 'hover focus', delay: 250, classes: 'in-modal-tt',
};
},
},
};
</script>
<style scoped lang="scss">
.flight-wrapper {
p.flight-intro {
margin: 0 0 0.5rem 0;
font-size: 1rem;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
h3.flight-type-subtitle {
margin: 0.25rem 0;
font-size: 1.2rem;
color: var(--widget-text-color);
}
.flight-group {
.flight {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0.25rem 0;
p.info {
margin: 0;
min-width: 33%;
color: var(--widget-text-color);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<div class="readme-stats">
<img class="stats-card" v-if="!hideProfileCard" :src="profileCard" alt="Profile Card" />
<img class="stats-card" v-if="!hideLanguagesCard" :src="topLanguagesCard" alt="Languages" />
<template v-if="repos">
<img class="stats-card" v-for="(repo, i) in repoCards" :key="i" :src="repo" :alt="repo" />
</template>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
computed: {
hideProfileCard() {
return this.options.hideProfileCard;
},
hideLanguagesCard() {
return this.options.hideLanguagesCard;
},
username() {
const usersChoice = this.options.username;
if ((!this.hideProfileCard || !this.hideLanguagesCard) && !usersChoice) {
this.error('You must specify a GitHub username');
}
return usersChoice;
},
repos() {
const usersChoice = this.options.repos;
if (!usersChoice) return null;
if (typeof usersChoice === 'string') return [usersChoice];
if (Array.isArray(usersChoice)) return usersChoice;
this.error('Invalid format for repositories input');
return null;
},
colors() {
const cssVars = getComputedStyle(document.documentElement);
const getColor = (colorVar) => cssVars.getPropertyValue(`--${colorVar}`).trim().replace('#', '');
const primary = getColor('widget-text-color') || '7cd6fd';
const accent = getColor('widget-accent-color') || '7cd6fd';
const background = getColor('widget-background-color') || '7cd6fd';
const radius = getColor('curve-factor').replace('px', '') || '6';
const white = getColor('white') || 'fff';
return {
primary, accent, background, white, radius,
};
},
locale() {
if (this.options.lang) return this.options.lang;
return this.$store.getters.appConfig.lang || 'en';
},
cardConfig() {
const c = this.colors;
return `&title_color=${c.primary}&text_color=${c.white}&icon_color=${c.primary}`
+ `&bg_color=${c.background}&border_radius=${c.radius}&locale=${this.locale}`
+ '&count_private=true&show_icons=true&hide_border=true';
},
profileCard() {
return `${widgetApiEndpoints.readMeStats}?username=${this.username}${this.cardConfig}`;
},
topLanguagesCard() {
return `${widgetApiEndpoints.readMeStats}/top-langs/?username=${this.username}`
+ `${this.cardConfig}&langs_count=12`;
},
repoCards() {
const cards = [];
this.repos.forEach((repo) => {
const username = repo.split('/')[0];
const repoName = repo.split('/')[1];
cards.push(`${widgetApiEndpoints.readMeStats}/pin/?username=${username}&repo=${repoName}`
+ `${this.cardConfig}&show_owner=true`);
});
return cards;
},
},
};
</script>
<style scoped lang="scss">
.readme-stats {
img.stats-card {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,157 @@
<template>
<div class="trending-repos-wrapper" v-if="trendingRepos">
<div v-for="repo in trendingRepos" :key="repo.idx" class="repo-row">
<img class="repo-img" v-if="repo.avatar" :src="repo.avatar" alt="Repo" />
<div class="repo-info">
<p class="repo-name">{{ repo.name }}</p>
<div class="star-wrap">
<p class="all-stars" v-if="repo.stars">{{ repo.stars | formatStars }}</p>
<p class="new-stars" v-if="repo.newStars">{{ repo.newStars | formatStars }}</p>
</div>
<a class="repo-link" :href="repo.link">{{ repo.slug }}</a>
<p class="repo-desc">{{ repo.desc }}</p>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { capitalize, showNumAsThousand } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
data() {
return {
trendingRepos: null,
};
},
filters: {
formatStars(starCount) {
if (!starCount) return null;
const numericCount = typeof starCount === 'string'
? parseInt(starCount.replaceAll(',', ''), 10) : starCount;
return `${showNumAsThousand(numericCount) || starCount}`;
},
},
computed: {
since() {
const usersChoice = this.options.since;
const options = ['daily', 'weekly', 'monthly'];
if (usersChoice && options.includes(usersChoice)) return usersChoice;
return options[0];
},
lang() {
return this.options.lang || '';
},
limit() {
return this.options.limit || 10;
},
endpoint() {
return `${widgetApiEndpoints.githubTrending}repo?since=${this.since}&lang=${this.lang}`;
},
},
methods: {
fetchData() {
axios.get(this.endpoint)
.then((response) => {
if (response.data.items) {
this.processData(response.data.items);
}
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
processData(repos) {
const mkeName = (r) => capitalize(r.split('/')[1].replaceAll('-', ' ').replaceAll('_', ' '));
let results = [];
repos.forEach((repo) => {
results.push({
name: mkeName(repo.repo),
slug: repo.repo,
desc: repo.desc,
lang: repo.lang,
link: repo.repo_link,
stars: repo.stars,
forks: repo.forks,
newStars: parseInt(repo.added_stars, 10),
avatar: repo.avatars[0] || 'https://github.com/fluidicon.png',
});
});
if (this.limit && this.limit < results.length) {
results = results.slice(0, this.limit);
}
this.trendingRepos = results;
},
},
};
</script>
<style scoped lang="scss">
.trending-repos-wrapper {
.repo-row {
display: flex;
align-items: center;
margin: 0.5rem 0;
cursor: default;
img.repo-img {
width: 2.5rem;
border-radius: var(--curve-factor-small);
}
.repo-info {
display: grid;
width: 100%;
grid-template-columns: auto 1fr;
padding-left: 0.5rem;
p.repo-name {
margin: 0.1rem 0;
font-size: 1.2rem;
font-weight: bold;
color: var(--widget-text-color);
}
a.repo-link {
margin: 0.1rem 0;
font-size: 0.8rem;
text-decoration: underline;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
p.repo-desc {
grid-column-start: span 2;
margin: 0.1rem 0 0.25rem;
font-size: 0.8rem;
color: var(--widget-text-color);
}
.star-wrap {
grid-row-start: span 2;
min-width: 3rem;
text-align: right;
p {
font-family: var(--font-monospace);
margin: 0;
&.all-stars {
color: var(--widget-text-color);
font-size: 1.2rem;
font-weight: bold;
}
&.new-stars {
font-size: 0.8rem;
color: var(--success);
opacity: var(--dimming-factor);
}
}
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>

View File

@ -0,0 +1,161 @@
<template>
<div class="health-checks-wrapper" v-if="crons">
<div
class="cron-row"
v-for="cron in crons" :key="cron.id"
v-tooltip="pingTimeTooltip(cron)"
>
<div class="status">
<p :class="cron.status">{{ cron.status | formatStatus }}</p>
</div>
<div class="info">
<p class="cron-name">{{ cron.name }}</p>
<p class="cron-desc">{{ cron.desc }}</p>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints, serviceEndpoints } from '@/utils/defaults';
import { capitalize, timestampToDateTime } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
crons: null,
};
},
filters: {
formatStatus(status) {
let symbol = '';
if (status === 'up') symbol = '✔';
if (status === 'down') symbol = '✘';
if (status === 'new') symbol = '❖';
return `${symbol} ${capitalize(status)}`;
},
formatDate(timestamp) {
return timestampToDateTime(timestamp);
},
},
computed: {
/* API endpoint, either for self-hosted or managed instance */
endpoint() {
if (this.options.host) return `${this.options.host}/api/v1/checks`;
return `${widgetApiEndpoints.healthChecks}`;
},
proxyReqEndpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
return `${baseUrl}${serviceEndpoints.corsProxy}`;
},
apiKey() {
if (!this.options.apiKey) {
this.error('An API key is required, please see the docs for more info');
}
return this.options.apiKey;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
const requestConfig = {
method: 'GET',
url: this.proxyReqEndpoint,
headers: {
'access-control-request-headers': '*',
'Target-URL': this.endpoint,
CustomHeaders: JSON.stringify({ 'X-Api-Key': this.apiKey }),
},
};
axios.request(requestConfig)
.then((response) => {
this.processData(response.data);
}).catch((error) => {
this.error('Unable to fetch cron data', error);
}).finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const results = [];
data.checks.forEach((cron) => {
results.push({
id: cron.slug,
name: cron.name,
desc: cron.desc,
status: cron.status,
pingCount: cron.n_pings,
lastPing: cron.last_ping,
nextPing: cron.next_ping,
url: this.makeUrl(cron.unique_key),
});
});
this.crons = results;
},
makeUrl(cronId) {
const base = this.options.host || 'https://healthchecks.io';
return `${base}/checks/${cronId}/details`;
},
pingTimeTooltip(cron) {
const { lastPing, nextPing, pingCount } = cron;
const content = `<b>Total number of Pings:</b> ${pingCount}<br>`
+ `<b>Last Ping:</b> ${timestampToDateTime(lastPing)}<br>`
+ `<b>Next Ping:</b>${timestampToDateTime(nextPing)}`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'ping-times-tt',
};
},
},
};
</script>
<style scoped lang="scss">
.health-checks-wrapper {
color: var(--widget-text-color);
.cron-row {
display: flex;
justify-content: center;
align-items: center;
padding: 0.25rem 0;
.status {
min-width: 5rem;
font-size: 1.2rem;
font-weight: bold;
p {
margin: 0;
color: var(--info);
&.up { color: var(--success); }
&.down { color: var(--danger); }
&.new { color: var(--neutral); }
}
}
.info {
p.cron-name {
margin: 0.25rem 0;
font-weight: bold;
color: var(--widget-text-color);
}
p.cron-desc {
margin: 0;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>
<style lang="scss">
.ping-times-tt {
min-width: 20rem;
}
</style>

View File

@ -0,0 +1,56 @@
<template>
<div class="iframe-widget">
<iframe
v-if="frameUrl"
:src="frameUrl"
:id="frameId"
title="Iframe Widget"
:style="frameHeight ? `height: ${frameHeight}px` : ''"
/>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
computed: {
/* Gets users specified URL to load into the iframe */
frameUrl() {
const usersChoice = this.options.url;
if (!usersChoice || typeof usersChoice !== 'string') {
this.error('Iframe widget expects a URL');
return null;
}
return usersChoice;
},
frameHeight() {
return this.options.frameHeight;
},
/* Generates an ID for the iframe */
frameId() {
return `iframe-${btoa(this.frameUrl || 'empty').substring(0, 16)}`;
},
},
methods: {
/* Refreshes iframe contents, called by parent */
update() {
this.startLoading();
(document.getElementById(this.frameId) || {}).src = this.frameUrl;
this.finishLoading();
},
},
};
</script>
<style scoped lang="scss">
.iframe-widget {
iframe {
width: 100%;
min-height: 240px;
border: 0;
}
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<div v-if="jokeType" class="joke-wrapper">
<p class="joke joke-line-1">{{ jokeLine1 }}</p>
<p class="joke joke-line-2" v-if="jokeLine2">{{ jokeLine2 }}</p>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
jokeType: null,
jokeLine1: null,
jokeLine2: null,
};
},
computed: {
/* Language code to fetch jokes for */
language() {
const supportedLanguages = ['en', 'cs', 'de', 'es', 'fr', 'pt'];
const usersChoice = this.options.language;
if (usersChoice && supportedLanguages.includes(usersChoice)) return usersChoice;
const localLanguage = this.$store.getters.appConfig.lang;
if (localLanguage && supportedLanguages.includes(localLanguage)) return localLanguage;
return 'en';
},
/* Should enable safe mode, to disallow NSFW jokes */
safeMode() {
return !!this.options.safeMode;
},
/* Format the users preferred category */
category() {
let usersChoice = this.options.category;
if (!usersChoice) return 'any';
if (Array.isArray(usersChoice)) usersChoice = usersChoice.join();
const categories = ['any', 'misc', 'programming', 'dark', 'pun', 'spooky', 'christmas'];
if (categories.some((cat) => usersChoice.toLowerCase().includes(cat))) return usersChoice;
return 'any';
},
/* Combine data parameters for the API endpoint */
endpoint() {
return `${widgetApiEndpoints.jokes}${this.category}`
+ `?lang=${this.language}${this.safeMode ? '&safe-mode' : ''}`;
},
},
methods: {
/* Make GET request to Jokes API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
if (response.data.error) {
this.error('No matching jokes returned', response.data.additionalInfo);
}
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch any jokes', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
this.jokeType = data.type;
if (this.jokeType === 'twopart') {
this.jokeLine1 = data.setup;
this.jokeLine2 = data.delivery;
} else if (this.jokeType === 'single') {
this.jokeLine1 = data.joke;
}
},
},
};
</script>
<style scoped lang="scss">
.joke-wrapper {
p.joke {
color: var(--widget-text-color);
font-size: 1.2rem;
}
}
</style>

View File

@ -0,0 +1,122 @@
<template>
<div class="cpu-history-chart" :id="chartId"></div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
chartTitle: null,
chartData: null,
chartDom: null,
};
},
computed: {
/* URL where NetData is hosted */
netDataHost() {
const usersChoice = this.options.host;
if (!usersChoice || typeof usersChoice !== 'string') {
this.error('Host parameter is required');
return '';
}
return usersChoice;
},
apiVersion() {
return this.options.apiVersion || 'v1';
},
endpoint() {
return `${this.netDataHost}/api/${this.apiVersion}/data?chart=system.cpu`;
},
/* A sudo-random ID for the chart DOM element */
chartId() {
return `cpu-history-chart-${Math.round(Math.random() * 10000)}`;
},
},
methods: {
/* Make GET request to NetData */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const timeData = [];
const systemCpu = [];
const userCpu = [];
data.data.reverse().forEach((reading) => {
timeData.push(this.formatDate(reading[0] * 1000));
systemCpu.push(reading[2]);
userCpu.push(reading[3]);
});
this.chartData = {
labels: timeData,
datasets: [
{ name: 'System CPU', type: 'bar', values: systemCpu },
{ name: 'User CPU', type: 'bar', values: userCpu },
],
};
this.chartTitle = this.makeChartTitle(data.data);
this.renderChart();
},
makeChartTitle(data) {
if (!data || !data[0][0]) return '';
const diff = Math.round((data[data.length - 1][0] - data[0][0]) / 60);
return `Past ${diff} minutes`;
},
renderChart() {
this.chartDom = this.generateChart();
},
/* Create new chart, using the crypto data */
generateChart() {
return new this.Chart(`#${this.chartId}`, {
title: this.chartTitle,
data: this.chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${Math.round(d)}%`,
},
});
},
},
};
</script>
<style lang="scss">
.cpu-history-chart .chart-container {
text.title {
text-transform: capitalize;
color: var(--widget-text-color);
}
.axis, .chart-label {
fill: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover { opacity: 1; }
}
}
</style>

View File

@ -0,0 +1,125 @@
<template>
<div class="load-history-chart" :id="chartId"></div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
chartTitle: null,
chartData: null,
chartDom: null,
};
},
computed: {
/* URL where NetData is hosted */
netDataHost() {
const usersChoice = this.options.host;
if (!usersChoice || typeof usersChoice !== 'string') {
this.error('Host parameter is required');
return '';
}
return usersChoice;
},
apiVersion() {
return this.options.apiVersion || 'v1';
},
endpoint() {
return `${this.netDataHost}/api/${this.apiVersion}/data?chart=system.cpu`;
},
/* A sudo-random ID for the chart DOM element */
chartId() {
return `cpu-history-chart-${Math.round(Math.random() * 10000)}`;
},
},
methods: {
/* Make GET request to NetData */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const timeData = [];
const load1min = [];
const load5mins = [];
const load15mins = [];
data.data.reverse().forEach((reading) => {
timeData.push(this.formatDate(reading[0] * 1000));
load1min.push(reading[1]);
load5mins.push(reading[2]);
load15mins.push(reading[3]);
});
this.chartData = {
labels: timeData,
datasets: [
{ name: '1 Min', type: 'bar', values: load1min },
{ name: '5 Mins', type: 'bar', values: load5mins },
{ name: '15 Mins', type: 'bar', values: load15mins },
],
};
this.chartTitle = this.makeChartTitle(data.data);
this.renderChart();
},
makeChartTitle(data) {
if (!data || !data[0][0]) return '';
const diff = Math.round((data[data.length - 1][0] - data[0][0]) / 60);
return `Past ${diff} minutes`;
},
renderChart() {
this.chartDom = this.generateChart();
},
/* Create new chart, using the crypto data */
generateChart() {
return new this.Chart(`#${this.chartId}`, {
title: this.chartTitle,
data: this.chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${Math.round(d)}%`,
},
});
},
},
};
</script>
<style lang="scss">
.load-history-chart .chart-container {
text.title {
text-transform: capitalize;
color: var(--widget-text-color);
}
.axis, .chart-label {
fill: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover { opacity: 1; }
}
}
</style>

View File

@ -0,0 +1,138 @@
<template>
<div class="memory-charts-wrapper">
<div class="chart" :id="`aggregate-${chartId}`"></div>
<div class="chart" :id="chartId"></div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
computed: {
/* URL where NetData is hosted */
netDataHost() {
const usersChoice = this.options.host;
if (!usersChoice || typeof usersChoice !== 'string') {
this.error('Host parameter is required');
return '';
}
return usersChoice;
},
apiVersion() {
return this.options.apiVersion || 'v1';
},
endpoint() {
return `${this.netDataHost}/api/${this.apiVersion}/data?chart=system.ram`;
},
/* A sudo-random ID for the chart DOM element */
chartId() {
return `cpu-history-chart-${Math.round(Math.random() * 10000)}`;
},
},
methods: {
/* Make GET request to NetData */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(inputData) {
const { labels, data } = inputData;
// Convert data to an object for easy working
const timeData = []; // List of timestamps for axis
const resultGroup = {}; // List of datasets, for each label
data.reverse().forEach((reading) => {
labels.forEach((label, indx) => {
if (indx === 0) { // First value is the timestamp, add to axis
timeData.push(this.formatTime(reading[indx] * 1000));
} else { // All other values correspond to a label
if (!resultGroup[label]) resultGroup[label] = [];
resultGroup[label].push(reading[indx]);
}
});
});
// Put data in the format expected by the charts
const averages = [];
const datasets = [];
Object.keys(resultGroup).forEach((label) => {
datasets.push({ name: label, type: 'bar', values: resultGroup[label] });
averages.push(Math.round(this.average(resultGroup[label])));
});
// Set results as component attributes, and call to render
const timeChartData = { labels: timeData, datasets };
const aggregateChartData = { labels: labels.slice(1), datasets: [{ values: averages }] };
this.renderCharts(timeChartData, aggregateChartData);
},
renderCharts(timeChartData, aggregateChartData) {
this.generateHistoryChart(timeChartData);
this.generateAggregateChart(aggregateChartData);
},
/* Create new chart, using the crypto data */
generateHistoryChart(timeChartData) {
return new this.Chart(`#${this.chartId}`, {
title: 'History',
data: timeChartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `${Math.round(d)}mb`,
},
});
},
generateAggregateChart(aggregateChartData) {
return new this.Chart(`#aggregate-${this.chartId}`, {
title: 'Averages',
data: aggregateChartData,
type: 'percentage',
height: 100,
colors: this.chartColors,
barOptions: {
height: 18,
depth: 5,
},
});
},
},
};
</script>
<style lang="scss">
.memory-charts-wrapper .chart {
text.title, text.legend-dataset-text {
text-transform: capitalize;
color: var(--widget-text-color);
}
.axis, .chart-label {
fill: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover { opacity: 1; }
}
}
</style>

View File

@ -0,0 +1,113 @@
<template>
<div class="news-wrapper" v-if="news">
<div class="article" v-for="article in news" :key="article.id">
<a class="headline" :href="article.url">{{ article.title }}</a>
<div class="article-meta">
<span class="publisher">{{ article.author }}</span>
<span class="date">{{ article.published | date }}</span>
</div>
<p class="description">{{ article.description }}</p>
<img class="thumbnail" v-if="article.image" :src="article.image" alt="Thumbnail" />
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { timestampToDate } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
news: null,
};
},
computed: {
apiKey() {
if (!this.options.apiKey) this.error('An API key is required, see docs for more info');
return this.options.apiKey;
},
country() {
return this.options.country ? `&country=${this.options.country}` : '';
},
category() {
return this.options.category ? `&category=${this.options.category}` : '';
},
lang() {
return this.options.lang ? `&language=${this.options.lang}` : '';
},
count() {
return this.options.count ? `&page_size=${this.options.count}` : '';
},
keywords() {
return this.options.keywords ? `&keywords=${this.options.keywords}` : '';
},
endpoint() {
return `${widgetApiEndpoints.news}?apiKey=${this.apiKey}`
+ `${this.country}${this.category}${this.lang}${this.count}`;
},
},
filters: {
date(date) {
return timestampToDate(date);
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
if (!response.data.news || response.data.news.length === 0) {
this.error('API didn\'t return any results for your query');
}
this.news = response.data.news;
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
},
};
</script>
<style scoped lang="scss">
.news-wrapper {
.article {
padding-bottom: 1rem;
a.headline {
color: var(--widget-text-color);
display: inline-block;
font-weight: bold;
font-size: 1.2rem;
padding: 0.5rem 0;
text-decoration: none;
&:hover { text-decoration: underline; }
}
p.description {
color: var(--widget-text-color);
}
img.thumbnail {
width: 100%;
max-width: 24rem;
display: flex;
margin: 0 auto;
border-radius: var(--curve-factor);
}
.article-meta {
display: flex;
justify-content: space-between;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
font-size: 0.8rem;
}
&:not(:last-child) { border-bottom: 1px dashed var(--widget-text-color); }
}
}
</style>

View File

@ -0,0 +1,164 @@
<template>
<div class="pi-hole-stats-wrapper">
<!-- Current Status -->
<div v-if="status" class="status">
<span class="status-lbl">{{ $t('widgets.pi-hole.status-heading') }}:</span>
<span :class="`status-val ${getStatusColor(status)}`">{{ status | capitalize }}</span>
</div>
<!-- Block Pie Chart -->
<p :id="chartId" class="block-pie"></p>
<!-- More Data -->
<div v-if="dataTable" class="data-table">
<div class="data-table-row" v-for="(row, inx) in dataTable" :key="inx" >
<p class="row-label">{{ row.lbl }}</p>
<p class="row-value">{{ row.val }}</p>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { capitalize } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
status: null,
dataTable: null,
blockPercentChart: null,
};
},
computed: {
/* Let user select which comic to display: random, latest or a specific number */
hostname() {
const usersChoice = this.options.hostname;
if (!usersChoice) this.error('You must specify the hostname for your Pi-Hole server');
return usersChoice || 'http://pi.hole';
},
endpoint() {
return `${this.hostname}/admin/api.php`;
},
hideStatus() { return this.options.hideStatus; },
hideChart() { return this.options.hideChart; },
hideInfo() { return this.options.hideInfo; },
},
filters: {
capitalize(str) {
return capitalize(str);
},
},
methods: {
/* Make GET request to local pi-hole instance */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
if (!this.hideStatus) {
this.status = data.status;
}
if (!this.hideInfo) {
this.dataTable = [
{ lbl: 'Active Clients', val: `${data.unique_clients}/${data.clients_ever_seen}` },
{ lbl: 'Ads Blocked Today', val: data.ads_blocked_today },
{ lbl: 'DNS Queries Today', val: data.dns_queries_today },
{ lbl: 'Total DNS Queries', val: data.dns_queries_all_types },
{ lbl: 'Domains on Block List', val: data.domains_being_blocked },
];
}
if (!this.hideChart) {
const blockedToday = Math.round(data.ads_percentage_today);
this.generateBlockPie(blockedToday);
}
},
getStatusColor(status) {
if (status === 'enabled') return 'green';
if (status === 'disabled') return 'red';
else return 'blue';
},
/* Generate pie chart showing the proportion of queries blocked */
generateBlockPie(blockedToday) {
const chartData = {
labels: ['Blocked', 'Allowed'],
datasets: [{
values: [blockedToday, 100 - blockedToday],
}],
};
return new this.Chart(`#${this.chartId}`, {
title: 'Block Percent',
data: chartData,
type: 'donut',
height: 250,
strokeWidth: 18,
colors: ['#f80363', '#20e253'],
tooltipOptions: {
formatTooltipY: d => `${Math.round(d)}%`,
},
});
},
},
};
</script>
<style scoped lang="scss">
.pi-hole-stats-wrapper {
display: flex;
flex-direction: column;
.status {
margin: 0.5rem 0;
.status-lbl {
color: var(--widget-text-color);
font-weight: bold;
}
.status-val {
margin-left: 0.5rem;
font-family: var(--font-monospace);
&.green { color: var(--success); }
&.red { color: var(--danger); }
&.blue { color: var(--info); }
}
}
img.block-percent-chart {
margin: 0.5rem auto;
max-width: 8rem;
width: 100%;
}
.block-pie {
margin: 0;
}
.data-table {
display: flex;
flex-direction: column;
.data-table-row {
display: flex;
justify-content: space-between;
p {
margin: 0.2rem 0;
color: var(--widget-text-color);
font-size: 0.9rem;
&.row-value {
font-family: var(--font-monospace);
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
}
</style>

View File

@ -0,0 +1,114 @@
<template>
<div class="pi-hole-queries-wrapper" v-if="results">
<div v-for="section in results" :key="section.id" class="query-section">
<p class="section-title">{{ section.title }}</p>
<div v-for="(query, i) in section.results" :key="i" class="query-row">
<p class="domain">{{ query.domain }}</p>
<p class="count">{{ query.count }}</p>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { showNumAsThousand } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
results: null,
};
},
computed: {
/* Let user select which comic to display: random, latest or a specific number */
hostname() {
const usersChoice = this.options.hostname;
if (!usersChoice) this.error('You must specify the hostname for your Pi-Hole server');
return usersChoice || 'http://pi.hole';
},
apiKey() {
if (!this.options.apiKey) this.error('API Key is required, please see the docs');
return this.options.apiKey;
},
count() {
const usersChoice = this.options.count;
if (usersChoice && typeof usersChoice === 'number') return usersChoice;
return 10;
},
endpoint() {
return `${this.hostname}/admin/api.php?topItems=${this.count}&auth=${this.apiKey}`;
},
},
methods: {
/* Make GET request to local pi-hole instance */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
if (Array.isArray(response.data)) {
this.error('Got success, but found no results, possible authorization error');
} else {
this.processData(response.data);
}
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const topAds = [];
Object.keys(data.top_ads).forEach((domain) => {
topAds.push({ domain, count: showNumAsThousand(data.top_ads[domain]) });
});
const topQueries = [];
Object.keys(data.top_queries).forEach((domain) => {
topQueries.push({ domain, count: showNumAsThousand(data.top_queries[domain]) });
});
this.results = [
{ id: '01', title: 'Top Ads Blocked', results: topAds },
{ id: '02', title: 'Top Queries', results: topQueries },
];
},
},
};
</script>
<style scoped lang="scss">
.pi-hole-queries-wrapper {
color: var(--widget-text-color);
.query-section {
display: inline-block;
width: 100%;
p.section-title {
margin: 0.75rem 0 0.25rem;
font-size: 1.2rem;
font-weight: bold;
}
.query-row {
display: flex;
justify-content: space-between;
margin: 0.25rem;
p.domain {
margin: 0.25rem 0;
overflow: hidden;
text-overflow: ellipsis;
}
p.count {
margin: 0.25rem 0;
font-family: var(--font-monospace);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
}
</style>

View File

@ -0,0 +1,86 @@
<template>
<div :id="chartId" class="pi-hole-traffic"></div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
status: null,
dataTable: null,
blockPercentChart: null,
};
},
computed: {
/* Let user select which comic to display: random, latest or a specific number */
hostname() {
const usersChoice = this.options.hostname;
if (!usersChoice) this.error('You must specify the hostname for your Pi-Hole server');
return usersChoice || 'http://pi.hole';
},
endpoint() {
return `${this.hostname}/admin/api.php?overTimeData10mins`;
},
},
methods: {
/* Make GET request to local pi-hole instance */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const timeData = [];
const domainsData = [];
Object.keys(data.domains_over_time).forEach((time) => {
timeData.push(this.formatTime(time * 1000));
domainsData.push(data.domains_over_time[time]);
});
const adsData = [];
Object.keys(data.ads_over_time).forEach((time) => {
adsData.push(data.ads_over_time[time]);
});
const chartData = {
labels: timeData,
datasets: [
{ name: 'Queries', type: 'bar', values: domainsData },
{ name: 'Ads Blocked', type: 'bar', values: adsData },
],
};
this.generateChart(chartData);
},
generateChart(chartData) {
return new this.Chart(`#${this.chartId}`, {
title: 'Recent Queries & Ads',
data: chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: ['#20e253', '#f80363'],
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
});
},
},
};
</script>

View File

@ -0,0 +1,121 @@
<template>
<div class="public-holidays-wrapper">
<div
v-for="(holiday, indx) in holidays"
:key="indx"
v-tooltip="tooltip(holiday)"
class="holiday-row"
>
<p class="holiday-date">{{ holiday.date }}</p>
<p class="holiday-name">{{ holiday.name }}</p>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { timestampToDate, capitalize } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
holidays: [],
};
},
computed: {
country() {
if (this.options.country) return this.options.country;
return navigator.language.split('-')[1] || 'GB';
},
holidayType() {
const options = ['all', 'public_holiday', 'observance',
'school_holiday', 'other_day', 'extra_working_day'];
const usersChoice = this.options.holidayType;
if (usersChoice && options.includes(usersChoice)) return usersChoice;
return 'public_holiday';
},
monthsToShow() {
const usersChoice = this.options.monthsToShow;
if (usersChoice && usersChoice > 0 && usersChoice <= 24) {
return usersChoice;
}
return 12;
},
startDate() {
const now = new Date();
return `${now.getDate()}-${now.getMonth()}-${now.getFullYear()}`;
},
endDate() {
const now = new Date();
const then = new Date((now.setMonth(now.getMonth() + this.monthsToShow)));
return `${then.getDate()}-${then.getMonth()}-${then.getFullYear()}`;
},
endpoint() {
return `${widgetApiEndpoints.holidays}`
+ `&fromDate=${this.startDate}&toDate=${this.endDate}`
+ `&country=${this.country}&holidayType=${this.holidayType}`;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch holiday data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(holidays) {
const results = [];
const makeDate = (date) => timestampToDate(
new Date(`${date.year}-${date.month}-${date.day}`).getTime(),
);
const formatType = (ht) => capitalize(ht.replaceAll('_', ' '));
holidays.forEach((holiday) => {
results.push({
name: holiday.name[0].text,
date: makeDate(holiday.date),
type: formatType(holiday.holidayType),
observed: holiday.observedOn ? makeDate(holiday.observedOn) : '',
});
});
this.holidays = results;
},
tooltip(holiday) {
const observed = holiday.observed ? `<br><b>Observed On</b>: ${holiday.observed}` : '';
const content = `<b>Type</b>: ${holiday.type}${observed}`;
return {
content, trigger: 'hover focus', html: true, delay: 250, classes: 'in-modal-tt',
};
},
},
};
</script>
<style scoped lang="scss">
.public-holidays-wrapper {
padding: 0.5rem 0;
.holiday-row {
display: flex;
justify-content: space-between;
p {
margin: 0.25rem 0;
color: var(--widget-text-color);
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<div class="ip-info-wrapper">
<p class="ip-address">{{ ipAddr }}</p>
<div class="region-wrapper" title="Open in Maps">
<img class="flag-image" :src="flagImg" alt="Flag" />
<div class="info-text">
<p class="isp-name">{{ ispName }}</p>
<a class="ip-location" :href="mapsUrl">{{ location }}</a>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { getCountryFlag, getMapUrl } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
ipAddr: null,
location: null,
ispName: null,
flagImg: null,
mapsUrl: null,
};
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(widgetApiEndpoints.publicIp)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch IP info', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(ipInfo) {
this.ipAddr = ipInfo.query;
this.ispName = ipInfo.isp;
this.location = `${ipInfo.city}, ${ipInfo.regionName}`;
this.flagImg = getCountryFlag(ipInfo.countryCode);
this.mapsUrl = getMapUrl({ lat: ipInfo.lat, lon: ipInfo.lon });
},
},
};
</script>
<style scoped lang="scss">
.ip-info-wrapper {
cursor: default;
p.ip-address {
font-size: 1.6rem;
margin: 0.5rem auto;
color: var(--widget-text-color);
font-family: var(--font-monospace);
}
.region-wrapper {
display: flex;
align-items: center;
img.flag-image {
width: 2rem;
border-radius: var(--curve-factor-small);
margin: 0.25rem 0.5rem 0 0;
}
a.ip-location {
font-size: 1rem;
margin: 0;
text-decoration: none;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover {
text-decoration: underline;
}
}
p.isp-name {
font-size: 1rem;
margin: 0.25rem 0 0 0;
color: var(--widget-text-color);
}
}
}
</style>

View File

@ -0,0 +1,229 @@
<template>
<div class="rss-wrapper">
<!-- Feed Meta Info -->
<a class="meta-container" v-if="meta" :href="meta.link" :title="meta.description">
<img class="feed-icon" :src="meta.image" v-if="meta.image" alt="Feed Image" />
<div class="feed-text">
<p class="feed-title">{{ meta.title }}</p>
<p class="feed-author" v-if="meta.author">By {{ meta.author }}</p>
</div>
</a>
<!-- Feed Content -->
<div class="post-wrapper" v-if="posts">
<div class="post-row" v-for="(post, indx) in posts" :key="indx">
<a class="post-top" :href="post.link">
<img class="post-img" :src="post.image" v-if="post.image" alt="Post Image">
<div class="post-title-wrap">
<p class="post-title">{{ post.title }}</p>
<p class="post-date">
{{ post.date | formatDate }} {{ post.author | formatAuthor }}
</p>
</div>
</a>
<div class="post-body" v-html="post.description"></div>
<a class="continue-reading-btn" :href="post.link">
{{ $t('widgets.general.open-link') }}
</a>
</div>
</div>
<!-- End Feed Content -->
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
meta: null,
posts: null,
};
},
computed: {
/* The URL to users atom-format RSS feed */
rssUrl() {
if (!this.options.rssUrl) this.error('Missing feed URL');
return encodeURIComponent(this.options.rssUrl || '');
},
apiKey() {
return this.options.apiKey;
},
limit() {
const usersChoice = this.options.limit;
if (usersChoice) return usersChoice;
return 10;
},
orderBy() {
const usersChoice = this.options.orderBy;
const options = ['title', 'pubDate', 'author'];
if (usersChoice && options.includes(usersChoice)) return usersChoice;
return 'pubDate';
},
orderDirection() {
const usersChoice = this.options.orderBy;
if (usersChoice && (usersChoice === 'desc' || usersChoice === 'asc')) return usersChoice;
return 'desc';
},
endpoint() {
const apiKey = this.apiKey ? `&api_key=${this.apiKey}` : '';
const limit = this.limit && this.apiKey ? `&count=${this.limit}` : '';
const orderBy = this.orderBy && this.apiKey ? `&order_by=${this.orderBy}` : '';
const direction = this.orderDirection ? `&order_dir=${this.orderDirection}` : '';
return `${widgetApiEndpoints.rssToJson}?rss_url=${this.rssUrl}`
+ `${apiKey}${limit}${orderBy}${direction}`;
},
},
filters: {
formatDate(timestamp) {
const localFormat = navigator.language;
const dateFormat = { weekday: 'short', day: 'numeric', month: 'short' };
return new Date(timestamp).toLocaleDateString(localFormat, dateFormat);
},
formatAuthor(author) {
return author ? `by ${author}` : '';
},
},
methods: {
/* Make GET request to Rss2Json */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((error) => {
this.error('Unable to RSS feed', error);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
const { feed, items } = data;
this.meta = {
title: feed.title,
link: feed.link,
author: feed.author,
description: feed.description,
image: feed.image,
};
const posts = [];
items.forEach((post) => {
posts.push({
title: post.title,
description: post.description,
image: post.thumbnail,
author: post.author,
date: post.pubDate,
link: post.link,
});
});
this.posts = posts;
},
},
};
</script>
<style scoped lang="scss">
.rss-wrapper {
.meta-container {
display: flex;
align-items: center;
text-decoration: none;
margin: 0.25rem 0 0.5rem 0;
p.feed-title {
margin: 0;
font-size: 1.2rem;
font-weight: bold;
color: var(--widget-text-color);
}
p.feed-author {
margin: 0;
font-size: 0.8rem;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
}
img.feed-icon {
border-radius: var(--curve-factor);
width: 2rem;
height: 2rem;
margin-right: 0.5rem;
}
}
.post-row {
border-top: 1px dashed var(--widget-text-color);
padding: 0.5rem 0 0.25rem 0;
.post-top {
display: flex;
align-items: center;
text-decoration: none;
p.post-title {
margin: 0;
font-size: 1rem;
font-weight: bold;
color: var(--widget-text-color);
}
p.post-date {
font-size: 0.8rem;
margin: 0;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
}
img.post-img {
border-radius: var(--curve-factor);
width: 2rem;
height: 2rem;
margin-right: 0.5rem;
}
}
.post-body {
font-size: 0.85rem;
color: var(--widget-text-color);
max-height: 400px;
overflow: hidden;
::v-deep p {
margin: 0.5rem 0;
}
::v-deep img {
max-width: 80%;
display: flex;
margin: 0 auto;
border-radius: var(--curve-factor);
}
::v-deep a {
color: var(--widget-text-color);
}
::v-deep svg path {
fill: var(--widget-text-color);
}
::v-deep blockquote {
margin-left: 0.5rem;
padding-left: 0.5rem;
border-left: 4px solid var(--widget-text-color);
}
::v-deep .avatar.avatar-user { display: none; }
}
a.continue-reading-btn {
width: 100%;
display: block;
font-size: 0.9rem;
text-align: right;
margin: 0 0 0.25rem;
padding: 0.1rem 0.25rem;
text-decoration: none;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
&:hover, &:focus {
opacity: 1;
text-decoration: underline;
}
}
}
}
</style>

View File

@ -0,0 +1,337 @@
<template>
<div class="sports-scores-wrapper" v-if="matches">
<!-- Show back to original button -->
<p v-if="whatToShow === 'team' && currentTeamId !== teamId"
@click="fetchTeamScores(teamId)" class="back-to-original">
Back to Original Team
</p>
<p v-else-if="whatToShow === 'league' && leagueId && currentLeagueId !== leagueId"
@click="fetchLeagueScores(leagueId)" class="back-to-original">
Back to Original League
</p>
<!-- Show toggle switch for past and future matches -->
<div class="past-or-future">
<span
:class="`btn ${whenToShow === 'past' ? 'selected' : ''}`"
v-tooltip="tooltip('View Recent Scores')"
@click="fetchPastFutureEvents('past')"
>
Past Scores
</span>
<span
:class="`btn ${whenToShow === 'future' ? 'selected' : ''}`"
v-tooltip="tooltip('View Upcoming Games')"
@click="fetchPastFutureEvents('future')"
>
Upcoming Games
</span>
</div>
<div class="match-row" v-for="match in matches" :key="match.id">
<!-- Banner Image -->
<div class="match-thumbnail-wrap">
<img :src="match.thumbnail" :alt="`${match.title} Banner Image`" class="match-thumbnail" />
</div>
<!-- Team Scores -->
<div class="score">
<div
:class="`score-block home ${currentTeamId !== match.home.id ? 'clickable' : ''}`"
v-tooltip="tooltip(`Click to view ${match.home.name} Scores`)"
@click="fetchTeamScores(match.home.id)"
>
<p class="team-score">{{ match.home.score }}</p>
<p class="team-name">{{ match.home.name }}</p>
<p class="team-location">Home</p>
</div>
<div class="colon">{{ match.home.score || match.away.score ? ':' : 'v' }}</div>
<div
class="score-block away clickable"
v-tooltip="tooltip(`Click to view ${match.away.name} Scores`)"
@click="fetchTeamScores(match.away.id)"
>
<p class="team-score">{{ match.away.score }}</p>
<p class="team-name">{{ match.away.name }}</p>
<p class="team-location">Away</p>
</div>
</div>
<!-- Match Meta Info -->
<div class="match-info">
<p class="status">{{ match.status }} </p>
<p class="league" @click="fetchLeagueScores(match.leagueId)">
{{ match.league }}, {{ match.season }}
</p>
<p>
<a :href="match.venue | mapsUrl">{{ match.venue }}</a>
on {{ match.date | formatDate }} ({{ match.time | formatTime }})</p>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { timestampToDate, getPlaceUrl } from '@/utils/MiscHelpers';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
data() {
return {
currentTeamId: null, // ID of the selected team
currentLeagueId: null, // ID of selected league
whenToShow: null, // Either 'past' or 'future'
whatToShow: null, // Either 'team' or 'league'
matches: null, // Array of matches returned
initiated: false, // Set to true once values set
};
},
computed: {
teamId() {
return this.options.teamId;
},
leagueId() {
return this.options.leagueId;
},
apiKey() {
return this.options.apiKey || '50130162';
},
limit() {
return this.options.limit || 20;
},
pastOrFuture() {
return this.options.pastOrFuture || 'past';
},
endpoint() {
this.initiate();
const endpoint = widgetApiEndpoints.sportsScores;
if (this.whatToShow === 'league' && this.whenToShow === 'future') {
return `${endpoint}/${this.apiKey}/eventsnextleague.php?id=${this.currentLeagueId}`;
} else if (this.whatToShow === 'league' && this.whenToShow === 'past') {
return `${endpoint}/${this.apiKey}/eventspastleague.php?id=${this.currentLeagueId}`;
} else if (this.whatToShow === 'team' && this.whenToShow === 'future') {
return `${endpoint}/${this.apiKey}/eventsnext.php?id=${this.currentTeamId}`;
} else if (this.whatToShow === 'team' && this.whenToShow === 'past') {
return `${endpoint}/${this.apiKey}/eventslast.php?id=${this.currentTeamId}`;
} else {
this.error('Missing team or league ID');
return '';
}
},
},
filters: {
formatDate(dateStr) {
return timestampToDate(dateStr);
},
formatTime(timeStr) {
if (!timeStr) return '';
return timeStr.slice(0, 5);
},
mapsUrl(placeName) {
return getPlaceUrl(placeName);
},
},
methods: {
initiate() {
if (!this.initiated) {
this.currentTeamId = this.teamId;
this.currentLeagueId = this.leagueId;
this.whenToShow = this.pastOrFuture;
this.whatToShow = this.teamOrLeague();
this.initiated = true;
}
},
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data.results || response.data.events);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
this.finishLoading();
})
.finally(() => {
this.finishLoading();
});
},
processData(data) {
const matches = [];
data.forEach((match) => {
matches.push({
id: match.idEvent,
sport: match.strSport,
title: match.strEvent,
league: match.strLeague,
leagueId: match.idLeague,
season: match.strSeason,
venue: match.strVenue,
date: match.dateEvent,
time: match.strTime,
status: match.strStatus,
thumbnail: match.strThumb,
home: {
id: match.idHomeTeam,
name: match.strHomeTeam,
score: match.intHomeScore,
},
away: {
id: match.idAwayTeam,
name: match.strAwayTeam,
score: match.intAwayScore,
},
});
});
this.matches = matches.slice(0, this.limit);
},
teamOrLeague() {
if (!this.currentTeamId && !this.currentLeagueId) {
this.error('You must specify either a teamId or leagueId');
}
if (this.currentTeamId) return 'team';
return 'league';
},
fetchTeamScores(teamId) {
if (teamId) {
this.whatToShow = 'team';
this.startLoading();
this.currentTeamId = teamId;
this.fetchData();
}
},
fetchLeagueScores(leagueId) {
if (leagueId) {
this.whatToShow = 'league';
this.startLoading();
this.currentLeagueId = leagueId;
this.fetchData();
}
},
fetchPastFutureEvents(pastOrFuture) {
this.startLoading();
this.whenToShow = pastOrFuture;
this.fetchData();
},
tooltip(content) {
return {
content, html: true, trigger: 'hover focus', delay: 250,
};
},
},
};
</script>
<style scoped lang="scss">
.sports-scores-wrapper {
p {
font-size: 1rem;
margin: 0.5rem auto;
color: var(--widget-text-color);
}
.match-row {
.match-thumbnail-wrap {
width: 80%;
max-height: 5rem;
display: flex;
border-radius: var(--curve-factor);
margin: 1rem auto 0.5rem auto;
overflow: hidden;
img.match-thumbnail {
width: 100%;
height: fit-content;
margin-top: -13%;
}
}
.score {
display: flex;
justify-content: space-around;
.score-block {
display: flex;
flex-direction: column;
min-width: 40%;
border: 1px solid transparent;
border-radius: var(--curve-factor);
p.team-score {
margin: 0.25rem auto;
font-size: 1.5rem;
font-weight: bold;
font-family: var(--font-monospace);
}
p.team-name {
text-align: center;
margin: 0;
}
p.team-location {
font-size: 0.8rem;
margin: 0 auto;
opacity: var(--dimming-factor);
}
&.clickable {
cursor: pointer;
&:hover {
border: 1px dashed var(--widget-text-color);
}
}
}
.colon {
margin: 0;
font-size: 2rem;
font-weight: bold;
color: var(--widget-text-color);
}
}
.match-info {
background: var(--widget-accent-color);
border-radius: var(--curve-factor);
padding: 0.25rem 0.5rem;
margin: 0.5rem auto 1rem auto;
p, a {
color: var(--widget-text-color);
opacity: var(--dimming-factor);
font-size: 0.8rem;
margin: 0;
&.status {
font-weight: bold;
}
&.league {
text-decoration: underline;
cursor: pointer;
}
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
p.back-to-original {
cursor: pointer;
font-size: 1rem;
padding: 0.1rem 0.25rem;
width: 100%;
color: var(--widget-text-color);
border-radius: var(--curve-factor);
text-decoration: underline;
text-align: left;
}
.past-or-future {
width: 100%;
color: var(--widget-text-color);
border-bottom: 1px dashed var(--widget-text-color);
padding: 0.5rem 0;
display: flex;
justify-content: space-evenly;
span.btn {
max-width: 50%;
cursor: pointer;
padding: 0.1rem 0.25rem;
border-radius: var(--curve-factor);
&.selected {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
&:hover {
font-weight: bold;
}
}
}
}
</style>

View File

@ -0,0 +1,203 @@
<template>
<div class="stat-ping-wrapper">
<div
class="service-row"
v-for="(service, indx) in services"
:key="indx"
v-tooltip="makeTooltip(service)"
>
<!-- Title -->
<p class="service-name">
{{ service.name }}:
<span v-if="service.online" class="status-online">
{{ $t('widgets.stat-ping.up') }}
</span>
<span v-else class="status-offline">
{{ $t('widgets.stat-ping.down') }}
</span>
</p>
<!-- Charts -->
<div class="charts">
<img
class="uptime-pie-chart" alt="24 Hour Uptime Chart"
:src="makeChartUrl(service.uptime24, '24 Hours')" />
<img class="uptime-pie-chart" alt="7 Day Uptime Chart"
:src="makeChartUrl(service.uptime7, '7 Days')" />
</div>
<!-- Info -->
<div class="info">
<div class="info-row">
<span class="lbl">Failed Pings</span>
<span class="val">{{ service.totalFailure }}/{{ service.totalSuccess }}</span>
</div>
<div class="info-row">
<span class="lbl">Last Success</span><span class="val">{{ service.lastSuccess }}</span>
</div>
<div class="info-row">
<span class="lbl">Last Failure</span><span class="val">{{ service.lastFailure }}</span>
</div>
<div class="info-row">
<span class="lbl">Avg Response Time</span>
<span class="val">{{ service.responseTime }} ms</span>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import { serviceEndpoints } from '@/utils/defaults';
import { showNumAsThousand } from '@/utils/MiscHelpers';
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
services: null,
};
},
computed: {
hostname() {
if (!this.options.hostname) this.error('A hostname is required');
return this.options.hostname;
},
limit() {
return this.options.limit;
},
proxyReqEndpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
return `${baseUrl}${serviceEndpoints.corsProxy}`;
},
endpoint() {
return `${this.hostname}/api/services`;
},
},
methods: {
fetchData() {
const requestConfig = {
method: 'GET',
url: this.proxyReqEndpoint,
headers: {
'Target-URL': this.endpoint,
},
};
axios.request(requestConfig)
.then((response) => {
this.processData(response.data);
}).catch((error) => {
this.error('Unable to fetch cron data', error);
}).finally(() => {
this.finishLoading();
});
},
makeChartUrl(uptime, title) {
const host = 'https://quickchart.io';
const chartId = 'zm-d3d5134f-5920-49d1-92ab-303aaaf8cb0b';
return `${host}/chart/render/${chartId}?data1=${uptime},${100 - uptime}&title=${title}`;
},
makeTooltip(service) {
const {
responseTime, totalFailure, totalSuccess, lastSuccess, lastFailure,
} = service;
const content = `<b>Failed Pings:</b> ${totalFailure}/${totalSuccess}<br>`
+ `<b>Response Time:</b> ${responseTime}ms<br>`
+ `<b>Last Success:</b> ${lastSuccess}<br>`
+ `<b>Last Failure:</b> ${lastFailure}`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'ping-times-tt',
};
},
getTimeAgo(dateTime) {
const now = new Date().getTime();
const then = new Date(dateTime).getTime();
if (then < 0) return 'Never';
const diff = (now - then) / 1000;
const divide = (time, round) => Math.round(time / round);
if (diff < 60) return `${divide(diff, 1)} seconds ago`;
if (diff < 3600) return `${divide(diff, 60)} minutes ago`;
if (diff < 86400) return `${divide(diff, 3600)} hours ago`;
if (diff < 604800) return `${divide(diff, 86400)} days ago`;
if (diff >= 604800) return `${divide(diff, 604800)} weeks ago`;
return 'unknown';
},
processData(data) {
let services = [];
data.forEach((service) => {
services.push({
name: service.name,
online: service.online,
uptime7: service.online_7_days,
uptime24: service.online_24_hours,
responseTime: Math.round(service.avg_response / 1000),
totalSuccess: showNumAsThousand(service.stats.hits),
totalFailure: showNumAsThousand(service.stats.failures),
lastSuccess: this.getTimeAgo(service.last_success),
lastFailure: this.getTimeAgo(service.last_error),
});
});
if (this.limit) services = services.slice(0, this.limit);
this.services = services;
},
},
};
</script>
<style scoped lang="scss">
.stat-ping-wrapper {
p {
color: var(--widget-text-color);
margin: 0.5rem 0;
}
.service-row {
p.service-name {
font-size: 1.2rem;
font-weight: bold;
span {
margin-left: 0.25rem;
font-family: var(--font-monospace);
&.status-online { color: var(--success); }
&.status-offline { color: var(--danger); }
}
}
.charts {
display: flex;
flex-direction: row;
justify-content: space-evenly;
img.uptime-pie-chart {
width: 35%;
margin: 0.5rem;
}
}
.info {
opacity: var(--dimming-factor);
margin: 1rem auto;
width: fit-content;
background: var(--background);
padding: 0.5rem;
border-radius: var(--curve-factor);
.info-row {
display: flex;
span {
color: var(--widget-text-color);
font-size: 0.8rem;
&.lbl {
font-weight: bold;
margin-right: 0.25rem;
min-width: 8rem;
}
&.val {
font-family: var(--font-monospace);
}
}
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>

View File

@ -0,0 +1,169 @@
<template>
<div class="crypto-price-chart" :id="chartId"></div>
</template>
<script>
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm';
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
chartData: null,
chartDom: null,
};
},
computed: {
/* The stock or share asset symbol to fetch data for */
stock() {
return this.options.stock;
},
/* The time interval between data points, in minutes */
interval() {
return `${(this.options.interval || 30)}min`;
},
/* The users API key for AlphaVantage */
apiKey() {
return this.options.apiKey;
},
/* The formatted GET request API endpoint to fetch stock data from */
endpoint() {
const func = 'TIME_SERIES_INTRADAY';
return `${widgetApiEndpoints.stockPriceChart}?function=${func}`
+ `&symbol=${this.stock}&interval=${this.interval}&apikey=${this.apiKey}`;
},
/* The number of data points to render on the chart */
dataPoints() {
const userChoice = this.options.dataPoints;
if (typeof usersChoice === 'number' && userChoice < 100 && userChoice > 5) {
return userChoice;
}
return 30;
},
/* A sudo-random ID for the chart DOM element */
chartId() {
return `stock-price-chart-${Math.round(Math.random() * 10000)}`;
},
/* Which price for each interval should be used (API requires in stupid format) */
priceTime() {
const usersChoice = this.options.priceTime || 'high';
switch (usersChoice) {
case ('open'): return '1. open';
case ('high'): return '2. high';
case ('low'): return '3. low';
case ('close'): return '4. close';
case ('volume'): return '5. volume';
default: return '2. high';
}
},
},
methods: {
/* Create new chart, using the crypto data */
generateChart() {
return new Chart(`#${this.chartId}`, {
title: `${this.stock} Price Chart`,
data: this.chartData,
type: 'axis-mixed',
height: this.chartHeight,
colors: this.chartColors,
truncateLegends: true,
lineOptions: {
regionFill: 1,
hideDots: 1,
},
axisOptions: {
xIsSeries: true,
xAxisMode: 'tick',
},
tooltipOptions: {
formatTooltipY: d => `$${d}`,
},
});
},
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
if (response.data.note) {
this.error('API Error', response.data.Note);
} else if (response.data['Error Message']) {
this.error('API Error', response.data['Error Message']);
} else {
this.processData(response.data);
}
})
.catch((error) => {
this.error('Unable to fetch stock price data', error);
})
.finally(() => {
this.finishLoading();
});
},
/* Convert data returned by API into a format that can be consumed by the chart
* To improve efficiency, only a certain amount of data points are plotted
*/
processData(data) {
const priceLabels = [];
const priceValues = [];
const dataKey = `Time Series (${this.interval})`;
const rawMarketData = data[dataKey];
const interval = Math.round(Object.keys(rawMarketData).length / this.dataPoints);
Object.keys(rawMarketData).forEach((timeGroup, index) => {
if (index % interval === 0) {
priceLabels.push(this.formatDate(timeGroup));
priceValues.push(this.formatPrice(rawMarketData[timeGroup][this.priceTime]));
}
});
// // Combine results with chart config
this.chartData = {
labels: priceLabels.reverse(),
datasets: [
{ name: `Price ${this.priceTime}`, type: 'bar', values: priceValues.reverse() },
],
};
// // Call chart render function
this.renderChart();
},
/* Uses class data to render the line chart */
renderChart() {
this.chartDom = this.generateChart();
},
/* Format the date for a given time stamp, also include time if required */
formatDate(timestamp) {
const localFormat = navigator.language;
const dateFormat = { weekday: 'short', day: 'numeric', month: 'short' };
return new Date(timestamp).toLocaleDateString(localFormat, dateFormat);
},
/* Format the price, rounding to given number of decimal places */
formatPrice(priceStr) {
const price = parseFloat(priceStr);
let numDecimals = 0;
if (price < 10) numDecimals = 1;
if (price < 1) numDecimals = 2;
if (price < 0.1) numDecimals = 3;
if (price < 0.01) numDecimals = 4;
if (price < 0.001) numDecimals = 5;
return price.toFixed(numDecimals);
},
},
};
</script>
<style lang="scss">
.crypto-price-chart .chart-container {
text.title {
text-transform: capitalize;
color: var(--widget-text-color);
}
.axis, .chart-label {
fill: var(--widget-text-color);
opacity: var(--dimming-factor);
&:hover { opacity: 1; }
}
}
</style>

View File

@ -0,0 +1,154 @@
<template>
<div class="system-info-wrapper">
<div class="some-info" v-if="info">
<p class="host">
{{ info.username | isUsername }}{{ info.hostname }}
</p>
<p class="system">
{{ info.system }} <span class="gap">|</span>
{{ $t('widgets.system-info.uptime') }}: {{ info.uptime | makeUptime }}
</p>
</div>
<div class="some-charts">
<div :id="`memory-${chartId}`" class="mem-chart"></div>
<div :id="`load-${chartId}`" class="load-chart"></div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import ChartingMixin from '@/mixins/ChartingMixin';
import { serviceEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin, ChartingMixin],
components: {},
data() {
return {
info: null,
};
},
computed: {
endpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
return `${baseUrl}${serviceEndpoints.systemInfo}`;
},
},
filters: {
isUsername(username) {
return username ? `${username}@` : '';
},
makeUptime(seconds) {
if (!seconds) return '';
if (seconds < 60) return `${seconds} seconds`;
if (seconds < 3600) return `${(seconds / 60).toFixed(1)} minutes`;
if (seconds < 86400) return `${(seconds / 3600).toFixed(2)} hours`;
if (seconds < 604800) return `${(seconds / 86400).toFixed(2)} days`;
if (seconds < 2629800) return `${(seconds / 604800).toFixed(2)} weeks`;
if (seconds < 31557600) return `${(seconds / 2629800).toFixed(2)} months`;
if (seconds >= 31557600) return `${(seconds / 31557600).toFixed(2)} years`;
return '';
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
if (!response.data.success) this.error('Error generating backend data');
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch system info', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
// Set class attributes for rendering
this.info = data.meta;
// Data for memory pie chart
const freeMem = parseInt(data.memory.freePercent, 10);
const memoryChartData = {
labels: ['Free', 'Used'],
datasets: [{
values: [freeMem, 100 - freeMem],
}],
};
this.generateMemoryPie(memoryChartData);
// Data for load bar chart
const loadBarChartData = {
labels: ['1 Min', '5 Mins', '15 Mins'],
datasets: [
{ values: [data.load.one, data.load.five, data.load.fifteen] },
],
};
this.generateLoadBar(loadBarChartData);
},
/* Using available memory info, generate simple pie chart */
generateMemoryPie(memoryChartData) {
return new this.Chart(`#memory-${this.chartId}`, {
title: 'Memory Usage',
data: memoryChartData,
type: 'donut',
height: 200,
strokeWidth: 12,
colors: ['#20e253', '#f80363'],
tooltipOptions: {
formatTooltipY: d => `${Math.round(d)}%`,
},
});
},
/* Using available load info, generate simple bar chart */
generateLoadBar(loadBarChartData) {
return new this.Chart(`#load-${this.chartId}`, {
title: 'Load Averages',
data: loadBarChartData,
type: 'bar',
height: 180,
colors: ['#04e4f4'],
barOptions: {
spaceRatio: 0.1,
},
});
},
},
};
</script>
<style lang="scss">
.system-info-wrapper {
color: var(--widget-text-color);
.some-info {
padding-bottom: 0.25rem;
border-bottom: 1px dashed var(--widget-text-color);
p.host {
font-size: 1.2rem;
margin: 0.25rem 0;
}
p.system {
font-size: 0.8rem;
margin: 0.25rem 0;
opacity: var(--dimming-factor);
}
span.gap {
margin: 0 0.4rem;
}
}
.some-charts {
display: flex;
.mem-chart, .load-chart {
width: 50%;
.chart-legend {
transform: translate(50px, 140px);
}
}
}
}
</style>

View File

@ -0,0 +1,172 @@
<template>
<div class="tfl-status">
<template v-if="lineStatuses">
<div v-for="line in filterLines" :key="line.index" class="line-row">
<p class="row name">{{ line.line }}</p>
<p :class="`row status ${getStatusColor(line.statusCode)}`">{{ line.status }}</p>
<p class="row disruption" v-if="line.disruption">{{ line.disruption | format }}</p>
</div>
<div v-if="!showAll" class="line-row">
<p class="row all-other">
{{
filterLines.length > 0 ?
$t('widgets.tfl-status.good-service-rest') :
$t('widgets.tfl-status.good-service-all')
}}
</p>
</div>
<p class="more-details-btn" @click="toggleAllLines">
{{ showAll ? $t('widgets.general.show-less') : $t('widgets.general.show-more') }}
</p>
</template>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
data() {
return {
lineStatuses: null,
showAll: false,
};
},
computed: {
/* Return only the lines without a good service, unless showing all */
filterLines() {
if (this.showAll) { return this.lineStatuses; }
return this.lineStatuses.filter((line) => line.statusCode !== 10);
},
},
filters: {
format(description) {
const parts = description.split(':');
return parts.length > 1 ? parts[1] : parts[0];
},
},
methods: {
/* Makes GET request to the TFL API */
fetchData() {
axios.get(widgetApiEndpoints.tflStatus)
.then((response) => {
this.lineStatuses = this.processData(response.data);
})
.catch(() => {
this.error('Unable to fetch data from TFL API');
})
.finally(() => {
this.finishLoading();
});
},
/* Processes the results to be rendered by the UI */
processData(data) {
let results = [];
data.forEach((line, index) => {
results.push({
index,
line: line.name,
statusCode: line.lineStatuses[0].statusSeverity,
status: line.lineStatuses[0].statusSeverityDescription,
disruption: line.lineStatuses[0].reason,
});
});
if (!this.options.sortAlphabetically) {
results = this.sortByStatusCode(results);
}
if (this.options.linesToShow && Array.isArray(this.options.linesToShow)) {
results = this.filterByLineName(results, this.options.linesToShow);
}
return results;
},
/* Get color, depending on the status code */
getStatusColor(code) {
if (code === 20) return 'dark'; // Strike action
if (code === 0) return 'info'; // Special service or upcoming planned works
if (code <= 6) return 'red'; // Closed, part-closed or severe delays
if (code <= 9) return 'orange'; // Minor delays, planned bus replacement
return 'green'; // Good Service - Everything is awesome!
},
/* If user only wants to see results from certain lines, filter the rest out */
filterByLineName(allLines, usersLines) {
const chosenLines = usersLines.map(name => name.toLowerCase());
const filtered = allLines.filter((line) => chosenLines.includes(line.line.toLowerCase()));
if (filtered.length < 1) {
this.error('No TFL lines match your filter');
return allLines;
}
return filtered;
},
/* Sort results in order of most-delayed first */
sortByStatusCode(lines) {
return lines.reverse().sort((a, b) => (a.statusCode > b.statusCode ? 1 : -1));
},
/* Toggle show/ hide all lines */
toggleAllLines() {
this.showAll = !this.showAll;
},
},
};
</script>
<style scoped lang="scss">
.tfl-status {
.line-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 0.5rem 0.25rem;
.row {
margin: 0.2rem 0;
}
.status {
font-weight: bold;
text-align: right;
&.green { color: var(--success); }
&.orange { color: var(--warning); }
&.red { color: var(--danger); }
&.info { color: var(--info); }
&.dark { color: #fa360f; }
}
.disruption {
opacity: var(--dimming-factor);
font-size: 0.85rem;
grid-column-start: span 2;
}
.all-other {
grid-column-start: span 2;
font-weight: bold;
text-align: center;
color: var(--success)
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
p {
color: var(--widget-text-color);
cursor: default;
margin: 0;
}
// Show more details button
.more-details-btn {
cursor: pointer;
text-align: center;
margin: 0.5rem 0.25rem 0.25rem;
padding: 0.1rem 0.25rem;
border: 1px solid transparent;
border-radius: var(--curve-factor);
&:hover {
border: 1px solid var(--widget-text-color);
}
&:focus, &:active {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
}
}
</style>

View File

@ -0,0 +1,226 @@
<template>
<div class="weather">
<!-- Icon + Temperature -->
<div class="intro">
<p class="temp">{{ temp }}</p>
<i :class="`owi owi-${icon}`"></i>
</div>
<!-- Weather description -->
<p class="description">{{ description }}</p>
<div class="details" v-if="showDetails && weatherDetails.length > 0">
<div class="info-wrap" v-for="(section, indx) in weatherDetails" :key="indx">
<p class="info-line" v-for="weather in section" :key="weather.label">
<span class="lbl">{{weather.label}}</span>
<span class="val">{{ weather.value }}</span>
</p>
</div>
</div>
<!-- Show/ hide toggle button -->
<p class="more-details-btn" @click="toggleDetails" v-if="weatherDetails.length > 0">
{{ showDetails ? $t('widgets.general.show-less') : $t('widgets.general.show-more') }}
</p>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
data() {
return {
loading: true,
icon: null,
description: null,
temp: null,
showDetails: false,
weatherDetails: [],
};
},
computed: {
units() {
return this.options.units || 'metric';
},
endpoint() {
const { apiKey, city } = this.options;
return `${widgetApiEndpoints.weather}?q=${city}&appid=${apiKey}&units=${this.units}`;
},
tempDisplayUnits() {
switch (this.units) {
case ('metric'): return '°C';
case ('imperial'): return '°F';
default: return '';
}
},
speedDisplayUnits() {
switch (this.units) {
case ('metric'): return 'm/s';
case ('imperial'): return 'mph';
default: return '';
}
},
},
methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.startLoading();
this.fetchWeather();
},
/* Adds units symbol to temperature, depending on metric or imperial */
processTemp(temp) {
return `${Math.round(temp)}${this.tempDisplayUnits}`;
},
/* Fetches the weather from OpenWeatherMap, and processes results */
fetchWeather() {
axios.get(this.endpoint)
.then((response) => {
this.loading = false;
const { data } = response;
this.icon = data.weather[0].icon;
this.description = data.weather[0].description;
this.temp = this.processTemp(data.main.temp);
if (!this.options.hideDetails) {
this.makeWeatherData(data);
}
})
.catch((error) => {
this.throwError('Failed to fetch weather', error);
})
.finally(() => {
this.finishLoading();
});
},
/* If showing additional info, then generate this data too */
makeWeatherData(data) {
this.weatherDetails = [
[
{ label: 'Min Temp', value: this.processTemp(data.main.temp_min) },
{ label: 'Max Temp', value: this.processTemp(data.main.temp_max) },
{ label: 'Feels Like', value: this.processTemp(data.main.feels_like) },
],
[
{ label: 'Pressure', value: `${data.main.pressure}hPa` },
{ label: 'Humidity', value: `${data.main.humidity}%` },
{ label: 'visibility', value: data.visibility },
{ label: 'wind', value: `${data.wind.speed}${this.speedDisplayUnits}` },
{ label: 'clouds', value: `${data.clouds.all}%` },
],
];
},
/* Show/ hide additional weather info */
toggleDetails() {
this.showDetails = !this.showDetails;
},
/* Validate input props, and print warning if incorrect */
checkProps() {
const ops = this.options;
let valid = true;
if (!ops.apiKey) {
this.throwError('Missing API key for OpenWeatherMap');
valid = false;
}
if (!ops.city) {
this.throwError('A city name is required to fetch weather');
valid = false;
}
if (ops.units && ops.units !== 'metric' && ops.units !== 'imperial') {
this.throwError('Invalid units specified, must be either \'metric\' or \'imperial\'');
valid = false;
}
return valid;
},
/* Just outputs an error message */
throwError(msg, error) {
this.error(msg, error);
},
},
created() {
if (this.checkProps()) {
this.fetchWeather();
}
},
};
</script>
<style scoped lang="scss">
@import '@/styles/weather-icons.scss';
.loader {
margin: 0 auto;
display: flex;
}
p {
color: var(--widget-text-color);
}
.weather {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
// Weather symbol and temperature
.intro {
grid-column-start: span 2;
display: flex;
justify-content: space-around;
.owi {
font-size: 3rem;
color: var(--widget-text-color);
margin: 0;
}
.temp {
font-size: 3rem;
margin: 0;
}
}
// Weather description
.description {
grid-column-start: 2;
text-transform: capitalize;
text-align: center;
margin: 0;
}
// Show more details button
.more-details-btn {
grid-column-start: span 2;
cursor: pointer;
font-size: 0.9rem;
text-align: center;
width: fit-content;
margin: 0.25rem auto;
padding: 0.1rem 0.25rem;
border: 1px solid transparent;
opacity: var(--dimming-factor);
border-radius: var(--curve-factor);
&:hover {
border: 1px solid var(--widget-text-color);
}
&:focus, &:active {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
}
// More weather details table
.details {
grid-column-start: span 2;
display: flex;
.info-wrap {
display: flex;
flex-direction: column;
width: 100%;
opacity: var(--dimming-factor);
p.info-line {
display: flex;
justify-content: space-between;
margin: 0.1rem 0.5rem;
padding: 0.1rem 0;
color: var(--widget-text-color);
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
}
}
</style>

View File

@ -0,0 +1,277 @@
<template>
<div class="weather-forecast">
<template v-if="weatherData.length > 0">
<!-- For each day, show the weather -->
<div
class="weather-day"
v-for="weather in weatherData"
:key="weather.index"
v-tooltip="tooltip(weather.description)"
@click="showMoreInfo(weather.info)"
>
<p class="date">{{ weather.date }}</p>
<p class="description">{{ weather.main }}</p>
<p class="temp">{{ weather.temp }}</p>
<i :class="`owi owi-${weather.icon}`"></i>
</div>
</template>
<!-- Show more details for a Clicked day -->
<div class="details" v-if="showDetails">
<div class="info-wrap" v-for="(section, indx) in moreInfo" :key="indx">
<p class="info-line" v-for="weather in section" :key="weather.label">
<span class="lbl">{{weather.label}}</span>
<span class="val">{{ weather.value }}</span>
</p>
</div>
</div>
<p class="more-details-btn" @click="toggleDetails" v-if="showDetails">
{{ $t('widgets.general.show-less') }}
</p>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
data() {
return {
loading: true,
showDetails: false,
weatherData: [],
moreInfo: [],
};
},
computed: {
units() {
return this.options.units || 'metric';
},
numDays() {
return this.options.numDays || 6;
},
endpoint() {
const { apiKey, city } = this.options;
const params = `?q=${city}&cnt=${this.numDays}&units=${this.units}&appid=${apiKey}`;
return `${widgetApiEndpoints.weatherForecast}${params}`;
},
tempDisplayUnits() {
switch (this.units) {
case ('metric'): return '°C';
case ('imperial'): return '°F';
default: return '';
}
},
speedDisplayUnits() {
switch (this.units) {
case ('metric'): return 'm/s';
case ('imperial'): return 'mph';
default: return '';
}
},
},
methods: {
/* Extends mixin, and updates data. Called by parent component */
update() {
this.startLoading();
this.fetchWeather();
},
/* Adds units symbol to temperature, depending on metric or imperial */
processTemp(temp) {
return `${Math.round(temp)}${this.tempDisplayUnits}`;
},
/* Convert timestamp to textual date, in users local format */
dateFromStamp(timestamp) {
const localFormat = navigator.language;
const dateFormat = { weekday: 'short', day: 'numeric', month: 'short' };
return new Date(timestamp * 1000).toLocaleDateString(localFormat, dateFormat);
},
/* Fetches the weather from OpenWeatherMap, and processes results */
fetchWeather() {
axios.get(this.endpoint)
.then((response) => {
if (response.data.list) {
this.processApiResults(response.data);
}
})
.catch((error) => {
this.error('Failed to fetch weather', error);
})
.finally(() => {
this.finishLoading();
});
},
/* Process the results from the Axios request */
processApiResults(dataList) {
const uiWeatherData = [];
dataList.list.forEach((day, index) => {
uiWeatherData.push({
index,
date: this.dateFromStamp(day.dt),
icon: day.weather[0].icon,
main: day.weather[0].main,
description: day.weather[0].description,
temp: this.processTemp(day.temp.day),
info: this.makeWeatherData(day),
});
});
this.weatherData = uiWeatherData;
},
/* Process additional data, needed when user clicks a given day */
makeWeatherData(data) {
return [
[
{ label: 'Min Temp', value: this.processTemp(data.temp.min) },
{ label: 'Max Temp', value: this.processTemp(data.temp.max) },
{ label: 'Feels Like', value: this.processTemp(data.feels_like.day) },
],
[
{ label: 'Pressure', value: `${data.pressure}hPa` },
{ label: 'Humidity', value: `${data.humidity}%` },
{ label: 'wind', value: `${data.speed}${this.speedDisplayUnits}` },
{ label: 'clouds', value: `${data.clouds}%` },
],
];
},
/* When a day is clicked, then show weather info on the UI */
showMoreInfo(moreInfo) {
this.moreInfo = moreInfo;
this.showDetails = true;
},
/* Show/ hide additional weather info */
toggleDetails() {
this.showDetails = !this.showDetails;
},
/* Display weather description and Click for more note on hover */
tooltip(text) {
const content = `${text.split(' ').map(
(word) => word[0].toUpperCase() + word.substring(1),
).join(' ')}\nClick for more Info`;
return { content, trigger: 'hover focus', delay: 250 };
},
/* Validate input props, and print warning if incorrect */
checkProps() {
const ops = this.options;
let valid = true;
if (!ops.apiKey) {
this.error('Missing API key for OpenWeatherMap');
valid = false;
}
if (!ops.city) {
this.error('A city name is required to fetch weather');
valid = false;
}
if (ops.units && ops.units !== 'metric' && ops.units !== 'imperial') {
this.error('Invalid units specified, must be either \'metric\' or \'imperial\'');
valid = false;
}
return valid;
},
},
/* When the widget loads, the props are checked, and weather fetched */
created() {
if (this.checkProps()) {
this.fetchWeather();
}
},
};
</script>
<style scoped lang="scss">
@import '@/styles/weather-icons.scss';
p {
color: var(--widget-text-color);
}
.weather-forecast {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
.weather-day {
display: grid;
gap: 0.25rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 30%;
min-width: 6rem;
cursor: pointer;
text-align: center;
padding: 0.5rem 0.25rem;
border: 1px solid transparent;
border-radius: var(--curve-factor);
&:hover {
border: 1px dashed var(--widget-text-color);
}
.date {
grid-column-start: span 2;
opacity: var(--dimming-factor);
margin: 0;
text-decoration: underline;
}
.description {
text-transform: capitalize;
text-align: center;
margin: 0;
}
.owi {
font-size: 1.4rem;
color: var(--widget-text-color);
margin: 0;
}
.temp {
font-size: 1.8rem;
grid-row-start: span 2;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
}
}
// Show more details button
.more-details-btn {
grid-column-start: span 2;
cursor: pointer;
font-size: 0.9rem;
text-align: center;
width: fit-content;
margin: 0.25rem auto;
padding: 0.1rem 0.25rem;
border: 1px solid transparent;
opacity: var(--dimming-factor);
border-radius: var(--curve-factor);
&:hover {
border: 1px solid var(--widget-text-color);
}
&:focus, &:active {
background: var(--widget-text-color);
color: var(--widget-background-color);
}
}
// More weather details table
.details {
width: 100%;
grid-column-start: span 2;
display: flex;
.info-wrap {
display: flex;
flex-direction: column;
width: 100%;
opacity: var(--dimming-factor);
p.info-line {
display: flex;
justify-content: space-between;
margin: 0.1rem 0.5rem;
padding: 0.1rem 0;
color: var(--widget-text-color);
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
}
}
</style>

View File

@ -0,0 +1,435 @@
<template>
<div :class="`widget-base ${ loading ? 'is-loading' : '' }`">
<!-- Update and Full-Page Action Buttons -->
<Button :click="update" class="action-btn update-btn" v-if="!error && !loading">
<UpdateIcon />
</Button>
<Button :click="fullScreenWidget" class="action-btn open-btn" v-if="!error && !loading">
<OpenIcon />
</Button>
<!-- Loading Spinner -->
<div v-if="loading" class="loading">
<LoadingAnimation v-if="loading" class="loader" />
</div>
<!-- Error Message Display -->
<div v-if="error" class="widget-error">
<p class="error-msg">An error occurred, see the logs for more info.</p>
<p class="error-output">{{ errorMsg }}</p>
</div>
<!-- Widget -->
<div v-else class="widget-wrap">
<Apod
v-if="widgetType === 'apod'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<Clock
v-else-if="widgetType === 'clock'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<CryptoPriceChart
v-else-if="widgetType === 'crypto-price-chart'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<CryptoWatchList
v-else-if="widgetType === 'crypto-watch-list'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<CveVulnerabilities
v-else-if="widgetType === 'cve-vulnerabilities'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<CodeStats
v-else-if="widgetType === 'code-stats'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<EmbedWidget
v-else-if="widgetType === 'embed'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<ExchangeRates
v-else-if="widgetType === 'exchange-rates'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<Flights
v-else-if="widgetType === 'flight-data'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GitHubTrending
v-else-if="widgetType === 'github-trending-repos'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<GitHubProfile
v-else-if="widgetType === 'github-profile-stats'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<HealthChecks
v-else-if="widgetType === 'health-checks'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<IframeWidget
v-else-if="widgetType === 'iframe'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<Jokes
v-else-if="widgetType === 'joke'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<NdCpuHistory
v-else-if="widgetType === 'nd-cpu-history'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<NdLoadHistory
v-else-if="widgetType === 'nd-load-history'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<NdRamHistory
v-else-if="widgetType === 'nd-ram-history'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<NewsHeadlines
v-else-if="widgetType === 'news-headlines'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<PiHoleStats
v-else-if="widgetType === 'pi-hole-stats'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<PiHoleTopQueries
v-else-if="widgetType === 'pi-hole-top-queries'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<PiHoleTraffic
v-else-if="widgetType === 'pi-hole-traffic'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<PublicHolidays
v-else-if="widgetType === 'public-holidays'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<PublicIp
v-else-if="widgetType === 'public-ip'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<RssFeed
v-else-if="widgetType === 'rss-feed'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<SportsScores
v-else-if="widgetType === 'sports-scores'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<StatPing
v-else-if="widgetType === 'stat-ping'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<StockPriceChart
v-else-if="widgetType === 'stock-price-chart'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<SystemInfo
v-else-if="widgetType === 'system-info'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<TflStatus
v-else-if="widgetType === 'tfl-status'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<XkcdComic
v-else-if="widgetType === 'xkcd-comic'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<Weather
v-else-if="widgetType === 'weather'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<WeatherForecast
v-else-if="widgetType === 'weather-forecast'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<!-- No widget type specified -->
<div v-else>{{ handleError('Widget type was not found') }}</div>
</div>
</div>
</template>
<script>
// Import form elements, icons and utils
import ErrorHandler from '@/utils/ErrorHandler';
import Button from '@/components/FormElements/Button';
import UpdateIcon from '@/assets/interface-icons/widget-update.svg';
import OpenIcon from '@/assets/interface-icons/open-new-tab.svg';
import LoadingAnimation from '@/assets/interface-icons/loader.svg';
export default {
name: 'Widget',
components: {
// Register form elements
Button,
UpdateIcon,
OpenIcon,
LoadingAnimation,
// Register widget components
Apod: () => import('@/components/Widgets/Apod.vue'),
Clock: () => import('@/components/Widgets/Clock.vue'),
CodeStats: () => import('@/components/Widgets/CodeStats.vue'),
CryptoPriceChart: () => import('@/components/Widgets/CryptoPriceChart.vue'),
CryptoWatchList: () => import('@/components/Widgets/CryptoWatchList.vue'),
CveVulnerabilities: () => import('@/components/Widgets/CveVulnerabilities.vue'),
EmbedWidget: () => import('@/components/Widgets/EmbedWidget.vue'),
ExchangeRates: () => import('@/components/Widgets/ExchangeRates.vue'),
Flights: () => import('@/components/Widgets/Flights.vue'),
GitHubTrending: () => import('@/components/Widgets/GitHubTrending.vue'),
GitHubProfile: () => import('@/components/Widgets/GitHubProfile.vue'),
HealthChecks: () => import('@/components/Widgets/HealthChecks.vue'),
IframeWidget: () => import('@/components/Widgets/IframeWidget.vue'),
Jokes: () => import('@/components/Widgets/Jokes.vue'),
NdCpuHistory: () => import('@/components/Widgets/NdCpuHistory.vue'),
NdLoadHistory: () => import('@/components/Widgets/NdLoadHistory.vue'),
NdRamHistory: () => import('@/components/Widgets/NdRamHistory.vue'),
NewsHeadlines: () => import('@/components/Widgets/NewsHeadlines.vue'),
PiHoleStats: () => import('@/components/Widgets/PiHoleStats.vue'),
PiHoleTopQueries: () => import('@/components/Widgets/PiHoleTopQueries.vue'),
PiHoleTraffic: () => import('@/components/Widgets/PiHoleTraffic.vue'),
PublicHolidays: () => import('@/components/Widgets/PublicHolidays.vue'),
PublicIp: () => import('@/components/Widgets/PublicIp.vue'),
RssFeed: () => import('@/components/Widgets/RssFeed.vue'),
SportsScores: () => import('@/components/Widgets/SportsScores.vue'),
StatPing: () => import('@/components/Widgets/StatPing.vue'),
StockPriceChart: () => import('@/components/Widgets/StockPriceChart.vue'),
SystemInfo: () => import('@/components/Widgets/SystemInfo.vue'),
TflStatus: () => import('@/components/Widgets/TflStatus.vue'),
Weather: () => import('@/components/Widgets/Weather.vue'),
WeatherForecast: () => import('@/components/Widgets/WeatherForecast.vue'),
XkcdComic: () => import('@/components/Widgets/XkcdComic.vue'),
},
props: {
widget: Object,
index: Number,
},
data: () => ({
loading: false,
error: false,
errorMsg: null,
updater: null, // Stores interval
}),
computed: {
/* Returns the widget type, shows error if not specified */
widgetType() {
if (!this.widget.type) {
ErrorHandler('Missing type attribute for widget');
return null;
}
return this.widget.type.toLowerCase();
},
/* Returns users specified widget options, or empty object */
widgetOptions() {
return this.widget.options || {};
},
/* A unique string to reference the widget by */
widgetRef() {
return `widget-${this.widgetType}-${this.index}`;
},
/* Returns either `false` or a number in ms to continuously update widget data */
updateInterval() {
const usersInterval = this.widget.updateInterval;
if (!usersInterval) return 0;
// If set to `true`, then default to 30 seconds
if (typeof usersInterval === 'boolean') return 30 * 1000;
// If set to a number, and within valid range, return user choice
if (typeof usersInterval === 'number'
&& usersInterval >= 10
&& usersInterval < 7200) {
return usersInterval * 1000;
}
return 0;
},
},
methods: {
/* Calls update data method on widget */
update() {
this.$refs[this.widgetRef].update();
},
/* Shows message when error occurred */
handleError(msg) {
this.error = true;
this.errorMsg = msg;
},
/* Opens current widget in full-page */
fullScreenWidget() {
this.$emit('navigateToSection');
},
/* Toggles loading state */
setLoaderState(loading) {
this.loading = loading;
},
},
mounted() {
// If continuous updates enabled, create interval
if (this.updateInterval) {
this.updater = setInterval(() => {
this.update();
}, this.updateInterval);
}
},
beforeDestroy() {
clearInterval(this.updater);
},
};
</script>
<style scoped lang="scss">
@import '@/styles/media-queries.scss';
.widget-base {
position: relative;
padding-top: 0.75rem;
// Refresh and full-page action buttons
button.action-btn {
height: 1rem;
min-width: auto;
width: 1.75rem;
margin: 0;
padding: 0.1rem 0;
position: absolute;
top: 0;
border: none;
opacity: var(--dimming-factor);
color: var(--widget-text-color);
&:hover {
opacity: 1;
color: var(--widget-background-color);
}
&.update-btn {
right: -0.25rem;
}
&.open-btn {
right: 1.75rem;
}
}
// Error message output
.widget-error {
p.error-msg {
color: var(--warning);
font-weight: bold;
font-size: 1rem;
margin: 0 auto 0.5rem auto;
}
p.error-output {
font-family: var(--font-monospace);
color: var(--widget-text-color);
font-size: 0.85rem;
margin: 0.5rem auto;
}
}
// Loading spinner
.loading {
margin: 0.2rem auto;
text-align: center;
svg.loader {
width: 100px;
}
}
// Hide widget contents while loading
&.is-loading {
.widget-wrap {
display: none;
}
}
}
</style>

View File

@ -0,0 +1,82 @@
<template>
<div class="xkcd-wrapper">
<h3 class="xkcd-title">{{ title }}</h3>
<a :href="`https://xkcd.com/${comicNum}/`">
<img :src="image" :alt="alt" class="xkcd-comic" />
</a>
</div>
</template>
<script>
import axios from 'axios';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
image: null,
title: '',
alt: '',
comicNum: '',
};
},
computed: {
/* Let user select which comic to display: random, latest or a specific number */
comicNumber() {
const usersChoice = this.options.comic;
if (!usersChoice) {
return 'latest';
} else if (usersChoice === 'random') {
return Math.abs(Math.floor(Math.random() * (1 - 2553)));
}
return usersChoice;
},
endpoint() {
return `${widgetApiEndpoints.xkcdComic}?comic=${this.comicNumber}`;
},
},
methods: {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
axios.get(this.endpoint)
.then((response) => {
this.processData(response.data);
})
.catch((dataFetchError) => {
this.error('Unable to fetch data', dataFetchError);
})
.finally(() => {
this.finishLoading();
});
},
/* Assign data variables to the returned data */
processData(data) {
this.image = data.img;
this.title = data.safe_title;
this.alt = data.alt;
this.comicNum = data.num;
},
},
};
</script>
<style scoped lang="scss">
.xkcd-wrapper {
.xkcd-title {
font-size: 1.2rem;
margin: 0.25rem auto;
color: var(--widget-text-color);
}
.xkcd-comic {
display: flex;
width: 100%;
max-width: 380px;
margin: 0.25rem auto;
border-radius: var(--curve-factor);
}
}
</style>

View File

@ -33,7 +33,10 @@ Vue.use(Toasted, toastedOptions);
Vue.component('v-select', VSelect);
Vue.directive('clickOutside', clickOutside);
Vue.config.productionTip = false; // Disable annoying console message
// When running in dev mode, enable Vue performance tools
const isDevMode = process.env.NODE_ENV === 'development';
Vue.config.performance = isDevMode;
Vue.config.productionTip = isDevMode;
// Setup i18n translations
const i18n = new VueI18n({

View File

@ -0,0 +1,46 @@
/**
* Mixin for helper functions, used for making chart widgets
*/
import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm';
const ChartingMixin = {
props: {},
computed: {
chartHeight() {
return this.options.chartHeight || 300;
},
chartColors() {
const ops = this.options;
if (ops.chartColor && typeof ops.chartColor === 'string') return [ops.chartColor];
if (ops.chartColors && Array.isArray(ops.chartColors)) return ops.chartColors;
const cssVars = getComputedStyle(document.documentElement);
return [cssVars.getPropertyValue('--widget-text-color').trim() || '#7cd6fd'];
},
chartId() {
return `widget-chart-${Math.round(Math.random() * 10000)}`;
},
},
data: () => ({
Chart,
}),
methods: {
/* Format the date for a given time stamp */
formatDate(timestamp) {
const localFormat = navigator.language;
const dateFormat = { weekday: 'short', day: 'numeric', month: 'short' };
return new Date(timestamp).toLocaleDateString(localFormat, dateFormat);
},
/* Format the time for a given time stamp */
formatTime(timestamp) {
const localFormat = navigator.language;
const timeFormat = { hour: 'numeric', minute: 'numeric', second: 'numeric' };
return Intl.DateTimeFormat(localFormat, timeFormat).format(timestamp);
},
/* Given an array of numbers, returns the average of all */
average(array) {
return array.reduce((a, b) => a + b) / array.length;
},
},
};
export default ChartingMixin;

120
src/mixins/HomeMixin.js Normal file
View File

@ -0,0 +1,120 @@
/**
* Mixin for all homepages (default home, minimal home, workspace, etc)
*/
import Defaults, { localStorageKeys, iconCdns } from '@/utils/defaults';
import { searchTiles } from '@/utils/Search';
const HomeMixin = {
props: {},
computed: {
sections() {
return this.$store.getters.sections;
},
appConfig() {
return this.$store.getters.appConfig;
},
pageInfo() {
return this.$store.getters.pageInfo;
},
isEditMode() {
return this.$store.state.editMode;
},
modalOpen() {
return this.$store.state.modalOpen;
},
},
data: () => ({
searchValue: '',
}),
methods: {
updateModalVisibility(modalState) {
this.$store.commit('SET_MODAL_OPEN', modalState);
},
/* Updates local data with search value, triggered from filter comp */
searching(searchValue) {
this.searchValue = searchValue || '';
},
/* Returns true if there is one or more sections in the config */
checkTheresData(sections) {
const localSections = localStorage[localStorageKeys.CONF_SECTIONS];
return (sections && sections.length >= 1) || (localSections && localSections.length >= 1);
},
/* Returns only the tiles that match the users search query */
filterTiles(allTiles) {
if (!allTiles) return [];
return searchTiles(allTiles, this.searchValue);
},
/* Checks if any sections or items use icons from a given CDN */
checkIfIconLibraryNeeded(prefix) {
if (!this.sections) return false;
let isNeeded = false; // Will be set to true if prefix found in icon name
this.sections.forEach((section) => {
if (section && section.icon && section.icon.includes(prefix)) isNeeded = true;
if (section && section.items) {
section.items.forEach((item) => {
if (item.icon && item.icon.includes(prefix)) isNeeded = true;
});
}
});
return isNeeded;
},
/* Checks if any of the icons are Font Awesome glyphs */
checkIfFontAwesomeNeeded() {
let isNeeded = this.checkIfIconLibraryNeeded('fa-');
const currentTheme = localStorage[localStorageKeys.THEME]; // Some themes require FA
if (['material', 'material-dark'].includes(currentTheme)) isNeeded = true;
return isNeeded;
},
/* Injects font-awesome's script tag, only if needed */
initiateFontAwesome() {
if (this.appConfig.enableFontAwesome || this.checkIfFontAwesomeNeeded()) {
const fontAwesomeScript = document.createElement('script');
const faKey = this.appConfig.fontAwesomeKey || Defaults.fontAwesomeKey;
fontAwesomeScript.setAttribute('src', `${iconCdns.fa}/${faKey}.js`);
document.head.appendChild(fontAwesomeScript);
}
},
/* Checks if any of the icons are Material Design Icons */
checkIfMdiNeeded() {
return this.checkIfIconLibraryNeeded('mdi-');
},
/* Injects Material Design Icons, only if needed */
initiateMaterialDesignIcons() {
if (this.checkIfMdiNeeded()) {
const mdiStylesheet = document.createElement('link');
mdiStylesheet.setAttribute('rel', 'stylesheet');
mdiStylesheet.setAttribute('href', iconCdns.mdi);
document.head.appendChild(mdiStylesheet);
}
},
/* Returns true if there is more than 1 sub-result visible during searching */
checkIfResults() {
if (!this.sections) return false;
else {
let itemsFound = true;
this.sections.forEach((section) => {
if (section.widgets && section.widgets.length > 0) itemsFound = false;
if (this.filterTiles(section.items, this.searchValue).length > 0) itemsFound = false;
});
return itemsFound;
}
},
/* If user has a background image, then generate CSS attributes */
getBackgroundImage() {
if (this.appConfig && this.appConfig.backgroundImg) {
return `background: url('${this.appConfig.backgroundImg}');background-size:cover;`;
}
return '';
},
/* Extracts the site name from domain, used for the searching functionality */
getDomainFromUrl(url) {
if (!url) return '';
const urlPattern = /^(?:https?:\/\/)?(?:w{3}\.)?([a-z\d.-]+)\.(?:[a-z.]{2,10})(?:[/\w.-]*)*/;
const domainPattern = url.match(urlPattern);
return domainPattern ? domainPattern[1] : '';
},
},
};
export default HomeMixin;

53
src/mixins/WidgetMixin.js Normal file
View File

@ -0,0 +1,53 @@
/**
* Mixin that all pre-built and custom widgets extend from.
* Manages loading state, error handling, data updates and user options
*/
import ProgressBar from 'rsup-progress';
import ErrorHandler from '@/utils/ErrorHandler';
const WidgetMixin = {
props: {
options: {
type: Object,
default: {},
},
},
data: () => ({
progress: new ProgressBar({ color: 'var(--progress-bar)' }),
}),
/* When component mounted, fetch initial data */
mounted() {
this.fetchData();
},
methods: {
/* Re-fetches external data, called by parent. Usually overridden by widget */
update() {
this.startLoading();
this.fetchData();
},
/* Called when an error occurs. Logs to handler, and passes to parent component */
error(msg, stackTrace) {
ErrorHandler(msg, stackTrace);
this.$emit('error', msg);
},
/* When a data request update starts, show loader */
startLoading() {
this.$emit('loading', true);
this.progress.start();
},
/* When a data request finishes, hide loader */
finishLoading() {
this.$emit('loading', false);
setTimeout(() => { this.progress.end(); }, 500);
},
/* Overridden by child component. Will make network request, then end loader */
fetchData() {
this.finishLoading();
},
tooltip(content) {
return { content, trigger: 'hover focus', delay: 250 };
},
},
};
export default WidgetMixin;

View File

@ -4,7 +4,7 @@ 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 { applyItemId } from '@/utils/SectionHelpers';
import filterUserSections from '@/utils/CheckSectionVisibility';
import { InfoHandler, InfoKeys } from '@/utils/ErrorHandler';
@ -51,12 +51,12 @@ 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 || []);
},
theme(state) {
return state.config.appConfig.theme;
},
webSearch(state, getters) {
return getters.appConfig.webSearch || {};
},

View File

@ -1,123 +1,128 @@
@import '@/styles/media-queries.scss';
:root {
/* Basic*/
--primary: #5cabca; // Main accent color
--background: #0b1021; // Page background
--background-darker: #05070e; // Used for navigation bar, footer and fills
/* Action Colors */
--info: #04e4f4;
--success: #20e253;
--warning: #f6f000;
--danger: #f80363;
--neutral: #272f4d;
--white: #fff;
--black: #000;
/* Modified Colors */
--item-group-background: #0b1021cc;
--medium-grey: #5e6474;
--item-background: #607d8b33;
--item-background-hover: #607d8b4d;
/* Semi-Transparent Black*/
--transparent-70: #000000b3;
--transparent-50: #00000080;
--transparent-30: #0000004d;
/* Semi-Transparent White*/
--transparent-white-70: #ffffffb3;
--transparent-white-50: #ffffff80;
--transparent-white-30: #ffffff4d;
/* Color variables for specific components
* all variables are optional, since they inherit initial values from above*
* Using specific variables makes overriding for custom themes really easy */
--heading-text-color: var(--primary);
// Nav-bar links
--nav-link-text-color: var(--primary);
--nav-link-background-color: #607d8b33;
--nav-link-text-color-hover: var(--primary);
--nav-link-background-color-hover: #607d8b33;
--nav-link-border-color: transparent;
--nav-link-border-color-hover: var(--primary);
--nav-link-shadow: 1px 1px 2px #232323;
--nav-link-shadow-hover: 1px 1px 2px #232323;
// Link items and sections
--item-text-color: var(--primary);
--item-text-color-hover: var(--item-text-color);
--item-group-outer-background: var(--primary);
--item-group-heading-text-color: var(--item-group-background);
--item-group-heading-text-color-hover: var(--background);
// Homepage settings
--settings-text-color: var(--primary);
--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);
--search-label-color: var(--settings-text-color);
// Page footer
--footer-text-color: var(--medium-grey);
--footer-text-color-link: var(--primary);
--footer-background: var(--background-darker);
// Right-click context menu
--context-menu-background: var(--background);
--context-menu-color: var(--primary);
--context-menu-secondary-color: var(--background-darker);
// Workspace view
--side-bar-background: var(--background-darker);
--side-bar-background-lighter: var(--background);
--side-bar-color: var(--primary);
--side-bar-item-background: var(--side-bar-background);
--side-bar-item-color: var(--side-bar-color);
// Minimal view
--minimal-view-background-color: var(--background);
--minimal-view-title-color: var(--primary);
--minimal-view-settings-color: var(--primary);
--minimal-view-section-heading-color: var(--primary);
--minimal-view-section-heading-background: var(--background-darker);
--minimal-view-search-background: var(--background-darker);
--minimal-view-search-color: var(--primary);
--minimal-view-group-color: var(--primary);
--minimal-view-group-background: var(--background-darker);
// Login page
--login-form-color: var(--primary);
--login-form-background: var(--background);
--login-form-background-secondary: var(--background-darker);
// About page
--about-page-color: var(--white);
--about-page-background: var(--background);
--about-page-accent: var(--primary);
// Webpage colors, highlight, scrollbar
--scroll-bar-color: var(--primary);
--scroll-bar-background: var(--background-darker);
--highlight-color: var(--background);
--highlight-background: var(--primary);
--progress-bar: var(--primary);
// Misc components
--loading-screen-color: var(--primary);
--loading-screen-background: var(--background);
--status-check-tooltip-background: var(--background-darker);
--status-check-tooltip-color: var(--primary);
--welcome-popup-background: var(--background-darker);
--welcome-popup-text-color: var(--primary);
--toast-background: var(--primary);
--toast-color: var(--background);
--description-tooltip-background: var(--background-darker);
--description-tooltip-color: var(--primary);
}
@import '@/styles/media-queries.scss';
:root {
/* Basic*/
--primary: #5cabca; // Main accent color
--background: #0b1021; // Page background
--background-darker: #05070e; // Used for navigation bar, footer and fills
/* Action Colors */
--info: #04e4f4;
--success: #20e253;
--warning: #f6f000;
--error: #fca016;
--danger: #f80363;
--neutral: #272f4d;
--white: #fff;
--black: #000;
/* Modified Colors */
--item-group-background: #0b1021cc;
--medium-grey: #5e6474;
--item-background: #607d8b33;
--item-background-hover: #607d8b4d;
/* Semi-Transparent Black*/
--transparent-70: #000000b3;
--transparent-50: #00000080;
--transparent-30: #0000004d;
/* Semi-Transparent White*/
--transparent-white-70: #ffffffb3;
--transparent-white-50: #ffffff80;
--transparent-white-30: #ffffff4d;
/* Color variables for specific components
* all variables are optional, since they inherit initial values from above*
* Using specific variables makes overriding for custom themes really easy */
--heading-text-color: var(--primary);
// Nav-bar links
--nav-link-text-color: var(--primary);
--nav-link-background-color: #607d8b33;
--nav-link-text-color-hover: var(--primary);
--nav-link-background-color-hover: #607d8b33;
--nav-link-border-color: transparent;
--nav-link-border-color-hover: var(--primary);
--nav-link-shadow: 1px 1px 2px #232323;
--nav-link-shadow-hover: 1px 1px 2px #232323;
// Link items and sections
--item-text-color: var(--primary);
--item-text-color-hover: var(--item-text-color);
--item-group-outer-background: var(--primary);
--item-group-heading-text-color: var(--item-group-background);
--item-group-heading-text-color-hover: var(--background);
// Homepage settings
--settings-text-color: var(--primary);
--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: var(--white);
--code-editor-color: var(--black);
--code-editor-background: var(--white);
// Widgets
--widget-text-color: var(--primary);
--widget-background-color: var(--background-darker);
--widget-accent-color: var(--background);
// 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);
--search-label-color: var(--settings-text-color);
// Page footer
--footer-text-color: var(--medium-grey);
--footer-text-color-link: var(--primary);
--footer-background: var(--background-darker);
// Right-click context menu
--context-menu-background: var(--background);
--context-menu-color: var(--primary);
--context-menu-secondary-color: var(--background-darker);
// Workspace view
--side-bar-background: var(--background-darker);
--side-bar-background-lighter: var(--background);
--side-bar-color: var(--primary);
--side-bar-item-background: var(--side-bar-background);
--side-bar-item-color: var(--side-bar-color);
// Minimal view
--minimal-view-background-color: var(--background);
--minimal-view-title-color: var(--primary);
--minimal-view-settings-color: var(--primary);
--minimal-view-section-heading-color: var(--primary);
--minimal-view-section-heading-background: var(--background-darker);
--minimal-view-search-background: var(--background-darker);
--minimal-view-search-color: var(--primary);
--minimal-view-group-color: var(--primary);
--minimal-view-group-background: var(--background-darker);
// Login page
--login-form-color: var(--primary);
--login-form-background: var(--background);
--login-form-background-secondary: var(--background-darker);
// About page
--about-page-color: var(--white);
--about-page-background: var(--background);
--about-page-accent: var(--primary);
// Webpage colors, highlight, scrollbar
--scroll-bar-color: var(--primary);
--scroll-bar-background: var(--background-darker);
--highlight-color: var(--background);
--highlight-background: var(--primary);
--progress-bar: var(--primary);
// Misc components
--loading-screen-color: var(--primary);
--loading-screen-background: var(--background);
--status-check-tooltip-background: var(--background-darker);
--status-check-tooltip-color: var(--primary);
--welcome-popup-background: var(--background-darker);
--welcome-popup-text-color: var(--primary);
--toast-background: var(--primary);
--toast-color: var(--background);
--description-tooltip-background: var(--background-darker);
--description-tooltip-color: var(--primary);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
/**
Styles and weather icons for the Dashy weather widget
Based on https://github.com/isneezy/open-weather-icons
Licensed under MIT - Copyright (c) 2017 Ivan Vilanculo
**/
@font-face {
font-family: 'OpenWeatherIcons';
src: url('/widget-resources/WeatherIcons.woff2');
font-style: normal;
font-weight: 400;
}
i.owi {
display: inline-block;
transform: translate(0, 0);
text-rendering: auto;
font-family: OpenWeatherIcons;
font-style: normal;
font-size: inherit;
-webkit-font-smoothing: antialiased;
color: var(--primary);
&.owi-01d::before { content: "\ea01"; }
&.owi-01n::before { content: "\ea02"; }
&.owi-02d::before { content: "\ea04"; }
&.owi-02n::before { content: "\ea03"; }
&.owi-03d::before { content: "\ea05"; }
&.owi-03n::before { content: "\ea06"; }
&.owi-04d::before { content: "\ea07"; }
&.owi-04n::before { content: "\ea08"; }
&.owi-09d::before { content: "\ea09"; }
&.owi-09n::before { content: "\ea0a"; }
&.owi-10d::before { content: "\ea0b"; }
&.owi-10n::before { content: "\ea0c"; }
&.owi-11d::before { content: "\ea0d"; }
&.owi-11n::before { content: "\ea0e"; }
&.owi-13d::before { content: "\ea10"; }
&.owi-13n::before { content: "\ea12"; }
&.owi-50d::before { content: "\ea11"; }
&.owi-50n::before { content: "\ea13"; }
&.owi-1232n::before { content: "\ea0f"; }
}

View File

@ -13,7 +13,7 @@ import {
layout as defaultLayout,
} from '@/utils/defaults';
import ErrorHandler from '@/utils/ErrorHandler';
import { applyItemId } from '@/utils/MiscHelpers';
import { applyItemId } from '@/utils/SectionHelpers';
import conf from '../../public/conf.yml';
export default class ConfigAccumulator {

View File

@ -488,8 +488,7 @@
"title": "Items",
"type": "object",
"required": [
"name",
"items"
"name"
],
"additionalProperties": false,
"properties": {
@ -716,9 +715,30 @@
}
}
}
},
"widgets": {
"type": "array",
"items": {
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"title": "Widget Type",
"type": "string",
"description": "The type of widget to use, see docs for supported options"
},
"options": {
"title": "Widget Options",
"type": "object",
"description": "Configuration options for widget. Varies depending on widget type, see docs for all options"
}
}
}
}
}
}
}
}
}
}

View File

@ -1,4 +1,4 @@
/* eslint no-console: ["error", { allow: ["log", "info"] }] */
/* eslint no-console: ["error", { allow: ["log", "info", "warn"] }] */
/* Prints the app name and version, helpful for debugging */
export const welcomeMsg = () => {
@ -7,13 +7,16 @@ export const welcomeMsg = () => {
};
/* Prints warning message, usually when there is a configuration error */
export const warningMsg = (message) => {
export const warningMsg = (message, stack) => {
console.info(
`\n%c⚠ Warning ⚠️%c \n${message} \n\n%cThis is likely not an issue with Dashy, but rather your configuration. If you think it is a bug, please open a ticket on GitHub: https://git.io/JukXk`,
"color:#ceb73f; background: #ceb73f33; font-size:1.5rem; padding:0.15rem; margin: 1rem auto; font-family: Rockwell, Tahoma, 'Trebuchet MS', Helvetica; border: 2px solid #ceb73f; border-radius: 4px; font-weight: bold; text-shadow: 1px 1px 1px #000000bf;",
'font-weight: bold; font-size: 1rem;color: #ceb73f;',
"color: #ceb73f; font-size: 0.75rem; font-family: Tahoma, 'Trebuchet MS', Helvetica;",
);
if (stack) {
console.warn(`%cStack Trace%c\n${stack}`, 'font-weight: bold;', '');
}
};
/* Prints status message */

View File

@ -22,10 +22,13 @@ const appendToErrorLog = (msg) => {
* If error reporting is enabled, will also log the message to Sentry
* If you wish to use your own error logging service, put code for it here
*/
const ErrorHandler = function handler(msg) {
warningMsg(msg); // Print to console
appendToErrorLog(msg); // Save to local storage
Sentry.captureMessage(`[USER-WARN] ${msg}`); // Report to bug tracker (if enabled)
const ErrorHandler = function handler(msg, errorStack) {
// Print to console
warningMsg(msg, errorStack);
// Save to local storage
appendToErrorLog(msg);
// Report to bug tracker (if enabled)
Sentry.captureMessage(`[USER-WARN] ${msg}`);
};
/* Similar to error handler, but for recording general info */

View File

@ -1,7 +1,5 @@
import { hideFurnitureOn } from '@/utils/defaults';
/* Returns false if page furniture should be hidden on said route */
export const shouldBeVisible = (routeName) => !hideFurnitureOn.includes(routeName);
/* A collection of generic reusable functions for various string processing tasks */
/* eslint-disable arrow-body-style */
/* Very rudimentary hash function for generative icons */
export const asciiHash = (input) => {
@ -26,23 +24,131 @@ export const sanitize = (string) => {
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 a timestamp, returns formatted date, in local format */
export const timestampToDate = (timestamp) => {
const localFormat = navigator.language;
const dateFormat = {
weekday: 'short', day: 'numeric', month: 'short', year: '2-digit',
};
const date = new Date(timestamp).toLocaleDateString(localFormat, dateFormat);
return `${date}`;
};
/* 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;
/* Given a timestamp, returns formatted time in local format */
export const timestampToTime = (timestamp) => {
const localFormat = navigator.language;
const timeFormat = { hour: 'numeric', minute: 'numeric', second: 'numeric' };
return Intl.DateTimeFormat(localFormat, timeFormat).format(new Date(timestamp));
};
/* Given a timestamp, returns both human Date and Time */
export const timestampToDateTime = (timestamp) => {
return `${timestampToDate(timestamp)} at ${timestampToTime(timestamp)}`;
};
/* Given a 2-digit country code, return path to flag image from Flagpedia */
export const getCountryFlag = (countryCode, dimens) => {
const protocol = 'https';
const cdn = 'flagcdn.com';
const dimensions = dimens || '64x48';
const country = countryCode.toLowerCase();
const ext = 'png';
return `${protocol}://${cdn}/${dimensions}/${country}.${ext}`;
};
/* Given a currency code, return path to corresponding countries flag icon */
export const getCurrencyFlag = (currency) => {
const cdn = 'https://raw.githubusercontent.com/transferwise/currency-flags';
return `${cdn}/master/src/flags/${currency.toLowerCase()}.png`;
};
/* Given a Latitude & Longitude object, and optional zoom level, return link to OSM */
export const getMapUrl = (location, zoom) => {
return `https://www.openstreetmap.org/#map=${zoom || 10}/${location.lat}/${location.lon}`;
};
/* Given a place name, return a link to Google Maps search page */
export const getPlaceUrl = (placeName) => {
return `https://www.google.com/maps/search/${encodeURIComponent(placeName)}`;
};
/* Given a large number, will add commas to make more readable */
export const putCommasInBigNum = (bigNum) => {
const strNum = Number.isNaN(bigNum) ? bigNum : String(bigNum);
return strNum.replace(/\B(?=(?:\d{3})+(?!\d))/g, ',');
};
/* Given a large number, will convert 1000 into k for readability */
export const showNumAsThousand = (bigNum) => {
if (bigNum < 1000) return bigNum;
return `${Math.round(bigNum / 1000)}k`;
};
/* Capitalizes the first letter of each word within a string */
export const capitalize = (str) => {
return str.replace(/\w\S*/g, (w) => (w.replace(/^\w/, (c) => c.toUpperCase())));
};
/* Round price to appropriate number of decimals */
export const roundPrice = (price) => {
if (Number.isNaN(price)) return price;
let decimals;
if (price > 1000) decimals = 0;
else if (price > 1) decimals = 2;
else if (price > 0.1) decimals = 3;
else if (price > 0.01) decimals = 4;
else if (price <= 0.01) decimals = 5;
else decimals = 2;
return price.toFixed(decimals);
};
/* Cuts string off at given length, and adds an ellipse */
export const truncateStr = (str, len = 60, ellipse = '...') => {
return str.length > len + ellipse.length ? `${str.slice(0, len)}${ellipse}` : str;
};
/* Given a currency code, return the corresponding unicode symbol */
export const findCurrencySymbol = (currencyCode) => {
const code = currencyCode.toUpperCase().trim();
const currencies = {
USD: '$', // US Dollar
EUR: '€', // Euro
GBP: '£', // British Pound Sterling
AFN: '؋', // Afghan Afghani
ALL: 'Lek', // Albanian Lek
AUD: '$', // Australian Dollar
AWG: 'ƒ', // Aruban Guilder
BAM: 'KM', // Bosnian Mark
BWP: 'P', // Botswana Pula
CAD: '$', // Canadian Dollar
CNY: '¥', // Chinese Yuan Renminbi
CRC: '₡', // Costa Rican Colón
CRS: '₡', // Costa Rican Colon
CUP: '₱', // Cuban Peso
DKK: 'kr', // Danish Krone
HKD: '$', // Hong Kong Dollar
HUF: 'Ft', // Hungarian Forint
HRK: 'kn', // Croatian Kuna
ISK: 'kr', // Icelandic Krona
ILS: '₪', // Israeli New Sheqel
INR: '₹', // Indian Rupee
IRR: '﷼', // Iranian Rial
JPY: '¥', // Japanese Yen
KRW: '₩', // South Korean Won
LAK: '₭', // Laos Kip
NGN: '₦', // Nigerian Naira
NOK: 'kr', // Norwegian Krone
PHP: '₱', // Philippine Peso
PKR: '₨', // Pakistani Rupee
PLN: 'zł', // Polish Zloty
PYG: '₲', // Paraguayan Guarani
RUB: '₽', // Russian Ruble
THB: '฿', // Thai Baht
UAH: '₴', // Ukrainian Hryvnia
VND: '₫', // Vietnamese Dong
YER: '﷼', // Yemen Rial
ZWD: 'Z$', // Zimbabwean Dollar
};
if (currencies[code]) return currencies[code];
return `${code} `; // Symbol not found, return text code instead
};

View File

@ -0,0 +1,31 @@
/* Helper functions for Sections and Items */
import { hideFurnitureOn } from '@/utils/defaults';
/* Returns false if page furniture should be hidden on said route */
export const shouldBeVisible = (routeName) => !hideFurnitureOn.includes(routeName);
/* 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);
});
}
if (sec.widgets) {
sec.widgets.forEach((widget, widgetIdx) => {
sections[secIdx].widgets[widgetIdx].id = makeItemId(sec.name, widget.type, widgetIdx);
});
}
});
return sections;
};

View File

@ -27,6 +27,8 @@ module.exports = {
faviconApi: 'allesedv',
/* The default sort order for sections */
sortOrder: 'default',
/* If no 'target' specified, this is the default opening method */
openingMethod: 'newtab',
/* The page paths for each route within the app for the router */
routePaths: {
home: '/home',
@ -43,6 +45,8 @@ module.exports = {
statusCheck: '/status-check',
save: '/config-manager/save',
rebuild: '/config-manager/rebuild',
systemInfo: '/system-info',
corsProxy: '/cors-proxy',
},
/* List of built-in themes, to be displayed within the theme-switcher dropdown */
builtInThemes: [
@ -74,6 +78,18 @@ module.exports = {
'high-contrast-dark',
'high-contrast-light',
],
/* Default color options for the theme configurator swatches */
swatches: [
['#eb5cad', '#985ceb', '#5346f3', '#5c90eb'],
['#5cdfeb', '#00CCB4', '#5ceb8d', '#afeb5c'],
['#eff961', '#ebb75c', '#eb615c', '#eb2d6c'],
['#060913', '#141b33', '#1c2645', '#263256'],
['#2b2d42', '#1a535c', '#372424', '#312437'],
['#f5f5f5', '#d9d9d9', '#bfbfbf', '#9a9a9a'],
['#636363', '#363636', '#313941', '#0d0d0d'],
],
/* Which CSS variables to show in the first view of theme configurator */
mainCssVars: ['primary', 'background', 'background-darker'],
/* Which structural components should be visible by default */
visibleComponents: {
splashScreen: false,
@ -88,8 +104,6 @@ module.exports = {
'minimal',
'login',
'download',
'landing-page-minimal',
// '404',
],
/* Key names for local storage identifiers */
localStorageKeys: {
@ -101,6 +115,7 @@ module.exports = {
THEME: 'theme',
CUSTOM_COLORS: 'customColors',
CONF_SECTIONS: 'confSections',
CONF_WIDGETS: 'confSections',
PAGE_INFO: 'pageInfo',
APP_CONFIG: 'appConfig',
BACKUP_ID: 'backupId',
@ -137,17 +152,14 @@ module.exports = {
PAGE_INFO: 'pageInfo',
APP_CONFIG: 'appConfig',
SECTIONS: 'sections',
WIDGETS: 'widgets',
},
/* Which CSS variables to show in the first view of theme configurator */
mainCssVars: ['primary', 'background', 'background-darker'],
/* Amount of time to show splash screen, when enabled, in milliseconds */
splashScreenTime: 1900,
splashScreenTime: 1000,
/* Page meta-data, rendered in the header of each view */
metaTagData: [
{ name: 'description', content: 'A simple static homepage for you\'re server' },
],
/* If no 'target' specified, this is the default opening method */
openingMethod: 'newtab',
/* Default option for Toast messages */
toastedOptions: {
position: 'bottom-center',
@ -191,6 +203,31 @@ module.exports = {
localPath: './item-icons',
faviconName: 'favicon.ico',
homeLabIcons: 'https://raw.githubusercontent.com/WalkxCode/dashboard-icons/master/png/{icon}.png',
homeLabIconsFallback: 'https://raw.githubusercontent.com/NX211/homer-icons/master/png/{icon}.png',
},
/* API endpoints for widgets that need to fetch external data */
widgetApiEndpoints: {
astronomyPictureOfTheDay: 'https://apodapi.herokuapp.com/api',
codeStats: 'https://codestats.net/',
cryptoPrices: 'https://api.coingecko.com/api/v3/coins/',
cryptoWatchList: 'https://api.coingecko.com/api/v3/coins/markets/',
cveVulnerabilities: 'https://www.cvedetails.com/json-feed.php',
exchangeRates: 'https://v6.exchangerate-api.com/v6/',
flights: 'https://aerodatabox.p.rapidapi.com/flights/airports/icao/',
githubTrending: 'https://gh-trending-repos.herokuapp.com/',
healthChecks: 'https://healthchecks.io/api/v1/checks',
holidays: 'https://kayaposoft.com/enrico/json/v2.0/?action=getHolidaysForDateRange',
jokes: 'https://v2.jokeapi.dev/joke/',
news: 'https://api.currentsapi.services/v1/latest-news',
publicIp: 'http://ip-api.com/json',
readMeStats: 'https://github-readme-stats.vercel.app/api',
rssToJson: 'https://api.rss2json.com/v1/api.json',
sportsScores: 'https://www.thesportsdb.com/api/v1/json',
stockPriceChart: 'https://www.alphavantage.co/query',
tflStatus: 'https://api.tfl.gov.uk/line/mode/tube/status',
weather: 'https://api.openweathermap.org/data/2.5/weather',
weatherForecast: 'https://api.openweathermap.org/data/2.5/forecast/daily',
xkcdComic: 'https://xkcd.vercel.app/',
},
/* URLs for web search engines */
searchEngineUrls: {
@ -232,16 +269,6 @@ module.exports = {
'/so': 'stackoverflow',
'/wa': 'wolframalpha',
},
/* Available built-in colors for the theme builder */
swatches: [
['#eb5cad', '#985ceb', '#5346f3', '#5c90eb'],
['#5cdfeb', '#00CCB4', '#5ceb8d', '#afeb5c'],
['#eff961', '#ebb75c', '#eb615c', '#eb2d6c'],
['#060913', '#141b33', '#1c2645', '#263256'],
['#2b2d42', '#1a535c', '#372424', '#312437'],
['#f5f5f5', '#d9d9d9', '#bfbfbf', '#9a9a9a'],
['#636363', '#363636', '#313941', '#0d0d0d'],
],
/* Use your own self-hosted Sentry instance. Only used if error reporting is turned on */
sentryDsn: 'https://3138ea85f15a4fa883a5b27a4dc8ee28@o937511.ingest.sentry.io/5887934',
/* A JS enum for indicating the user state, when guest mode + authentication is enabled */

View File

@ -27,22 +27,25 @@
+ (singleSectionView ? 'single-section-view ' : '')
+ (this.colCount ? `col-count-${this.colCount} ` : '')"
>
<Section
v-for="(section, index) in filteredTiles"
:key="index"
:index="index"
:title="section.name"
:icon="section.icon || undefined"
:displayData="getDisplayData(section)"
:groupId="`section-${index}`"
:items="filterTiles(section.items, searchValue)"
:searchTerm="searchValue"
:itemSize="itemSizeBound"
@itemClicked="finishedSearching()"
@change-modal-visibility="updateModalVisibility"
:class="
(searchValue && filterTiles(section.items, searchValue).length === 0) ? 'no-results' : ''"
/>
<template v-for="(section, index) in filteredTiles">
<Section
:key="index"
:index="index"
:title="section.name"
:icon="section.icon || undefined"
:displayData="getDisplayData(section)"
:groupId="`section-${index}`"
:items="filterTiles(section.items, searchValue)"
:widgets="section.widgets"
:searchTerm="searchValue"
:itemSize="itemSizeBound"
@itemClicked="finishedSearching()"
@change-modal-visibility="updateModalVisibility"
:isWide="!!singleSectionView || layoutOrientation === 'horizontal'"
:class="
(searchValue && filterTiles(section.items, searchValue).length === 0) ? 'no-results' : ''"
/>
</template>
<!-- Show add new section button, in edit mode -->
<AddNewSection v-if="isEditMode" />
</div>
@ -58,20 +61,20 @@
</template>
<script>
import HomeMixin from '@/mixins/HomeMixin';
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 StoreKeys from '@/utils/StoreMutations';
import Defaults, { localStorageKeys, iconCdns, modalNames } from '@/utils/defaults';
import { localStorageKeys, modalNames } from '@/utils/defaults';
import ErrorHandler from '@/utils/ErrorHandler';
import BackIcon from '@/assets/interface-icons/back-arrow.svg';
export default {
name: 'home',
mixins: [HomeMixin],
components: {
SettingsContainer,
EditModeSaveMenu,
@ -81,30 +84,14 @@ export default {
BackIcon,
},
data: () => ({
searchValue: '',
layout: '',
itemSizeBound: '',
addNewSectionOpen: false,
}),
computed: {
sections() {
return this.$store.getters.sections;
},
appConfig() {
return this.$store.getters.appConfig;
},
pageInfo() {
return this.$store.getters.pageInfo;
},
modalOpen() {
return this.$store.state.modalOpen;
},
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;
@ -138,31 +125,14 @@ export default {
},
},
methods: {
/* Returns true if there is one or more sections in the config */
checkTheresData(sections) {
const localSections = localStorage[localStorageKeys.CONF_SECTIONS];
return (sections && sections.length >= 1) || (localSections && localSections.length >= 1);
},
/* Updates local data with search value, triggered from filter comp */
searching(searchValue) {
this.searchValue = searchValue || '';
},
/* Clears input field, once a searched item is opened */
finishedSearching() {
this.$refs.filterComp.clearFilterInput();
},
/* Returns only the tiles that match the users search query */
filterTiles(allTiles, searchTerm) {
return searchTiles(allTiles, searchTerm);
},
/* Returns optional section display preferences if available */
getDisplayData(section) {
return !section.displayData ? {} : section.displayData;
},
/* 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);
@ -204,65 +174,6 @@ export default {
availibleThemes.Default = '#';
return availibleThemes;
},
/* Checks if any sections or items use icons from a given CDN */
checkIfIconLibraryNeeded(prefix) {
let isNeeded = false;
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;
});
});
return isNeeded;
},
/* Checks if any of the icons are Font Awesome glyphs */
checkIfFontAwesomeNeeded() {
let isNeeded = this.checkIfIconLibraryNeeded('fa-');
const currentTheme = localStorage[localStorageKeys.THEME]; // Some themes require FA
if (['material', 'material-dark'].includes(currentTheme)) isNeeded = true;
return isNeeded;
},
/* Injects font-awesome's script tag, only if needed */
initiateFontAwesome() {
if (this.appConfig.enableFontAwesome || this.checkIfFontAwesomeNeeded()) {
const fontAwesomeScript = document.createElement('script');
const faKey = this.appConfig.fontAwesomeKey || Defaults.fontAwesomeKey;
fontAwesomeScript.setAttribute('src', `${iconCdns.fa}/${faKey}.js`);
document.head.appendChild(fontAwesomeScript);
}
},
/* Checks if any of the icons are Material Design Icons */
checkIfMdiNeeded() {
return this.checkIfIconLibraryNeeded('mdi-');
},
/* Injects Material Design Icons, only if needed */
initiateMaterialDesignIcons() {
if (this.checkIfMdiNeeded()) {
const mdiStylesheet = document.createElement('link');
mdiStylesheet.setAttribute('rel', 'stylesheet');
mdiStylesheet.setAttribute('href', iconCdns.mdi);
document.head.appendChild(mdiStylesheet);
}
},
/* Returns true if there is more than 1 sub-result visible during searching */
checkIfResults() {
if (!this.sections) return false;
else {
let itemsFound = true;
this.sections.forEach((section) => {
if (this.filterTiles(section.items, this.searchValue).length > 0) itemsFound = false;
});
return itemsFound;
}
},
/* If user has a background image, then generate CSS attributes */
getBackgroundImage() {
if (this.appConfig && this.appConfig.backgroundImg) {
return `background: url('${this.appConfig.backgroundImg}');background-size:cover;`;
}
return '';
},
},
mounted() {
this.initiateFontAwesome();

View File

@ -2,7 +2,7 @@
<div class="minimal-home" :style="getBackgroundImage() + setColumnCount()">
<!-- Buttons for config and home page -->
<div class="minimal-buttons">
<ConfigLauncher @modalChanged="modalChanged" class="config-launcher" />
<ConfigLauncher @modalChanged="updateModalVisibility" class="config-launcher" />
</div>
<!-- Page title and search bar -->
<div class="title-and-search">
@ -34,6 +34,7 @@
:icon="section.icon || undefined"
:groupId="`section-${index}`"
:items="filterTiles(section.items)"
:widgets="section.widgets"
:selected="selectedSection === index"
:showAll="!tabbedView"
itemSize="small"
@ -50,17 +51,17 @@
</template>
<script>
import HomeMixin from '@/mixins/HomeMixin';
import MinimalSection from '@/components/MinimalView/MinimalSection.vue';
import MinimalHeading from '@/components/MinimalView/MinimalHeading.vue';
import MinimalSearch from '@/components/MinimalView/MinimalSearch.vue';
import { GetTheme, ApplyLocalTheme, ApplyCustomVariables } from '@/utils/ThemeHelper';
import { searchTiles } from '@/utils/Search';
import Defaults, { localStorageKeys } from '@/utils/defaults';
import { localStorageKeys } from '@/utils/defaults';
import ConfigLauncher from '@/components/Settings/ConfigLauncher';
export default {
name: 'home',
mixins: [HomeMixin],
components: {
MinimalSection,
MinimalHeading,
@ -68,24 +69,11 @@ export default {
ConfigLauncher,
},
data: () => ({
searchValue: '',
layout: '',
modalOpen: false, // When true, keybindings are disabled
selectedSection: 0, // The index of currently selected section
tabbedView: true, // By default use tabs, when searching then show all instead
theme: GetTheme(),
}),
computed: {
sections() {
return this.$store.getters.sections;
},
appConfig() {
return this.$store.getters.appConfig;
},
pageInfo() {
return this.$store.getters.pageInfo;
},
},
watch: {
/* When the theme changes, then call the update method */
searchValue() {
@ -96,11 +84,6 @@ export default {
sectionSelected(index) {
this.selectedSection = index;
},
/* Returns true if there is one or more sections in the config */
checkTheresData(sections) {
const localSections = localStorage[localStorageKeys.CONF_SECTIONS];
return (sections && sections.length >= 1) || (localSections && localSections.length >= 1);
},
/* Returns sections from local storage if available, otherwise uses the conf.yml */
getSections(sections) {
// If the user has stored sections in local storage, return those
@ -112,58 +95,19 @@ export default {
// Otherwise, return the usuall data from conf.yml
return sections;
},
/* Updates local data with search value, triggered from filter comp */
searching(searchValue) {
this.searchValue = searchValue || '';
},
/* Clears input field, once a searched item is opened */
finishedSearching() {
this.$refs.filterComp.clearMinFilterInput();
},
/* Extracts the site name from domain, used for the searching functionality */
getDomainFromUrl(url) {
if (!url) return '';
const urlPattern = /^(?:https?:\/\/)?(?:w{3}\.)?([a-z\d.-]+)\.(?:[a-z.]{2,10})(?:[/\w.-]*)*/;
const domainPattern = url.match(urlPattern);
return domainPattern ? domainPattern[1] : '';
},
/* Returns only the tiles that match the users search query */
filterTiles(allTiles) {
if (!allTiles) return [];
return searchTiles(allTiles, this.searchValue);
},
/* Update data when modal is open (so that key bindings can be disabled) */
updateModalVisibility(modalState) {
this.modalOpen = modalState;
},
/* Checks if any of the icons are Font Awesome glyphs */
checkIfFontAwesomeNeeded() {
let isNeeded = false;
if (!this.sections) return false;
this.sections.forEach((section) => {
if (section.icon && section.icon.includes('fa-')) isNeeded = true;
section.items.forEach((item) => {
if (item.icon && item.icon.includes('fa-')) isNeeded = true;
});
});
return isNeeded;
},
/* Injects font-awesome's script tag, only if needed */
initiateFontAwesome() {
if (this.appConfig.enableFontAwesome || this.checkIfFontAwesomeNeeded()) {
const fontAwesomeScript = document.createElement('script');
const faKey = this.appConfig.fontAwesomeKey || Defaults.fontAwesomeKey;
fontAwesomeScript.setAttribute('src', `https://kit.fontawesome.com/${faKey}.js`);
document.head.appendChild(fontAwesomeScript);
}
},
/* Returns true if there is more than 1 sub-result visible during searching */
checkIfResults() {
if (!this.sections) return false;
else {
let itemsFound = true;
this.sections.forEach((section) => {
if (this.filterTiles(section.items).length > 0) itemsFound = false;
if (section.widgets || this.filterTiles(section.items).length > 0) {
itemsFound = false;
}
});
return itemsFound;
}
@ -186,12 +130,10 @@ export default {
ApplyCustomVariables(this.theme);
}
},
modalChanged(modalState) {
this.modalOpen = modalState;
},
},
mounted() {
this.initiateFontAwesome();
this.initiateMaterialDesignIcons();
this.applyTheme();
},
};

View File

@ -7,7 +7,7 @@
</template>
<script>
import HomeMixin from '@/mixins/HomeMixin';
import SideBar from '@/components/Workspace/SideBar';
import WebContent from '@/components/Workspace/WebContent';
import MultiTaskingWebComtent from '@/components/Workspace/MultiTaskingWebComtent';
@ -16,6 +16,7 @@ import { GetTheme, ApplyLocalTheme, ApplyCustomVariables } from '@/utils/ThemeHe
export default {
name: 'Workspace',
mixins: [HomeMixin],
data: () => ({
url: '',
GetTheme,

View File

@ -4,6 +4,9 @@
*/
const ProgressBarPlugin = require('progress-bar-webpack-plugin');
// Get app mode: production, development or test
const mode = process.env.NODE_ENV || 'production';
// Get current version
process.env.VUE_APP_VERSION = require('./package.json').version;
@ -22,7 +25,7 @@ const progressFormat = '\x1b[1m\x1b[36mBuilding Dashy\x1b[0m '
// Webpack Config
const configureWebpack = {
performance: { hints: false },
mode,
module: {
rules: [
{ test: /.svg$/, loader: 'vue-svg-loader' },

View File

@ -4732,6 +4732,11 @@ fragment-cache@^0.2.1:
dependencies:
map-cache "^0.2.2"
frappe-charts@^1.6.2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-1.6.2.tgz#4671a943a8606e5020180fa65c8ea1835c510baf"
integrity sha512-9TC3/+YVUi84yYoEbxFiSqu+1FQ5If/ydUNj6i8FRpwynd08t6a7RkS+IRJozAk6NfdL8/LVTTE1DUOjjKZZxg==
fresh@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"