mirror of
https://codeberg.org/listyantidewi/your-everyday-tools.git
synced 2026-07-01 23:17:37 +08:00
3e54ed40b0
- Introduced a global search input in the top bar for easy access to tools. - Implemented JavaScript logic to filter and display search results dynamically. - Enhanced CSS for the search input and dropdown results, including styling for empty states. - Updated HTML to include necessary elements for the global search feature.
528 lines
18 KiB
JavaScript
528 lines
18 KiB
JavaScript
/* ── Theme ────────────────────────────────────── */
|
|
const THEME_KEY = "theme";
|
|
|
|
function getStoredTheme() {
|
|
return localStorage.getItem(THEME_KEY) || "system";
|
|
}
|
|
|
|
function resolveTheme(mode) {
|
|
if (mode === "dark") return "dark";
|
|
if (mode === "light") return "light";
|
|
return (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) ? "dark" : "light";
|
|
}
|
|
|
|
function applyTheme(mode, animate) {
|
|
localStorage.setItem(THEME_KEY, mode);
|
|
const html = document.documentElement;
|
|
|
|
if (animate) {
|
|
html.classList.add("theme-animate");
|
|
}
|
|
|
|
html.dataset.theme = resolveTheme(mode);
|
|
|
|
document.querySelectorAll(".theme-btn").forEach(btn => {
|
|
btn.classList.toggle("active", btn.dataset.themeMode === mode);
|
|
});
|
|
|
|
if (animate) {
|
|
clearTimeout(applyTheme._timer);
|
|
applyTheme._timer = setTimeout(() => html.classList.remove("theme-animate"), 400);
|
|
}
|
|
}
|
|
|
|
function initTheme() {
|
|
const mode = getStoredTheme();
|
|
applyTheme(mode, false);
|
|
document.querySelectorAll(".theme-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
// Spin the icon
|
|
const icon = btn.querySelector("i");
|
|
if (icon) {
|
|
btn.classList.remove("spinning");
|
|
void btn.offsetWidth; // force reflow to restart animation
|
|
btn.classList.add("spinning");
|
|
setTimeout(() => btn.classList.remove("spinning"), 380);
|
|
}
|
|
applyTheme(btn.dataset.themeMode, true);
|
|
});
|
|
});
|
|
if (window.matchMedia) {
|
|
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
|
if (getStoredTheme() === "system") {
|
|
document.documentElement.dataset.theme = resolveTheme("system");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ── Sidebar ──────────────────────────────────── */
|
|
function toggleCategory(btn) {
|
|
btn.classList.toggle("open");
|
|
const items = btn.nextElementSibling;
|
|
items.classList.toggle("open");
|
|
}
|
|
|
|
function openSidebar() {
|
|
document.getElementById("sidebar").classList.add("open");
|
|
document.getElementById("overlay").classList.add("open");
|
|
}
|
|
|
|
function closeSidebar() {
|
|
document.getElementById("sidebar").classList.remove("open");
|
|
document.getElementById("overlay").classList.remove("open");
|
|
}
|
|
|
|
// Highlight active nav item & auto-open its category
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const path = window.location.pathname;
|
|
document.querySelectorAll(".nav-item").forEach(a => {
|
|
if (a.getAttribute("href") === path) {
|
|
a.classList.add("active");
|
|
const items = a.closest(".nav-items");
|
|
if (items) {
|
|
items.classList.add("open");
|
|
const btn = items.previousElementSibling;
|
|
if (btn) btn.classList.add("open");
|
|
}
|
|
}
|
|
});
|
|
|
|
initTheme();
|
|
initToolSearch();
|
|
initGlobalSearch();
|
|
initUploadZone();
|
|
initToolForm();
|
|
initDependentOptions();
|
|
initCapabilityStatus();
|
|
});
|
|
|
|
/* ── Global Toolbar Search ────────────────────── */
|
|
function initGlobalSearch() {
|
|
const wrapper = document.getElementById("global-search");
|
|
const input = document.getElementById("global-search-input");
|
|
const dropdown = document.getElementById("global-search-dropdown");
|
|
if (!wrapper || !input || !dropdown) return;
|
|
|
|
// Hide on home page; show everywhere else
|
|
if (window.location.pathname === "/") {
|
|
wrapper.style.display = "none";
|
|
return;
|
|
}
|
|
wrapper.style.display = "";
|
|
|
|
// Build tool list from sidebar nav items (already in DOM)
|
|
const tools = [];
|
|
document.querySelectorAll(".nav-category").forEach(cat => {
|
|
const catBtn = cat.querySelector(".nav-category-btn");
|
|
const catName = catBtn ? catBtn.textContent.trim() : "";
|
|
cat.querySelectorAll(".nav-item").forEach(a => {
|
|
tools.push({
|
|
name: a.textContent.trim(),
|
|
href: a.getAttribute("href"),
|
|
cat: catName,
|
|
desc: a.dataset.desc || "",
|
|
});
|
|
});
|
|
});
|
|
|
|
let activeIdx = -1;
|
|
|
|
function openDropdown() {
|
|
dropdown.classList.add("open");
|
|
input.setAttribute("aria-expanded", "true");
|
|
}
|
|
|
|
function closeDropdown() {
|
|
dropdown.classList.remove("open");
|
|
input.setAttribute("aria-expanded", "false");
|
|
activeIdx = -1;
|
|
}
|
|
|
|
function renderResults(query) {
|
|
if (!query) { closeDropdown(); return; }
|
|
|
|
const matches = tools.filter(t =>
|
|
t.name.toLowerCase().includes(query) ||
|
|
t.cat.toLowerCase().includes(query) ||
|
|
t.desc.includes(query)
|
|
).slice(0, 8);
|
|
|
|
if (matches.length === 0) {
|
|
dropdown.innerHTML = `<div class="global-search-empty">No tools found.</div>`;
|
|
openDropdown();
|
|
return;
|
|
}
|
|
|
|
dropdown.innerHTML = matches.map((t, i) => `
|
|
<a href="${t.href}" class="global-search-result" role="option" data-idx="${i}">
|
|
<span class="result-info">
|
|
<span class="result-name">${escapeHtml(t.name)}</span>
|
|
<span class="result-desc">${escapeHtml(t.desc)}</span>
|
|
</span>
|
|
<span class="result-cat">${escapeHtml(t.cat)}</span>
|
|
</a>
|
|
`).join("");
|
|
activeIdx = -1;
|
|
openDropdown();
|
|
}
|
|
|
|
function setActive(idx) {
|
|
const items = dropdown.querySelectorAll(".global-search-result");
|
|
items.forEach(el => el.classList.remove("active"));
|
|
if (idx >= 0 && idx < items.length) {
|
|
items[idx].classList.add("active");
|
|
items[idx].scrollIntoView({ block: "nearest" });
|
|
}
|
|
activeIdx = idx;
|
|
}
|
|
|
|
input.addEventListener("input", () => {
|
|
renderResults(input.value.trim().toLowerCase());
|
|
});
|
|
|
|
input.addEventListener("keydown", e => {
|
|
const items = dropdown.querySelectorAll(".global-search-result");
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setActive(Math.min(activeIdx + 1, items.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setActive(Math.max(activeIdx - 1, 0));
|
|
} else if (e.key === "Enter") {
|
|
if (activeIdx >= 0 && items[activeIdx]) {
|
|
window.location.href = items[activeIdx].getAttribute("href");
|
|
}
|
|
} else if (e.key === "Escape") {
|
|
input.blur();
|
|
closeDropdown();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("click", e => {
|
|
if (!wrapper.contains(e.target)) closeDropdown();
|
|
});
|
|
}
|
|
|
|
/* ── Home Page Tool Search ────────────────────── */
|
|
function initToolSearch() {
|
|
const input = document.getElementById("tool-search");
|
|
if (!input) return;
|
|
|
|
const cards = Array.from(document.querySelectorAll(".tool-card"));
|
|
const sections = Array.from(document.querySelectorAll(".category-section"));
|
|
const empty = document.getElementById("search-empty");
|
|
|
|
input.addEventListener("input", () => {
|
|
const query = input.value.trim().toLowerCase();
|
|
|
|
cards.forEach(card => {
|
|
const match = !query || card.dataset.search.includes(query);
|
|
card.style.display = match ? "" : "none";
|
|
});
|
|
|
|
sections.forEach(section => {
|
|
const hasVisible = Array.from(section.querySelectorAll(".tool-card"))
|
|
.some(c => c.style.display !== "none");
|
|
section.style.display = hasVisible ? "" : "none";
|
|
});
|
|
|
|
if (empty) {
|
|
const anyVisible = cards.some(c => c.style.display !== "none");
|
|
empty.style.display = anyVisible ? "none" : "";
|
|
}
|
|
});
|
|
}
|
|
|
|
async function initCapabilityStatus() {
|
|
const box = document.getElementById("capability-status");
|
|
if (!box) return;
|
|
const endpoint = box.dataset.endpoint;
|
|
if (!endpoint) return;
|
|
try {
|
|
const resp = await fetch("/capabilities");
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
const status = data.routes && data.routes[endpoint];
|
|
if (!status) return;
|
|
box.className = "capability-status " + status.quality;
|
|
box.style.display = "block";
|
|
|
|
const missing = (status.missing_engines || [])
|
|
.map(id => data.engines[id]?.label || id)
|
|
.join(", ");
|
|
const engines = (status.required_engines || [])
|
|
.map(id => data.engines[id]?.label || id)
|
|
.join(", ");
|
|
const detail = status.quality === "high"
|
|
? `Using local high-fidelity engine${engines ? ": " + engines : ""}.`
|
|
: status.quality === "basic"
|
|
? `High-fidelity engine missing${missing ? ": " + missing : ""}. ${status.fallback || ""}`
|
|
: `Required local engine missing${missing ? ": " + missing : ""}.`;
|
|
|
|
box.innerHTML = `
|
|
<strong><i class="bi ${status.quality === "high" ? "bi-check-circle-fill" : "bi-exclamation-triangle-fill"}"></i> ${status.status}</strong>
|
|
<span>${status.label}</span>
|
|
<small>${detail}</small>
|
|
`;
|
|
} catch (_) {}
|
|
}
|
|
|
|
|
|
/* ── Upload Zone ──────────────────────────────── */
|
|
let selectedFiles = [];
|
|
|
|
function initUploadZone() {
|
|
const zone = document.getElementById("upload-zone");
|
|
const input = document.getElementById("file-input");
|
|
if (!zone || !input) return;
|
|
|
|
zone.addEventListener("dragover", e => { e.preventDefault(); zone.classList.add("dragover"); });
|
|
zone.addEventListener("dragleave", () => zone.classList.remove("dragover"));
|
|
zone.addEventListener("drop", e => {
|
|
e.preventDefault();
|
|
zone.classList.remove("dragover");
|
|
addFiles(e.dataTransfer.files);
|
|
});
|
|
|
|
input.addEventListener("change", () => {
|
|
addFiles(input.files);
|
|
input.value = "";
|
|
});
|
|
}
|
|
|
|
function addFiles(fileList) {
|
|
const input = document.getElementById("file-input");
|
|
const isMultiple = input && input.hasAttribute("multiple");
|
|
|
|
if (isMultiple) {
|
|
selectedFiles.push(...Array.from(fileList));
|
|
} else {
|
|
selectedFiles = [fileList[0]];
|
|
}
|
|
renderFileList();
|
|
}
|
|
|
|
function removeFile(idx) {
|
|
selectedFiles.splice(idx, 1);
|
|
renderFileList();
|
|
}
|
|
|
|
function renderFileList() {
|
|
const list = document.getElementById("file-list");
|
|
const prompt = document.getElementById("upload-prompt");
|
|
if (!list) return;
|
|
|
|
if (selectedFiles.length === 0) {
|
|
list.innerHTML = "";
|
|
if (prompt) prompt.style.display = "";
|
|
return;
|
|
}
|
|
if (prompt) prompt.style.display = "none";
|
|
|
|
list.innerHTML = selectedFiles.map((f, i) => `
|
|
<div class="file-item">
|
|
<span><i class="bi bi-file-earmark"></i> ${f.name}
|
|
<small>(${formatSize(f.size)})</small></span>
|
|
<button type="button" class="remove-file" onclick="removeFile(${i})">×</button>
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
function formatSize(bytes) {
|
|
if (bytes < 1024) return bytes + " B";
|
|
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
|
|
return (bytes / 1048576).toFixed(1) + " MB";
|
|
}
|
|
|
|
|
|
/* ── Form Submission ──────────────────────────── */
|
|
function initToolForm() {
|
|
const form = document.getElementById("tool-form");
|
|
if (!form) return;
|
|
|
|
form.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const endpoint = form.dataset.endpoint;
|
|
if (!endpoint) return;
|
|
|
|
const btnText = document.querySelector(".btn-text");
|
|
const btnLoad = document.querySelector(".btn-loading");
|
|
const submitBtn = document.getElementById("submit-btn");
|
|
const resultArea = document.getElementById("result-area");
|
|
|
|
// Validate: either files or text input required
|
|
const textInput = form.querySelector("textarea[name='text']");
|
|
if (!textInput && selectedFiles.length === 0) {
|
|
showError("Please select a file first.");
|
|
return;
|
|
}
|
|
if (textInput && !textInput.value.trim()) {
|
|
showError("Please enter some text.");
|
|
return;
|
|
}
|
|
|
|
// Show loading
|
|
if (btnText) btnText.style.display = "none";
|
|
if (btnLoad) btnLoad.style.display = "inline-flex";
|
|
submitBtn.disabled = true;
|
|
resultArea.style.display = "none";
|
|
|
|
const formData = new FormData(form);
|
|
|
|
// Remove the empty file input and add our tracked files
|
|
formData.delete("files");
|
|
selectedFiles.forEach(f => formData.append("files", f));
|
|
|
|
try {
|
|
const resp = await fetch(endpoint, { method: "POST", body: formData });
|
|
|
|
if (!resp.ok) {
|
|
let msg = "Processing failed.";
|
|
try {
|
|
const json = await resp.json();
|
|
msg = json.error || msg;
|
|
} catch (_) {}
|
|
showError(msg);
|
|
return;
|
|
}
|
|
|
|
const ct = resp.headers.get("Content-Type") || "";
|
|
|
|
if (ct.includes("application/json")) {
|
|
const json = await resp.json();
|
|
if (json.error) {
|
|
showError(json.error);
|
|
} else if (json.text !== undefined) {
|
|
showTextResult(json.text);
|
|
} else if (json.data !== undefined) {
|
|
showTextResult(typeof json.data === "string" ? json.data : JSON.stringify(json.data, null, 2));
|
|
}
|
|
} else {
|
|
// Binary file download
|
|
const blob = await resp.blob();
|
|
const cd = resp.headers.get("Content-Disposition") || "";
|
|
let filename = "download";
|
|
const match = cd.match(/filename="?([^";\n]+)"?/);
|
|
if (match) filename = match[1];
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
// If image, show preview
|
|
const meta = {
|
|
engine: resp.headers.get("X-Conversion-Engine") || "",
|
|
quality: resp.headers.get("X-Conversion-Quality") || "",
|
|
warnings: resp.headers.get("X-Fidelity-Warnings") || ""
|
|
};
|
|
|
|
if (ct.startsWith("image/")) {
|
|
showFileResult(url, filename, true, meta);
|
|
} else {
|
|
showFileResult(url, filename, false, meta);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
showError("Network error: " + err.message);
|
|
} finally {
|
|
if (btnText) btnText.style.display = "";
|
|
if (btnLoad) btnLoad.style.display = "none";
|
|
submitBtn.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function showError(msg) {
|
|
const area = document.getElementById("result-area");
|
|
area.style.display = "block";
|
|
document.getElementById("result-success").style.display = "none";
|
|
document.getElementById("result-text")?.style.setProperty("display", "none");
|
|
const errEl = document.getElementById("result-error");
|
|
errEl.style.display = "flex";
|
|
document.getElementById("error-message").textContent = msg;
|
|
}
|
|
|
|
function showFileResult(url, filename, isImage, meta = {}) {
|
|
const area = document.getElementById("result-area");
|
|
area.style.display = "block";
|
|
document.getElementById("result-error").style.display = "none";
|
|
document.getElementById("result-text")?.style.setProperty("display", "none");
|
|
|
|
const success = document.getElementById("result-success");
|
|
success.style.display = "flex";
|
|
document.getElementById("result-message").textContent = "File ready!";
|
|
|
|
const btn = document.getElementById("download-btn");
|
|
btn.href = url;
|
|
btn.download = filename;
|
|
btn.textContent = "";
|
|
btn.innerHTML = '<i class="bi bi-download"></i> Download ' + filename;
|
|
|
|
const preview = document.getElementById("result-preview");
|
|
if (isImage) {
|
|
preview.style.display = "block";
|
|
preview.innerHTML = `<img src="${url}" alt="Preview">`;
|
|
} else {
|
|
preview.style.display = "none";
|
|
}
|
|
|
|
const oldMeta = success.querySelector(".result-meta");
|
|
if (oldMeta) oldMeta.remove();
|
|
if (meta.engine || meta.quality || meta.warnings) {
|
|
const div = document.createElement("div");
|
|
div.className = "result-meta";
|
|
const parts = [];
|
|
if (meta.engine) parts.push(`<span>Engine: ${escapeHtml(meta.engine)}</span>`);
|
|
if (meta.quality) parts.push(`<span>Quality: ${escapeHtml(meta.quality)}</span>`);
|
|
if (meta.warnings) parts.push(`<span>Warnings: ${escapeHtml(meta.warnings)}</span>`);
|
|
div.innerHTML = parts.join("");
|
|
success.appendChild(div);
|
|
}
|
|
}
|
|
|
|
function showTextResult(text) {
|
|
const area = document.getElementById("result-area");
|
|
area.style.display = "block";
|
|
document.getElementById("result-error").style.display = "none";
|
|
document.getElementById("result-success").style.display = "none";
|
|
|
|
const textBox = document.getElementById("result-text");
|
|
if (textBox) {
|
|
textBox.style.display = "block";
|
|
document.getElementById("result-text-content").textContent = text;
|
|
}
|
|
}
|
|
|
|
function copyResult() {
|
|
const text = document.getElementById("result-text-content")?.textContent;
|
|
if (text) navigator.clipboard.writeText(text);
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
return String(text).replace(/[&<>"']/g, c => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'"
|
|
}[c]));
|
|
}
|
|
|
|
|
|
/* ── Dependent Options ────────────────────────── */
|
|
function initDependentOptions() {
|
|
document.querySelectorAll("[data-depends-on]").forEach(el => {
|
|
const parentName = el.dataset.dependsOn;
|
|
const requiredVal = el.dataset.dependsValue;
|
|
const parentInput = document.querySelector(`[name="${parentName}"]`);
|
|
if (!parentInput) return;
|
|
|
|
const check = () => {
|
|
// Support comma-separated values
|
|
const vals = requiredVal.split(",");
|
|
el.style.display = vals.includes(parentInput.value) ? "" : "none";
|
|
};
|
|
parentInput.addEventListener("change", check);
|
|
check();
|
|
});
|
|
}
|