diff --git a/.gitignore b/.gitignore index 07be599381..5c3f839bb9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ yarn-error.log* *.njsproj *.sln *.sw? + diff --git a/docs/configuring.md b/docs/configuring.md index 62b876f7ab..6dcd5540f4 100644 --- a/docs/configuring.md +++ b/docs/configuring.md @@ -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 Enter 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 Ctrl + Enter (or Cmd + Enter 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)** diff --git a/docs/searching.md b/docs/searching.md index 6595950cda..970b290cc1 100644 --- a/docs/searching.md +++ b/docs/searching.md @@ -55,6 +55,21 @@ In the above example, pressing 2 will launch Bookstack. Or hitting ⏎. Web search options are configured under `appConfig.webSearch`. +### Go To Link + +Dashy can detect link-like input and open it directly when you press Enter. + +- 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 Ctrl + Enter (or Cmd + Enter 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 Esc. 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. diff --git a/src/components/LinkItems/Item.vue b/src/components/LinkItems/Item.vue index 5ddc8d8798..88c0f82f62 100644 --- a/src/components/LinkItems/Item.vue +++ b/src/components/LinkItems/Item.vue @@ -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; diff --git a/src/components/Settings/SearchBar.vue b/src/components/Settings/SearchBar.vue index 42d7cc775f..6434ad1719 100644 --- a/src/components/Settings/SearchBar.vue +++ b/src/components/Settings/SearchBar.vue @@ -1,29 +1,103 @@ @@ -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; } + } + } diff --git a/src/mixins/HomeMixin.js b/src/mixins/HomeMixin.js index 6f163f88ef..56fa363367 100644 --- a/src/mixins/HomeMixin.js +++ b/src/mixins/HomeMixin.js @@ -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 */ diff --git a/src/mixins/ItemMixin.js b/src/mixins/ItemMixin.js index 35518ff451..07ecb5ffca 100644 --- a/src/mixins/ItemMixin.js +++ b/src/mixins/ItemMixin.js @@ -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'); diff --git a/src/store.js b/src/store.js index 9130139c68..8bce7a82e4 100644 --- a/src/store.js +++ b/src/store.js @@ -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 }; diff --git a/src/utils/ConfigSchema.json b/src/utils/ConfigSchema.json index 92039798ab..5e48a35f3c 100644 --- a/src/utils/ConfigSchema.json +++ b/src/utils/ConfigSchema.json @@ -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", diff --git a/src/utils/Search.js b/src/utils/Search.js index 99f3248403..78f526be27 100644 --- a/src/utils/Search.js +++ b/src/utils/Search.js @@ -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); diff --git a/src/views/Home.vue b/src/views/Home.vue index 004b181ac9..944c3c9822 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -19,6 +19,26 @@ + +
+
Exact Match
+
+
+
+