This commit is contained in:
aspenyang 2025-12-27 19:00:01 +00:00 committed by GitHub
commit bb67f51492
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 729 additions and 33 deletions

1
.gitignore vendored
View file

@ -18,3 +18,4 @@ yarn-error.log*
*.njsproj
*.sln
*.sw?

View file

@ -32,6 +32,7 @@ The following file provides a reference of all supported configuration options.
- [**`pages`**](#pages-optional) - List of additional config files, for multi-page dashboards
- [**`appConfig`**](#appconfig-optional) - Main application settings
- [`webSearch`](#appconfigwebsearch-optional) - Configure web search engine options
- [`advancedSearch`](#appconfigadvancedsearch-optional) - Limit search to specific fields
- [`hideComponents`](#appconfighidecomponents-optional) - Show/ hide page components
- [`auth`](#appconfigauth-optional) - Built-in authentication setup
- [`users`](#appconfigauthusers-optional) - List or users (for simple auth)
@ -103,12 +104,14 @@ For more info, see the[Multi-Page docs](/docs/pages-and-sections.md#multi-page-s
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`goToLinkEnabled`** | `boolean` | _Optional_ | If `true`, typing a URL/hostname/IP in the search bar and pressing <kbd>Enter</kbd> will navigate directly instead of web searching. Defaults to `true`.
**`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`. NOTE: This has been replaced by an environmental variable: `VUE_APP_STARTING_VIEW` in V3 onwards
**`defaultOpeningMethod`** | `enum` | _Optional_ | The default opening method for items, if no `target` is specified for a given item. Can be either `newtab`, `sametab`, `modal`, `workspace`, `clipboard`, `top` or `parent`. 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`** | `number` | _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)
**`advancedSearch`** | `object` | _Optional_ | Limit searching to selected fields only. See [`advancedSearch`](#appconfigadvancedsearch-optional)
**`backgroundImg`** | `string` | _Optional_ | Path to an optional full-screen app background image. This can be either remote (http) or local (relative to /app/public/item-icons/ inside the container). Note that this will slow down initial load
**`enableFontAwesome`** | `boolean` | _Optional_ | If set to `true` font-awesome will be loaded, if set to `false` they will not be. if left blank font-awesome will be enabled only if required by 1 or more icons
**`enableMaterialDesignIcons`** | `boolean` | _Optional_ | If set to `true` mdi icons will be loaded, if set to `false` they will not be. Where `true` is enabled, if left blank material design icons will be enabled only if required by 1 or more icons
@ -219,6 +222,29 @@ For more info, see the **[Authentication Docs](/docs/authentication.md)**
**`customSearchEngine`** | `string` | _Optional_ | You can also use a custom search engine, or your own self-hosted instance. This requires `searchEngine: custom` to be set. Then add the URL of your service, with GET query string included here
**`openingMethod`** | `string` | _Optional_ | Set your preferred opening method for search results: `newtab`, `sametab`, `workspace`. Defaults to `newtab`
**`searchBangs`** | `object` | _Optional_ | A key-value-pair set of custom search _bangs_ for redirecting query to a specific app or search engine. The key of each should be the bang you will type (typically starting with `/`, `!` or `:`), and value is the destination, either as a search engine key (e.g. `reddit`) or a URL with search parameters (e.g. `https://en.wikipedia.org/w/?search=`)
**`enableCtrlEnterWebSearch`** | `boolean` | _Optional_ | When `true`, pressing <kbd>Ctrl</kbd> + <kbd>Enter</kbd> (or <kbd>Cmd</kbd> + <kbd>Enter</kbd> on macOS) forces a web search using the configured engine, ignoring advanced selection or link detection. Defaults to `false`.
**[⬆️ Back to Top](#configuring)**
## `appConfig.advancedSearch` _(optional)_
Configure search to consider only specific fields when matching queries.
**Field** | **Type** | **Required**| **Description**
--- | --- | --- | ---
**`enabled`** | `boolean` | _Optional_ | If `true`, only the fields set to `true` under `fields` will be searched. Defaults to `false`.
**`fields`** | `object` | _Optional_ | Field toggles for advanced search. Keys: `title`, `description`, `provider`, `url`, `tags`, `domain`. Each value is a boolean, default `false`.
Example:
```yaml
appConfig:
advancedSearch:
enabled: true
fields:
title: true
tags: true
```
**[⬆️ Back to Top](#configuring)**

View file

@ -55,6 +55,21 @@ In the above example, pressing <kbd>2</kbd> will launch Bookstack. Or hitting <k
It's possible to search the web directly from Dashy, which might be useful if you're using Dashy as your start page. This can be done by typing your query as normal, and then pressing <kbd></kbd>. Web search options are configured under `appConfig.webSearch`.
### Go To Link
Dashy can detect link-like input and open it directly when you press <kbd>Enter</kbd>.
- Configure with: `appConfig.goToLinkEnabled` (default: `true`).
- Behavior: If the text looks like a URL (starts with `http://` or `https://`), begins with `www.`, or matches a domain pattern like `example.com` or `service.internal/path`, Dashy opens it immediately instead of doing a web search.
- Dashy will automatically add `https://` if no protocol is provided.
Example:
```yaml
appConfig:
goToLinkEnabled: true
```
### Setting Search Engine
Set your default search engine using the `webSearch.searchEngine` property. This defaults to DuckDuckGo. Search engine must be referenced by their key, the following providers are supported:
@ -114,7 +129,58 @@ appConfig:
webSearch: { disableWebSearch: true }
```
### Ctrl/Cmd+Enter Web Search
When web search is enabled, you can optionally force a web search using a shortcut, even if an app is selected or a link is detected.
- Configure with: `appConfig.webSearch.enableCtrlEnterWebSearch` (default: `false`)
- Effect: Pressing <kbd>Ctrl</kbd> + <kbd>Enter</kbd> (or <kbd>Cmd</kbd> + <kbd>Enter</kbd> on macOS) triggers a web search with your configured engine, ignoring advanced selection or link detection.
Example:
```yaml
appConfig:
webSearch:
enableCtrlEnterWebSearch: true
```
Note: This only affects the search bar context. Standard item launching shortcuts remain unchanged elsewhere.
## Clearing Search
You can clear your search term at any time, resting the UI to it's initial state, by pressing <kbd>Esc</kbd>.
This can also be used to close any open pop-up modals.
## Advanced Search
If you prefer more precise results, you can limit searching to specific item fields. When enabled, only selected fields will be considered in matching.
- Configure root: `appConfig.advancedSearch.enabled` (default: `false`)
- Configure fields: `appConfig.advancedSearch.fields`
- Available fields: `title`, `description`, `provider`, `url`, `tags`, `domain`
Examples:
Only search titles and tags:
```yaml
appConfig:
advancedSearch:
enabled: true
fields:
title: true
tags: true
```
Search by provider and domain only:
```yaml
appConfig:
advancedSearch:
enabled: true
fields:
provider: true
domain: true
```
Tip: Leave `enabled: false` to keep the default broad search across common fields.

View file

@ -259,6 +259,11 @@ export default {
}
&:focus {
outline: 2px solid var(--primary);
box-shadow: 0 0 0 3px rgba(0,0,0,0.05), var(--item-shadow);
}
&.tile--selected {
outline: 2px solid var(--primary);
box-shadow: 0 0 0 3px rgba(0,0,0,0.05), var(--item-shadow);
}
&.add-new {
border: 2px dashed var(--primary) !important;
@ -279,6 +284,7 @@ export default {
z-index: 2;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3; /* standard property for compatibility */
-webkit-box-orient: vertical;
word-break: keep-all;
overflow: hidden;

View file

@ -1,29 +1,103 @@
<template>
<form @submit.prevent="searchSubmitted" :class="minimalSearch ? 'minimal' : 'normal'">
<label for="filter-tiles">{{ $t('search.search-label') }}</label>
<div class="search-wrap">
<input
id="filter-tiles"
v-model="input"
ref="filter"
:placeholder="$t('search.search-placeholder')"
v-on:input="userIsTypingSomething"
@keydown.esc="clearFilterInput" />
<p v-if="(!searchPrefs.disableWebSearch) && input.length > 0" class="web-search-note">
<div class = "search-settings-row">
<form
@submit.prevent="searchSubmitted()"
:class="minimalSearch ? 'minimal' : 'normal'"
>
<label for="filter-tiles">
{{ $t('search.search-label') }}
</label>
<div class="search-wrap">
<input
id="filter-tiles"
v-model="input"
ref="filter"
:placeholder="$t('search.search-placeholder')"
v-on:input="userIsTypingSomething"
@keydown.esc="clearFilterInput"
/>
<p v-if="showOpenItemNote" class="web-search-note">
Press Enter to open the item
</p>
<p v-else-if="showWebSearchNote" class="web-search-note">
{{ $t('search.enter-to-search-web') }}
</p>
</div>
<i v-if="input.length > 0"
<i
v-if="input.length > 0"
class="clear-search"
:title="$t('search.clear-search-tooltip')"
@click="clearFilterInput">x</i>
</form>
@click="clearFilterInput"
>x</i>
</form>
<div class="settings-block">
<button
@click="showSearchPanel = !showSearchPanel"
class="settings-toggle"
type="button"
v-tooltip="showSearchPanel ? $t('Hide Search Options') : $t('Show Search Options')"
>
<IconConfigEditor />
</button>
<div v-show="showSearchPanel" class="floating-search-panel">
<label class="theme-label">
<input
type="checkbox"
:checked="searchPrefs.disableWebSearch"
@change="toggleDisableWebSearch"
/>
Disable Web Search
</label>
<label
v-if="!searchPrefs.disableWebSearch"
class="theme-label"
>
<input
type="checkbox"
:checked="!!searchPrefs.enableCtrlEnterWebSearch"
@change="toggleCtrlEnterWebSearch"
/>
Enable Ctrl/Cmd + Enter to search web
</label>
<label class="theme-label">
<input
type="checkbox"
:checked="goToLinkEnabled"
@change="goToLinkEnabled = $event.target.checked"
/>
Go to Link (auto-detect links)
</label>
<label class="theme-label">
<input
type="checkbox"
:checked="advancedSearch.enabled"
@change="toggleAdvancedEnabled"
/>
Advanced Search
</label>
<div v-if="advancedSearch.enabled" class="advanced-fields">
<p class="adv-hint">Match only in selected fields:</p>
<div class="field-grid">
<label v-for="f in fieldList" :key="f.key" class="field-check">
<input
type="checkbox"
:checked="advancedSearch.fields[f.key]"
@change="toggleField(f.key, $event)"
/>
{{ f.label }}
</label>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import router from '@/router';
import ArrowKeyNavigation from '@/utils/ArrowKeyNavigation';
import ErrorHandler from '@/utils/ErrorHandler';
import IconConfigEditor from '@/assets/interface-icons/config-editor.svg';
import { getCustomKeyShortcuts } from '@/utils/ConfigHelpers';
import { getSearchEngineFromBang, findUrlForSearchEngine, stripBangs } from '@/utils/Search';
import {
@ -38,11 +112,16 @@ export default {
props: {
minimalSearch: Boolean, // If true, then keep it simple
},
components: {
IconConfigEditor,
},
data() {
return {
input: '', // Users current search term
akn: new ArrowKeyNavigation(), // Class that manages arrow key naviagtion
input: '',
akn: new ArrowKeyNavigation(),
getCustomKeyShortcuts,
showSearchPanel: false,
// goToLinkEnabled is now managed by Vuex/appConfig
};
},
computed: {
@ -52,6 +131,62 @@ export default {
searchPrefs() {
return this.$store.getters.webSearch || {};
},
showOpenItemNote() {
// Show when Enter will open an exact match item (advanced on + matches)
const input = (this.input || '').trim();
if (input.length === 0) return false;
// If Go-to-Link would intercept, don't show
if (this.goToLinkEnabled && this.isUrlLike(input)) return false;
const adv = this.$store.getters.advancedSearch || {};
if (!adv.enabled) return false;
const exactItems = this.getExactMatchItemsList();
return !!(exactItems && exactItems.length > 0);
},
showWebSearchNote() {
// Only show hint when pressing Enter will actually search the web
const input = (this.input || '').trim();
if (input.length === 0) return false;
// If Go-to-Link would intercept, then Enter does not search web
if (this.goToLinkEnabled && this.isUrlLike(input)) return false;
// If web search is disabled, don't show
if (this.searchPrefs && this.searchPrefs.disableWebSearch) return false;
// If advanced search is enabled and there are exact matches,
// Enter opens tile instead of web search
const adv = this.$store.getters.advancedSearch || {};
if (adv.enabled) {
const exactItems = this.getExactMatchItemsList();
if (exactItems && exactItems.length > 0) return false;
}
return true;
},
goToLinkEnabled: {
get() {
return this.$store.getters.goToLinkEnabled;
},
set(value) {
this.$store.commit('setGoToLinkEnabled', value);
// Also update appConfig in store for persistence
const newAppConfig = {
...this.$store.getters.appConfig,
goToLinkEnabled: value,
};
this.$store.commit('SET_APP_CONFIG', newAppConfig);
},
},
advancedSearch() {
const adv = this.$store.getters.advancedSearch || {};
return { enabled: !!adv.enabled, fields: adv.fields || {} };
},
fieldList() {
return [
{ key: 'title', label: 'Title' },
{ key: 'description', label: 'Description' },
{ key: 'provider', label: 'Provider' },
{ key: 'url', label: 'URL' },
{ key: 'tags', label: 'Tags' },
{ key: 'domain', label: 'Domain' },
];
},
},
mounted() {
window.addEventListener('keydown', this.handleKeyPress);
@ -60,6 +195,78 @@ export default {
window.removeEventListener('keydown', this.handleKeyPress);
},
methods: {
// Selection utilities for Advanced Search tile navigation (Exact Match only)
getExactMatchItemsList() {
const container = document.querySelector('.exact-match-block');
if (!container) return [];
return Array.from(container.querySelectorAll('.item'));
},
setSelectionClass(el) {
const items = this.getExactMatchItemsList();
items.forEach(i => i.classList.remove('tile--selected'));
if (el) el.classList.add('tile--selected');
},
clearSelectionHighlight() {
const items = this.getExactMatchItemsList();
items.forEach(i => i.classList.remove('tile--selected'));
},
updateDefaultSelection() {
const adv = this.$store.getters.advancedSearch || {};
if (!adv.enabled) return;
if (!this.input || this.input.trim().length === 0) { this.clearSelectionHighlight(); return; }
const items = this.getExactMatchItemsList();
if (!items || items.length === 0) { this.clearSelectionHighlight(); return; }
const focused = items.find(i => i === document.activeElement);
if (focused) { this.setSelectionClass(focused); return; }
const first = items[0];
if (first) this.setSelectionClass(first);
},
toggleDisableWebSearch(event) {
const value = event.target.checked;
const newAppConfig = {
...this.$store.getters.appConfig,
webSearch: {
...this.$store.getters.appConfig.webSearch,
disableWebSearch: value,
},
};
this.$store.commit('setDisableWebSearch', value);
this.$store.commit('SET_APP_CONFIG', newAppConfig);
},
toggleAdvancedEnabled(event) {
const enabled = event.target.checked;
this.$store.commit('setAdvancedSearch', { enabled });
// If enabling and no fields chosen yet, default to title + url
if (enabled) {
const currentFields = (this.advancedSearch.fields || {});
const anyChosen = Object.values(currentFields).some(Boolean);
if (!anyChosen) {
const defaults = { title: true, url: true };
this.$store.commit('setAdvancedSearch', { fields: defaults });
}
}
const newAppConfig = {
...this.$store.getters.appConfig,
advancedSearch: {
...this.advancedSearch,
enabled,
fields: (this.$store.getters.advancedSearch.fields || {}),
},
};
this.$store.commit('SET_APP_CONFIG', newAppConfig);
},
toggleField(fieldKey, event) {
const { checked } = event.target;
const current = this.advancedSearch.fields || {};
const fields = { ...current, [fieldKey]: checked };
this.$store.commit('setAdvancedSearch', { fields });
const newAppConfig = {
...this.$store.getters.appConfig,
advancedSearch: { ...this.advancedSearch, fields },
};
this.$store.commit('SET_APP_CONFIG', newAppConfig);
this.userIsTypingSomething();
},
/* Call correct function dependending on which key is pressed */
handleKeyPress(event) {
const currentElem = document.activeElement.id;
@ -67,6 +274,22 @@ export default {
const notAlreadySearching = currentElem !== 'filter-tiles';
// If a modal is open, then do nothing
if (!this.active) return;
// Force web search with Ctrl/Cmd + Enter when enabled (and input focused)
const isEnter = key === 'Enter' || keyCode === 13;
const isCtrlOrCmd = !!(event.ctrlKey || event.metaKey);
if (
isEnter
&& isCtrlOrCmd
&& currentElem === 'filter-tiles'
&& this.searchPrefs
&& !this.searchPrefs.disableWebSearch
&& this.searchPrefs.enableCtrlEnterWebSearch
) {
event.preventDefault();
// Force web search, ignoring advanced selection and go-to-link
this.searchSubmitted(true);
return;
}
if (/^[/:!a-zA-Z]$/.test(key) && notAlreadySearching) {
// Letter or bang key pressed - start searching
if (this.$refs.filter) this.$refs.filter.focus();
@ -75,8 +298,41 @@ export default {
// Number key pressed, check if user has a custom binding
this.handleHotKey(key);
} else if (keyCode >= 37 && keyCode <= 40) {
// Arrow key pressed - start navigation
this.akn.arrowNavigation(keyCode);
// Arrow key pressed
const adv = this.$store.getters.advancedSearch || {};
if (adv.enabled) {
const itemsArr = this.getExactMatchItemsList();
if (!itemsArr || itemsArr.length === 0) {
// No exact matches -> fall back to default navigation
this.akn.arrowNavigation(keyCode);
return;
}
const focusedEl = itemsArr.find(i => i === document.activeElement);
let idx = focusedEl ? itemsArr.indexOf(focusedEl) : -1;
const move = (delta) => {
if (idx === -1) idx = 0; // no focus yet -> first
else idx = (idx + delta + itemsArr.length) % itemsArr.length; // wrap
const el = itemsArr[idx];
if (el) {
el.focus();
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
this.setSelectionClass(el);
}
};
if (keyCode === 37) { // Left
event.preventDefault();
move(-1);
} else if (keyCode === 39) { // Right
event.preventDefault();
move(1);
} else if (keyCode === 38 || keyCode === 40) {
// For now, ignore up/down in advanced mode to keep UX simple
event.preventDefault();
}
} else {
// Default navigation behavior
this.akn.arrowNavigation(keyCode);
}
} else if (keyCode === 27) {
// Esc key pressed - reset form
this.clearFilterInput();
@ -85,6 +341,7 @@ export default {
/* Emmits users's search term up to parent */
userIsTypingSomething() {
this.$emit('user-is-searchin', this.input);
this.$nextTick(() => this.updateDefaultSelection());
},
/* Resets everything to initial state, when user is finished */
clearFilterInput() {
@ -92,6 +349,7 @@ export default {
this.userIsTypingSomething(); // Emmit new empty value
document.activeElement.blur(); // Remove focus
this.akn.resetIndex(); // Reset current element index
this.clearSelectionHighlight();
},
/* If configured, launch specific app when hotkey pressed */
handleHotKey(key) {
@ -122,27 +380,87 @@ export default {
},
/* Launch web search, to correct search engine, passing in users query */
searchSubmitted() {
// Get search preferences from appConfig
const { searchPrefs } = this;
if (!searchPrefs.disableWebSearch) { // Only proceed if user hasn't disabled web search
const bangList = { ...defaultSearchBangs, ...(searchPrefs.searchBangs || {}) };
searchSubmitted(force = false) {
// If the first argument is an Event (from accidental binding), treat as not forced
if (force && typeof force === 'object' && 'preventDefault' in force) {
force = false; // eslint-disable-line no-param-reassign
}
const { searchPrefs, goToLinkEnabled } = this;
const input = this.input.trim();
// 1. If not forcing web search, and "Go to Link" is enabled
// and input is URL-like, open as link
if (!force && goToLinkEnabled && this.isUrlLike(input)) {
window.open(this.normalizeUrl(input), '_blank');
this.clearFilterInput();
return;
}
// 1.5 Advanced Search override: if enabled and user has typed something,
// and there are matched tiles on the page, pressing Enter should open the
// selected tile (focused) or the first matched tile instead of web search
const adv = this.$store.getters.advancedSearch || {};
if (!force && (adv.enabled === true) && input.length > 0) {
const items = this.getExactMatchItemsList();
if (!items || items.length === 0) {
// No exact matches -> allow normal web search flow below
} else {
const focused = items.find(i => i === document.activeElement);
const first = items[0];
const targetEl = focused || first;
if (targetEl) {
targetEl.click();
this.clearFilterInput();
this.clearSelectionHighlight();
return;
}
}
}
// 2. If not URL-like, or "Go to Link" is disabled, only search if web search is enabled
if (!searchPrefs.disableWebSearch) {
const bangList = {
...defaultSearchBangs,
...(searchPrefs.searchBangs || {}),
};
const openingMethod = searchPrefs.openingMethod || defaultSearchOpeningMethod;
const searchBang = getSearchEngineFromBang(this.input, bangList);
const searchBang = getSearchEngineFromBang(input, bangList);
const searchEngine = searchPrefs.searchEngine || defaultSearchEngine;
// Use either search bang, or preffered search engine
const desiredSearchEngine = searchBang || searchEngine;
const isCustomSearch = (searchPrefs.searchEngine === 'custom' && searchPrefs.customSearchEngine);
let searchUrl = isCustomSearch
? searchPrefs.customSearchEngine
: findUrlForSearchEngine(desiredSearchEngine, searchEngineUrls);
if (searchUrl) { // Append search query to URL, and launch
searchUrl += encodeURIComponent(stripBangs(this.input, bangList));
if (searchUrl) {
searchUrl += encodeURIComponent(stripBangs(input, bangList));
this.launchWebSearch(searchUrl, openingMethod);
this.clearFilterInput();
}
}
},
toggleCtrlEnterWebSearch(event) {
const value = event.target.checked;
const currentAppConfig = this.$store.getters.appConfig || {};
const newAppConfig = {
...currentAppConfig,
webSearch: {
...(currentAppConfig.webSearch || {}),
enableCtrlEnterWebSearch: value,
},
};
this.$store.commit('SET_APP_CONFIG', newAppConfig);
},
// Utility: Detect if input is a URL or domain-like string
isUrlLike(input) {
// Matches URLs with protocol, www, or domain.tld (e.g., youtube.com)
const urlPattern = /^(https?:\/\/)?([\w-]+\.)+[a-zA-Z]{2,}(\/.*)?$/;
return urlPattern.test(input.trim());
},
// Utility: Normalize input to a full URL (adds https:// if missing)
normalizeUrl(input) {
let url = input.trim();
if (!/^https?:\/\//.test(url)) {
url = `https://${url}`;
}
return url;
},
},
};
</script>
@ -151,10 +469,16 @@ export default {
@import '@/styles/media-queries.scss';
.search-settings-row {
display: flex;
// flex-direction: column;
align-items: center;
// width: 100%;
}
form.normal {
display: flex;
align-items: center;
border-radius: 0 0 var(--curve-factor-navbar) 0;
// border-radius: 0 0 var(--curve-factor-navbar) 0;
padding: 0 0.2rem 0.2rem 0;
background: var(--search-container-background);
.search-wrap {
@ -211,6 +535,59 @@ export default {
}
}
.settings-block {
display: flex;
flex-direction: column;
align-items: center;
margin: 0.5rem 0;
width: 100%;
position: relative;
border-radius: 0 0 var(--curve-factor-navbar) 0;
padding: 0 0.2rem 0.2rem 0;
background: var(--search-container-background);
.settings-toggle {
background: var(--settings-background);
color: var(--settings-text-color);
border: none;
padding: 0.5rem;
margin: 0.5rem 0.5rem 0.5rem 0;
border-radius: var(--curve-factor);
cursor: pointer;
&:hover {
background: var(--settings-text-color);
color: var(--settings-background);
}
}
.settings-toggle svg {
width: 1rem;
height: 1rem;
fill: currentColor;
display: block;
}
.floating-search-panel {
position: absolute;
top: 100%;
left: 0;
min-width: 180px;
max-width: max-content;
background: var(--settings-background);
border: 1px solid var(--settings-text-color);
border-radius: var(--curve-factor);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
// Add vertical spacing between option rows (no borders as requested)
> .theme-label {
margin: 0.35rem 0; // top & bottom margin only
}
.advanced-fields { // keep advanced section consistent spacing from previous option
margin-top: 0.4rem;
}
}
}
@include tablet {
form.normal {
display: block;
@ -278,4 +655,34 @@ export default {
}
}
}
.theme-label {
color: var(--settings-text-color);
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.advanced-fields {
padding: 0.4rem 0.6rem 0.6rem 0.6rem;
border-top: 1px solid var(--settings-text-color);
.adv-hint {
margin: 0.2rem 0 0.4rem 0;
font-size: 0.7rem;
opacity: 0.7;
color: var(--settings-text-color);
}
.field-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 0.25rem 0.5rem;
}
.field-check {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
color: var(--settings-text-color);
input { margin: 0; }
}
}
</style>

View file

@ -4,7 +4,7 @@
import Defaults, { localStorageKeys, iconCdns } from '@/utils/defaults';
import Keys from '@/utils/StoreMutations';
import { searchTiles } from '@/utils/Search';
import { searchTiles, searchTilesWithFields } from '@/utils/Search';
import { checkItemVisibility } from '@/utils/CheckItemVisibility';
const HomeMixin = {
@ -99,10 +99,15 @@ const HomeMixin = {
},
/* Returns only the tiles that match the users search query */
filterTiles(allTiles) {
if (!allTiles) {
return [];
}
if (!allTiles) return [];
const visibleTiles = allTiles.filter((tile) => checkItemVisibility(tile));
const { appConfig } = this;
const adv = appConfig?.advancedSearch || {};
const enabled = !!adv.enabled;
const fields = adv.fields || {};
if (enabled) {
return searchTilesWithFields(visibleTiles, this.searchValue, fields);
}
return searchTiles(visibleTiles, this.searchValue);
},
/* Checks if any sections or items use icons from a given CDN */

View file

@ -152,6 +152,12 @@ export default {
} else if (this.accumulatedTarget === 'clipboard') {
e.preventDefault();
this.copyToClipboard(url);
} else {
// Explicitly navigate to ensure clicks always open even if other handlers interfere
// Use anchorTarget mapping for correct behavior (same tab/new tab/parent/top)
e.preventDefault();
const target = this.anchorTarget || '_self';
window.open(url, target);
}
// Emit event to clear search field, etc
this.$emit('itemClicked');

View file

@ -73,6 +73,10 @@ const store = new Vuex.Store({
if (!state.config) return {};
return state.config.appConfig || {};
},
goToLinkEnabled(state, getters) {
// Default to true if not set
return typeof getters.appConfig.goToLinkEnabled === 'boolean' ? getters.appConfig.goToLinkEnabled : true;
},
sections(state) {
return filterUserSections(state.config.sections || []);
},
@ -89,6 +93,9 @@ const store = new Vuex.Store({
webSearch(state, getters) {
return getters.appConfig.webSearch || {};
},
advancedSearch(state, getters) {
return getters.appConfig.advancedSearch || {};
},
visibleComponents(state, getters) {
return componentVisibility(getters.appConfig);
},
@ -232,6 +239,22 @@ const store = new Vuex.Store({
state.config = newConfig;
InfoHandler('Sections updated', InfoKeys.EDITOR);
},
// Dynamically update disableWebSearch in appConfig
setDisableWebSearch(state, value) {
if (!state.config.appConfig) state.config.appConfig = {};
state.config.appConfig.disableWebSearch = value;
},
// Dynamically update goToLinkEnabled in appConfig
setGoToLinkEnabled(state, value) {
if (!state.config.appConfig) state.config.appConfig = {};
state.config.appConfig.goToLinkEnabled = value;
},
// Update advanced search settings (partial merge)
setAdvancedSearch(state, value) {
if (!state.config.appConfig) state.config.appConfig = {};
const current = state.config.appConfig.advancedSearch || {};
state.config.appConfig.advancedSearch = { ...current, ...value };
},
[UPDATE_SECTION](state, payload) {
const { sectionIndex, sectionData } = payload;
const newConfig = { ...state.config };

View file

@ -183,6 +183,12 @@
"appConfig": {
"type": "object",
"properties": {
"goToLinkEnabled": {
"title": "Go To Link Enabled",
"type": "boolean",
"default": true,
"description": "If true, enables Go to Link (auto-detect links) in the search bar."
},
"startingView": {
"title": "Starting View",
"type": "string",
@ -351,6 +357,12 @@
"default": "false",
"description": "If set to true, web search will be disabled all together"
},
"enableCtrlEnterWebSearch": {
"title": "Enable Ctrl/Cmd+Enter Web Search",
"type": "boolean",
"default": false,
"description": "When true and web search is enabled, pressing Ctrl+Enter (Cmd+Enter on macOS) will force a web search using the configured engine, ignoring advanced selection or link detection"
},
"searchEngine": {
"title": "Search Engine",
"type": "string",
@ -404,6 +416,34 @@
}
}
},
"advancedSearch": {
"title": "Advanced Search",
"type": "object",
"description": "Enable filtering search by specific fields",
"additionalProperties": false,
"properties": {
"enabled": {
"title": "Advanced Search Enabled",
"type": "boolean",
"default": false,
"description": "If true, only selected fields will be searched"
},
"fields": {
"title": "Advanced Search Fields",
"type": "object",
"additionalProperties": false,
"properties": {
"title": { "type": "boolean", "default": false },
"description": { "type": "boolean", "default": false },
"provider": { "type": "boolean", "default": false },
"url": { "type": "boolean", "default": false },
"tags": { "type": "boolean", "default": false },
"domain": { "type": "boolean", "default": false }
},
"description": "Which fields to include in advanced search filtering"
}
}
},
"enableFontAwesome": {
"title": "Enable Font-Awesome?",
"type": "boolean",

View file

@ -51,6 +51,37 @@ export const searchTiles = (allTiles, searchTerm) => {
});
};
/**
* Advanced search: filter only within selected fields
* @param {array} allTiles tiles
* @param {string} searchTerm user query
* @param {object} fieldSelection map of fieldName -> boolean
* Supported keys: title, description, provider, url, tags, domain
* @returns filtered tiles
*/
export const searchTilesWithFields = (allTiles, searchTerm, fieldSelection = {}) => {
if (!searchTerm) return allTiles;
if (!allTiles) return [];
// If no fields explicitly selected (all false), fall back to original behavior
const anySelected = Object.values(fieldSelection).some(Boolean);
if (!anySelected) return searchTiles(allTiles, searchTerm);
return allTiles.filter((tile) => {
const {
title, description, provider, url, tags,
} = tile;
const domain = getDomainFromUrl(url);
return (
(fieldSelection.title && filterHelper(title, searchTerm))
|| (fieldSelection.description && filterHelper(description, searchTerm))
|| (fieldSelection.provider && filterHelper(provider, searchTerm))
|| (fieldSelection.url && filterHelper(url, searchTerm))
|| (fieldSelection.tags && filterHelper(tags, searchTerm))
|| (fieldSelection.domain && filterHelper(domain, searchTerm))
);
});
};
/* From a list of search bangs, return the URL associated with it */
export const getSearchEngineFromBang = (searchQuery, bangList) => {
const bangNames = Object.keys(bangList);

View file

@ -19,6 +19,26 @@
</router-link>
</div>
<!-- Main content, section for each group of items -->
<!-- Exact match block (advanced search only) -->
<div v-if="showExactMatchBlock" class="exact-match-block">
<div class="exact-match-header">Exact Match</div>
<div class="exact-match-items">
<Section
v-for="(group, idx) in exactMatches"
:key="`exact-group-${idx}`"
:title="group.section.name"
:icon="group.section.icon || undefined"
:displayData="getDisplayData(group.section)"
:groupId="`exact-match-${makeSectionId(group.section)}-${idx}`"
:items="group.items"
:widgets="[]"
:searchTerm="searchValue"
:itemSize="itemSizeBound"
@itemClicked="finishedSearching()"
:isWide="false"
/>
</div>
</div>
<div v-if="checkTheresData(sections) || isEditMode" :class="computedClass">
<template v-for="(section, index) in filteredSections">
<Section
@ -105,6 +125,46 @@ export default {
return section;
});
},
showExactMatchBlock() {
if (!this.searchValue) return false;
const adv = this.appConfig?.advancedSearch || {};
if (!adv.enabled) return false;
return this.exactMatches.length > 0;
},
exactMatches() {
if (!this.searchValue) return [];
const term = this.searchValue.trim().toLowerCase();
const adv = this.appConfig?.advancedSearch || {};
if (!adv.enabled) return [];
const fields = adv.fields || {};
const normalize = (v) => (v || '').toString().trim().toLowerCase();
const getDomain = (url) => {
if (!url) return '';
try {
const host = new URL(url).hostname.replace(/^www\./, '').toLowerCase();
const parts = host.split('.');
if (parts.length >= 2) return parts[parts.length - 2];
return host;
} catch (e) { return ''; }
};
const tagList = (tags) => (Array.isArray(tags) ? tags.map(t => normalize(t)) : []);
const matchesExactField = (tile) => (
(fields.title && normalize(tile.title) === term)
|| (fields.description && normalize(tile.description) === term)
|| (fields.provider && normalize(tile.provider) === term)
|| (fields.url && normalize(tile.url) === term)
|| (fields.tags && tagList(tile.tags).includes(term))
|| (fields.domain && getDomain(tile.url) === term)
);
const groups = [];
(this.sections || []).forEach((section) => {
const matched = (section.items || [])
.filter(item => this.filterTiles([item]).length === 1)
.filter(matchesExactField);
if (matched.length) groups.push({ section, items: matched });
});
return groups;
},
/* Updates layout (when button clicked), and saves in local storage */
layoutOrientation() {
return this.$store.getters.layout;
@ -186,6 +246,31 @@ export default {
@import '@/styles/media-queries.scss';
@import '@/styles/style-helpers.scss';
.exact-match-block {
margin: 1rem auto 0.5rem auto;
width: 95%;
background: var(--search-container-background);
border: 1px solid var(--outline-color);
border-radius: var(--curve-factor);
padding: 0.5rem 0.75rem 0.75rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
.exact-match-header {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
opacity: 0.7;
margin: 0 0 0.5rem 0.25rem;
color: var(--settings-text-color);
}
.exact-match-items {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
> * { flex: 1 1 140px; }
}
}
.home {
padding-bottom: 1px;
background: var(--background);

View file

@ -44,4 +44,4 @@ sections:
description: Get help with Dashy, raise a bug, or get in contact
url: https://github.com/Lissy93/dashy/blob/master/.github/SUPPORT.md
icon: far fa-hands-helping