diff --git a/apps/web-clipper-manifestv3/src/options/index.html b/apps/web-clipper-manifestv3/src/options/index.html new file mode 100644 index 000000000..122fbb25e --- /dev/null +++ b/apps/web-clipper-manifestv3/src/options/index.html @@ -0,0 +1,117 @@ + + + + + + Trilium Web Clipper Options + + + +
+

⚙ Trilium Web Clipper Options

+ +
+
+ + + Enter the URL of your Trilium server (e.g., http://localhost:8080) +
+ +
+ + + Use {title} for page title, {url} for page URL, {date} for current date +
+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + + +
+
+ +
+

◐ Theme Settings

+
+ + + +
+

Choose your preferred theme. System follows your operating system's theme setting.

+
+ +
+

📄 Content Format

+

Choose how to save clipped content:

+
+ + + +
+

+ Tip: The "Both" option creates an HTML note for reading and a markdown child note perfect for AI tools. +

+
+ +
+

⟲ Connection Test

+

Test your connection to the Trilium server:

+
+ + Not tested +
+
+ + +
+ + + + \ No newline at end of file diff --git a/apps/web-clipper-manifestv3/src/options/options.css b/apps/web-clipper-manifestv3/src/options/options.css new file mode 100644 index 000000000..d1d1b385a --- /dev/null +++ b/apps/web-clipper-manifestv3/src/options/options.css @@ -0,0 +1,357 @@ +/* Import shared theme system */ +@import url('../shared/theme.css'); + +/* Options page specific styles */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + max-width: 600px; + margin: 0 auto; + padding: 20px; + background: var(--color-background); + color: var(--color-text-primary); + transition: var(--theme-transition); +} + +.container { + background: var(--color-surface); + padding: 30px; + border-radius: 8px; + box-shadow: var(--shadow-lg); + border: 1px solid var(--color-border-primary); +} + +h1 { + color: var(--color-text-primary); + margin-bottom: 30px; + font-size: 24px; + font-weight: 600; +} + +.form-group { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: var(--color-text-primary); +} + +input[type="text"], +input[type="url"], +textarea, +select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border-primary); + border-radius: 6px; + font-size: 14px; + background: var(--color-surface); + color: var(--color-text-primary); + transition: var(--theme-transition); + box-sizing: border-box; +} + +input[type="text"]:focus, +input[type="url"]:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); +} + +textarea { + resize: vertical; + min-height: 80px; +} + +button { + background: var(--color-primary); + color: white; + border: none; + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: var(--theme-transition); +} + +button:hover { + background: var(--color-primary-hover); +} + +button:active { + transform: translateY(1px); +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.secondary-btn { + background: var(--color-surface); + color: var(--color-text-primary); + border: 1px solid var(--color-border-primary); +} + +.secondary-btn:hover { + background: var(--color-surface-hover); +} + +/* Status messages */ +.status-message { + padding: 12px; + border-radius: 6px; + margin-bottom: 20px; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; +} + +.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); +} + +.status-message.info { + background: var(--color-info-bg); + color: var(--color-info-text); + border: 1px solid var(--color-info-border); +} + +/* Test connection section */ +.test-section { + background: var(--color-surface-secondary); + padding: 20px; + border-radius: 6px; + margin-top: 30px; + border: 1px solid var(--color-border-primary); +} + +.test-section h3 { + margin-top: 0; + color: var(--color-text-primary); +} + +/* Theme section */ +.theme-section { + background: var(--color-surface-secondary); + padding: 20px; + border-radius: 6px; + margin-top: 20px; + border: 1px solid var(--color-border-primary); +} + +.theme-section h3 { + margin-top: 0; + color: var(--color-text-primary); + margin-bottom: 15px; +} + +.theme-options { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.theme-option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--color-border-primary); + border-radius: 6px; + background: var(--color-surface); + cursor: pointer; + transition: var(--theme-transition); +} + +.theme-option:hover { + background: var(--color-surface-hover); +} + +.theme-option.active { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); +} + +.theme-option input[type="radio"] { + margin: 0; + width: auto; +} + +/* Action buttons */ +.actions { + display: flex; + gap: 10px; + justify-content: flex-end; + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid var(--color-border-primary); +} + +/* Responsive design */ +@media (max-width: 640px) { + body { + padding: 10px; + } + + .container { + padding: 20px; + } + + .actions { + flex-direction: column; + } + + .theme-options { + flex-direction: column; + } +} + +/* Loading state */ +.loading { + opacity: 0.6; + pointer-events: none; +} + +/* Helper text */ +.help-text { + font-size: 12px; + color: var(--color-text-secondary); + margin-top: 4px; + line-height: 1.4; +} + +/* Connection status indicator */ +.connection-indicator { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + padding: 4px 8px; + border-radius: 4px; + font-weight: 500; +} + +.connection-indicator.connected { + background: var(--color-success-bg); + color: var(--color-success-text); +} + +.connection-indicator.disconnected { + background: var(--color-error-bg); + color: var(--color-error-text); +} + +.connection-indicator.checking { + background: var(--color-info-bg); + color: var(--color-info-text); +} + +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +/* Content Format Section */ +.content-format-section { + background: var(--color-surface-secondary); + padding: 20px; + border-radius: 6px; + margin-top: 20px; + border: 1px solid var(--color-border-primary); +} + +.content-format-section h3 { + margin-top: 0; + color: var(--color-text-primary); + margin-bottom: 10px; +} + +.content-format-section > p { + color: var(--color-text-secondary); + margin-bottom: 15px; + font-size: 14px; +} + +.format-options { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 15px; +} + +.format-option { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px; + border: 2px solid var(--color-border-primary); + border-radius: 8px; + background: var(--color-surface); + cursor: pointer; + transition: all 0.2s ease; +} + +.format-option:hover { + background: var(--color-surface-hover); + border-color: var(--color-primary-light); +} + +.format-option input[type="radio"] { + margin-top: 2px; + width: auto; + cursor: pointer; +} + +.format-option input[type="radio"]:checked + .format-details { + color: var(--color-primary); +} + +.format-option:has(input[type="radio"]:checked) { + background: var(--color-primary-light); + border-color: var(--color-primary); + box-shadow: 0 0 0 3px var(--color-primary-light); +} + +.format-details { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} + +.format-details strong { + color: var(--color-text-primary); + font-size: 15px; + font-weight: 600; +} + +.format-description { + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1.4; +} + +.content-format-section .help-text { + background: var(--color-info-bg); + border-left: 3px solid var(--color-info-border); + padding: 10px 12px; + border-radius: 4px; + margin-top: 0; +} diff --git a/apps/web-clipper-manifestv3/src/options/options.ts b/apps/web-clipper-manifestv3/src/options/options.ts new file mode 100644 index 000000000..5ec5d3e77 --- /dev/null +++ b/apps/web-clipper-manifestv3/src/options/options.ts @@ -0,0 +1,289 @@ +import { Logger } from '@/shared/utils'; +import { ExtensionConfig } from '@/shared/types'; +import { ThemeManager, ThemeMode } from '@/shared/theme'; + +const logger = Logger.create('Options', 'options'); + +/** + * Options page controller for the Trilium Web Clipper extension + * Handles configuration management and settings UI + */ +class OptionsController { + private form: HTMLFormElement; + private statusElement: HTMLElement; + + constructor() { + this.form = document.getElementById('options-form') as HTMLFormElement; + this.statusElement = document.getElementById('status') as HTMLElement; + + this.initialize(); + } + + private async initialize(): Promise { + try { + logger.info('Initializing options page...'); + + await this.initializeTheme(); + await this.loadCurrentSettings(); + this.setupEventHandlers(); + + logger.info('Options page initialized successfully'); + } catch (error) { + logger.error('Failed to initialize options page', error as Error); + this.showStatus('Failed to initialize options page', 'error'); + } + } + + private setupEventHandlers(): void { + this.form.addEventListener('submit', this.handleSave.bind(this)); + + const testButton = document.getElementById('test-connection'); + testButton?.addEventListener('click', this.handleTestConnection.bind(this)); + + const viewLogsButton = document.getElementById('view-logs'); + viewLogsButton?.addEventListener('click', this.handleViewLogs.bind(this)); + + // Theme radio buttons + const themeRadios = document.querySelectorAll('input[name="theme"]'); + themeRadios.forEach(radio => { + radio.addEventListener('change', this.handleThemeChange.bind(this)); + }); + } + + private async loadCurrentSettings(): Promise { + try { + const config = await chrome.storage.sync.get(); + + // Populate form fields with current settings + const triliumUrl = document.getElementById('trilium-url') as HTMLInputElement; + const defaultTitle = document.getElementById('default-title') as HTMLInputElement; + const autoSave = document.getElementById('auto-save') as HTMLInputElement; + const enableToasts = document.getElementById('enable-toasts') as HTMLInputElement; + const screenshotFormat = document.getElementById('screenshot-format') as HTMLSelectElement; + + if (triliumUrl) triliumUrl.value = config.triliumServerUrl || ''; + if (defaultTitle) defaultTitle.value = config.defaultNoteTitle || 'Web Clip - {title}'; + if (autoSave) autoSave.checked = config.autoSave || false; + if (enableToasts) enableToasts.checked = config.enableToasts !== false; // default true + if (screenshotFormat) screenshotFormat.value = config.screenshotFormat || 'png'; + + // Load content format preference (default to 'html') + const contentFormat = config.contentFormat || 'html'; + const formatRadio = document.querySelector(`input[name="contentFormat"][value="${contentFormat}"]`) as HTMLInputElement; + if (formatRadio) { + formatRadio.checked = true; + } + + logger.debug('Settings loaded', { config }); + } catch (error) { + logger.error('Failed to load settings', error as Error); + this.showStatus('Failed to load current settings', 'error'); + } + } + + private async handleSave(event: Event): Promise { + event.preventDefault(); + + try { + logger.info('Saving settings...'); + + // Get content format selection + const contentFormatRadio = document.querySelector('input[name="contentFormat"]:checked') as HTMLInputElement; + const contentFormat = contentFormatRadio?.value || 'html'; + + const config: Partial = { + triliumServerUrl: (document.getElementById('trilium-url') as HTMLInputElement).value.trim(), + defaultNoteTitle: (document.getElementById('default-title') as HTMLInputElement).value.trim(), + autoSave: (document.getElementById('auto-save') as HTMLInputElement).checked, + enableToasts: (document.getElementById('enable-toasts') as HTMLInputElement).checked, + screenshotFormat: (document.getElementById('screenshot-format') as HTMLSelectElement).value as 'png' | 'jpeg', + screenshotQuality: 0.9 + }; + + // Validate settings + if (config.triliumServerUrl && !this.isValidUrl(config.triliumServerUrl)) { + throw new Error('Please enter a valid Trilium server URL'); + } + + if (!config.defaultNoteTitle) { + throw new Error('Please enter a default note title template'); + } + + // Save to storage (including content format) + await chrome.storage.sync.set({ ...config, contentFormat }); + + this.showStatus('Settings saved successfully!', 'success'); + logger.info('Settings saved successfully', { config, contentFormat }); + + } catch (error) { + logger.error('Failed to save settings', error as Error); + this.showStatus(`Failed to save settings: ${(error as Error).message}`, 'error'); + } + } + + private async handleTestConnection(): Promise { + try { + logger.info('Testing Trilium connection...'); + this.showStatus('Testing connection...', 'info'); + this.updateConnectionStatus('checking', 'Testing connection...'); + + const triliumUrl = (document.getElementById('trilium-url') as HTMLInputElement).value.trim(); + + if (!triliumUrl) { + throw new Error('Please enter a Trilium server URL first'); + } + + if (!this.isValidUrl(triliumUrl)) { + throw new Error('Please enter a valid URL (e.g., http://localhost:8080)'); + } + + // Test connection to Trilium + const testUrl = `${triliumUrl.replace(/\/$/, '')}/api/app-info`; + const response = await fetch(testUrl, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`Connection failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + if (data.appName && data.appName.toLowerCase().includes('trilium')) { + this.updateConnectionStatus('connected', `Connected to ${data.appName}`); + this.showStatus(`Successfully connected to ${data.appName} (${data.appVersion || 'unknown version'})`, 'success'); + logger.info('Connection test successful', { data }); + } else { + this.updateConnectionStatus('connected', 'Connected (Unknown service)'); + this.showStatus('Connected, but server may not be Trilium', 'warning'); + logger.warn('Connected but unexpected response', { data }); + } + + } catch (error) { + logger.error('Connection test failed', error as Error); + + this.updateConnectionStatus('disconnected', 'Connection failed'); + + if (error instanceof TypeError && error.message.includes('fetch')) { + this.showStatus('Connection failed: Cannot reach server. Check URL and ensure Trilium is running.', 'error'); + } else { + this.showStatus(`Connection failed: ${(error as Error).message}`, 'error'); + } + } + } + + private isValidUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.protocol === 'http:' || urlObj.protocol === 'https:'; + } catch { + return false; + } + } + + private showStatus(message: string, type: 'success' | 'error' | 'info' | 'warning'): void { + this.statusElement.textContent = message; + this.statusElement.className = `status-message ${type}`; + this.statusElement.style.display = 'block'; + + // Auto-hide success messages after 5 seconds + if (type === 'success') { + setTimeout(() => { + this.statusElement.style.display = 'none'; + }, 5000); + } + } + + private updateConnectionStatus(status: 'connected' | 'disconnected' | 'checking', text: string): void { + const connectionStatus = document.getElementById('connection-status'); + const connectionText = document.getElementById('connection-text'); + + if (connectionStatus && connectionText) { + connectionStatus.className = `connection-indicator ${status}`; + connectionText.textContent = text; + } + } + + private handleViewLogs(): void { + // Open the log viewer in a new tab + chrome.tabs.create({ + url: chrome.runtime.getURL('logs.html') + }); + } + + private async initializeTheme(): Promise { + try { + await ThemeManager.initialize(); + await this.loadThemeSettings(); + } catch (error) { + logger.error('Failed to initialize theme', error as Error); + } + } + + private async loadThemeSettings(): Promise { + try { + const config = await ThemeManager.getThemeConfig(); + const themeRadios = document.querySelectorAll('input[name="theme"]') as NodeListOf; + + themeRadios.forEach(radio => { + if (config.followSystem || config.mode === 'system') { + radio.checked = radio.value === 'system'; + } else { + radio.checked = radio.value === config.mode; + } + + // Update active class + const themeOption = radio.closest('.theme-option'); + if (themeOption) { + themeOption.classList.toggle('active', radio.checked); + } + }); + } catch (error) { + logger.error('Failed to load theme settings', error as Error); + } + } + + private async handleThemeChange(event: Event): Promise { + try { + const radio = event.target as HTMLInputElement; + const selectedTheme = radio.value as ThemeMode; + + logger.info('Theme change requested', { theme: selectedTheme }); + + // Update theme configuration + if (selectedTheme === 'system') { + await ThemeManager.setThemeConfig({ + mode: 'system', + followSystem: true + }); + } else { + await ThemeManager.setThemeConfig({ + mode: selectedTheme, + followSystem: false + }); + } + + // Update active classes + const themeOptions = document.querySelectorAll('.theme-option'); + themeOptions.forEach(option => { + const input = option.querySelector('input[type="radio"]') as HTMLInputElement; + option.classList.toggle('active', input.checked); + }); + + this.showStatus('Theme updated successfully!', 'success'); + } catch (error) { + logger.error('Failed to change theme', error as Error); + this.showStatus('Failed to update theme', 'error'); + } + } +} + +// Initialize the options controller when DOM is loaded +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => new OptionsController()); +} else { + new OptionsController(); +}