build: Add HTMX copy step to build:ts script

- Modified package.json build:ts to copy htmx.min.js from node_modules to web/static/
- This fixes the 404 error for /static/htmx.min.js that occurred after ESBuild migration
- Added htmx.min.js to static files

See ESBUILD_MIGRATION_PLAN.md for migration context.
This commit is contained in:
2026-03-08 21:35:35 -04:00
parent a84ffb253e
commit 0af319fef9
5 changed files with 331 additions and 35 deletions
+8 -7
View File
@@ -5,24 +5,25 @@
"scripts": {
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
"build:ts": "esbuild web/src/main.ts --bundle --outfile=web/static/main.js --sourcemap --target=es2020 --minify",
"build:ts:dev": "esbuild web/src/main.ts --bundle --outfile=web/static/main.js --sourcemap --target=es2020",
"build:ts:watch": "esbuild web/src/main.ts --bundle --outfile=web/static/main.js --sourcemap --target=es2020 --watch",
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && esbuild web/src/main.ts --bundle --outfile=web/static/main.js --sourcemap --target=es2020 --minify",
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && esbuild web/src/main.ts --bundle --outfile=web/static/main.js --sourcemap --target=es2020",
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && esbuild web/src/main.ts --bundle --outfile=web/static/main.js --sourcemap --target=es2020 --watch",
"build": "npm run build:ts && npm run build:css:prod",
"dev": "npm run build:ts:dev && npm run build:css"
},
"dependencies": {
"alpinejs": "^3.15.8",
"lunr": "^2.3.9",
"chart.js": "^4.5.1",
"highlight.js": "^11.11.1",
"htmx.org": "^2.0.8",
"highlight.js": "^11.11.1"
"lunr": "^2.3.9"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.27",
"esbuild": "^0.27.3",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"esbuild": "^0.27.3"
"typescript": "^5.9.3"
}
}
+83 -1
View File
@@ -1,2 +1,84 @@
(()=>{var i=()=>{let t=document.getElementById("theme-dropdown");if(t){t.classList.toggle("hidden");let e=document.getElementById("user-menu");e&&!t.classList.contains("hidden")&&e.classList.add("hidden")}},a=()=>{let t=document.getElementById("user-menu");if(t){t.classList.toggle("hidden");let e=document.getElementById("theme-dropdown");e&&!t.classList.contains("hidden")&&e.classList.add("hidden")}},c=t=>{window.applyTheme&&window.applyTheme(t);let e=localStorage.getItem("token");e&&fetch("/api/auth/theme",{method:"PUT",headers:{"Content-Type":"application/json",Authorization:`Bearer ${e}`},body:JSON.stringify({theme:t})}).catch(o=>console.log("Theme save failed",o));let n=document.getElementById("theme-dropdown");n&&n.classList.add("hidden")},l=()=>{localStorage.removeItem("token"),localStorage.removeItem("user"),window.location.href="/"};document.addEventListener("click",t=>{let e=t.target,n=document.getElementById("theme-dropdown"),o=document.getElementById("user-menu"),s=e?.closest('button[onclick="toggleThemeDropdown()"]'),d=e?.closest('button[onclick="toggleUserMenu()"]');!s&&n&&!n.classList.contains("hidden")&&(n.contains(e)||n.classList.add("hidden")),!d&&o&&!o.classList.contains("hidden")&&(o.contains(e)||o.classList.add("hidden"))});window.toggleThemeDropdown=i;window.toggleUserMenu=a;window.changeThemeTo=c;window.logout=l;})();
// Header functionality
import { Alpine } from "./alpine";
import { updateThemeIndicators } from "./themeDropdown";
const toggleThemeDropdown = () => {
const dropdown = document.getElementById("theme-dropdown");
if (dropdown) {
dropdown.classList.toggle("hidden");
// Close user menu if open
const userMenu = document.getElementById("user-menu");
if (userMenu && !dropdown.classList.contains("hidden")) {
userMenu.classList.add("hidden");
}
}
};
const toggleUserMenu = () => {
const menu = document.getElementById("user-menu");
if (menu) {
menu.classList.toggle("hidden");
// Close theme dropdown if open
const themeDropdown = document.getElementById("theme-dropdown");
if (themeDropdown && !menu.classList.contains("hidden")) {
themeDropdown.classList.add("hidden");
}
}
};
const changeThemeTo = (theme) => {
// Apply the theme using the consolidated function from theme.ts
if (window.applyTheme) {
window.applyTheme(theme);
}
// Save to server if logged in
const token = localStorage.getItem("token");
if (token) {
fetch("/api/auth/theme", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ theme }),
}).catch((err) => console.log("Theme save failed", err));
}
// Close dropdown
const dropdown = document.getElementById("theme-dropdown");
if (dropdown) {
dropdown.classList.add("hidden");
}
};
const logout = () => {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/";
};
// Close dropdowns when clicking outside
document.addEventListener("click", (e) => {
const target = e.target;
const themeDropdown = document.getElementById("theme-dropdown");
const userMenu = document.getElementById("user-menu");
const themeButton = target?.closest('button[onclick="toggleThemeDropdown()"]');
const userButton = target?.closest('button[onclick="toggleUserMenu()"]');
if (!themeButton &&
themeDropdown &&
!themeDropdown.classList.contains("hidden")) {
if (!themeDropdown.contains(target)) {
themeDropdown.classList.add("hidden");
}
}
if (!userButton && userMenu && !userMenu.classList.contains("hidden")) {
if (!userMenu.contains(target)) {
userMenu.classList.add("hidden");
}
}
});
export { toggleThemeDropdown, toggleUserMenu, changeThemeTo, logout };
Alpine.global("header", {
logout,
toggleThemeDropdown,
toggleUserMenu,
changeThemeTo: (theme) => {
changeThemeTo(theme);
updateThemeIndicators(); // Call themeDropdown function
},
});
//# sourceMappingURL=header.js.map
+1
View File
File diff suppressed because one or more lines are too long
+230 -18
View File
@@ -1,55 +1,267 @@
(()=>{var i=null,x=300,v=2;function b(){let e=document.getElementById("header-search");if(!e){console.warn("Search input not found");return}e.addEventListener("input",E),e.addEventListener("keydown",I),e.addEventListener("focus",()=>{e.value.length>=v&&y(e.value)}),document.addEventListener("click",r=>{let t=document.getElementById("search-results"),s=document.getElementById("header-search");t&&!t.contains(r.target)&&r.target!==s&&c()})}function E(e){let t=e.target.value.trim();if(i&&clearTimeout(i),t.length<v){c();return}i=setTimeout(()=>{y(t)},x)}function I(e){let r=document.getElementById("search-results");if(!r||r.classList.contains("hidden"))return;let t=r.querySelectorAll(".search-result-item"),s=parseInt(r.dataset.selectedIndex||"-1");if(e.key==="ArrowDown"){e.preventDefault();let n=Math.min(s+1,t.length-1);h(t,n)}else if(e.key==="ArrowUp"){e.preventDefault();let n=Math.max(s-1,-1);h(t,n)}else if(e.key==="Enter"){if(e.preventDefault(),s>=0&&t[s]){let n=t[s].querySelector("a");n&&n.click()}}else e.key==="Escape"&&c()}function h(e,r){e.forEach((s,n)=>{n===r?s.classList.add("bg-opacity-80"):s.classList.remove("bg-opacity-80")});let t=document.getElementById("search-results");t&&(t.dataset.selectedIndex=r.toString())}function y(e){let r=localStorage.getItem("token");if(!r){console.warn("No authentication token found");return}k(),fetch(`/api/media-items/search?q=${encodeURIComponent(e)}`,{headers:{Authorization:`Bearer ${r}`,"Content-Type":"application/json"}}).then(t=>t.status===404?{error:"no results found",results:[]}:t.json()).then(t=>{t&&"error"in t&&t.error==="no results found"?d(e):Array.isArray(t)&&t.length>0?L(t,e):(Array.isArray(t),d(e))}).catch(t=>{console.error("Search error:",t),S()})}function k(){l();let e=document.getElementById("search-results");e&&(e.innerHTML=`
let searchInputTimeout = null;
const SEARCH_DEBOUNCE_MS = 300;
const SEARCH_MIN_CHARS = 2;
function initializeSearch() {
const searchInput = document.getElementById("header-search");
if (!searchInput) {
console.warn("Search input not found");
return;
}
searchInput.addEventListener("input", handleSearchInput);
searchInput.addEventListener("keydown", handleSearchKeydown);
searchInput.addEventListener("focus", () => {
if (searchInput.value.length >= SEARCH_MIN_CHARS) {
performSearch(searchInput.value);
}
});
document.addEventListener("click", (e) => {
const searchResults = document.getElementById("search-results");
const searchInputEl = document.getElementById("header-search");
if (searchResults &&
!searchResults.contains(e.target) &&
e.target !== searchInputEl) {
hideSearchResults();
}
});
}
function handleSearchInput(e) {
const target = e.target;
const query = target.value.trim();
if (searchInputTimeout) {
clearTimeout(searchInputTimeout);
}
if (query.length < SEARCH_MIN_CHARS) {
hideSearchResults();
return;
}
searchInputTimeout = setTimeout(() => {
performSearch(query);
}, SEARCH_DEBOUNCE_MS);
}
function handleSearchKeydown(e) {
const searchResults = document.getElementById("search-results");
if (!searchResults || searchResults.classList.contains("hidden")) {
return;
}
const items = searchResults.querySelectorAll(".search-result-item");
const currentIndex = parseInt(searchResults.dataset.selectedIndex || "-1");
if (e.key === "ArrowDown") {
e.preventDefault();
const nextIndex = Math.min(currentIndex + 1, items.length - 1);
selectSearchResult(items, nextIndex);
}
else if (e.key === "ArrowUp") {
e.preventDefault();
const prevIndex = Math.max(currentIndex - 1, -1);
selectSearchResult(items, prevIndex);
}
else if (e.key === "Enter") {
e.preventDefault();
if (currentIndex >= 0 && items[currentIndex]) {
const link = items[currentIndex].querySelector("a");
if (link)
link.click();
}
}
else if (e.key === "Escape") {
hideSearchResults();
}
}
function selectSearchResult(items, index) {
items.forEach((item, i) => {
if (i === index) {
item.classList.add("bg-opacity-80");
}
else {
item.classList.remove("bg-opacity-80");
}
});
const searchResults = document.getElementById("search-results");
if (searchResults) {
searchResults.dataset.selectedIndex = index.toString();
}
}
function performSearch(query) {
const token = localStorage.getItem("token");
if (!token) {
console.warn("No authentication token found");
return;
}
showSearchLoading();
fetch(`/api/media-items/search?q=${encodeURIComponent(query)}`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
.then((response) => {
if (response.status === 404) {
return { error: "no results found", results: [] };
}
return response.json();
})
.then((data) => {
hideSearchLoading();
if (data && "error" in data && data.error === "no results found") {
showNoResults(query);
}
else if (Array.isArray(data) && data.length > 0) {
showSearchResults(data, query);
}
else if (Array.isArray(data)) {
showNoResults(query);
}
else {
showNoResults(query);
}
})
.catch((error) => {
hideSearchLoading();
console.error("Search error:", error);
showSearchError();
});
}
function showSearchLoading() {
createSearchResultsContainer();
const searchResults = document.getElementById("search-results");
if (!searchResults)
return;
searchResults.innerHTML = `
<div class="p-4 text-center" style="color: var(--text-secondary)">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2" style="border-color: var(--accent)"></div>
<p class="mt-2 text-sm">Searching...</p>
</div>
`,e.classList.remove("hidden"))}function L(e,r){l();let t=document.getElementById("search-results");if(!t)return;t.dataset.selectedIndex="-1";let s={ebooks:"\u{1F4DA}",comics:"\u{1F4D6}",manga:"\u{1F5FE}"},n=`
`;
searchResults.classList.remove("hidden");
}
function hideSearchLoading() { }
function showSearchResults(results, query) {
createSearchResultsContainer();
const searchResults = document.getElementById("search-results");
if (!searchResults)
return;
searchResults.dataset.selectedIndex = "-1";
const libraryIconMap = {
ebooks: "📚",
comics: "📖",
manga: "🗾",
};
let html = `
<div class="p-3 border-b" style="border-color: var(--border)">
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
${e.length} result${e.length!==1?"s":""} for "${a(r)}"
${results.length} result${results.length !== 1 ? "s" : ""} for "${searchEscapeHtml(query)}"
</p>
</div>
<div class="max-h-96 overflow-y-auto">
`;e.forEach((o,p)=>{let f=s[o.library_type_name]||"\u{1F4C1}",g=m(o.title,r),u=o.author?m(o.author,r):"";n+=`
`;
results.forEach((item, index) => {
const icon = libraryIconMap[item.library_type_name] || "📁";
const titleHtml = highlightMatch(item.title, query);
const authorHtml = item.author ? highlightMatch(item.author, query) : "";
html += `
<div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer"
style="border-color: var(--border); background-color: var(--bg-secondary)"
data-index="${p}">
data-index="${index}">
<a href="/bookshelf"
class="block"
onclick="window.selectLibraryAndBook('${o.library_id}', '${o.id}')">
onclick="window.selectLibraryAndBook('${item.library_id}', '${item.id}')">
<div class="flex items-start space-x-3">
<div class="text-2xl">${f}</div>
<div class="text-2xl">${icon}</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">
${g}
${titleHtml}
</h4>
${u?`<p class="text-xs truncate" style="color: var(--text-secondary)">${u}</p>`:""}
${authorHtml ? `<p class="text-xs truncate" style="color: var(--text-secondary)">${authorHtml}</p>` : ""}
<p class="text-xs mt-1" style="color: var(--text-secondary)">
${a(o.library_name)}
${searchEscapeHtml(item.library_name)}
</p>
</div>
</div>
</a>
</div>
`}),n+=`
`;
});
html += `
</div>
<div class="p-2 border-t text-center" style="border-color: var(--border)">
<p class="text-xs" style="color: var(--text-secondary)">
Press <kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">\u2191\u2193</kbd> to navigate,
Press <kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">↑↓</kbd> to navigate,
<kbd class="px-1 py-0.5 rounded" style="background-color: var(--bg-primary)">Enter</kbd> to select
</p>
</div>
`,t.innerHTML=n,t.classList.remove("hidden")}function d(e){l();let r=document.getElementById("search-results");r&&(r.innerHTML=`
`;
searchResults.innerHTML = html;
searchResults.classList.remove("hidden");
}
function showNoResults(query) {
createSearchResultsContainer();
const searchResults = document.getElementById("search-results");
if (!searchResults)
return;
searchResults.innerHTML = `
<div class="p-4 text-center">
<div class="text-4xl mb-2">\u{1F50D}</div>
<p class="text-sm" style="color: var(--text-primary)">No results found for "${a(e)}"</p>
<div class="text-4xl mb-2">🔍</div>
<p class="text-sm" style="color: var(--text-primary)">No results found for "${searchEscapeHtml(query)}"</p>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Try different keywords</p>
</div>
`,r.classList.remove("hidden"))}function S(){l();let e=document.getElementById("search-results");e&&(e.innerHTML=`
`;
searchResults.classList.remove("hidden");
}
function showSearchError() {
createSearchResultsContainer();
const searchResults = document.getElementById("search-results");
if (!searchResults)
return;
searchResults.innerHTML = `
<div class="p-4 text-center">
<div class="text-4xl mb-2">\u26A0\uFE0F</div>
<div class="text-4xl mb-2">⚠️</div>
<p class="text-sm" style="color: var(--text-primary)">Search error</p>
<p class="text-xs mt-1" style="color: var(--text-secondary)">Please try again</p>
</div>
`,e.classList.remove("hidden"))}function c(){let e=document.getElementById("search-results");e&&e.classList.add("hidden")}function l(){let e=document.getElementById("search-results");if(!e){e=document.createElement("div"),e.id="search-results",e.className="hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border",e.style.cssText="background-color: var(--bg-secondary); border-color: var(--border)";let r=document.getElementById("header-search");if(r){let t=r.closest(".relative");t&&t.appendChild(e)}}}function m(e,r){if(!e)return"";let t=r.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"),s=new RegExp(`(${t})`,"gi");return a(e).replace(s,'<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>')}function a(e){let r=document.createElement("div");return r.textContent=e,r.innerHTML}function w(e,r){localStorage.setItem("selectedLibrary",e),localStorage.setItem("selectedBook",r),c()}document.addEventListener("DOMContentLoaded",b);window.selectLibraryAndBook=w;})();
`;
searchResults.classList.remove("hidden");
}
function hideSearchResults() {
const searchResults = document.getElementById("search-results");
if (searchResults) {
searchResults.classList.add("hidden");
}
}
function createSearchResultsContainer() {
let searchResults = document.getElementById("search-results");
if (!searchResults) {
searchResults = document.createElement("div");
searchResults.id = "search-results";
searchResults.className =
"hidden absolute z-50 w-full max-w-2xl mt-2 rounded-lg shadow-lg border";
searchResults.style.cssText =
"background-color: var(--bg-secondary); border-color: var(--border)";
const searchInput = document.getElementById("header-search");
if (searchInput) {
const searchContainer = searchInput.closest(".relative");
if (searchContainer) {
searchContainer.appendChild(searchResults);
}
}
}
}
function highlightMatch(text, query) {
if (!text)
return "";
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escapedQuery})`, "gi");
return searchEscapeHtml(text).replace(regex, '<mark style="background-color: var(--accent); color: var(--bg-primary); padding: 0 2px; border-radius: 2px;">$1</mark>');
}
function searchEscapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function selectLibraryAndBook(libraryId, bookId) {
localStorage.setItem("selectedLibrary", libraryId);
localStorage.setItem("selectedBook", bookId);
hideSearchResults();
}
document.addEventListener("DOMContentLoaded", initializeSearch);
window.selectLibraryAndBook = selectLibraryAndBook;
//# sourceMappingURL=search.js.map