/* * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * * Copyright (C) 2019 - 2024 Jan Böhmer (https://github.com/jbtronics) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import { Controller } from "@hotwired/stimulus"; import { autocomplete } from '@algolia/autocomplete-js'; //import "@algolia/autocomplete-theme-classic/dist/theme.css"; import "../../css/components/autocomplete_bootstrap_theme.css"; import { createLocalStorageRecentSearchesPlugin } from '@algolia/autocomplete-plugin-recent-searches'; import {marked} from "marked"; import { trans, } from '../../translator'; /** * This controller is responsible for the search fields in the navbar and the homepage. * It uses the Algolia Autocomplete library to provide a fast and responsive search. */ export default class extends Controller { static targets = ["input"]; _autocomplete; // Highlight the search query in the results _highlight = (text, query) => { if (!text) return text; if (!query) return text; const HIGHLIGHT_PRE_TAG = '__aa-highlight__' const HIGHLIGHT_POST_TAG = '__/aa-highlight__' const escaped = query.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); const regex = new RegExp(escaped, 'gi'); return text.replace(regex, (match) => `${HIGHLIGHT_PRE_TAG}${match}${HIGHLIGHT_POST_TAG}`); } initialize() { // The endpoint for searching parts or assemblies const base_url = this.element.dataset.autocomplete; // The URL template for the part detail pages const part_detail_uri_template = this.element.dataset.detailUrl; // The URL template for the assembly detail pages const assembly_detail_uri_template = this.element.dataset.assemblyDetailUrl; // The URL template for the project detail pages const project_detail_uri_template = this.element.dataset.projectDetailUrl; const hasAssemblyDetailUrl = typeof assembly_detail_uri_template === "string" && assembly_detail_uri_template.length > 0; const hasProjectDetailUrl = typeof project_detail_uri_template === "string" && project_detail_uri_template.length > 0; //The URL of the placeholder picture const placeholder_image = this.element.dataset.placeholderImage; //If the element is in navbar mode, or not const navbar_mode = this.element.dataset.navbarMode === "true"; const that = this; const recentSearchesPlugin = createLocalStorageRecentSearchesPlugin({ key: 'RECENT_SEARCH', limit: 5, }); // Cache the last query to avoid fetching the same endpoint twice (parts source + assemblies source) let lastQuery = null; let lastFetchPromise = null; const fetchMixedItems = (query) => { if (query === lastQuery && lastFetchPromise) { return lastFetchPromise; } lastQuery = query; const urlString = base_url.replace('__QUERY__', encodeURIComponent(query)); const url = new URL(urlString, window.location.href); if (hasAssemblyDetailUrl || hasProjectDetailUrl) { url.searchParams.set('multidatasources', '1'); } lastFetchPromise = fetch(url.toString()) .then((response) => response.json()) .then((items) => { //Iterate over all fields besides the id and highlight them (if present) const fields = ["name", "description", "category", "footprint"]; items.forEach((item) => { for (const field of fields) { if (item[field] !== undefined && item[field] !== null) { item[field] = that._highlight(item[field], query); } } }); return items; }); return lastFetchPromise; }; this._autocomplete = autocomplete({ container: this.element, //Place the panel in the navbar, if the element is in navbar mode panelContainer: navbar_mode ? document.getElementById("navbar-search-form") : document.body, panelPlacement: this.element.dataset.panelPlacement, plugins: [recentSearchesPlugin], openOnFocus: true, placeholder: trans("search.placeholder"), translations: { submitButtonTitle: trans("search.submit") }, // Use a navigator compatible with turbo: navigator: { navigate({ itemUrl }) { window.Turbo.visit(itemUrl, { action: "advance" }); }, navigateNewTab({ itemUrl }) { const windowReference = window.open(itemUrl, '_blank', 'noopener'); if (windowReference) { windowReference.focus(); } }, navigateNewWindow({ itemUrl }) { window.open(itemUrl, '_blank', 'noopener'); }, }, // If the form is submitted, forward the term to the form onSubmit({ state, event, ...setters }) { //Put the current text into each target input field const input = that.inputTarget; if (!input) { return; } //Do not submit the form, if the input is empty if (state.query === "") { return; } input.value = state.query; input.form.requestSubmit(); }, getSources({ query }) { const sources = [ // Parts source (filtered from mixed endpoint results) { sourceId: 'parts', getItems() { return fetchMixedItems(query).then((items) => items.filter((item) => item.type !== "assembly") ); }, getItemUrl({ item }) { return part_detail_uri_template.replace('__ID__', item.id); }, templates: { header({ html }) { return html`${trans("part.labelp")}
`; }, item({ item, components, html }) { const details_url = part_detail_uri_template.replace('__ID__', item.id); return html`
${item.name}
${components.Highlight({hit: item, attribute: 'name'})}
${components.Highlight({hit: item, attribute: 'description'})} ${item.category ? html`

${components.Highlight({hit: item, attribute: 'category'})}

` : ""} ${item.footprint ? html`

${components.Highlight({hit: item, attribute: 'footprint'})}

` : ""}
`; }, }, }, ]; if (hasAssemblyDetailUrl) { sources.push( // Assemblies source (filtered from the same mixed endpoint results) { sourceId: 'assemblies', getItems() { return fetchMixedItems(query).then((items) => items.filter((item) => item.type === "assembly") ); }, getItemUrl({ item }) { return assembly_detail_uri_template.replace('__ID__', item.id); }, templates: { header({ html }) { return html`${trans(STATISTICS_ASSEMBLIES)}
`; }, item({ item, components, html }) { const details_url = assembly_detail_uri_template.replace('__ID__', item.id); return html`
${item.name}
${components.Highlight({hit: item, attribute: 'name'})}
${components.Highlight({hit: item, attribute: 'description'})}
`; }, }, } ); } if (hasProjectDetailUrl) { sources.push( // Projects source (filtered from the same mixed endpoint results) { sourceId: 'projects', getItems() { return fetchMixedItems(query).then((items) => items.filter((item) => item.type === "project") ); }, getItemUrl({ item }) { return project_detail_uri_template.replace('__ID__', item.id); }, templates: { header({ html }) { return html`${trans(STATISTICS_PROJECTS)}
`; }, item({ item, components, html }) { const details_url = project_detail_uri_template.replace('__ID__', item.id); return html`
${item.name}
${components.Highlight({hit: item, attribute: 'name'})}
${components.Highlight({hit: item, attribute: 'description'})}
`; }, }, } ); } return sources; }, }); //Try to find the input field and register a defocus handler. This is necessarry, as by default the autocomplete //lib has problems when multiple inputs are present on the page. (see https://github.com/algolia/autocomplete/issues/1216) const inputs = this.element.getElementsByClassName('aa-Input'); for (const input of inputs) { input.addEventListener('blur', () => { this._autocomplete.setIsOpen(false); }); } } }