diff options
| author | Petri Hienonen <petri.hienonen@gmail.com> | 2025-12-14 14:23:14 +0200 |
|---|---|---|
| committer | Petri Hienonen <petri.hienonen@gmail.com> | 2025-12-14 14:23:14 +0200 |
| commit | bf67302ce6757ef77d84221ead640f1e84df6265 (patch) | |
| tree | f067eea8751b7b87b8a89bf484fca7a55f956944 | |
| parent | 9ee74a446f260c98c03506a182c04419e9d312e7 (diff) | |
| download | page-bf67302ce6757ef77d84221ead640f1e84df6265.tar.zst | |
Basic refactor
| -rw-r--r-- | src/main.js | 2633 | ||||
| -rw-r--r-- | src/style.css | 41 |
2 files changed, 1729 insertions, 945 deletions
diff --git a/src/main.js b/src/main.js index ab8b0b0..4db4edb 100644 --- a/src/main.js +++ b/src/main.js @@ -1,1032 +1,1775 @@ // ═══════════════════════════════════════════════════════════ -// SOURCES +// UTILS CLASS // ═══════════════════════════════════════════════════════════ -const SOURCES = { - dictionary: { - bang: "!d", - count: 0, - enabled: true, - fetch: async (q) => { - const r = await fetch( - `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(q)}`, - ); - if (!r.ok) return []; - const d = await r.json(); - return d.slice(0, 3).flatMap((e) => - e.meanings.slice(0, 2).map((m) => ({ - meta: { phonetic: e.phonetic }, - snippet: m.definitions[0]?.definition || "", - source: "dictionary", - title: `${e.word} [${m.partOfSpeech}]`, - url: e.sourceUrls?.[0] || "#", - })), - ); - }, - name: "DICTIONARY", - }, - github: { - bang: "!g", - count: 0, - enabled: true, - fetch: async (q) => { - const r = await fetch( - `https://api.github.com/search/repositories?q=${encodeURIComponent(q)}&per_page=8&sort=stars`, - ); - const d = await r.json(); - return (d.items || []).map((i) => ({ - meta: { forks: i.forks_count, lang: i.language, stars: i.stargazers_count }, - snippet: i.description || "NO DESCRIPTION", - source: "github", - title: i.full_name, - url: i.html_url, - })); - }, - name: "GITHUB", - }, - hackernews: { - bang: "!hn", - count: 0, - enabled: true, - fetch: async (q) => { - const r = await fetch( - `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(q)}&hitsPerPage=8`, - ); - const d = await r.json(); - return (d.hits || []) - .filter((h) => h.title) - .map((h) => ({ - meta: { comments: h.num_comments, points: h.points }, - snippet: `${h.points || 0} POINTS // ${h.author}`, - source: "hackernews", - title: h.title, - url: h.url || `https://news.ycombinator.com/item?id=${h.objectID}`, - })); - }, - name: "HACKERNEWS", - }, - news: { - bang: "!n", - count: 0, - enabled: true, - fetch: async (q) => { - // Combine HN, Reddit News, and Wiki Current Events logic - const p1 = fetch( - `https://hn.algolia.com/api/v1/search_by_date?query=${encodeURIComponent(q)}&tags=story&hitsPerPage=6`, - ) - .then((r) => r.json()) - .catch(() => ({ hits: [] })); - const p2 = fetch( - `https://www.reddit.com/r/worldnews+news+technology/search.json?q=${encodeURIComponent(q)}&restrict_sr=1&sort=new&limit=6`, - ) - .then((r) => r.json()) - .catch(() => ({ data: { children: [] } })); - - const [hn, rd] = await Promise.all([p1, p2]); - - const hnRes = (hn.hits || []).map((h) => ({ - meta: { date: h.created_at_i, lang: "HN" }, - snippet: `HACKERNEWS // ${h.points || 0} PTS // ${timeAgo(h.created_at_i * 1000)}`, - source: "news", - title: h.title, - url: h.url || `https://news.ycombinator.com/item?id=${h.objectID}`, - })); - - const rdRes = (rd.data?.children || []).map((c) => ({ - meta: { date: c.data.created_utc, lang: "RD" }, - snippet: `REDDIT r/${c.data.subreddit.toUpperCase()} // ${c.data.score} UPVOTES`, - source: "news", - title: c.data.title, - url: `https://reddit.com${c.data.permalink}`, - })); - - // Interleave/Sort by recency - return [...hnRes, ...rdRes].sort((a, b) => b.meta.date - a.meta.date); - }, - name: "AGGREGATE NEWS", - }, - npm: { - bang: "!npm", - count: 0, - enabled: true, - fetch: async (q) => { - const r = await fetch( - `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=8`, - ); - const d = await r.json(); - return (d.objects || []).map((o) => ({ - meta: { version: o.package.version }, - snippet: o.package.description || "NO DESCRIPTION", - source: "npm", - title: o.package.name, - url: `https://npmjs.com/package/${o.package.name}`, - })); - }, - name: "NPM", - }, - openlibrary: { - bang: "!b", - count: 0, - enabled: true, - fetch: async (q) => { - const r = await fetch( - `https://openlibrary.org/search.json?q=${encodeURIComponent(q)}&limit=6`, - ); - const d = await r.json(); - return (d.docs || []).map((b) => ({ - meta: { year: b.first_publish_year }, - snippet: `BY ${(b.author_name || ["UNKNOWN"]).join(", ").toUpperCase()}`, - source: "openlibrary", - title: b.title, - url: `https://openlibrary.org${b.key}`, - })); - }, - name: "OPENLIBRARY", - }, - reddit: { - bang: "!r", - count: 0, - enabled: true, - fetch: async (q) => { - const r = await fetch( - `https://www.reddit.com/search.json?q=${encodeURIComponent(q)}&limit=8`, - ); - const d = await r.json(); - return (d.data?.children || []).map((c) => ({ - meta: { comments: c.data.num_comments, score: c.data.score }, - snippet: c.data.selftext?.substring(0, 200) || `r/${c.data.subreddit}`, - source: "reddit", - title: c.data.title, - url: `https://reddit.com${c.data.permalink}`, - })); - }, - name: "REDDIT", - }, - stackoverflow: { - bang: "!so", - count: 0, - enabled: true, - fetch: async (q) => { - const r = await fetch( - `https://api.stackexchange.com/2.3/search?order=desc&sort=relevance&intitle=${encodeURIComponent(q)}&site=stackoverflow&pagesize=8`, - ); - const d = await r.json(); - return (d.items || []).map((i) => ({ - meta: { answers: i.answer_count, score: i.score }, - snippet: `${i.answer_count} ANSWERS // ${i.view_count} VIEWS`, - source: "stackoverflow", - title: decodeHTML(i.title), - url: i.link, - })); - }, - name: "STACKOVERFLOW", - }, - wikipedia: { - bang: "!w", - count: 0, - enabled: true, - fetch: async (q) => { - const r = await fetch( - `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&origin=*&srlimit=8`, - ); - const d = await r.json(); - return (d.query?.search || []).map((i) => ({ - meta: { words: i.wordcount }, - snippet: i.snippet.replace(/<[^>]*>/g, ""), - source: "wikipedia", - title: i.title, - url: `https://en.wikipedia.org/wiki/${encodeURIComponent(i.title)}`, - })); - }, - instant: async (q) => { - const s = await fetch( - `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&origin=*&srlimit=1`, - ); - const sd = await s.json(); - if (!sd.query?.search?.length) return null; - const title = sd.query.search[0].title; - const r = await fetch( - `https://en.wikipedia.org/w/api.php?action=query&prop=extracts&exintro&explaintext&titles=${encodeURIComponent(title)}&format=json&origin=*`, - ); - const d = await r.json(); - const page = Object.values(d.query.pages)[0]; - return { - content: page.extract, - source: "WIKIPEDIA", - title: page.title, - url: `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`, - }; - }, - name: "WIKIPEDIA", - }, -}; - -const STOCK_EXCHANGES = { - AAPL: "NASDAQ", - ADA: "CRYPTO", - AMD: "NASDAQ", - AMZN: "NASDAQ", - BAC: "NYSE", - BTC: "CRYPTO", - DIS: "NYSE", - DOGE: "CRYPTO", - ETH: "CRYPTO", - EURUSD: "FX", - GBPUSD: "FX", - GOOG: "NASDAQ", - GOOGL: "NASDAQ", - INTC: "NASDAQ", - JNJ: "NYSE", - JPM: "NYSE", - KO: "NYSE", - META: "NASDAQ", - MSFT: "NASDAQ", - NFLX: "NASDAQ", - NVDA: "NASDAQ", - PYPL: "NASDAQ", - QQQ: "NASDAQ", - SOL: "CRYPTO", - SPY: "AMEX", - TSLA: "NASDAQ", - USDJPY: "FX", - V: "NYSE", - WMT: "NYSE", - XOM: "NYSE", - XRP: "CRYPTO", -}; - -function getSymbol(ticker) { - const t = ticker.toUpperCase(); - const ex = STOCK_EXCHANGES[t]; - if (ex === "CRYPTO") return `BINANCE:${t}USDT`; - if (ex === "FX") return `FX:${t}`; - if (ex) return `${ex}:${t}`; - return `NASDAQ:${t}`; -} -// ═══════════════════════════════════════════════════════════ -// STATE -// ═══════════════════════════════════════════════════════════ -const state = { - calcExpr: "", - calcResult: "0", - hasSearched: false, - history: JSON.parse(localStorage.getItem("sh") || "[]"), - query: "", - results: [], - watchlist: JSON.parse(localStorage.getItem("sw") || '["TSLA", "AAPL", "BTC", "ETH"]'), -}; - -const $ = (id) => document.getElementById(id); -const searchInput = $("search-input"); -const resultsEl = $("results"); -const heroEl = $("hero"); -const mainEl = $("main-content"); -const statsBar = $("stats-bar"); +/** + * Static utility class for common helper functions + */ +class Utils { + /** + * Escape HTML special characters + * @param {string} s - String to escape + * @returns {string} Escaped string + */ + static esc(s) { + if (!s) return ""; + const map = { + "'": "'", + '"': """, + "&": "&", + "<": "<", + ">": ">", + }; + return s.replace(/[&<>"']/g, (ch) => map[ch]); + } -// ═══════════════════════════════════════════════════════════ -// INIT -// ═══════════════════════════════════════════════════════════ -function init() { - renderSources(); - renderHistory(); - renderWatchlist(); - initTheme(); - updateClocks(); - setInterval(updateClocks, 1000); - setupCalc(); - setupListeners(); - setupWatchlistListeners(); - handleUrlQueryOnLoad(); -} + /** + * Decode HTML entities + * @param {string} s - String with HTML entities + * @returns {string} Decoded string + */ + static decodeHTML(s) { + const textarea = document.createElement("textarea"); + textarea.innerHTML = s; + return textarea.value; + } -function renderSources() { - $("sources-list").innerHTML = Object.entries(SOURCES) - .map( - ([k, s]) => ` - <div class="source-item ${s.enabled ? "active" : ""}" data-source="${k}" onclick="toggleSource('${k}')"> - ${s.name} <span class="source-count" id="c-${k}">${s.count}</span> - </div> - `, - ) - .join(""); -} + /** + * Format large numbers (K, M) + * @param {number} n - Number to format + * @returns {string} Formatted string + */ + static fmt(n) { + if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(1)}K`; + return n?.toString() || "0"; + } -function toggleSource(k) { - SOURCES[k].enabled = !SOURCES[k].enabled; - renderSources(); - if (state.query) search(state.query); -} + /** + * Convert timestamp to relative time string + * @param {number} ts - Timestamp in milliseconds + * @returns {string} Relative time string + */ + static timeAgo(ts) { + const s = Math.floor((Date.now() - ts) / 1000); + if (s < 60) return "NOW"; + if (s < 3600) return `${Math.floor(s / 60)}M`; + if (s < 86400) return `${Math.floor(s / 3600)}H`; + return `${Math.floor(s / 86400)}D`; + } + + /** + * Highlight search terms in text + * @param {string} text - Text to highlight + * @param {string} query - Search query + * @returns {string} HTML with highlighted terms + */ + static highlight(text, query) { + if (!query) return text; + const words = query.split(/\s+/).filter((w) => w.length > 2); + let result = text; + words.forEach((w) => { + const regex = new RegExp(`(${w})`, "gi"); + result = result.replace(regex, "<mark>$1</mark>"); + }); + return result; + } -function toggleWidget(id) { - $(id).classList.toggle("open"); + /** + * Copy text to clipboard + * @param {string} text - Text to copy + */ + static async copy(text) { + try { + await navigator.clipboard.writeText(text); + Toast.show("COPIED"); + } catch (err) { + // Fallback for older browsers + const textarea = document.createElement("textarea"); + textarea.value = text; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + Toast.show("COPIED"); + } + } + + /** + * Show toast notification + * @param {string} message - Message to display + * @param {number} duration - Duration in ms (default: 3000) + */ + static toast(message, duration = 3000) { + Toast.show(message, duration); + } + + /** + * Safely evaluate mathematical expressions + * @param {string} expr - Mathematical expression + * @returns {number|string} Result or error string + */ + static safeEval(expr) { + // Remove unsafe characters and evaluate + const safeExpr = expr.replace(/[^0-9+\-*/().^]/g, "").replace(/\^/g, "**"); + try { + // Use Function constructor in a safer way with restricted scope + const result = new Function(`return ${safeExpr}`)(); + return typeof result === "number" && !isNaN(result) && isFinite(result) ? result : "ERR"; + } catch { + return "ERR"; + } + } + + /** + * Generate a random UUID v4 + * @returns {string} UUID + */ + static generateUUID() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); + }); + } + + /** + * Convert RGB to HSL + * @param {number} r - Red (0-255) + * @param {number} g - Green (0-255) + * @param {number} b - Blue (0-255) + * @returns {string} HSL string + */ + static rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b), + min = Math.min(r, g, b); + let h, + s, + l = (max + min) / 2; + + if (max === min) { + h = s = 0; + } else { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + break; + case g: + h = ((b - r) / d + 2) / 6; + break; + case b: + h = ((r - g) / d + 4) / 6; + break; + } + } + return `${Math.round(h * 360)}°, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%`; + } + + /** + * Debounce function + * @param {Function} func - Function to debounce + * @param {number} wait - Wait time in ms + * @returns {Function} Debounced function + */ + static debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + /** + * Generate random password + * @param {number} length - Password length + * @returns {string} Random password + */ + static generatePassword(length = 20) { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"; + let password = ""; + for (let i = 0; i < length; i++) { + password += chars[Math.floor(Math.random() * chars.length)]; + } + return password; + } } // ═══════════════════════════════════════════════════════════ -// THEME +// TOAST CLASS // ═══════════════════════════════════════════════════════════ -function initTheme() { - const t = localStorage.getItem("theme"); - if (t === "light") document.body.classList.add("light-mode"); -} -function toggleTheme() { - document.body.classList.toggle("light-mode"); - const isLight = document.body.classList.contains("light-mode"); - localStorage.setItem("theme", isLight ? "light" : "dark"); +class Toast { + /** + * Show toast notification + * @param {string} message - Message to display + * @param {number} duration - Duration in ms + */ + static show(message, duration = 3000) { + const container = + document.getElementById("toast-container") || + (() => { + const div = document.createElement("div"); + div.id = "toast-container"; + div.className = "toast-container"; + document.body.appendChild(div); + return div; + })(); + + const toast = document.createElement("div"); + toast.className = "toast"; + toast.textContent = message; + + container.appendChild(toast); + + setTimeout(() => { + toast.style.opacity = "0"; + toast.style.transform = "translateY(20px)"; + setTimeout(() => toast.remove(), 200); + }, duration); + } } // ═══════════════════════════════════════════════════════════ -// WATCHLIST +// TRADINGVIEW WIDGET CLASS // ═══════════════════════════════════════════════════════════ -function renderWatchlist() { - const c = $("watchlist-container"); - if (!state.watchlist.length) { - c.innerHTML = '<div style="color:var(--gray);font-size:11px;padding:10px;">EMPTY</div>'; - return; - } - c.innerHTML = state.watchlist - .map((t) => { - const ex = STOCK_EXCHANGES[t] || "STOCK"; - return ` - <div class="watchlist-item-row" onclick="searchFor('$${t}')"> - <span>${t} <span style="color:var(--gray)">${ex}</span></span> - <span class="watchlist-del-btn" onclick="removeTicker(event, '${t}')">×</span> - </div>`; - }) - .join(""); -} -function addTicker() { - const val = $("watchlist-input").value.toUpperCase().trim(); - if (val && !state.watchlist.includes(val)) { - state.watchlist.push(val); - localStorage.setItem("sw", JSON.stringify(state.watchlist)); - renderWatchlist(); - $("watchlist-input").value = ""; +class TradingViewWidget { + /** + * Create a TradingView widget + * @param {string} containerId - Container element ID + * @param {string} symbol - Trading symbol + * @param {boolean} isLight - Light theme flag + */ + constructor(containerId, symbol, isLight = false) { + this.containerId = containerId; + this.symbol = symbol; + this.isLight = isLight; + this.widget = null; + } + + /** + * Load and initialize the TradingView widget + */ + async load() { + if (!window.TradingView) { + await this.loadScript(); + } + this.createWidget(); + } + + /** + * Load TradingView script + * @returns {Promise<void>} + */ + loadScript() { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = "https://s3.tradingview.com/tv.js"; + script.onload = () => resolve(); + script.onerror = () => reject(new Error("Failed to load TradingView script")); + document.head.appendChild(script); + }); + } + + /** + * Create the TradingView widget + */ + createWidget() { + if (this.widget) { + this.widget.remove(); + } + + this.widget = new TradingView.widget({ + allow_symbol_change: true, + backgroundColor: this.isLight ? "rgba(244, 244, 245, 1)" : "rgba(0,0,0,1)", + container_id: this.containerId, + enable_publishing: false, + gridColor: this.isLight ? "rgba(220, 220, 220, 1)" : "rgba(30,30,30,1)", + height: 500, + interval: "D", + locale: "en", + studies: ["Volume@tv-basicstudies"], + style: "1", + symbol: this.symbol, + theme: this.isLight ? "light" : "dark", + timezone: "Etc/UTC", + toolbar_bg: this.isLight ? "#f4f4f5" : "#000", + width: "100%", + }); + } + + /** + * Remove the widget + */ + destroy() { + if (this.widget) { + const container = document.getElementById(this.containerId); + if (container) { + container.innerHTML = ""; + } + this.widget = null; + } } -} -window.removeTicker = (e, t) => { - e.stopPropagation(); // Prevent search trigger - state.watchlist = state.watchlist.filter((i) => i !== t); - localStorage.setItem("sw", JSON.stringify(state.watchlist)); - renderWatchlist(); -}; - -function setupWatchlistListeners() { - $("watchlist-add-btn").onclick = addTicker; - $("watchlist-input").addEventListener("keydown", (e) => { - if (e.key === "Enter") addTicker(); - }); + /** + * Update widget symbol + * @param {string} symbol - New symbol + */ + updateSymbol(symbol) { + this.symbol = symbol; + if (this.widget) { + this.widget.chart().setSymbol(symbol); + } + } } // ═══════════════════════════════════════════════════════════ -// SEARCH +// SEARCH CLASS // ═══════════════════════════════════════════════════════════ -function updateUrlWithQuery(query) { - const url = new URL(window.location); - if (query && query.trim()) { - url.searchParams.set("q", query); - } else { - url.searchParams.delete("q"); - } - // Use replaceState to avoid adding to browser history - window.history.replaceState({}, "", url); -} -function getQueryFromUrl() { - const params = new URLSearchParams(window.location.search); - return params.get("q") || ""; -} +class Search { + /** + * Create a new Search instance + */ + constructor() { + // State + this.state = { + calcExpr: "", + calcResult: "0", + hasSearched: false, + history: JSON.parse(localStorage.getItem("sh") || "[]"), + query: "", + results: [], + selectedResultIndex: -1, + watchlist: JSON.parse(localStorage.getItem("sw") || '["TSLA", "AAPL", "BTC", "ETH"]'), + }; -function handleUrlQueryOnLoad() { - const query = getQueryFromUrl(); - if (query.trim()) { - // Set the search input value - searchInput.value = query; - // Trigger search - search(query); + // Request cancellation + this.abortController = null; + + // Suggestions + this.suggestions = []; + this.showingSuggestions = false; + + // DOM Elements + this.searchInput = document.getElementById("search-input"); + this.resultsEl = document.getElementById("results"); + this.heroEl = document.getElementById("hero"); + this.mainEl = document.getElementById("main-content"); + this.statsBar = document.getElementById("stats-bar"); + + // Stock exchanges mapping + this.stockExchanges = { + AAPL: "NASDAQ", + ADA: "CRYPTO", + AMD: "NASDAQ", + AMZN: "NASDAQ", + BAC: "NYSE", + BTC: "CRYPTO", + DIS: "NYSE", + DOGE: "CRYPTO", + ETH: "CRYPTO", + EURUSD: "FX", + GBPUSD: "FX", + GOOG: "NASDAQ", + GOOGL: "NASDAQ", + INTC: "NASDAQ", + JNJ: "NYSE", + JPM: "NYSE", + KO: "NYSE", + META: "NASDAQ", + MSFT: "NASDAQ", + NFLX: "NASDAQ", + NVDA: "NASDAQ", + PYPL: "NASDAQ", + QQQ: "NASDAQ", + SOL: "CRYPTO", + SPY: "AMEX", + TSLA: "NASDAQ", + USDJPY: "FX", + V: "NYSE", + WMT: "NYSE", + XOM: "NYSE", + XRP: "CRYPTO", + }; + + // Sources configuration + this.sources = { + dictionary: { + bang: "!d", + count: 0, + enabled: true, + fetch: this.createSourceFetchHandler("dictionary", async (q, signal) => { + const response = await fetch( + `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(q)}`, + { signal }, + ); + if (!response.ok) return []; + const data = await response.json(); + return data.slice(0, 3).flatMap((entry) => + entry.meanings.slice(0, 2).map((meaning) => ({ + meta: { phonetic: entry.phonetic }, + snippet: meaning.definitions[0]?.definition || "", + source: "dictionary", + title: `${entry.word} [${meaning.partOfSpeech}]`, + url: entry.sourceUrls?.[0] || "#", + })), + ); + }), + name: "DICTIONARY", + }, + github: { + bang: "!g", + count: 0, + enabled: true, + fetch: this.createSourceFetchHandler("github", async (q, signal) => { + const response = await fetch( + `https://api.github.com/search/repositories?q=${encodeURIComponent(q)}&per_page=8&sort=stars`, + { signal }, + ); + const data = await response.json(); + return (data.items || []).map((item) => ({ + meta: { forks: item.forks_count, lang: item.language, stars: item.stargazers_count }, + snippet: item.description || "NO DESCRIPTION", + source: "github", + title: item.full_name, + url: item.html_url, + })); + }), + name: "GITHUB", + }, + hackernews: { + bang: "!hn", + count: 0, + enabled: true, + fetch: this.createSourceFetchHandler("hackernews", async (q, signal) => { + const response = await fetch( + `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(q)}&hitsPerPage=8`, + { signal }, + ); + const data = await response.json(); + return (data.hits || []) + .filter((hit) => hit.title) + .map((hit) => ({ + meta: { comments: hit.num_comments, points: hit.points }, + snippet: `${hit.points || 0} POINTS // ${hit.author}`, + source: "hackernews", + title: hit.title, + url: hit.url || `https://news.ycombinator.com/item?id=${hit.objectID}`, + })); + }), + name: "HACKERNEWS", + }, + news: { + bang: "!n", + count: 0, + enabled: true, + fetch: this.createSourceFetchHandler("news", async (q, signal) => { + const [hn, rd] = await Promise.all([ + fetch( + `https://hn.algolia.com/api/v1/search_by_date?query=${encodeURIComponent(q)}&tags=story&hitsPerPage=6`, + { signal }, + ) + .then((r) => r.json()) + .catch(() => ({ hits: [] })), + fetch( + `https://www.reddit.com/r/worldnews+news+technology/search.json?q=${encodeURIComponent(q)}&restrict_sr=1&sort=new&limit=6`, + { signal }, + ) + .then((r) => r.json()) + .catch(() => ({ data: { children: [] } })), + ]); + + const hnResults = (hn.hits || []).map((hit) => ({ + meta: { date: hit.created_at_i, lang: "HN" }, + snippet: `HACKERNEWS // ${hit.points || 0} PTS // ${Utils.timeAgo(hit.created_at_i * 1000)}`, + source: "news", + title: hit.title, + url: hit.url || `https://news.ycombinator.com/item?id=${hit.objectID}`, + })); + + const redditResults = (rd.data?.children || []).map((child) => ({ + meta: { date: child.data.created_utc, lang: "RD" }, + snippet: `REDDIT r/${child.data.subreddit.toUpperCase()} // ${child.data.score} UPVOTES`, + source: "news", + title: child.data.title, + url: `https://reddit.com${child.data.permalink}`, + })); + + return [...hnResults, ...redditResults].sort((a, b) => b.meta.date - a.meta.date); + }), + name: "AGGREGATE NEWS", + }, + npm: { + bang: "!npm", + count: 0, + enabled: true, + fetch: this.createSourceFetchHandler("npm", async (q, signal) => { + const response = await fetch( + `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=8`, + { signal }, + ); + const data = await response.json(); + return (data.objects || []).map((obj) => ({ + meta: { version: obj.package.version }, + snippet: obj.package.description || "NO DESCRIPTION", + source: "npm", + title: obj.package.name, + url: `https://npmjs.com/package/${obj.package.name}`, + })); + }), + name: "NPM", + }, + openlibrary: { + bang: "!b", + count: 0, + enabled: true, + fetch: this.createSourceFetchHandler("openlibrary", async (q, signal) => { + const response = await fetch( + `https://openlibrary.org/search.json?q=${encodeURIComponent(q)}&limit=6`, + { signal }, + ); + const data = await response.json(); + return (data.docs || []).map((book) => ({ + meta: { year: book.first_publish_year }, + snippet: `BY ${(book.author_name || ["UNKNOWN"]).join(", ").toUpperCase()}`, + source: "openlibrary", + title: book.title, + url: `https://openlibrary.org${book.key}`, + })); + }), + name: "OPENLIBRARY", + }, + reddit: { + bang: "!r", + count: 0, + enabled: true, + fetch: this.createSourceFetchHandler("reddit", async (q, signal) => { + const response = await fetch( + `https://www.reddit.com/search.json?q=${encodeURIComponent(q)}&limit=8`, + { signal }, + ); + const data = await response.json(); + return (data.data?.children || []).map((child) => ({ + meta: { comments: child.data.num_comments, score: child.data.score }, + snippet: child.data.selftext?.substring(0, 200) || `r/${child.data.subreddit}`, + source: "reddit", + title: child.data.title, + url: `https://reddit.com${child.data.permalink}`, + })); + }), + name: "REDDIT", + }, + stackoverflow: { + bang: "!so", + count: 0, + enabled: true, + fetch: this.createSourceFetchHandler("stackoverflow", async (q, signal) => { + const response = await fetch( + `https://api.stackexchange.com/2.3/search?order=desc&sort=relevance&intitle=${encodeURIComponent(q)}&site=stackoverflow&pagesize=8`, + { signal }, + ); + const data = await response.json(); + return (data.items || []).map((item) => ({ + meta: { answers: item.answer_count, score: item.score }, + snippet: `${item.answer_count} ANSWERS // ${item.view_count} VIEWS`, + source: "stackoverflow", + title: Utils.decodeHTML(item.title), + url: item.link, + })); + }), + name: "STACKOVERFLOW", + }, + wikipedia: { + bang: "!w", + count: 0, + enabled: true, + fetch: this.createSourceFetchHandler("wikipedia", async (q, signal) => { + const response = await fetch( + `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&origin=*&srlimit=8`, + { signal }, + ); + const data = await response.json(); + return (data.query?.search || []).map((item) => ({ + meta: { words: item.wordcount }, + snippet: item.snippet.replace(/<[^>]*>/g, ""), + source: "wikipedia", + title: item.title, + url: `https://en.wikipedia.org/wiki/${encodeURIComponent(item.title)}`, + })); + }), + instant: async (q, signal) => { + const searchResponse = await fetch( + `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch=${encodeURIComponent(q)}&format=json&origin=*&srlimit=1`, + { signal }, + ); + const searchData = await searchResponse.json(); + if (!searchData.query?.search?.length) return null; + + const title = searchData.query.search[0].title; + const pageResponse = await fetch( + `https://en.wikipedia.org/w/api.php?action=query&prop=extracts&exintro&explaintext&titles=${encodeURIComponent(title)}&format=json&origin=*`, + { signal }, + ); + const pageData = await pageResponse.json(); + const page = Object.values(pageData.query.pages)[0]; + + return { + content: page.extract, + source: "WIKIPEDIA", + title: page.title, + url: `https://en.wikipedia.org/wiki/${encodeURIComponent(title)}`, + }; + }, + name: "WIKIPEDIA", + }, + }; + + // Initialize + this.init(); } -} -async function search(query) { - if (!query.trim()) { - updateUrlWithQuery(""); - return goHome(); - } - updateUrlWithQuery(query); - - // Show results view - if (!state.hasSearched) { - state.hasSearched = true; - heroEl.classList.add("compact"); - mainEl.style.display = "grid"; - statsBar.classList.add("visible"); - } - - // Stock ticker - const stockMatch = query.match(/^\$([A-Za-z]{1,5})$/); - if (stockMatch) { - showStockWidget(stockMatch[1].toUpperCase()); - addHistory(query); - return; - } - - // Bangs - const bangMatch = query.match(/^!(\w+)\s*(.*)/); - if (bangMatch) { - const bang = `!${bangMatch[1]}`; - const q = bangMatch[2]; - const src = Object.entries(SOURCES).find(([_, s]) => s.bang === bang); - if (src && q) { - Object.keys(SOURCES).forEach((k) => (SOURCES[k].enabled = k === src[0])); - renderSources(); - query = q; + /** + * Create a fetch handler with error handling + * @param {string} sourceName - Name of the source + * @param {Function} fetchFn - Fetch function + * @returns {Function} Wrapped fetch function + */ + createSourceFetchHandler(sourceName, fetchFn) { + return async (query, signal) => { + try { + return await fetchFn(query, signal); + } catch (error) { + if (error.name === "AbortError") { + throw error; // Let abort errors pass through + } + console.error(`Error fetching from ${sourceName}:`, error); + return []; + } + }; + } + + /** + * Initialize the search engine + */ + init() { + this.renderSources(); + this.renderHistory(); + this.renderWatchlist(); + this.initTheme(); + this.updateClocks(); + setInterval(() => this.updateClocks(), 1000); + this.setupCalc(); + this.setupListeners(); + this.setupWatchlistListeners(); + this.handleUrlQueryOnLoad(); + this.setupKeyboardNavigation(); + } + + /** + * Render sources list + */ + renderSources() { + const sourcesList = document.getElementById("sources-list"); + if (!sourcesList) return; + + sourcesList.innerHTML = Object.entries(this.sources) + .map( + ([key, source]) => ` + <div class="source-item ${source.enabled ? "active" : ""}" + data-source="${key}" + onclick="searchEngine.toggleSource('${key}')"> + ${source.name} <span class="source-count" id="c-${key}">${source.count}</span> + </div> + `, + ) + .join(""); + } + + /** + * Toggle source enabled state + * @param {string} key - Source key + */ + toggleSource(key) { + if (this.sources[key]) { + this.sources[key].enabled = !this.sources[key].enabled; + this.renderSources(); + if (this.state.query) { + this.search(this.state.query); + } + } + } + + /** + * Initialize theme from localStorage + */ + initTheme() { + const theme = localStorage.getItem("theme"); + if (theme === "light") { + document.body.classList.add("light-mode"); + } + } + + /** + * Toggle theme between dark and light + */ + toggleTheme() { + document.body.classList.toggle("light-mode"); + const isLight = document.body.classList.contains("light-mode"); + localStorage.setItem("theme", isLight ? "light" : "dark"); + } + + /** + * Render watchlist + */ + renderWatchlist() { + const container = document.getElementById("watchlist-container"); + if (!container) return; + + if (!this.state.watchlist.length) { + container.innerHTML = + '<div style="color:var(--gray);font-size:11px;padding:10px;">EMPTY</div>'; + return; + } + + container.innerHTML = this.state.watchlist + .map((ticker) => { + const exchange = this.stockExchanges[ticker] || "STOCK"; + return ` + <div class="watchlist-item-row" onclick="searchEngine.searchFor('$${ticker}')"> + <span>${ticker} <span style="color:var(--gray)">${exchange}</span></span> + <span class="watchlist-del-btn" onclick="searchEngine.removeTicker(event, '${ticker}')">×</span> + </div> + `; + }) + .join(""); + } + + /** + * Add ticker to watchlist + */ + addTicker() { + const input = document.getElementById("watchlist-input"); + if (!input) return; + + const ticker = input.value.toUpperCase().trim(); + if (ticker && !this.state.watchlist.includes(ticker)) { + this.state.watchlist.push(ticker); + localStorage.setItem("sw", JSON.stringify(this.state.watchlist)); + this.renderWatchlist(); + input.value = ""; } } - // Calc - if (query.startsWith("=")) { - showCalcResult(query.slice(1)); - return; + /** + * Remove ticker from watchlist + * @param {Event} event - Click event + * @param {string} ticker - Ticker to remove + */ + removeTicker(event, ticker) { + event.stopPropagation(); + this.state.watchlist = this.state.watchlist.filter((t) => t !== ticker); + localStorage.setItem("sw", JSON.stringify(this.state.watchlist)); + this.renderWatchlist(); } - state.query = query; - showLoading(); - addHistory(query); + /** + * Setup watchlist event listeners + */ + setupWatchlistListeners() { + const addBtn = document.getElementById("watchlist-add-btn"); + const input = document.getElementById("watchlist-input"); + + if (addBtn) { + addBtn.onclick = () => this.addTicker(); + } + + if (input) { + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") this.addTicker(); + }); + } + } + + /** + * Get trading symbol for ticker + * @param {string} ticker - Stock ticker + * @returns {string} Trading symbol + */ + getSymbol(ticker) { + const t = ticker.toUpperCase(); + const exchange = this.stockExchanges[t]; + + if (exchange === "CRYPTO") return `BINANCE:${t}USDT`; + if (exchange === "FX") return `FX:${t}`; + if (exchange) return `${exchange}:${t}`; + return `NASDAQ:${t}`; + } + + /** + * Update URL with search query + * @param {string} query - Search query + */ + updateUrlWithQuery(query) { + const url = new URL(window.location); + if (query && query.trim()) { + url.searchParams.set("q", query); + } else { + url.searchParams.delete("q"); + } + window.history.replaceState({}, "", url); + } + + /** + * Get query from URL + * @returns {string} Query string + */ + getQueryFromUrl() { + const params = new URLSearchParams(window.location.search); + return params.get("q") || ""; + } + + /** + * Handle URL query on page load + */ + handleUrlQueryOnLoad() { + const query = this.getQueryFromUrl(); + if (query.trim()) { + this.searchInput.value = query; + this.search(query); + } + } + + /** + * Main search function + * @param {string} query - Search query + */ + async search(query) { + // Cancel any ongoing request + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + + const trimmedQuery = query.trim(); + if (!trimmedQuery) { + this.updateUrlWithQuery(""); + return this.goHome(); + } + + this.updateUrlWithQuery(trimmedQuery); + + // Show results view + if (!this.state.hasSearched) { + this.state.hasSearched = true; + this.heroEl.classList.add("compact"); + this.mainEl.style.display = "grid"; + this.statsBar.classList.add("visible"); + } + + // Stock ticker + const stockMatch = trimmedQuery.match(/^\$([A-Za-z]{1,5})$/); + if (stockMatch) { + this.showStockWidget(stockMatch[1].toUpperCase()); + this.addHistory(trimmedQuery); + return; + } + + // Bangs + const bangMatch = trimmedQuery.match(/^!(\w+)\s*(.*)/); + if (bangMatch) { + const bang = `!${bangMatch[1]}`; + const bangQuery = bangMatch[2]; + const sourceEntry = Object.entries(this.sources).find(([_, s]) => s.bang === bang); + + if (sourceEntry && bangQuery) { + // Enable only this source + Object.keys(this.sources).forEach((k) => { + this.sources[k].enabled = k === sourceEntry[0]; + }); + this.renderSources(); + query = bangQuery; + } + } - const start = performance.now(); - const enabled = Object.entries(SOURCES).filter(([_, s]) => s.enabled); + // Calculator + if (trimmedQuery.startsWith("=")) { + this.showCalcResult(trimmedQuery.slice(1)); + return; + } - fetchInstant(query); + this.state.query = trimmedQuery; + this.showLoading(); + this.addHistory(trimmedQuery); + + const startTime = performance.now(); + const enabledSources = Object.entries(this.sources).filter(([_, s]) => s.enabled); + + // Fetch instant results + this.fetchInstant(trimmedQuery); + + // Fetch from all enabled sources + const promises = enabledSources.map(async ([key, source]) => { + try { + const results = await source.fetch(trimmedQuery, this.abortController.signal); + this.sources[key].count = results.length; + const countEl = document.getElementById(`c-${key}`); + if (countEl) countEl.textContent = results.length; + return results; + } catch (error) { + if (error.name !== "AbortError") { + console.error(`Source ${key} failed:`, error); + } + return []; + } + }); - const promises = enabled.map(async ([k, s]) => { try { - const r = await s.fetch(query); - SOURCES[k].count = r.length; - $(`c-${k}`).textContent = r.length; - return r; - } catch (e) { - return []; + const allResults = await Promise.all(promises); + this.state.results = allResults.flat(); + const elapsed = Math.round(performance.now() - startTime); + + // Update stats + document.getElementById("stat-results").textContent = this.state.results.length; + document.getElementById("stat-sources").textContent = enabledSources.length; + document.getElementById("stat-time").textContent = `${elapsed}MS`; + document.getElementById("results-label").textContent = `${this.state.results.length} RESULTS`; + document.getElementById("results-info").textContent = + `${elapsed}MS // ${enabledSources.length} SOURCES`; + + this.renderResults(); + this.state.selectedResultIndex = -1; // Reset selection + } catch (error) { + if (error.name !== "AbortError") { + console.error("Search failed:", error); + Toast.show("SEARCH FAILED"); + } } - }); + } - const all = await Promise.all(promises); - state.results = all.flat(); - const elapsed = Math.round(performance.now() - start); + /** + * Go to home state + */ + goHome() { + this.state.hasSearched = false; + this.heroEl.classList.remove("compact"); + this.mainEl.style.display = "none"; + this.statsBar.classList.remove("visible"); + this.searchInput.value = ""; + this.updateUrlWithQuery(""); + this.hideSuggestions(); + this.state.selectedResultIndex = -1; + } - $("stat-results").textContent = state.results.length; - $("stat-sources").textContent = enabled.length; - $("stat-time").textContent = `${elapsed}MS`; - $("results-label").textContent = `${state.results.length} RESULTS`; - $("results-info").textContent = `${elapsed}MS // ${enabled.length} SOURCES`; + /** + * Show stock widget + * @param {string} ticker - Stock ticker + */ + showStockWidget(ticker) { + const symbol = this.getSymbol(ticker); + const widgetId = `tv_${Math.random().toString(36).substr(2, 9)}`; + const isLight = document.body.classList.contains("light-mode"); + + this.resultsEl.innerHTML = ` + <div class="stock-widget"> + <div class="stock-header"> + <div class="stock-info"> + <h2>${ticker}</h2> + <div class="ticker">${symbol}</div> + </div> + <div class="stock-actions-top"> + <button class="stock-btn" onclick="window.open('https://www.tradingview.com/symbols/${symbol}/','_blank')">TRADINGVIEW ↗</button> + <button class="stock-btn" onclick="Utils.copy('${symbol}')">COPY</button> + </div> + </div> + <div class="tradingview-widget-container" id="${widgetId}"></div> + </div> + `; - renderResults(); -} + document.getElementById("results-label").textContent = `STOCK: ${ticker}`; + document.getElementById("results-info").textContent = symbol; -function goHome() { - state.hasSearched = false; - heroEl.classList.remove("compact"); - mainEl.style.display = "none"; - statsBar.classList.remove("visible"); - searchInput.value = ""; - updateUrlWithQuery(""); -} + const tvWidget = new TradingViewWidget(widgetId, symbol, isLight); + tvWidget.load(); + } -// ═══════════════════════════════════════════════════════════ -// STOCK WIDGET -// ═══════════════════════════════════════════════════════════ -function showStockWidget(ticker) { - const symbol = getSymbol(ticker); - const widgetId = `tv_${Math.random().toString(36).substr(2, 9)}`; - const isLight = document.body.classList.contains("light-mode"); - - resultsEl.innerHTML = ` - <div class="stock-widget"> - <div class="stock-header"> - <div class="stock-info"> - <h2>${ticker}</h2> - <div class="ticker">${symbol}</div> - </div> - <div class="stock-actions-top"> - <button class="stock-btn" onclick="window.open('https://www.tradingview.com/symbols/${symbol}/','_blank')">TRADINGVIEW ↗</button> - <button class="stock-btn" onclick="copy('${symbol}')">COPY</button> + /** + * Fetch instant results (dictionary/Wikipedia) + * @param {string} query - Search query + */ + async fetchInstant(query) { + // Dictionary + if (/^[a-zA-Z]+$/.test(query) && this.sources.dictionary.enabled) { + try { + const response = await fetch( + `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(query)}`, + { signal: this.abortController?.signal }, + ); + if (response.ok) { + const data = await response.json(); + if (data[0]) { + const meanings = data[0].meanings + .slice(0, 3) + .map((m) => `[${m.partOfSpeech.toUpperCase()}] ${m.definitions[0]?.definition}`) + .join("\n\n"); + this.showInstant({ + content: meanings, + source: "DICTIONARY", + title: data[0].word.toUpperCase() + (data[0].phonetic ? ` ${data[0].phonetic}` : ""), + }); + return; + } + } + } catch (error) { + if (error.name !== "AbortError") { + console.error("Dictionary fetch failed:", error); + } + } + } + + // Wikipedia + if (this.sources.wikipedia.enabled && this.sources.wikipedia.instant) { + try { + const instantResult = await this.sources.wikipedia.instant( + query, + this.abortController?.signal, + ); + if (instantResult) { + this.showInstant(instantResult); + } + } catch (error) { + if (error.name !== "AbortError") { + console.error("Wikipedia instant fetch failed:", error); + } + } + } + } + + /** + * Show instant answer + * @param {Object} data - Instant answer data + */ + showInstant(data) { + const instantBox = document.createElement("div"); + instantBox.className = "instant-box"; + instantBox.innerHTML = ` + <div class="instant-label">INSTANT ANSWER</div> + <div class="instant-title">${Utils.esc(data.title)}</div> + <div class="instant-content">${Utils.esc(data.content) + .split("\n") + .map((p) => `<p>${p}</p>`) + .join("")}</div> + <div class="instant-meta"> + <span>SOURCE: ${Utils.esc(data.source)}</span> + ${data.url ? `<a href="${data.url}" target="_blank">VIEW FULL →</a>` : ""} + </div> + `; + + // Clear previous results but keep instant box if it exists + const existingInstant = this.resultsEl.querySelector(".instant-box"); + if (existingInstant) { + existingInstant.remove(); + } + + // Insert at the beginning + if (this.resultsEl.firstChild) { + this.resultsEl.insertBefore(instantBox, this.resultsEl.firstChild); + } else { + this.resultsEl.appendChild(instantBox); + } + } + + /** + * Render search results (batched for performance) + */ + renderResults() { + if (this.state.results.length === 0) { + this.resultsEl.innerHTML = ` + <div class="empty-state"> + <div class="empty-title">NO RESULTS</div> + <div class="empty-text">TRY DIFFERENT KEYWORDS OR ENABLE MORE SOURCES</div> + </div> + `; + return; + } + + // Use DocumentFragment for batch DOM updates + const fragment = document.createDocumentFragment(); + + // Keep instant box if present + const instantBox = this.resultsEl.querySelector(".instant-box"); + if (instantBox) { + fragment.appendChild(instantBox); + } + + // Create result elements + this.state.results.forEach((result, index) => { + const resultEl = document.createElement("div"); + resultEl.className = "result"; + resultEl.dataset.index = index; + resultEl.innerHTML = ` + <div class="result-index">${String(index + 1).padStart(2, "0")}</div> + <div class="result-body"> + <div class="result-source">${result.source.toUpperCase()}</div> + <div class="result-title">${Utils.esc(result.title)}</div> + <div class="result-url">${Utils.esc(result.url)}</div> + <div class="result-snippet">${Utils.highlight(Utils.esc(result.snippet), this.state.query)}</div> + ${ + result.meta + ? ` + <div class="result-meta"> + ${result.meta.stars !== undefined ? `<span>★ ${Utils.fmt(result.meta.stars)}</span>` : ""} + ${result.meta.forks !== undefined ? `<span>⑂ ${Utils.fmt(result.meta.forks)}</span>` : ""} + ${result.meta.score !== undefined ? `<span>↑ ${Utils.fmt(result.meta.score)}</span>` : ""} + ${result.meta.comments !== undefined ? `<span>◨ ${Utils.fmt(result.meta.comments)}</span>` : ""} + ${result.meta.words !== undefined ? `<span>◎ ${Utils.fmt(result.meta.words)}W</span>` : ""} + ${result.meta.lang ? `<span>${result.meta.lang.toUpperCase()}</span>` : ""} + ${result.meta.version ? `<span>V${result.meta.version}</span>` : ""} </div> - </div> - <div class="tradingview-widget-container" id="${widgetId}"></div> + ` + : "" + } </div> `; - $("results-label").textContent = `STOCK: ${ticker}`; - $("results-info").textContent = symbol; + // Add click handler + resultEl.addEventListener("click", (e) => { + if (!e.target.closest(".result-meta")) { + window.open(result.url, "_blank"); + } + }); - loadTradingView(widgetId, symbol, isLight); -} + fragment.appendChild(resultEl); + }); -function loadTradingView(containerId, symbol, isLight) { - if (!window.TradingView) { - const s = document.createElement("script"); - s.src = "https://s3.tradingview.com/tv.js"; - s.onload = () => createChart(containerId, symbol, isLight); - document.head.appendChild(s); - } else { - createChart(containerId, symbol, isLight); + // Batch update + this.resultsEl.innerHTML = ""; + this.resultsEl.appendChild(fragment); } -} -function createChart(containerId, symbol, isLight) { - new TradingView.widget({ - allow_symbol_change: true, - backgroundColor: isLight ? "rgba(244, 244, 245, 1)" : "rgba(0,0,0,1)", - container_id: containerId, - enable_publishing: false, - gridColor: isLight ? "rgba(220, 220, 220, 1)" : "rgba(30,30,30,1)", - height: 500, - interval: "D", - locale: "en", - studies: ["Volume@tv-basicstudies"], - style: "1", - symbol: symbol, - theme: isLight ? "light" : "dark", - timezone: "Etc/UTC", - toolbar_bg: isLight ? "#f4f4f5" : "#000", - width: "100%", - }); -} + /** + * Show loading state + */ + showLoading() { + this.resultsEl.innerHTML = Array(4) + .fill( + '<div class="loading-row"><span class="loading-text">FETCHING</span><div class="loading-bar"></div></div>', + ) + .join(""); + } -async function fetchInstant(query) { - if (/^[a-zA-Z]+$/.test(query) && SOURCES.dictionary.enabled) { - try { - const r = await fetch( - `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(query)}`, - ); - if (r.ok) { - const d = await r.json(); - if (d[0]) { - const meanings = d[0].meanings - .slice(0, 3) - .map((m) => `[${m.partOfSpeech.toUpperCase()}] ${m.definitions[0]?.definition}`) - .join("\n\n"); - showInstant({ - content: meanings, - source: "DICTIONARY", - title: d[0].word.toUpperCase() + (d[0].phonetic ? ` ${d[0].phonetic}` : ""), - }); - return; + /** + * Show calculator result + * @param {string} expr - Mathematical expression + */ + showCalcResult(expr) { + const result = Utils.safeEval(expr); + this.resultsEl.innerHTML = ` + <div class="instant-box"> + <div class="instant-label">CALCULATOR</div> + <div class="instant-title" style="font-variant-numeric:tabular-nums"> + ${Utils.esc(expr)} = ${result} + </div> + </div> + `; + document.getElementById("results-label").textContent = "CALCULATOR"; + } + + /** + * Setup calculator + */ + setupCalc() { + document.querySelectorAll(".calc-btn").forEach((btn) => { + btn.addEventListener("click", () => { + const value = btn.dataset.v; + if (value === "C") { + this.state.calcExpr = ""; + this.state.calcResult = "0"; + } else if (value === "=") { + try { + this.state.calcResult = Utils.safeEval(this.state.calcExpr); + } catch { + this.state.calcResult = "ERR"; + } + } else { + this.state.calcExpr += value; } - } - } catch {} + + document.getElementById("calc-expr").textContent = this.state.calcExpr; + document.getElementById("calc-result").textContent = this.state.calcResult; + }); + }); } - if (SOURCES.wikipedia.enabled) { - const w = await SOURCES.wikipedia.instant?.(query); - if (w) showInstant(w); + + /** + * Add query to history + * @param {string} query - Search query + */ + addHistory(query) { + this.state.history = this.state.history.filter((h) => h.q !== query); + this.state.history.unshift({ q: query, t: Date.now() }); + this.state.history = this.state.history.slice(0, 20); + localStorage.setItem("sh", JSON.stringify(this.state.history)); + this.renderHistory(); } -} -function showInstant(data) { - const el = document.createElement("div"); - el.className = "instant-box"; - el.innerHTML = ` - <div class="instant-label">INSTANT ANSWER</div> - <div class="instant-title">${esc(data.title)}</div> - <div class="instant-content">${esc(data.content) - .split("\n") - .map((p) => `<p>${p}</p>`) - .join("")}</div> - <div class="instant-meta"> - <span>SOURCE: ${esc(data.source)}</span> - ${data.url ? `<a href="${data.url}" target="_blank">VIEW FULL →</a>` : ""} + /** + * Render history + */ + renderHistory() { + const list = document.getElementById("history-list"); + if (!list) return; + + if (!this.state.history.length) { + list.innerHTML = '<div style="color:var(--gray);font-size:11px;">NO HISTORY</div>'; + return; + } + + list.innerHTML = this.state.history + .slice(0, 6) + .map( + (item) => ` + <div class="history-item" onclick="searchEngine.searchFor('${Utils.esc(item.q)}')"> + <span>${Utils.esc(item.q)}</span> + <span class="history-time">${Utils.timeAgo(item.t)}</span> </div> - `; - resultsEl.innerHTML = ""; - resultsEl.appendChild(el); -} + `, + ) + .join(""); + } -function renderResults() { - if (state.results.length === 0) { - resultsEl.innerHTML = `<div class="empty-state"><div class="empty-title">NO RESULTS</div><div class="empty-text">TRY DIFFERENT KEYWORDS OR ENABLE MORE SOURCES</div></div>`; - return; - } - - const instant = resultsEl.querySelector(".instant-box"); - resultsEl.innerHTML = ""; - if (instant) resultsEl.appendChild(instant); - - state.results.forEach((r, i) => { - const el = document.createElement("div"); - el.className = "result"; - el.innerHTML = ` - <div class="result-index">${String(i + 1).padStart(2, "0")}</div> - <div class="result-body"> - <div class="result-source">${r.source.toUpperCase()}</div> - <div class="result-title" onclick="window.open('${esc(r.url)}','_blank')">${esc(r.title)}</div> - <div class="result-url">${esc(r.url)}</div> - <div class="result-snippet">${highlight(esc(r.snippet), state.query)}</div> - ${ - r.meta - ? `<div class="result-meta"> - ${r.meta.stars !== undefined ? `<span>★ ${fmt(r.meta.stars)}</span>` : ""} - ${r.meta.forks !== undefined ? `<span>⑂ ${fmt(r.meta.forks)}</span>` : ""} - ${r.meta.score !== undefined ? `<span>↑ ${fmt(r.meta.score)}</span>` : ""} - ${r.meta.comments !== undefined ? `<span>◨ ${fmt(r.meta.comments)}</span>` : ""} - ${r.meta.words !== undefined ? `<span>◎ ${fmt(r.meta.words)}W</span>` : ""} - ${r.meta.lang ? `<span>${r.meta.lang.toUpperCase()}</span>` : ""} - ${r.meta.version ? `<span>V${r.meta.version}</span>` : ""} - </div>` - : "" - } + /** + * Update clocks + */ + updateClocks() { + const now = new Date(); + const clockEl = document.getElementById("clock"); + if (clockEl) { + clockEl.textContent = now.toLocaleTimeString("en-GB"); + } + + const zones = [ + { label: "LOCAL", tz: Intl.DateTimeFormat().resolvedOptions().timeZone }, + { label: "UTC", tz: "UTC" }, + { label: "NYC", tz: "America/New_York" }, + { label: "TOKYO", tz: "Asia/Tokyo" }, + ]; + + const clocksEl = document.getElementById("world-clocks"); + if (clocksEl) { + clocksEl.innerHTML = zones + .map( + (zone) => ` + <div class="clock"> + <div class="clock-time"> + ${now.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + timeZone: zone.tz, + })} + </div> + <div class="clock-label">${zone.label}</div> </div> - `; - resultsEl.appendChild(el); - }); -} + `, + ) + .join(""); + } + } -function showLoading() { - resultsEl.innerHTML = Array(4) - .fill( - `<div class="loading-row"><span class="loading-text">FETCHING</span><div class="loading-bar"></div></div>`, - ) - .join(""); -} + /** + * Setup event listeners + */ + setupListeners() { + // Debounced search on input + const debouncedSearch = Utils.debounce((value) => { + this.search(value); + this.updateSuggestions(value); + }, 300); + + this.searchInput.addEventListener("input", (e) => { + debouncedSearch(e.target.value); + }); + + // Enter key for immediate search + this.searchInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + this.search(this.searchInput.value); + this.hideSuggestions(); + } else if (e.key === "Escape") { + this.goHome(); + } + }); + + // Global keyboard shortcuts + document.addEventListener("keydown", (e) => { + if ( + e.key === "/" && + document.activeElement !== this.searchInput && + document.activeElement !== document.getElementById("watchlist-input") + ) { + e.preventDefault(); + this.searchInput.focus(); + } + if (e.key === "Escape") { + this.goHome(); + } + }); + + // Clear button + const clearBtn = document.getElementById("clear-btn"); + if (clearBtn) { + clearBtn.onclick = () => { + this.goHome(); + this.searchInput.focus(); + }; + } -function showCalcResult(expr) { - try { - const result = Function(`"use strict";return (${expr.replace(/\^/g, "**")})`)(); - resultsEl.innerHTML = `<div class="instant-box"><div class="instant-label">CALCULATOR</div><div class="instant-title" style="font-variant-numeric:tabular-nums">${esc(expr)} = ${result}</div></div>`; - } catch { - toast("INVALID EXPRESSION"); - } -} + // Voice search + const voiceBtn = document.getElementById("voice-btn"); + if (voiceBtn) { + voiceBtn.onclick = () => this.voiceSearch(); + } -// ═══════════════════════════════════════════════════════════ -// CALCULATOR -// ═══════════════════════════════════════════════════════════ -function setupCalc() { - document.querySelectorAll(".calc-btn").forEach((btn) => { - btn.onclick = () => { - const v = btn.dataset.v; - if (v === "C") { - state.calcExpr = ""; - state.calcResult = "0"; - } else if (v === "=") { - try { - state.calcResult = Function( - `"use strict";return (${state.calcExpr.replace(/\^/g, "**")})`, - )(); - } catch { - state.calcResult = "ERR"; + // Handle browser back/forward + window.addEventListener("popstate", () => { + const query = this.getQueryFromUrl(); + if (query !== this.state.query) { + if (query.trim()) { + this.searchInput.value = query; + this.search(query); + } else { + this.goHome(); } - } else { - state.calcExpr += v; } - $("calc-expr").textContent = state.calcExpr; - $("calc-result").textContent = state.calcResult; + }); + } + + /** + * Setup keyboard navigation for results + */ + setupKeyboardNavigation() { + document.addEventListener("keydown", (e) => { + if (!this.state.hasSearched || this.state.results.length === 0) return; + + const results = Array.from(document.querySelectorAll(".result")); + if (results.length === 0) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + this.navigateResults(1); + break; + case "ArrowUp": + e.preventDefault(); + this.navigateResults(-1); + break; + case "Enter": + if (this.state.selectedResultIndex >= 0) { + const result = results[this.state.selectedResultIndex]; + if (result) { + const url = result.querySelector(".result-url").textContent; + if (url) window.open(url, "_blank"); + } + } + break; + } + }); + } + + /** + * Navigate through results with arrow keys + * @param {number} direction - 1 for down, -1 for up + */ + navigateResults(direction) { + const results = Array.from(document.querySelectorAll(".result")); + if (results.length === 0) return; + + // Remove previous selection + if (this.state.selectedResultIndex >= 0 && results[this.state.selectedResultIndex]) { + results[this.state.selectedResultIndex].classList.remove("selected"); + } + + // Calculate new index + let newIndex = this.state.selectedResultIndex + direction; + if (newIndex < 0) newIndex = results.length - 1; + if (newIndex >= results.length) newIndex = 0; + + // Apply new selection + this.state.selectedResultIndex = newIndex; + results[newIndex].classList.add("selected"); + results[newIndex].scrollIntoView({ behavior: "smooth", block: "nearest" }); + } + + /** + * Update search suggestions + * @param {string} query - Current query + */ + updateSuggestions(query) { + if (!query.trim()) { + this.hideSuggestions(); + return; + } + + // Get suggestions from history and bangs + this.suggestions = [ + ...this.state.history + .filter((item) => item.q.toLowerCase().includes(query.toLowerCase())) + .map((item) => item.q) + .slice(0, 3), + ...Object.values(this.sources) + .map((source) => `${source.bang} `) + .filter((bang) => bang.includes(query.toLowerCase())), + ]; + + this.showSuggestions(); + } + + /** + * Show suggestions dropdown + */ + showSuggestions() { + if (this.suggestions.length === 0) { + this.hideSuggestions(); + return; + } + + // Create or update suggestions container + let container = document.querySelector(".suggestions-container"); + if (!container) { + container = document.createElement("div"); + container.className = "suggestions-container"; + this.searchInput.parentNode.appendChild(container); + } + + container.innerHTML = this.suggestions + .map( + (suggestion) => ` + <div class="suggestion" onclick="searchEngine.selectSuggestion('${Utils.esc(suggestion)}')"> + ${Utils.esc(suggestion)} + </div> + `, + ) + .join(""); + + this.showingSuggestions = true; + } + + /** + * Hide suggestions dropdown + */ + hideSuggestions() { + const container = document.querySelector(".suggestions-container"); + if (container) { + container.remove(); + } + this.showingSuggestions = false; + } + + /** + * Select a suggestion + * @param {string} suggestion - Selected suggestion + */ + selectSuggestion(suggestion) { + this.searchInput.value = suggestion; + this.search(suggestion); + this.hideSuggestions(); + } + + /** + * Voice search + */ + voiceSearch() { + if (!("webkitSpeechRecognition" in window || "SpeechRecognition" in window)) { + Toast.show("VOICE NOT SUPPORTED"); + return; + } + + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + const recognition = new SpeechRecognition(); + recognition.lang = "en-US"; + + const voiceBtn = document.getElementById("voice-btn"); + voiceBtn.style.background = "#fff"; + voiceBtn.style.color = "#000"; + + recognition.onresult = (event) => { + const transcript = event.results[0][0].transcript; + this.searchInput.value = transcript; + this.search(transcript); }; - }); -} -// ═══════════════════════════════════════════════════════════ -// TOOLS -// ═══════════════════════════════════════════════════════════ -function toolUUID() { - const uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); - }); - copy(uuid); - toast(`UUID: ${uuid.substring(0, 13)}...`); -} + recognition.onend = () => { + voiceBtn.style.background = ""; + voiceBtn.style.color = ""; + }; -let timerInterval, - timerSec = 0; -function toolTimer() { - if (!state.hasSearched) { - state.hasSearched = true; - heroEl.classList.add("compact"); - mainEl.style.display = "grid"; - statsBar.classList.add("visible"); - } - resultsEl.innerHTML = `<div class="tool-modal"><div class="instant-label">TIMER</div><div class="instant-title" id="timer-display" style="font-variant-numeric:tabular-nums">00:00:00</div><div class="tool-btn-row" style="margin-top:20px"><button class="tool-action" onclick="startTimer()">START</button><button class="tool-action" onclick="stopTimer()">STOP</button><button class="tool-action" onclick="resetTimer()">RESET</button></div></div>`; - $("results-label").textContent = "TIMER"; -} -window.startTimer = () => { - if (timerInterval) return; - timerInterval = setInterval(() => { - timerSec++; - const h = String(Math.floor(timerSec / 3600)).padStart(2, "0"); - const m = String(Math.floor((timerSec % 3600) / 60)).padStart(2, "0"); - const s = String(timerSec % 60).padStart(2, "0"); - $("timer-display").textContent = `${h}:${m}:${s}`; - }, 1000); -}; -window.stopTimer = () => { - clearInterval(timerInterval); - timerInterval = null; -}; -window.resetTimer = () => { - stopTimer(); - timerSec = 0; - $("timer-display").textContent = "00:00:00"; -}; - -function toolBase64() { - if (!state.hasSearched) { - state.hasSearched = true; - heroEl.classList.add("compact"); - mainEl.style.display = "grid"; - statsBar.classList.add("visible"); - } - resultsEl.innerHTML = `<div class="tool-modal"><div class="instant-label">BASE64</div><textarea id="b64-input" placeholder="ENTER TEXT..."></textarea><div class="tool-btn-row"><button class="tool-action" onclick="$('b64-output').textContent=btoa($('b64-input').value)">ENCODE</button><button class="tool-action" onclick="try{$('b64-output').textContent=atob($('b64-input').value)}catch{$('b64-output').textContent='INVALID'}">DECODE</button></div><div class="tool-output" id="b64-output">OUTPUT</div></div>`; - $("results-label").textContent = "BASE64"; -} + recognition.start(); + } -function toolJSON() { - if (!state.hasSearched) { - state.hasSearched = true; - heroEl.classList.add("compact"); - mainEl.style.display = "grid"; - statsBar.classList.add("visible"); + /** + * Search for specific query + * @param {string} query - Query to search for + */ + searchFor(query) { + this.searchInput.value = query; + this.search(query); } - resultsEl.innerHTML = `<div class="tool-modal"><div class="instant-label">JSON</div><textarea id="json-input" placeholder="PASTE JSON..."></textarea><div class="tool-btn-row"><button class="tool-action" onclick="formatJSON()">FORMAT</button><button class="tool-action" onclick="minifyJSON()">MINIFY</button></div><pre class="tool-output" id="json-output" style="white-space:pre-wrap;max-height:300px;overflow:auto">OUTPUT</pre></div>`; - $("results-label").textContent = "JSON"; -} -window.formatJSON = () => { - try { - $("json-output").textContent = JSON.stringify(JSON.parse($("json-input").value), null, 2); - } catch (e) { - $("json-output").textContent = `INVALID: ${e.message}`; - } -}; -window.minifyJSON = () => { - try { - $("json-output").textContent = JSON.stringify(JSON.parse($("json-input").value)); - } catch (e) { - $("json-output").textContent = `INVALID: ${e.message}`; - } -}; - -function toolHash() { - if (!state.hasSearched) { - state.hasSearched = true; - heroEl.classList.add("compact"); - mainEl.style.display = "grid"; - statsBar.classList.add("visible"); - } - resultsEl.innerHTML = `<div class="tool-modal"><div class="instant-label">SHA-256 HASH</div><textarea id="hash-input" placeholder="ENTER TEXT..."></textarea><div class="tool-btn-row"><button class="tool-action" onclick="genHash()">GENERATE</button></div><div class="tool-output" id="hash-output">OUTPUT</div></div>`; - $("results-label").textContent = "HASH"; -} -window.genHash = async () => { - const data = new TextEncoder().encode($("hash-input").value); - const hash = await crypto.subtle.digest("SHA-256", data); - $("hash-output").textContent = Array.from(new Uint8Array(hash)) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -}; - -function toolColor() { - if (!state.hasSearched) { - state.hasSearched = true; - heroEl.classList.add("compact"); - mainEl.style.display = "grid"; - statsBar.classList.add("visible"); - } - resultsEl.innerHTML = `<div class="tool-modal"><div class="instant-label">COLOR</div><input type="color" id="color-input" value="#ffffff" style="width:100%;height:100px;border:3px solid var(--fg);background:var(--bg);cursor:crosshair;"><div class="tool-output" id="color-output" style="margin-top:20px"></div></div>`; - const updateColor = () => { - const hex = $("color-input").value; - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - $("color-output").innerHTML = - `HEX: ${hex}<br>RGB: ${r}, ${g}, ${b}<br>HSL: ${rgbToHsl(r, g, b)}`; - }; - $("color-input").oninput = updateColor; - updateColor(); - $("results-label").textContent = "COLOR"; -} -function rgbToHsl(r, g, b) { - r /= 255; - g /= 255; - b /= 255; - const max = Math.max(r, g, b), - min = Math.min(r, g, b); - let h, - s, - l = (max + min) / 2; - if (max === min) { - h = s = 0; - } else { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - switch (max) { - case r: - h = ((g - b) / d + (g < b ? 6 : 0)) / 6; - break; - case g: - h = ((b - r) / d + 2) / 6; - break; - case b: - h = ((r - g) / d + 4) / 6; - break; + + /** + * Insert bang into search input + * @param {string} bang - Bang to insert + */ + insertBang(bang) { + this.searchInput.value = `${bang} `; + this.searchInput.focus(); + this.updateUrlWithQuery(`${bang} `); + } + + /** + * Toggle widget visibility + * @param {string} id - Widget ID + */ + toggleWidget(id) { + const widget = document.getElementById(id); + if (widget) { + widget.classList.toggle("open"); } } - return `${Math.round(h * 360)}°, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%`; -} -function toolPassword() { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"; - let pass = ""; - for (let i = 0; i < 20; i++) pass += chars[Math.floor(Math.random() * chars.length)]; - copy(pass); - toast(`PASSWORD: ${pass.substring(0, 10)}...`); -} + // Tool methods + toolUUID() { + const uuid = Utils.generateUUID(); + Utils.copy(uuid); + Toast.show(`UUID: ${uuid.substring(0, 13)}...`); + } -function toolLoremIpsum() { - const lorem = - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris."; - copy(lorem); - toast("LOREM IPSUM COPIED"); -} + toolTimer() { + if (!this.state.hasSearched) { + this.state.hasSearched = true; + this.heroEl.classList.add("compact"); + this.mainEl.style.display = "grid"; + this.statsBar.classList.add("visible"); + } + this.resultsEl.innerHTML = ` + <div class="tool-modal"> + <div class="instant-label">TIMER</div> + <div class="instant-title" id="timer-display" style="font-variant-numeric:tabular-nums">00:00:00</div> + <div class="tool-btn-row" style="margin-top:20px"> + <button class="tool-action" onclick="searchEngine.startTimer()">START</button> + <button class="tool-action" onclick="searchEngine.stopTimer()">STOP</button> + <button class="tool-action" onclick="searchEngine.resetTimer()">RESET</button> + </div> + </div> + `; + document.getElementById("results-label").textContent = "TIMER"; + } -// ═══════════════════════════════════════════════════════════ -// HISTORY -// ═══════════════════════════════════════════════════════════ -function addHistory(q) { - state.history = state.history.filter((h) => h.q !== q); - state.history.unshift({ q, t: Date.now() }); - state.history = state.history.slice(0, 20); - localStorage.setItem("sh", JSON.stringify(state.history)); - renderHistory(); -} + startTimer() { + if (this.timerInterval) return; + this.timerSec = this.timerSec || 0; + this.timerInterval = setInterval(() => { + this.timerSec++; + const h = String(Math.floor(this.timerSec / 3600)).padStart(2, "0"); + const m = String(Math.floor((this.timerSec % 3600) / 60)).padStart(2, "0"); + const s = String(this.timerSec % 60).padStart(2, "0"); + const display = document.getElementById("timer-display"); + if (display) display.textContent = `${h}:${m}:${s}`; + }, 1000); + } -function renderHistory() { - const list = $("history-list"); - if (!state.history.length) { - list.innerHTML = '<div style="color:var(--gray);font-size:11px;">NO HISTORY</div>'; - return; - } - list.innerHTML = state.history - .slice(0, 6) - .map( - (h) => - `<div class="history-item" onclick="searchFor('${esc(h.q)}')"><span>${esc(h.q)}</span><span class="history-time">${timeAgo(h.t)}</span></div>`, - ) - .join(""); -} + stopTimer() { + clearInterval(this.timerInterval); + this.timerInterval = null; + } -// ═══════════════════════════════════════════════════════════ -// CLOCKS -// ═══════════════════════════════════════════════════════════ -function updateClocks() { - const now = new Date(); - $("clock").textContent = now.toLocaleTimeString("en-GB"); - const zones = [ - { label: "LOCAL", tz: Intl.DateTimeFormat().resolvedOptions().timeZone }, - { label: "UTC", tz: "UTC" }, - { label: "NYC", tz: "America/New_York" }, - { label: "TOKYO", tz: "Asia/Tokyo" }, - ]; - $("world-clocks").innerHTML = zones - .map( - (z) => - `<div class="clock"><div class="clock-time">${new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", timeZone: z.tz })}</div><div class="clock-label">${z.label}</div></div>`, - ) - .join(""); -} + resetTimer() { + this.stopTimer(); + this.timerSec = 0; + const display = document.getElementById("timer-display"); + if (display) display.textContent = "00:00:00"; + } -// ═══════════════════════════════════════════════════════════ -// LISTENERS -// ═══════════════════════════════════════════════════════════ -function setupListeners() { - let debounce; - searchInput.addEventListener("input", (e) => { - clearTimeout(debounce); - debounce = setTimeout(() => search(e.target.value), 300); - }); - - searchInput.addEventListener("keydown", (e) => { - if (e.key === "Enter") { - clearTimeout(debounce); - search(searchInput.value); + toolBase64() { + if (!this.state.hasSearched) { + this.state.hasSearched = true; + this.heroEl.classList.add("compact"); + this.mainEl.style.display = "grid"; + this.statsBar.classList.add("visible"); } - if (e.key === "Escape") goHome(); - }); - - document.addEventListener("keydown", (e) => { - if ( - e.key === "/" && - document.activeElement !== searchInput && - document.activeElement !== $("watchlist-input") - ) { - e.preventDefault(); - searchInput.focus(); + this.resultsEl.innerHTML = ` + <div class="tool-modal"> + <div class="instant-label">BASE64</div> + <textarea id="b64-input" placeholder="ENTER TEXT..."></textarea> + <div class="tool-btn-row"> + <button class="tool-action" onclick="document.getElementById('b64-output').textContent = btoa(document.getElementById('b64-input').value)">ENCODE</button> + <button class="tool-action" onclick="try{document.getElementById('b64-output').textContent = atob(document.getElementById('b64-input').value)}catch{e){document.getElementById('b64-output').textContent='INVALID'}">DECODE</button> + </div> + <div class="tool-output" id="b64-output">OUTPUT</div> + </div> + `; + document.getElementById("results-label").textContent = "BASE64"; + } + + toolJSON() { + if (!this.state.hasSearched) { + this.state.hasSearched = true; + this.heroEl.classList.add("compact"); + this.mainEl.style.display = "grid"; + this.statsBar.classList.add("visible"); } - if (e.key === "Escape") goHome(); - }); + this.resultsEl.innerHTML = ` + <div class="tool-modal"> + <div class="instant-label">JSON</div> + <textarea id="json-input" placeholder="PASTE JSON..."></textarea> + <div class="tool-btn-row"> + <button class="tool-action" onclick="searchEngine.formatJSON()">FORMAT</button> + <button class="tool-action" onclick="searchEngine.minifyJSON()">MINIFY</button> + </div> + <pre class="tool-output" id="json-output" style="white-space:pre-wrap;max-height:300px;overflow:auto">OUTPUT</pre> + </div> + `; + document.getElementById("results-label").textContent = "JSON"; + } - $("clear-btn").onclick = goHome; - $("voice-btn").onclick = voiceSearch; + formatJSON() { + try { + const input = document.getElementById("json-input").value; + const output = JSON.stringify(JSON.parse(input), null, 2); + document.getElementById("json-output").textContent = output; + } catch (e) { + document.getElementById("json-output").textContent = `INVALID: ${e.message}`; + } + } - // Also update URL when clearing with clear button - $("clear-btn").onclick = () => { - goHome(); - searchInput.focus(); - }; -} + minifyJSON() { + try { + const input = document.getElementById("json-input").value; + const output = JSON.stringify(JSON.parse(input)); + document.getElementById("json-output").textContent = output; + } catch (e) { + document.getElementById("json-output").textContent = `INVALID: ${e.message}`; + } + } -function voiceSearch() { - if (!("webkitSpeechRecognition" in window || "SpeechRecognition" in window)) { - toast("VOICE NOT SUPPORTED"); - return; - } - const Sr = window.SpeechRecognition || window.webkitSpeechRecognition; - const r = new Sr(); - r.lang = "en-US"; - $("voice-btn").style.background = "#fff"; - $("voice-btn").style.color = "#000"; - r.onresult = (e) => { - searchInput.value = e.results[0][0].transcript; - search(searchInput.value); - }; - r.onend = () => { - $("voice-btn").style.background = ""; - $("voice-btn").style.color = ""; - }; - r.start(); -} + toolHash() { + if (!this.state.hasSearched) { + this.state.hasSearched = true; + this.heroEl.classList.add("compact"); + this.mainEl.style.display = "grid"; + this.statsBar.classList.add("visible"); + } + this.resultsEl.innerHTML = ` + <div class="tool-modal"> + <div class="instant-label">SHA-256 HASH</div> + <textarea id="hash-input" placeholder="ENTER TEXT..."></textarea> + <div class="tool-btn-row"> + <button class="tool-action" onclick="searchEngine.genHash()">GENERATE</button> + </div> + <div class="tool-output" id="hash-output">OUTPUT</div> + </div> + `; + document.getElementById("results-label").textContent = "HASH"; + } -// ═══════════════════════════════════════════════════════════ -// UTILITIES -// ═══════════════════════════════════════════════════════════ -function esc(s) { - if (!s) return ""; - return s.replace( - /[&<>"']/g, - (m) => ({ "'": "'", '"': """, "&": "&", "<": "<", ">": ">" })[m], - ); -} -function decodeHTML(s) { - const t = document.createElement("textarea"); - t.innerHTML = s; - return t.value; -} -function highlight(text, query) { - if (!query) return text; - const words = query.split(/\s+/).filter((w) => w.length > 2); - let result = text; - words.forEach((w) => { - result = result.replace(new RegExp(`(${w})`, "gi"), "<mark>$1</mark>"); - }); - return result; -} -function fmt(n) { - if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`; - if (n >= 1000) return `${(n / 1000).toFixed(1)}K`; - return n?.toString() || "0"; -} -function timeAgo(ts) { - const s = Math.floor((Date.now() - ts) / 1000); - if (s < 60) return "NOW"; - if (s < 3600) return `${Math.floor(s / 60)}M`; - if (s < 86400) return `${Math.floor(s / 3600)}H`; - return `${Math.floor(s / 86400)}D`; -} -function copy(text) { - navigator.clipboard.writeText(text); - toast("COPIED"); -} -function toast(msg) { - const t = document.createElement("div"); - t.className = "toast"; - t.textContent = msg; - $("toast-container").appendChild(t); - setTimeout(() => t.remove(), 3000); -} -function searchFor(q) { - searchInput.value = q; - search(q); -} -function insertBang(bang) { - searchInput.value = `${bang} `; - searchInput.focus(); -} + async genHash() { + const input = document.getElementById("hash-input").value; + const data = new TextEncoder().encode(input); + const hash = await crypto.subtle.digest("SHA-256", data); + const output = Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + document.getElementById("hash-output").textContent = output; + } -window.addEventListener("popstate", (event) => { - const query = getQueryFromUrl(); - if (query !== state.query) { - if (query.trim()) { - searchInput.value = query; - search(query); - } else { - goHome(); + toolColor() { + if (!this.state.hasSearched) { + this.state.hasSearched = true; + this.heroEl.classList.add("compact"); + this.mainEl.style.display = "grid"; + this.statsBar.classList.add("visible"); } + this.resultsEl.innerHTML = ` + <div class="tool-modal"> + <div class="instant-label">COLOR</div> + <input type="color" id="color-input" value="#ffffff" style="width:100%;height:100px;border:3px solid var(--fg);background:var(--bg);cursor:crosshair;"> + <div class="tool-output" id="color-output" style="margin-top:20px"></div> + </div> + `; + + const colorInput = document.getElementById("color-input"); + const colorOutput = document.getElementById("color-output"); + + const updateColor = () => { + const hex = colorInput.value; + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + colorOutput.innerHTML = `HEX: ${hex}<br>RGB: ${r}, ${g}, ${b}<br>HSL: ${Utils.rgbToHsl(r, g, b)}`; + }; + + colorInput.addEventListener("input", updateColor); + updateColor(); + document.getElementById("results-label").textContent = "COLOR"; } -}); -// Also update the URL when using shortcuts -window.insertBang = (bang) => { - searchInput.value = `${bang} `; - searchInput.focus(); - updateUrlWithQuery(`${bang} `); -}; + toolPassword() { + const password = Utils.generatePassword(); + Utils.copy(password); + Toast.show(`PASSWORD: ${password.substring(0, 10)}...`); + } + + toolLoremIpsum() { + const lorem = + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris."; + Utils.copy(lorem); + Toast.show("LOREM IPSUM COPIED"); + } +} + +// ═══════════════════════════════════════════════════════════ +// GLOBAL INSTANCE AND INITIALIZATION +// ═══════════════════════════════════════════════════════════ -init(); +// Create global instance +const searchEngine = new Search(); + +// Expose methods to global scope for onclick handlers +window.toggleTheme = () => searchEngine.toggleTheme(); +window.toggleWidget = (id) => searchEngine.toggleWidget(id); +window.toggleSource = (key) => searchEngine.toggleSource(key); +window.insertBang = (bang) => searchEngine.insertBang(bang); +window.searchFor = (query) => searchEngine.searchFor(query); +window.removeTicker = (event, ticker) => searchEngine.removeTicker(event, ticker); + +// Tool functions +window.toolUUID = () => searchEngine.toolUUID(); +window.toolTimer = () => searchEngine.toolTimer(); +window.toolBase64 = () => searchEngine.toolBase64(); +window.toolJSON = () => searchEngine.toolJSON(); +window.toolHash = () => searchEngine.toolHash(); +window.toolColor = () => searchEngine.toolColor(); +window.toolPassword = () => searchEngine.toolPassword(); +window.toolLoremIpsum = () => searchEngine.toolLoremIpsum(); + +// Timer functions +window.startTimer = () => searchEngine.startTimer(); +window.stopTimer = () => searchEngine.stopTimer(); +window.resetTimer = () => searchEngine.resetTimer(); + +// JSON functions +window.formatJSON = () => searchEngine.formatJSON(); +window.minifyJSON = () => searchEngine.minifyJSON(); + +// Hash function +window.genHash = () => searchEngine.genHash(); + +// Copy function +window.copy = (text) => Utils.copy(text); diff --git a/src/style.css b/src/style.css index c051103..74a10b4 100644 --- a/src/style.css +++ b/src/style.css @@ -1020,3 +1020,44 @@ body.light-mode ::-webkit-scrollbar-thumb { grid-template-columns: 1fr; } } + +/* Suggestions */ +.suggestions-container { + position: absolute; + top: 100%; + left: 80px; /* Align with search input */ + right: 160px; /* Leave space for action buttons */ + background: var(--bg); + border: 2px solid var(--border-color); + border-top: none; + z-index: 1000; + max-height: 300px; + overflow-y: auto; +} + +.suggestion { + padding: 12px 15px; + cursor: pointer; + font-size: 14px; + border-bottom: 1px solid var(--border-thin-color); + transition: background 0.1s; +} + +.suggestion:hover { + background: var(--hover-bg); +} + +.suggestion:last-child { + border-bottom: none; +} + +/* Selected result */ +.result.selected { + background: var(--hover-bg); + border-left: 3px solid var(--fg); +} + +.result.selected .result-index { + font-weight: bold; + color: var(--fg); +} |
