mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
🔥 refactor(calendar): remove Network View feature
Remove Network View (D3 event graph visualization) to reduce complexity: - Delete NetworkView.svelte component (~416 LOC) - Delete network.svelte.ts store (~371 LOC) - Delete network.ts API client (~47 LOC) - Delete view-mode.svelte.ts store (~76 LOC) - Remove network tab from view switcher - Simplify view switching logic Also adds voice recording feature (separate work, included to fix build): - Add VoiceRecordButton and VoiceRecordingModal components - Add voice-recording store and audio utilities - Integrate voice input into calendar layout Add CLEANUP_PLAN.md documenting planned simplifications. Total removal: ~910 LOC
This commit is contained in:
parent
19199290f5
commit
9a93ca0c89
12 changed files with 1731 additions and 1038 deletions
|
|
@ -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<NetworkGraphResponse> {
|
||||
const result = await fetchApi<NetworkGraphResponse>('/network/graph');
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
return result.data || { nodes: [], links: [] };
|
||||
},
|
||||
};
|
||||
|
|
@ -1,416 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { networkStore, type SimulationNode } from '$lib/stores/network.svelte';
|
||||
import { NetworkGraph, NetworkControls } from '@manacore/shared-ui';
|
||||
import '$lib/i18n';
|
||||
|
||||
let graphComponent = $state<NetworkGraph | null>(null);
|
||||
let controlsComponent: NetworkControls;
|
||||
let graphContainer: HTMLDivElement;
|
||||
|
||||
function handleNodeClick(node: SimulationNode) {
|
||||
networkStore.selectNode(node.id);
|
||||
}
|
||||
|
||||
function handleNodeDoubleClick(node: SimulationNode) {
|
||||
goto(`/event/${node.id}`);
|
||||
}
|
||||
|
||||
function handleBackgroundClick() {
|
||||
networkStore.selectNode(null);
|
||||
}
|
||||
|
||||
function handleDragStart(node: SimulationNode) {
|
||||
networkStore.fixNode(node.id, node.x ?? 0, node.y ?? 0);
|
||||
networkStore.reheatSimulation();
|
||||
}
|
||||
|
||||
function handleDrag(node: SimulationNode, x: number, y: number) {
|
||||
networkStore.fixNode(node.id, x, y);
|
||||
}
|
||||
|
||||
function handleDragEnd(node: SimulationNode) {
|
||||
networkStore.releaseNode(node.id);
|
||||
}
|
||||
|
||||
function handleZoomIn() {
|
||||
graphComponent?.zoomIn();
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
graphComponent?.zoomOut();
|
||||
}
|
||||
|
||||
function handleResetZoom() {
|
||||
graphComponent?.resetZoom();
|
||||
}
|
||||
|
||||
function handleFocusSelected() {
|
||||
graphComponent?.focusOnSelectedNode();
|
||||
}
|
||||
|
||||
function handleFocusSearch() {
|
||||
controlsComponent?.focusSearch();
|
||||
}
|
||||
|
||||
function handleSearch(query: string) {
|
||||
networkStore.setSearch(query);
|
||||
}
|
||||
|
||||
function handleTagFilter(tagId: string | null) {
|
||||
networkStore.setFilterTag(tagId);
|
||||
}
|
||||
|
||||
function handleSubtitleFilter(location: string | null) {
|
||||
networkStore.setFilterLocation(location);
|
||||
}
|
||||
|
||||
function handleStrengthFilter(strength: number) {
|
||||
networkStore.setMinStrength(strength);
|
||||
}
|
||||
|
||||
function handleClearFilters() {
|
||||
networkStore.clearFilters();
|
||||
}
|
||||
|
||||
// Initialize simulation when data is loaded and container is ready
|
||||
$effect(() => {
|
||||
if (!networkStore.loading && networkStore.allNodes.length > 0 && graphContainer) {
|
||||
const rect = graphContainer.getBoundingClientRect();
|
||||
if (rect.width > 0 && rect.height > 0) {
|
||||
networkStore.initSimulation(rect.width, rect.height);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
networkStore.loadGraph();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
networkStore.stopSimulation();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="network-view">
|
||||
<!-- Controls (floating) -->
|
||||
<div class="controls-wrapper">
|
||||
<NetworkControls
|
||||
bind:this={controlsComponent}
|
||||
searchQuery={networkStore.searchQuery}
|
||||
tags={networkStore.uniqueTags}
|
||||
selectedTagId={networkStore.filterTagId}
|
||||
subtitles={networkStore.uniqueLocations}
|
||||
selectedSubtitle={networkStore.filterLocation}
|
||||
subtitleLabel="Ort"
|
||||
nodeCount={networkStore.nodes.length}
|
||||
linkCount={networkStore.links.length}
|
||||
nodeLabel="Events"
|
||||
linkLabel="Verbindungen"
|
||||
searchPlaceholder="Event suchen..."
|
||||
minStrength={networkStore.minStrength}
|
||||
onSearch={handleSearch}
|
||||
onTagFilter={handleTagFilter}
|
||||
onSubtitleFilter={handleSubtitleFilter}
|
||||
onStrengthFilter={handleStrengthFilter}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onResetZoom={handleResetZoom}
|
||||
onFocusSelected={handleFocusSelected}
|
||||
onClearFilters={handleClearFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error Banner -->
|
||||
{#if networkStore.error}
|
||||
<div class="error-banner" role="alert">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{networkStore.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="graph-container" bind:this={graphContainer}>
|
||||
{#if networkStore.loading}
|
||||
<div class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Lade Netzwerk-Graph...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<NetworkGraph
|
||||
bind:this={graphComponent}
|
||||
nodes={networkStore.nodes}
|
||||
links={networkStore.links}
|
||||
selectedNodeId={networkStore.selectedNodeId}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onBackgroundClick={handleBackgroundClick}
|
||||
onDragStart={handleDragStart}
|
||||
onDrag={handleDrag}
|
||||
onDragEnd={handleDragEnd}
|
||||
onFocusSearch={handleFocusSearch}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Selected Event Info Panel -->
|
||||
{#if networkStore.selectedNode}
|
||||
<div class="info-panel">
|
||||
<div class="info-header">
|
||||
<h3>{networkStore.selectedNode.name}</h3>
|
||||
<button
|
||||
class="close-btn"
|
||||
onclick={() => networkStore.selectNode(null)}
|
||||
aria-label="Schlie\u00dfen"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#if networkStore.selectedNode.subtitle}
|
||||
<p class="info-subtitle">{networkStore.selectedNode.subtitle}</p>
|
||||
{/if}
|
||||
{#if networkStore.selectedNode.tags.length > 0}
|
||||
<div class="info-tags">
|
||||
{#each networkStore.selectedNode.tags as tag}
|
||||
<span
|
||||
class="tag"
|
||||
style="background-color: {tag.color || 'hsl(var(--muted))'}; color: white;"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="info-stats">
|
||||
<span>{networkStore.selectedNode.connectionCount} Verbindungen</span>
|
||||
</div>
|
||||
<button class="view-btn" onclick={() => goto(`/event/${networkStore.selectedNode?.id}`)}>
|
||||
Event anzeigen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.network-view {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: hsl(var(--color-background));
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Floating Controls */
|
||||
.controls-wrapper {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 10;
|
||||
max-width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
/* Error Banner */
|
||||
.error-banner {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
border: 1px solid hsl(var(--destructive) / 0.3);
|
||||
border-radius: 0.875rem;
|
||||
color: hsl(var(--destructive));
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* Graph Container - Full size within parent */
|
||||
.graph-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 1rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid hsl(var(--muted));
|
||||
border-top-color: hsl(var(--primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Info Panel */
|
||||
.info-panel {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
width: 320px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
z-index: 50;
|
||||
background: hsl(var(--card) / 0.9);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
animation: slideInRight 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.info-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-header h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.info-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-stats {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.view-btn {
|
||||
margin-top: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.view-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.info-panel {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
max-height: 50%;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
animation: slideInUp 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.controls-wrapper {
|
||||
width: calc(100% - 2rem);
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
<script lang="ts">
|
||||
import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Called when voice recording completes with transcription */
|
||||
onResult?: (text: string) => void;
|
||||
/** Called when recording starts */
|
||||
onRecordingStart?: () => void;
|
||||
/** Size of the button in pixels */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
let { onResult, onRecordingStart, size = 32 }: Props = $props();
|
||||
|
||||
// Reactive state from store
|
||||
let isRecording = $derived(voiceRecordingStore.isRecording);
|
||||
let isProcessing = $derived(voiceRecordingStore.isProcessing);
|
||||
let isSupported = $derived(voiceRecordingStore.isSupported);
|
||||
let hasError = $derived(voiceRecordingStore.hasError);
|
||||
let errorMessage = $derived(voiceRecordingStore.error?.message || '');
|
||||
|
||||
// Handle click
|
||||
async function handleClick() {
|
||||
if (!isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRecording) {
|
||||
// Stop recording
|
||||
await voiceRecordingStore.stopRecording();
|
||||
} else if (!isProcessing) {
|
||||
// Start recording
|
||||
onRecordingStart?.();
|
||||
await voiceRecordingStore.startRecording();
|
||||
}
|
||||
}
|
||||
|
||||
// Set up result callback
|
||||
$effect(() => {
|
||||
voiceRecordingStore.setOnResult((result) => {
|
||||
onResult?.(result.text);
|
||||
});
|
||||
});
|
||||
|
||||
// Clear error after 5 seconds
|
||||
$effect(() => {
|
||||
if (hasError) {
|
||||
const timeout = setTimeout(() => {
|
||||
voiceRecordingStore.clearError();
|
||||
}, 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isSupported}
|
||||
<button
|
||||
type="button"
|
||||
class="voice-btn"
|
||||
class:recording={isRecording}
|
||||
class:processing={isProcessing}
|
||||
class:error={hasError}
|
||||
onclick={handleClick}
|
||||
disabled={isProcessing}
|
||||
title={hasError
|
||||
? errorMessage
|
||||
: isRecording
|
||||
? 'Aufnahme stoppen'
|
||||
: isProcessing
|
||||
? 'Verarbeite...'
|
||||
: 'Spracheingabe'}
|
||||
style="--btn-size: {size}px"
|
||||
>
|
||||
{#if isProcessing}
|
||||
<!-- Spinner -->
|
||||
<div class="spinner"></div>
|
||||
{:else if isRecording}
|
||||
<!-- Pulsing red dot -->
|
||||
<div class="recording-dot"></div>
|
||||
{:else}
|
||||
<!-- Microphone icon -->
|
||||
<svg
|
||||
class="mic-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Error tooltip -->
|
||||
{#if hasError && errorMessage}
|
||||
<div class="error-tooltip" role="alert">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.voice-btn {
|
||||
position: relative;
|
||||
width: var(--btn-size, 32px);
|
||||
height: var(--btn-size, 32px);
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.voice-btn:hover:not(:disabled) {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.voice-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Recording state */
|
||||
.voice-btn.recording {
|
||||
background: hsl(var(--color-error) / 0.1);
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.voice-btn.recording:hover {
|
||||
background: hsl(var(--color-error) / 0.2);
|
||||
}
|
||||
|
||||
/* Processing state */
|
||||
.voice-btn.processing {
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.voice-btn.error {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
/* Microphone icon */
|
||||
.mic-icon {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
/* Recording dot with pulse animation */
|
||||
.recording-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-error));
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid hsl(var(--color-primary) / 0.3);
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Error tooltip */
|
||||
.error-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: hsl(var(--color-error));
|
||||
color: hsl(var(--color-error-foreground, 0 0% 100%));
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px hsl(var(--color-foreground) / 0.15);
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.error-tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
<script lang="ts">
|
||||
import { voiceRecordingStore } from '$lib/stores/voice-recording.svelte';
|
||||
import { fade, scale } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
/** Called when recording completes with transcription */
|
||||
onResult?: (text: string) => void;
|
||||
/** Called when modal is closed (via cancel or completion) */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let { onResult, onClose }: Props = $props();
|
||||
|
||||
// Reactive state from store
|
||||
let isRecording = $derived(voiceRecordingStore.isRecording);
|
||||
let isProcessing = $derived(voiceRecordingStore.isProcessing);
|
||||
let isRequesting = $derived(voiceRecordingStore.state === 'requesting');
|
||||
let hasError = $derived(voiceRecordingStore.hasError);
|
||||
let errorMessage = $derived(voiceRecordingStore.error?.message || '');
|
||||
let formattedDuration = $derived(voiceRecordingStore.formattedDuration);
|
||||
let duration = $derived(voiceRecordingStore.duration);
|
||||
|
||||
// Warning when approaching max duration (50 seconds = 50000ms)
|
||||
let showWarning = $derived(duration >= 50000 && isRecording);
|
||||
|
||||
// Show modal when any voice activity is happening
|
||||
let isVisible = $derived(isRecording || isProcessing || isRequesting || hasError);
|
||||
|
||||
// Handle stop
|
||||
async function handleStop() {
|
||||
await voiceRecordingStore.stopRecording();
|
||||
}
|
||||
|
||||
// Handle cancel
|
||||
function handleCancel() {
|
||||
voiceRecordingStore.cancel();
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
// Set up result callback
|
||||
$effect(() => {
|
||||
voiceRecordingStore.setOnResult((result) => {
|
||||
onResult?.(result.text);
|
||||
onClose?.();
|
||||
});
|
||||
});
|
||||
|
||||
// Handle keyboard
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
handleCancel();
|
||||
} else if (event.key === 'Enter' && isRecording) {
|
||||
handleStop();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isVisible}
|
||||
<!-- Backdrop -->
|
||||
<div class="backdrop" transition:fade={{ duration: 150 }} onclick={handleCancel}></div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div
|
||||
class="modal"
|
||||
transition:scale={{ duration: 150, start: 0.9 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Sprachaufnahme"
|
||||
>
|
||||
{#if isRequesting}
|
||||
<!-- Requesting permission state -->
|
||||
<div class="modal-content">
|
||||
<div class="spinner large"></div>
|
||||
<p class="status-text">Mikrofonzugriff wird angefordert...</p>
|
||||
</div>
|
||||
{:else if isProcessing}
|
||||
<!-- Processing/transcribing state -->
|
||||
<div class="modal-content">
|
||||
<div class="spinner large"></div>
|
||||
<p class="status-text">Verarbeite...</p>
|
||||
</div>
|
||||
{:else if hasError}
|
||||
<!-- Error state -->
|
||||
<div class="modal-content error">
|
||||
<div class="error-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="error-message">{errorMessage}</p>
|
||||
<button class="btn btn-primary" onclick={handleCancel}>Schließen</button>
|
||||
</div>
|
||||
{:else if isRecording}
|
||||
<!-- Recording state -->
|
||||
<div class="modal-content recording">
|
||||
<!-- Recording indicator -->
|
||||
<div class="recording-indicator">
|
||||
<div class="recording-dot-large"></div>
|
||||
<span class="recording-label">Aufnahme</span>
|
||||
</div>
|
||||
|
||||
<!-- Timer -->
|
||||
<div class="timer" class:warning={showWarning}>
|
||||
{formattedDuration}
|
||||
</div>
|
||||
|
||||
{#if showWarning}
|
||||
<p class="warning-text">Max. 60 Sekunden</p>
|
||||
{/if}
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="controls">
|
||||
<button class="btn btn-icon btn-cancel" onclick={handleCancel} title="Abbrechen (Esc)">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-icon btn-stop"
|
||||
onclick={handleStop}
|
||||
title="Aufnahme beenden (Enter)"
|
||||
>
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="hint-text">Sprechen Sie Ihren Termin...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 201;
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: 1.5rem;
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px hsl(var(--color-border));
|
||||
padding: 2rem;
|
||||
min-width: 280px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Recording indicator */
|
||||
.recording-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: hsl(var(--color-error) / 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.recording-dot-large {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-error));
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.recording-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-error));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Timer */
|
||||
.timer {
|
||||
font-size: 3rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: hsl(var(--color-foreground));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timer.warning {
|
||||
color: hsl(var(--color-warning));
|
||||
animation: blink 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-warning));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: hsl(var(--color-muted) / 0.8);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.btn-stop {
|
||||
background: hsl(var(--color-success));
|
||||
color: hsl(var(--color-success-foreground, 0 0% 100%));
|
||||
}
|
||||
|
||||
.btn-stop:hover {
|
||||
transform: scale(1.05);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.modal-content.error {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
|
||||
.error-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
border: 3px solid hsl(var(--color-border));
|
||||
border-top-color: hsl(var(--color-primary));
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.spinner.large {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
141
apps/calendar/apps/web/src/lib/services/stt.ts
Normal file
141
apps/calendar/apps/web/src/lib/services/stt.ts
Normal file
|
|
@ -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<TranscriptionResponse> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${STT_URL}/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000), // 5 second timeout
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<SimulationNode[]>([]);
|
||||
let links = $state<SimulationLink[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedNodeId = $state<string | null>(null);
|
||||
let simulation: Simulation<SimulationNode, SimulationLink> | null = null;
|
||||
let searchQuery = $state('');
|
||||
let filterTagId = $state<string | null>(null);
|
||||
let filterLocation = $state<string | null>(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<string>();
|
||||
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<string, { id: string; name: string; color: string | null }>();
|
||||
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<SimulationNode, SimulationLink>(nodes)
|
||||
.force(
|
||||
'link',
|
||||
forceLink<SimulationNode, SimulationLink>(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<string>();
|
||||
|
||||
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;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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<AppViewMode>(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';
|
||||
}
|
||||
},
|
||||
};
|
||||
243
apps/calendar/apps/web/src/lib/stores/voice-recording.svelte.ts
Normal file
243
apps/calendar/apps/web/src/lib/stores/voice-recording.svelte.ts
Normal file
|
|
@ -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<VoiceRecordingState>('idle');
|
||||
let duration = $state(0);
|
||||
let error = $state<VoiceRecordingError | null>(null);
|
||||
let permissionState = $state<PermissionState>('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> {
|
||||
permissionState = await requestMicrophonePermission();
|
||||
return permissionState;
|
||||
},
|
||||
|
||||
/**
|
||||
* Start voice recording
|
||||
*/
|
||||
async startRecording(): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
272
apps/calendar/apps/web/src/lib/utils/audio-recorder.ts
Normal file
272
apps/calendar/apps/web/src/lib/utils/audio-recorder.ts
Normal file
|
|
@ -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<void>;
|
||||
/** Stop recording and return the audio blob */
|
||||
stop(): Promise<Blob>;
|
||||
/** 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<PermissionState> {
|
||||
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<typeof setInterval> | 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<Blob> {
|
||||
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')}`;
|
||||
}
|
||||
|
|
@ -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<PillTabGroupConfig>({
|
||||
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}
|
||||
|
||||
<!-- Global Input Bar (hidden via CSS in immersive mode to prevent re-mount focus) -->
|
||||
<!-- Global Input Bar with Voice Button (hidden via CSS in immersive mode to prevent re-mount focus) -->
|
||||
<div class="input-bar-wrapper" class:hidden={settingsStore.immersiveModeEnabled}>
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
onSearchChange={handleSearchChange}
|
||||
placeholder="Neuer Termin oder suchen..."
|
||||
emptyText="Keine Termine gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="calendar"
|
||||
bottomOffset={isMobile
|
||||
? `${70 + tagStripOffset}px`
|
||||
: isSidebarMode
|
||||
? `${tagStripOffset}px`
|
||||
: showCalendarToolbar && !isToolbarCollapsed
|
||||
? `${140 + tagStripOffset}px`
|
||||
: `${70 + tagStripOffset}px`}
|
||||
hasFabRight={showCalendarToolbar && !isSidebarMode}
|
||||
hasFabLeft={!isMobile &&
|
||||
showCalendarToolbar &&
|
||||
!isSidebarMode &&
|
||||
settingsStore.dateStripCollapsed}
|
||||
defaultOptions={calendarOptions}
|
||||
selectedDefaultId={selectedDefaultCalendarId}
|
||||
defaultOptionLabel="Standard-Kalender"
|
||||
onDefaultChange={handleDefaultCalendarChange}
|
||||
onShowShortcuts={handleShowShortcuts}
|
||||
onShowSyntaxHelp={handleShowSyntaxHelp}
|
||||
/>
|
||||
<div class="input-bar-row">
|
||||
<QuickInputBar
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleSelect}
|
||||
onSearchChange={handleSearchChange}
|
||||
placeholder="Neuer Termin oder suchen..."
|
||||
emptyText="Keine Termine gefunden"
|
||||
searchingText="Suche..."
|
||||
onCreate={handleCreate}
|
||||
onParseCreate={handleParseCreate}
|
||||
createText="Erstellen"
|
||||
appIcon="calendar"
|
||||
bottomOffset={isMobile
|
||||
? `${70 + tagStripOffset}px`
|
||||
: isSidebarMode
|
||||
? `${tagStripOffset}px`
|
||||
: showCalendarToolbar && !isToolbarCollapsed
|
||||
? `${140 + tagStripOffset}px`
|
||||
: `${70 + tagStripOffset}px`}
|
||||
hasFabRight={showCalendarToolbar && !isSidebarMode}
|
||||
hasFabLeft={!isMobile &&
|
||||
showCalendarToolbar &&
|
||||
!isSidebarMode &&
|
||||
settingsStore.dateStripCollapsed}
|
||||
defaultOptions={calendarOptions}
|
||||
selectedDefaultId={selectedDefaultCalendarId}
|
||||
defaultOptionLabel="Standard-Kalender"
|
||||
onDefaultChange={handleDefaultCalendarChange}
|
||||
onShowShortcuts={handleShowShortcuts}
|
||||
onShowSyntaxHelp={handleShowSyntaxHelp}
|
||||
/>
|
||||
<!-- Voice Record Button -->
|
||||
{#if voiceRecordingStore.isSupported}
|
||||
<div
|
||||
class="voice-button-wrapper"
|
||||
style="--bottom-offset: {isMobile
|
||||
? `${70 + tagStripOffset}px`
|
||||
: isSidebarMode
|
||||
? `${tagStripOffset}px`
|
||||
: showCalendarToolbar && !isToolbarCollapsed
|
||||
? `${140 + tagStripOffset}px`
|
||||
: `${70 + tagStripOffset}px`}"
|
||||
>
|
||||
<VoiceRecordButton onResult={handleVoiceResult} size={40} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice Recording Modal -->
|
||||
<VoiceRecordingModal onResult={handleVoiceResult} />
|
||||
|
||||
<!-- Immersive Mode Toggle (always visible on main calendar page) -->
|
||||
<ImmersiveModeToggle
|
||||
isImmersive={settingsStore.immersiveModeEnabled}
|
||||
|
|
@ -1059,4 +1090,55 @@
|
|||
padding-right: calc(54px + 1rem + 8px); /* FAB width + margin + gap */
|
||||
}
|
||||
}
|
||||
|
||||
/* Voice Button Wrapper */
|
||||
.input-bar-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.voice-button-wrapper {
|
||||
position: fixed;
|
||||
bottom: calc(var(--bottom-offset, 70px) + env(safe-area-inset-bottom, 0px) + 7px);
|
||||
left: 50%;
|
||||
transform: translateX(calc(-50% + 260px)); /* Position to the right of centered InputBar */
|
||||
z-index: 91;
|
||||
background: hsl(var(--color-surface) / 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 50%;
|
||||
padding: 0.25rem;
|
||||
box-shadow:
|
||||
0 4px 6px -1px hsl(var(--color-foreground) / 0.1),
|
||||
0 2px 4px -1px hsl(var(--color-foreground) / 0.06);
|
||||
transition:
|
||||
bottom 0.3s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.voice-button-wrapper:hover {
|
||||
transform: translateX(calc(-50% + 260px)) scale(1.05);
|
||||
}
|
||||
|
||||
/* Adjust voice button position on smaller screens */
|
||||
@media (max-width: 900px) {
|
||||
.voice-button-wrapper {
|
||||
right: calc(1rem + 54px + 8px); /* FAB width + margin + gap from right FAB */
|
||||
left: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.voice-button-wrapper:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: Hide voice button (use modal instead) */
|
||||
@media (max-width: 640px) {
|
||||
.voice-button-wrapper {
|
||||
right: calc(54px + 1rem + 54px + 8px); /* Right FAB + margin + voice btn + gap */
|
||||
left: auto;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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<VoiceEventData>) {
|
||||
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<VoiceEventData>);
|
||||
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 @@
|
|||
<title>{$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if viewModeStore.mode === 'network'}
|
||||
<!-- Network View Mode -->
|
||||
<div class="network-layout">
|
||||
<NetworkView />
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Calendar View Mode -->
|
||||
<div class="calendar-layout">
|
||||
<!-- Desktop: Left Sidebar -->
|
||||
<aside class="calendar-sidebar desktop-only" class:collapsed={settingsStore.sidebarCollapsed}>
|
||||
<!-- Collapse button at top -->
|
||||
<button
|
||||
class="sidebar-collapse-btn"
|
||||
onclick={() => settingsStore.toggleSidebar()}
|
||||
title={$_('calendar.hideSidebar')}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<TodoSidebarSection maxItems={5} />
|
||||
</aside>
|
||||
|
||||
<!-- Main Calendar Area -->
|
||||
<div class="calendar-main" class:expanded={settingsStore.sidebarCollapsed}>
|
||||
<div class="calendar-content">
|
||||
{#if !initialized}
|
||||
<CalendarViewSkeleton />
|
||||
{:else}
|
||||
<ViewCarousel onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Bottom Todo Section -->
|
||||
<aside
|
||||
class="calendar-sidebar-mobile mobile-only"
|
||||
class:collapsed={settingsStore.sidebarCollapsed}
|
||||
<div class="calendar-layout">
|
||||
<!-- Desktop: Left Sidebar -->
|
||||
<aside class="calendar-sidebar desktop-only" class:collapsed={settingsStore.sidebarCollapsed}>
|
||||
<!-- Collapse button at top -->
|
||||
<button
|
||||
class="sidebar-collapse-btn"
|
||||
onclick={() => settingsStore.toggleSidebar()}
|
||||
title={$_('calendar.hideSidebar')}
|
||||
>
|
||||
<TodoSidebarSection maxItems={3} />
|
||||
</aside>
|
||||
|
||||
<!-- Quick Event Overlay (for both create and edit) -->
|
||||
{#if showQuickOverlay}
|
||||
{#key overlayKey}
|
||||
<QuickEventOverlay
|
||||
startTime={editingEvent ? undefined : quickCreateDate}
|
||||
event={editingEvent ?? undefined}
|
||||
onClose={handleQuickOverlayClose}
|
||||
onCreated={handleEventCreated}
|
||||
onUpdated={handleEventUpdated}
|
||||
onDeleted={handleEventDeleted}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<TodoSidebarSection maxItems={5} />
|
||||
</aside>
|
||||
|
||||
<!-- Main Calendar Area -->
|
||||
<div class="calendar-main" class:expanded={settingsStore.sidebarCollapsed}>
|
||||
<div class="calendar-content">
|
||||
{#if !initialized}
|
||||
<CalendarViewSkeleton />
|
||||
{:else}
|
||||
<ViewCarousel onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile: Bottom Todo Section -->
|
||||
<aside
|
||||
class="calendar-sidebar-mobile mobile-only"
|
||||
class:collapsed={settingsStore.sidebarCollapsed}
|
||||
>
|
||||
<TodoSidebarSection maxItems={3} />
|
||||
</aside>
|
||||
|
||||
<!-- Quick Event Overlay (for both create and edit) -->
|
||||
{#if showQuickOverlay}
|
||||
{#key overlayKey}
|
||||
<QuickEventOverlay
|
||||
startTime={editingEvent ? undefined : quickCreateDate}
|
||||
event={editingEvent ?? undefined}
|
||||
onClose={handleQuickOverlayClose}
|
||||
onCreated={handleEventCreated}
|
||||
onUpdated={handleEventUpdated}
|
||||
onDeleted={handleEventDeleted}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Network Layout - Full height without sidebar */
|
||||
.network-layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.calendar-layout {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
|
|
|
|||
223
apps/calendar/docs/CLEANUP_PLAN.md
Normal file
223
apps/calendar/docs/CLEANUP_PLAN.md
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
# Calendar App - Cleanup Plan
|
||||
|
||||
Dieser Plan dokumentiert Features und Code, die überdurchschnittlich viel Komplexität erzeugen bei geringem Nutzen. Ziel ist eine schlankere, wartbarere Codebase.
|
||||
|
||||
## Status-Legende
|
||||
|
||||
- ✅ Erledigt
|
||||
- 🔄 In Bearbeitung
|
||||
- ⏳ Geplant
|
||||
- ❌ Abgelehnt
|
||||
|
||||
---
|
||||
|
||||
## Erledigte Aufräumarbeiten
|
||||
|
||||
### ✅ Statistiken & Heatmap (2024-01-28)
|
||||
|
||||
**Commit:** `2f3473b7`
|
||||
|
||||
**Entfernte Dateien:**
|
||||
- `src/lib/stores/statistics.svelte.ts` (270 Zeilen)
|
||||
- `src/lib/stores/heatmap.svelte.ts` (190 Zeilen)
|
||||
- `src/lib/components/calendar/StatsSidebarSection.svelte` (434 Zeilen)
|
||||
- `src/lib/components/calendar/StatsOverlay.svelte` (257 Zeilen)
|
||||
|
||||
**Geänderte Dateien:**
|
||||
- Heatmap-CSS aus allen View-Komponenten entfernt
|
||||
- Nav-Item "Statistiken" entfernt
|
||||
- Toolbar Heatmap-Toggle entfernt
|
||||
|
||||
**Ersparnis:** ~1.450 Zeilen
|
||||
|
||||
---
|
||||
|
||||
## Geplante Aufräumarbeiten
|
||||
|
||||
### Priorität 1: Quick Wins (Hoher ROI)
|
||||
|
||||
#### ⏳ 1.1 Network View entfernen
|
||||
|
||||
**Status:** Geplant
|
||||
**Geschätzte Ersparnis:** ~800 Zeilen
|
||||
**Komplexität:** HOCH | **Nutzen:** NIEDRIG
|
||||
|
||||
**Beschreibung:**
|
||||
D3 Force-Simulation für Event-Graph-Visualisierung. Kalendereinträge sind keine natürliche Graph-Struktur. Nutzer navigieren nach Datum, nicht nach "Event-Beziehungen".
|
||||
|
||||
**Zu entfernende Dateien:**
|
||||
- `src/lib/components/calendar/NetworkView.svelte` (~416 Zeilen)
|
||||
- `src/lib/stores/network.svelte.ts` (~372 Zeilen)
|
||||
|
||||
**Zu ändernde Dateien:**
|
||||
- `src/lib/stores/view-mode.svelte.ts` - Network-Mode entfernen
|
||||
- `src/routes/(app)/+page.svelte` - Network-View Conditional entfernen
|
||||
- `src/routes/(app)/+layout.svelte` - Network-Tab aus ViewSwitcher entfernen
|
||||
|
||||
---
|
||||
|
||||
#### ⏳ 1.2 Session Events (Guest Mode) entfernen
|
||||
|
||||
**Status:** Geplant
|
||||
**Geschätzte Ersparnis:** ~150 Zeilen
|
||||
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
|
||||
|
||||
**Beschreibung:**
|
||||
Event-Management für unauthentifizierte Nutzer via sessionStorage. Events verschwinden bei Tab-Schließung - frustrierendes UX. Vereinfacht den events-Store erheblich.
|
||||
|
||||
**Zu entfernende Dateien:**
|
||||
- `src/lib/stores/session-events.svelte.ts` (~154 Zeilen)
|
||||
|
||||
**Zu ändernde Dateien:**
|
||||
- `src/lib/stores/events.svelte.ts` - Session-Event-Logik entfernen
|
||||
- `src/routes/(app)/+layout.svelte` - Guest-Banner/Modal anpassen
|
||||
- `src/lib/api/events.ts` - Session-Fallback entfernen
|
||||
|
||||
---
|
||||
|
||||
#### ⏳ 1.3 Event Parser (NLP) entfernen
|
||||
|
||||
**Status:** Geplant
|
||||
**Geschätzte Ersparnis:** ~260 Zeilen
|
||||
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
|
||||
|
||||
**Beschreibung:**
|
||||
Natural Language Parsing für Termineinträge (nur Deutsch). Regex-basiert und fehleranfällig. Die meisten Nutzer verwenden strukturierte Formulare.
|
||||
|
||||
**Zu entfernende Dateien:**
|
||||
- `src/lib/utils/event-parser.ts` (~261 Zeilen)
|
||||
|
||||
**Zu ändernde Dateien:**
|
||||
- `src/routes/(app)/+layout.svelte` - QuickInputBar onCreate/onParseCreate entfernen
|
||||
- QuickInputBar-Integration vereinfachen (nur Suche, kein Quick-Create)
|
||||
|
||||
---
|
||||
|
||||
### Priorität 2: Mittlerer Aufwand
|
||||
|
||||
#### ⏳ 2.1 Swipe Navigation entfernen
|
||||
|
||||
**Status:** Geplant
|
||||
**Geschätzte Ersparnis:** ~180 Zeilen
|
||||
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
|
||||
|
||||
**Beschreibung:**
|
||||
Trackpad-/Touch-Swipe für horizontale Kalendernavigation. Pfeiltasten und Buttons reichen völlig aus.
|
||||
|
||||
**Zu entfernende Dateien:**
|
||||
- `src/lib/composables/useSwipeNavigation.svelte.ts` (~183 Zeilen)
|
||||
|
||||
**Zu ändernde Dateien:**
|
||||
- `src/lib/components/calendar/ViewCarousel.svelte` - Swipe-Integration entfernen
|
||||
|
||||
---
|
||||
|
||||
#### ⏳ 2.2 Context Menus entfernen
|
||||
|
||||
**Status:** Geplant
|
||||
**Geschätzte Ersparnis:** ~400 Zeilen
|
||||
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
|
||||
|
||||
**Beschreibung:**
|
||||
4+ verschiedene Context-Menus mit duplizierten Aktionen. Mobile unterstützt keine Context-Menus. Aktionen besser in sichtbare Buttons verschieben.
|
||||
|
||||
**Zu entfernende Dateien:**
|
||||
- `src/lib/components/event/EventContextMenu.svelte`
|
||||
- `src/lib/components/calendar/CalendarHeaderContextMenu.svelte`
|
||||
- `src/lib/components/calendar/DateStripContextMenu.svelte`
|
||||
- `src/lib/components/calendar/ViewModePillContextMenu.svelte`
|
||||
- `src/lib/stores/eventContextMenu.svelte.ts`
|
||||
|
||||
---
|
||||
|
||||
#### ⏳ 2.3 Settings vereinfachen
|
||||
|
||||
**Status:** Geplant
|
||||
**Geschätzte Ersparnis:** ~200 Zeilen
|
||||
**Komplexität:** MITTEL | **Nutzen:** NIEDRIG
|
||||
|
||||
**Beschreibung:**
|
||||
Aktuell ~42 Einstellungen - die meisten Nutzer verwenden Defaults. Reduzieren auf ~8 Kern-Einstellungen.
|
||||
|
||||
**Zu entfernende Settings:**
|
||||
- Mondphasen-Anzeige
|
||||
- DateStrip-Varianten (compact, eventIndicators, etc.)
|
||||
- Header-Format-Optionen
|
||||
- Zeitfilter-Optionen
|
||||
|
||||
---
|
||||
|
||||
### Priorität 3: Größere Refactorings
|
||||
|
||||
#### ⏳ 3.1 Calendar Views reduzieren (7 → 3)
|
||||
|
||||
**Status:** Geplant
|
||||
**Geschätzte Ersparnis:** ~1.500 Zeilen
|
||||
**Komplexität:** HOCH | **Nutzen:** HOCH (Vereinfachung)
|
||||
|
||||
**Beschreibung:**
|
||||
7 verschiedene View-Typen sind zu viel. Die meisten Nutzer brauchen nur Week, Month, Agenda.
|
||||
|
||||
**Behalten:**
|
||||
- `WeekView.svelte` (Standard)
|
||||
- `MonthView.svelte`
|
||||
- `AgendaView.svelte`
|
||||
|
||||
**Entfernen:**
|
||||
- `YearView.svelte` (~420 Zeilen)
|
||||
- `DayView.svelte` (~1.104 Zeilen) - Week-View für einzelne Tage nutzen
|
||||
- `MultiDayView.svelte` (~1.594 Zeilen) - Week-View mit variablem dayCount
|
||||
|
||||
---
|
||||
|
||||
#### ⏳ 3.2 Tag-System vereinfachen
|
||||
|
||||
**Status:** Geplant
|
||||
**Geschätzte Ersparnis:** ~1.600 Zeilen
|
||||
**Komplexität:** HOCH | **Nutzen:** MITTEL
|
||||
|
||||
**Beschreibung:**
|
||||
Tag-Gruppen-Hierarchie entfernen → nur flache Tags. Drag-Drop-Sortierung entfernen → alphabetisch sortieren.
|
||||
|
||||
**Zu vereinfachende Dateien:**
|
||||
- `src/lib/components/calendar/TagStripModal.svelte` (1.463 Zeilen → ~300 Zeilen)
|
||||
- `src/lib/stores/event-tag-groups.svelte.ts` (entfernen)
|
||||
- `src/lib/stores/event-tags.svelte.ts` (vereinfachen)
|
||||
|
||||
---
|
||||
|
||||
#### ⏳ 3.3 Birthday-Integration vereinfachen
|
||||
|
||||
**Status:** Geplant
|
||||
**Geschätzte Ersparnis:** ~350 Zeilen
|
||||
**Komplexität:** MITTEL | **Nutzen:** MITTEL-NIEDRIG
|
||||
|
||||
**Beschreibung:**
|
||||
Cross-App API-Integration für Geburtstage. Ersetzbar durch manuelles Eintragen oder einfachen Import.
|
||||
|
||||
**Zu entfernende/vereinfachende Dateien:**
|
||||
- `src/lib/stores/birthdays.svelte.ts` (~220 Zeilen)
|
||||
- `src/lib/api/birthdays.ts` (~101 Zeilen)
|
||||
- `src/lib/components/birthday/BirthdayPopover.svelte`
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Phase | Features | LOC Ersparnis | Status |
|
||||
|-------|----------|---------------|--------|
|
||||
| ✅ Done | Statistiken/Heatmap | ~1.450 | Erledigt |
|
||||
| 🟢 Prio 1 | Network, Sessions, Parser | ~1.200 | Geplant |
|
||||
| 🟡 Prio 2 | Swipe, Context, Settings | ~780 | Geplant |
|
||||
| 🔴 Prio 3 | Views, Tags, Birthdays | ~3.450 | Geplant |
|
||||
| **Gesamt** | | **~6.880** | |
|
||||
|
||||
**Ziel:** ~30% Code-Reduktion bei gleichem/besserem Nutzererlebnis
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
| Datum | Aktion | Commit |
|
||||
|-------|--------|--------|
|
||||
| 2024-01-28 | Statistiken & Heatmap entfernt | `2f3473b7` |
|
||||
Loading…
Add table
Add a link
Reference in a new issue