forked from enviPath/enviPy
Modal now opens on badge click. Modal now closes on random click around Reviewed-on: enviPath/enviPy#192 Co-authored-by: Tobias O <tobias.olenyi@envipath.com> Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
687 lines
23 KiB
HTML
687 lines
23 KiB
HTML
{% load static %}
|
|
<dialog id="search_modal" class="modal @max-sm:modal-top justify-center">
|
|
<div class="modal-box h-full w-lvw p-1 sm:h-8/12 sm:w-[85vw] sm:max-w-5xl">
|
|
<!-- Search Input and Mode Selector -->
|
|
<div class="form-control mb-4 w-full shrink-0">
|
|
<div class="join m-0 w-full items-center p-3">
|
|
<label class="input join-item input-ghost grow">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="lucide lucide-search-icon lucide-search"
|
|
>
|
|
<path d="m21 21-4.34-4.34" />
|
|
<circle cx="11" cy="11" r="8" />
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
autofocus
|
|
id="modal_searchbar"
|
|
placeholder="Benfuracarb"
|
|
class="grow"
|
|
aria-label="Search"
|
|
/>
|
|
</label>
|
|
|
|
<!-- Mode Dropdown -->
|
|
<div>
|
|
<button
|
|
type="button"
|
|
tabindex="0"
|
|
id="modal_mode_button"
|
|
popovertarget="search_dropdown_menu"
|
|
style="anchor-name:--1"
|
|
class="btn join-item btn-ghost"
|
|
>
|
|
Text
|
|
<svg
|
|
class="ml-1 h-4 w-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
<ul
|
|
tabindex="0"
|
|
class="dropdown dropdown-end menu bg-base-200 rounded-box w-64 p-2 shadow-lg"
|
|
popover
|
|
id="search_dropdown_menu"
|
|
style="position-anchor:--anchor-2"
|
|
>
|
|
<li class="menu-title">Text</li>
|
|
<li>
|
|
<a
|
|
id="modal_dropdown_text"
|
|
class="tooltip tooltip-left"
|
|
data-tip="Search on object names and descriptions"
|
|
>
|
|
Text
|
|
</a>
|
|
</li>
|
|
<li class="menu-title">SMILES</li>
|
|
<li>
|
|
<a
|
|
id="modal_dropdown_smiles_default"
|
|
class="tooltip tooltip-left"
|
|
data-tip="Ignores stereochemistry and charge"
|
|
>
|
|
Default
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a
|
|
id="modal_dropdown_smiles_canonical"
|
|
class="tooltip tooltip-left"
|
|
data-tip="Ignores stereochemistry, preserves charge"
|
|
>
|
|
Canonical
|
|
</a>
|
|
</li>
|
|
<li>
|
|
<a
|
|
id="modal_dropdown_smiles_exact"
|
|
class="tooltip tooltip-left"
|
|
data-tip="Exact match for stereochemistry and charge"
|
|
>
|
|
Exact
|
|
</a>
|
|
</li>
|
|
<li class="menu-title">InChI</li>
|
|
<li>
|
|
<a
|
|
id="modal_dropdown_inchikey"
|
|
class="tooltip tooltip-left"
|
|
data-tip="Search by InChIKey"
|
|
>
|
|
InChIKey
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
id="modal_search_button"
|
|
class="btn btn-xs btn-ghost join-item"
|
|
>
|
|
<kbd class="kbd kbd-sm text-base-content/50 p-1">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
class="lucide lucide-corner-down-left-icon lucide-corner-down-left"
|
|
>
|
|
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
|
|
<path d="m9 10-5 5 5 5" />
|
|
</svg>
|
|
</kbd>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Package Selector with Pills -->
|
|
<div class="form-control mb-4 shrink-0">
|
|
<!-- Pills Container -->
|
|
<div
|
|
id="modal_package_pills_container"
|
|
class="border-base-300 m-3 flex min-h-12 flex-wrap items-center gap-2 rounded-lg border-2 border-dashed p-3"
|
|
>
|
|
<!-- Pills will be added here dynamically -->
|
|
</div>
|
|
|
|
<!-- Package Dropdown Menu -->
|
|
<ul
|
|
class="dropdown dropdown-center menu bg-base-200 rounded-box max-h-96 w-80 overflow-y-auto p-2 shadow-lg"
|
|
popover
|
|
id="package_dropdown_menu"
|
|
style="position-anchor:--anchor-packages"
|
|
>
|
|
{% if unreviewed_packages %}
|
|
<li class="menu-title">Reviewed Packages</li>
|
|
{% for obj in reviewed_packages %}
|
|
<li>
|
|
<a
|
|
class="package-option flex items-center justify-between"
|
|
data-package-url="{{ obj.url }}"
|
|
data-package-name="{{ obj.name }}"
|
|
>
|
|
<span>{{ obj.name }}</span>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="package-checkmark hidden h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M5 13l4 4L19 7"
|
|
/>
|
|
</svg>
|
|
</a>
|
|
</li>
|
|
{% endfor %}
|
|
<li class="menu-title">Unreviewed Packages</li>
|
|
{% for obj in unreviewed_packages %}
|
|
<li>
|
|
<a
|
|
class="package-option flex items-center justify-between"
|
|
data-package-url="{{ obj.url }}"
|
|
data-package-name="{{ obj.name }}"
|
|
>
|
|
<span>{{ obj.name }}</span>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="package-checkmark hidden h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M5 13l4 4L19 7"
|
|
/>
|
|
</svg>
|
|
</a>
|
|
</li>
|
|
{% endfor %}
|
|
{% else %}
|
|
<li class="menu-title">Reviewed Packages</li>
|
|
{% for obj in reviewed_packages %}
|
|
<li>
|
|
<a
|
|
class="package-option flex items-center justify-between"
|
|
data-package-url="{{ obj.url }}"
|
|
data-package-name="{{ obj.name }}"
|
|
>
|
|
<span>{{ obj.name }}</span>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="package-checkmark hidden h-4 w-4"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M5 13l4 4L19 7"
|
|
/>
|
|
</svg>
|
|
</a>
|
|
</li>
|
|
{% endfor %}
|
|
{% endif %}
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Loading Indicator -->
|
|
<div id="search_loading" class="hidden shrink-0 justify-center py-8">
|
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
</div>
|
|
|
|
<!-- Results Container - scrollable -->
|
|
<div id="search_results" class="min-h-0 flex-1 overflow-y-auto p-2"></div>
|
|
</div>
|
|
|
|
<!-- Backdrop to close -->
|
|
<form method="dialog" class="modal-backdrop">
|
|
<button>close</button>
|
|
</form>
|
|
</dialog>
|
|
|
|
<script>
|
|
(function () {
|
|
// Package Selector Module - Data-driven multiselect package selection
|
|
const PackageSelector = {
|
|
// Single source of truth: array of selected packages
|
|
selectedPackages: [],
|
|
|
|
elements: {
|
|
pillsContainer: null,
|
|
packageDropdown: null,
|
|
packageOptions: null,
|
|
},
|
|
|
|
init() {
|
|
this.cacheElements();
|
|
this.loadInitialSelection();
|
|
this.attachEventListeners();
|
|
this.render();
|
|
},
|
|
|
|
cacheElements() {
|
|
this.elements.pillsContainer = document.getElementById(
|
|
"modal_package_pills_container",
|
|
);
|
|
this.elements.packageDropdown = document.getElementById(
|
|
"package_dropdown_menu",
|
|
);
|
|
this.elements.packageOptions =
|
|
document.querySelectorAll(".package-option");
|
|
},
|
|
|
|
loadInitialSelection() {
|
|
// Load pre-selected packages from server-rendered pills
|
|
const existingPills =
|
|
this.elements.pillsContainer.querySelectorAll(".badge");
|
|
existingPills.forEach((pill) => {
|
|
this.selectedPackages.push({
|
|
url: pill.dataset.packageUrl,
|
|
name: pill.dataset.packageName,
|
|
});
|
|
});
|
|
|
|
// If no pills found, select all reviewed packages by default
|
|
if (this.selectedPackages.length === 0) {
|
|
// Iterate through all menu items and collect reviewed packages
|
|
const menuItems =
|
|
this.elements.packageDropdown.querySelectorAll("li");
|
|
|
|
for (const item of menuItems) {
|
|
// Check if this is the "Unreviewed Packages" menu title
|
|
if (
|
|
item.classList.contains("menu-title") &&
|
|
item.textContent.trim() === "Unreviewed Packages"
|
|
) {
|
|
break; // Stop processing after this point
|
|
}
|
|
|
|
// Check for package options (only reviewed packages reach here)
|
|
const packageOption = item.querySelector(".package-option");
|
|
if (packageOption) {
|
|
this.selectedPackages.push({
|
|
url: packageOption.dataset.packageUrl,
|
|
name: packageOption.dataset.packageName,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
attachEventListeners() {
|
|
// Toggle package selection on dropdown item click
|
|
this.elements.packageOptions.forEach((option) => {
|
|
option.addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation(); // Prevent dropdown from closing
|
|
const packageUrl = option.dataset.packageUrl;
|
|
const packageName = option.dataset.packageName;
|
|
this.togglePackageSelection(packageUrl, packageName);
|
|
});
|
|
});
|
|
|
|
// Remove package when X is clicked (using event delegation)
|
|
this.elements.pillsContainer.addEventListener("click", (e) => {
|
|
if (
|
|
e.target.classList.contains("package-remove-btn") ||
|
|
e.target.closest(".package-remove-btn")
|
|
) {
|
|
const pill = e.target.closest(".badge");
|
|
if (pill) {
|
|
const packageUrl = pill.dataset.packageUrl;
|
|
this.removePackage(packageUrl);
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
togglePackageSelection(packageUrl, packageName) {
|
|
const index = this.selectedPackages.findIndex(
|
|
(pkg) => pkg.url === packageUrl,
|
|
);
|
|
|
|
if (index !== -1) {
|
|
// Remove from selection
|
|
this.selectedPackages.splice(index, 1);
|
|
} else {
|
|
// Add to selection
|
|
this.selectedPackages.push({ url: packageUrl, name: packageName });
|
|
}
|
|
|
|
this.render();
|
|
},
|
|
|
|
removePackage(packageUrl) {
|
|
const index = this.selectedPackages.findIndex(
|
|
(pkg) => pkg.url === packageUrl,
|
|
);
|
|
if (index !== -1) {
|
|
this.selectedPackages.splice(index, 1);
|
|
this.render();
|
|
}
|
|
},
|
|
|
|
render() {
|
|
this.renderPills();
|
|
this.renderAddButton();
|
|
this.renderCheckmarks();
|
|
},
|
|
|
|
renderPills() {
|
|
// Clear existing pills and button (except placeholder)
|
|
const pills = this.elements.pillsContainer.querySelectorAll(".badge");
|
|
pills.forEach((pill) => pill.remove());
|
|
|
|
const existingButton = this.elements.pillsContainer.querySelector(
|
|
"#modal_package_add_button",
|
|
);
|
|
if (existingButton) {
|
|
existingButton.remove();
|
|
}
|
|
|
|
// Create pills from data
|
|
this.selectedPackages.forEach((pkg) => {
|
|
const pill = this.createPillElement(pkg.url, pkg.name);
|
|
this.elements.pillsContainer.appendChild(pill);
|
|
});
|
|
},
|
|
|
|
renderAddButton() {
|
|
// Only render button if there are packages available
|
|
if (this.elements.packageOptions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.id = "modal_package_add_button";
|
|
button.setAttribute("popovertarget", "package_dropdown_menu");
|
|
button.style.cssText = "anchor-name:--anchor-packages";
|
|
button.className = "btn btn-sm btn-ghost gap-2 text-base-content/50";
|
|
|
|
button.innerHTML = `
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus-icon lucide-plus"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
|
|
Add Package
|
|
`;
|
|
|
|
this.elements.pillsContainer.appendChild(button);
|
|
},
|
|
|
|
createPillElement(packageUrl, packageName) {
|
|
const pill = document.createElement("span");
|
|
pill.className = "badge badge-outline gap-2 max-w-xs";
|
|
pill.dataset.packageUrl = packageUrl;
|
|
pill.dataset.packageName = packageName;
|
|
|
|
pill.innerHTML = `
|
|
<span class="truncate" title="${packageName}">${packageName}</span>
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg"
|
|
class="h-4 w-4 cursor-pointer hover:text-error package-remove-btn flex-shrink-0 rotate-45"
|
|
viewBox="0 0 24 24"
|
|
fill="none" stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M5 12h14"/><path d="M12 5v14"/>
|
|
</svg>
|
|
`;
|
|
|
|
return pill;
|
|
},
|
|
|
|
renderCheckmarks() {
|
|
// Update all checkmarks based on selected packages
|
|
this.elements.packageOptions.forEach((option) => {
|
|
const packageUrl = option.dataset.packageUrl;
|
|
const isSelected = this.selectedPackages.some(
|
|
(pkg) => pkg.url === packageUrl,
|
|
);
|
|
const checkmark = option.querySelector(".package-checkmark");
|
|
|
|
if (checkmark) {
|
|
checkmark.classList.toggle("hidden", !isSelected);
|
|
}
|
|
});
|
|
},
|
|
|
|
getSelectedPackages() {
|
|
return this.selectedPackages.map((pkg) => pkg.url);
|
|
},
|
|
};
|
|
|
|
// Modal and Search Management
|
|
const modal = document.getElementById("search_modal");
|
|
const searchbar = document.getElementById("modal_searchbar");
|
|
const searchButton = document.getElementById("modal_search_button");
|
|
const modeButton = document.getElementById("modal_mode_button");
|
|
const resultsDiv = document.getElementById("search_results");
|
|
const loadingDiv = document.getElementById("search_loading");
|
|
|
|
// MutationObserver to detect when modal opens
|
|
const observer = new MutationObserver((mutations) => {
|
|
mutations.forEach((mutation) => {
|
|
if (mutation.attributeName === "open" && modal.open) {
|
|
PackageSelector.render();
|
|
// Delay focus to allow CSS transitions to complete (modal has 0.3s transition)
|
|
setTimeout(() => {
|
|
searchbar.focus();
|
|
}, 320);
|
|
}
|
|
});
|
|
});
|
|
|
|
observer.observe(modal, { attributes: true });
|
|
|
|
// Close modal when clicking outside (on the backdrop)
|
|
// According to DaisyUI docs: https://daisyui.com/components/modal/
|
|
// The backdrop form with method="dialog" should handle closing automatically when its button is clicked.
|
|
// We also handle clicks directly on the dialog element (backdrop area) or the backdrop form.
|
|
modal.addEventListener("click", function (event) {
|
|
const backdrop = modal.querySelector(".modal-backdrop");
|
|
const modalBox = modal.querySelector(".modal-box");
|
|
|
|
// Close if clicking directly on the dialog element (backdrop area)
|
|
// or on the backdrop form (but ensure we're not clicking on modal-box content)
|
|
if (
|
|
event.target === modal ||
|
|
(backdrop &&
|
|
(event.target === backdrop || backdrop.contains(event.target)) &&
|
|
!modalBox.contains(event.target))
|
|
) {
|
|
modal.close();
|
|
}
|
|
});
|
|
|
|
// Clear results when modal closes
|
|
modal.addEventListener("close", function () {
|
|
resultsDiv.innerHTML = "";
|
|
loadingDiv.classList.add("hidden");
|
|
searchbar.value = "";
|
|
});
|
|
|
|
// Mode dropdown handlers
|
|
const dropdownMenu = document.getElementById("search_dropdown_menu");
|
|
|
|
const modeButtons = [
|
|
{ id: "modal_dropdown_text", text: "Text" },
|
|
{ id: "modal_dropdown_smiles_default", text: "Default" },
|
|
{ id: "modal_dropdown_smiles_canonical", text: "Canonical" },
|
|
{ id: "modal_dropdown_smiles_exact", text: "Exact" },
|
|
{ id: "modal_dropdown_inchikey", text: "InChIKey" },
|
|
];
|
|
|
|
modeButtons.forEach(({ id, text }) => {
|
|
document.getElementById(id).addEventListener("click", function (e) {
|
|
e.preventDefault();
|
|
modeButton.innerHTML =
|
|
text +
|
|
` <svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
</svg>`;
|
|
// Close dropdown using popover API
|
|
if (dropdownMenu && typeof dropdownMenu.hidePopover === "function") {
|
|
dropdownMenu.hidePopover();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Initialize Package Selector
|
|
PackageSelector.init();
|
|
|
|
// Search Response Handler
|
|
function handleSearchResponse(data) {
|
|
resultsDiv.innerHTML = "";
|
|
|
|
function makeContent(objs) {
|
|
let links = "";
|
|
objs.forEach((obj) => {
|
|
links += `<a href="${obj.url}" class="block px-4 py-2 hover:bg-base-300 rounded-lg transition-colors">${obj.name}</a>`;
|
|
});
|
|
return links;
|
|
}
|
|
|
|
let allEmpty = true;
|
|
let content = "";
|
|
|
|
// Category order for better UX
|
|
const categoryOrder = [
|
|
"Compounds",
|
|
"Compound Structures",
|
|
"Rules",
|
|
"Reactions",
|
|
"Pathways",
|
|
];
|
|
|
|
categoryOrder.forEach((key) => {
|
|
if (!data[key] || data[key].length < 1) {
|
|
return;
|
|
}
|
|
allEmpty = false;
|
|
|
|
content += `
|
|
<div class="collapse collapse-arrow bg-base-200 mb-2">
|
|
<input type="checkbox" checked />
|
|
<div class="collapse-title font-medium">
|
|
${key} <span class="badge badge-neutral badge-sm ml-2">${data[key].length}</span>
|
|
</div>
|
|
<div class="collapse-content">
|
|
${makeContent(data[key])}
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
if (allEmpty) {
|
|
resultsDiv.innerHTML = `
|
|
<div class="alert alert-warning">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
|
</svg>
|
|
<span>No results found</span>
|
|
</div>
|
|
`;
|
|
} else {
|
|
resultsDiv.innerHTML = `
|
|
<div class="mb-2">
|
|
<div class="text-sm font-semibold text-base-content/70 mb-2">Results</div>
|
|
${content}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// Search Execution
|
|
function performSearch(e) {
|
|
e.preventDefault();
|
|
|
|
const query = searchbar.value.trim();
|
|
|
|
if (!query) {
|
|
console.log("Search phrase empty, won't do search");
|
|
return;
|
|
}
|
|
|
|
const selPacks = PackageSelector.getSelectedPackages();
|
|
|
|
if (selPacks.length < 1) {
|
|
console.log("No package selected, won't do search");
|
|
resultsDiv.innerHTML = `
|
|
<div class="alert alert-info">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<span>Please select at least one package</span>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const mode = modeButton.textContent.trim().toLowerCase();
|
|
|
|
const params = new URLSearchParams();
|
|
selPacks.forEach((pack) => params.append("packages", pack));
|
|
params.append("search", query);
|
|
params.append("mode", mode);
|
|
|
|
// Show loading
|
|
loadingDiv.classList.remove("hidden");
|
|
resultsDiv.innerHTML = "";
|
|
|
|
fetch(`{{ SERVER_BASE }}/search?${params.toString()}`, {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
},
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error("Search request failed");
|
|
}
|
|
return response.json();
|
|
})
|
|
.then((result) => {
|
|
loadingDiv.classList.add("hidden");
|
|
handleSearchResponse(result);
|
|
})
|
|
.catch((error) => {
|
|
loadingDiv.classList.add("hidden");
|
|
console.error("Search error:", error);
|
|
resultsDiv.innerHTML = `
|
|
<div class="alert alert-error">
|
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<span>Search failed. Please try again.</span>
|
|
</div>
|
|
`;
|
|
});
|
|
}
|
|
|
|
// Event listeners for search
|
|
searchButton.addEventListener("click", performSearch);
|
|
searchbar.addEventListener("keydown", function (e) {
|
|
if (e.key === "Enter") {
|
|
performSearch(e);
|
|
}
|
|
});
|
|
})();
|
|
</script>
|