[Feature] Modern UI roll out (#236)

This PR moves all the collection pages into the new UI in a rough push.
I did not put the same amount of care into these as into search, index, and predict.

## Major changes

- All modals are now migrated to a state based alpine.js implementation.
- jQuery is no longer present in the base layout; ajax is replace by native fetch api
- most of the pps.js is now obsolte (as I understand it; the code is not referenced any more @jebus  please double check)
- in-memory pagination for large result lists (set to 50; we can make that configurable later; performance degrades at around 1k) stukk a bit rough tracked in #235

## Minor things

- Sarch and index also use alpine now
- The loading spinner is now CSS animated (not sure if it currently gets correctly called)

## Not done

- Ihave not even cheked the admin pages. Not sure If these need migrations
- The temporary migration pages still use the old template. Not sure what is supposed to happen with those? @jebus

## What I did to test

- opend all pages in browse, and user ; plus all pages reachable from there.
- Interacted and tested the functionality of each modal superfically with exception of the API key modal (no functional test).

---
This PR is massive sorry for that; just did not want to push half-brokenn state.
@jebus @liambrydon I would be glad if you could click around and try to break it :)

Finally closes #133

Co-authored-by: Tim Lorsbach <tim@lorsba.ch>
Reviewed-on: enviPath/enviPy#236
Co-authored-by: Tobias O <tobias.olenyi@envipath.com>
Co-committed-by: Tobias O <tobias.olenyi@envipath.com>
This commit is contained in:
2025-11-26 23:16:44 +13:00
committed by jebus
parent 7f6f209b4a
commit 1a2c9bb543
110 changed files with 10784 additions and 9465 deletions

View File

