mirror of https://github.com/Lissy93/dashy.git
🔀 Merge pull request #292 from Lissy93/FEATURE/advanced-item-targets
[FEATURE] Improved Item Opening Method Options Fixes #289
This commit is contained in:
commit
a42b9ee7c5
|
@ -1,5 +1,11 @@
|
|||
# Changelog
|
||||
|
||||
## ✨ 1.8.8 - Improved Item Targets [PR #292](https://github.com/Lissy93/dashy/pull/292)
|
||||
- Adds support for `_top` and `_parent` anchor targets on items, Re: #289
|
||||
- Adds `appConfig.defaultOpeningMethod` option to specify default target
|
||||
- Adds new icons to show items opening method on hover
|
||||
- Refactors target checking, updates item target docs and schema
|
||||
|
||||
## ⚡️ 1.8.7 - Bug Fixes and Improvements [PR #273](https://github.com/Lissy93/dashy/pull/273)
|
||||
- Clean URLs without the hash, now using history-mode routing
|
||||
- New initial main example conf.yml
|
||||
|
|
|
@ -304,6 +304,9 @@ One of the primary purposes of Dashy is to make launching commonly used apps and
|
|||
- `newtab` - The app will be launched in a new tab
|
||||
- `modal` - Launch app in a resizable/ movable popup modal on the current page
|
||||
- `workspace` - Changes to Workspace view, and launches app
|
||||
- `top` - Opens in the top-most browsing context, useful if your accessing Dashy through an iframe
|
||||
|
||||
You can also set the default opening method, which will be applied to all items that don't have a specified target, using `appConfig.defaultOpeningMethod`, to one of the above values.
|
||||
|
||||
Even if the target is not set (or is set to `sametab`), you can still launch any given app in an alternative method: Alt + Click will open the modal, and Ctrl + Click will open in a new tab. You can also right-click on any item to see all options (as seen in the screenshot below). This custom context menu can be disabled by setting `appConfig.disableContextMenu: true`.
|
||||
|
||||
|
|
|
@ -37,9 +37,12 @@ Dashy supports several different ways to launch your apps. The default opening m
|
|||
|
||||
- `sametab` - The app will be launched in the current tab
|
||||
- `newtab` - The app will be launched in a new tab
|
||||
- `top` - Opens in the top-most browsing context, useful if your accessing Dashy through an iframe
|
||||
- `modal` - Launch app in a resizable/ movable popup modal on the current page
|
||||
- `workspace` - Changes to Workspace view, and launches app
|
||||
|
||||
You can also set the default opening method, which will be applied to all items that don't have a specified target, using `appConfig.defaultOpeningMethod`, to one of the above values.
|
||||
|
||||
Even if the target is not set (or is set to `sametab`), you can still launch any given app in an alternative method: Alt + Click will open the modal, and Ctrl + Click will open in a new tab. You can also right-click on any item to see all options (as seen in the screenshot below). This custom context menu can be disabled by setting `appConfig.disableContextMenu: true`.
|
||||
|
||||
<p align="center">
|
||||
|
|
|
@ -74,6 +74,7 @@ Tips:
|
|||
--- | --- | --- | ---
|
||||
**`language`** | `string` | _Optional_ | The 2 (or 4-digit) [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language, e.g. `en` or `en-GB`. This must be a language that the app has already been [translated](https://github.com/Lissy93/dashy/tree/master/src/assets/locales) into. If your language is unavailable, Dashy will fallback to English. By default Dashy will attempt to auto-detect your language, although this may not work on some privacy browsers.
|
||||
**`startingView`** | `enum` | _Optional_ | Which page to load by default, and on the base page or domain root. You can still switch to different views from within the UI. Can be either `default`, `minimal` or `workspace`. Defaults to `default`
|
||||
**`defaultOpeningMethod`** | `enum` | _Optional_ | The default opening method for items, if no `target` is specified for a given item. Can be either `newtab`, `sametab`, `top`, `parent`, `modal` or `workspace`. Defaults to `newtab`
|
||||
**`statusCheck`** | `boolean` | _Optional_ | When set to `true`, Dashy will ping each of your services and display their status as a dot next to each item. This can be overridden by setting `statusCheck` under each item. Defaults to `false`
|
||||
**`statusCheckInterval`** | `boolean` | _Optional_ | The number of seconds between checks. If set to `0` then service will only be checked on initial page load, which is usually the desired functionality. If value is less than `10` you may experience a hit in performance. Defaults to `0`
|
||||
**`webSearch`** | `object` | _Optional_ | Configuration options for the web search feature, set your default search engine, opening method or disable web search. See [`webSearch`](#appconfigwebsearch-optional)
|
||||
|
@ -181,7 +182,7 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)**
|
|||
**`description`** | `string` | _Optional_ | Additional info about an item, which is shown in the tooltip on hover, or visible on large tiles
|
||||
**`url`** | `string` | Required | The URL / location of web address for when the item is clicked
|
||||
**`icon`** | `string` | _Optional_ | The icon for a given item. Can be a font-awesome icon, favicon, remote URL or local URL. See [`item.icon`](#sectionicon-and-sectionitemicon)
|
||||
**`target`** | `string` | _Optional_ | The opening method for when the item is clicked, either `newtab`, `sametab`, `modal` or `workspace`. Where `newtab` will open the link in a new tab, `sametab` will open it in the current tab, and `modal` will open a pop-up modal with the content displayed within that iframe. Note that for the iframe to load, you must have set the CORS headers to either allow `*` ot allow the domain that you are hosting Dashy on, for some websites and self-hosted services, this is already set.
|
||||
**`target`** | `string` | _Optional_ | The opening method for when the item is clicked, either `newtab`, `sametab`, `top`, `parent`, `modal` or `workspace`. Where `newtab` will open the link in a new tab, `sametab` will open it in the current tab, and `modal` will open a pop-up modal and `workspace` will open in the Workspace view. Defaults to `newtab`
|
||||
**`hotkey`** | `number` | _Optional_ | Give frequently opened applications a numeric hotkey, between `0 - 9`. You can then just press that key to launch that application.
|
||||
**`tags`** | `string[]` | _Optional_ | A list of tags, which can be used for improved search
|
||||
**`statusCheck`** | `boolean` | _Optional_ | When set to `true`, Dashy will ping the URL associated with the current service, and display its status as a dot next to the item. The value here will override `appConfig.statusCheck` so you can turn off or on checks for a given service. Defaults to `appConfig.statusCheck`, falls back to `false`
|
||||
|
|
|
@ -7,7 +7,7 @@ One of the primary purposes of Dashy is to allow you to quickly find and launch
|
|||
You can navigate through your items or search results using the keyboard. You can use <kbd>Tab</kbd> to cycle through results, and <kbd>Shift</kbd> + <kbd>Tab</kbd> to go backwards. Or use the arrow keys, <kbd>↑</kbd>, <kbd>→</kbd>, <kbd>↓</kbd> and <kbd>←</kbd>.
|
||||
|
||||
## Launching Apps
|
||||
You can launch a elected app by hitting <kbd>Enter</kbd>. This will open the app using your default opening method, specified in `target` (either `newtab`, `sametab`, `modal` or `workspace`). You can also use <kbd>Alt</kbd> + <kbd>Enter</kbd> to open the app in a pop-up modal, or <kbd>Ctrl</kbd> + <kbd>Enter</kbd> to open it in a new tab. For all available opening methods, just right-click on an item, to bring up the context menu.
|
||||
You can launch a elected app by hitting <kbd>Enter</kbd>. This will open the app using your default opening method, specified in `target` (either `newtab`, `sametab`, `modal`, `top` or `workspace`). You can also use <kbd>Alt</kbd> + <kbd>Enter</kbd> to open the app in a pop-up modal, or <kbd>Ctrl</kbd> + <kbd>Enter</kbd> to open it in a new tab. For all available opening methods, just right-click on an item, to bring up the context menu.
|
||||
|
||||
## Tags
|
||||
By default, items are filtered by the `title` attribute, as well as the hostname (extracted from `url`), the `provider` and `description`. If you need to find results based on text which isn't included in these attributes, then you can add `tags` to a given item.
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Dashy",
|
||||
"version": "1.8.7",
|
||||
"version": "1.8.8",
|
||||
"license": "MIT",
|
||||
"main": "server",
|
||||
"scripts": {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="level-up" class="svg-inline--fa fa-level-up fa-w-11" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="currentColor" d="M345.04 144l-136-136.901c-9.388-9.465-24.691-9.465-34.079 0L38.96 144c-9.307 9.384-9.277 24.526.069 33.872l22.056 22.056c9.619 9.619 25.301 9.329 34.557-.639L152 138.84V432H68.024a11.996 11.996 0 0 0-8.485 3.515l-56 56C-4.021 499.074 1.333 512 12.024 512H208c13.255 0 24-10.745 24-24V138.84l56.357 60.448c9.256 9.968 24.938 10.258 34.557.639l22.056-22.056c9.346-9.345 9.377-24.487.07-33.871z"></path></svg>
|
After Width: | Height: | Size: 627 B |
|
@ -0,0 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="box-open" class="svg-inline--fa fa-box-open fa-w-20" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M638.3 143.8L586.8 41c-4-8-12.1-9.5-16.7-8.9L320 64 69.8 32.1c-4.6-.6-12.6.9-16.6 8.9L1.7 143.8c-4.6 9.2.3 20.2 10.1 23L64 181.7V393c0 14.7 10 27.5 24.2 31l216.2 54.1c6 1.5 17.4 3.4 31 0L551.8 424c14.2-3.6 24.2-16.4 24.2-31V181.7l52.1-14.9c9.9-2.8 14.7-13.8 10.2-23zM86 82.6l154.8 19.7-41.2 68.3-138-39.4L86 82.6zm26 112.8l97.8 27.9c8 2.3 15.2-1.8 18.5-7.3L296 103.8v322.7l-184-46V195.4zm416 185.1l-184 46V103.8l67.7 112.3c3.3 5.5 10.6 9.6 18.5 7.3l97.8-27.9v185zm-87.7-209.9l-41.2-68.3L554 82.6l24.3 48.6-138 39.4z"></path></svg>
|
After Width: | Height: | Size: 751 B |
|
@ -1,20 +1 @@
|
|||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
data-prefix="far"
|
||||
data-icon="browser"
|
||||
class="svg-inline--fa fa-browser fa-w-16"
|
||||
role="img"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
transform = "rotate(-90 250 250)"
|
||||
fill="currentColor"
|
||||
d="M464 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5
|
||||
48-48V80c0-26.5-21.5-48-48-48zM48 92c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12
|
||||
12v24c0 6.6-5.4 12-12 12H60c-6.6 0-12-5.4-12-12V92zm416 334c0 3.3-2.7 6-6
|
||||
6H54c-3.3 0-6-2.7-6-6V168h416v258zm0-310c0 6.6-5.4 12-12 12H172c-6.6
|
||||
0-12-5.4-12-12V92c0-6.6 5.4-12 12-12h280c6.6 0 12 5.4 12 12v24z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="briefcase" class="svg-inline--fa fa-briefcase fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M464 128h-80V80c0-26.51-21.49-48-48-48H176c-26.51 0-48 21.49-48 48v48H48c-26.51 0-48 21.49-48 48v256c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V176c0-26.51-21.49-48-48-48zM176 80h160v48H176V80zM54 176h404c3.31 0 6 2.69 6 6v74H48v-74c0-3.31 2.69-6 6-6zm404 256H54c-3.31 0-6-2.69-6-6V304h144v24c0 13.25 10.75 24 24 24h80c13.25 0 24-10.75 24-24v-24h144v122c0 3.31-2.69 6-6 6z"></path></svg>
|
Before Width: | Height: | Size: 697 B After Width: | Height: | Size: 617 B |
|
@ -0,0 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="question" class="svg-inline--fa fa-question fa-w-12" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M202.021 0C122.202 0 70.503 32.703 29.914 91.026c-7.363 10.58-5.093 25.086 5.178 32.874l43.138 32.709c10.373 7.865 25.132 6.026 33.253-4.148 25.049-31.381 43.63-49.449 82.757-49.449 30.764 0 68.816 19.799 68.816 49.631 0 22.552-18.617 34.134-48.993 51.164-35.423 19.86-82.299 44.576-82.299 106.405V320c0 13.255 10.745 24 24 24h72.471c13.255 0 24-10.745 24-24v-5.773c0-42.86 125.268-44.645 125.268-160.627C377.504 66.256 286.902 0 202.021 0zM192 373.459c-38.196 0-69.271 31.075-69.271 69.271 0 38.195 31.075 69.27 69.271 69.27s69.271-31.075 69.271-69.271-31.075-69.27-69.271-69.27z"></path></svg>
|
After Width: | Height: | Size: 816 B |
|
@ -156,7 +156,7 @@ export default {
|
|||
localStorage.setItem(localStorageKeys.PAGE_INFO, JSON.stringify(data.pageInfo));
|
||||
}
|
||||
if (data.appConfig) {
|
||||
data.appConfig.auth = this.config.appConfig.auth || [];
|
||||
data.appConfig.auth = this.config.appConfig.auth || {};
|
||||
localStorage.setItem(localStorageKeys.APP_CONFIG, JSON.stringify(data.appConfig));
|
||||
}
|
||||
if (data.appConfig.theme) {
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
<a @click="itemOpened"
|
||||
@mouseup.right="openContextMenu"
|
||||
@contextmenu.prevent
|
||||
:href="(target !== 'modal' && target !== 'workspace') ? url : '#'"
|
||||
:target="target === 'newtab' ? '_blank' : ''"
|
||||
:href="hyperLinkHref"
|
||||
:target="anchorTarget"
|
||||
:class="`item ${!icon? 'short': ''} size-${itemSize}`"
|
||||
v-tooltip="getTooltipOptions()"
|
||||
rel="noopener noreferrer" tabindex="0"
|
||||
|
@ -21,7 +21,7 @@
|
|||
v-bind:style="customStyles" class="bounce" />
|
||||
<!-- Small icon, showing opening method on hover -->
|
||||
<ItemOpenMethodIcon class="opening-method-icon" :isSmall="!icon || itemSize === 'small'"
|
||||
:openingMethod="target" position="bottom right"
|
||||
:openingMethod="accumulatedTarget" position="bottom right"
|
||||
:hotkey="hotkey" />
|
||||
<!-- Status indicator dot (if enabled) showing weather srevice is availible -->
|
||||
<StatusIndicator
|
||||
|
@ -49,7 +49,12 @@ import Icon from '@/components/LinkItems/ItemIcon.vue';
|
|||
import ItemOpenMethodIcon from '@/components/LinkItems/ItemOpenMethodIcon';
|
||||
import StatusIndicator from '@/components/LinkItems/StatusIndicator';
|
||||
import ContextMenu from '@/components/LinkItems/ContextMenu';
|
||||
import { localStorageKeys, serviceEndpoints } from '@/utils/defaults';
|
||||
import {
|
||||
localStorageKeys,
|
||||
serviceEndpoints,
|
||||
openingMethod as defaultOpeningMethod,
|
||||
} from '@/utils/defaults';
|
||||
import { targetValidator } from '@/utils/ConfigHelpers';
|
||||
|
||||
export default {
|
||||
name: 'Item',
|
||||
|
@ -66,8 +71,7 @@ export default {
|
|||
hotkey: Number, // Shortcut for quickly launching app
|
||||
target: { // Where resource will open, either 'newtab', 'sametab' or 'modal'
|
||||
type: String,
|
||||
default: 'newtab',
|
||||
validator: (value) => ['newtab', 'sametab', 'modal', 'workspace'].indexOf(value) !== -1,
|
||||
validator: targetValidator,
|
||||
},
|
||||
itemSize: String,
|
||||
enableStatusCheck: Boolean,
|
||||
|
@ -80,6 +84,25 @@ export default {
|
|||
appConfig() {
|
||||
return this.$store.getters.appConfig;
|
||||
},
|
||||
accumulatedTarget() {
|
||||
return this.target || this.appConfig.defaultOpeningMethod || defaultOpeningMethod;
|
||||
},
|
||||
/* Convert config target value, into HTML anchor target attribute */
|
||||
anchorTarget() {
|
||||
const target = this.accumulatedTarget;
|
||||
switch (target) {
|
||||
case 'sametab': return '_self';
|
||||
case 'newtab': return '_blank';
|
||||
case 'parent': return '_parent';
|
||||
case 'top': return '_top';
|
||||
default: return undefined;
|
||||
}
|
||||
},
|
||||
/* Get the href value for the anchor, if not opening in modal/ workspace */
|
||||
hyperLinkHref() {
|
||||
const noAnchorNeeded = ['modal', 'workspace'];
|
||||
return noAnchorNeeded.includes(this.accumulatedTarget) ? '#' : this.url;
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -105,10 +128,10 @@ export default {
|
|||
methods: {
|
||||
/* Called when an item is clicked, manages the opening of modal & resets the search field */
|
||||
itemOpened(e) {
|
||||
if (e.altKey || this.target === 'modal') {
|
||||
if (e.altKey || this.accumulatedTarget === 'modal') {
|
||||
e.preventDefault();
|
||||
this.$emit('triggerModal', this.url);
|
||||
} else if (this.target === 'workspace') {
|
||||
} else if (this.accumulatedTarget === 'workspace') {
|
||||
router.push({ name: 'workspace', query: { url: this.url } });
|
||||
} else {
|
||||
this.$emit('itemClicked');
|
||||
|
@ -151,12 +174,15 @@ export default {
|
|||
classes: `item-description-tooltip tooltip-is-${this.itemSize}`,
|
||||
};
|
||||
},
|
||||
/* Used by certain themes, which display an icon with animated CSS */
|
||||
/* Used by certain themes (material), to show animated CSS icon */
|
||||
getUnicodeOpeningIcon() {
|
||||
switch (this.target) {
|
||||
switch (this.accumulatedTarget) {
|
||||
case 'newtab': return '"\\f360"';
|
||||
case 'sametab': return '"\\f24d"';
|
||||
case 'parent': return '"\\f3bf"';
|
||||
case 'top': return '"\\f102"';
|
||||
case 'modal': return '"\\f2d0"';
|
||||
case 'workspace': return '"\\f0b1"';
|
||||
default: return '"\\f054"';
|
||||
}
|
||||
},
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
<SameTabOpenIcon v-else-if="openingMethod === 'sametab'" />
|
||||
<IframeOpenIcon v-else-if="openingMethod === 'modal'" />
|
||||
<WorkspaceOpenIcon v-else-if="openingMethod === 'workspace'" />
|
||||
<ParentOpenIcon v-else-if="openingMethod === 'parent'" />
|
||||
<TopOpenIcon v-else-if="openingMethod === 'top'" />
|
||||
<UnknownIcon v-else />
|
||||
</div>
|
||||
<div v-if="hotkey" :class="`hotkey-denominator ${makeClass(position, isSmall, isTransparent)}`">
|
||||
{{ hotkey }}
|
||||
|
@ -20,11 +23,14 @@ import NewTabOpenIcon from '@/assets/interface-icons/open-new-tab.svg';
|
|||
import SameTabOpenIcon from '@/assets/interface-icons/open-current-tab.svg';
|
||||
import IframeOpenIcon from '@/assets/interface-icons/open-iframe.svg';
|
||||
import WorkspaceOpenIcon from '@/assets/interface-icons/open-workspace.svg';
|
||||
import ParentOpenIcon from '@/assets/interface-icons/open-parent.svg';
|
||||
import TopOpenIcon from '@/assets/interface-icons/open-top.svg';
|
||||
import UnknownIcon from '@/assets/interface-icons/unknown-icon.svg';
|
||||
|
||||
export default {
|
||||
name: 'ItemOpenMethodIcon',
|
||||
props: {
|
||||
openingMethod: String, // newtab | sametab | modal | workspace
|
||||
openingMethod: String, // newtab | sametab | parent | top | modal | workspace
|
||||
isSmall: Boolean, // If true, will apply small class
|
||||
position: String, // Position classes: top, bottom, left, right
|
||||
isTransparent: Boolean, // If true, will apply opacity
|
||||
|
@ -44,6 +50,9 @@ export default {
|
|||
SameTabOpenIcon,
|
||||
IframeOpenIcon,
|
||||
WorkspaceOpenIcon,
|
||||
ParentOpenIcon,
|
||||
TopOpenIcon,
|
||||
UnknownIcon,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -7,6 +7,8 @@ import {
|
|||
theme as defaultTheme,
|
||||
language as defaultLanguage,
|
||||
} from '@/utils/defaults';
|
||||
import ErrorHandler from '@/utils/ErrorHandler';
|
||||
import ConfigSchema from '@/utils/ConfigSchema.json';
|
||||
|
||||
/**
|
||||
* Initiates the Accumulator class and generates a complete config object
|
||||
|
@ -97,3 +99,17 @@ export const getUsersLanguage = () => {
|
|||
const langObj = languages.find(lang => lang.code === langCode);
|
||||
return langObj;
|
||||
};
|
||||
|
||||
/**
|
||||
* validator for item target attribute
|
||||
* Uses enum values from config schema, and shows warning if invalid
|
||||
* @param {String} target
|
||||
* @returns {Boolean} isValid
|
||||
*/
|
||||
export const targetValidator = (target) => {
|
||||
const acceptedTargets = ConfigSchema.properties.sections.items
|
||||
.properties.items.items.properties.target.enum;
|
||||
const isTargetValid = acceptedTargets.indexOf(target) !== -1;
|
||||
if (!isTargetValid) ErrorHandler(`Unknown target value: ${target}`);
|
||||
return isTargetValid;
|
||||
};
|
||||
|
|
|
@ -76,6 +76,18 @@
|
|||
"default": "default",
|
||||
"description": "Which page to load by default, and on the base page or domain root. You can still switch to different views from within the UI"
|
||||
},
|
||||
"defaultOpeningMethod": {
|
||||
"enum": [
|
||||
"newtab",
|
||||
"sametab",
|
||||
"parent",
|
||||
"top",
|
||||
"modal",
|
||||
"workspace"
|
||||
],
|
||||
"default": "newtab",
|
||||
"description": "The default opening method for items. Only used if no item.target is specified"
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"default": "callisto",
|
||||
|
@ -554,6 +566,8 @@
|
|||
"enum": [
|
||||
"newtab",
|
||||
"sametab",
|
||||
"parent",
|
||||
"top",
|
||||
"modal",
|
||||
"workspace"
|
||||
],
|
||||
|
|
|
@ -139,6 +139,8 @@ module.exports = {
|
|||
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',
|
||||
|
|
Loading…
Reference in New Issue