feat(manacore/web): theme-aware AppViews with inline creation

Rewrite Todo, Calendar, and Contacts AppView components for the
workbench paper-sheet pages:
- Theme-aware styling (light paper-sheet + dark mode)
- Todo: quick-add input, checkbox toggle, filter tabs
- Calendar: week strip with event dots, inline event creation
- Contacts: search + inline create form (name, email)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 14:33:22 +02:00
parent 3ea28b9065
commit eabd9200a3
3 changed files with 709 additions and 83 deletions

View file

@ -1,11 +1,13 @@
<!--
Calendar — Split-Screen AppView
Mini week view with today's events.
Calendar — Workbench AppView
Mini week view with today's events + quick event creation.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalEvent } from './types';
import { eventsStore } from './stores/events.svelte';
import { Plus } from '@manacore/shared-icons';
let events = $state<LocalEvent[]>([]);
@ -15,7 +17,7 @@
const weekDays = $derived(() => {
const days: Date[] = [];
const start = new Date(now);
start.setDate(start.getDate() - start.getDay() + 1); // Monday
start.setDate(start.getDate() - start.getDay() + 1);
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(d.getDate() + i);
@ -47,32 +49,83 @@
}
const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
// Quick event creation
let showNewEvent = $state(false);
let newTitle = $state('');
let newTime = $state('09:00');
async function createEvent() {
const title = newTitle.trim();
if (!title) return;
const startTime = `${todayStr}T${newTime}:00`;
const [h, m] = newTime.split(':').map(Number);
const endH = h + 1;
const endTime = `${todayStr}T${String(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';
await eventsStore.createEvent({
calendarId,
title,
startTime,
endTime,
});
newTitle = '';
showNewEvent = false;
}
</script>
<div class="flex h-full flex-col gap-4 p-4">
<div class="app-view">
<!-- Mini week strip -->
<div class="grid grid-cols-7 gap-1">
<div class="week-strip">
{#each weekDays() as day, i}
{@const isToday = day.toISOString().split('T')[0] === todayStr}
<div class="flex flex-col items-center gap-1">
<span class="text-[10px] text-white/40">{dayNames[i]}</span>
<span
class="flex h-7 w-7 items-center justify-center rounded-full text-xs
{isToday ? 'bg-blue-500 text-white' : 'text-white/60'}"
>
{@const dayEvents = events.filter((e) =>
e.startDate.startsWith(day.toISOString().split('T')[0])
)}
<div class="day-col">
<span class="day-name">{dayNames[i]}</span>
<span class="day-num" class:today={isToday}>
{day.getDate()}
</span>
{#if dayEvents.length > 0}
<span class="day-dot"></span>
{/if}
</div>
{/each}
</div>
<!-- Today's events -->
<div class="flex-1 overflow-auto">
<h3 class="mb-2 text-xs font-medium text-white/50">Heute</h3>
<div class="events-section">
<div class="section-header">
<h3 class="section-title">Heute</h3>
<button class="add-btn" onclick={() => (showNewEvent = !showNewEvent)} title="Neuer Termin">
<Plus size={14} />
</button>
</div>
{#if showNewEvent}
<form
onsubmit={(e) => {
e.preventDefault();
createEvent();
}}
class="new-event-form"
>
<input bind:value={newTitle} placeholder="Termin-Titel..." class="event-input" autofocus />
<input bind:value={newTime} type="time" class="event-time" />
<button type="submit" class="event-submit">OK</button>
</form>
{/if}
{#each todayEvents as event (event.id)}
<div class="mb-2 rounded-md border border-white/10 bg-white/5 px-3 py-2">
<p class="text-sm font-medium text-white/80">{event.title}</p>
<p class="text-xs text-white/40">
<div class="event-card">
<p class="event-title">{event.title}</p>
<p class="event-time-label">
{#if event.allDay}
Ganztägig
{:else}
@ -80,13 +133,203 @@
{/if}
</p>
{#if event.location}
<p class="text-xs text-white/30">{event.location}</p>
<p class="event-location">{event.location}</p>
{/if}
</div>
{/each}
{#if todayEvents.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Termine heute</p>
{#if todayEvents.length === 0 && !showNewEvent}
<p class="empty">Keine Termine heute</p>
{/if}
</div>
</div>
<style>
.app-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
height: 100%;
}
.week-strip {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
}
.day-col {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.day-name {
font-size: 0.625rem;
color: #9ca3af;
}
:global(.dark) .day-name {
color: #6b7280;
}
.day-num {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 9999px;
font-size: 0.75rem;
color: #6b7280;
}
.day-num.today {
background: #3b82f6;
color: white;
font-weight: 600;
}
:global(.dark) .day-num {
color: #9ca3af;
}
.day-dot {
width: 4px;
height: 4px;
border-radius: 9999px;
background: #3b82f6;
}
.events-section {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.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: #9ca3af;
margin: 0;
}
.add-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 0.25rem;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.add-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #3b82f6;
}
:global(.dark) .add-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #60a5fa;
}
.new-event-form {
display: flex;
gap: 0.375rem;
padding: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.375rem;
animation: slideDown 0.15s ease-out;
}
:global(.dark) .new-event-form {
border-color: rgba(255, 255, 255, 0.08);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.event-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: #374151;
min-width: 0;
}
.event-input::placeholder {
color: #c0bfba;
}
:global(.dark) .event-input {
color: #f3f4f6;
}
:global(.dark) .event-input::placeholder {
color: #4b5563;
}
.event-time {
width: 5rem;
border: none;
background: transparent;
outline: none;
font-size: 0.75rem;
color: #6b7280;
text-align: center;
}
:global(.dark) .event-time {
color: #9ca3af;
color-scheme: dark;
}
.event-submit {
padding: 0.25rem 0.5rem;
border: none;
border-radius: 0.25rem;
background: #3b82f6;
color: white;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
}
.event-card {
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(0, 0, 0, 0.02);
}
:global(.dark) .event-card {
border-color: rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
}
.event-title {
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
margin: 0;
}
:global(.dark) .event-title {
color: #e5e7eb;
}
.event-time-label {
font-size: 0.6875rem;
color: #9ca3af;
margin: 0;
}
.event-location {
font-size: 0.6875rem;
color: #b0afa8;
margin: 0;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
</style>

View file

@ -1,11 +1,13 @@
<!--
Contacts — Split-Screen AppView
Contact list with search.
Contacts — Workbench AppView
Contact list with search + quick create.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalContact } from './types';
import { contactsStore } from './stores/contacts.svelte';
import { Plus, Star } from '@manacore/shared-icons';
let contacts = $state<LocalContact[]>([]);
let search = $state('');
@ -44,39 +46,275 @@
const l = c.lastName?.[0] ?? '';
return (f + l).toUpperCase() || '?';
}
// Quick create
let showCreate = $state(false);
let newFirstName = $state('');
let newLastName = $state('');
let newEmail = $state('');
async function createContact() {
const firstName = newFirstName.trim();
const lastName = newLastName.trim();
if (!firstName && !lastName) return;
await contactsStore.createContact({
firstName: firstName || undefined,
lastName: lastName || undefined,
email: newEmail.trim() || undefined,
});
newFirstName = '';
newLastName = '';
newEmail = '';
showCreate = false;
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<input
bind:value={search}
placeholder="Kontakt suchen..."
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
/>
<div class="app-view">
<div class="search-row">
<input bind:value={search} placeholder="Kontakt suchen..." class="search-input" />
<button class="add-btn" onclick={() => (showCreate = !showCreate)} title="Neuer Kontakt">
<Plus size={14} />
</button>
</div>
<p class="text-xs text-white/40">{filtered().length} Kontakte</p>
{#if showCreate}
<form
onsubmit={(e) => {
e.preventDefault();
createContact();
}}
class="create-form"
>
<div class="form-row">
<input bind:value={newFirstName} placeholder="Vorname" class="form-input" autofocus />
<input bind:value={newLastName} placeholder="Nachname" class="form-input" />
</div>
<div class="form-row">
<input
bind:value={newEmail}
placeholder="E-Mail (optional)"
class="form-input full"
type="email"
/>
<button type="submit" class="form-submit">Anlegen</button>
</div>
</form>
{/if}
<div class="flex-1 overflow-auto">
<p class="count">{filtered().length} Kontakte</p>
<div class="contact-list">
{#each filtered() as contact (contact.id)}
<div class="flex items-center gap-3 rounded-md px-2 py-2 transition-colors hover:bg-white/5">
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/10 text-xs font-medium text-white/60"
>
{initials(contact)}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-white/80">{displayName(contact)}</p>
<div class="contact-item">
<div class="avatar">{initials(contact)}</div>
<div class="contact-info">
<p class="contact-name">{displayName(contact)}</p>
{#if contact.company}
<p class="truncate text-xs text-white/40">{contact.company}</p>
<p class="contact-company">{contact.company}</p>
{/if}
</div>
{#if contact.isFavorite}
<span class="text-yellow-400 text-xs">&#9733;</span>
<span class="fav"><Star size={12} weight="fill" /></span>
{/if}
</div>
{/each}
{#if filtered().length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Kontakte gefunden</p>
<p class="empty">Keine Kontakte gefunden</p>
{/if}
</div>
</div>
<style>
.app-view {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
height: 100%;
}
.search-row {
display: flex;
gap: 0.375rem;
}
.search-input {
flex: 1;
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.08);
background: transparent;
font-size: 0.8125rem;
color: #374151;
outline: none;
}
.search-input::placeholder {
color: #c0bfba;
}
.search-input:focus {
border-color: rgba(0, 0, 0, 0.15);
}
:global(.dark) .search-input {
border-color: rgba(255, 255, 255, 0.08);
color: #f3f4f6;
}
:global(.dark) .search-input::placeholder {
color: #4b5563;
}
:global(.dark) .search-input:focus {
border-color: rgba(255, 255, 255, 0.15);
}
.add-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 0.375rem;
border: none;
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.add-btn:hover {
background: rgba(0, 0, 0, 0.06);
color: #22c55e;
}
:global(.dark) .add-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #4ade80;
}
.create-form {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.5rem;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.375rem;
animation: slideDown 0.15s ease-out;
}
:global(.dark) .create-form {
border-color: rgba(255, 255, 255, 0.08);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.form-row {
display: flex;
gap: 0.375rem;
}
.form-input {
flex: 1;
padding: 0.3125rem 0.5rem;
border-radius: 0.25rem;
border: 1px solid rgba(0, 0, 0, 0.06);
background: transparent;
font-size: 0.75rem;
color: #374151;
outline: none;
min-width: 0;
}
.form-input::placeholder {
color: #c0bfba;
}
:global(.dark) .form-input {
border-color: rgba(255, 255, 255, 0.06);
color: #f3f4f6;
}
:global(.dark) .form-input::placeholder {
color: #4b5563;
}
.form-input.full {
flex: 2;
}
.form-submit {
padding: 0.3125rem 0.625rem;
border: none;
border-radius: 0.25rem;
background: #22c55e;
color: white;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
}
.count {
font-size: 0.6875rem;
color: #9ca3af;
}
.contact-list {
flex: 1;
overflow-y: auto;
}
.contact-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.375rem 0.25rem;
border-radius: 0.25rem;
transition: background 0.15s;
}
.contact-item:hover {
background: rgba(0, 0, 0, 0.03);
}
:global(.dark) .contact-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.avatar {
width: 32px;
height: 32px;
border-radius: 9999px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.06);
font-size: 0.6875rem;
font-weight: 600;
color: #6b7280;
}
:global(.dark) .avatar {
background: rgba(255, 255, 255, 0.08);
color: #9ca3af;
}
.contact-info {
min-width: 0;
flex: 1;
}
.contact-name {
font-size: 0.8125rem;
color: #374151;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.dark) .contact-name {
color: #e5e7eb;
}
.contact-company {
font-size: 0.6875rem;
color: #9ca3af;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fav {
color: #f59e0b;
display: flex;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
</style>

View file

@ -1,9 +1,8 @@
<!--
Todo — Split-Screen AppView
Todo — Workbench AppView
Compact task list with quick add, filter by inbox/today/overdue.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import {
useAllTasks,
filterIncomplete,
@ -13,6 +12,7 @@
getTaskStats,
} from './queries';
import { tasksStore } from './stores/tasks.svelte';
import { Circle, Check } from '@manacore/shared-icons';
type ViewFilter = 'inbox' | 'today' | 'overdue';
@ -44,7 +44,9 @@
async function addTask() {
const title = newTitle.trim();
if (!title) return;
await tasksStore.createTask({ title });
const data: Record<string, unknown> = { title };
if (filter === 'today') data.dueDate = new Date().toISOString();
await tasksStore.createTask(data as { title: string; dueDate?: string });
newTitle = '';
}
@ -53,75 +55,218 @@
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- Stats -->
<div class="flex gap-3 text-xs text-white/50">
<div class="app-view">
<div class="stats">
<span>{stats.total} gesamt</span>
<span>{stats.today} heute</span>
<span class:text-red-400={stats.overdue > 0}>{stats.overdue} überfällig</span>
<span class:overdue={stats.overdue > 0}>{stats.overdue} überfällig</span>
</div>
<!-- Filter tabs -->
<div class="flex gap-1">
<div class="filter-tabs">
{#each ['inbox', 'today', 'overdue'] as f}
<button
onclick={() => (filter = f as ViewFilter)}
class="rounded-md px-2.5 py-1 text-xs transition-colors
{filter === f ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/70'}"
class="filter-tab"
class:active={filter === f}
>
{f === 'inbox' ? 'Inbox' : f === 'today' ? 'Heute' : 'Überfällig'}
</button>
{/each}
</div>
<!-- Quick add -->
<form
onsubmit={(e) => {
e.preventDefault();
addTask();
}}
class="flex gap-2"
class="quick-add"
>
<input
bind:value={newTitle}
placeholder="Neue Aufgabe..."
class="flex-1 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
/>
<button
type="submit"
class="rounded-md bg-white/10 px-3 py-1.5 text-sm text-white/70 transition-colors hover:bg-white/15"
>+</button
>
<span class="add-icon"><Circle size={18} /></span>
<input bind:value={newTitle} placeholder="Neue Aufgabe..." class="add-input" />
</form>
<!-- Task list -->
<div class="flex-1 overflow-auto">
<div class="task-list">
{#each filtered() as task (task.id)}
<button
onclick={() => toggle(task.id)}
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-white/5"
>
<div
class="mt-0.5 h-4 w-4 shrink-0 rounded border transition-colors
{task.isCompleted ? 'border-green-500 bg-green-500/20' : 'border-white/20'}"
></div>
<div class="min-w-0 flex-1">
<p
class="truncate text-sm {task.isCompleted
? 'text-white/30 line-through'
: 'text-white/80'}"
>
{task.title}
</p>
<button onclick={() => toggle(task.id)} class="task-item">
<div class="checkbox" class:checked={task.isCompleted}>
{#if task.isCompleted}<Check size={12} />{/if}
</div>
<div class="task-content">
<p class="task-title" class:completed={task.isCompleted}>{task.title}</p>
{#if task.dueDate}
<p class="text-xs text-white/30">{new Date(task.dueDate).toLocaleDateString('de')}</p>
<p class="task-due">{new Date(task.dueDate).toLocaleDateString('de')}</p>
{/if}
</div>
</button>
{/each}
{#if filtered().length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Aufgaben</p>
<p class="empty">Keine Aufgaben</p>
{/if}
</div>
</div>
<style>
.app-view {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 1rem;
height: 100%;
}
.stats {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: #9ca3af;
}
.overdue {
color: #ef4444;
}
:global(.dark) .stats {
color: #6b7280;
}
.filter-tabs {
display: flex;
gap: 0.25rem;
}
.filter-tab {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: #9ca3af;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.filter-tab:hover {
color: #374151;
background: rgba(0, 0, 0, 0.04);
}
.filter-tab.active {
background: rgba(0, 0, 0, 0.06);
color: #374151;
}
:global(.dark) .filter-tab:hover {
color: #e5e7eb;
background: rgba(255, 255, 255, 0.06);
}
:global(.dark) .filter-tab.active {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
.quick-add {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.08);
background: transparent;
}
:global(.dark) .quick-add {
border-color: rgba(255, 255, 255, 0.08);
}
.add-icon {
color: #d1d5db;
display: flex;
}
:global(.dark) .add-icon {
color: #4b5563;
}
.add-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: #374151;
}
.add-input::placeholder {
color: #c0bfba;
}
:global(.dark) .add-input {
color: #f3f4f6;
}
:global(.dark) .add-input::placeholder {
color: #4b5563;
}
.task-list {
flex: 1;
overflow-y: auto;
}
.task-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
width: 100%;
padding: 0.375rem 0.25rem;
border: none;
background: transparent;
text-align: left;
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.15s;
}
.task-item:hover {
background: rgba(0, 0, 0, 0.03);
}
:global(.dark) .task-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.checkbox {
margin-top: 0.125rem;
width: 16px;
height: 16px;
border-radius: 0.25rem;
border: 1.5px solid #d1d5db;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
}
.checkbox.checked {
border-color: #22c55e;
background: #22c55e;
color: white;
}
:global(.dark) .checkbox {
border-color: #4b5563;
}
.task-content {
min-width: 0;
flex: 1;
}
.task-title {
font-size: 0.8125rem;
color: #374151;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-title.completed {
color: #9ca3af;
text-decoration: line-through;
}
:global(.dark) .task-title {
color: #e5e7eb;
}
:global(.dark) .task-title.completed {
color: #6b7280;
}
.task-due {
font-size: 0.6875rem;
color: #9ca3af;
margin: 0;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
</style>