'use strict'; const { locale: uiLocale = 'en', t: uiT, } = globalThis.TabHarborI18n || {}; /* ---------------------------------------------------------------- UI HELPERS ---------------------------------------------------------------- */ function playCloseSound() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const t = ctx.currentTime; const duration = 0.25; const buffer = ctx.createBuffer(1, ctx.sampleRate * duration, ctx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < data.length; i++) { const pos = i / data.length; const env = pos < 0.1 ? pos / 0.1 : Math.pow(1 - (pos - 0.1) / 0.9, 1.5); data[i] = (Math.random() * 2 - 1) * env; } const source = ctx.createBufferSource(); source.buffer = buffer; const filter = ctx.createBiquadFilter(); filter.type = 'bandpass'; filter.Q.value = 2.0; filter.frequency.setValueAtTime(4000, t); filter.frequency.exponentialRampToValueAtTime(400, t + duration); const gain = ctx.createGain(); gain.gain.setValueAtTime(0.15, t); gain.gain.exponentialRampToValueAtTime(0.001, t + duration); source.connect(filter).connect(gain).connect(ctx.destination); source.start(t); setTimeout(() => ctx.close(), 500); } catch { // Audio not supported — fail silently } } function shootConfetti(x, y) { const colors = [ '#c8713a', '#e8a070', '#5a7a62', '#8aaa92', '#5a6b7a', '#8a9baa', '#d4b896', '#b35a5a', ]; const particleCount = 17; for (let i = 0; i < particleCount; i++) { const el = document.createElement('div'); const isCircle = Math.random() > 0.5; const size = 5 + Math.random() * 6; const color = colors[Math.floor(Math.random() * colors.length)]; el.style.cssText = ` position: fixed; left: ${x}px; top: ${y}px; width: ${size}px; height: ${size}px; background: ${color}; border-radius: ${isCircle ? '50%' : '2px'}; pointer-events: none; z-index: 9999; transform: translate(-50%, -50%); opacity: 1; `; document.body.appendChild(el); const angle = Math.random() * Math.PI * 2; const speed = 60 + Math.random() * 120; const vx = Math.cos(angle) * speed; const vy = Math.sin(angle) * speed - 80; const gravity = 200; const startTime = performance.now(); const duration = 700 + Math.random() * 200; function frame(now) { const elapsed = (now - startTime) / 1000; const progress = elapsed / (duration / 1000); if (progress >= 1) { el.remove(); return; } const px = vx * elapsed; const py = vy * elapsed + 0.5 * gravity * elapsed * elapsed; const opacity = progress < 0.5 ? 1 : 1 - (progress - 0.5) * 2; const rotate = elapsed * 200 * (isCircle ? 0 : 1); el.style.transform = `translate(calc(-50% + ${px}px), calc(-50% + ${py}px)) rotate(${rotate}deg)`; el.style.opacity = opacity; requestAnimationFrame(frame); } requestAnimationFrame(frame); } } function animateCardOut(card) { if (!card) return; const rect = card.getBoundingClientRect(); shootConfetti(rect.left + rect.width / 2, rect.top + rect.height / 2); card.classList.add('closing'); setTimeout(() => { card.remove(); checkAndShowEmptyState(); }, 300); } function showToast(message, { action } = {}) { const toast = document.getElementById('toast'); const toastText = document.getElementById('toastText'); if (!toast || !toastText) return; const toastAction = document.getElementById('toastAction'); if (!toast || !toastText) return; toastText.textContent = message; if (action && toastAction) { toastAction.textContent = action.label; toastAction.hidden = false; toastAction.onclick = async () => { try { await Promise.resolve(action.fn()); } catch (error) { console.error('Toast action failed:', error); } finally { toast.classList.remove('visible'); } }; } else if (toastAction) { toastAction.hidden = true; toastAction.onclick = null; } toast.classList.add('visible'); setTimeout(() => toast.classList.remove('visible'), 2500); } function setImageFallbackAttributes(imgEl, fallbackUrl = '') { if (!imgEl) return; const fallbackSources = Array.isArray(fallbackUrl) ? fallbackUrl.map(url => String(url || '').trim()).filter(Boolean) : String(fallbackUrl || '').trim() ? [String(fallbackUrl || '').trim()] : []; if (fallbackSources.length) { imgEl.dataset.fallbackSrc = fallbackSources[0]; if (fallbackSources.length > 1) { imgEl.dataset.fallbackSrcset = JSON.stringify(fallbackSources.slice(1)); } else { delete imgEl.dataset.fallbackSrcset; } } else { delete imgEl.dataset.fallbackSrc; delete imgEl.dataset.fallbackSrcset; } delete imgEl.dataset.fallbackApplied; } function revealImageFallback(imgEl) { if (!imgEl) return; imgEl.style.display = 'none'; const sibling = imgEl.nextElementSibling; if (!sibling) return; if ( sibling.classList.contains('group-nav-fallback') || sibling.classList.contains('chip-favicon-fallback') || sibling.classList.contains('inline-favicon-fallback') || sibling.classList.contains('popup-tab-favicon-fallback') || sibling.classList.contains('quick-shortcut-fallback') || sibling.classList.contains('tab-picker-favicon-fallback') ) { sibling.style.display = ( sibling.classList.contains('inline-favicon-fallback') || sibling.classList.contains('tab-picker-favicon-fallback') ) ? 'inline-flex' : 'flex'; } } function handleImageFallbackError(imgEl) { if (!imgEl) return; const fallbackQueue = []; const primaryFallback = String(imgEl.dataset.fallbackSrc || '').trim(); if (primaryFallback) fallbackQueue.push(primaryFallback); const serializedQueue = String(imgEl.dataset.fallbackSrcset || '').trim(); if (serializedQueue) { try { const parsed = JSON.parse(serializedQueue); if (Array.isArray(parsed)) { fallbackQueue.push(...parsed.map(url => String(url || '').trim()).filter(Boolean)); } } catch { // Ignore malformed fallback queues and continue to inline fallback. } } const currentSrc = String(imgEl.currentSrc || imgEl.src || '').trim(); const nextFallback = fallbackQueue.find(url => url && url !== currentSrc && url !== String(imgEl.dataset.fallbackApplied || '').trim()); if (nextFallback) { const remaining = fallbackQueue.filter(url => url && url !== nextFallback); imgEl.dataset.fallbackApplied = nextFallback; imgEl.dataset.fallbackSrc = nextFallback; if (remaining.length) { imgEl.dataset.fallbackSrcset = JSON.stringify(remaining); } else { delete imgEl.dataset.fallbackSrcset; } imgEl.src = nextFallback; return; } revealImageFallback(imgEl); } if (!globalThis.__tabHarborImageFallbackBound) { document.addEventListener('error', event => { const target = event.target; if (!(target instanceof HTMLImageElement)) return; if (!('fallbackSrc' in target.dataset) && !('fallbackSrcset' in target.dataset)) return; handleImageFallbackError(target); }, true); globalThis.__tabHarborImageFallbackBound = true; } function renderMissionsEmptyState() { return `
${uiT ? uiT('emptyTitle') : 'Inbox zero, but for tabs.'}
${uiT ? uiT('emptySubtitle') : "You're free."}
`; } function checkAndShowEmptyState() { const missionsEl = document.getElementById('openTabsMissions'); if (!missionsEl) return; const remaining = missionsEl.querySelectorAll('.mission-card:not(.closing)').length; if (remaining > 0) return; missionsEl.innerHTML = renderMissionsEmptyState(); const countEl = document.getElementById('openTabsSectionCount'); if (countEl) countEl.textContent = uiT ? uiT('emptyTabsCount') : '0 domains'; } function timeAgo(dateStr) { if (!dateStr) return ''; const then = new Date(dateStr); const now = new Date(); const diffMins = Math.floor((now - then) / 60000); const diffHours = Math.floor((now - then) / 3600000); const diffDays = Math.floor((now - then) / 86400000); if (diffMins < 1) return uiT ? uiT('timeJustNow') : 'just now'; if (diffMins < 60) return uiT ? uiT('timeMinAgo', { count: diffMins }) : `${diffMins} min ago`; if (diffHours < 24) { if (!uiT) return `${diffHours} hr${diffHours !== 1 ? 's' : ''} ago`; return diffHours === 1 ? uiT('timeHourAgo') : uiT('timeHoursAgo', { count: diffHours }); } if (diffDays === 1) return uiT ? uiT('timeYesterday') : 'yesterday'; return uiT ? uiT('timeDaysAgo', { count: diffDays }) : `${diffDays} days ago`; } function getGreeting() { const hour = new Date().getHours(); if (hour < 12) return uiT ? uiT('greetingMorning') : 'Good morning'; if (hour < 17) return uiT ? uiT('greetingAfternoon') : 'Good afternoon'; return uiT ? uiT('greetingEvening') : 'Good evening'; } function getDateDisplay() { const locale = uiLocale === 'zh-CN' ? 'zh-CN' : 'en-US'; return new Date().toLocaleDateString(locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); } const FRIENDLY_DOMAINS = { 'github.com': 'GitHub', 'www.github.com': 'GitHub', 'gist.github.com': 'GitHub Gist', 'youtube.com': 'YouTube', 'www.youtube.com': 'YouTube', 'music.youtube.com': 'YouTube Music', 'x.com': 'X', 'www.x.com': 'X', 'twitter.com': 'X', 'www.twitter.com': 'X', 'reddit.com': 'Reddit', 'www.reddit.com': 'Reddit', 'old.reddit.com': 'Reddit', 'substack.com': 'Substack', 'www.substack.com': 'Substack', 'medium.com': 'Medium', 'www.medium.com': 'Medium', 'linkedin.com': 'LinkedIn', 'www.linkedin.com': 'LinkedIn', 'stackoverflow.com': 'Stack Overflow', 'www.stackoverflow.com': 'Stack Overflow', 'news.ycombinator.com': 'Hacker News', 'google.com': 'Google', 'www.google.com': 'Google', 'mail.google.com': 'Gmail', 'docs.google.com': 'Google Docs', 'drive.google.com': 'Google Drive', 'calendar.google.com': 'Google Calendar', 'meet.google.com': 'Google Meet', 'gemini.google.com': 'Gemini', 'chatgpt.com': 'ChatGPT', 'www.chatgpt.com': 'ChatGPT', 'chat.openai.com': 'ChatGPT', 'claude.ai': 'Claude', 'www.claude.ai': 'Claude', 'code.claude.com': 'Claude Code', 'notion.so': 'Notion', 'www.notion.so': 'Notion', 'figma.com': 'Figma', 'www.figma.com': 'Figma', 'slack.com': 'Slack', 'app.slack.com': 'Slack', 'discord.com': 'Discord', 'www.discord.com': 'Discord', 'wikipedia.org': 'Wikipedia', 'en.wikipedia.org': 'Wikipedia', 'amazon.com': 'Amazon', 'www.amazon.com': 'Amazon', 'netflix.com': 'Netflix', 'www.netflix.com': 'Netflix', 'spotify.com': 'Spotify', 'open.spotify.com': 'Spotify', 'vercel.com': 'Vercel', 'www.vercel.com': 'Vercel', 'npmjs.com': 'npm', 'www.npmjs.com': 'npm', 'developer.mozilla.org': 'MDN', 'arxiv.org': 'arXiv', 'www.arxiv.org': 'arXiv', 'huggingface.co': 'Hugging Face', 'www.huggingface.co': 'Hugging Face', 'producthunt.com': 'Product Hunt', 'www.producthunt.com': 'Product Hunt', 'xiaohongshu.com': 'RedNote', 'www.xiaohongshu.com': 'RedNote', 'local-files': 'Local Files', }; function friendlyDomain(hostname) { if (!hostname) return ''; if (FRIENDLY_DOMAINS[hostname]) return FRIENDLY_DOMAINS[hostname]; if (hostname.endsWith('.substack.com') && hostname !== 'substack.com') { return capitalize(hostname.replace('.substack.com', '')) + "'s Substack"; } if (hostname.endsWith('.github.io')) { return capitalize(hostname.replace('.github.io', '')) + ' (GitHub Pages)'; } const clean = hostname .replace(/^www\./, '') .replace(/\.(co\.uk|co\.jp|com|org|net|io|co|ai|dev|app|so|me|xyz|info|us|uk)$/, ''); return clean.split('.').map(part => capitalize(part)).join(' '); } function capitalize(str) { if (!str) return ''; return str.charAt(0).toUpperCase() + str.slice(1); } function stripTitleNoise(title) { if (!title) return ''; title = title.replace(/^\(\d+\+?\)\s*/, ''); title = title.replace(/\s*\([\d,]+\+?\)\s*/g, ' '); title = title.replace(/\s*[\-\u2010-\u2015]\s*[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g, ''); title = title.replace(/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g, ''); title = title.replace(/\s+on X:\s*/, ': '); title = title.replace(/\s*\/\s*X\s*$/, ''); return title.trim(); } function cleanTitle(title, hostname) { if (!title || !hostname) return title || ''; const friendly = friendlyDomain(hostname); const domain = hostname.replace(/^www\./, ''); const seps = [' - ', ' | ', ' — ', ' · ', ' – ']; for (const sep of seps) { const idx = title.lastIndexOf(sep); if (idx === -1) continue; const suffix = title.slice(idx + sep.length).trim(); const suffixLow = suffix.toLowerCase(); if ( suffixLow === domain.toLowerCase() || suffixLow === friendly.toLowerCase() || suffixLow === domain.replace(/\.\w+$/, '').toLowerCase() || domain.toLowerCase().includes(suffixLow) || friendly.toLowerCase().includes(suffixLow) ) { const cleaned = title.slice(0, idx).trim(); if (cleaned.length >= 5) return cleaned; } } return title; } function smartTitle(title, url) { if (!url) return title || ''; let pathname = ''; let hostname = ''; try { const u = new URL(url); pathname = u.pathname; hostname = u.hostname; } catch { return title || ''; } const titleIsUrl = !title || title === url || title.startsWith(hostname) || title.startsWith('http'); if ((hostname === 'x.com' || hostname === 'twitter.com' || hostname === 'www.x.com') && pathname.includes('/status/')) { const username = pathname.split('/')[1]; if (username) return titleIsUrl ? `Post by @${username}` : title; } if (hostname === 'github.com' || hostname === 'www.github.com') { const parts = pathname.split('/').filter(Boolean); if (parts.length >= 2) { const [owner, repo, ...rest] = parts; if (rest[0] === 'issues' && rest[1]) return `${owner}/${repo} Issue #${rest[1]}`; if (rest[0] === 'pull' && rest[1]) return `${owner}/${repo} PR #${rest[1]}`; if (rest[0] === 'blob' || rest[0] === 'tree') return `${owner}/${repo} — ${rest.slice(2).join('/')}`; if (titleIsUrl) return `${owner}/${repo}`; } } if ((hostname === 'www.youtube.com' || hostname === 'youtube.com') && pathname === '/watch') { if (titleIsUrl) return 'YouTube Video'; } if ((hostname === 'www.reddit.com' || hostname === 'reddit.com' || hostname === 'old.reddit.com') && pathname.includes('/comments/')) { const parts = pathname.split('/').filter(Boolean); const subIdx = parts.indexOf('r'); if (subIdx !== -1 && parts[subIdx + 1] && titleIsUrl) { return `r/${parts[subIdx + 1]} post`; } } return title || url; } // Test exposure globalThis.friendlyDomain = friendlyDomain; globalThis.stripTitleNoise = stripTitleNoise; globalThis.cleanTitle = cleanTitle; globalThis.smartTitle = smartTitle; globalThis.capitalize = capitalize; const ICONS = { tabs: ``, close: ``, archive: ``, focus: ``, move: ``, pin: ``, moon: ``, };