@ -1,5 +1,10 @@
{% load static %}
<dialog id="search_modal" class="modal @max-sm:modal-top justify-center">
<dialog
id="search_modal"
class="modal @max-sm:modal-top justify-center"
x-data="searchModal()"
@close="reset()"
>
<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">
@ -23,7 +28,9 @@
<input
type="text"
autofocus
id="modal_searchbar"
x-ref="searchbar"
x-model="query"
@keydown.enter="performSearch('{{ SERVER_BASE }}')"
placeholder="Benfuracarb"
class="grow"
aria-label="Search"
@ -35,12 +42,11 @@
<button
type="button"
tabindex="0"
id="modal_mode_button"
popovertarget="search_dropdown_menu"
style="anchor-name:--1"
style="anchor-name: --1"
class="btn join-item btn-ghost"
>
Text
<span x-text="searchModeLabel"></span>
<svg
class="ml-1 h-4 w-4"
fill="none"
@ -59,13 +65,14 @@
tabindex="0"
class="dropdown dropdown-end menu bg-base-200 rounded-box w-64 p-2 shadow-lg"
popover
x-ref="modeDropdown"
id="search_dropdown_menu"
style="position-anchor:--anchor-2"
style="position-anchor: --anchor-2"
>
<li class="menu-title">Text</li>
<li>
<a
id="modal_dropdown_text"
@click.prevent="setSearchMode('text', 'Text')"
class="tooltip tooltip-left"
data-tip="Search on object names and descriptions"
>
@ -75,7 +82,7 @@
<li class="menu-title">SMILES</li>
<li>
<a
id="modal_dropdown_smiles_default"
@click.prevent="setSearchMode('smiles_default', 'Default')"
class="tooltip tooltip-left"
data-tip="Ignores stereochemistry and charge"
>
@ -84,7 +91,7 @@
</li>
<li>
<a
id="modal_dropdown_smiles_canonical"
@click.prevent="setSearchMode('smiles_canonical', 'Canonical')"
class="tooltip tooltip-left"
data-tip="Ignores stereochemistry, preserves charge"
>
@ -93,7 +100,7 @@
</li>
<li>
<a
id="modal_dropdown_smiles_exact"
@click.prevent="setSearchMode('smiles_exact', 'Exact')"
class="tooltip tooltip-left"
data-tip="Exact match for stereochemistry and charge"
>
@ -103,7 +110,7 @@
<li class="menu-title">InChI</li>
<li>
<a
id="modal_dropdown_inchikey"
@click.prevent="setSearchMode('inchikey', 'InChIKey')"
class="tooltip tooltip-left"
data-tip="Search by InChIKey"
>
@ -115,7 +122,7 @@
<button
type="button"
id="modal_search_button"
@click="performSearch('{{ SERVER_BASE }}')"
class="btn btn-xs btn-ghost join-item"
>
<kbd class="kbd kbd-sm text-base-content/50 p-1">
@ -143,18 +150,62 @@
<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 -->
<!-- Dynamic Pills -->
<template x-for="pkg in selectedPackages" :key="pkg.url">
<span class="badge badge-outline gap-2 max-w-xs">
<span class="truncate" :title="pkg.name" x-text="pkg.name"></span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 cursor-pointer hover:text-error shrink-0 rotate-45"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
@click="removePackage(pkg.url)"
>
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
</span>
</template>
<!-- Add Package Button -->
<button
type="button"
popovertarget="package_dropdown_menu"
style="anchor-name: --anchor-packages"
class="btn btn-sm btn-ghost gap-2 text-base-content/50"
>
<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
</button>
</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
x-ref="packageDropdown"
id="package_dropdown_menu"
style="position-anchor:--anchor-packages"
style="position-anchor: --anchor-packages"
>
{% if unreviewed_packages %}
<li class="menu-title">Reviewed Packages</li>
@ -164,11 +215,13 @@
class="package-option flex items-center justify-between"
data-package-url="{{ obj.url }}"
data-package-name="{{ obj.name }}"
@click.prevent.stop="togglePackage('{{ obj.url }}', '{{ obj.name }}')"
>
<span>{{ obj.name }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="package-checkmark hidden h-4 w-4"
class="h-4 w-4"
:class="isPackageSelected('{{ obj.url }}') ? '' : 'hidden'"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@ -190,11 +243,13 @@
class="package-option flex items-center justify-between"
data-package-url="{{ obj.url }}"
data-package-name="{{ obj.name }}"
@click.prevent.stop="togglePackage('{{ obj.url }}', '{{ obj.name }}')"
>
<span>{{ obj.name }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="package-checkmark hidden h-4 w-4"
class="h-4 w-4"
:class="isPackageSelected('{{ obj.url }}') ? '' : 'hidden'"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@ -217,11 +272,13 @@
class="package-option flex items-center justify-between"
data-package-url="{{ obj.url }}"
data-package-name="{{ obj.name }}"
@click.prevent.stop="togglePackage('{{ obj.url }}', '{{ obj.name }}')"
>
<span>{{ obj.name }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="package-checkmark hidden h-4 w-4"
class="h-4 w-4"
:class="isPackageSelected('{{ obj.url }}') ? '' : 'hidden'"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@ -241,12 +298,201 @@
</div>
<!-- Loading Indicator -->
<div id="search_loading" class="hidden shrink-0 justify-center py-8">
<div x-show="isSearching" class="flex 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>
<!-- Results Container -->
<div class="min-h-0 flex-1 overflow-y-auto p-2">
<!-- No packages selected error -->
<template x-if="results && results.error === 'no_packages'">
<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>
</template>
<!-- Search error -->
<template x-if="error">
<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 x-text="error"></span>
</div>
</template>
<!-- No results -->
<template x-if="results && !results.error && !hasResults()">
<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>
</template>
<!-- Results display -->
<template x-if="results && !results.error && hasResults()">
<div class="mb-2">
<div class="text-sm font-semibold text-base-content/70 mb-2">
Results
</div>
<!-- Compounds -->
<template x-if="results.Compounds && results.Compounds.length > 0">
<div class="collapse collapse-arrow bg-base-200 mb-2">
<input type="checkbox" checked />
<div class="collapse-title font-medium">
Compounds
<span
class="badge badge-neutral badge-sm ml-2"
x-text="results.Compounds.length"
></span>
</div>
<div class="collapse-content">
<template x-for="item in results.Compounds" :key="item.url">
<a
:href="item.url"
class="block px-4 py-2 hover:bg-base-300 rounded-lg transition-colors"
x-text="item.name"
></a>
</template>
</div>
</div>
</template>
<!-- Compound Structures -->
<template
x-if="results['Compound Structures'] && results['Compound Structures'].length > 0"
>
<div class="collapse collapse-arrow bg-base-200 mb-2">
<input type="checkbox" checked />
<div class="collapse-title font-medium">
Compound Structures
<span
class="badge badge-neutral badge-sm ml-2"
x-text="results['Compound Structures'].length"
></span>
</div>
<div class="collapse-content">
<template
x-for="item in results['Compound Structures']"
:key="item.url"
>
<a
:href="item.url"
class="block px-4 py-2 hover:bg-base-300 rounded-lg transition-colors"
x-text="item.name"
></a>
</template>
</div>
</div>
</template>
<!-- Rules -->
<template x-if="results.Rules && results.Rules.length > 0">
<div class="collapse collapse-arrow bg-base-200 mb-2">
<input type="checkbox" checked />
<div class="collapse-title font-medium">
Rules
<span
class="badge badge-neutral badge-sm ml-2"
x-text="results.Rules.length"
></span>
</div>
<div class="collapse-content">
<template x-for="item in results.Rules" :key="item.url">
<a
:href="item.url"
class="block px-4 py-2 hover:bg-base-300 rounded-lg transition-colors"
x-text="item.name"
></a>
</template>
</div>
</div>
</template>
<!-- Reactions -->
<template x-if="results.Reactions && results.Reactions.length > 0">
<div class="collapse collapse-arrow bg-base-200 mb-2">
<input type="checkbox" checked />
<div class="collapse-title font-medium">
Reactions
<span
class="badge badge-neutral badge-sm ml-2"
x-text="results.Reactions.length"
></span>
</div>
<div class="collapse-content">
<template x-for="item in results.Reactions" :key="item.url">
<a
:href="item.url"
class="block px-4 py-2 hover:bg-base-300 rounded-lg transition-colors"
x-text="item.name"
></a>
</template>
</div>
</div>
</template>
<!-- Pathways -->
<template x-if="results.Pathways && results.Pathways.length > 0">
<div class="collapse collapse-arrow bg-base-200 mb-2">
<input type="checkbox" checked />
<div class="collapse-title font-medium">
Pathways
<span
class="badge badge-neutral badge-sm ml-2"
x-text="results.Pathways.length"
></span>
</div>
<div class="collapse-content">
<template x-for="item in results.Pathways" :key="item.url">
<a
:href="item.url"
class="block px-4 py-2 hover:bg-base-300 rounded-lg transition-colors"
x-text="item.name"
></a>
</template>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- Backdrop to close -->
@ -254,433 +500,3 @@
<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>