From 6b484bb2fc3d60d7558d82765ab5b27ebe8b1ca3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 12 Feb 2026 19:05:25 +0100 Subject: [PATCH] added read-alouds and the ability to save API keys with encryption --- public/healthz.json | 4 +- src/lib/components/EventMenu.svelte | 34 +- .../content/MarkdownRenderer.svelte | 6 + src/lib/components/content/TTSControls.svelte | 364 +++++++++++++++++ src/lib/components/modals/TTSModal.svelte | 131 ++++++ src/lib/services/security/api-key-storage.ts | 245 +++++++++++ src/lib/services/tts/text-extractor.ts | 104 +++++ src/lib/services/tts/tts-service.ts | 339 ++++++++++++++++ src/lib/services/tts/types.ts | 115 ++++++ src/routes/settings/+page.svelte | 383 ++++++++++++++++++ static/icons/volume.svg | 1 + 11 files changed, 1710 insertions(+), 16 deletions(-) create mode 100644 src/lib/components/content/TTSControls.svelte create mode 100644 src/lib/components/modals/TTSModal.svelte create mode 100644 src/lib/services/security/api-key-storage.ts create mode 100644 src/lib/services/tts/text-extractor.ts create mode 100644 src/lib/services/tts/tts-service.ts create mode 100644 src/lib/services/tts/types.ts create mode 100644 static/icons/volume.svg diff --git a/public/healthz.json b/public/healthz.json index c7033d0..33e50a0 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.3.1", - "buildTime": "2026-02-12T17:17:48.963Z", + "buildTime": "2026-02-12T17:56:54.795Z", "gitCommit": "unknown", - "timestamp": 1770916668963 + "timestamp": 1770919014796 } \ No newline at end of file diff --git a/src/lib/components/EventMenu.svelte b/src/lib/components/EventMenu.svelte index a9929fd..ae1a68e 100644 --- a/src/lib/components/EventMenu.svelte +++ b/src/lib/components/EventMenu.svelte @@ -20,6 +20,7 @@ import { sessionManager } from '../services/auth/session-manager.js'; import { signAndPublish } from '../services/nostr/auth-handler.js'; import RelatedEventsModal from './modals/RelatedEventsModal.svelte'; + import TTSModal from './modals/TTSModal.svelte'; import { KIND, isReplaceableKind, isParameterizedReplaceableKind } from '../types/kind-lookup.js'; import { goto } from '$app/navigation'; import Icon from './ui/Icon.svelte'; @@ -44,10 +45,15 @@ let reportModalOpen = $state(false); let deleteEventModalOpen = $state(false); let versionHistoryModalOpen = $state(false); + let ttsModalOpen = $state(false); let copied = $state(null); // Check if this is a replaceable event let isReplaceable = $derived(isReplaceableKind(event.kind) || isParameterizedReplaceableKind(event.kind)); + // Check if this is a kind 30041 (e-book section) + let isKind30041 = $derived(event.kind === KIND.PUBLICATION_CONTENT); + // Check if event has readable content for TTS + let hasReadableContent = $derived(event.content && event.content.trim().length > 0); let menuButtonElement: HTMLButtonElement | null = $state(null); let menuDropdownElement: HTMLDivElement | null = $state(null); let menuPosition = $state({ top: 0, right: 0 }); @@ -388,6 +394,11 @@ versionHistoryModalOpen = true; closeMenu(); } + + function openTTSModal() { + ttsModalOpen = true; + closeMenu(); + }
@@ -412,13 +423,19 @@ View this note + {#if hasReadableContent} + + {/if} {#if isReplaceable} {/if} @@ -508,7 +525,7 @@ {#if isLoggedIn && !isOwnEvent} {/if} @@ -531,6 +548,7 @@ reportModalOpen = false} /> deleteEventModalOpen = false} /> versionHistoryModalOpen = false} /> + diff --git a/src/lib/components/modals/TTSModal.svelte b/src/lib/components/modals/TTSModal.svelte new file mode 100644 index 0000000..d545eca --- /dev/null +++ b/src/lib/components/modals/TTSModal.svelte @@ -0,0 +1,131 @@ + + +{#if open && (event || contentElement)} + +{/if} + + diff --git a/src/lib/services/security/api-key-storage.ts b/src/lib/services/security/api-key-storage.ts new file mode 100644 index 0000000..e61809e --- /dev/null +++ b/src/lib/services/security/api-key-storage.ts @@ -0,0 +1,245 @@ +/** + * Secure encrypted storage for API keys (TTS, GitHub, etc.) + * Uses password-based encryption similar to NIP-49 but for arbitrary strings + * Stores encrypted keys in IndexedDB preferences store + */ + +import { getDB } from '../cache/indexeddb-store.js'; +import { savePreference, loadPreference } from '../preferences.js'; + +/** + * API key types + */ +export type ApiKeyType = + | 'tts.openai' + | 'tts.elevenlabs' + | 'github.token' + | string; // Allow custom types + +/** + * Derive encryption key from password using PBKDF2 + */ +async function deriveKey(password: string, salt: Uint8Array): Promise { + const encoder = new TextEncoder(); + const passwordKey = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveBits', 'deriveKey'] + ); + + // Create a new ArrayBuffer-backed Uint8Array to ensure proper type + const saltArray = new Uint8Array(salt.length); + saltArray.set(salt); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: saltArray, + iterations: 100000, + hash: 'SHA-256' + }, + passwordKey, + { + name: 'AES-GCM', + length: 256 + }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypt an API key string + */ +async function encryptApiKey(apiKey: string, password: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(apiKey); + + // Generate random salt and IV + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + + // Derive key from password + const key = await deriveKey(password, salt); + + // Encrypt + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv + }, + key, + data + ); + + // Combine salt, IV, and encrypted data + const combined = new Uint8Array(salt.length + iv.length + encrypted.byteLength); + combined.set(salt, 0); + combined.set(iv, salt.length); + combined.set(new Uint8Array(encrypted), salt.length + iv.length); + + // Convert to base64 for storage + return btoa(String.fromCharCode(...combined)); +} + +/** + * Decrypt an API key string + */ +async function decryptApiKey(encryptedData: string, password: string): Promise { + try { + // Decode from base64 + const combined = Uint8Array.from(atob(encryptedData), c => c.charCodeAt(0)); + + // Extract salt, IV, and encrypted data + const salt = combined.slice(0, 16); + const iv = combined.slice(16, 28); + const encrypted = combined.slice(28); + + // Derive key from password + const key = await deriveKey(password, salt); + + // Decrypt + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: iv + }, + key, + encrypted + ); + + // Convert back to string + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } catch (error) { + throw new Error('Failed to decrypt API key: Invalid password or corrupted data'); + } +} + +/** + * Get password from user (prompts if not in session) + */ +async function getPassword(): Promise { + // Check if password is available in session + // For now, we'll prompt - in the future this could check session manager + return new Promise((resolve, reject) => { + const password = prompt('Enter your password to access API keys:'); + if (!password) { + reject(new Error('Password required')); + return; + } + resolve(password); + }); +} + +/** + * Save an encrypted API key + * @param type - API key type (e.g., 'tts.openai', 'github.token') + * @param apiKey - The API key to encrypt and store + * @param password - User password for encryption + */ +export async function saveEncryptedApiKey( + type: ApiKeyType, + apiKey: string, + password: string +): Promise { + if (!apiKey || !password) { + throw new Error('API key and password are required'); + } + + try { + const encrypted = await encryptApiKey(apiKey, password); + const preferenceKey = `api_key.${type}`; + await savePreference(preferenceKey, encrypted); + } catch (error) { + console.error('Failed to save encrypted API key:', error); + throw error; + } +} + +/** + * Load and decrypt an API key + * @param type - API key type (e.g., 'tts.openai', 'github.token') + * @param password - User password for decryption (will prompt if not provided) + * @returns Decrypted API key or null if not found + */ +export async function loadEncryptedApiKey( + type: ApiKeyType, + password?: string +): Promise { + try { + const preferenceKey = `api_key.${type}`; + const encrypted = await loadPreference(preferenceKey, null as string | null); + + if (!encrypted) { + return null; + } + + // Get password if not provided + const pwd = password || await getPassword(); + + try { + return await decryptApiKey(encrypted, pwd); + } catch (error) { + console.error('Failed to decrypt API key:', error); + throw error; + } + } catch (error) { + console.error('Failed to load encrypted API key:', error); + return null; + } +} + +/** + * Delete an API key + * @param type - API key type to delete + */ +export async function deleteApiKey(type: ApiKeyType): Promise { + try { + const preferenceKey = `api_key.${type}`; + const db = await getDB(); + await db.delete('preferences', preferenceKey); + } catch (error) { + console.error('Failed to delete API key:', error); + throw error; + } +} + +/** + * Check if an API key exists (without decrypting) + * @param type - API key type to check + */ +export async function hasApiKey(type: ApiKeyType): Promise { + try { + const preferenceKey = `api_key.${type}`; + const encrypted = await loadPreference(preferenceKey, null as string | null); + return encrypted !== null; + } catch (error) { + return false; + } +} + +/** + * List all stored API key types + */ +export async function listApiKeyTypes(): Promise { + try { + const db = await getDB(); + const allPreferences = await db.getAll('preferences'); + const apiKeyTypes: ApiKeyType[] = []; + + for (const pref of allPreferences) { + if (pref.key.startsWith('api_key.')) { + const type = pref.key.replace('api_key.', '') as ApiKeyType; + apiKeyTypes.push(type); + } + } + + return apiKeyTypes; + } catch (error) { + console.error('Failed to list API key types:', error); + return []; + } +} diff --git a/src/lib/services/tts/text-extractor.ts b/src/lib/services/tts/text-extractor.ts new file mode 100644 index 0000000..19edf65 --- /dev/null +++ b/src/lib/services/tts/text-extractor.ts @@ -0,0 +1,104 @@ +/** + * Text extraction utility for TTS + * Extracts plain text from HTML and filters out nostr: addresses + */ + +import { findNIP21Links } from '../nostr/nip21-parser.js'; + +/** + * Extract plain text from HTML element or string + */ +export function extractTextFromHTML(html: string | HTMLElement): string { + let text: string; + + if (typeof html === 'string') { + // Create temporary element to parse HTML + const temp = document.createElement('div'); + temp.innerHTML = html; + text = temp.textContent || temp.innerText || ''; + } else { + text = html.textContent || html.innerText || ''; + } + + return text; +} + +/** + * Remove nostr: addresses from text + * Preserves sentence structure by replacing with empty string or space + */ +export function filterNostrAddresses(text: string): string { + // Find all nostr: links + const links = findNIP21Links(text); + + if (links.length === 0) { + return text; + } + + // Replace from end to start to preserve indices + let filtered = text; + for (let i = links.length - 1; i >= 0; i--) { + const link = links[i]; + const before = filtered.substring(0, link.start); + const after = filtered.substring(link.end); + + // Check if there's whitespace before and after - if so, remove the whole thing + // Otherwise, just remove the nostr: part and keep the identifier + const charBefore = before[before.length - 1] || ' '; + const charAfter = after[0] || ' '; + const isWhitespaceBefore = /\s/.test(charBefore); + const isWhitespaceAfter = /\s/.test(charAfter); + + if (isWhitespaceBefore && isWhitespaceAfter) { + // Remove entire address including surrounding whitespace (but keep one space) + filtered = before.trimEnd() + ' ' + after.trimStart(); + } else if (isWhitespaceBefore) { + // Remove address but keep space before + filtered = before + after; + } else if (isWhitespaceAfter) { + // Remove address but keep space after + filtered = before + after; + } else { + // No whitespace - just remove the address (might break words, but better than reading it) + filtered = before + after; + } + } + + // Clean up multiple spaces + filtered = filtered.replace(/\s+/g, ' '); + + return filtered.trim(); +} + +/** + * Extract and filter text for TTS + * Combines extraction and filtering in one step + */ +export function extractTextForTTS(html: string | HTMLElement): string { + const text = extractTextFromHTML(html); + return filterNostrAddresses(text); +} + +/** + * Split text into sentences for better TTS processing + */ +export function splitIntoSentences(text: string): string[] { + // Simple sentence splitting - can be improved + const sentences = text + .split(/([.!?]+\s+)/) + .filter(s => s.trim().length > 0) + .map(s => s.trim()); + + return sentences; +} + +/** + * Normalize text for TTS (remove extra whitespace, normalize punctuation) + */ +export function normalizeTextForTTS(text: string): string { + return text + .replace(/\s+/g, ' ') // Multiple spaces to single space + .replace(/\n+/g, ' ') // Newlines to space + .replace(/\t+/g, ' ') // Tabs to space + .trim(); +} diff --git a/src/lib/services/tts/tts-service.ts b/src/lib/services/tts/tts-service.ts new file mode 100644 index 0000000..f3282b9 --- /dev/null +++ b/src/lib/services/tts/tts-service.ts @@ -0,0 +1,339 @@ +/** + * TTS Service + * Manages text-to-speech with multiple provider support + */ + +import type { TTSProvider, TTSProviderInterface, TTSOptions, TTSState, TTSVoice, TTSEventCallbacks } from './types.js'; + +/** + * Web Speech API TTS Provider + */ +class WebSpeechProvider implements TTSProviderInterface { + readonly name = 'Web Speech API'; + readonly type: TTSProvider = 'webspeech'; + + private synth: SpeechSynthesis; + private utterance: SpeechSynthesisUtterance | null = null; + private state: TTSState = 'idle'; + private callbacks: TTSEventCallbacks = {}; + private currentText = ''; + private currentOptions: TTSOptions = {}; + private voices: TTSVoice[] = []; + private voiceLoaded = false; + + constructor() { + if (typeof window === 'undefined' || !('speechSynthesis' in window)) { + throw new Error('Web Speech API not available'); + } + this.synth = window.speechSynthesis; + this.loadVoices(); + + // Reload voices when they become available (some browsers load them asynchronously) + this.synth.onvoiceschanged = () => { + this.loadVoices(); + }; + } + + private loadVoices(): void { + const browserVoices = this.synth.getVoices(); + this.voices = browserVoices.map(voice => ({ + id: voice.voiceURI, + name: voice.name, + lang: voice.lang, + gender: voice.name.toLowerCase().includes('female') ? 'female' : + voice.name.toLowerCase().includes('male') ? 'male' : 'neutral', + provider: 'webspeech' + })); + this.voiceLoaded = true; + } + + async isAvailable(): Promise { + return typeof window !== 'undefined' && 'speechSynthesis' in window; + } + + async getVoices(): Promise { + if (!this.voiceLoaded) { + await new Promise(resolve => setTimeout(resolve, 100)); + this.loadVoices(); + } + return this.voices; + } + + async speak(text: string, options?: TTSOptions): Promise { + // Stop any current speech + this.stop(); + + if (!text.trim()) { + return; + } + + this.currentText = text; + this.currentOptions = options || {}; + + // Create utterance + this.utterance = new SpeechSynthesisUtterance(text); + + // Set voice + if (options?.voice && options.voice.provider === 'webspeech') { + const browserVoice = this.synth.getVoices().find(v => v.voiceURI === options.voice!.id); + if (browserVoice) { + this.utterance.voice = browserVoice; + } + } + + // Set options + this.utterance.rate = options?.speed ?? 1.0; + this.utterance.pitch = options?.pitch ?? 1.0; + this.utterance.volume = options?.volume ?? 1.0; + + // Set up event handlers + this.utterance.onstart = () => { + this.state = 'playing'; + this.callbacks.onStateChange?.(this.state); + }; + + this.utterance.onend = () => { + this.state = 'idle'; + this.utterance = null; + this.callbacks.onStateChange?.(this.state); + this.callbacks.onEnd?.(); + }; + + this.utterance.onerror = (event) => { + this.state = 'error'; + const error = new Error(`Speech synthesis error: ${event.error}`); + this.callbacks.onError?.(error); + this.callbacks.onStateChange?.(this.state); + }; + + // Speak + this.synth.speak(this.utterance); + this.state = 'playing'; + this.callbacks.onStateChange?.(this.state); + } + + async pause(): Promise { + if (this.state === 'playing' && this.synth.speaking) { + this.synth.pause(); + this.state = 'paused'; + this.callbacks.onStateChange?.(this.state); + } + } + + async resume(): Promise { + if (this.state === 'paused' && this.synth.paused) { + this.synth.resume(); + this.state = 'playing'; + this.callbacks.onStateChange?.(this.state); + } + } + + async stop(): Promise { + if (this.synth.speaking || this.synth.paused) { + this.synth.cancel(); + } + this.utterance = null; + this.state = 'idle'; + this.callbacks.onStateChange?.(this.state); + } + + getState(): TTSState { + return this.state; + } + + async getProgress(): Promise { + // Web Speech API doesn't provide progress, so we estimate based on time + // This is a simplified implementation + return 0; + } + + async setProgress(position: number): Promise { + // Web Speech API doesn't support seeking + // Would need to stop and restart at new position + if (this.currentText && position >= 0 && position <= 1) { + const charIndex = Math.floor(this.currentText.length * position); + const newText = this.currentText.substring(charIndex); + await this.stop(); + await this.speak(newText, this.currentOptions); + } + } + + destroy(): void { + this.stop(); + this.callbacks = {}; + } + + setCallbacks(callbacks: TTSEventCallbacks): void { + this.callbacks = { ...this.callbacks, ...callbacks }; + } +} + +/** + * TTS Service + * Manages TTS providers and provides unified interface + */ +export class TTSService { + private provider: TTSProviderInterface | null = null; + private providerType: TTSProvider = 'webspeech'; + private callbacks: TTSEventCallbacks = {}; + + /** + * Initialize TTS service with a provider + */ + async initialize(providerType: TTSProvider = 'webspeech'): Promise { + // Cleanup existing provider + if (this.provider) { + this.provider.destroy(); + } + + this.providerType = providerType; + + // Create provider + if (providerType === 'webspeech') { + try { + this.provider = new WebSpeechProvider(); + const available = await this.provider.isAvailable(); + if (!available) { + throw new Error('Web Speech API not available'); + } + if (this.provider.setCallbacks) { + this.provider.setCallbacks(this.callbacks); + } + } catch (error) { + console.error('Failed to initialize Web Speech API:', error); + throw error; + } + } else if (providerType === 'openai') { + // TODO: Implement OpenAI provider + throw new Error('OpenAI TTS provider not yet implemented'); + } else if (providerType === 'elevenlabs') { + // TODO: Implement ElevenLabs provider + throw new Error('ElevenLabs TTS provider not yet implemented'); + } else { + throw new Error(`Unknown TTS provider: ${providerType}`); + } + } + + /** + * Check if current provider is available + */ + async isAvailable(): Promise { + if (!this.provider) { + await this.initialize(); + } + return this.provider ? await this.provider.isAvailable() : false; + } + + /** + * Get available voices + */ + async getVoices(): Promise { + if (!this.provider) { + await this.initialize(); + } + return this.provider ? await this.provider.getVoices() : []; + } + + /** + * Speak text + */ + async speak(text: string, options?: TTSOptions): Promise { + if (!this.provider) { + await this.initialize(); + } + if (this.provider) { + await this.provider.speak(text, options); + } + } + + /** + * Pause playback + */ + async pause(): Promise { + if (this.provider) { + await this.provider.pause(); + } + } + + /** + * Resume playback + */ + async resume(): Promise { + if (this.provider) { + await this.provider.resume(); + } + } + + /** + * Stop playback + */ + async stop(): Promise { + if (this.provider) { + await this.provider.stop(); + } + } + + /** + * Get current state + */ + getState(): TTSState { + return this.provider ? this.provider.getState() : 'idle'; + } + + /** + * Get current progress + */ + async getProgress(): Promise { + return this.provider ? await this.provider.getProgress() : 0; + } + + /** + * Set playback position + */ + async setProgress(position: number): Promise { + if (this.provider) { + await this.provider.setProgress(position); + } + } + + /** + * Set event callbacks + */ + setCallbacks(callbacks: TTSEventCallbacks): void { + this.callbacks = { ...this.callbacks, ...callbacks }; + if (this.provider && this.provider.setCallbacks) { + this.provider.setCallbacks(this.callbacks); + } + } + + /** + * Get current provider type + */ + getProviderType(): TTSProvider { + return this.providerType; + } + + /** + * Cleanup + */ + destroy(): void { + if (this.provider) { + this.provider.destroy(); + this.provider = null; + } + this.callbacks = {}; + } +} + +// Singleton instance +let ttsServiceInstance: TTSService | null = null; + +/** + * Get TTS service instance + */ +export function getTTSService(): TTSService { + if (!ttsServiceInstance) { + ttsServiceInstance = new TTSService(); + } + return ttsServiceInstance; +} diff --git a/src/lib/services/tts/types.ts b/src/lib/services/tts/types.ts new file mode 100644 index 0000000..ef75a6e --- /dev/null +++ b/src/lib/services/tts/types.ts @@ -0,0 +1,115 @@ +/** + * TTS (Text-to-Speech) types and interfaces + */ + +/** + * TTS provider type + */ +export type TTSProvider = 'webspeech' | 'openai' | 'elevenlabs'; + +/** + * TTS voice configuration + */ +export interface TTSVoice { + id: string; + name: string; + lang: string; + gender?: 'male' | 'female' | 'neutral'; + provider: TTSProvider; +} + +/** + * TTS playback state + */ +export type TTSState = 'idle' | 'playing' | 'paused' | 'error'; + +/** + * TTS options + */ +export interface TTSOptions { + voice?: TTSVoice; + speed?: number; // 0.5 to 2.0 + pitch?: number; // 0.5 to 2.0 + volume?: number; // 0.0 to 1.0 + provider?: TTSProvider; +} + +/** + * TTS provider interface + */ +export interface TTSProviderInterface { + /** + * Provider name + */ + readonly name: string; + + /** + * Provider type + */ + readonly type: TTSProvider; + + /** + * Check if provider is available + */ + isAvailable(): Promise; + + /** + * Get available voices + */ + getVoices(): Promise; + + /** + * Speak text + */ + speak(text: string, options?: TTSOptions): Promise; + + /** + * Pause playback + */ + pause(): Promise; + + /** + * Resume playback + */ + resume(): Promise; + + /** + * Stop playback + */ + stop(): Promise; + + /** + * Get current state + */ + getState(): TTSState; + + /** + * Get current playback position (0-1) + */ + getProgress(): Promise; + + /** + * Set playback position (0-1) + */ + setProgress(position: number): Promise; + + /** + * Cleanup resources + */ + destroy(): void; + + /** + * Set event callbacks (optional - not all providers may support this) + */ + setCallbacks?(callbacks: TTSEventCallbacks): void; +} + +/** + * TTS event callbacks + */ +export interface TTSEventCallbacks { + onStateChange?: (state: TTSState) => void; + onProgress?: (progress: number) => void; + onError?: (error: Error) => void; + onEnd?: () => void; +} diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index fcb69c2..b3d606e 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -6,6 +6,7 @@ import { goto } from '$app/navigation'; import Icon from '../../lib/components/ui/Icon.svelte'; import { KEYBOARD_SHORTCUTS } from '../../lib/services/keyboard-shortcuts.js'; + import { saveEncryptedApiKey, loadEncryptedApiKey, deleteApiKey, hasApiKey, type ApiKeyType } from '../../lib/services/security/api-key-storage.js'; type TextSize = 'small' | 'medium' | 'large'; type LineSpacing = 'tight' | 'normal' | 'loose'; @@ -20,6 +21,16 @@ let expiringEvents = $state(false); let includeClientTag = $state(true); + // API Key management + let apiKeyTypes: Array<{ type: ApiKeyType; label: string; description: string }> = [ + { type: 'tts.openai', label: 'OpenAI TTS', description: 'API key for OpenAI text-to-speech' }, + { type: 'tts.elevenlabs', label: 'ElevenLabs TTS', description: 'API key for ElevenLabs text-to-speech' }, + { type: 'github.token', label: 'GitHub Token', description: 'Personal access token for GitHub integration' } + ]; + let apiKeyStates = $state>({}); + let passwordPrompt = $state<{ open: boolean; type: ApiKeyType | null; action: 'save' | 'load' }>({ open: false, type: null, action: 'save' }); + let passwordInput = $state(''); + // PWA install state let deferredPrompt = $state(null); let showInstallButton = $state(false); @@ -66,6 +77,9 @@ // Load client tag preference includeClientTag = shouldIncludeClientTag(); + + // Load API key states (async, don't await) + loadApiKeyStates().catch(err => console.error('Failed to load API key states:', err)); // Check if PWA is already installed if (typeof window !== 'undefined') { @@ -199,6 +213,102 @@ } } + async function loadApiKeyStates() { + for (const { type } of apiKeyTypes) { + const hasKey = await hasApiKey(type); + apiKeyStates[type] = { + hasKey, + editing: false, + value: '', + error: null + }; + } + } + + async function handleApiKeyEdit(type: ApiKeyType) { + if (!apiKeyStates[type]) { + apiKeyStates[type] = { hasKey: false, editing: false, value: '', error: null }; + } + apiKeyStates[type].editing = true; + apiKeyStates[type].error = null; + + // If key exists, prompt for password to view + if (apiKeyStates[type].hasKey) { + passwordPrompt = { open: true, type, action: 'load' }; + passwordInput = ''; + } else { + apiKeyStates[type].value = ''; + } + } + + async function handleApiKeySave(type: ApiKeyType) { + if (!apiKeyStates[type] || !apiKeyStates[type].value.trim()) { + apiKeyStates[type].error = 'API key is required'; + return; + } + + passwordPrompt = { open: true, type, action: 'save' }; + passwordInput = ''; + } + + async function handlePasswordSubmit() { + if (!passwordPrompt.type || !passwordInput) { + return; + } + + const type = passwordPrompt.type; + const password = passwordInput; + passwordInput = ''; + + try { + if (passwordPrompt.action === 'save') { + if (!apiKeyStates[type] || !apiKeyStates[type].value.trim()) { + throw new Error('API key is required'); + } + await saveEncryptedApiKey(type, apiKeyStates[type].value.trim(), password); + apiKeyStates[type].hasKey = true; + apiKeyStates[type].editing = false; + apiKeyStates[type].value = ''; + apiKeyStates[type].error = null; + } else { + const key = await loadEncryptedApiKey(type, password); + if (key) { + apiKeyStates[type].value = key; + } else { + throw new Error('Failed to load API key'); + } + } + passwordPrompt = { open: false, type: null, action: 'save' }; + } catch (error) { + apiKeyStates[type].error = error instanceof Error ? error.message : 'Failed to process API key'; + passwordPrompt = { open: false, type: null, action: 'save' }; + } + } + + async function handleApiKeyDelete(type: ApiKeyType) { + if (!confirm('Are you sure you want to delete this API key?')) { + return; + } + + try { + await deleteApiKey(type); + apiKeyStates[type].hasKey = false; + apiKeyStates[type].editing = false; + apiKeyStates[type].value = ''; + apiKeyStates[type].error = null; + } catch (error) { + apiKeyStates[type].error = error instanceof Error ? error.message : 'Failed to delete API key'; + } + } + + function handleApiKeyCancel(type: ApiKeyType) { + if (apiKeyStates[type]) { + apiKeyStates[type].editing = false; + apiKeyStates[type].value = ''; + apiKeyStates[type].error = null; + } + } + async function handlePWAInstall() { // If we have a deferred prompt (Chrome/Edge), use it if (deferredPrompt) { @@ -509,6 +619,80 @@

+ +
+
+ API Keys +
+

+ Manage API keys for external services. Keys are encrypted and stored securely. +

+
+ {#each apiKeyTypes as { type, label, description }} + {@const state = apiKeyStates[type] || { hasKey: false, editing: false, value: '', error: null }} +
+
+
+
{label}
+
{description}
+
+
+ {#if state.hasKey} + Configured + {/if} +
+
+ {#if state.editing} +
+ + {#if state.error} +
{state.error}
+ {/if} +
+ + +
+
+ {:else} +
+ + {#if state.hasKey} + + {/if} +
+ {/if} +
+ {/each} +
+
+
@@ -769,4 +953,203 @@ :global(.dark) .shortcut-description { color: var(--fog-dark-text, #cbd5e1); } + + .api-keys-list { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .api-key-item { + padding: 1rem; + background: var(--fog-surface, #f8fafc); + border: 1px solid var(--fog-border, #e5e7eb); + border-radius: 0.5rem; + } + + :global(.dark) .api-key-item { + background: var(--fog-dark-surface, #1e293b); + border-color: var(--fog-dark-border, #475569); + } + + .api-key-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; + } + + .api-key-status-badge { + padding: 0.25rem 0.5rem; + background: var(--fog-accent, #64748b); + color: white; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + } + + :global(.dark) .api-key-status-badge { + background: var(--fog-dark-accent, #94a3b8); + } + + .api-key-edit { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .api-key-input { + padding: 0.5rem; + border: 1px solid var(--fog-border, #e5e7eb); + border-radius: 0.25rem; + background: var(--fog-post, #ffffff); + color: var(--fog-text, #1e293b); + font-family: monospace; + font-size: 0.875rem; + width: 100%; + } + + :global(.dark) .api-key-input { + background: var(--fog-dark-post, #334155); + border-color: var(--fog-dark-border, #475569); + color: var(--fog-dark-text, #cbd5e1); + } + + .api-key-error { + color: var(--fog-error, #ef4444); + font-size: 0.875rem; + } + + :global(.dark) .api-key-error { + color: var(--fog-dark-error, #f87171); + } + + .api-key-actions { + display: flex; + gap: 0.5rem; + } + + /* Modal styles for password prompt */ + .modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.4); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + } + + .modal-content { + background: var(--fog-post, #ffffff); + border: 1px solid var(--fog-border, #e5e7eb); + border-radius: 8px; + max-width: 500px; + width: 90%; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + } + + :global(.dark) .modal-content { + background: var(--fog-dark-post, #334155); + border-color: var(--fog-dark-border, #475569); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); + } + + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + border-bottom: 1px solid var(--fog-border, #e5e7eb); + } + + :global(.dark) .modal-header { + border-bottom-color: var(--fog-dark-border, #475569); + } + + .modal-header h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--fog-text, #1f2937); + } + + :global(.dark) .modal-header h2 { + color: var(--fog-dark-text, #cbd5e1); + } + + .close-button { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--fog-text-light, #52667a); + padding: 0; + width: 2rem; + height: 2rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + transition: background-color 0.2s; + } + + .close-button:hover { + background: var(--fog-highlight, #f3f4f6); + } + + :global(.dark) .close-button { + color: var(--fog-dark-text-light, #a8b8d0); + } + + :global(.dark) .close-button:hover { + background: var(--fog-dark-highlight, #374151); + } + + .modal-body { + padding: 1rem; + } + + +{#if passwordPrompt.open} + +{/if} diff --git a/static/icons/volume.svg b/static/icons/volume.svg new file mode 100644 index 0000000..b9c3402 --- /dev/null +++ b/static/icons/volume.svg @@ -0,0 +1 @@ +