feat(web): shared FloatingInputBar, migrate 7 modules

Extract the floating pill-shaped input bar (text + optional voice)
into a shared component at $lib/components/FloatingInputBar.svelte.
Migrate todo, calendar, dreams, notes, journal, memoro and contacts
from inline forms / VoiceCaptureBar to the unified bottom bar.

Calendar now shows all upcoming events with relative date labels
(Heute, Morgen, weekday name, or short date).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-12 16:08:03 +02:00
parent 0deab50a9c
commit 949795c267
7 changed files with 415 additions and 402 deletions

View file

@ -0,0 +1,165 @@
<!--
FloatingInputBar — Shared floating input bar for module ListViews.
Positioned at the bottom of a relative parent. Includes optional
voice input button (mic icon left, text field right).
-->
<script lang="ts">
import { Microphone } from '@mana/shared-icons';
import { voiceRecorder, formatElapsed } from '$lib/components/voice/recorder.svelte';
import { requireAuth } from '$lib/auth/require-auth.svelte';
interface Props {
/** Bound text value. */
value: string;
/** Placeholder when idle. */
placeholder?: string;
/** Called on form submit (Enter). */
onSubmit: () => void;
/** Enable voice input. */
voice?: boolean;
/** Feature id for voice auth gate. */
voiceFeature?: string;
/** Reason text for voice auth gate. */
voiceReason?: string;
/** Called with the recorded blob when voice recording stops. */
onVoiceComplete?: (blob: Blob, durationMs: number) => Promise<void> | void;
}
let {
value = $bindable(),
placeholder = 'Neuer Eintrag...',
onSubmit,
voice = false,
voiceFeature = 'voice-capture',
voiceReason = 'Dafür brauchst du ein Mana-Konto.',
onVoiceComplete,
}: Props = $props();
const isRecording = $derived(voiceRecorder.status === 'recording');
const isBusy = $derived(
voiceRecorder.status === 'requesting' || voiceRecorder.status === 'stopping'
);
async function handleVoiceToggle() {
if (isRecording) {
try {
const result = await voiceRecorder.stop();
if (result.durationMs < 500) return;
await onVoiceComplete?.(result.blob, result.durationMs);
} catch {
// cancelled or error
}
return;
}
if (voiceRecorder.status !== 'idle') return;
const ok = await requireAuth({ feature: voiceFeature, reason: voiceReason });
if (!ok) return;
await voiceRecorder.start();
}
</script>
<form
onsubmit={(e) => {
e.preventDefault();
onSubmit();
}}
class="floating-input-bar"
>
{#if voice}
<button
type="button"
class="voice-btn"
class:recording={isRecording}
onclick={handleVoiceToggle}
disabled={isBusy}
title={isRecording ? 'Aufnahme beenden' : 'Sprechen'}
>
{#if isRecording}
<span class="rec-dot"></span>
{:else}
<Microphone size={16} weight="bold" />
{/if}
</button>
{/if}
<input
bind:value
placeholder={isRecording ? formatElapsed(voiceRecorder.elapsedMs) : placeholder}
class="input-field"
disabled={isRecording}
/>
</form>
<style>
.floating-input-bar {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
right: 0.5rem;
display: flex;
align-items: center;
gap: 0;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
box-shadow: 0 -2px 8px hsl(0 0% 0% / 0.06);
overflow: hidden;
}
.voice-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: color 0.15s;
}
.voice-btn:hover:not(:disabled) {
color: hsl(var(--color-primary));
}
.voice-btn.recording {
color: hsl(var(--color-error));
}
.voice-btn:disabled {
opacity: 0.5;
cursor: wait;
}
.rec-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: hsl(var(--color-error));
animation: pulse-dot 1.2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
.input-field {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
padding: 0.5rem 0.75rem 0.5rem 0;
}
/* When there's no voice button, restore left padding */
.floating-input-bar:not(:has(.voice-btn)) .input-field {
padding-left: 0.75rem;
}
.input-field::placeholder {
color: hsl(var(--color-muted-foreground));
}
.input-field:disabled {
opacity: 0.5;
}
</style>

View file

@ -1,14 +1,13 @@
<!--
Calendar — Workbench ListView
Mini week view with today's events + quick event creation.
Clicking an event opens the detail view.
Mini week strip + today's events. Floating input at bottom.
-->
<script lang="ts">
import { db } from '$lib/data/database';
import { eventsStore } from './stores/events.svelte';
import { useAllCalendarItems } from './queries';
import type { CalendarEvent } from './types';
import { Plus, PencilSimple, Trash } from '@mana/shared-icons';
import { PencilSimple, Trash } from '@mana/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
@ -16,6 +15,8 @@
import { useAllTags, getTagsByIds } from '@mana/shared-stores';
import { addTagId } from '$lib/data/tag-mutations';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import { transcribeAudio } from '$lib/voice/transcribe';
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
let { navigate, goBack, params }: ViewProps = $props();
@ -48,9 +49,9 @@
return days;
});
const todayEvents = $derived(
const upcomingEvents = $derived(
allItems
.filter((e) => e.startTime.startsWith(todayStr))
.filter((e) => e.startTime >= todayStr)
.sort((a, b) => a.startTime.localeCompare(b.startTime))
);
@ -58,8 +59,32 @@
return new Date(iso).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
}
const WEEKDAYS = [
'Sonntag',
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
];
const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function formatDateLabel(iso: string): string {
const date = new Date(iso);
const dateStr = date.toISOString().split('T')[0];
const todayDate = new Date(now);
const tomorrowDate = new Date(todayDate);
tomorrowDate.setDate(tomorrowDate.getDate() + 1);
if (dateStr === todayStr) return 'Heute';
if (dateStr === tomorrowDate.toISOString().split('T')[0]) return 'Morgen';
// Within 7 days: show weekday name
const diffMs = date.getTime() - todayDate.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 7) return WEEKDAYS[date.getDay()];
return date.toLocaleDateString('de', { day: 'numeric', month: 'short' });
}
const ctxMenu = useItemContextMenu<CalendarEvent>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
@ -89,6 +114,14 @@
: []
);
async function handleVoiceComplete(blob: Blob, _durationMs: number) {
const { text } = await transcribeAudio(blob, 'de');
if (text) {
newTitle = text;
await createEvent();
}
}
// Quick event creation
let newTitle = $state('');
@ -103,7 +136,6 @@
const endH = h + 1;
const endTime = `${todayStr}T${String(endH > 23 ? 23 : endH).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
// Get default calendar or use a fallback id
const calendars = await db.table('calendars').toArray();
const defaultCal = calendars.find((c: Record<string, unknown>) => !c.deletedAt);
const calendarId = defaultCal?.id ?? 'default';
@ -118,7 +150,7 @@
}
</script>
<div class="app-view">
<div class="cal-view">
<!-- Mini week strip -->
<div class="week-strip">
{#each weekDays() as day, i}
@ -138,34 +170,21 @@
{/each}
</div>
<!-- Today's events -->
<div class="events-section">
<div class="section-header">
<h3 class="section-title">Heute</h3>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
createEvent();
}}
class="quick-add"
>
<span class="add-icon"><Plus size={16} /></span>
<input bind:value={newTitle} placeholder="Neuer Termin..." class="add-input" />
</form>
{#each todayEvents as event (event.id)}
<!-- Event list -->
<div class="event-list">
{#each upcomingEvents as event (event.id)}
{@const eventTags = getTagsByIds(allTags, event.tagIds ?? [])}
<button
class="event-card"
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="event-row"
onclick={() =>
navigate('detail', {
eventId: event.id,
_siblingIds: todayEvents.map((e) => e.id),
_siblingIds: upcomingEvents.map((e) => e.id),
_siblingKey: 'eventId',
})}
oncontextmenu={(e) => ctxMenu.open(e, event)}
role="listitem"
use:dragSource={{
type: 'event',
data: () => ({
@ -183,37 +202,38 @@
canDrop: (p) => !(event.tagIds ?? []).includes((p.data as unknown as TagDragData).id),
}}
>
<div class="event-header">
<p class="event-title">{event.title}</p>
{#if eventTags.length > 0}
<div class="event-tags">
{#each eventTags as tag (tag.id)}
<span class="tag-pill" style="--tag-color: {tag.color}">
<span class="tag-dot" style="background: {tag.color}"></span>
{tag.name}
</span>
{/each}
</div>
{/if}
<span class="event-title">{event.title}</span>
<div class="event-right">
{#each eventTags as tag (tag.id)}
<span class="tag-dot" style="background: {tag.color}" title={tag.name}></span>
{/each}
<span class="event-date">{formatDateLabel(event.startTime)}</span>
<span class="event-time">
{#if event.isAllDay}
Ganztägig
{:else}
{formatTime(event.startTime)}
{/if}
</span>
</div>
<p class="event-time-label">
{#if event.isAllDay}
Ganztägig
{:else}
{formatTime(event.startTime)}{formatTime(event.endTime)}
{/if}
</p>
{#if event.location}
<p class="event-location">{event.location}</p>
{/if}
</button>
</div>
{/each}
{#if todayEvents.length === 0}
<p class="empty">Keine Termine heute</p>
{#if upcomingEvents.length === 0}
<p class="empty">Keine Termine</p>
{/if}
</div>
<FloatingInputBar
bind:value={newTitle}
placeholder="Neuer Termin..."
onSubmit={createEvent}
voice
voiceFeature="calendar-voice-capture"
voiceReason="Termine werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto."
onVoiceComplete={handleVoiceComplete}
/>
<ContextMenu
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
@ -224,25 +244,27 @@
</div>
<style>
.app-view {
.cal-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
height: 100%;
position: relative;
}
/* Week strip */
.week-strip {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
padding: 0.75rem 0.75rem 0.5rem;
border-bottom: 1px solid hsl(var(--color-foreground) / 0.1);
}
.day-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
gap: 0.125rem;
}
/* P5: theme-token migration. */
.day-name {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
@ -253,129 +275,79 @@
justify-content: center;
width: 28px;
height: 28px;
border-radius: 9999px;
border-radius: 50%;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
color: hsl(var(--color-foreground));
}
.day-num.today {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
background: hsl(var(--color-foreground));
color: hsl(var(--color-background));
font-weight: 600;
}
.day-dot {
width: 4px;
height: 4px;
border-radius: 9999px;
background: hsl(var(--color-primary));
border-radius: 50%;
background: hsl(var(--color-foreground) / 0.35);
}
.events-section {
/* Event list */
.event-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
padding-bottom: 4rem;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.quick-add {
.event-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
}
.add-icon {
color: hsl(var(--color-muted-foreground));
display: flex;
}
.add-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.add-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.event-card {
display: block;
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
padding: 0.5rem 0.25rem;
cursor: pointer;
text-align: left;
border-radius: 0.25rem;
transition: background 0.15s;
}
.event-card:hover {
.event-row:hover {
background: hsl(var(--color-surface-hover));
}
.event-header {
.event-title {
flex: 1;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.event-right {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.375rem;
}
.event-tags {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.tag-pill {
display: inline-flex;
align-items: center;
gap: 0.1875rem;
padding: 0 0.325rem;
border-radius: 9999px;
background: color-mix(in srgb, var(--tag-color) 12%, transparent);
font-size: 0.5625rem;
.event-date {
font-size: 0.625rem;
font-weight: 500;
color: hsl(var(--color-foreground) / 0.6);
white-space: nowrap;
}
.event-time {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.25rem;
white-space: nowrap;
}
.tag-dot {
width: 5px;
height: 5px;
border-radius: 9999px;
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
:global(.event-card.mana-drop-target-hover) {
:global(.event-row.mana-drop-target-hover) {
outline: 2px solid hsl(var(--color-primary) / 0.4);
outline-offset: -2px;
background: hsl(var(--color-primary) / 0.06) !important;
}
.event-title {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
margin: 0;
}
.event-time-label {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.event-location {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.empty {
padding: 2rem 0;
text-align: center;
@ -383,19 +355,9 @@
color: hsl(var(--color-muted-foreground));
}
/* Mobile: larger touch targets, tighter spacing */
@media (max-width: 640px) {
.app-view {
padding: 0.75rem;
}
.event-card {
padding: 0.75rem;
min-height: 44px;
}
.quick-add {
padding: 0.625rem 0.75rem;
.event-row {
padding: 0.625rem 0.375rem;
min-height: 44px;
}
}

View file

@ -8,7 +8,8 @@
import { db } from '$lib/data/database';
import type { LocalContact } from './types';
import { contactsStore } from './stores/contacts.svelte';
import { Plus, Star, PencilSimple, Trash, StarFour } from '@mana/shared-icons';
import { Star, PencilSimple, Trash, StarFour } from '@mana/shared-icons';
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { dropTarget, dragSource } from '@mana/shared-ui/dnd';
@ -120,17 +121,6 @@
<div class="app-view">
<input bind:value={search} placeholder="Kontakt suchen..." class="search-input" />
<form
onsubmit={(e) => {
e.preventDefault();
createContact();
}}
class="quick-add"
>
<span class="add-icon"><Plus size={16} /></span>
<input bind:value={newName} placeholder="Neuer Kontakt..." class="add-input" />
</form>
<p class="count">{filtered().length} Kontakte</p>
<div class="contact-list">
@ -189,6 +179,8 @@
{/if}
</div>
<FloatingInputBar bind:value={newName} placeholder="Neuer Kontakt..." onSubmit={createContact} />
<ContextMenu
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
@ -205,6 +197,7 @@
gap: 0.5rem;
padding: 1rem;
height: 100%;
position: relative;
}
/* P5: theme-token migration. */
.search-input {
@ -223,30 +216,6 @@
.search-input:focus {
border-color: hsl(var(--color-border-strong));
}
.quick-add {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
}
.add-icon {
color: hsl(var(--color-muted-foreground));
display: flex;
}
.add-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.add-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.count {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
@ -254,6 +223,7 @@
.contact-list {
flex: 1;
overflow-y: auto;
padding-bottom: 4rem;
}
.contact-item {
display: flex;

View file

@ -11,7 +11,7 @@
useAllDreams,
} from './queries';
import { dreamsStore } from './stores/dreams.svelte';
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
import { MOOD_COLORS, MOOD_LABELS, type Dream, type DreamMood, type SleepQuality } from './types';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
@ -80,9 +80,8 @@
symbolFilter = symbolFilter === name ? null : name;
}
async function handleQuickCreate(e: KeyboardEvent) {
if (e.key !== 'Enter' || !newTitle.trim()) return;
e.preventDefault();
async function handleQuickCreate() {
if (!newTitle.trim()) return;
const dream = await dreamsStore.createDream({ title: newTitle.trim() });
newTitle = '';
startEdit(dream);
@ -208,26 +207,6 @@
}}
/>
{:else}
<!-- Voice capture -->
<VoiceCaptureBar
idleLabel="Traum sprechen"
feature="dreams-voice-capture"
reason="Sprach-Aufnahmen werden verschlüsselt in deinem persönlichen Tagebuch gespeichert. Dafür brauchst du ein Mana-Konto."
onComplete={handleVoiceComplete}
/>
<!-- Quick create -->
<form onsubmit={(e) => e.preventDefault()} class="quick-add">
<span class="add-icon">&#x1f319;</span>
<input
class="add-input"
type="text"
placeholder="Was hast du geträumt? (Enter)"
bind:value={newTitle}
onkeydown={handleQuickCreate}
/>
</form>
<!-- Insights ribbon -->
{#if insights.total > 0}
<div class="insights">
@ -487,9 +466,19 @@
</div>
{#if dreams.length === 0}
<p class="empty">Tippe oben, um deinen ersten Traum festzuhalten.</p>
<p class="empty">Erzähl deinen ersten Traum.</p>
{/if}
<FloatingInputBar
bind:value={newTitle}
placeholder="Was hast du geträumt?"
onSubmit={handleQuickCreate}
voice
voiceFeature="dreams-voice-capture"
voiceReason="Sprach-Aufnahmen werden verschlüsselt in deinem persönlichen Tagebuch gespeichert. Dafür brauchst du ein Mana-Konto."
onVoiceComplete={handleVoiceComplete}
/>
<ContextMenu
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
@ -507,10 +496,9 @@
gap: 0.625rem;
padding: 1rem;
height: 100%;
position: relative;
}
/* Voice capture styles live in $lib/components/voice/VoiceCaptureBar.svelte */
/* ── View Tabs ─────────────────────────────── */
.view-tabs {
display: flex;
@ -537,31 +525,6 @@
background: hsl(var(--color-primary) / 0.08);
}
/* ── Quick Add ─────────────────────────────── */
.quick-add {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
}
.add-icon {
font-size: 0.875rem;
}
.add-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.add-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
/* ── Insights ──────────────────────────────── */
.insights {
display: flex;
@ -647,6 +610,7 @@
.dream-list {
flex: 1;
overflow-y: auto;
padding-bottom: 4rem;
}
.month-label {

View file

@ -19,7 +19,7 @@
type JournalEntry,
type JournalMood,
} from './types';
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
@ -85,9 +85,8 @@
let insights = $derived(computeInsights(entries));
let onThisDay = $derived(getOnThisDay(entries));
async function handleQuickCreate(e: KeyboardEvent) {
if (e.key !== 'Enter' || !newTitle.trim()) return;
e.preventDefault();
async function handleQuickCreate() {
if (!newTitle.trim()) return;
const entry = await journalStore.createEntry({ title: newTitle.trim() });
newTitle = '';
startEdit(entry);
@ -182,26 +181,6 @@
</script>
<div class="app-view">
<!-- Voice capture -->
<VoiceCaptureBar
idleLabel="Eintrag sprechen"
feature="journal-voice-capture"
reason="Spracheinträge werden verschlüsselt in deinem Tagebuch gespeichert. Dafür brauchst du ein Mana-Konto."
onComplete={handleVoiceComplete}
/>
<!-- Quick create -->
<form onsubmit={(e) => e.preventDefault()} class="quick-add">
<span class="add-icon">{'\u{270d}\u{fe0f}'}</span>
<input
class="add-input"
type="text"
placeholder="Was bewegt dich heute? (Enter)"
bind:value={newTitle}
onkeydown={handleQuickCreate}
/>
</form>
<!-- On this day -->
{#if onThisDay.length > 0}
<div class="on-this-day">
@ -422,6 +401,16 @@
<p class="empty">Schreibe deinen ersten Tagebucheintrag.</p>
{/if}
<FloatingInputBar
bind:value={newTitle}
placeholder="Was bewegt dich heute?"
onSubmit={handleQuickCreate}
voice
voiceFeature="journal-voice-capture"
voiceReason="Spracheinträge werden verschlüsselt in deinem Tagebuch gespeichert. Dafür brauchst du ein Mana-Konto."
onVoiceComplete={handleVoiceComplete}
/>
<ContextMenu
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
@ -438,31 +427,7 @@
gap: 0.625rem;
padding: 1rem;
height: 100%;
}
/* ── Quick Add ─────────────────────────────── */
.quick-add {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
}
.add-icon {
font-size: 0.875rem;
}
.add-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.add-input::placeholder {
color: hsl(var(--color-muted-foreground));
position: relative;
}
/* ── On This Day ──────────────────────────── */
@ -597,6 +562,7 @@
.entry-list {
flex: 1;
overflow-y: auto;
padding-bottom: 4rem;
}
.month-label {

View file

@ -9,7 +9,7 @@
import type { ViewProps } from '$lib/app-registry';
import type { LocalMemo } from './types';
import { memosStore } from './stores/memos.svelte';
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
let { navigate }: ViewProps = $props();
@ -26,6 +26,19 @@
const pinned = $derived(memos.filter((m) => m.isPinned));
let memoTitle = $state('');
async function handleTextCreate() {
if (!memoTitle.trim()) return;
const memo = await memosStore.create({ title: memoTitle.trim() });
memoTitle = '';
navigate('detail', {
memoId: memo.id,
_siblingIds: sorted.map((m) => m.id),
_siblingKey: 'memoId',
});
}
async function handleVoiceComplete(blob: Blob, durationMs: number) {
const memo = await memosStore.createFromVoice(blob, durationMs, 'de');
// Open the new memo so the user sees the transcription land
@ -52,63 +65,73 @@
};
</script>
<BaseListView items={sorted} getKey={(m) => m.id} emptyTitle="Keine Memos">
{#snippet toolbar()}
<VoiceCaptureBar
idleLabel="Memo sprechen"
feature="memoro-voice-capture"
reason="Sprach-Memos werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto."
onComplete={handleVoiceComplete}
/>
{/snippet}
<div class="memoro-view">
<BaseListView items={sorted} getKey={(m) => m.id} emptyTitle="Keine Memos">
{#snippet header()}
<span>{memos.length} Memos</span>
<span>{pinned.length} angepinnt</span>
{/snippet}
{#snippet header()}
<span>{memos.length} Memos</span>
<span>{pinned.length} angepinnt</span>
{/snippet}
{#snippet item(memo)}
<button
onclick={() =>
navigate('detail', {
memoId: memo.id,
_siblingIds: sorted.map((m) => m.id),
_siblingKey: 'memoId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1">
{#if memo.isPinned}
<span class="text-xs text-white/30">&#128204;</span>
{#snippet item(memo)}
<button
onclick={() =>
navigate('detail', {
memoId: memo.id,
_siblingIds: sorted.map((m) => m.id),
_siblingKey: 'memoId',
})}
class="mb-2 w-full rounded-md border border-white/10 px-3 py-2.5 text-left transition-colors hover:bg-white/5 min-h-[44px]"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1">
{#if memo.isPinned}
<span class="text-xs text-white/30">&#128204;</span>
{/if}
<p class="truncate text-sm font-medium text-white/80">
{memo.title || 'Unbenanntes Memo'}
</p>
</div>
{#if memo.intro}
<p class="mt-0.5 truncate text-xs text-white/40">{memo.intro}</p>
{/if}
<p class="truncate text-sm font-medium text-white/80">
{memo.title || 'Unbenanntes Memo'}
</p>
</div>
{#if memo.intro}
<p class="mt-0.5 truncate text-xs text-white/40">{memo.intro}</p>
{/if}
</div>
<div class="flex items-center gap-1.5 shrink-0">
{#if memo.transcriptModel && memo.processingStatus === 'completed'}
<div class="flex items-center gap-1.5 shrink-0">
{#if memo.transcriptModel && memo.processingStatus === 'completed'}
<span
class="rounded px-1 py-0.5 text-[9px] bg-white/5 text-white/30"
title="STT-Pipeline"
>
{memo.transcriptModel}
</span>
{/if}
<span
class="rounded px-1 py-0.5 text-[9px] bg-white/5 text-white/30"
title="STT-Pipeline"
class="rounded px-1.5 py-0.5 text-[10px] {statusColors[memo.processingStatus] ?? ''}"
>
{memo.transcriptModel}
{memo.processingStatus === 'completed'
? formatDuration(memo.audioDurationMs)
: memo.processingStatus}
</span>
{/if}
<span
class="rounded px-1.5 py-0.5 text-[10px] {statusColors[memo.processingStatus] ?? ''}"
>
{memo.processingStatus === 'completed'
? formatDuration(memo.audioDurationMs)
: memo.processingStatus}
</span>
</div>
</div>
</div>
</button>
{/snippet}
</BaseListView>
</button>
{/snippet}
</BaseListView>
<FloatingInputBar
bind:value={memoTitle}
placeholder="Memo sprechen..."
onSubmit={handleTextCreate}
voice
voiceFeature="memoro-voice-capture"
voiceReason="Sprach-Memos werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto."
onVoiceComplete={handleVoiceComplete}
/>
</div>
<style>
.memoro-view {
height: 100%;
position: relative;
}
</style>

View file

@ -10,7 +10,7 @@
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import { PencilSimple, Trash, PushPin } from '@mana/shared-icons';
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
import FloatingInputBar from '$lib/components/FloatingInputBar.svelte';
let { navigate, goBack, params }: ViewProps = $props();
@ -25,9 +25,8 @@
let filtered = $derived(searchNotes(notes, searchQuery));
async function handleQuickCreate(e: KeyboardEvent) {
if (e.key !== 'Enter' || !newTitle.trim()) return;
e.preventDefault();
async function handleQuickCreate() {
if (!newTitle.trim()) return;
const note = await notesStore.createNote({ title: newTitle.trim() });
newTitle = '';
startEdit(note);
@ -119,26 +118,6 @@
</script>
<div class="app-view">
<!-- Voice capture -->
<VoiceCaptureBar
idleLabel="Notiz sprechen"
feature="notes-voice-capture"
reason="Notizen werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto."
onComplete={handleVoiceComplete}
/>
<!-- Quick create -->
<form onsubmit={(e) => e.preventDefault()} class="quick-add">
<span class="add-icon">+</span>
<input
class="add-input"
type="text"
placeholder="Neue Notiz... (Enter)"
bind:value={newTitle}
onkeydown={handleQuickCreate}
/>
</form>
<!-- Search -->
{#if notes.length > 5}
<input class="search-input" type="text" placeholder="Suchen..." bind:value={searchQuery} />
@ -210,9 +189,19 @@
</div>
{#if notes.length === 0}
<p class="empty">Tippe oben, um eine Notiz zu erstellen.</p>
<p class="empty">Erstelle deine erste Notiz.</p>
{/if}
<FloatingInputBar
bind:value={newTitle}
placeholder="Neue Notiz..."
onSubmit={handleQuickCreate}
voice
voiceFeature="notes-voice-capture"
voiceReason="Notizen werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto."
onVoiceComplete={handleVoiceComplete}
/>
<ContextMenu
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
@ -229,36 +218,9 @@
gap: 0.625rem;
padding: 1rem;
height: 100%;
position: relative;
}
/* P5: theme-token migration. */
.quick-add {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
}
.add-icon {
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
}
.add-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.add-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.search-input {
padding: 0.3rem 0.5rem;
border-radius: 0.375rem;
@ -277,6 +239,7 @@
.note-list {
flex: 1;
overflow-y: auto;
padding-bottom: 4rem;
}
.note-item {
display: flex;