🔧 fix(mac-mini): update health checks and disable missing services

- Disable api-gateway and skilltree-web (no working images/Dockerfiles)
- Fix mana-search Dockerfile healthcheck port and endpoint
- Update health-check.sh to skip disabled services
- Fix search service health endpoint (/api/v1/health)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-12 13:28:55 +01:00
parent 177e4eea88
commit d5e18c9c27
14 changed files with 3702 additions and 10 deletions

View file

@ -0,0 +1,487 @@
# Implementierungsplan: MacBook Pro M1 Max als zweiter Server
## Übersicht
**Ziel:** MacBook Pro M1 Max (64GB RAM) als AI/ML-Server einrichten, der parallel zum Mac Mini läuft.
**Zeitschätzung:** 4-6 Stunden für komplette Implementierung
---
## Phase 1: Hardware-Vorbereitung (30 Min)
### 1.1 MacBook Pro physisch vorbereiten
- [ ] Daten sichern (falls noch nicht geschehen)
- [ ] Vertikalen Laptop-Ständer besorgen (~25€)
- [ ] USB-C zu Ethernet Adapter besorgen (~30€)
- [ ] Stromleiste mit Überspannungsschutz (~30€)
- [ ] Position neben Mac Mini festlegen
### 1.2 Netzwerk-Konfiguration planen
```
Mac Mini: 192.168.x.10 (bestehend)
MacBook Pro: 192.168.x.11 (neu)
```
---
## Phase 2: macOS Setup auf MacBook Pro (1-2 Std)
### 2.1 Optionaler Clean Install
```bash
# Falls gewünscht: macOS neu installieren
# Recovery Mode: Cmd+R beim Start
# Festplattendienstprogramm → Löschen → APFS
# macOS neu installieren
```
### 2.2 Grundlegende Konfiguration
```bash
# Systemeinstellungen
# 1. Computername setzen
sudo scutil --set ComputerName "mana-server-ai"
sudo scutil --set HostName "mana-server-ai"
sudo scutil --set LocalHostName "mana-server-ai"
# 2. SSH aktivieren
# System Settings → General → Sharing → Remote Login → ON
# 3. Clamshell-Modus ermöglichen
# System Settings → Battery → Power Adapter:
# - "Prevent automatic sleeping when the display is off" → ON
# - "Wake for network access" → ON
# 4. Auto-Login (optional, für Server-Betrieb)
# System Settings → Users & Groups → Automatic Login
# 5. Autostart nach Stromausfall
sudo systemsetup -setrestartfreeze on
sudo systemsetup -setrestartpowerfailure on
```
### 2.3 Statische IP konfigurieren
```bash
# System Settings → Network → Ethernet → Details → TCP/IP
# Configure IPv4: Manually
# IP Address: 192.168.x.11
# Subnet Mask: 255.255.255.0
# Router: 192.168.x.1
# DNS: 1.1.1.1, 8.8.8.8
```
### 2.4 Development Tools installieren
```bash
# Xcode Command Line Tools
xcode-select --install
# Homebrew
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Essentials
brew install git node pnpm python@3.11 cloudflared
# Docker Desktop
brew install --cask docker
# Nach Installation: Docker Desktop öffnen und starten
```
---
## Phase 3: Externe SSD einrichten (30 Min)
### 3.1 Verzeichnisstruktur erstellen
```bash
# SSD mounten (falls nicht automatisch)
# Erwarteter Mount-Punkt: /Volumes/ManaData-AI oder ähnlich
# Verzeichnisse erstellen
sudo mkdir -p /Volumes/ManaData-AI/{
ollama,
flux2,
stt-models,
tts-models,
postgres-replica,
backups
}
# Berechtigungen setzen
sudo chown -R $(whoami):staff /Volumes/ManaData-AI
```
### 3.2 Symlinks einrichten
```bash
# Ollama Modelle
ln -sf /Volumes/ManaData-AI/ollama ~/.ollama
# STT Modelle
ln -sf /Volumes/ManaData-AI/stt-models ~/stt-models
# TTS Modelle
ln -sf /Volumes/ManaData-AI/tts-models ~/tts-models
# FLUX.2 Modelle
ln -sf /Volumes/ManaData-AI/flux2 ~/flux2
```
---
## Phase 4: Cloudflare Tunnel einrichten (30 Min)
### 4.1 Neuen Tunnel erstellen
```bash
# Bei Cloudflare anmelden
cloudflared tunnel login
# Neuen Tunnel für MacBook Pro erstellen
cloudflared tunnel create mana-server-ai
# Tunnel-ID notieren (z.B. abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
export TUNNEL_ID="<tunnel-id-hier>"
# DNS-Routen erstellen
cloudflared tunnel route dns mana-server-ai mbp.mana.how
cloudflared tunnel route dns mana-server-ai llm.mana.how
cloudflared tunnel route dns mana-server-ai tts-v2.mana.how
cloudflared tunnel route dns mana-server-ai stt-v2.mana.how
cloudflared tunnel route dns mana-server-ai img.mana.how
```
### 4.2 Dateien zu erstellen
**Datei:** `cloudflared-config.macbookpro.yml`
```yaml
tunnel: <TUNNEL_ID>
credentials-file: /Users/mana/.cloudflared/<TUNNEL_ID>.json
ingress:
# SSH Access
- hostname: mbp.mana.how
service: ssh://localhost:22
# LLM Service (mana-llm mit Ollama)
- hostname: llm.mana.how
service: http://localhost:3025
originRequest:
connectTimeout: 300s
# TTS Service (Kokoro + F5)
- hostname: tts-v2.mana.how
service: http://localhost:3022
# STT Service (Whisper Large)
- hostname: stt-v2.mana.how
service: http://localhost:3021
# Image Generation (FLUX.2)
- hostname: img.mana.how
service: http://localhost:3023
# Catch-all
- service: http_status:404
```
---
## Phase 5: AI/ML Services installieren (2 Std)
### 5.1 Ollama mit großen Modellen
```bash
# Ollama installieren
brew install ollama
# Service starten
brew services start ollama
# Große Modelle laden (dauert je nach Verbindung)
ollama pull gemma3:27b # 16 GB - Hauptmodell
ollama pull llama3.1:70b # ~40 GB 4-bit quant (optional)
ollama pull codestral:22b # ~14 GB - Code
ollama pull deepseek-coder:33b # ~20 GB - Code (optional)
# Existierende kleinere Modelle auch laden für Kompatibilität
ollama pull gemma3:4b
ollama pull gemma3:12b
```
### 5.2 mana-tts mit F5-TTS
```bash
# Python Virtual Environment
python3.11 -m venv ~/venvs/mana-tts
source ~/venvs/mana-tts/bin/activate
# TTS Dependencies (inkl. F5-TTS für Voice Cloning)
pip install kokoro-onnx f5-tts torch torchaudio
pip install fastapi uvicorn python-multipart
# Modelle herunterladen
# (Details in services/mana-tts/setup.py oder setup-tts.sh)
```
**LaunchAgent erstellen:** `com.manacore.mana-tts.plist`
### 5.3 mana-stt mit Whisper Large
```bash
# Python Virtual Environment
python3.11 -m venv ~/venvs/mana-stt
source ~/venvs/mana-stt/bin/activate
# Whisper installieren
pip install openai-whisper faster-whisper
pip install fastapi uvicorn python-multipart
# Large-v3 Modell herunterladen (wird automatisch geladen)
# ~3 GB Download
```
**LaunchAgent erstellen:** `com.manacore.mana-stt.plist`
### 5.4 mana-image-gen mit FLUX.2
```bash
# Bestehende Setup-Skript verwenden (angepasst)
./scripts/mac-mini/setup-image-gen.sh
# Oder manuell:
cd ~/
git clone https://github.com/city96/flux2.c
cd flux2.c
make MPS=1 # Apple Metal Support
# Modell herunterladen (~16 GB)
# Details in services/mana-image-gen/
```
**LaunchAgent erstellen:** `com.manacore.image-gen.plist`
---
## Phase 6: Docker Services (Optional, für Replicas) (1 Std)
### 6.1 docker-compose.macbookpro.yml erstellen
Nur für:
- PostgreSQL Replica (Hot Standby)
- Redis Replica
- mana-llm Container
- Backup Worker
### 6.2 PostgreSQL Streaming Replication
**Auf Mac Mini (Primary):**
```bash
# postgresql.conf anpassen
wal_level = replica
max_wal_senders = 3
wal_keep_size = 64MB
# pg_hba.conf anpassen
host replication replicator 192.168.x.11/32 md5
```
**Auf MacBook Pro (Replica):**
```bash
# Base Backup vom Primary
pg_basebackup -h 192.168.x.10 -U replicator -D /Volumes/ManaData-AI/postgres-replica -P
# standby.signal erstellen
touch /Volumes/ManaData-AI/postgres-replica/standby.signal
# postgresql.auto.conf
primary_conninfo = 'host=192.168.x.10 port=5432 user=replicator password=xxx'
```
---
## Phase 7: Autostart & Health Checks (30 Min)
### 7.1 Scripts zu erstellen
```
scripts/macbook-pro/
├── setup-autostart.sh # LaunchAgents einrichten
├── startup.sh # Boot-Startup
├── health-check.sh # Service-Monitoring
├── status.sh # Übersicht
├── restart.sh # Services neustarten
└── stop.sh # Services stoppen
```
### 7.2 LaunchAgents zu erstellen
```
~/Library/LaunchAgents/
├── com.cloudflare.cloudflared.plist # Tunnel
├── com.manacore.mana-tts.plist # TTS Service
├── com.manacore.mana-stt.plist # STT Service
├── com.manacore.image-gen.plist # Image Gen
├── com.manacore.health-check.plist # Health Checks
└── homebrew.mxcl.ollama.plist # Ollama (auto von brew)
```
---
## Phase 8: Dokumentation & Testing (30 Min)
### 8.1 Dokumentation aktualisieren
**Dateien zu erstellen/aktualisieren:**
- `docs/MACBOOK_PRO_SERVER.md` - Neue Dokumentation
- `docs/MAC_MINI_SERVER.md` - Verweise auf MBP hinzufügen
- `docs/TWO_SERVER_ARCHITECTURE.md` - Architektur-Übersicht
- `CLAUDE.md` - SSH-Config für mbp hinzufügen
### 8.2 SSH-Config erweitern
```
# ~/.ssh/config
Host mana-server
HostName mac-mini.mana.how
User till
ProxyCommand /opt/homebrew/bin/cloudflared access ssh --hostname %h
Host mana-server-ai
HostName mbp.mana.how
User till
ProxyCommand /opt/homebrew/bin/cloudflared access ssh --hostname %h
```
### 8.3 Testing Checklist
- [ ] SSH zu MacBook Pro funktioniert: `ssh mana-server-ai`
- [ ] Ollama API erreichbar: `curl http://192.168.x.11:11434/api/version`
- [ ] TTS Service: `curl http://192.168.x.11:3022/health`
- [ ] STT Service: `curl http://192.168.x.11:3021/health`
- [ ] Image Gen: `curl http://192.168.x.11:3023/health`
- [ ] LLM Service: `curl https://llm.mana.how/health`
- [ ] PostgreSQL Replica synchronisiert
- [ ] Health Checks laufen alle 5 Min
- [ ] Notifications bei Fehlern
---
## Dateien die erstellt werden müssen
### Neue Dateien
| Datei | Beschreibung |
|-------|--------------|
| `docker-compose.macbookpro.yml` | Docker Compose für MBP (Replicas, mana-llm) |
| `cloudflared-config.macbookpro.yml` | Cloudflare Tunnel Config |
| `.env.macbookpro` | Environment Variables |
| `scripts/macbook-pro/setup-autostart.sh` | LaunchAgent Setup |
| `scripts/macbook-pro/startup.sh` | Boot Startup Script |
| `scripts/macbook-pro/health-check.sh` | Health Monitoring |
| `scripts/macbook-pro/status.sh` | Service Status |
| `scripts/macbook-pro/restart.sh` | Restart Services |
| `scripts/macbook-pro/stop.sh` | Stop Services |
| `scripts/macbook-pro/setup-ollama.sh` | Ollama Setup mit großen Modellen |
| `scripts/macbook-pro/setup-tts.sh` | TTS Setup mit F5 |
| `scripts/macbook-pro/setup-stt.sh` | STT Setup mit Whisper Large |
| `scripts/macbook-pro/backup-worker.sh` | Backup vom Mac Mini |
| `docker/postgres/replica-setup.sh` | PostgreSQL Replica Init |
| `docs/MACBOOK_PRO_SERVER.md` | Server Dokumentation |
| `docs/TWO_SERVER_ARCHITECTURE.md` | Architektur Übersicht |
### Zu aktualisierende Dateien
| Datei | Änderung |
|-------|----------|
| `CLAUDE.md` | SSH-Config für MBP |
| `docs/MAC_MINI_SERVER.md` | Verweise auf MBP |
| `.env.development` | MBP-spezifische Vars |
---
## Architektur nach Implementierung
```
┌─────────────────────────────────────┐
│ Cloudflare Tunnel │
│ *.mana.how → Mac Mini (Primary) │
│ llm/tts-v2/stt-v2/img.mana.how │
│ → MacBook Pro (AI/ML) │
└───────────────┬─────────────────────┘
┌─────────────────────┴─────────────────────┐
│ │
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ MAC MINI M4 (16GB) │ │ MACBOOK PRO M1 MAX (64GB) │
│ "Docker Orchestrator" │ │ "AI/ML Powerhouse" │
│ 192.168.x.10 │ │ 192.168.x.11 │
├─────────────────────────────┤ ├─────────────────────────────┤
│ │ │ │
│ PostgreSQL PRIMARY ─────────┼────────▶│ PostgreSQL REPLICA │
│ Redis PRIMARY ──────────────┼────────▶│ Redis REPLICA │
│ MinIO S3 │ │ │
│ │ │ Ollama (27B, 70B Modelle) │
│ mana-core-auth (Primary) │ │ mana-llm (large models) │
│ API Gateway │ │ │
│ mana-search + SearXNG │ │ mana-tts (Kokoro + F5) │
│ mana-media │ │ mana-stt (Whisper Large) │
│ │ │ mana-image-gen (2048x2048) │
│ Alle NestJS Backends │ │ │
│ Alle SvelteKit Frontends │ │ Backup-Worker │
│ Matrix Synapse + Bots │ │ │
│ Monitoring Stack │ │ │
│ n8n, Umami │ │ │
└─────────────────────────────┘ └─────────────────────────────┘
ssh.mana.how mbp.mana.how
```
---
## Risiken & Mitigationen
| Risiko | Mitigation |
|--------|------------|
| MacBook-Akku bläht sich auf | Monatliche visuelle Prüfung; Al Dente App für Ladelimit bei 80% |
| Clamshell Überhitzung | Vertikaler Ständer für Konvektion; Monitoring der Temperatur |
| Replication Lag | Monitoring in Grafana; Alerts bei > 1 Minute Lag |
| Komplexität | Gute Dokumentation; Health Checks mit Alerts |
| macOS Update bricht Services | Auto-Updates deaktivieren; manuelles Update nach Testing |
---
## Implementierungs-Reihenfolge
1. **Hardware vorbereiten** (Phase 1)
2. **macOS konfigurieren** (Phase 2)
3. **Externe SSD einrichten** (Phase 3)
4. **Cloudflare Tunnel** (Phase 4)
5. **Ollama + große Modelle** (Phase 5.1)
6. **mana-tts migrieren** (Phase 5.2)
7. **mana-stt migrieren** (Phase 5.3)
8. **mana-image-gen migrieren** (Phase 5.4)
9. **Autostart einrichten** (Phase 7)
10. **Testing** (Phase 8)
11. **PostgreSQL Replication** (Phase 6) - Optional, später
12. **Auth Redundanz** - Optional, später
---
## Nächste Schritte
Wenn du bereit bist zu implementieren, sag mir welche Phase wir zuerst angehen sollen. Ich kann dann:
1. Die entsprechenden Scripts erstellen
2. Die Config-Dateien generieren
3. Schritt-für-Schritt Anleitung geben
**Empfehlung:** Starte mit Phase 4 (Cloudflare Tunnel) und Phase 5.1 (Ollama), da diese den größten unmittelbaren Nutzen bringen.

