Browse Source

added read-alouds and the ability to save API keys with encryption

master
Silberengel 1 month ago
parent
commit
6b484bb2fc
  1. 4
      public/healthz.json
  2. 34
      src/lib/components/EventMenu.svelte
  3. 6
      src/lib/components/content/MarkdownRenderer.svelte
  4. 364
      src/lib/components/content/TTSControls.svelte
  5. 131
      src/lib/components/modals/TTSModal.svelte
  6. 245
      src/lib/services/security/api-key-storage.ts
  7. 104
      src/lib/services/tts/text-extractor.ts
  8. 339
      src/lib/services/tts/tts-service.ts
  9. 115
      src/lib/services/tts/types.ts
  10. 383
      src/routes/settings/+page.svelte
  11. 1
      static/icons/volume.svg

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.3.1", "version": "0.3.1",
"buildTime": "2026-02-12T17:17:48.963Z", "buildTime": "2026-02-12T17:56:54.795Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770916668963 "timestamp": 1770919014796
} }

34
src/lib/components/EventMenu.svelte

@ -20,6 +20,7 @@
import { sessionManager } from '../services/auth/session-manager.js'; import { sessionManager } from '../services/auth/session-manager.js';
import { signAndPublish } from '../services/nostr/auth-handler.js'; import { signAndPublish } from '../services/nostr/auth-handler.js';
import RelatedEventsModal from './modals/RelatedEventsModal.svelte'; import RelatedEventsModal from './modals/RelatedEventsModal.svelte';
import TTSModal from './modals/TTSModal.svelte';
import { KIND, isReplaceableKind, isParameterizedReplaceableKind } from '../types/kind-lookup.js'; import { KIND, isReplaceableKind, isParameterizedReplaceableKind } from '../types/kind-lookup.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Icon from './ui/Icon.svelte'; import Icon from './ui/Icon.svelte';
@ -44,10 +45,15 @@
let reportModalOpen = $state(false); let reportModalOpen = $state(false);
let deleteEventModalOpen = $state(false); let deleteEventModalOpen = $state(false);
let versionHistoryModalOpen = $state(false); let versionHistoryModalOpen = $state(false);
let ttsModalOpen = $state(false);
let copied = $state<string | null>(null); let copied = $state<string | null>(null);
// Check if this is a replaceable event // Check if this is a replaceable event
let isReplaceable = $derived(isReplaceableKind(event.kind) || isParameterizedReplaceableKind(event.kind)); 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 menuButtonElement: HTMLButtonElement | null = $state(null);
let menuDropdownElement: HTMLDivElement | null = $state(null); let menuDropdownElement: HTMLDivElement | null = $state(null);
let menuPosition = $state({ top: 0, right: 0 }); let menuPosition = $state({ top: 0, right: 0 });
@ -388,6 +394,11 @@
versionHistoryModalOpen = true; versionHistoryModalOpen = true;
closeMenu(); closeMenu();
} }
function openTTSModal() {
ttsModalOpen = true;
closeMenu();
}
</script> </script>
<div class="event-menu-container"> <div class="event-menu-container">
@ -412,13 +423,19 @@
<Icon name="eye" size={16} /> <Icon name="eye" size={16} />
<span>View this note</span> <span>View this note</span>
</button> </button>
{#if hasReadableContent}
<button class="menu-item" onclick={openTTSModal}>
<Icon name="volume" size={16} />
<span>Read aloud</span>
</button>
{/if}
<button class="menu-item" onclick={viewJson}> <button class="menu-item" onclick={viewJson}>
<Icon name="code" size={16} /> <Icon name="code" size={16} />
<span>View JSON</span> <span>View JSON</span>
</button> </button>
{#if isReplaceable} {#if isReplaceable}
<button class="menu-item" onclick={openVersionHistory}> <button class="menu-item" onclick={openVersionHistory}>
<span class="menu-item-icon"><Icon name="database" size={16} /></span> <Icon name="database" size={16} />
<span>See version history</span> <span>See version history</span>
</button> </button>
{/if} {/if}
@ -508,7 +525,7 @@
{#if isLoggedIn && !isOwnEvent} {#if isLoggedIn && !isOwnEvent}
<div class="menu-divider"></div> <div class="menu-divider"></div>
<button class="menu-item menu-item-danger" onclick={openReportModal}> <button class="menu-item menu-item-danger" onclick={openReportModal}>
<span class="menu-item-icon"><Icon name="x" size={16} /></span> <Icon name="x" size={16} />
<span>Report this event</span> <span>Report this event</span>
</button> </button>
{/if} {/if}
@ -531,6 +548,7 @@
<ReportEventModal bind:open={reportModalOpen} event={event} onClose={() => reportModalOpen = false} /> <ReportEventModal bind:open={reportModalOpen} event={event} onClose={() => reportModalOpen = false} />
<DeleteEventModal bind:open={deleteEventModalOpen} event={event} onClose={() => deleteEventModalOpen = false} /> <DeleteEventModal bind:open={deleteEventModalOpen} event={event} onClose={() => deleteEventModalOpen = false} />
<VersionHistoryModal bind:open={versionHistoryModalOpen} event={event} onClose={() => versionHistoryModalOpen = false} /> <VersionHistoryModal bind:open={versionHistoryModalOpen} event={event} onClose={() => versionHistoryModalOpen = false} />
<TTSModal bind:open={ttsModalOpen} event={event} />
<style> <style>
.event-menu-container { .event-menu-container {
@ -641,18 +659,6 @@
.menu-item :global(.icon) { .menu-item :global(.icon) {
flex-shrink: 0; flex-shrink: 0;
} }
.menu-item-icon {
flex-shrink: 0;
font-size: 1rem;
line-height: 1;
display: inline-flex;
align-items: center;
}
.menu-item-icon :global(.icon-wrapper) {
display: inline-block;
}
.menu-item span { .menu-item span {
flex: 1; flex: 1;

6
src/lib/components/content/MarkdownRenderer.svelte

@ -18,6 +18,8 @@
import EmbeddedEvent from './EmbeddedEvent.svelte'; import EmbeddedEvent from './EmbeddedEvent.svelte';
import EmbeddedEventBlurb from './EmbeddedEventBlurb.svelte'; import EmbeddedEventBlurb from './EmbeddedEventBlurb.svelte';
import TTSControls from './TTSControls.svelte';
import { KIND } from '../../types/kind-lookup.js';
let mountingEmbeddedEvents = $state(false); // Guard for mounting let mountingEmbeddedEvents = $state(false); // Guard for mounting
let mountingEmbeddedBlurbs = $state(false); // Guard for mounting blurbs let mountingEmbeddedBlurbs = $state(false); // Guard for mounting blurbs
@ -663,6 +665,7 @@
// Check if event should use Asciidoctor (kinds 30818 and 30041) // Check if event should use Asciidoctor (kinds 30818 and 30041)
const useAsciidoctor = $derived(event?.kind === 30818 || event?.kind === 30041); const useAsciidoctor = $derived(event?.kind === 30818 || event?.kind === 30041);
const isKind30040 = $derived(event?.kind === 30040); const isKind30040 = $derived(event?.kind === 30040);
const isKind30023 = $derived(event?.kind === KIND.LONG_FORM_NOTE);
// Load highlights for event // Load highlights for event
// Highlights are loaded for all content events (kind 30041, kind 1, etc.) // Highlights are loaded for all content events (kind 30041, kind 1, etc.)
@ -1420,6 +1423,9 @@
> >
{@html renderedHtml} {@html renderedHtml}
</div> </div>
{#if isKind30023 && containerRef}
<TTSControls text={containerRef} />
{/if}
</HighlightOverlay> </HighlightOverlay>
<style> <style>

364
src/lib/components/content/TTSControls.svelte

@ -0,0 +1,364 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getTTSService } from '../../services/tts/tts-service.js';
import { extractTextForTTS } from '../../services/tts/text-extractor.js';
import type { TTSVoice, TTSState } from '../../services/tts/types.js';
import Icon from '../ui/Icon.svelte';
interface Props {
text: string | HTMLElement; // Text or HTML element to read
autoStart?: boolean; // Auto-start playback
}
let { text, autoStart = false }: Props = $props();
const ttsService = getTTSService();
let state = $state<TTSState>('idle');
let voices = $state<TTSVoice[]>([]);
let selectedVoice = $state<TTSVoice | null>(null);
let speed = $state(1.0);
let volume = $state(1.0);
let progress = $state(0);
let extractedText = $state('');
let available = $state(false);
onMount(async () => {
// Check availability
available = await ttsService.isAvailable();
if (!available) {
return;
}
// Load voices
try {
const loadedVoices = await ttsService.getVoices();
voices = loadedVoices;
// Select default voice (prefer English)
const defaultVoice = loadedVoices.find(v => v.lang.startsWith('en')) || loadedVoices[0];
if (defaultVoice) {
selectedVoice = defaultVoice;
}
} catch (error) {
console.error('Failed to load voices:', error);
}
// Extract text
if (typeof text === 'string') {
extractedText = extractTextForTTS(text);
} else {
extractedText = extractTextForTTS(text);
}
// Set up callbacks
ttsService.setCallbacks({
onStateChange: (newState) => {
state = newState;
},
onProgress: (prog) => {
progress = prog;
},
onError: (error) => {
console.error('TTS error:', error);
},
onEnd: () => {
state = 'idle';
progress = 0;
}
});
// Auto-start if requested
if (autoStart && extractedText) {
await handlePlay();
}
});
onDestroy(() => {
ttsService.stop();
});
async function handlePlay() {
if (!extractedText) return;
if (state === 'paused') {
await ttsService.resume();
} else {
await ttsService.speak(extractedText, {
voice: selectedVoice || undefined,
speed,
volume
});
}
}
async function handlePause() {
await ttsService.pause();
}
async function handleStop() {
await ttsService.stop();
progress = 0;
}
function handleSpeedChange(e: Event) {
const target = e.target as HTMLInputElement;
speed = parseFloat(target.value);
// If playing, restart with new speed
if (state === 'playing' || state === 'paused') {
handleStop();
setTimeout(() => handlePlay(), 100);
}
}
function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
volume = parseFloat(target.value);
// If playing, restart with new volume
if (state === 'playing' || state === 'paused') {
handleStop();
setTimeout(() => handlePlay(), 100);
}
}
function handleVoiceChange(e: Event) {
const target = e.target as HTMLSelectElement;
const voiceId = target.value;
selectedVoice = voices.find(v => v.id === voiceId) || null;
// If playing, restart with new voice
if (state === 'playing' || state === 'paused') {
handleStop();
setTimeout(() => handlePlay(), 100);
}
}
</script>
{#if available}
<div class="tts-controls">
<div class="tts-controls-main">
<!-- Play/Pause/Stop buttons -->
<div class="tts-buttons">
{#if state === 'playing'}
<button class="tts-button" onclick={handlePause} aria-label="Pause">
⏸ Pause
</button>
{:else if state === 'paused'}
<button class="tts-button" onclick={handlePlay} aria-label="Resume">
▶ Resume
</button>
{:else}
<button class="tts-button" onclick={handlePlay} aria-label="Play" disabled={!extractedText}>
▶ Play
</button>
{/if}
{#if state === 'playing' || state === 'paused'}
<button class="tts-button" onclick={handleStop} aria-label="Stop">
⏹ Stop
</button>
{/if}
</div>
<!-- Speed control -->
<div class="tts-control-group">
<label for="tts-speed" class="tts-label">Speed</label>
<input
id="tts-speed"
type="range"
min="0.5"
max="2.0"
step="0.1"
value={speed}
oninput={handleSpeedChange}
class="tts-slider"
/>
<span class="tts-value">{speed.toFixed(1)}x</span>
</div>
<!-- Volume control -->
<div class="tts-control-group">
<label for="tts-volume" class="tts-label">Volume</label>
<input
id="tts-volume"
type="range"
min="0"
max="1"
step="0.1"
value={volume}
oninput={handleVolumeChange}
class="tts-slider"
/>
<span class="tts-value">{Math.round(volume * 100)}%</span>
</div>
<!-- Voice selection -->
{#if voices.length > 0}
<div class="tts-control-group">
<label for="tts-voice" class="tts-label">Voice</label>
<select
id="tts-voice"
value={selectedVoice?.id || ''}
onchange={handleVoiceChange}
class="tts-select"
>
{#each voices as voice}
<option value={voice.id}>{voice.name} ({voice.lang})</option>
{/each}
</select>
</div>
{/if}
</div>
<!-- Progress indicator -->
{#if state === 'playing' || state === 'paused'}
<div class="tts-progress">
<div class="tts-progress-bar" style="width: {progress * 100}%"></div>
</div>
{/if}
</div>
{:else}
<div class="tts-unavailable">
<p>Text-to-speech is not available in your browser.</p>
</div>
{/if}
<style>
.tts-controls {
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
}
:global(.dark) .tts-controls {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
}
.tts-controls-main {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
}
.tts-buttons {
display: flex;
gap: 0.5rem;
}
.tts-button {
background: var(--fog-surface, #f8fafc);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
padding: 0.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
color: var(--fog-text, #475569);
}
.tts-button:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
}
.tts-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .tts-button {
background: var(--fog-dark-surface, #1e293b);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #cbd5e1);
}
:global(.dark) .tts-button:hover:not(:disabled) {
background: var(--fog-dark-highlight, #374151);
}
.tts-control-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tts-label {
font-size: 0.875rem;
color: var(--fog-text, #475569);
white-space: nowrap;
}
:global(.dark) .tts-label {
color: var(--fog-dark-text, #cbd5e1);
}
.tts-slider {
width: 100px;
}
.tts-value {
font-size: 0.875rem;
color: var(--fog-text-light, #52667a);
min-width: 3rem;
text-align: right;
}
:global(.dark) .tts-value {
color: var(--fog-dark-text-light, #a8b8d0);
}
.tts-select {
background: var(--fog-surface, #f8fafc);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
color: var(--fog-text, #475569);
min-width: 200px;
}
:global(.dark) .tts-select {
background: var(--fog-dark-surface, #1e293b);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #cbd5e1);
}
.tts-progress {
margin-top: 1rem;
height: 4px;
background: var(--fog-border, #e5e7eb);
border-radius: 2px;
overflow: hidden;
}
:global(.dark) .tts-progress {
background: var(--fog-dark-border, #475569);
}
.tts-progress-bar {
height: 100%;
background: var(--fog-accent, #64748b);
transition: width 0.1s;
}
:global(.dark) .tts-progress-bar {
background: var(--fog-dark-accent, #94a3b8);
}
.tts-unavailable {
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
color: var(--fog-text-light, #52667a);
font-size: 0.875rem;
}
:global(.dark) .tts-unavailable {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text-light, #a8b8d0);
}
</style>

131
src/lib/components/modals/TTSModal.svelte

@ -0,0 +1,131 @@
<script lang="ts">
import TTSControls from '../content/TTSControls.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { extractTextForTTS } from '../../services/tts/text-extractor.js';
interface Props {
open?: boolean;
event?: NostrEvent;
contentElement?: HTMLElement;
}
let { open = $bindable(false), event, contentElement }: Props = $props();
function close() {
open = false;
}
</script>
{#if open && (event || contentElement)}
<div
class="modal-overlay"
onclick={(e) => e.target === e.currentTarget && close()}
onkeydown={(e) => e.key === 'Escape' && close()}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="modal-content" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.key === 'Escape' && close()}>
<div class="modal-header">
<h2>Read Aloud</h2>
<button onclick={close} class="close-button" aria-label="Close">×</button>
</div>
<div class="modal-body">
{#if contentElement}
<TTSControls text={contentElement} />
{:else if event}
<TTSControls text={event.content} />
{/if}
</div>
</div>
</div>
{/if}
<style>
.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: 600px;
width: 90%;
max-height: 80vh;
overflow: auto;
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;
}
</style>

245
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<CryptoKey> {
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<string> {
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<string> {
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<string> {
// 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<void> {
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<string | null> {
try {
const preferenceKey = `api_key.${type}`;
const encrypted = await loadPreference<string | null>(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<void> {
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<boolean> {
try {
const preferenceKey = `api_key.${type}`;
const encrypted = await loadPreference<string | null>(preferenceKey, null as string | null);
return encrypted !== null;
} catch (error) {
return false;
}
}
/**
* List all stored API key types
*/
export async function listApiKeyTypes(): Promise<ApiKeyType[]> {
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 [];
}
}

104
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();
}

339
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<boolean> {
return typeof window !== 'undefined' && 'speechSynthesis' in window;
}
async getVoices(): Promise<TTSVoice[]> {
if (!this.voiceLoaded) {
await new Promise(resolve => setTimeout(resolve, 100));
this.loadVoices();
}
return this.voices;
}
async speak(text: string, options?: TTSOptions): Promise<void> {
// 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<void> {
if (this.state === 'playing' && this.synth.speaking) {
this.synth.pause();
this.state = 'paused';
this.callbacks.onStateChange?.(this.state);
}
}
async resume(): Promise<void> {
if (this.state === 'paused' && this.synth.paused) {
this.synth.resume();
this.state = 'playing';
this.callbacks.onStateChange?.(this.state);
}
}
async stop(): Promise<void> {
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<number> {
// 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<void> {
// 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<void> {
// 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<boolean> {
if (!this.provider) {
await this.initialize();
}
return this.provider ? await this.provider.isAvailable() : false;
}
/**
* Get available voices
*/
async getVoices(): Promise<TTSVoice[]> {
if (!this.provider) {
await this.initialize();
}
return this.provider ? await this.provider.getVoices() : [];
}
/**
* Speak text
*/
async speak(text: string, options?: TTSOptions): Promise<void> {
if (!this.provider) {
await this.initialize();
}
if (this.provider) {
await this.provider.speak(text, options);
}
}
/**
* Pause playback
*/
async pause(): Promise<void> {
if (this.provider) {
await this.provider.pause();
}
}
/**
* Resume playback
*/
async resume(): Promise<void> {
if (this.provider) {
await this.provider.resume();
}
}
/**
* Stop playback
*/
async stop(): Promise<void> {
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<number> {
return this.provider ? await this.provider.getProgress() : 0;
}
/**
* Set playback position
*/
async setProgress(position: number): Promise<void> {
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;
}

115
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<boolean>;
/**
* Get available voices
*/
getVoices(): Promise<TTSVoice[]>;
/**
* Speak text
*/
speak(text: string, options?: TTSOptions): Promise<void>;
/**
* Pause playback
*/
pause(): Promise<void>;
/**
* Resume playback
*/
resume(): Promise<void>;
/**
* Stop playback
*/
stop(): Promise<void>;
/**
* Get current state
*/
getState(): TTSState;
/**
* Get current playback position (0-1)
*/
getProgress(): Promise<number>;
/**
* Set playback position (0-1)
*/
setProgress(position: number): Promise<void>;
/**
* 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;
}

383
src/routes/settings/+page.svelte

@ -6,6 +6,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import Icon from '../../lib/components/ui/Icon.svelte'; import Icon from '../../lib/components/ui/Icon.svelte';
import { KEYBOARD_SHORTCUTS } from '../../lib/services/keyboard-shortcuts.js'; 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 TextSize = 'small' | 'medium' | 'large';
type LineSpacing = 'tight' | 'normal' | 'loose'; type LineSpacing = 'tight' | 'normal' | 'loose';
@ -20,6 +21,16 @@
let expiringEvents = $state(false); let expiringEvents = $state(false);
let includeClientTag = $state(true); 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<Record<string, { hasKey: boolean; editing: boolean; value: string; error: string | null }>>({});
let passwordPrompt = $state<{ open: boolean; type: ApiKeyType | null; action: 'save' | 'load' }>({ open: false, type: null, action: 'save' });
let passwordInput = $state('');
// PWA install state // PWA install state
let deferredPrompt = $state<BeforeInstallPromptEvent | null>(null); let deferredPrompt = $state<BeforeInstallPromptEvent | null>(null);
let showInstallButton = $state(false); let showInstallButton = $state(false);
@ -66,6 +77,9 @@
// Load client tag preference // Load client tag preference
includeClientTag = shouldIncludeClientTag(); 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 // Check if PWA is already installed
if (typeof window !== 'undefined') { 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() { async function handlePWAInstall() {
// If we have a deferred prompt (Chrome/Edge), use it // If we have a deferred prompt (Chrome/Edge), use it
if (deferredPrompt) { if (deferredPrompt) {
@ -509,6 +619,80 @@
</p> </p>
</div> </div>
<!-- API Keys -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">
<span class="font-semibold text-fog-text dark:text-fog-dark-text">API Keys</span>
</div>
<p class="text-fog-text-light dark:text-fog-dark-text-light mb-4" style="font-size: 0.875em;">
Manage API keys for external services. Keys are encrypted and stored securely.
</p>
<div class="api-keys-list">
{#each apiKeyTypes as { type, label, description }}
{@const state = apiKeyStates[type] || { hasKey: false, editing: false, value: '', error: null }}
<div class="api-key-item">
<div class="api-key-header">
<div>
<div class="font-semibold text-fog-text dark:text-fog-dark-text">{label}</div>
<div class="text-fog-text-light dark:text-fog-dark-text-light" style="font-size: 0.875em;">{description}</div>
</div>
<div class="api-key-status">
{#if state.hasKey}
<span class="api-key-status-badge">Configured</span>
{/if}
</div>
</div>
{#if state.editing}
<div class="api-key-edit">
<input
type="password"
bind:value={state.value}
placeholder="Enter API key"
class="api-key-input"
/>
{#if state.error}
<div class="api-key-error">{state.error}</div>
{/if}
<div class="api-key-actions">
<button
onclick={() => handleApiKeySave(type)}
class="toggle-button"
disabled={!state.value.trim()}
>
Save
</button>
<button
onclick={() => handleApiKeyCancel(type)}
class="toggle-button"
>
Cancel
</button>
</div>
</div>
{:else}
<div class="api-key-actions">
<button
onclick={() => handleApiKeyEdit(type)}
class="toggle-button"
>
{state.hasKey ? 'Edit' : 'Add'} API Key
</button>
{#if state.hasKey}
<button
onclick={() => handleApiKeyDelete(type)}
class="toggle-button"
style="background: var(--fog-error, #ef4444); color: white; border-color: var(--fog-error, #ef4444);"
>
Delete
</button>
{/if}
</div>
{/if}
</div>
{/each}
</div>
</div>
<!-- Keyboard Shortcuts --> <!-- Keyboard Shortcuts -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded"> <div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3"> <div class="preference-label mb-3">
@ -769,4 +953,203 @@
:global(.dark) .shortcut-description { :global(.dark) .shortcut-description {
color: var(--fog-dark-text, #cbd5e1); 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;
}
</style> </style>
<!-- Password Prompt Modal -->
{#if passwordPrompt.open}
<div
class="modal-overlay"
onclick={(e) => e.target === e.currentTarget && (passwordPrompt = { open: false, type: null, action: 'save' })}
onkeydown={(e) => e.key === 'Escape' && (passwordPrompt = { open: false, type: null, action: 'save' })}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="modal-content" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => { if (e.key === 'Escape') passwordPrompt = { open: false, type: null, action: 'save' }; }}>
<div class="modal-header">
<h2>Enter Password</h2>
<button onclick={() => passwordPrompt = { open: false, type: null, action: 'save' }} class="close-button">×</button>
</div>
<div class="modal-body">
<p class="text-fog-text-light dark:text-fog-dark-text-light mb-4">
Enter your password to {passwordPrompt.action === 'save' ? 'encrypt and save' : 'decrypt and view'} the API key.
</p>
<input
type="password"
bind:value={passwordInput}
placeholder="Password"
class="api-key-input"
onkeydown={(e) => e.key === 'Enter' && handlePasswordSubmit()}
/>
<div class="api-key-actions mt-4">
<button onclick={handlePasswordSubmit} class="toggle-button" disabled={!passwordInput}>
{passwordPrompt.action === 'save' ? 'Save' : 'Load'}
</button>
<button onclick={() => passwordPrompt = { open: false, type: null, action: 'save' }} class="toggle-button">
Cancel
</button>
</div>
</div>
</div>
</div>
{/if}

1
static/icons/volume.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>

After

Width:  |  Height:  |  Size: 312 B

Loading…
Cancel
Save