diff --git a/apps/calendar/apps/web/src/lib/api/network.ts b/apps/calendar/apps/web/src/lib/api/network.ts deleted file mode 100644 index 0bdc15860..000000000 --- a/apps/calendar/apps/web/src/lib/api/network.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Network Graph API Client - */ - -import { fetchApi } from './client'; - -export interface NetworkTag { - id: string; - name: string; - color: string | null; -} - -export interface NetworkNode { - id: string; - name: string; - photoUrl: string | null; - company: string | null; - isFavorite: boolean; - tags: NetworkTag[]; - connectionCount: number; -} - -export interface NetworkLink { - source: string; - target: string; - type: 'tag' | 'calendar' | 'date' | 'location'; - strength: number; - sharedTags: string[]; -} - -export interface NetworkGraphResponse { - nodes: NetworkNode[]; - links: NetworkLink[]; -} - -export const networkApi = { - /** - * Get the network graph of events connected by shared tags - */ - async getGraph(): Promise { - const result = await fetchApi('/network/graph'); - if (result.error) { - throw result.error; - } - return result.data || { nodes: [], links: [] }; - }, -}; diff --git a/apps/calendar/apps/web/src/lib/components/calendar/NetworkView.svelte b/apps/calendar/apps/web/src/lib/components/calendar/NetworkView.svelte deleted file mode 100644 index cbb6e191c..000000000 --- a/apps/calendar/apps/web/src/lib/components/calendar/NetworkView.svelte +++ /dev/null @@ -1,416 +0,0 @@ - - -
- -
- -
- - - {#if networkStore.error} - - {/if} - - -
- {#if networkStore.loading} -
-
-

Lade Netzwerk-Graph...

-
- {:else} - - {/if} -
- - - {#if networkStore.selectedNode} -
-
-

{networkStore.selectedNode.name}

- -
- {#if networkStore.selectedNode.subtitle} -

{networkStore.selectedNode.subtitle}

- {/if} - {#if networkStore.selectedNode.tags.length > 0} -
- {#each networkStore.selectedNode.tags as tag} - - {tag.name} - - {/each} -
- {/if} -
- {networkStore.selectedNode.connectionCount} Verbindungen -
- -
- {/if} -
- - diff --git a/apps/calendar/apps/web/src/lib/components/voice/VoiceRecordButton.svelte b/apps/calendar/apps/web/src/lib/components/voice/VoiceRecordButton.svelte new file mode 100644 index 000000000..2bdf64f39 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/voice/VoiceRecordButton.svelte @@ -0,0 +1,235 @@ + + +{#if isSupported} + + + + {#if hasError && errorMessage} + + {/if} +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/components/voice/VoiceRecordingModal.svelte b/apps/calendar/apps/web/src/lib/components/voice/VoiceRecordingModal.svelte new file mode 100644 index 000000000..1da0dc42e --- /dev/null +++ b/apps/calendar/apps/web/src/lib/components/voice/VoiceRecordingModal.svelte @@ -0,0 +1,363 @@ + + + + +{#if isVisible} + +
+ + + +{/if} + + diff --git a/apps/calendar/apps/web/src/lib/services/stt.ts b/apps/calendar/apps/web/src/lib/services/stt.ts new file mode 100644 index 000000000..1ed2c509d --- /dev/null +++ b/apps/calendar/apps/web/src/lib/services/stt.ts @@ -0,0 +1,141 @@ +/** + * Speech-to-Text (STT) Service Client + * + * Communicates with the mana-stt service for audio transcription. + */ + +import { browser } from '$app/environment'; + +/** + * STT service URL - defaults to localhost for development + */ +const STT_URL = browser + ? import.meta.env.PUBLIC_STT_URL || 'http://localhost:3020' + : 'http://localhost:3020'; + +export interface TranscriptionResult { + /** The transcribed text */ + text: string; + /** Detected or specified language */ + language: string; + /** Model used for transcription */ + model: string; +} + +export interface TranscriptionError { + message: string; + code?: string; +} + +export type TranscriptionResponse = + | { success: true; data: TranscriptionResult } + | { success: false; error: TranscriptionError }; + +/** + * Transcribe audio using the mana-stt service + * + * @param audioBlob - The audio blob to transcribe + * @param language - Optional language code ('de', 'en', etc.) or 'auto' for auto-detection + * @returns The transcription result or error + */ +export async function transcribeAudio( + audioBlob: Blob, + language?: string +): Promise { + try { + const formData = new FormData(); + + // Determine file extension based on MIME type + const mimeType = audioBlob.type || 'audio/webm'; + let extension = 'webm'; + if (mimeType.includes('ogg')) extension = 'ogg'; + else if (mimeType.includes('mp4')) extension = 'mp4'; + else if (mimeType.includes('mpeg') || mimeType.includes('mp3')) extension = 'mp3'; + + formData.append('file', audioBlob, `recording.${extension}`); + + // Add language parameter if specified (and not 'auto') + if (language && language !== 'auto') { + formData.append('language', language); + } + + const response = await fetch(`${STT_URL}/transcribe`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + let errorMessage = 'Transcription failed'; + try { + const errorData = await response.json(); + errorMessage = errorData.error || errorData.message || errorMessage; + } catch { + errorMessage = `HTTP ${response.status}: ${response.statusText}`; + } + + return { + success: false, + error: { + message: errorMessage, + code: `HTTP_${response.status}`, + }, + }; + } + + const data = await response.json(); + + // Handle empty transcription + if (!data.text || data.text.trim() === '') { + return { + success: false, + error: { + message: 'Keine Sprache erkannt. Bitte erneut versuchen.', + code: 'EMPTY_TRANSCRIPTION', + }, + }; + } + + return { + success: true, + data: { + text: data.text.trim(), + language: data.language || language || 'auto', + model: data.model || 'unknown', + }, + }; + } catch (error) { + // Handle network errors + if (error instanceof TypeError && error.message.includes('fetch')) { + return { + success: false, + error: { + message: 'Spracherkennung nicht verfügbar', + code: 'NETWORK_ERROR', + }, + }; + } + + return { + success: false, + error: { + message: error instanceof Error ? error.message : 'Unknown error', + code: 'UNKNOWN_ERROR', + }, + }; + } +} + +/** + * Check if the STT service is available + */ +export async function checkSttServiceHealth(): Promise { + try { + const response = await fetch(`${STT_URL}/health`, { + method: 'GET', + signal: AbortSignal.timeout(5000), // 5 second timeout + }); + return response.ok; + } catch { + return false; + } +} diff --git a/apps/calendar/apps/web/src/lib/stores/network.svelte.ts b/apps/calendar/apps/web/src/lib/stores/network.svelte.ts deleted file mode 100644 index 793d48c95..000000000 --- a/apps/calendar/apps/web/src/lib/stores/network.svelte.ts +++ /dev/null @@ -1,371 +0,0 @@ -/** - * Network Store - Manages network graph state with D3-force simulation - */ - -import { browser } from '$app/environment'; -import { networkApi } from '$lib/api/network'; -import type { NetworkNode, NetworkLink } from '$lib/api/network'; -import { - forceSimulation, - forceLink, - forceManyBody, - forceCenter, - forceCollide, - type Simulation, -} from 'd3-force'; -import type { - SimulationNode as SharedSimulationNode, - SimulationLink as SharedSimulationLink, -} from '@manacore/shared-ui'; - -// Re-export types from shared-ui for convenience -export type SimulationNode = SharedSimulationNode; -export type SimulationLink = SharedSimulationLink; - -// State -let nodes = $state([]); -let links = $state([]); -let loading = $state(false); -let error = $state(null); -let selectedNodeId = $state(null); -let simulation: Simulation | null = null; -let searchQuery = $state(''); -let filterTagId = $state(null); -let filterLocation = $state(null); -let minStrength = $state(0); -let tickCounter = $state(0); -let simulationInitialized = false; -let dataLoaded = false; -let lastDimensions = { width: 0, height: 0 }; - -// Derived state for filtering -const filteredNodes = $derived.by(() => { - let result = nodes; - - // Search filter - if (searchQuery.trim()) { - const query = searchQuery.toLowerCase(); - result = result.filter( - (node) => - node.name.toLowerCase().includes(query) || - node.subtitle?.toLowerCase().includes(query) || - node.tags.some((t) => t.name.toLowerCase().includes(query)) - ); - } - - // Tag filter - if (filterTagId) { - result = result.filter((node) => node.tags.some((t) => t.id === filterTagId)); - } - - // Location filter (uses subtitle field) - if (filterLocation) { - result = result.filter((node) => node.subtitle === filterLocation); - } - - return result; -}); - -const filteredLinks = $derived.by(() => { - const filteredNodeIds = new Set(filteredNodes.map((n) => n.id)); - return links.filter((link) => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - // Check if both nodes are visible - if (!filteredNodeIds.has(sourceId) || !filteredNodeIds.has(targetId)) { - return false; - } - // Filter by minimum strength - if (minStrength > 0 && link.strength < minStrength) { - return false; - } - return true; - }); -}); - -// Get unique locations for filter dropdown -const uniqueLocations = $derived.by(() => { - const locations = new Set(); - for (const node of nodes) { - if (node.subtitle) { - locations.add(node.subtitle); - } - } - return Array.from(locations).sort(); -}); - -// Get unique tags for filter dropdown -const uniqueTags = $derived.by(() => { - const tagsMap = new Map(); - for (const node of nodes) { - for (const tag of node.tags) { - if (!tagsMap.has(tag.id)) { - tagsMap.set(tag.id, tag); - } - } - } - return Array.from(tagsMap.values()).sort((a, b) => a.name.localeCompare(b.name)); -}); - -export const networkStore = { - // Getters - get nodes() { - void tickCounter; - return filteredNodes; - }, - get allNodes() { - void tickCounter; - return nodes; - }, - get links() { - void tickCounter; - return filteredLinks; - }, - get allLinks() { - void tickCounter; - return links; - }, - get tick() { - return tickCounter; - }, - get loading() { - return loading; - }, - get error() { - return error; - }, - get selectedNodeId() { - return selectedNodeId; - }, - get selectedNode() { - return nodes.find((n) => n.id === selectedNodeId) || null; - }, - get searchQuery() { - return searchQuery; - }, - get filterTagId() { - return filterTagId; - }, - get filterLocation() { - return filterLocation; - }, - get minStrength() { - return minStrength; - }, - get uniqueLocations() { - return uniqueLocations; - }, - get uniqueTags() { - return uniqueTags; - }, - - /** - * Load network graph data from API - */ - async loadGraph(force = false) { - if (dataLoaded && !force) { - return; - } - - if (loading) { - return; - } - - loading = true; - error = null; - - if (simulation) { - simulation.stop(); - simulation = null; - } - simulationInitialized = false; - - try { - const response = await networkApi.getGraph(); - - // Convert to simulation nodes with subtitle for location - nodes = response.nodes.map((node) => ({ - ...node, - subtitle: node.company, // Map company/location to subtitle - x: undefined, - y: undefined, - vx: undefined, - vy: undefined, - fx: null, - fy: null, - })); - - // Convert to simulation links - // Cast type to be compatible with SimulationLink (calendar API has extended types) - links = response.links.map((link) => ({ - source: link.source, - target: link.target, - type: link.type as SimulationLink['type'], - strength: link.strength, - sharedTags: link.sharedTags, - })); - - dataLoaded = true; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load network graph'; - console.error('Failed to load network graph:', e); - } finally { - loading = false; - } - }, - - /** - * Initialize D3 force simulation - */ - initSimulation(width: number, height: number) { - if (!browser) return; - if (nodes.length === 0) return; - if (width <= 0 || height <= 0) return; - - if (simulationInitialized && simulation) { - if ( - Math.abs(lastDimensions.width - width) > 50 || - Math.abs(lastDimensions.height - height) > 50 - ) { - lastDimensions = { width, height }; - this.updateSimulationCenter(width, height); - } - return; - } - - if (simulation) { - simulation.stop(); - } - - lastDimensions = { width, height }; - - const centerX = width / 2; - const centerY = height / 2; - const radius = Math.min(width, height) / 3; - - nodes.forEach((node, i) => { - if (node.x === undefined || node.y === undefined) { - const angle = (i / nodes.length) * 2 * Math.PI; - const r = radius * (0.5 + Math.random() * 0.5); - node.x = centerX + r * Math.cos(angle); - node.y = centerY + r * Math.sin(angle); - } - }); - - simulation = forceSimulation(nodes) - .force( - 'link', - forceLink(links) - .id((d) => d.id) - .distance(100) - .strength(0.5) - ) - .force('charge', forceManyBody().strength(-300)) - .force('center', forceCenter(centerX, centerY)) - .force('collision', forceCollide().radius(50)) - .on('tick', () => { - tickCounter++; - }); - - simulationInitialized = true; - simulation.alpha(1).restart(); - }, - - updateSimulationCenter(width: number, height: number) { - if (simulation) { - simulation.force('center', forceCenter(width / 2, height / 2)); - simulation.alpha(0.3).restart(); - } - }, - - stopSimulation() { - if (simulation) { - simulation.stop(); - simulation = null; - } - simulationInitialized = false; - }, - - reset() { - this.stopSimulation(); - nodes = []; - links = []; - dataLoaded = false; - lastDimensions = { width: 0, height: 0 }; - tickCounter = 0; - }, - - reheatSimulation() { - if (simulation) { - simulation.alpha(0.3).restart(); - } - }, - - fixNode(nodeId: string, x: number, y: number) { - const node = nodes.find((n) => n.id === nodeId); - if (node) { - node.fx = x; - node.fy = y; - } - }, - - releaseNode(nodeId: string) { - const node = nodes.find((n) => n.id === nodeId); - if (node) { - node.fx = null; - node.fy = null; - } - }, - - selectNode(nodeId: string | null) { - selectedNodeId = nodeId; - }, - - setSearch(query: string) { - searchQuery = query; - }, - - setFilterTag(tagId: string | null) { - filterTagId = tagId; - }, - - setFilterLocation(location: string | null) { - filterLocation = location; - }, - - setMinStrength(strength: number) { - minStrength = strength; - }, - - clearFilters() { - searchQuery = ''; - filterTagId = null; - filterLocation = null; - minStrength = 0; - }, - - getConnectedNodes(nodeId: string): SimulationNode[] { - const connectedIds = new Set(); - - for (const link of links) { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - - if (sourceId === nodeId) { - connectedIds.add(targetId); - } else if (targetId === nodeId) { - connectedIds.add(sourceId); - } - } - - return nodes.filter((n) => connectedIds.has(n.id)); - }, - - getNodeLinks(nodeId: string): SimulationLink[] { - return links.filter((link) => { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - return sourceId === nodeId || targetId === nodeId; - }); - }, -}; diff --git a/apps/calendar/apps/web/src/lib/stores/view-mode.svelte.ts b/apps/calendar/apps/web/src/lib/stores/view-mode.svelte.ts deleted file mode 100644 index 8235cafa9..000000000 --- a/apps/calendar/apps/web/src/lib/stores/view-mode.svelte.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * View Mode Store - Manages app view mode (calendar vs network) - * Similar pattern to Contacts app view-mode store - */ - -import { browser } from '$app/environment'; - -export type AppViewMode = 'calendar' | 'network'; - -const STORAGE_KEY = 'calendar-app-view-mode'; - -// Valid view modes -const VALID_MODES: AppViewMode[] = ['calendar', 'network']; - -function isValidMode(mode: string | null): mode is AppViewMode { - return mode !== null && VALID_MODES.includes(mode as AppViewMode); -} - -// Get initial mode from sessionStorage or default to 'calendar' -function getInitialMode(): AppViewMode { - if (!browser) return 'calendar'; - - const sessionMode = sessionStorage.getItem(STORAGE_KEY); - if (isValidMode(sessionMode)) { - return sessionMode; - } - - return 'calendar'; -} - -let mode = $state(getInitialMode()); - -export const viewModeStore = { - get mode() { - return mode; - }, - - setMode(newMode: AppViewMode) { - mode = newMode; - if (browser) { - sessionStorage.setItem(STORAGE_KEY, newMode); - } - }, - - /** - * Toggle between calendar and network mode - */ - toggle() { - const newMode = mode === 'calendar' ? 'network' : 'calendar'; - this.setMode(newMode); - }, - - /** - * Reset to default view (calendar) - */ - resetToDefault() { - mode = 'calendar'; - if (browser) { - sessionStorage.removeItem(STORAGE_KEY); - } - }, - - /** - * Initialize mode from sessionStorage (call on app load) - */ - initialize() { - if (!browser) return; - - const sessionMode = sessionStorage.getItem(STORAGE_KEY); - if (isValidMode(sessionMode)) { - mode = sessionMode; - } else { - mode = 'calendar'; - } - }, -}; diff --git a/apps/calendar/apps/web/src/lib/stores/voice-recording.svelte.ts b/apps/calendar/apps/web/src/lib/stores/voice-recording.svelte.ts new file mode 100644 index 000000000..054c4bdb7 --- /dev/null +++ b/apps/calendar/apps/web/src/lib/stores/voice-recording.svelte.ts @@ -0,0 +1,243 @@ +/** + * Voice Recording Store + * + * Manages the state of voice recording for event creation. + * Uses Svelte 5 runes for reactive state management. + */ + +import { + createAudioRecorder, + requestMicrophonePermission, + isAudioRecordingSupported, + formatDuration, + type AudioRecorder, + type PermissionState, +} from '$lib/utils/audio-recorder'; +import { transcribeAudio, type TranscriptionResult } from '$lib/services/stt'; +import { settingsStore } from './settings.svelte'; + +export type VoiceRecordingState = + | 'idle' + | 'requesting' // Requesting microphone permission + | 'recording' + | 'processing' // Transcribing audio + | 'error'; + +export interface VoiceRecordingError { + message: string; + code?: string; +} + +// State +let state = $state('idle'); +let duration = $state(0); +let error = $state(null); +let permissionState = $state('prompt'); + +// Internal +let recorder: AudioRecorder | null = null; +let onResultCallback: ((result: TranscriptionResult) => void) | null = null; + +/** + * Voice Recording Store + */ +export const voiceRecordingStore = { + // Getters + get state() { + return state; + }, + + get duration() { + return duration; + }, + + get formattedDuration() { + return formatDuration(duration); + }, + + get error() { + return error; + }, + + get permissionState() { + return permissionState; + }, + + get isSupported() { + return isAudioRecordingSupported(); + }, + + get isIdle() { + return state === 'idle'; + }, + + get isRecording() { + return state === 'recording'; + }, + + get isProcessing() { + return state === 'processing'; + }, + + get hasError() { + return state === 'error'; + }, + + /** + * Set the callback for when transcription completes successfully + */ + setOnResult(callback: (result: TranscriptionResult) => void) { + onResultCallback = callback; + }, + + /** + * Check microphone permission without starting recording + */ + async checkPermission(): Promise { + permissionState = await requestMicrophonePermission(); + return permissionState; + }, + + /** + * Start voice recording + */ + async startRecording(): Promise { + if (state !== 'idle' && state !== 'error') { + return; + } + + // Reset error state + error = null; + state = 'requesting'; + + try { + // Check permission + permissionState = await requestMicrophonePermission(); + + if (permissionState === 'unsupported') { + throw { + message: 'Kein Mikrofon gefunden', + code: 'NOT_SUPPORTED', + }; + } + + if (permissionState === 'denied') { + throw { + message: 'Mikrofonzugriff verweigert. Bitte in Browsereinstellungen erlauben.', + code: 'PERMISSION_DENIED', + }; + } + + // Create and start recorder + recorder = createAudioRecorder({ + maxDuration: 60000, // 60 seconds max + onDurationUpdate: (ms) => { + duration = ms; + }, + onMaxDurationWarning: () => { + // Could show a toast or visual warning + console.warn('Approaching max recording duration'); + }, + onError: (err) => { + error = { + message: err.message, + code: 'RECORDER_ERROR', + }; + state = 'error'; + }, + }); + + await recorder.start(); + state = 'recording'; + duration = 0; + } catch (err) { + // Handle error objects with message/code + if (err && typeof err === 'object' && 'message' in err) { + error = err as VoiceRecordingError; + } else if (err instanceof Error) { + error = { + message: err.message, + code: 'START_ERROR', + }; + } else { + error = { + message: 'Aufnahme konnte nicht gestartet werden', + code: 'UNKNOWN_ERROR', + }; + } + state = 'error'; + recorder = null; + } + }, + + /** + * Stop recording and process transcription + */ + async stopRecording(): Promise { + if (state !== 'recording' || !recorder) { + return; + } + + state = 'processing'; + + try { + const audioBlob = await recorder.stop(); + recorder = null; + + // Get language setting + const language = settingsStore.sttLanguage; + + // Transcribe + const result = await transcribeAudio(audioBlob, language); + + if (result.success) { + // Success - call the callback + state = 'idle'; + duration = 0; + onResultCallback?.(result.data); + } else { + // Transcription error + error = result.error; + state = 'error'; + } + } catch (err) { + error = { + message: err instanceof Error ? err.message : 'Transkription fehlgeschlagen', + code: 'TRANSCRIPTION_ERROR', + }; + state = 'error'; + recorder = null; + } + }, + + /** + * Cancel recording without transcription + */ + cancel(): void { + if (recorder) { + recorder.cancel(); + recorder = null; + } + state = 'idle'; + duration = 0; + error = null; + }, + + /** + * Clear error and return to idle state + */ + clearError(): void { + error = null; + if (state === 'error') { + state = 'idle'; + } + }, + + /** + * Reset to initial state + */ + reset(): void { + this.cancel(); + error = null; + }, +}; diff --git a/apps/calendar/apps/web/src/lib/utils/audio-recorder.ts b/apps/calendar/apps/web/src/lib/utils/audio-recorder.ts new file mode 100644 index 000000000..afd99c86c --- /dev/null +++ b/apps/calendar/apps/web/src/lib/utils/audio-recorder.ts @@ -0,0 +1,272 @@ +/** + * Audio Recorder Utility + * + * Wrapper around MediaRecorder API for voice recording functionality. + * Handles microphone permissions, recording state, and audio blob creation. + */ + +export type PermissionState = 'granted' | 'denied' | 'prompt' | 'unsupported'; + +export interface AudioRecorderOptions { + /** Called when recording duration updates (every 100ms) */ + onDurationUpdate?: (durationMs: number) => void; + /** Called when an error occurs */ + onError?: (error: Error) => void; + /** Maximum recording duration in milliseconds (default: 60000 = 60s) */ + maxDuration?: number; + /** Warning callback when approaching max duration */ + onMaxDurationWarning?: () => void; +} + +export interface AudioRecorder { + /** Start recording audio */ + start(): Promise; + /** Stop recording and return the audio blob */ + stop(): Promise; + /** Cancel recording without returning data */ + cancel(): void; + /** Whether currently recording */ + readonly isRecording: boolean; + /** Current recording duration in milliseconds */ + readonly duration: number; +} + +/** + * Check if the browser supports audio recording + */ +export function isAudioRecordingSupported(): boolean { + return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.MediaRecorder); +} + +/** + * Request microphone permission and return the current permission state + */ +export async function requestMicrophonePermission(): Promise { + if (!isAudioRecordingSupported()) { + return 'unsupported'; + } + + try { + // Check existing permission if available + if (navigator.permissions) { + const permissionStatus = await navigator.permissions.query({ + name: 'microphone' as PermissionName, + }); + + if (permissionStatus.state === 'granted') { + return 'granted'; + } + + if (permissionStatus.state === 'denied') { + return 'denied'; + } + } + + // Try to get user media to trigger permission prompt + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + // Stop the stream immediately - we just needed to check permission + stream.getTracks().forEach((track) => track.stop()); + + return 'granted'; + } catch (error) { + if (error instanceof DOMException) { + if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { + return 'denied'; + } + if (error.name === 'NotFoundError') { + return 'unsupported'; + } + } + return 'denied'; + } +} + +/** + * Get the best supported audio MIME type + */ +function getSupportedMimeType(): string { + const mimeTypes = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/ogg;codecs=opus', + 'audio/mp4', + 'audio/mpeg', + ]; + + for (const mimeType of mimeTypes) { + if (MediaRecorder.isTypeSupported(mimeType)) { + return mimeType; + } + } + + // Fallback - let the browser decide + return ''; +} + +/** + * Create an audio recorder instance + */ +export function createAudioRecorder(options: AudioRecorderOptions = {}): AudioRecorder { + const { onDurationUpdate, onError, maxDuration = 60000, onMaxDurationWarning } = options; + + let mediaRecorder: MediaRecorder | null = null; + let mediaStream: MediaStream | null = null; + let audioChunks: Blob[] = []; + let isRecording = false; + let duration = 0; + let durationInterval: ReturnType | null = null; + let startTime = 0; + let warningShown = false; + + const recorder: AudioRecorder = { + get isRecording() { + return isRecording; + }, + + get duration() { + return duration; + }, + + async start() { + if (isRecording) { + throw new Error('Already recording'); + } + + if (!isAudioRecordingSupported()) { + throw new Error('Audio recording is not supported in this browser'); + } + + try { + // Get audio stream + mediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true, + }, + }); + + // Create MediaRecorder with best supported format + const mimeType = getSupportedMimeType(); + mediaRecorder = new MediaRecorder(mediaStream, mimeType ? { mimeType } : undefined); + + audioChunks = []; + duration = 0; + warningShown = false; + + // Handle data chunks + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunks.push(event.data); + } + }; + + // Handle errors + mediaRecorder.onerror = (event) => { + const error = new Error( + 'MediaRecorder error: ' + (event as any).error?.message || 'Unknown error' + ); + onError?.(error); + cleanup(); + }; + + // Start recording + mediaRecorder.start(100); // Collect data every 100ms for smoother stop + isRecording = true; + startTime = Date.now(); + + // Track duration + durationInterval = setInterval(() => { + duration = Date.now() - startTime; + onDurationUpdate?.(duration); + + // Warning at 50 seconds (10 seconds before max) + if (!warningShown && duration >= maxDuration - 10000) { + warningShown = true; + onMaxDurationWarning?.(); + } + + // Auto-stop at max duration + if (duration >= maxDuration) { + recorder.stop().catch(onError); + } + }, 100); + } catch (error) { + cleanup(); + throw error; + } + }, + + async stop(): Promise { + if (!isRecording || !mediaRecorder) { + throw new Error('Not currently recording'); + } + + return new Promise((resolve, reject) => { + if (!mediaRecorder) { + reject(new Error('MediaRecorder not available')); + return; + } + + mediaRecorder.onstop = () => { + const mimeType = mediaRecorder?.mimeType || 'audio/webm'; + const blob = new Blob(audioChunks, { type: mimeType }); + cleanup(); + resolve(blob); + }; + + try { + mediaRecorder.stop(); + } catch (error) { + cleanup(); + reject(error); + } + }); + }, + + cancel() { + cleanup(); + }, + }; + + function cleanup() { + isRecording = false; + duration = 0; + + if (durationInterval) { + clearInterval(durationInterval); + durationInterval = null; + } + + if (mediaRecorder) { + if (mediaRecorder.state !== 'inactive') { + try { + mediaRecorder.stop(); + } catch { + // Ignore errors when stopping + } + } + mediaRecorder = null; + } + + if (mediaStream) { + mediaStream.getTracks().forEach((track) => track.stop()); + mediaStream = null; + } + + audioChunks = []; + } + + return recorder; +} + +/** + * Format duration in milliseconds to MM:SS format + */ +export function formatDuration(durationMs: number): string { + const totalSeconds = Math.floor(durationMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; +} diff --git a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte index 65d27f632..39db8992e 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+layout.svelte @@ -44,7 +44,6 @@ isNavCollapsed as collapsedStore, isToolbarCollapsed as toolbarCollapsedStore, } from '$lib/stores/navigation'; - import { viewModeStore } from '$lib/stores/view-mode.svelte'; import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n'; import { getPillAppItems } from '@manacore/shared-branding'; import { setLocale, supportedLocales } from '$lib/i18n'; @@ -66,6 +65,9 @@ import ViewModePillContextMenu from '$lib/components/calendar/ViewModePillContextMenu.svelte'; import SettingsModal from '$lib/components/settings/SettingsModal.svelte'; import AuthGateModal from '$lib/components/AuthGateModal.svelte'; + import VoiceRecordButton from '$lib/components/voice/VoiceRecordButton.svelte'; + import VoiceRecordingModal from '$lib/components/voice/VoiceRecordingModal.svelte'; + import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte'; import { eventContextMenuStore } from '$lib/stores/eventContextMenu.svelte'; import { sessionEventsStore } from '$lib/stores/session-events.svelte'; import { GuestWelcomeModal, shouldShowGuestWelcome } from '@manacore/shared-auth-ui'; @@ -366,37 +368,21 @@ return viewLabels[view]; } - // Handle view/mode change - switches between calendar views and network mode - function handleViewModeChange(id: string) { - if (id === 'network') { - viewModeStore.setMode('network'); - } else { - // Switch to calendar mode and set the view type - viewModeStore.setMode('calendar'); - viewStore.setViewType(id as CalendarViewType); - } + // Handle view change + function handleViewChange(id: string) { + viewStore.setViewType(id as CalendarViewType); } - // Current view value - shows 'network' when in network mode, otherwise the calendar view type - let currentViewValue = $derived( - viewModeStore.mode === 'network' ? 'network' : viewStore.viewType - ); - // View switcher tab group (only shown on calendar main page) - // Includes calendar views + network option let viewSwitcherTabGroup = $derived({ type: 'tabs', - options: [ - ...enabledViews.map((view) => ({ - id: view, - label: getViewLabel(view), - title: - view === 'custom' ? `${settingsStore.customDayCount}-Tage-Ansicht` : viewTitles[view], - })), - { id: 'network', label: 'N', title: 'Netzwerk-Ansicht' }, - ], - value: currentViewValue, - onChange: handleViewModeChange, + options: enabledViews.map((view) => ({ + id: view, + label: getViewLabel(view), + title: view === 'custom' ? `${settingsStore.customDayCount}-Tage-Ansicht` : viewTitles[view], + })), + value: viewStore.viewType, + onChange: handleViewChange, onContextMenu: handleViewContextMenu, }); @@ -571,6 +557,31 @@ let hasSessionEvents = $derived(sessionEventsStore.hasEvents); let sessionEventCount = $derived(sessionEventsStore.count); + // Voice recording result handler + function handleVoiceResult(transcription: string) { + if (!browser) return; + + // Parse the transcribed text to extract event data + const parsed = parseEventInput(transcription); + + // Dispatch custom event for +page.svelte to handle + // The event data includes parsed info plus original transcription as description + window.dispatchEvent( + new CustomEvent('voice-event-create', { + detail: { + title: parsed.title || transcription, + startTime: parsed.startTime, + endTime: parsed.endTime, + location: parsed.location, + isAllDay: parsed.isAllDay, + tagNames: parsed.tagNames, + calendarName: parsed.calendarName, + description: transcription, // Original transcription as description + }, + }) + ); + } + onMount(async () => { // Initialize split-panel from URL/localStorage splitPanel.initialize(); @@ -753,40 +764,60 @@ {/if} {/if} - +
- +
+ + + {#if voiceRecordingStore.isSupported} +
+ +
+ {/if} +
+ + + diff --git a/apps/calendar/apps/web/src/routes/(app)/+page.svelte b/apps/calendar/apps/web/src/routes/(app)/+page.svelte index 8cd78651c..37ab2b46b 100644 --- a/apps/calendar/apps/web/src/routes/(app)/+page.svelte +++ b/apps/calendar/apps/web/src/routes/(app)/+page.svelte @@ -5,14 +5,13 @@ import { eventsStore } from '$lib/stores/events.svelte'; import { calendarsStore } from '$lib/stores/calendars.svelte'; import { settingsStore } from '$lib/stores/settings.svelte'; - import { viewModeStore } from '$lib/stores/view-mode.svelte'; import ViewCarousel from '$lib/components/calendar/ViewCarousel.svelte'; - import NetworkView from '$lib/components/calendar/NetworkView.svelte'; import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte'; import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte'; import { CalendarViewSkeleton } from '$lib/components/skeletons'; import type { CalendarEvent } from '@calendar/shared'; import { addMinutes } from 'date-fns'; + import { browser } from '$app/environment'; let initialized = $state(false); @@ -74,6 +73,67 @@ // Event is automatically removed from store } + // Voice event creation handler + interface VoiceEventData { + title: string; + startTime?: Date; + endTime?: Date; + location?: string; + isAllDay: boolean; + tagNames: string[]; + calendarName?: string; + description: string; + } + + function handleVoiceEventCreate(event: CustomEvent) { + const data = event.detail; + + // Close any existing overlay first + editingEvent = null; + eventsStore.clearDraftEvent(); + + // Determine start time - use parsed time or default to now + const startTime = data.startTime || new Date(); + quickCreateDate = startTime; + + // Calculate end time + let endTime: Date; + if (data.endTime) { + endTime = data.endTime; + } else if (data.isAllDay) { + endTime = new Date(startTime); + endTime.setHours(23, 59, 59, 999); + } else { + endTime = addMinutes(startTime, settingsStore.defaultEventDuration); + } + + // Get default calendar + const defaultCalendar = calendarsStore.defaultCalendar; + + // Create draft event with voice transcription data + eventsStore.createDraftEvent({ + calendarId: defaultCalendar?.id || '', + title: data.title, + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + isAllDay: data.isAllDay, + location: data.location, + description: data.description ? `Sprachnotiz: ${data.description}` : undefined, + }); + + overlayKey++; + showQuickOverlay = true; + } + + // Listen for voice event creation from layout + $effect(() => { + if (browser) { + const handler = (e: Event) => handleVoiceEventCreate(e as CustomEvent); + window.addEventListener('voice-event-create', handler); + return () => window.removeEventListener('voice-event-create', handler); + } + }); + // Track view changes to refetch events let lastViewType = $state(viewStore.viewType); let lastDateKey = $state(viewStore.currentDate.toDateString()); @@ -102,79 +162,63 @@ {$_('app.name')} -{#if viewModeStore.mode === 'network'} - -
- -
-{:else} - -
- - - - -
-
- {#if !initialized} - - {:else} - - {/if} -
-
- - - + + +
+
+ {#if !initialized} + + {:else} + + {/if} +
-{/if} + + + + + + {#if showQuickOverlay} + {#key overlayKey} + + {/key} + {/if} +