View file

@ -0,0 +1,635 @@
<script lang="ts">
import { setContext, onMount } from 'svelte';
import { unifiedBarStore } from '$lib/stores/unified-bar.svelte';
import { createEventDispatcher } from 'svelte';
import { quintOut } from 'svelte/easing';
import { fly, slide } from 'svelte/transition';
// Components
// import QuickInputBar from '@manacore/shared-ui/components/QuickInputBar.svelte';
import DateStrip from './DateStrip.svelte';
import TagStrip from './TagStrip.svelte';
import CalendarToolbar from './CalendarToolbar.svelte';
import DateStripFab from './DateStripFab.svelte';
// Props
interface Props {
// QuickInputBar props
onSearch?: (query: string) => void;
onSelect?: (result: any) => void;
onSearchChange?: (query: string) => void;
onCreate?: (data: any) => void;
onParseCreate?: (data: any) => void;
placeholder?: string;
emptyText?: string;
searchingText?: string;
createText?: string;
appIcon?: string;
bottomOffset?: string;
hasFabRight?: boolean;
hasFabLeft?: boolean;
defaultOptions?: any[];
// DateStrip props
selectedDate?: Date;
onDateSelect?: (date: Date) => void;
// Responsive
isMobile?: boolean;
// Calendar toolbar visibility
showCalendarToolbar?: boolean;
onToolbarCollapsedChange?: (collapsed: boolean) => void;
}
let {
// QuickInputBar props
onSearch = () => {},
onSelect = () => {},
onSearchChange = () => {},
onCreate = () => {},
onParseCreate = () => {},
placeholder = 'Neuer Termin oder suchen...',
emptyText = 'Keine Termine gefunden',
searchingText = 'Suche...',
createText = 'Erstellen',
appIcon = 'calendar',
bottomOffset = '70px',
hasFabRight = false,
hasFabLeft = false,
defaultOptions = [],
// DateStrip props
onDateSelect = () => {},
// Responsive
isMobile = false,
// Calendar toolbar
showCalendarToolbar = false,
onToolbarCollapsedChange = () => {},
}: Props = $props();
const dispatch = createEventDispatcher();
// Local state for transitions
let isTransitioning = $state(false);
let previousMode = $state(unifiedBarStore.mode);
// Computed values
let layerZIndices = $derived({
input: 80,
date: 85,
tag: 90,
toolbar: 95,
overlay: 110,
});
let activeLayers = $derived(() => {
const layers = [];
if (unifiedBarStore.showQuickInput) layers.push('input');
if (unifiedBarStore.showDateStrip) layers.push('date');
if (unifiedBarStore.showTagStrip) layers.push('tag');
if (unifiedBarStore.showCalendarToolbar) layers.push('toolbar');
return layers;
});
// Simplified bottom offsets (using fixed values for now)
let layerBottomOffsets = $derived({
input: '0px',
date: '70px',
tag: '140px',
toolbar: '280px',
});
// Handle mode transitions
$effect(() => {
const currentMode = unifiedBarStore.mode;
if (currentMode !== previousMode) {
isTransitioning = true;
setTimeout(() => {
isTransitioning = false;
}, unifiedBarStore.settings.barAnimationDuration);
previousMode = currentMode;
}
});
// Overlay menu handlers
function handleOverlayToggle() {
unifiedBarStore.toggleOverlay();
dispatch('overlayToggle', { isOpen: unifiedBarStore.isOverlayOpen });
}
function handleOverlayAction(action: string) {
dispatch('overlayAction', { action });
// Handle common actions
switch (action) {
case 'toggle-date-strip':
unifiedBarStore.toggleDateStrip();
break;
case 'toggle-tag-strip':
unifiedBarStore.toggleTagStrip();
break;
case 'toggle-toolbar':
unifiedBarStore.toggleCalendarToolbar();
break;
case 'collapse-all':
unifiedBarStore.collapseAll();
break;
}
// Close overlay after action
unifiedBarStore.set('overlayMenuOpen', false);
}
// Layer activation
function handleLayerClick(layer: string) {
unifiedBarStore.setActiveLayer(layer as any);
if (unifiedBarStore.mode === 'collapsed') {
unifiedBarStore.setMode('expanded');
}
}
// Context for child components
onMount(() => {
setContext('unifiedBarStore', unifiedBarStore);
setContext('isMobile', isMobile);
});
// Transition configurations
const slideConfig = {
duration: unifiedBarStore.settings.barAnimationDuration,
easing: quintOut,
y: 20,
};
const flyConfig = {
duration: unifiedBarStore.settings.barAnimationDuration,
easing: quintOut,
opacity: 0.8,
};
</script>
<!-- UnifiedBar Container -->
<div
class="unified-bar-container"
class:transitioning={isTransitioning}
style="--animation-duration: {unifiedBarStore.settings.barAnimationDuration}ms;"
>
<!-- Layer 3: CalendarToolbar -->
{#if unifiedBarStore.showCalendarToolbar}
<div
class="unified-bar-layer toolbar-layer"
style="z-index: {layerZIndices.toolbar}; bottom: {layerBottomOffsets.toolbar};"
class:active={unifiedBarStore.activeLayer === 'toolbar'}
transition:fly={{ ...flyConfig, y: 100 }}
role="toolbar"
aria-label="Calendar toolbar"
onclick={() => handleLayerClick('toolbar')}
>
<!-- CalendarToolbar placeholder -->
<div class="toolbar-placeholder">CalendarToolbar Component</div>
</div>
{/if}
<!-- Layer 2: TagStrip -->
{#if unifiedBarStore.showTagStrip}
<div
class="unified-bar-layer tag-strip-layer"
style="z-index: {layerZIndices.tag}; bottom: {layerBottomOffsets.tag};"
class:active={unifiedBarStore.activeLayer === 'tag'}
transition:fly={{ ...flyConfig, y: 50 }}
role="toolbar"
aria-label="Tag filter bar"
onclick={() => handleLayerClick('tag')}
>
<!-- TagStrip placeholder -->
<div class="tag-strip-placeholder">TagStrip Component</div>
</div>
{/if}
<!-- Layer 1: DateStrip -->
{#if unifiedBarStore.showDateStrip}
<div
class="unified-bar-layer date-strip-layer"
style="z-index: {layerZIndices.date}; bottom: {layerBottomOffsets.date};"
class:active={unifiedBarStore.activeLayer === 'date'}
transition:fly={{ ...flyConfig, y: 30 }}
role="toolbar"
aria-label="Date strip"
onclick={() => handleLayerClick('date')}
>
<!-- DateStrip placeholder -->
<div class="date-strip-placeholder">DateStrip Component</div>
</div>
{/if}
<!-- Layer 1: DateStrip -->
{#if unifiedBarStore.showDateStrip && !unifiedBarStore.legacyDateStripCollapsed}
<div
class="unified-bar-layer date-strip-layer"
style="z-index: {layerZIndices.date}; bottom: 70px;"
class:active={unifiedBarStore.activeLayer === 'date'}
transition:fly={{ ...flyConfig, y: 30 }}
role="toolbar"
aria-label="Date strip"
onclick={() => handleLayerClick('date')}
>
<!-- DateStrip placeholder -->
<div class="date-strip-placeholder">DateStrip Component</div>
</div>
{/if}
<!-- Layer 2: TagStrip -->
{#if unifiedBarStore.showTagStrip}
<div
class="unified-bar-layer tag-strip-layer"
style="z-index: {layerZIndices.tag}; bottom: {layerBottomOffsets.tag};"
class:active={unifiedBarStore.activeLayer === 'tag'}
transition:fly={{ ...flyConfig, y: 50 }}
role="toolbar"
aria-label="Tag filter bar"
onclick={() => handleLayerClick('tag')}
>
<!-- TagStrip placeholder -->
<div class="tag-strip-placeholder">TagStrip Component</div>
</div>
{/if}
<!-- Layer 1: DateStrip -->
{#if unifiedBarStore.showDateStrip}
<div
class="unified-bar-layer date-strip-layer"
style="z-index: {layerZIndices.date}; bottom: {layerBottomOffsets.date};"
class:active={unifiedBarStore.activeLayer === 'date'}
transition:fly={{ ...flyConfig, y: 30 }}
role="toolbar"
aria-label="Date strip"
onclick={() => handleLayerClick('date')}
>
<!-- DateStrip placeholder -->
<div class="date-strip-placeholder">DateStrip Component</div>
</div>
{/if}
<!-- Layer 1: DateStrip -->
{#if unifiedBarStore.showDateStrip}
<div
class="unified-bar-layer date-strip-layer"
style="z-index: {layerZIndices.date}; bottom: {layerBottomOffsets.date};"
class:active={unifiedBarStore.activeLayer === 'date'}
transition:fly={{ ...flyConfig, y: 30 }}
role="toolbar"
aria-label="Date strip"
onclick={() => handleLayerClick('date')}
>
<!-- DateStrip placeholder -->
<div class="date-strip-placeholder">DateStrip Component</div>
</div>
{/if}
<!-- Layer 0: QuickInputBar -->
{#if unifiedBarStore.showQuickInput}
<div
class="unified-bar-layer input-layer"
style="z-index: {layerZIndices.input}; bottom: {layerBottomOffsets.input};"
class:active={unifiedBarStore.activeLayer === 'input'}
transition:slide={slideConfig}
role="toolbar"
aria-label="Quick input"
onclick={() => handleLayerClick('input')}
>
<div class="input-bar-wrapper">
<div class="input-bar-row">
<!-- QuickInputBar placeholder - will be implemented with actual component -->
<div class="quick-input-placeholder">
QuickInputBar Component
<button onmousedown={handleOverlayToggle}>
{unifiedBarStore.isOverlayOpen ? 'X' : 'Menu'}
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- Overlay Menu -->
{#if unifiedBarStore.isOverlayOpen}
<div
class="unified-bar-overlay"
style="z-index: {layerZIndices.overlay};"
transition:fly={{ ...flyConfig, y: 100 }}
role="dialog"
aria-modal="true"
aria-label="Menu"
>
<div class="overlay-backdrop" onclick={handleOverlayToggle} />
<div class="overlay-content">
<div class="overlay-header">
<h3>Menü</h3>
<button class="close-btn" onmousedown={handleOverlayToggle}>
<span class="icon-x"></span>
</button>
</div>
<div class="overlay-actions">
<!-- Layer Toggles -->
<div class="action-group">
<h4>Ansichten</h4>
<button
class="action-btn"
class:active={unifiedBarStore.showDateStrip}
onmousedown={() => handleOverlayAction('toggle-date-strip')}
>
<span class="icon-calendar"></span>
Datum-Leiste
<span class="status">{unifiedBarStore.showDateStrip ? '✓' : ''}</span>
</button>
<button
class="action-btn"
class:active={unifiedBarStore.showTagStrip}
onmousedown={() => handleOverlayAction('toggle-tag-strip')}
>
<span class="icon-tag"></span>
Tag-Filter
<span class="status">{unifiedBarStore.showTagStrip ? '✓' : ''}</span>
</button>
<button
class="action-btn"
class:active={unifiedBarStore.showCalendarToolbar}
onmousedown={() => handleOverlayAction('toggle-toolbar')}
>
<span class="icon-toolbox"></span>
Kalender-Toolbar
<span class="status">{unifiedBarStore.showCalendarToolbar ? '✓' : ''}</span>
</button>
</div>
<!-- Quick Actions -->
<div class="action-group">
<h4>Schnellaktionen</h4>
<button class="action-btn" onmousedown={() => handleOverlayAction('collapse-all')}>
<span class="icon-minimize"></span>
Alle einklappen
</button>
<button class="action-btn" onmousedown={() => handleOverlayAction('new-event')}>
<span class="icon-plus"></span>
Neuer Termin
</button>
<button class="action-btn" onmousedown={() => handleOverlayAction('today')}>
<span class="icon-navigation"></span>
Heute
</button>
</div>
</div>
</div>
</div>
{/if}
</div>
<style>
.unified-bar-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
pointer-events: none;
}
.unified-bar-container.transitioning {
transition: all var(--animation-duration) ease;
}
.unified-bar-layer {
position: absolute;
left: 0;
right: 0;
pointer-events: auto;
background: color-mix(in srgb, var(--color-surface) 95%, transparent);
backdrop-filter: blur(20px);
border: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
transition: all var(--animation-duration) ease;
}
.unified-bar-layer.active {
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
z-index: calc(var(--z-index) + 5) !important;
}
.unified-bar-layer:hover {
background: color-mix(in srgb, var(--color-surface) 98%, transparent);
}
.input-layer {
background: color-mix(in srgb, var(--color-surface) 90%, transparent);
border-top: 1px solid var(--color-border);
}
.input-bar-wrapper {
position: relative;
padding: var(--spacing-sm) var(--spacing-md);
}
.input-bar-row {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
/* Overlay Styles */
.unified-bar-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: auto;
display: flex;
align-items: flex-end;
}
.overlay-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
}
.overlay-content {
position: relative;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-bottom: none;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: var(--spacing-lg);
max-width: 100%;
width: 500px;
max-height: 70vh;
overflow-y: auto;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
}
.overlay-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.overlay-header h3 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--color-foreground);
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: var(--spacing-xs);
border-radius: var(--radius-md);
color: var(--color-muted-foreground);
transition: all var(--transition-fast);
}
.close-btn:hover {
background: var(--color-muted);
color: var(--color-foreground);
}
.overlay-actions {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
.action-group h4 {
margin: 0 0 var(--spacing-md) 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.action-btn {
display: flex;
align-items: center;
gap: var(--spacing-md);
width: 100%;
padding: var(--spacing-md);
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-base);
font-size: 0.875rem;
color: var(--color-foreground);
text-align: left;
}
.action-btn:hover {
background: var(--color-muted);
border-color: var(--color-primary);
transform: translateY(-1px);
}
.action-btn.active {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
}
.action-btn .status {
margin-left: auto;
font-weight: 600;
color: var(--color-success);
}
/* Icons (placeholder - replace with actual icon component) */
.icon-x::before {
content: '×';
}
.icon-calendar::before {
content: '📅';
}
.icon-tag::before {
content: '🏷️';
}
.icon-toolbox::before {
content: '🔧';
}
.icon-minimize::before {
content: '⬇️';
}
.icon-plus::before {
content: '+';
}
.icon-navigation::before {
content: '🧭';
}
/* Placeholder styles */
.quick-input-placeholder,
.tag-strip-placeholder,
.date-strip-placeholder,
.toolbar-placeholder {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-lg);
background: var(--color-background);
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--color-muted-foreground);
font-size: 0.875rem;
font-weight: 500;
min-height: 60px;
}
.quick-input-placeholder button {
margin-left: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--color-primary);
color: var(--color-primary-foreground);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.75rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.overlay-content {
width: 100%;
max-height: 80vh;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.action-btn {
padding: var(--spacing-lg);
font-size: 1rem;
}
.quick-input-placeholder,
.tag-strip-placeholder,
.date-strip-placeholder,
.toolbar-placeholder {
min-height: 50px;
font-size: 0.75rem;
}
}
</style>

View file

@ -0,0 +1,422 @@
<script lang="ts">
import {
unifiedBarStore,
type UnifiedBarLayer,
type UnifiedBarMode,
} from '$lib/stores/unified-bar.svelte';
import { createEventDispatcher } from 'svelte';
import { slide } from 'svelte/transition';
interface Props {
onModeChange?: (mode: UnifiedBarMode) => void;
onLayerChange?: (layer: UnifiedBarLayer) => void;
disabled?: boolean;
}
let { onModeChange = () => {}, onLayerChange = () => {}, disabled = false }: Props = $props();
const dispatch = createEventDispatcher();
// Current state
let currentMode = $derived(unifiedBarStore.mode);
let activeLayer = $derived(unifiedBarStore.activeLayer);
// Handle mode switching
function switchToMode(mode: UnifiedBarMode) {
unifiedBarStore.setMode(mode);
onModeChange(mode);
dispatch('modeChange', { mode, previousMode: currentMode });
}
// Handle layer activation
function activateLayer(layer: UnifiedBarLayer) {
unifiedBarStore.expandToLayer(layer);
onLayerChange(layer);
dispatch('layerChange', { layer, previousLayer: activeLayer });
}
// Quick actions
function handleQuickAction(action: string) {
dispatch('quickAction', { action });
}
</script>
<!-- Unified Bar Controls -->
<div class="unified-bar-controls" class:disabled>
<!-- Mode Selector -->
<div class="mode-selector">
<button
class="mode-btn"
class:active={currentMode === 'collapsed'}
onmousedown={() => switchToMode('collapsed')}
title="Zusammengeklappt"
>
<span class="icon-minimize-2"></span>
</button>
<button
class="mode-btn"
class:active={currentMode === 'expanded'}
onmousedown={() => switchToMode('expanded')}
title="Erweitert"
>
<span class="icon-maximize-2"></span>
</button>
<button
class="mode-btn"
class:active={currentMode === 'overlay'}
onmousedown={() => unifiedBarStore.toggleOverlay()}
title="Menü"
>
<span class="icon-menu"></span>
</button>
</div>
<!-- Layer Controls (only visible in expanded mode) -->
{#if currentMode === 'expanded'}
<div class="layer-controls" transition:slide={{ duration: 200 }}>
<div class="layer-btn-group">
<button
class="layer-btn"
class:active={activeLayer === 'input' || unifiedBarStore.showQuickInput}
onmousedown={() => activateLayer('input')}
title="Schnelleingabe"
>
<span class="icon-edit"></span>
<span class="label">Eingabe</span>
</button>
<button
class="layer-btn"
class:active={activeLayer === 'date' || unifiedBarStore.showDateStrip}
onmousedown={() => activateLayer('date')}
title="Datum-Leiste"
>
<span class="icon-calendar"></span>
<span class="label">Datum</span>
</button>
<button
class="layer-btn"
class:active={activeLayer === 'tag' || unifiedBarStore.showTagStrip}
onmousedown={() => activateLayer('tag')}
title="Tag-Filter"
>
<span class="icon-tag"></span>
<span class="label">Tags</span>
</button>
<button
class="layer-btn"
class:active={activeLayer === 'toolbar' || unifiedBarStore.showCalendarToolbar}
onmousedown={() => activateLayer('toolbar')}
title="Kalender-Toolbar"
>
<span class="icon-settings"></span>
<span class="label">Tools</span>
</button>
</div>
<div class="quick-actions">
<button
class="quick-action-btn"
onmousedown={() => handleQuickAction('new-event')}
title="Neuer Termin"
>
<span class="icon-plus"></span>
</button>
<button
class="quick-action-btn"
onmousedown={() => handleQuickAction('today')}
title="Heute"
>
<span class="icon-navigation"></span>
</button>
<button
class="quick-action-btn"
onmousedown={() => unifiedBarStore.collapseAll()}
title="Alle einklappen"
>
<span class="icon-chevron-down"></span>
</button>
</div>
</div>
{/if}
<!-- Status Indicator -->
<div class="status-indicator">
<div class="status-dots">
<div class="status-dot" class:active={unifiedBarStore.showQuickInput} title="Eingabe"></div>
<div
class="status-dot"
class:active={unifiedBarStore.showDateStrip}
title="Datum-Leiste"
></div>
<div class="status-dot" class:active={unifiedBarStore.showTagStrip} title="Tag-Filter"></div>
<div
class="status-dot"
class:active={unifiedBarStore.showCalendarToolbar}
title="Kalender-Toolbar"
></div>
</div>
<span class="mode-text">
{#if currentMode === 'collapsed'}
Zusammengklappt
{:else if currentMode === 'expanded'}
Erweitert
{:else if currentMode === 'overlay'}
Menü offen
{/if}
</span>
</div>
</div>
<style>
.unified-bar-controls {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
user-select: none;
}
.unified-bar-controls.disabled {
opacity: 0.5;
pointer-events: none;
}
/* Mode Selector */
.mode-selector {
display: flex;
gap: var(--spacing-xs);
background: var(--color-background);
border-radius: var(--radius-md);
padding: var(--spacing-xs);
}
.mode-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
color: var(--color-muted-foreground);
font-size: 16px;
}
.mode-btn:hover {
background: var(--color-muted);
color: var(--color-foreground);
transform: translateY(-1px);
}
.mode-btn.active {
background: var(--color-primary);
color: var(--color-primary-foreground);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Layer Controls */
.layer-controls {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: 0 var(--spacing-md);
}
.layer-btn-group {
display: flex;
gap: var(--spacing-xs);
background: var(--color-background);
border-radius: var(--radius-md);
padding: var(--spacing-xs);
}
.layer-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
color: var(--color-muted-foreground);
min-width: 60px;
}
.layer-btn:hover {
background: var(--color-muted);
color: var(--color-foreground);
transform: translateY(-1px);
}
.layer-btn.active {
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.layer-btn .label {
font-size: 0.625rem;
font-weight: 500;
text-align: center;
line-height: 1;
}
.layer-btn .icon {
font-size: 16px;
}
/* Quick Actions */
.quick-actions {
display: flex;
gap: var(--spacing-xs);
}
.quick-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
color: var(--color-muted-foreground);
font-size: 14px;
}
.quick-action-btn:hover {
background: var(--color-muted);
border-color: var(--color-primary);
color: var(--color-primary);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Status Indicator */
.status-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-xs);
margin-left: auto;
}
.status-dots {
display: flex;
gap: var(--spacing-xs);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-border);
transition: all var(--transition-fast);
}
.status-dot.active {
background: var(--color-success);
box-shadow: 0 0 8px color-mix(in srgb, var(--color-success) 50%, transparent);
}
.status-dot:hover {
transform: scale(1.2);
}
.mode-text {
font-size: 0.75rem;
color: var(--color-muted-foreground);
font-weight: 500;
text-align: center;
white-space: nowrap;
}
/* Icons (placeholder - replace with actual icon components) */
.icon-minimize-2::before {
content: '⬇️';
}
.icon-maximize-2::before {
content: '⬆️';
}
.icon-menu::before {
content: '☰';
}
.icon-edit::before {
content: '✏️';
}
.icon-calendar::before {
content: '📅';
}
.icon-tag::before {
content: '🏷️';
}
.icon-settings::before {
content: '⚙️';
}
.icon-plus::before {
content: '+';
}
.icon-navigation::before {
content: '🧭';
}
.icon-chevron-down::before {
content: '⬇️';
}
/* Responsive Design */
@media (max-width: 768px) {
.unified-bar-controls {
flex-wrap: wrap;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
}
.layer-controls {
width: 100%;
justify-content: space-between;
padding: var(--spacing-sm) 0;
}
.layer-btn {
min-width: 50px;
padding: var(--spacing-sm);
}
.layer-btn .label {
font-size: 0.5rem;
}
.status-indicator {
margin-left: 0;
width: 100%;
}
.mode-text {
display: none;
}
.status-dots {
justify-content: center;
}
}
</style>

View file

@ -0,0 +1,266 @@
/**
* UnifiedBar Store - Manages the new unified bottom bar system
* Replaces multiple individual bars with a layered, cohesive interface
*/
import type { CalendarViewType } from '@calendar/shared';
import { createAppSettingsStore } from '@manacore/shared-stores';
import { userSettings } from './user-settings.svelte';
// UnifiedBar modes and layers
export type UnifiedBarMode = 'collapsed' | 'expanded' | 'overlay';
export type UnifiedBarLayer = 'input' | 'date' | 'tag' | 'toolbar' | 'settings';
// Store interface
export interface UnifiedBarSettings extends Record<string, unknown> {
// UnifiedBar mode control
unifiedBarMode: UnifiedBarMode;
unifiedBarActiveLayer: UnifiedBarLayer;
// Legacy compatibility (keep existing settings working)
dateStripCollapsed: boolean;
tagStripCollapsed: boolean;
calendarToolbarCollapsed: boolean;
// New UnifiedBar-specific settings
showQuickInput: boolean;
showDateStrip: boolean;
showTagStrip: boolean;
showCalendarToolbar: boolean;
overlayMenuOpen: boolean;
// Animation and interaction preferences
barAnimationDuration: number;
enableHapticFeedback: boolean;
autoCollapseBars: boolean;
// Quick access toggles
quickAccessActions: string[];
}
const DEFAULT_SETTINGS: UnifiedBarSettings = {
// Default to collapsed mode with only input bar visible
unifiedBarMode: 'collapsed',
unifiedBarActiveLayer: 'input',
// Legacy compatibility
dateStripCollapsed: false,
tagStripCollapsed: true,
calendarToolbarCollapsed: true,
// New settings
showQuickInput: true,
showDateStrip: true,
showTagStrip: false,
showCalendarToolbar: false,
overlayMenuOpen: false,
// Interaction preferences
barAnimationDuration: 300,
enableHapticFeedback: true,
autoCollapseBars: false,
// Quick actions
quickAccessActions: ['new-event', 'search', 'today', 'calendar-toggle'],
};
// Cloud sync state
let cloudSyncEnabled = $state(false);
let initialSyncDone = $state(false);
// Sync to cloud callback
async function syncToCloud(settings: UnifiedBarSettings) {
if (!cloudSyncEnabled || typeof window === 'undefined') return;
try {
await userSettings.updateDeviceAppSettings(settings as unknown as Record<string, unknown>);
} catch (e) {
console.error('Failed to sync unified bar settings to cloud:', e);
}
}
// Create base store
const baseStore = createAppSettingsStore<UnifiedBarSettings>(
'unified-bar-settings',
DEFAULT_SETTINGS,
{
onSettingsChange: syncToCloud,
}
);
// Load settings from cloud
function loadFromCloud(): Partial<UnifiedBarSettings> | null {
if (!userSettings.loaded) return null;
const cloudSettings = userSettings.currentDeviceAppSettings;
if (cloudSettings && Object.keys(cloudSettings).length > 0) {
return cloudSettings as unknown as Partial<UnifiedBarSettings>;
}
return null;
}
export const unifiedBarStore = {
// Base store methods
get settings() {
return baseStore.settings;
},
initialize: baseStore.initialize,
set: baseStore.set,
update: baseStore.update,
reset: baseStore.reset,
getDefaults: baseStore.getDefaults,
// Mode management
get mode() {
return baseStore.settings.unifiedBarMode;
},
get activeLayer() {
return baseStore.settings.unifiedBarActiveLayer;
},
get isOverlayOpen() {
return baseStore.settings.overlayMenuOpen;
},
// Layer visibility helpers
get showQuickInput() {
return baseStore.settings.showQuickInput;
},
get showDateStrip() {
return baseStore.settings.showDateStrip && !baseStore.settings.dateStripCollapsed;
},
get showTagStrip() {
return baseStore.settings.showTagStrip && !baseStore.settings.tagStripCollapsed;
},
get showCalendarToolbar() {
return baseStore.settings.showCalendarToolbar && !baseStore.settings.calendarToolbarCollapsed;
},
// Mode switching
setMode(mode: UnifiedBarMode) {
baseStore.set('unifiedBarMode', mode);
},
toggleOverlay() {
const isOpen = baseStore.settings.overlayMenuOpen;
baseStore.set('overlayMenuOpen', !isOpen);
if (!isOpen) {
baseStore.set('unifiedBarMode', 'overlay');
} else {
baseStore.set('unifiedBarMode', 'collapsed');
}
},
setActiveLayer(layer: UnifiedBarLayer) {
baseStore.set('unifiedBarActiveLayer', layer);
},
// Layer toggles
toggleQuickInput() {
baseStore.set('showQuickInput', !baseStore.settings.showQuickInput);
},
toggleDateStrip() {
const newValue = !baseStore.settings.showDateStrip;
baseStore.set('showDateStrip', newValue);
baseStore.set('dateStripCollapsed', !newValue);
},
toggleTagStrip() {
const newValue = !baseStore.settings.showTagStrip;
baseStore.set('showTagStrip', newValue);
baseStore.set('tagStripCollapsed', !newValue);
},
toggleCalendarToolbar() {
const newValue = !baseStore.settings.showCalendarToolbar;
baseStore.set('showCalendarToolbar', newValue);
baseStore.set('calendarToolbarCollapsed', !newValue);
},
// Quick actions
expandToLayer(layer: UnifiedBarLayer) {
baseStore.set('unifiedBarMode', 'expanded');
baseStore.set('unifiedBarActiveLayer', layer);
// Auto-show the layer if hidden
switch (layer) {
case 'date':
if (!baseStore.settings.showDateStrip) {
baseStore.set('showDateStrip', true);
baseStore.set('dateStripCollapsed', false);
}
break;
case 'tag':
if (!baseStore.settings.showTagStrip) {
baseStore.set('showTagStrip', true);
baseStore.set('tagStripCollapsed', false);
}
break;
case 'toolbar':
if (!baseStore.settings.showCalendarToolbar) {
baseStore.set('showCalendarToolbar', true);
baseStore.set('calendarToolbarCollapsed', false);
}
break;
}
},
collapseAll() {
baseStore.set('unifiedBarMode', 'collapsed');
baseStore.set('unifiedBarActiveLayer', 'input');
if (baseStore.settings.autoCollapseBars) {
baseStore.set('showDateStrip', false);
baseStore.set('showTagStrip', false);
baseStore.set('showCalendarToolbar', false);
}
},
// Cloud sync methods
enableCloudSync() {
cloudSyncEnabled = true;
if (!initialSyncDone) {
const cloudSettings = loadFromCloud();
if (cloudSettings && Object.keys(cloudSettings).length > 0) {
baseStore.update(cloudSettings);
} else {
syncToCloud(baseStore.settings);
}
initialSyncDone = true;
}
},
disableCloudSync() {
cloudSyncEnabled = false;
},
// Legacy compatibility helpers (for gradual migration)
get legacyDateStripCollapsed() {
return baseStore.settings.dateStripCollapsed;
},
get legacyTagStripCollapsed() {
return baseStore.settings.tagStripCollapsed;
},
// Sync from legacy settings (migration helper)
syncFromLegacySettings(legacy: { dateStripCollapsed?: boolean; tagStripCollapsed?: boolean }) {
const updates: Partial<UnifiedBarSettings> = {};
if (legacy.dateStripCollapsed !== undefined) {
updates.dateStripCollapsed = legacy.dateStripCollapsed;
updates.showDateStrip = !legacy.dateStripCollapsed;
}
if (legacy.tagStripCollapsed !== undefined) {
updates.tagStripCollapsed = legacy.tagStripCollapsed;
updates.showTagStrip = !legacy.tagStripCollapsed;
}
if (Object.keys(updates).length > 0) {
baseStore.update(updates);
}
},
};

View file

@ -0,0 +1,328 @@
<script lang="ts">
import UnifiedBar from '$lib/components/calendar/UnifiedBar.svelte';
import UnifiedBarControls from '$lib/components/calendar/UnifiedBarControls.svelte';
import { unifiedBarStore } from '$lib/stores/unified-bar.svelte';
import { onMount } from 'svelte';
// Demo handlers
function handleSearch(query: string) {
console.log('Search:', query);
}
function handleSelect(result: any) {
console.log('Select:', result);
}
function handleSearchChange(query: string) {
console.log('Search change:', query);
}
function handleCreate(data: any) {
console.log('Create:', data);
}
function handleParseCreate(data: any) {
console.log('Parse create:', data);
}
function handleDateSelect(date: Date) {
console.log('Date select:', date);
}
function handleOverlayToggle(event: CustomEvent) {
console.log('Overlay toggle:', event.detail);
}
function handleOverlayAction(event: CustomEvent) {
console.log('Overlay action:', event.detail);
}
function handleModeChange(mode: string) {
console.log('Mode change:', mode);
}
function handleLayerChange(layer: string) {
console.log('Layer change:', layer);
}
function handleQuickAction(event: CustomEvent) {
console.log('Quick action:', event.detail);
}
function handleToolbarCollapsedChange(collapsed: boolean) {
console.log('Toolbar collapsed:', collapsed);
}
// Initialize store on mount
onMount(() => {
unifiedBarStore.enableCloudSync();
});
</script>
<svelte:head>
<title>UnifiedBar Demo - Calendar</title>
</svelte:head>
<main class="demo-container">
<header class="demo-header">
<h1>UnifiedBar Demo</h1>
<p>Demonstration der neuen unified bottom bar Architektur</p>
</header>
<!-- Controls for testing -->
<section class="controls-section">
<h2>UnifiedBar Controls</h2>
<UnifiedBarControls onModeChange={handleModeChange} onLayerChange={handleLayerChange} />
</section>
<!-- Main content area -->
<section class="content-section">
<div class="content-placeholder">
<h2>Kalender Inhalt</h2>
<p>Dies ist der Hauptinhaltbereich, in dem die Kalender-Ansichten angezeigt werden.</p>
<div class="info-cards">
<div class="info-card">
<h3>Aktueller Modus</h3>
<p><strong>{unifiedBarStore.mode}</strong></p>
</div>
<div class="info-card">
<h3>Aktiver Layer</h3>
<p><strong>{unifiedBarStore.activeLayer}</strong></p>
</div>
<div class="info-card">
<h3>Sichtbare Layers</h3>
<ul>
{#if unifiedBarStore.showQuickInput}<li>✓ QuickInput</li>{/if}
{#if unifiedBarStore.showDateStrip}<li>✓ DateStrip</li>{/if}
{#if unifiedBarStore.showTagStrip}<li>✓ TagStrip</li>{/if}
{#if unifiedBarStore.showCalendarToolbar}<li>✓ CalendarToolbar</li>{/if}
</ul>
</div>
<div class="info-card">
<h3>Overlay Status</h3>
<p><strong>{unifiedBarStore.isOverlayOpen ? 'Offen' : 'Geschlossen'}</strong></p>
</div>
</div>
<div class="demo-actions">
<button onmousedown={() => unifiedBarStore.setMode('collapsed')}> Zusammengklappt </button>
<button onmousedown={() => unifiedBarStore.setMode('expanded')}> Erweitert </button>
<button onmousedown={() => unifiedBarStore.toggleOverlay()}> Overlay Toggle </button>
<button onmousedown={() => unifiedBarStore.expandToLayer('date')}> zum Datum-Layer </button>
<button onmousedown={() => unifiedBarStore.collapseAll()}> Alle einklappen </button>
</div>
</div>
</section>
<!-- UnifiedBar at the bottom -->
<UnifiedBar
onSearch={handleSearch}
onSelect={handleSelect}
onSearchChange={handleSearchChange}
onCreate={handleCreate}
onParseCreate={handleParseCreate}
onDateSelect={handleDateSelect}
onToolbarCollapsedChange={handleToolbarCollapsedChange}
placeholder="Neuer Termin oder suchen..."
emptyText="Keine Termine gefunden"
searchingText="Suche..."
createText="Erstellen"
appIcon="calendar"
isMobile={false}
showCalendarToolbar={true}
/>
<!-- Footer with info -->
<footer class="demo-footer">
<p>UnifiedBar Demo - Calendar App</p>
<p>Scrollen Sie, um zu sehen wie die Bars fixiert bleiben</p>
</footer>
</main>
<style>
.demo-container {
min-height: 100vh;
padding-bottom: 400px; /* Space for UnifiedBar */
background: var(--color-background);
color: var(--color-foreground);
}
.demo-header {
padding: var(--spacing-xl) var(--spacing-lg);
text-align: center;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
}
.demo-header h1 {
margin: 0 0 var(--spacing-sm) 0;
font-size: 2rem;
font-weight: 700;
color: var(--color-primary);
}
.demo-header p {
margin: 0;
color: var(--color-muted-foreground);
font-size: 1rem;
}
.controls-section {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-surface) 50%, transparent);
}
.controls-section h2 {
margin: 0 0 var(--spacing-md) 0;
font-size: 1.25rem;
font-weight: 600;
}
.content-section {
padding: var(--spacing-lg);
}
.content-placeholder {
max-width: 1200px;
margin: 0 auto;
}
.content-placeholder h2 {
margin: 0 0 var(--spacing-lg) 0;
font-size: 1.5rem;
font-weight: 600;
}
.content-placeholder p {
margin: 0 0 var(--spacing-xl) 0;
color: var(--color-muted-foreground);
line-height: 1.6;
}
.info-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--spacing-lg);
margin-bottom: var(--spacing-xl);
}
.info-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
transition: all var(--transition-base);
}
.info-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.info-card h3 {
margin: 0 0 var(--spacing-sm) 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-card p {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-foreground);
}
.info-card ul {
margin: 0;
padding: 0;
list-style: none;
}
.info-card li {
padding: var(--spacing-xs) 0;
color: var(--color-success);
font-weight: 500;
}
.demo-actions {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-md);
margin-top: var(--spacing-xl);
}
.demo-actions button {
padding: var(--spacing-md) var(--spacing-lg);
background: var(--color-primary);
color: var(--color-primary-foreground);
border: none;
border-radius: var(--radius-md);
cursor: pointer;
font-weight: 500;
transition: all var(--transition-base);
}
.demo-actions button:hover {
filter: brightness(0.9);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.demo-footer {
position: fixed;
bottom: 350px;
left: 0;
right: 0;
text-align: center;
padding: var(--spacing-md);
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
border-top: 1px solid var(--color-border);
color: var(--color-muted-foreground);
font-size: 0.875rem;
}
.demo-footer p {
margin: 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.demo-container {
padding-bottom: 500px;
}
.demo-header {
padding: var(--spacing-lg) var(--spacing-md);
}
.demo-header h1 {
font-size: 1.5rem;
}
.controls-section,
.content-section {
padding: var(--spacing-md);
}
.info-cards {
grid-template-columns: 1fr;
gap: var(--spacing-md);
}
.demo-actions {
flex-direction: column;
}
.demo-footer {
bottom: 450px;
}
}
</style>

View file

@ -1137,7 +1137,10 @@ services:
retries: 3
start_period: 40s
# NOTE: skilltree-web disabled - Dockerfile needs pnpm deploy fix for shared packages
# TODO: Fix Dockerfile similar to mana-search to resolve @manacore/shared-vite-config
skilltree-web:
profiles: ["disabled"]
build:
context: .
dockerfile: apps/skilltree/apps/web/Dockerfile

View file

@ -0,0 +1,54 @@
#!/bin/bash
# Generate random token
gen_token() {
openssl rand -hex 32
}
# Bot configurations: name, sender_localpart
declare -a BOTS=(
"mana:mana-bot"
"ollama:ollama-bot"
"stats:stats-bot"
"projectdoc:projectdoc-bot"
"todo:todo-bot"
"calendar:calendar-bot"
"nutriphi:nutriphi-bot"
"zitare:zitare-bot"
"clock:clock-bot"
"tts:tts-bot"
)
echo "# Generated AS tokens for .env file:" > as-tokens.env
echo "" >> as-tokens.env
for bot_config in "${BOTS[@]}"; do
IFS=":" read -r name sender <<< "$bot_config"
as_token=$(gen_token)
hs_token=$(gen_token)
cat > "${name}-bot.yaml" << EOF
id: ${name}-bot
hs_token: ${hs_token}
as_token: ${as_token}
url: null
sender_localpart: ${sender}
namespaces:
users:
- exclusive: true
regex: '@${sender}:mana\.how'
rooms: []
aliases: []
rate_limited: false
EOF
# Convert name to uppercase for env var
env_name=$(echo "${name}" | tr '[:lower:]' '[:upper:]' | tr '-' '_')
echo "MATRIX_${env_name}_BOT_AS_TOKEN=${as_token}" >> as-tokens.env
echo "Created ${name}-bot.yaml with AS token"
done
echo ""
echo "Done! Add the tokens from as-tokens.env to your .env file"

View file

@ -81,8 +81,9 @@ password_config:
pepper: "${SYNAPSE_PASSWORD_PEPPER:-change-me-pepper}"
# Session lifetime (must be >= refresh_token_lifetime)
session_lifetime: 168h
refresh_token_lifetime: 168h
# Set to 10 years for bot tokens to avoid frequent expiration
session_lifetime: 87600h
refresh_token_lifetime: 87600h
# ============================================
# Rate Limiting
@ -149,6 +150,8 @@ registration_shared_secret: "${SYNAPSE_REGISTRATION_SECRET:-change-me-registrati
# ============================================
# Application Services (for Bots)
# Currently disabled - using long-lived user tokens instead
# TODO: Migrate bots to AS for truly permanent tokens
# ============================================
app_service_config_files: []

File diff suppressed because it is too large Load diff

View file

@ -245,7 +245,7 @@ check_service "NutriPhi Web" "http://localhost:5017/health"
echo ""
echo "SkillTree:"
check_service "SkillTree Backend" "http://localhost:3038/health"
check_service "SkillTree Web" "http://localhost:5018/health"
# SkillTree Web disabled - Dockerfile needs fix for shared packages
echo ""
echo "Photos:"
@ -254,8 +254,8 @@ check_service "Photos Web" "http://localhost:5019/health"
echo ""
echo "Core Services:"
check_service "API Gateway" "http://localhost:3010/health"
check_service "Search Service" "http://localhost:3020/health"
# API Gateway disabled - no GHCR image, no Dockerfile
check_service "Search Service" "http://localhost:3020/api/v1/health"
check_service "Media Service" "http://localhost:3015/api/v1/health"
check_service "LLM Service" "http://localhost:3025/health"

View file

@ -39,12 +39,12 @@ WORKDIR /app
COPY --from=builder --chown=nestjs:nodejs /app/deploy ./
COPY --from=builder --chown=nestjs:nodejs /app/services/mana-search/dist ./dist
# Expose port
EXPOSE 3021
# Expose port (default 3020, configurable via PORT env)
EXPOSE 3020
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node -e "fetch('http://localhost:3021/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
# Health check uses /api/v1/health endpoint
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3020/api/v1/health || exit 1
# Start the application
CMD ["node", "dist/main"]

View file

@ -0,0 +1,368 @@
"""
German TTS Service - Piper TTS (local, fast) with Edge TTS fallback.
Primary: Piper TTS - 100% local, DSGVO-konform, very fast
Fallback: Edge TTS - Cloud-based (Microsoft), high quality but sends data externally
"""
import logging
import tempfile
import os
import asyncio
from dataclasses import dataclass
from typing import Optional
from pathlib import Path
import numpy as np
import soundfile as sf
logger = logging.getLogger(__name__)
# Paths for Piper models
PIPER_VOICES_DIR = Path(__file__).parent.parent / "piper_voices"
# Available German voices
PIPER_VOICES = {
# === LOCAL PIPER VOICES (Primary - 100% local) ===
"de_thorsten": {
"type": "piper",
"model": "thorsten_medium.onnx",
"name": "Thorsten",
"description": "Deutsche Männerstimme (lokal, schnell)",
"language": "de",
"gender": "male",
"local": True,
},
# === EDGE TTS VOICES (Fallback - Cloud) ===
"de_katja": {
"type": "edge",
"edge_voice": "de-DE-KatjaNeural",
"name": "Katja",
"description": "Deutsche Frauenstimme (Cloud)",
"language": "de",
"gender": "female",
"local": False,
},
"de_conrad": {
"type": "edge",
"edge_voice": "de-DE-ConradNeural",
"name": "Conrad",
"description": "Deutsche Männerstimme (Cloud)",
"language": "de",
"gender": "male",
"local": False,
},
"de_amala": {
"type": "edge",
"edge_voice": "de-DE-AmalaNeural",
"name": "Amala",
"description": "Deutsche Frauenstimme jung (Cloud)",
"language": "de",
"gender": "female",
"local": False,
},
"de_florian": {
"type": "edge",
"edge_voice": "de-DE-FlorianNeural",
"name": "Florian",
"description": "Deutsche Männerstimme jung (Cloud)",
"language": "de",
"gender": "male",
"local": False,
},
# Legacy alias - maps to local Thorsten
"de_anna": {
"type": "piper",
"model": "thorsten_medium.onnx",
"name": "Anna (→ Thorsten)",
"description": "Alias für Thorsten (lokal)",
"language": "de",
"gender": "male",
"local": True,
},
}
DEFAULT_PIPER_VOICE = "de_thorsten"
# Cached Piper voice instance
_piper_voice = None
_piper_available = None
_edge_available = None
def _get_piper_model_path(model_name: str) -> Path:
"""Get full path to a Piper model."""
return PIPER_VOICES_DIR / model_name
def check_piper_available() -> bool:
"""Check if Piper TTS is available."""
global _piper_available
if _piper_available is not None:
return _piper_available
try:
from piper import PiperVoice
model_path = _get_piper_model_path("thorsten_medium.onnx")
if model_path.exists():
_piper_available = True
logger.info(f"Piper TTS available with model: {model_path}")
else:
_piper_available = False
logger.warning(f"Piper model not found: {model_path}")
except ImportError as e:
_piper_available = False
logger.warning(f"Piper TTS not installed: {e}")
return _piper_available
def _check_edge_available() -> bool:
"""Check if Edge TTS is available."""
global _edge_available
if _edge_available is not None:
return _edge_available
try:
import edge_tts
_edge_available = True
logger.info("Edge TTS available as fallback")
except ImportError:
_edge_available = False
logger.warning("Edge TTS not installed")
return _edge_available
def is_piper_loaded() -> bool:
"""Check if any TTS is available."""
return check_piper_available() or _check_edge_available()
def _get_piper_voice():
"""Get or create cached Piper voice instance."""
global _piper_voice
if _piper_voice is not None:
return _piper_voice
if not check_piper_available():
return None
try:
from piper import PiperVoice
model_path = _get_piper_model_path("thorsten_medium.onnx")
config_path = _get_piper_model_path("thorsten_medium.onnx.json")
logger.info(f"Loading Piper voice from {model_path}")
_piper_voice = PiperVoice.load(str(model_path), str(config_path))
logger.info("Piper voice loaded successfully")
return _piper_voice
except Exception as e:
logger.error(f"Failed to load Piper voice: {e}")
return None
@dataclass
class PiperSynthesisResult:
"""Result of TTS synthesis."""
audio: np.ndarray
sample_rate: int
duration: float
voice: str
async def _synthesize_with_piper(
text: str,
length_scale: float = 1.0,
) -> PiperSynthesisResult:
"""Synthesize using local Piper TTS."""
voice = _get_piper_voice()
if voice is None:
raise RuntimeError("Piper voice not available")
logger.debug(f"Piper synthesizing: \"{text[:50]}...\"")
# Piper uses length_scale directly (1.0 = normal, >1 = slower)
# Run in thread pool to not block async
loop = asyncio.get_event_loop()
def _synth():
audio_data = []
for audio_chunk in voice.synthesize_stream_raw(text, length_scale=length_scale):
audio_data.append(audio_chunk)
return b"".join(audio_data)
audio_bytes = await loop.run_in_executor(None, _synth)
# Convert to numpy (16-bit PCM)
audio = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
sample_rate = voice.config.sample_rate
duration = len(audio) / sample_rate
logger.debug(f"Piper synthesis complete: {duration:.2f}s, {sample_rate}Hz")
return PiperSynthesisResult(
audio=audio,
sample_rate=sample_rate,
duration=duration,
voice="de_thorsten",
)
async def _synthesize_with_edge(
text: str,
edge_voice: str,
length_scale: float = 1.0,
) -> PiperSynthesisResult:
"""Synthesize using Edge TTS (cloud fallback)."""
import edge_tts
logger.debug(f"Edge TTS synthesizing: \"{text[:50]}...\" with voice={edge_voice}")
# Convert length_scale to rate string
rate_percent = int((1.0 / length_scale - 1.0) * 100)
rate_str = f"{rate_percent:+d}%"
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp_file:
tmp_path = tmp_file.name
try:
communicate = edge_tts.Communicate(text, edge_voice, rate=rate_str)
await communicate.save(tmp_path)
audio, sample_rate = sf.read(tmp_path)
if len(audio.shape) > 1:
audio = audio.mean(axis=1)
audio = audio.astype(np.float32)
duration = len(audio) / sample_rate
logger.debug(f"Edge TTS synthesis complete: {duration:.2f}s, {sample_rate}Hz")
return PiperSynthesisResult(
audio=audio,
sample_rate=sample_rate,
duration=duration,
voice=edge_voice,
)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
async def synthesize_piper(
text: str,
voice: str = DEFAULT_PIPER_VOICE,
length_scale: float = 1.0,
) -> PiperSynthesisResult:
"""
Synthesize speech - uses local Piper if available, falls back to Edge TTS.
Args:
text: Text to synthesize
voice: Voice ID (e.g., "de_thorsten", "de_katja")
length_scale: Speed control (1.0 = normal, >1 = slower, <1 = faster)
Returns:
PiperSynthesisResult with audio data
"""
if not text.strip():
raise ValueError("Text cannot be empty")
# Get voice config
if voice not in PIPER_VOICES:
logger.warning(f"Unknown voice: {voice}, using default {DEFAULT_PIPER_VOICE}")
voice = DEFAULT_PIPER_VOICE
voice_config = PIPER_VOICES[voice]
voice_type = voice_config.get("type", "piper")
# Try local Piper first for piper-type voices
if voice_type == "piper" and check_piper_available():
try:
return await _synthesize_with_piper(text, length_scale)
except Exception as e:
logger.warning(f"Piper synthesis failed, trying Edge fallback: {e}")
# Use Edge TTS for edge-type voices or as fallback
if _check_edge_available():
edge_voice = voice_config.get("edge_voice", "de-DE-ConradNeural")
if voice_type == "piper":
# Fallback: use Conrad for male voices
edge_voice = "de-DE-ConradNeural"
return await _synthesize_with_edge(text, edge_voice, length_scale)
raise RuntimeError("No TTS backend available (neither Piper nor Edge TTS)")
def list_piper_voices() -> list[dict]:
"""List all available German voices."""
voices = []
piper_available = check_piper_available()
edge_available = _check_edge_available()
for voice_id, config in PIPER_VOICES.items():
# Skip legacy alias
if voice_id == "de_anna":
continue
voice_type = config.get("type", "piper")
is_available = (voice_type == "piper" and piper_available) or \
(voice_type == "edge" and edge_available)
voices.append({
"id": voice_id,
"name": config["name"],
"description": config["description"],
"language": config["language"],
"gender": config.get("gender", "unknown"),
"local": config.get("local", False),
"installed": is_available,
"loaded": is_available,
})
# Sort: local voices first
voices.sort(key=lambda v: (not v["local"], v["id"]))
return voices
def get_piper_voice(voice_id: str) -> Optional[dict]:
"""Get voice configuration by ID."""
if voice_id not in PIPER_VOICES:
return None
config = PIPER_VOICES[voice_id]
voice_type = config.get("type", "piper")
piper_available = check_piper_available()
edge_available = _check_edge_available()
is_available = (voice_type == "piper" and piper_available) or \
(voice_type == "edge" and edge_available)
return {
"id": voice_id,
"name": config["name"],
"description": config["description"],
"language": config["language"],
"gender": config.get("gender", "unknown"),
"local": config.get("local", False),
"installed": is_available,
"loaded": is_available,
}
async def download_piper_voice(voice_id: str) -> bool:
"""Check if voice is available."""
if voice_id not in PIPER_VOICES:
return False
config = PIPER_VOICES[voice_id]
voice_type = config.get("type", "piper")
if voice_type == "piper":
return check_piper_available()
elif voice_type == "edge":
return _check_edge_available()
return False

View file

@ -0,0 +1,45 @@
#!/bin/bash
# Install mana-tts as a launchd service on macOS
# Run this script on the Mac Mini server
set -e
SERVICE_NAME="com.manacore.mana-tts"
PLIST_FILE="$SERVICE_NAME.plist"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents"
LOG_DIR="$HOME/logs"
echo "Installing mana-tts launchd service..."
# Create logs directory
mkdir -p "$LOG_DIR"
# Stop existing service if running
if launchctl list | grep -q "$SERVICE_NAME"; then
echo "Stopping existing service..."
launchctl unload "$LAUNCH_AGENTS_DIR/$PLIST_FILE" 2>/dev/null || true
fi
# Copy plist to LaunchAgents
cp "$SCRIPT_DIR/$PLIST_FILE" "$LAUNCH_AGENTS_DIR/"
# Load the service
echo "Loading service..."
launchctl load "$LAUNCH_AGENTS_DIR/$PLIST_FILE"
# Check status
sleep 2
if launchctl list | grep -q "$SERVICE_NAME"; then
echo "Service installed and running!"
echo ""
echo "Useful commands:"
echo " View logs: tail -f $LOG_DIR/mana-tts.log"
echo " View errors: tail -f $LOG_DIR/mana-tts.error.log"
echo " Stop: launchctl unload $LAUNCH_AGENTS_DIR/$PLIST_FILE"
echo " Start: launchctl load $LAUNCH_AGENTS_DIR/$PLIST_FILE"
echo " Health check: curl http://localhost:3022/health"
else
echo "ERROR: Service failed to start. Check logs at $LOG_DIR/mana-tts.error.log"
exit 1
fi