diff --git a/apps/web-clipper-manifestv3/src/popup/index.html b/apps/web-clipper-manifestv3/src/popup/index.html new file mode 100644 index 000000000..29e7840b6 --- /dev/null +++ b/apps/web-clipper-manifestv3/src/popup/index.html @@ -0,0 +1,212 @@ + + + + + + Trilium Web Clipper + + + + + + + + \ No newline at end of file diff --git a/apps/web-clipper-manifestv3/src/popup/popup.css b/apps/web-clipper-manifestv3/src/popup/popup.css new file mode 100644 index 000000000..44a3bd4b4 --- /dev/null +++ b/apps/web-clipper-manifestv3/src/popup/popup.css @@ -0,0 +1,689 @@ +/* Modern Trilium Web Clipper Popup Styles with Theme Support */ + +/* Import shared theme system */ +@import url('../shared/theme.css'); + +/* Reset and base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + color: var(--color-text-primary); + background: var(--color-bg-primary); + width: 380px; + min-height: 500px; + max-height: 600px; + transition: var(--theme-transition); +} + +/* Popup container */ +.popup-container { + display: flex; + flex-direction: column; + height: 100%; +} + +/* Header */ +.popup-header { + background: var(--color-primary); + color: var(--color-text-inverse); + padding: 16px; + text-align: center; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.popup-title { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 18px; + font-weight: 600; + margin: 0; +} + +.persistent-connection-status { + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); +} + +.persistent-status-dot { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + cursor: pointer; +} + +.persistent-status-dot.connected { + background-color: #22c55e; + box-shadow: 0 0 6px rgba(34, 197, 94, 0.5); +} + +.persistent-status-dot.disconnected { + background-color: #ef4444; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.5); +} + +.persistent-status-dot.testing { + background-color: #f59e0b; + box-shadow: 0 0 6px rgba(245, 158, 11, 0.5); + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.popup-icon { + width: 24px; + height: 24px; +} + +/* Main content */ +.popup-main { + flex: 1; + padding: 16px; + display: flex; + flex-direction: column; + gap: 20px; +} + +/* Action buttons */ +.action-buttons { + display: flex; + flex-direction: column; + gap: 8px; +} + +.action-btn { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border: 1px solid var(--color-border-primary); + border-radius: 8px; + background: var(--color-surface); + color: var(--color-text-primary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: var(--theme-transition); + text-align: left; +} + +.action-btn:hover { + background: var(--color-surface-hover); + border-color: var(--color-border-focus); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.action-btn:active { + transform: translateY(0); + box-shadow: var(--shadow-sm); +} + +.action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-icon { + font-size: 18px; + min-width: 18px; + color: var(--color-icon-secondary); +} + +.action-btn:hover .btn-icon { + color: var(--color-primary); +} + +.btn-text { + flex: 1; +} + +/* Status section */ +.status-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.status-message { + padding: 12px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; +} + +.status-message--info { + background: var(--color-info-bg); + color: var(--color-info-text); + border: 1px solid var(--color-info-border); +} + +.status-message--success { + background: var(--color-success-bg); + color: var(--color-success-text); + border: 1px solid var(--color-success-border); +} + +.status-message--error { + background: var(--color-error-bg); + color: var(--color-error-text); + border: 1px solid var(--color-error-border); +} + +.progress-bar { + height: 4px; + background: var(--color-border-primary); + border-radius: 2px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--color-primary-gradient); + border-radius: 2px; + animation: progress-indeterminate 2s infinite; +} + +@keyframes progress-indeterminate { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(400px); + } +} + +.hidden { + display: none !important; +} + +/* Info section */ +.info-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.info-section h3 { + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; +} + +.current-page { + padding: 12px; + background: var(--color-surface-secondary); + border-radius: 6px; + border: 1px solid var(--color-border-primary); +} + +.page-title { + font-weight: 500; + color: var(--color-text-primary); + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page-url { + font-size: 12px; + color: var(--color-text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Already clipped indicator */ +.already-clipped { + margin-top: 12px; + padding: 10px 12px; + background: var(--color-success-bg); + border: 1px solid var(--color-success-border); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.already-clipped.hidden { + display: none; +} + +.clipped-label { + display: flex; + align-items: center; + gap: 6px; + flex: 1; +} + +.clipped-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + background: var(--color-success); + color: white; + border-radius: 50%; + font-size: 11px; + font-weight: bold; +} + +.clipped-text { + font-size: 13px; + font-weight: 500; + color: var(--color-success); +} + +.open-note-link { + font-size: 12px; + color: var(--color-primary); + text-decoration: none; + font-weight: 500; + white-space: nowrap; + transition: all 0.2s; +} + +.open-note-link:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +.open-note-link:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: 2px; +} + +.trilium-status { + padding: 12px; + background: var(--color-surface-secondary); + border-radius: 6px; + border: 1px solid var(--color-border-primary); +} + +.connection-status { + display: flex; + align-items: center; + gap: 8px; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-text-secondary); +} + +.connection-status[data-status="connected"] .status-indicator { + background: var(--color-success); +} + +.connection-status[data-status="disconnected"] .status-indicator { + background: var(--color-error); +} + +.connection-status[data-status="checking"] .status-indicator { + background: var(--color-warning); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* Footer */ +.popup-footer { + border-top: 1px solid var(--color-border-primary); + padding: 12px; + display: flex; + justify-content: space-between; + background: var(--color-surface-secondary); +} + +.footer-btn { + display: flex; + align-items: center; + gap: 3px; + padding: 5px 8px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--color-text-secondary); + font-size: 12px; + cursor: pointer; + transition: var(--theme-transition); +} + +.footer-btn:hover { + background: var(--color-surface-hover); + color: var(--color-text-primary); +} + +.footer-btn .btn-icon { + font-size: 14px; +} + +/* Responsive adjustments */ +@media (max-width: 400px) { + body { + width: 320px; + } + + .popup-main { + padding: 12px; + } + + .action-btn { + padding: 10px 12px; + } +} + +/* Theme toggle button styles */ +.theme-toggle { + background: var(--color-surface); + border: 1px solid var(--color-border-primary); + color: var(--color-text-secondary); +} + +.theme-toggle:hover { + background: var(--color-surface-hover); + color: var(--color-text-primary); +} + +/* Settings Panel Styles */ +.settings-panel { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--color-bg-primary); + z-index: 10; + display: flex; + flex-direction: column; +} + +.settings-panel.hidden { + display: none; +} + +.settings-header { + background: var(--color-primary); + color: var(--color-text-inverse); + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; +} + +.back-btn { + background: transparent; + border: none; + color: var(--color-text-inverse); + cursor: pointer; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + font-size: 14px; +} + +.back-btn:hover { + background: rgba(255, 255, 255, 0.1); +} + +.settings-header h2 { + margin: 0; + font-size: 16px; + font-weight: 600; +} + +.settings-content { + flex: 1; + padding: 16px; + overflow-y: auto; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 4px; + font-weight: 500; + color: var(--color-text-primary); +} + +.form-group input[type="url"], +.form-group input[type="text"], +.form-group select { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--color-border-primary); + border-radius: 6px; + background: var(--color-surface); + color: var(--color-text-primary); + font-size: 14px; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1); +} + +.form-group small { + display: block; + margin-top: 4px; + color: var(--color-text-secondary); + font-size: 12px; +} + +.checkbox-label { + display: flex !important; + align-items: center; + gap: 8px; + cursor: pointer; + margin-bottom: 0 !important; +} + +.checkbox-label input[type="checkbox"] { + width: auto; + margin: 0; +} + +.theme-section { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--color-border-primary); +} + +.theme-section h3 { + margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.theme-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.theme-option { + display: flex !important; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--color-border-primary); + border-radius: 6px; + cursor: pointer; + background: var(--color-surface); + margin-bottom: 0 !important; +} + +.theme-option:hover { + background: var(--color-surface-hover); +} + +.theme-option input[type="radio"] { + width: auto; + margin: 0; +} + +.theme-option input[type="radio"]:checked + span { + color: var(--color-primary); + font-weight: 500; +} + +.settings-actions { + display: flex; + gap: 8px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--color-border-primary); +} + +.secondary-btn { + flex: 1; + padding: 8px 16px; + border: 1px solid var(--color-border-primary); + border-radius: 6px; + background: var(--color-surface); + color: var(--color-text-secondary); + cursor: pointer; + font-size: 12px; +} + +.secondary-btn:hover { + background: var(--color-surface-hover); + color: var(--color-text-primary); +} + +.primary-btn { + flex: 1; + padding: 8px 16px; + border: none; + border-radius: 6px; + background: var(--color-primary); + color: var(--color-text-inverse); + cursor: pointer; + font-size: 12px; + font-weight: 500; +} + +.primary-btn:hover { + background: var(--color-primary-dark); +} + +/* Settings section styles */ +.connection-section, +.content-section { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--color-border-primary); +} + +.connection-section h3, +.content-section h3 { + margin: 0 0 12px 0; + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.connection-subsection { + margin-bottom: 16px; + padding: 12px; + background: var(--color-surface); + border-radius: 6px; + border: 1px solid var(--color-border-primary); +} + +.connection-subsection h4 { + margin: 0 0 8px 0; + font-size: 13px; + font-weight: 600; + color: var(--color-text-primary); +} + +.connection-subsection .form-group { + margin-bottom: 10px; +} + +.connection-subsection .form-group:last-child { + margin-bottom: 0; +} + +.connection-test { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; +} + +.connection-result { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; +} + +.connection-result.hidden { + display: none; +} + +.connection-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.connection-status-dot.connected { + background-color: #22c55e; +} + +.connection-status-dot.disconnected { + background-color: #ef4444; +} + +.connection-status-dot.testing { + background-color: #f59e0b; + animation: pulse 1.5s infinite; +} \ No newline at end of file diff --git a/apps/web-clipper-manifestv3/src/popup/popup.ts b/apps/web-clipper-manifestv3/src/popup/popup.ts new file mode 100644 index 000000000..44a4636a0 --- /dev/null +++ b/apps/web-clipper-manifestv3/src/popup/popup.ts @@ -0,0 +1,744 @@ +import { Logger, MessageUtils } from '@/shared/utils'; +import { ThemeManager } from '@/shared/theme'; + +const logger = Logger.create('Popup', 'popup'); + +/** + * Popup script for the Trilium Web Clipper extension + * Handles the popup interface and user interactions + */ +class PopupController { + private elements: { [key: string]: HTMLElement } = {}; + private connectionCheckInterval?: number; + + constructor() { + this.initialize(); + } + + private async initialize(): Promise { + try { + logger.info('Initializing popup...'); + + this.cacheElements(); + this.setupEventHandlers(); + await this.initializeTheme(); + await this.loadCurrentPageInfo(); + await this.checkTriliumConnection(); + this.startPeriodicConnectionCheck(); + + logger.info('Popup initialized successfully'); + } catch (error) { + logger.error('Failed to initialize popup', error as Error); + this.showError('Failed to initialize popup'); + } + } + + private cacheElements(): void { + const elementIds = [ + 'save-selection', + 'save-page', + 'save-screenshot', + 'open-settings', + 'back-to-main', + 'view-logs', + 'help', + 'theme-toggle', + 'theme-text', + 'status-message', + 'status-text', + 'progress-bar', + 'page-title', + 'page-url', + 'connection-status', + 'connection-text', + 'settings-panel', + 'settings-form', + 'trilium-url', + 'enable-server', + 'desktop-port', + 'enable-desktop', + 'default-title', + 'auto-save', + 'enable-toasts', + 'screenshot-format', + 'test-connection', + 'persistent-connection-status', + 'connection-result', + 'connection-result-text' + ]; + + elementIds.forEach(id => { + const element = document.getElementById(id); + if (element) { + this.elements[id] = element; + } else { + logger.warn(`Element not found: ${id}`); + } + }); + } + + private setupEventHandlers(): void { + // Action buttons + this.elements['save-selection']?.addEventListener('click', this.handleSaveSelection.bind(this)); + this.elements['save-page']?.addEventListener('click', this.handleSavePage.bind(this)); + this.elements['save-screenshot']?.addEventListener('click', this.handleSaveScreenshot.bind(this)); + + // Footer buttons + this.elements['open-settings']?.addEventListener('click', this.handleOpenSettings.bind(this)); + this.elements['back-to-main']?.addEventListener('click', this.handleBackToMain.bind(this)); + this.elements['view-logs']?.addEventListener('click', this.handleViewLogs.bind(this)); + this.elements['theme-toggle']?.addEventListener('click', this.handleThemeToggle.bind(this)); + this.elements['help']?.addEventListener('click', this.handleHelp.bind(this)); + + // Settings form + this.elements['settings-form']?.addEventListener('submit', this.handleSaveSettings.bind(this)); + this.elements['test-connection']?.addEventListener('click', this.handleTestConnection.bind(this)); + + // Theme radio buttons + const themeRadios = document.querySelectorAll('input[name="theme"]'); + themeRadios.forEach(radio => { + radio.addEventListener('change', this.handleThemeRadioChange.bind(this)); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', this.handleKeyboardShortcuts.bind(this)); + } + + private handleKeyboardShortcuts(event: KeyboardEvent): void { + if (event.ctrlKey && event.shiftKey && event.key === 'S') { + event.preventDefault(); + this.handleSaveSelection(); + } else if (event.altKey && event.shiftKey && event.key === 'S') { + event.preventDefault(); + this.handleSavePage(); + } else if (event.ctrlKey && event.shiftKey && event.key === 'E') { + event.preventDefault(); + this.handleSaveScreenshot(); + } + } + + private async handleSaveSelection(): Promise { + logger.info('Save selection requested'); + + try { + this.showProgress('Saving selection...'); + + const response = await MessageUtils.sendMessage({ + type: 'SAVE_SELECTION' + }); + + this.showSuccess('Selection saved successfully!'); + logger.info('Selection saved', { response }); + } catch (error) { + this.showError('Failed to save selection'); + logger.error('Failed to save selection', error as Error); + } + } + + private async handleSavePage(): Promise { + logger.info('Save page requested'); + + try { + this.showProgress('Saving page...'); + + const response = await MessageUtils.sendMessage({ + type: 'SAVE_PAGE' + }); + + this.showSuccess('Page saved successfully!'); + logger.info('Page saved', { response }); + } catch (error) { + this.showError('Failed to save page'); + logger.error('Failed to save page', error as Error); + } + } + + private async handleSaveScreenshot(): Promise { + logger.info('Save screenshot requested'); + + try { + this.showProgress('Capturing screenshot...'); + + const response = await MessageUtils.sendMessage({ + type: 'SAVE_SCREENSHOT' + }); + + this.showSuccess('Screenshot saved successfully!'); + logger.info('Screenshot saved', { response }); + } catch (error) { + this.showError('Failed to save screenshot'); + logger.error('Failed to save screenshot', error as Error); + } + } + + private handleOpenSettings(): void { + try { + logger.info('Opening settings panel'); + this.showSettingsPanel(); + } catch (error) { + logger.error('Failed to open settings panel', error as Error); + } + } + + private handleBackToMain(): void { + try { + logger.info('Returning to main panel'); + this.hideSettingsPanel(); + } catch (error) { + logger.error('Failed to return to main panel', error as Error); + } + } + + private showSettingsPanel(): void { + const settingsPanel = this.elements['settings-panel']; + if (settingsPanel) { + settingsPanel.classList.remove('hidden'); + this.loadSettingsData(); + } + } + + private hideSettingsPanel(): void { + const settingsPanel = this.elements['settings-panel']; + if (settingsPanel) { + settingsPanel.classList.add('hidden'); + } + } + + private async loadSettingsData(): Promise { + try { + const settings = await chrome.storage.sync.get([ + 'triliumUrl', + 'enableServer', + 'desktopPort', + 'enableDesktop', + 'defaultTitle', + 'autoSave', + 'enableToasts', + 'screenshotFormat' + ]); + + // Populate connection form fields + const urlInput = this.elements['trilium-url'] as HTMLInputElement; + const enableServerCheck = this.elements['enable-server'] as HTMLInputElement; + const desktopPortInput = this.elements['desktop-port'] as HTMLInputElement; + const enableDesktopCheck = this.elements['enable-desktop'] as HTMLInputElement; + + // Populate content form fields + const titleInput = this.elements['default-title'] as HTMLInputElement; + const autoSaveCheck = this.elements['auto-save'] as HTMLInputElement; + const toastsCheck = this.elements['enable-toasts'] as HTMLInputElement; + const formatSelect = this.elements['screenshot-format'] as HTMLSelectElement; + + // Set connection values + if (urlInput) urlInput.value = settings.triliumUrl || ''; + if (enableServerCheck) enableServerCheck.checked = settings.enableServer !== false; + if (desktopPortInput) desktopPortInput.value = settings.desktopPort || '37840'; + if (enableDesktopCheck) enableDesktopCheck.checked = settings.enableDesktop !== false; + + // Set content values + if (titleInput) titleInput.value = settings.defaultTitle || 'Web Clip - {title}'; + if (autoSaveCheck) autoSaveCheck.checked = settings.autoSave || false; + if (toastsCheck) toastsCheck.checked = settings.enableToasts !== false; + if (formatSelect) formatSelect.value = settings.screenshotFormat || 'png'; + + // Load theme settings + const themeConfig = await ThemeManager.getThemeConfig(); + const themeMode = themeConfig.followSystem ? 'system' : themeConfig.mode; + const themeRadio = document.querySelector(`input[name="theme"][value="${themeMode}"]`) as HTMLInputElement; + if (themeRadio) themeRadio.checked = true; + + } catch (error) { + logger.error('Failed to load settings data', error as Error); + } + } + + private async handleSaveSettings(event: Event): Promise { + event.preventDefault(); + try { + logger.info('Saving settings'); + + // Connection settings + const urlInput = this.elements['trilium-url'] as HTMLInputElement; + const enableServerCheck = this.elements['enable-server'] as HTMLInputElement; + const desktopPortInput = this.elements['desktop-port'] as HTMLInputElement; + const enableDesktopCheck = this.elements['enable-desktop'] as HTMLInputElement; + + // Content settings + const titleInput = this.elements['default-title'] as HTMLInputElement; + const autoSaveCheck = this.elements['auto-save'] as HTMLInputElement; + const toastsCheck = this.elements['enable-toasts'] as HTMLInputElement; + const formatSelect = this.elements['screenshot-format'] as HTMLSelectElement; + + const settings = { + triliumUrl: urlInput?.value || '', + enableServer: enableServerCheck?.checked !== false, + desktopPort: desktopPortInput?.value || '37840', + enableDesktop: enableDesktopCheck?.checked !== false, + defaultTitle: titleInput?.value || 'Web Clip - {title}', + autoSave: autoSaveCheck?.checked || false, + enableToasts: toastsCheck?.checked !== false, + screenshotFormat: formatSelect?.value || 'png' + }; + + await chrome.storage.sync.set(settings); + this.showSuccess('Settings saved successfully!'); + + // Auto-hide settings panel after saving + setTimeout(() => { + this.hideSettingsPanel(); + }, 1500); + + } catch (error) { + logger.error('Failed to save settings', error as Error); + this.showError('Failed to save settings'); + } + } + + private async handleTestConnection(): Promise { + try { + logger.info('Testing connection'); + + // Get connection settings from form + const urlInput = this.elements['trilium-url'] as HTMLInputElement; + const enableServerCheck = this.elements['enable-server'] as HTMLInputElement; + const desktopPortInput = this.elements['desktop-port'] as HTMLInputElement; + const enableDesktopCheck = this.elements['enable-desktop'] as HTMLInputElement; + + const serverUrl = urlInput?.value?.trim(); + const enableServer = enableServerCheck?.checked; + const desktopPort = desktopPortInput?.value?.trim() || '37840'; + const enableDesktop = enableDesktopCheck?.checked; + + if (!enableServer && !enableDesktop) { + this.showConnectionResult('Please enable at least one connection type', 'disconnected'); + return; + } + + this.showConnectionResult('Testing connections...', 'testing'); + this.updatePersistentStatus('testing', 'Testing connections...'); + + // Use the background service to test connections + const response = await MessageUtils.sendMessage({ + type: 'TEST_CONNECTION', + serverUrl: enableServer ? serverUrl : undefined, + authToken: enableServer ? (await this.getStoredAuthToken(serverUrl)) : undefined, + desktopPort: enableDesktop ? desktopPort : undefined + }) as { success: boolean; results: any; error?: string }; + + if (!response.success) { + this.showConnectionResult(`Connection test failed: ${response.error}`, 'disconnected'); + this.updatePersistentStatus('disconnected', 'Connection test failed'); + return; + } + + const connectionResults = this.processConnectionResults(response.results, enableServer, enableDesktop); + + if (connectionResults.hasConnection) { + this.showConnectionResult(connectionResults.message, 'connected'); + this.updatePersistentStatus('connected', connectionResults.statusTooltip); + + // Trigger a new connection search to update the background service + await MessageUtils.sendMessage({ type: 'TRIGGER_CONNECTION_SEARCH' }); + } else { + this.showConnectionResult(connectionResults.message, 'disconnected'); + this.updatePersistentStatus('disconnected', connectionResults.statusTooltip); + } + + } catch (error) { + logger.error('Connection test failed', error as Error); + const errorText = 'Connection test failed - check settings'; + this.showConnectionResult(errorText, 'disconnected'); + this.updatePersistentStatus('disconnected', 'Connection test failed'); + } + } + + private async getStoredAuthToken(serverUrl?: string): Promise { + try { + if (!serverUrl) return undefined; + + const data = await chrome.storage.sync.get('authToken'); + return data.authToken; + } catch (error) { + logger.error('Failed to get stored auth token', error as Error); + return undefined; + } + } + + private processConnectionResults(results: any, enableServer: boolean, enableDesktop: boolean) { + const connectedSources: string[] = []; + const failedSources: string[] = []; + const statusMessages: string[] = []; + + if (enableServer && results.server) { + if (results.server.connected) { + connectedSources.push(`Server (${results.server.version || 'Unknown'})`); + statusMessages.push(`Server: Connected`); + } else { + failedSources.push('Server'); + } + } + + if (enableDesktop && results.desktop) { + if (results.desktop.connected) { + connectedSources.push(`Desktop Client (${results.desktop.version || 'Unknown'})`); + statusMessages.push(`Desktop: Connected`); + } else { + failedSources.push('Desktop Client'); + } + } + + const hasConnection = connectedSources.length > 0; + let message = ''; + let statusTooltip = ''; + + if (hasConnection) { + message = `Connected to: ${connectedSources.join(', ')}`; + statusTooltip = statusMessages.join(' | '); + } else { + message = `Failed to connect to: ${failedSources.join(', ')}`; + statusTooltip = 'No connections available'; + } + + return { hasConnection, message, statusTooltip }; + } + + private showConnectionResult(message: string, status: 'connected' | 'disconnected' | 'testing'): void { + const resultElement = this.elements['connection-result']; + const textElement = this.elements['connection-result-text']; + const dotElement = resultElement?.querySelector('.connection-status-dot'); + + if (resultElement && textElement && dotElement) { + resultElement.classList.remove('hidden'); + textElement.textContent = message; + + // Update dot status + dotElement.classList.remove('connected', 'disconnected', 'testing'); + dotElement.classList.add(status); + } + } + + private updatePersistentStatus(status: 'connected' | 'disconnected' | 'testing', tooltip: string): void { + const persistentStatus = this.elements['persistent-connection-status']; + const dotElement = persistentStatus?.querySelector('.persistent-status-dot'); + + if (persistentStatus && dotElement) { + // Update dot status + dotElement.classList.remove('connected', 'disconnected', 'testing'); + dotElement.classList.add(status); + + // Update tooltip + persistentStatus.setAttribute('title', tooltip); + } + } + + private startPeriodicConnectionCheck(): void { + // Check connection every 30 seconds + this.connectionCheckInterval = window.setInterval(async () => { + try { + await this.checkTriliumConnection(); + } catch (error) { + logger.error('Periodic connection check failed', error as Error); + } + }, 30000); + + // Clean up interval when popup closes + window.addEventListener('beforeunload', () => { + if (this.connectionCheckInterval) { + clearInterval(this.connectionCheckInterval); + } + }); + } + + private async handleThemeRadioChange(event: Event): Promise { + try { + const target = event.target as HTMLInputElement; + const mode = target.value as 'light' | 'dark' | 'system'; + + logger.info('Theme changed via radio button', { mode }); + + if (mode === 'system') { + await ThemeManager.setThemeConfig({ mode: 'system', followSystem: true }); + } else { + await ThemeManager.setThemeConfig({ mode, followSystem: false }); + } + + await this.updateThemeButton(); + + } catch (error) { + logger.error('Failed to change theme via radio', error as Error); + } + } + + private handleViewLogs(): void { + logger.info('Opening log viewer'); + chrome.tabs.create({ url: chrome.runtime.getURL('logs.html') }); + window.close(); + } + + private handleHelp(): void { + logger.info('Opening help'); + const helpUrl = 'https://github.com/zadam/trilium/wiki/Web-clipper'; + chrome.tabs.create({ url: helpUrl }); + window.close(); + } + + private async initializeTheme(): Promise { + try { + await ThemeManager.initialize(); + await this.updateThemeButton(); + } catch (error) { + logger.error('Failed to initialize theme', error as Error); + } + } + + private async handleThemeToggle(): Promise { + try { + logger.info('Theme toggle requested'); + await ThemeManager.toggleTheme(); + await this.updateThemeButton(); + } catch (error) { + logger.error('Failed to toggle theme', error as Error); + } + } + + private async updateThemeButton(): Promise { + try { + const config = await ThemeManager.getThemeConfig(); + const themeText = this.elements['theme-text']; + const themeIcon = this.elements['theme-toggle']?.querySelector('.btn-icon'); + + if (themeText) { + // Show current theme mode + if (config.followSystem || config.mode === 'system') { + themeText.textContent = 'System'; + } else if (config.mode === 'light') { + themeText.textContent = 'Light'; + } else { + themeText.textContent = 'Dark'; + } + } + + if (themeIcon) { + // Show icon for current theme + if (config.followSystem || config.mode === 'system') { + themeIcon.textContent = '↻'; + } else if (config.mode === 'light') { + themeIcon.textContent = '☀'; + } else { + themeIcon.textContent = '☽'; + } + } + } catch (error) { + logger.error('Failed to update theme button', error as Error); + } + } + + private async loadCurrentPageInfo(): Promise { + try { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + const activeTab = tabs[0]; + + if (activeTab) { + this.updatePageInfo(activeTab.title || 'Untitled', activeTab.url || ''); + } + } catch (error) { + logger.error('Failed to load current page info', error as Error); + this.updatePageInfo('Error loading page info', ''); + } + } + + private async updatePageInfo(title: string, url: string): Promise { + if (this.elements['page-title']) { + this.elements['page-title'].textContent = title; + this.elements['page-title'].title = title; + } + + if (this.elements['page-url']) { + this.elements['page-url'].textContent = this.shortenUrl(url); + this.elements['page-url'].title = url; + } + + // Check for existing note and show indicator + await this.checkForExistingNote(url); + } + + private async checkForExistingNote(url: string): Promise { + try { + logger.info('Starting check for existing note', { url }); + + // Only check if we have a valid URL + if (!url || url.startsWith('chrome://') || url.startsWith('about:')) { + logger.debug('Skipping check - invalid URL', { url }); + this.hideAlreadyClippedIndicator(); + return; + } + + logger.debug('Sending CHECK_EXISTING_NOTE message to background', { url }); + + // Send message to background to check for existing note + const response = await MessageUtils.sendMessage({ + type: 'CHECK_EXISTING_NOTE', + url + }) as { exists: boolean; noteId?: string }; + + logger.info('Received response from background', { response }); + + if (response && response.exists && response.noteId) { + logger.info('Note exists - showing indicator', { noteId: response.noteId }); + this.showAlreadyClippedIndicator(response.noteId); + } else { + logger.debug('Note does not exist - hiding indicator', { response }); + this.hideAlreadyClippedIndicator(); + } + } catch (error) { + logger.error('Failed to check for existing note', error as Error); + this.hideAlreadyClippedIndicator(); + } + } + + private showAlreadyClippedIndicator(noteId: string): void { + logger.info('Showing already-clipped indicator', { noteId }); + + const indicator = document.getElementById('already-clipped'); + const openLink = document.getElementById('open-note-link') as HTMLAnchorElement; + + logger.debug('Indicator element found', { + indicatorExists: !!indicator, + linkExists: !!openLink + }); + + if (indicator) { + indicator.classList.remove('hidden'); + logger.debug('Removed hidden class from indicator'); + } else { + logger.error('Could not find already-clipped element in DOM!'); + } + + if (openLink) { + openLink.onclick = (e: MouseEvent) => { + e.preventDefault(); + this.handleOpenNoteInTrilium(noteId); + }; + } + } + + private hideAlreadyClippedIndicator(): void { + const indicator = document.getElementById('already-clipped'); + if (indicator) { + indicator.classList.add('hidden'); + } + } + + private async handleOpenNoteInTrilium(noteId: string): Promise { + try { + logger.info('Opening note in Trilium', { noteId }); + + await MessageUtils.sendMessage({ + type: 'OPEN_NOTE', + noteId + }); + + // Close popup after opening note + window.close(); + } catch (error) { + logger.error('Failed to open note in Trilium', error as Error); + this.showError('Failed to open note in Trilium'); + } + } + + private shortenUrl(url: string): string { + if (url.length <= 50) return url; + + try { + const urlObj = new URL(url); + return `${urlObj.hostname}${urlObj.pathname.substring(0, 20)}...`; + } catch { + return url.substring(0, 50) + '...'; + } + } + + private async checkTriliumConnection(): Promise { + try { + // Get saved connection settings + // We don't need to check individual settings anymore since the background service handles this + + // Get current connection status from background service + const statusResponse = await MessageUtils.sendMessage({ + type: 'GET_CONNECTION_STATUS' + }) as any; + + const status = statusResponse?.status || 'not-found'; + + if (status === 'found-desktop' || status === 'found-server') { + const connectionType = status === 'found-desktop' ? 'Desktop Client' : 'Server'; + const url = statusResponse?.url || 'Unknown'; + this.updateConnectionStatus('connected', `Connected to ${connectionType}`); + this.updatePersistentStatus('connected', `${connectionType}: ${url}`); + } else if (status === 'searching') { + this.updateConnectionStatus('checking', 'Checking connections...'); + this.updatePersistentStatus('testing', 'Searching for Trilium...'); + } else { + this.updateConnectionStatus('disconnected', 'No active connections'); + this.updatePersistentStatus('disconnected', 'No connections available'); + } + + } catch (error) { + logger.error('Failed to check Trilium connection', error as Error); + this.updateConnectionStatus('disconnected', 'Connection check failed'); + this.updatePersistentStatus('disconnected', 'Connection check failed'); + } + } + + private updateConnectionStatus(status: 'connected' | 'disconnected' | 'checking' | 'testing', message: string): void { + const statusElement = this.elements['connection-status']; + const textElement = this.elements['connection-text']; + + if (statusElement && textElement) { + statusElement.setAttribute('data-status', status); + textElement.textContent = message; + } + } + + private showProgress(message: string): void { + this.showStatus(message, 'info'); + this.elements['progress-bar']?.classList.remove('hidden'); + } + + private showSuccess(message: string): void { + this.showStatus(message, 'success'); + this.elements['progress-bar']?.classList.add('hidden'); + + // Auto-hide after 3 seconds + setTimeout(() => { + this.hideStatus(); + }, 3000); + } + + private showError(message: string): void { + this.showStatus(message, 'error'); + this.elements['progress-bar']?.classList.add('hidden'); + } + + private showStatus(message: string, type: 'info' | 'success' | 'error'): void { + const statusElement = this.elements['status-message']; + const textElement = this.elements['status-text']; + + if (statusElement && textElement) { + statusElement.className = `status-message status-message--${type}`; + textElement.textContent = message; + statusElement.classList.remove('hidden'); + } + } + + private hideStatus(): void { + this.elements['status-message']?.classList.add('hidden'); + this.elements['progress-bar']?.classList.add('hidden'); + } +} + +// Initialize the popup when DOM is loaded +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => new PopupController()); +} else { + new PopupController(); +}