feat: implement popup interface

Features:
- Quick action buttons (Selection, Page, Link, Screenshot, Image)
- Connection status indicator with real-time updates
- Theme toggle (system/light/dark) with visual feedback
- Navigation to Settings and Logs pages
- Keyboard shortcuts display
- Full theme system integration

Entry point for most user interactions.
Initializes theme on load and persists preference.
Uses centralized logging for debugging.
This commit is contained in:
Octech2722 2025-10-18 12:16:35 -05:00
parent b51f83555b
commit 90c58142ce
3 changed files with 1645 additions and 0 deletions

View File

@ -0,0 +1,212 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Trilium Web Clipper</title>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<div class="popup-container">
<header class="popup-header">
<h1 class="popup-title">
<img src="../icons/icon-32.png" alt="Trilium" class="popup-icon">
Trilium Web Clipper
</h1>
<div id="persistent-connection-status" class="persistent-connection-status" title="Connection status">
<span class="persistent-status-dot disconnected"></span>
</div>
</header>
<main class="popup-main">
<div class="action-buttons">
<button id="save-selection" class="action-btn" title="Ctrl+Shift+S">
<span class="btn-icon"></span>
<span class="btn-text">Save Selection</span>
</button>
<button id="save-page" class="action-btn" title="Alt+Shift+S">
<span class="btn-icon"></span>
<span class="btn-text">Save Full Page</span>
</button>
<button id="save-screenshot" class="action-btn" title="Ctrl+Shift+E">
<span class="btn-icon"></span>
<span class="btn-text">Save Screenshot</span>
</button>
</div>
<div class="status-section">
<div id="status-message" class="status-message hidden">
<span id="status-text"></span>
</div>
<div id="progress-bar" class="progress-bar hidden">
<div class="progress-fill"></div>
</div>
</div>
<div class="info-section">
<div class="current-page">
<h3>Current Page</h3>
<p id="page-title" class="page-title">Loading...</p>
<p id="page-url" class="page-url">Loading...</p>
<!-- Already clipped indicator -->
<div id="already-clipped" class="already-clipped hidden">
<div class="clipped-label">
<span class="clipped-icon"></span>
<span class="clipped-text">Already saved today</span>
</div>
<a id="open-note-link" class="open-note-link" href="#" title="Open this note in Trilium">
Open in Trilium →
</a>
</div>
</div>
<div class="trilium-status">
<h3>Trilium Connection</h3>
<div id="connection-status" class="connection-status">
<span class="status-indicator"></span>
<span id="connection-text">Checking...</span>
</div>
</div>
</div>
</main>
<!-- Settings Panel (hidden by default) -->
<div id="settings-panel" class="settings-panel hidden">
<div class="settings-header">
<button id="back-to-main" class="back-btn">
<span class="btn-icon"></span>
Back
</button>
<h2>Settings</h2>
</div>
<div class="settings-content">
<form id="settings-form">
<div class="connection-section">
<h3>Connection Settings</h3>
<div class="connection-subsection">
<h4>Trilium Server</h4>
<div class="form-group">
<label for="trilium-url">Server URL:</label>
<input type="url" id="trilium-url" placeholder="http://localhost:8080">
<small>Enter the URL of your Trilium server (optional)</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="enable-server" checked>
<span>Enable server connection</span>
</label>
</div>
</div>
<div class="connection-subsection">
<h4>Trilium Desktop Client</h4>
<div class="form-group">
<label for="desktop-port">Desktop Client Port:</label>
<input type="number" id="desktop-port" placeholder="37840" min="1" max="65535">
<small>Port number for local Trilium desktop client (optional)</small>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="enable-desktop" checked>
<span>Enable desktop client connection</span>
</label>
</div>
</div>
<div class="connection-test">
<button type="button" id="test-connection" class="secondary-btn">Test Connections</button>
<div id="connection-result" class="connection-result hidden">
<span class="connection-status-dot"></span>
<span id="connection-result-text">Not tested</span>
</div>
</div>
</div>
<div class="content-section">
<h3>Content Settings</h3>
<div class="form-group">
<label for="default-title">Note Title Template:</label>
<input type="text" id="default-title" placeholder="Web Clip - {title}" required>
<small>Use {title}, {url}, {date}</small>
</div>
<div class="form-group">
<label for="screenshot-format">Screenshot Format:</label>
<select id="screenshot-format">
<option value="png">PNG (Higher Quality)</option>
<option value="jpeg">JPEG (Smaller Size)</option>
</select>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="auto-save">
<span>Enable auto-save for selections</span>
</label>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="enable-toasts" checked>
<span>Show toast notifications</span>
</label>
</div>
</div>
<div class="theme-section">
<h3>Theme Settings</h3>
<div class="theme-options">
<label class="theme-option">
<input type="radio" name="theme" value="light">
<span>Light</span>
</label>
<label class="theme-option">
<input type="radio" name="theme" value="dark">
<span>Dark</span>
</label>
<label class="theme-option">
<input type="radio" name="theme" value="system">
<span>System</span>
</label>
</div>
</div>
<div class="settings-actions">
<button type="submit" class="primary-btn">Save Settings</button>
</div>
</form>
</div>
</div>
<footer class="popup-footer">
<button id="open-settings" class="footer-btn">
<span class="btn-icon"></span>
Settings
</button>
<button id="view-logs" class="footer-btn">
<span class="btn-icon"></span>
Logs
</button>
<button id="theme-toggle" class="footer-btn theme-toggle" title="Toggle theme">
<span class="btn-icon"></span>
<span id="theme-text">Dark</span>
</button>
<button id="help" class="footer-btn">
<span class="btn-icon">?</span>
Help
</button>
</footer>
</div>
<script type="module" src="popup.ts"></script>
</body>
</html>

View File

@ -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;
}

View File

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string | undefined> {
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<void> {
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<void> {
try {
await ThemeManager.initialize();
await this.updateThemeButton();
} catch (error) {
logger.error('Failed to initialize theme', error as Error);
}
}
private async handleThemeToggle(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
}