mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 05:41:09 +02:00
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:
parent
0deab50a9c
commit
949795c267
7 changed files with 415 additions and 402 deletions
165
apps/mana/apps/web/src/lib/components/FloatingInputBar.svelte
Normal file
165
apps/mana/apps/web/src/lib/components/FloatingInputBar.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">🌙</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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">📌</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">📌</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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue