mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
feat(ux): notepad design, keyboard navigation, contenteditable across todo/calendar/contacts
Todo: Notepad redesign (A4 width, lined paper, red margin), contenteditable titles, Arrow/Tab navigation between tasks, circular navigation with QuickInputBar, debug borders (Ctrl+Shift+D), removed hover effects, collapsed completed section. Calendar Agenda: contenteditable event titles, Arrow/Tab navigation, circular navigation with InputBar, removed hover effects, separate detail button. Contacts Alphabet: contenteditable contact names, Arrow/Tab navigation, circular navigation with InputBar, removed card hover, avatar as detail button. Guidelines: new sections for list navigation, contenteditable patterns, debug borders. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b9232438cf
commit
4a5fe3bee8
14 changed files with 1150 additions and 365 deletions
|
|
@ -346,6 +346,132 @@ Text: "Diese Aufgabe wird unwiderruflich gelöscht."
|
|||
| Abbrechen/Schließen | `Escape` |
|
||||
| Navigation | `Tab` / `Shift+Tab` |
|
||||
|
||||
### Listen-Navigation mit Tastatur
|
||||
|
||||
In Listen (Tasks, Kontakte, Favoriten etc.) soll der Nutzer sich nahtlos per Tastatur bewegen können, ohne dass Fokus auf nicht-editierbare Elemente (Checkboxes, Drag-Handles) springt.
|
||||
|
||||
**Prinzip: Zirkuläre Navigation zwischen Input und Liste**
|
||||
|
||||
```
|
||||
InputBar → ArrowUp/Tab → Erster Listeneintrag
|
||||
↕ ArrowUp/Down/Tab/Shift+Tab
|
||||
Letzter Eintrag → ArrowDown/Tab → InputBar
|
||||
```
|
||||
|
||||
**Implementierung mit `contenteditable`:**
|
||||
|
||||
```svelte
|
||||
<!-- Immer editierbar, kein Mode-Switch -->
|
||||
<span
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
spellcheck="true"
|
||||
onkeydown={handleKeydown}
|
||||
onblur={handleBlur}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
```
|
||||
|
||||
**Keydown-Handler für Listen-Navigation:**
|
||||
|
||||
```typescript
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
currentRef?.blur(); // Speichert via onblur
|
||||
} else if (e.key === 'Escape') {
|
||||
currentRef.textContent = originalValue; // Verwerfen
|
||||
currentRef?.blur();
|
||||
} else if (e.key === 'Tab' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const direction = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey) ? -1 : 1;
|
||||
e.preventDefault();
|
||||
// Alle editierbaren Elemente auf der Seite sammeln
|
||||
const allEditable = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('.item-title[contenteditable]')
|
||||
);
|
||||
const currentIndex = allEditable.indexOf(currentRef!);
|
||||
const next = allEditable[currentIndex + direction];
|
||||
currentRef?.blur();
|
||||
if (next) {
|
||||
next.focus();
|
||||
} else {
|
||||
// Am Rand der Liste: Fokus zur InputBar
|
||||
document.querySelector<HTMLInputElement>('.quick-input-bar input')?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Von der InputBar in die Liste (Page-Level Handler):**
|
||||
|
||||
```svelte
|
||||
<svelte:window onkeydown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.quick-input-bar') && (e.key === 'ArrowUp' || e.key === 'Tab')) {
|
||||
const first = document.querySelector<HTMLElement>('.item-title[contenteditable]');
|
||||
if (first) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}} />
|
||||
```
|
||||
|
||||
**Regeln:**
|
||||
|
||||
- `contenteditable` statt Input-Toggle: Cursor landet direkt an Klick-Position
|
||||
- Kein visueller Unterschied zwischen Lese- und Edit-Modus (kein Border, kein Hintergrund)
|
||||
- Speichern via `blur`-Event (Auto-Save), nicht via explizitem Save-Button
|
||||
- ArrowDown/Up navigiert vertikal, Tab/Shift+Tab ebenfalls (gleiche Richtung)
|
||||
- Am Listenende/-anfang springt Fokus zurück zur InputBar (zirkulär)
|
||||
- Hover-Effekte auf Listeneinträgen vermeiden (ruhige UI)
|
||||
|
||||
### Inline-Editing mit contenteditable
|
||||
|
||||
**Bevorzuge `contenteditable` statt Input-Toggle** für Texte, die direkt in der Ansicht editiert werden.
|
||||
|
||||
| Aspekt | Input-Toggle | contenteditable |
|
||||
| -------------------- | ------------------------------ | ---------------------------- |
|
||||
| Klicks zum Editieren | 2 (aktivieren + positionieren) | 1 (Cursor an Klick-Position) |
|
||||
| DOM-Wechsel | `<span>` ↔ `<input>` | Keiner |
|
||||
| Mehrzeilig | Braucht `<textarea>` | Nativ |
|
||||
| Styling | Muss Input an Span anpassen | Gleich |
|
||||
|
||||
**Wann Input statt contenteditable:**
|
||||
|
||||
- Formular-Felder mit Validierung (Datum, Zahl, E-Mail)
|
||||
- Wenn `type="date"`, `type="number"` etc. benötigt wird
|
||||
- In Expanded-Forms/Modals (nicht inline)
|
||||
|
||||
### Debug-Borders (Entwickler-Tool)
|
||||
|
||||
`Ctrl+Shift+D` aktiviert farbkodierte Outlines für alle UI-Elemente. Nutzt `outline` statt `border` um Layout nicht zu beeinflussen. State wird in localStorage persistiert.
|
||||
|
||||
```css
|
||||
.debug-mode * {
|
||||
outline: 1px solid rgba(255, 0, 0, 0.3) !important;
|
||||
}
|
||||
.debug-mode div {
|
||||
outline-color: rgba(255, 0, 0, 0.4) !important;
|
||||
}
|
||||
.debug-mode section {
|
||||
outline-color: rgba(0, 100, 255, 0.5) !important;
|
||||
}
|
||||
.debug-mode nav {
|
||||
outline-color: rgba(0, 180, 0, 0.5) !important;
|
||||
}
|
||||
.debug-mode button {
|
||||
outline-color: rgba(255, 220, 0, 0.6) !important;
|
||||
}
|
||||
.debug-mode input,
|
||||
.debug-mode textarea {
|
||||
outline-color: rgba(139, 92, 246, 0.6) !important;
|
||||
}
|
||||
```
|
||||
|
||||
Aktuell in Todo implementiert, geplant als `@manacore/shared-debug` Package.
|
||||
|
||||
## Z-Index-Hierarchie
|
||||
|
||||
Konsistente Schichtung für Overlays:
|
||||
|
|
@ -390,6 +516,10 @@ font-bold: 700 /* Wichtige Titel */
|
|||
- [ ] Inline-Interaktion statt Modal möglich?
|
||||
- [ ] Loading/Error/Empty States vorhanden?
|
||||
- [ ] Tastatur-Navigation funktioniert?
|
||||
- [ ] Listen: Arrow/Tab-Navigation zwischen Einträgen?
|
||||
- [ ] Listen: Zirkuläre Navigation zu InputBar?
|
||||
- [ ] Inline-Editing: contenteditable statt Input-Toggle?
|
||||
- [ ] Keine Hover-Effekte auf Listeneinträgen?
|
||||
- [ ] Animations sparsam und sinnvoll?
|
||||
- [ ] CSS-Variablen statt hardcoded Farben?
|
||||
- [ ] Konsistente Spacing-Werte?
|
||||
|
|
|
|||
|
|
@ -103,6 +103,41 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Inline title editing
|
||||
function handleTitleBlur(event: CalendarEvent, el: HTMLSpanElement) {
|
||||
const trimmed = (el.textContent || '').trim();
|
||||
if (trimmed && trimmed !== event.title) {
|
||||
eventsStore.updateEvent(event.id, { title: trimmed });
|
||||
} else {
|
||||
el.textContent = event.title;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTitleKeydown(e: KeyboardEvent, event: CalendarEvent) {
|
||||
const target = e.target as HTMLSpanElement;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
target.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
target.textContent = event.title;
|
||||
target.blur();
|
||||
} else if (e.key === 'Tab' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const direction = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey) ? -1 : 1;
|
||||
e.preventDefault();
|
||||
const allTitles = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('.agenda-event-title[contenteditable]')
|
||||
);
|
||||
const currentIndex = allTitles.indexOf(target);
|
||||
const next = allTitles[currentIndex + direction];
|
||||
target.blur();
|
||||
if (next) {
|
||||
next.focus();
|
||||
} else {
|
||||
document.querySelector<HTMLInputElement>('.quick-input-bar input')?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu state
|
||||
let contextMenuVisible = $state(false);
|
||||
let contextMenuX = $state(0);
|
||||
|
|
@ -180,9 +215,9 @@
|
|||
|
||||
<div class="events-for-date">
|
||||
{#each group.events as event}
|
||||
<button
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="event-item"
|
||||
onclick={() => handleEventClick(event)}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -207,7 +242,18 @@
|
|||
)}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="event-title">{event.title}</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="event-title agenda-event-title"
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
spellcheck="true"
|
||||
onkeydown={(e) => handleTitleKeydown(e, event)}
|
||||
onblur={(e) => handleTitleBlur(event, e.target as HTMLSpanElement)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{event.title}
|
||||
</span>
|
||||
{#if event.location}
|
||||
<div class="event-location">
|
||||
<svg
|
||||
|
|
@ -233,15 +279,22 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<svg class="chevron-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="expand-btn"
|
||||
onclick={() => handleEventClick(event)}
|
||||
title="Details öffnen"
|
||||
aria-label="Details öffnen"
|
||||
>
|
||||
<svg class="chevron-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -329,25 +382,14 @@
|
|||
|
||||
.event-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
background: hsl(var(--color-surface));
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
|
||||
.event-item:hover {
|
||||
transform: translateX(4px);
|
||||
border-color: hsl(var(--color-border-hover, var(--color-border)));
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.color-bar {
|
||||
|
|
@ -376,9 +418,13 @@
|
|||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
cursor: text;
|
||||
outline: none;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.0625rem 0.125rem;
|
||||
margin: -0.0625rem -0.125rem;
|
||||
}
|
||||
|
||||
.event-location {
|
||||
|
|
@ -396,15 +442,30 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.expand-btn:hover {
|
||||
opacity: 1;
|
||||
background: hsl(var(--color-surface-hover, var(--color-surface)));
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-item:hover .chevron-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { todosStore } from '$lib/stores/todos.svelte';
|
||||
|
|
@ -142,6 +143,23 @@
|
|||
<title>{$_('app.name')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
if (viewStore.viewType !== 'agenda') return;
|
||||
const target = e.target as HTMLElement;
|
||||
const isInQuickInput = target.closest('.quick-input-bar');
|
||||
if (isInQuickInput && (e.key === 'ArrowUp' || (e.key === 'Tab' && !e.shiftKey))) {
|
||||
const firstTitle = document.querySelector<HTMLElement>(
|
||||
'.agenda-event-title[contenteditable]'
|
||||
);
|
||||
if (firstTitle) {
|
||||
e.preventDefault();
|
||||
firstTitle.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="service-banners">
|
||||
<ServiceStatusBanner
|
||||
serviceName="Todo-Service"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
|
||||
import { contactsFilterStore } from '$lib/stores/filter.svelte';
|
||||
import { contactsSettings } from '$lib/stores/settings.svelte';
|
||||
import { contactsStore } from '$lib/stores/contacts.svelte';
|
||||
import AlphabetNavContextMenu from '$lib/components/AlphabetNavContextMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -100,6 +101,50 @@
|
|||
// Available letters (letters that have contacts)
|
||||
let availableLetters = $derived(Object.keys(groupedContacts).sort());
|
||||
|
||||
// Inline name editing
|
||||
function handleNameBlur(contact: Contact, el: HTMLSpanElement) {
|
||||
const trimmed = (el.textContent || '').trim();
|
||||
const currentName = getDisplayName(contact);
|
||||
if (trimmed && trimmed !== currentName) {
|
||||
// Parse display name back into first/last name
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const firstName = parts[0] || '';
|
||||
const lastName = parts.slice(1).join(' ') || '';
|
||||
contactsStore.updateContact(contact.id, {
|
||||
firstName,
|
||||
lastName,
|
||||
displayName: trimmed,
|
||||
});
|
||||
} else {
|
||||
el.textContent = currentName;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNameKeydown(e: KeyboardEvent, contact: Contact) {
|
||||
const target = e.target as HTMLSpanElement;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
target.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
target.textContent = getDisplayName(contact);
|
||||
target.blur();
|
||||
} else if (e.key === 'Tab' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const direction = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey) ? -1 : 1;
|
||||
e.preventDefault();
|
||||
const allNames = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('.contact-name-editable[contenteditable]')
|
||||
);
|
||||
const currentIndex = allNames.indexOf(target);
|
||||
const next = allNames[currentIndex + direction];
|
||||
target.blur();
|
||||
if (next) {
|
||||
next.focus();
|
||||
} else {
|
||||
document.querySelector<HTMLInputElement>('.quick-input-bar input')?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToLetter(letter: string) {
|
||||
const element = document.getElementById(`section-${letter}`);
|
||||
if (element) {
|
||||
|
|
@ -162,10 +207,6 @@
|
|||
<div class="section-contacts">
|
||||
{#each groupedContacts[letter] as contact (contact.id)}
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => onContactClick(contact.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onContactClick(contact.id)}
|
||||
class="alphabet-contact-card {selectionMode && selectedIds.has(contact.id)
|
||||
? 'selected'
|
||||
: ''}"
|
||||
|
|
@ -188,8 +229,12 @@
|
|||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="avatar-sm">
|
||||
<!-- Avatar — click opens detail -->
|
||||
<button
|
||||
class="avatar-sm avatar-btn"
|
||||
onclick={() => onContactClick(contact.id)}
|
||||
title="Details öffnen"
|
||||
>
|
||||
{#if contact.photoUrl}
|
||||
<img
|
||||
src={contact.photoUrl}
|
||||
|
|
@ -199,12 +244,23 @@
|
|||
{:else}
|
||||
{getInitials(contact)}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Contact Info -->
|
||||
<div class="contact-info">
|
||||
<div class="contact-main-row">
|
||||
<span class="contact-name">{getDisplayName(contact)}</span>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="contact-name contact-name-editable"
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
spellcheck="true"
|
||||
onkeydown={(e) => handleNameKeydown(e, contact)}
|
||||
onblur={(e) => handleNameBlur(contact, e.target as HTMLSpanElement)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{getDisplayName(contact)}
|
||||
</span>
|
||||
{#if contact.isFavorite}
|
||||
<svg class="favorite-badge" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
|
|
@ -238,7 +294,6 @@
|
|||
{#if contact.phone || contact.mobile}
|
||||
<a
|
||||
href="tel:{contact.mobile || contact.phone}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="action-chip"
|
||||
title={contact.mobile || contact.phone}
|
||||
>
|
||||
|
|
@ -253,12 +308,7 @@
|
|||
</a>
|
||||
{/if}
|
||||
{#if contact.email}
|
||||
<a
|
||||
href="mailto:{contact.email}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
class="action-chip"
|
||||
title={contact.email}
|
||||
>
|
||||
<a href="mailto:{contact.email}" class="action-chip" title={contact.email}>
|
||||
<svg class="action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
|
|
@ -413,16 +463,9 @@
|
|||
background-color: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alphabet-contact-card:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
|
@ -460,6 +503,24 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.contact-name-editable {
|
||||
cursor: text;
|
||||
outline: none;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.0625rem 0.125rem;
|
||||
margin: -0.0625rem -0.125rem;
|
||||
}
|
||||
|
||||
.avatar-btn {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.avatar-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.favorite-badge {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
|
|
|
|||
|
|
@ -8,4 +8,20 @@
|
|||
<title>{$_('contacts.title')} - Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isInQuickInput = target.closest('.quick-input-bar');
|
||||
if (isInQuickInput && (e.key === 'ArrowUp' || (e.key === 'Tab' && !e.shiftKey))) {
|
||||
const firstName = document.querySelector<HTMLElement>(
|
||||
'.contact-name-editable[contenteditable]'
|
||||
);
|
||||
if (firstName) {
|
||||
e.preventDefault();
|
||||
firstName.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ContactList />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
---
|
||||
title: 'Todo: Notepad-Design & Keyboard-Navigation'
|
||||
description: 'Komplettes Redesign der Todo-Liste als Notizblock mit durchgängiger Tastaturnavigation zwischen Tasks und QuickInputBar.'
|
||||
date: 2026-03-30
|
||||
author: 'Till Schneider'
|
||||
category: 'feature'
|
||||
tags: ['todo', 'ux', 'keyboard-navigation', 'contenteditable', 'design', 'accessibility', 'notepad']
|
||||
featured: false
|
||||
commits: 42
|
||||
readTime: 5
|
||||
stats:
|
||||
filesChanged: 834
|
||||
linesAdded: 25713
|
||||
linesRemoved: 10299
|
||||
contributors:
|
||||
- name: 'Till Schneider'
|
||||
handle: 'Till-JS'
|
||||
commits: 42
|
||||
workingHours:
|
||||
start: '2026-03-30T09:00'
|
||||
end: '2026-03-30T18:00'
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
- Notizblock-Design mit liniertem Papier-Hintergrund und roter Margin-Linie
|
||||
- Durchgängige Tastaturnavigation: Pfeiltasten, Tab, Shift+Tab zwischen allen Tasks
|
||||
- contenteditable statt Input-Toggle: Cursor landet direkt an der Klick-Position
|
||||
- Zirkuläre Navigation: QuickInputBar -> Tasks -> QuickInputBar
|
||||
- Debug-Borders Modus (Ctrl+Shift+D) für UI-Debugging
|
||||
|
||||
## Notepad-Redesign
|
||||
|
||||
Die Todo-Homepage wurde von einer schmalen Card-Ansicht (560px) zu einem A4-breiten Notizblock (840px) umgebaut.
|
||||
|
||||
### Vorher
|
||||
|
||||
- Kleine weiße Card mit Box-Shadow auf grauem Hintergrund
|
||||
- CollapsibleSection-Komponenten mit Toggle-Logik
|
||||
- Task-Items mit Hover-Effekten (translateY, Hintergrundwechsel)
|
||||
- Separate Input-Elemente für Titel-Bearbeitung (zwei Klicks nötig)
|
||||
|
||||
### Nachher
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ ┃ Überfällig (2) │ ← Rote Margin-Linie
|
||||
│ ┃ ○ Steuererklärung abgeben Gestern │
|
||||
│ ┃ ○ Zahnarzt anrufen 25. Mär │
|
||||
│ ┃─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ← Gestrichelte Trenner
|
||||
│ ┃ Heute (3) │
|
||||
│ ┃ ○ Meeting vorbereiten Heute │
|
||||
│ ┃ ○ Code Review PR #42 Heute │
|
||||
│ ┃ ○ Einkaufen gehen Heute │
|
||||
│ ┃─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
|
||||
│ ┃ ▼ Erledigt (5) │ ← Standardmäßig eingeklappt
|
||||
└──────────────────────────────────────────────┘
|
||||
Linierter Hintergrund (Notizbuch-Linien)
|
||||
```
|
||||
|
||||
**Design-Entscheidungen:**
|
||||
|
||||
| Aspekt | Entscheidung | Grund |
|
||||
| ------------- | --------------------------------- | ---------------------------------------------- |
|
||||
| Hintergrund | Liniert (#fffef5) nur auf Sheet | Papier-Metapher, Hauptbereich bleibt neutral |
|
||||
| Breite | 840px (A4-ähnlich) | Mehr Platz für lange Titel, Labels, Kontakte |
|
||||
| Sections | Einfache Header statt Collapsible | Weniger UI-Noise, Sections sind immer relevant |
|
||||
| Erledigt | Eingeklappt mit Chevron-Toggle | Aktive Tasks im Fokus |
|
||||
| Drag-Handle | Links außerhalb des Sheets | Stört nicht, erscheint nur bei Hover |
|
||||
| Hover-Effekte | Komplett entfernt | Ruhigere UI, weniger visuelle Ablenkung |
|
||||
|
||||
## Keyboard-Navigation
|
||||
|
||||
### Navigationsmodell
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ QuickInputBar (unten) │ ← ArrowUp / Tab
|
||||
│ [Neue Aufgabe oder...] │
|
||||
└──────────┬──────────────────┘
|
||||
│ ↑ ArrowUp / Tab
|
||||
▼ ↓ ArrowDown / Tab
|
||||
┌─────────────────────────────┐
|
||||
│ Task 1: Meeting vorber... │ ← contenteditable, Cursor an Klick-Position
|
||||
│ Task 2: Code Review PR... │ ← ArrowDown / Tab → nächster Task
|
||||
│ Task 3: Einkaufen gehen │ ← ArrowDown → zurück zur QuickInputBar
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### Tastatur-Befehle im Task-Titel
|
||||
|
||||
| Taste | Aktion |
|
||||
| --------------------------- | ------------------------ |
|
||||
| `ArrowDown` / `Tab` | Nächster Task-Titel |
|
||||
| `ArrowUp` / `Shift+Tab` | Vorheriger Task-Titel |
|
||||
| `Enter` | Speichern und Blur |
|
||||
| `Escape` | Änderungen verwerfen |
|
||||
| Am letzten Task `ArrowDown` | Zurück zur QuickInputBar |
|
||||
| Am ersten Task `ArrowUp` | Zurück zur QuickInputBar |
|
||||
|
||||
### Von QuickInputBar zu Tasks
|
||||
|
||||
| Taste | Aktion |
|
||||
| --------- | ----------------------------- |
|
||||
| `ArrowUp` | Erster Task-Titel fokussieren |
|
||||
| `Tab` | Erster Task-Titel fokussieren |
|
||||
|
||||
### contenteditable statt Input-Toggle
|
||||
|
||||
**Vorher:** Klick auf Titel → `<span>` wird durch `<input>` ersetzt → zweiter Klick positioniert Cursor.
|
||||
|
||||
**Nachher:** Titel ist immer ein `<span contenteditable>` → ein Klick positioniert den Cursor sofort an der Maus-Position. Kein visueller Unterschied zwischen Lese- und Bearbeitungsmodus.
|
||||
|
||||
```svelte
|
||||
<span
|
||||
class="task-title"
|
||||
contenteditable="true"
|
||||
onkeydown={handleTitleKeydown}
|
||||
onblur={handleTitleBlur}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
```
|
||||
|
||||
## Debug-Borders
|
||||
|
||||
Neues Entwickler-Tool: `Ctrl+Shift+D` aktiviert farbkodierte Outlines für alle UI-Elemente.
|
||||
|
||||
| Element | Farbe |
|
||||
| ---------------- | ------- |
|
||||
| `div` | Rot |
|
||||
| `section` | Blau |
|
||||
| `nav` | Grün |
|
||||
| `button` | Gelb |
|
||||
| `input/textarea` | Violett |
|
||||
| `a` | Pink |
|
||||
| `img/svg` | Cyan |
|
||||
|
||||
State wird in localStorage persistiert. Aktuell nur in der Todo-App, geplant als globales Shared-Package.
|
||||
|
||||
## Weitere Verbesserungen
|
||||
|
||||
| Bereich | Vorher | Nachher |
|
||||
| ---------------------- | ------------------------ | --------------------------------------- |
|
||||
| Task-Titel | Einzeilig, abgeschnitten | Mehrzeilig mit word-break |
|
||||
| Checkbox-Alignment | Vertikal zentriert | Am Anfang der ersten Zeile (flex-start) |
|
||||
| Mobile Bottom-Padding | 150px | 100px |
|
||||
| Erledigt-Datum Opacity | 0.5 (zu blass) | 0.7, Hover 1.0 |
|
||||
| Task-Hover | translateY + Hintergrund | Kein Hover-Effekt |
|
||||
| Titel-Input | Border + Hintergrund | Unsichtbar, gleiche Position |
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Bereich | Commits | Highlights |
|
||||
| ------------------- | ------- | ----------------------------------------------- |
|
||||
| Notepad-Design | ~15 | Liniertes Papier, A4-Breite, Section-Redesign |
|
||||
| Keyboard-Navigation | ~10 | Arrow/Tab zwischen Tasks, zirkulär mit InputBar |
|
||||
| contenteditable | ~5 | Direkte Cursor-Platzierung, kein Mode-Switch |
|
||||
| Debug-Borders | ~3 | Ctrl+Shift+D, farbkodiert, localStorage |
|
||||
| Cleanup | ~9 | Hover entfernt, Alignment, Opacity |
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
- Debug-Borders als globales Shared-Package für alle Apps
|
||||
- Keyboard-Navigation Pattern in andere Apps übertragen (Zitare, Contacts)
|
||||
- Tastatur-Shortcut-Hilfe (? oder Ctrl+/) mit Overlay
|
||||
|
|
@ -42,13 +42,7 @@
|
|||
|
||||
/* Task item transitions */
|
||||
.task-item {
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
transform: translateY(-1px);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Checkbox animations */
|
||||
|
|
@ -135,3 +129,63 @@
|
|||
background-color: var(--color-secondary);
|
||||
border-left: 3px solid var(--color-primary);
|
||||
}
|
||||
|
||||
/* Debug Mode - Toggle with Ctrl+Shift+D */
|
||||
.debug-mode * {
|
||||
outline: 1px solid rgba(255, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
.debug-mode div {
|
||||
outline-color: rgba(255, 0, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
.debug-mode section {
|
||||
outline-color: rgba(0, 100, 255, 0.5) !important;
|
||||
}
|
||||
|
||||
.debug-mode nav {
|
||||
outline-color: rgba(0, 180, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
.debug-mode main {
|
||||
outline-color: rgba(255, 140, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
.debug-mode header,
|
||||
.debug-mode footer {
|
||||
outline-color: rgba(128, 0, 128, 0.5) !important;
|
||||
}
|
||||
|
||||
.debug-mode button {
|
||||
outline-color: rgba(255, 220, 0, 0.6) !important;
|
||||
}
|
||||
|
||||
.debug-mode input,
|
||||
.debug-mode textarea,
|
||||
.debug-mode select {
|
||||
outline-color: rgba(139, 92, 246, 0.6) !important;
|
||||
}
|
||||
|
||||
.debug-mode a {
|
||||
outline-color: rgba(236, 72, 153, 0.5) !important;
|
||||
}
|
||||
|
||||
.debug-mode img,
|
||||
.debug-mode svg {
|
||||
outline-color: rgba(0, 200, 200, 0.5) !important;
|
||||
}
|
||||
|
||||
.debug-mode ul,
|
||||
.debug-mode ol,
|
||||
.debug-mode li {
|
||||
outline-color: rgba(234, 179, 8, 0.4) !important;
|
||||
}
|
||||
|
||||
.debug-mode p,
|
||||
.debug-mode span,
|
||||
.debug-mode h1,
|
||||
.debug-mode h2,
|
||||
.debug-mode h3,
|
||||
.debug-mode h4 {
|
||||
outline-color: rgba(107, 114, 128, 0.3) !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,30 +180,42 @@
|
|||
}
|
||||
|
||||
// Inline title editing
|
||||
let isEditingTitle = $state(false);
|
||||
let editingTitle = $state('');
|
||||
let titleEditRef = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function handleTitleClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
isEditingTitle = true;
|
||||
editingTitle = task.title;
|
||||
setTimeout(() => titleEditRef?.focus(), 0);
|
||||
}
|
||||
let titleRef = $state<HTMLSpanElement | null>(null);
|
||||
|
||||
function handleTitleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
commitTitleEdit();
|
||||
e.preventDefault();
|
||||
titleRef?.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
isEditingTitle = false;
|
||||
if (titleRef) titleRef.textContent = task.title;
|
||||
titleRef?.blur();
|
||||
} else if (e.key === 'Tab' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const direction = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey) ? -1 : 1;
|
||||
e.preventDefault();
|
||||
const allTitles = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('.task-title[contenteditable]')
|
||||
);
|
||||
const currentIndex = allTitles.indexOf(titleRef!);
|
||||
const nextTitle = allTitles[currentIndex + direction];
|
||||
titleRef?.blur();
|
||||
if (nextTitle) {
|
||||
nextTitle.focus();
|
||||
} else {
|
||||
// No next/prev todo — focus QuickInputBar
|
||||
const input = document.querySelector<HTMLInputElement>('.quick-input-bar input');
|
||||
input?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function commitTitleEdit() {
|
||||
isEditingTitle = false;
|
||||
const trimmed = editingTitle.trim();
|
||||
function handleTitleBlur() {
|
||||
if (!titleRef) return;
|
||||
const trimmed = (titleRef.textContent || '').trim();
|
||||
if (trimmed && trimmed !== task.title) {
|
||||
onSave?.({ title: trimmed });
|
||||
} else {
|
||||
// Revert if empty or unchanged
|
||||
titleRef.textContent = task.title;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -409,22 +421,20 @@
|
|||
|
||||
<!-- Content -->
|
||||
<div class="task-content">
|
||||
{#if isEditingTitle}
|
||||
<input
|
||||
bind:this={titleEditRef}
|
||||
class="task-title-input"
|
||||
type="text"
|
||||
bind:value={editingTitle}
|
||||
onkeydown={handleTitleKeydown}
|
||||
onblur={commitTitleEdit}
|
||||
/>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span class="task-title" class:line-through={task.isCompleted} onclick={handleTitleClick}>
|
||||
{task.title}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
bind:this={titleRef}
|
||||
class="task-title"
|
||||
class:line-through={task.isCompleted}
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
spellcheck="true"
|
||||
onkeydown={handleTitleKeydown}
|
||||
onblur={handleTitleBlur}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
|
||||
<!-- Labels and subtasks below title -->
|
||||
{#if subtaskProgress() || (task.labels && task.labels.length > 0)}
|
||||
|
|
@ -721,13 +731,14 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
min-height: 2.5rem; /* matches notepad --line-height */
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-bottom: none;
|
||||
box-shadow: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
|
@ -748,21 +759,14 @@
|
|||
:global(.dark) .task-item {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.task-item:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .task-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.task-item-wrapper.expanded .task-item:hover {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.task-item.completed {
|
||||
|
|
@ -780,23 +784,24 @@
|
|||
border-bottom-color: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
/* Drag handle */
|
||||
/* Drag handle — sticks out left beyond the content area */
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.25;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 0.375rem;
|
||||
margin-left: -0.25rem;
|
||||
padding: 0.25rem 0.25rem;
|
||||
margin-left: -2rem;
|
||||
margin-right: -0.5rem;
|
||||
margin-top: 0.125rem;
|
||||
border-radius: 0.25rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.task-item:hover .drag-handle {
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
|
|
@ -828,6 +833,7 @@
|
|||
|
||||
/* Checkbox with priority color fill */
|
||||
.task-checkbox {
|
||||
margin-top: 0.1875rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 9999px;
|
||||
|
|
@ -947,40 +953,21 @@
|
|||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
cursor: text;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
margin: -0.125rem -0.25rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.task-title:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .task-title:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.task-title-input {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #8b5cf6;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
margin: -0.125rem -0.25rem;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.dark) .task-title-input {
|
||||
color: #f3f4f6;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: #8b5cf6;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.dark) .task-title {
|
||||
|
|
@ -1084,6 +1071,7 @@
|
|||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
:global(.dark) .due-date {
|
||||
|
|
@ -1109,12 +1097,12 @@
|
|||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.125rem 0;
|
||||
opacity: 0.5;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.completed-date-toggle:hover {
|
||||
opacity: 0.8;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.completed-date-toggle .date-label {
|
||||
|
|
|
|||
|
|
@ -317,16 +317,12 @@
|
|||
|
||||
<style>
|
||||
.task-list {
|
||||
min-height: 40px;
|
||||
min-height: 2.5rem;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.task-list :global(.task-item-wrapper:last-child .task-item) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.task-list.empty {
|
||||
border: 2px dashed rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
|
|
@ -362,7 +358,7 @@
|
|||
}
|
||||
|
||||
.dnd-shadow-placeholder {
|
||||
min-height: 3rem;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
/* Shadow placeholder (where dragged item will land) */
|
||||
|
|
|
|||
21
apps/todo/apps/web/src/lib/stores/debug.svelte.ts
Normal file
21
apps/todo/apps/web/src/lib/stores/debug.svelte.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
const DEBUG_KEY = 'manacore:debug-mode';
|
||||
|
||||
function createDebugStore() {
|
||||
let enabled = $state(
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem(DEBUG_KEY) === 'true' : false
|
||||
);
|
||||
|
||||
return {
|
||||
get enabled() {
|
||||
return enabled;
|
||||
},
|
||||
toggle() {
|
||||
enabled = !enabled;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(DEBUG_KEY, String(enabled));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const debugStore = createDebugStore();
|
||||
|
|
@ -9,6 +9,17 @@ import type { TaskPriority } from '@todo/shared';
|
|||
// Settings types
|
||||
export type TodoView = 'inbox' | 'today' | 'upcoming' | 'kanban' | 'completed';
|
||||
export type KanbanCardSize = 'compact' | 'normal' | 'large';
|
||||
export type PageMode = 'date' | 'priority' | 'custom';
|
||||
|
||||
export interface PageConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
filter: {
|
||||
priorities?: ('low' | 'medium' | 'high' | 'urgent')[];
|
||||
completed?: boolean;
|
||||
dateRange?: 'overdue' | 'today' | 'tomorrow' | 'upcoming' | 'any';
|
||||
};
|
||||
}
|
||||
|
||||
export interface TodoAppSettings extends Record<string, unknown> {
|
||||
// Task Behavior
|
||||
|
|
@ -50,6 +61,10 @@ export interface TodoAppSettings extends Record<string, unknown> {
|
|||
// Navigation UI
|
||||
pillNavCollapsed: boolean;
|
||||
filterStripCollapsed: boolean;
|
||||
|
||||
// Page mode
|
||||
pageMode: PageMode;
|
||||
customPages: PageConfig[];
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: TodoAppSettings = {
|
||||
|
|
@ -92,6 +107,10 @@ const DEFAULT_SETTINGS: TodoAppSettings = {
|
|||
// Navigation UI
|
||||
pillNavCollapsed: true, // PillNav hidden by default, shown via FAB
|
||||
filterStripCollapsed: false, // FilterStrip shown by default when PillNav is visible
|
||||
|
||||
// Page mode
|
||||
pageMode: 'priority' as PageMode,
|
||||
customPages: [] as PageConfig[],
|
||||
};
|
||||
|
||||
// Create base store using factory
|
||||
|
|
@ -183,6 +202,12 @@ export const todoSettings = {
|
|||
get filterStripCollapsed() {
|
||||
return baseStore.settings.filterStripCollapsed;
|
||||
},
|
||||
get pageMode() {
|
||||
return baseStore.settings.pageMode;
|
||||
},
|
||||
get customPages() {
|
||||
return baseStore.settings.customPages;
|
||||
},
|
||||
|
||||
// Toggle methods
|
||||
togglePillNav() {
|
||||
|
|
|
|||
|
|
@ -602,10 +602,10 @@
|
|||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 900px;
|
||||
max-width: none;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.content-wrapper.full-width {
|
||||
|
|
@ -615,9 +615,6 @@
|
|||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content-wrapper {
|
||||
padding: 1rem;
|
||||
}
|
||||
.content-wrapper.full-width {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
|
@ -625,19 +622,16 @@
|
|||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content-wrapper {
|
||||
padding: 2rem;
|
||||
}
|
||||
.content-wrapper.full-width {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: More space for QuickInputBar + PillNav */
|
||||
/* Mobile: Space for QuickInputBar + PillNav */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding-bottom: calc(150px + env(safe-area-inset-bottom));
|
||||
padding-bottom: calc(100px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,19 @@
|
|||
import { onMount, getContext } from 'svelte';
|
||||
import { format, addDays, subDays, startOfDay } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { Sparkle, ArrowDown } from '@manacore/shared-icons';
|
||||
import {
|
||||
Sparkle,
|
||||
ArrowDown,
|
||||
Warning,
|
||||
CalendarBlank,
|
||||
CalendarDots,
|
||||
CheckCircle,
|
||||
} from '@manacore/shared-icons';
|
||||
import { tasksStore } from '$lib/stores/tasks.svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { applyTaskFilters } from '$lib/utils/task-filters';
|
||||
import { filterOverdue, filterToday, filterCompleted } from '$lib/data/task-queries';
|
||||
import TaskList from '$lib/components/TaskList.svelte';
|
||||
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
||||
import { TaskListSkeleton } from '$lib/components/skeletons';
|
||||
import type { Task } from '@todo/shared';
|
||||
|
||||
|
|
@ -17,6 +23,7 @@
|
|||
getContext('tasks');
|
||||
|
||||
let tipDismissed = $state(false);
|
||||
let completedOpen = $state(false);
|
||||
|
||||
// Stable date references (computed once, not on every re-render)
|
||||
const today = startOfDay(new Date());
|
||||
|
|
@ -136,186 +143,433 @@
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build pages array from visible sections
|
||||
let pages = $derived.by(() => {
|
||||
const p: { id: string; label: string; icon: string }[] = [];
|
||||
if (overdueTasks.length > 0) p.push({ id: 'overdue', label: 'Überfällig', icon: 'warning' });
|
||||
if (showTodaySection) p.push({ id: 'today', label: 'Heute', icon: 'calendar' });
|
||||
if (showTomorrowSection) p.push({ id: 'tomorrow', label: 'Morgen', icon: 'calendar-dots' });
|
||||
if (showUpcomingSection) p.push({ id: 'upcoming', label: 'Demnächst', icon: 'calendar-dots' });
|
||||
if (showCompletedSection) p.push({ id: 'completed', label: 'Erledigt', icon: 'check' });
|
||||
return p;
|
||||
});
|
||||
|
||||
let activePage = $state(0);
|
||||
let scrollContainer: HTMLDivElement | undefined = $state();
|
||||
|
||||
function scrollToPage(index: number) {
|
||||
if (!scrollContainer) return;
|
||||
const sheets = scrollContainer.querySelectorAll('.notepad-sheet');
|
||||
if (sheets[index]) {
|
||||
sheets[index].scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollContainer) return;
|
||||
const sheets = scrollContainer.querySelectorAll('.notepad-sheet');
|
||||
const containerRect = scrollContainer.getBoundingClientRect();
|
||||
const center = containerRect.left + containerRect.width / 2;
|
||||
|
||||
let closest = 0;
|
||||
let closestDist = Infinity;
|
||||
sheets.forEach((sheet, i) => {
|
||||
const rect = sheet.getBoundingClientRect();
|
||||
const sheetCenter = rect.left + rect.width / 2;
|
||||
const dist = Math.abs(sheetCenter - center);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = i;
|
||||
}
|
||||
});
|
||||
activePage = closest;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Todo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="unified-view">
|
||||
{#if allTasks.loading}
|
||||
<TaskListSkeleton sections={3} tasksPerSection={3} />
|
||||
{:else if allTasks.error}
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
|
||||
{allTasks.error}
|
||||
<svelte:window
|
||||
onkeydown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const isInQuickInput = target.closest('.quick-input-bar');
|
||||
if (isInQuickInput && (e.key === 'ArrowUp' || (e.key === 'Tab' && !e.shiftKey))) {
|
||||
const firstTitle = document.querySelector<HTMLElement>('.task-title[contenteditable]');
|
||||
if (firstTitle) {
|
||||
e.preventDefault();
|
||||
firstTitle.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if allTasks.loading}
|
||||
<div class="notepad-page">
|
||||
<div class="scroll-track">
|
||||
<div class="notepad-sheet">
|
||||
<TaskListSkeleton sections={3} tasksPerSection={3} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if allEmpty}
|
||||
<!-- Enhanced empty state -->
|
||||
<div class="empty-state-container">
|
||||
<div class="empty-state-content">
|
||||
<div class="empty-state-icon">
|
||||
<Sparkle size={56} weight="duotone" />
|
||||
</div>
|
||||
{:else if allTasks.error}
|
||||
<div class="notepad-page">
|
||||
<div class="scroll-track">
|
||||
<div class="notepad-sheet">
|
||||
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 p-4 rounded-lg">
|
||||
{allTasks.error}
|
||||
</div>
|
||||
|
||||
<h2 class="empty-state-title">Bereit für einen produktiven Tag</h2>
|
||||
|
||||
<div class="empty-state-cta">
|
||||
<p class="empty-state-cta-text">Tippe unten um loszulegen...</p>
|
||||
<div class="empty-state-arrow">
|
||||
<ArrowDown size={20} weight="bold" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if allEmpty}
|
||||
<div class="notepad-page">
|
||||
<div class="scroll-track">
|
||||
<div class="notepad-sheet">
|
||||
<div class="empty-state-container">
|
||||
<div class="empty-state-content">
|
||||
<div class="empty-state-icon">
|
||||
<Sparkle size={56} weight="duotone" />
|
||||
</div>
|
||||
<h2 class="empty-state-title">Bereit für einen produktiven Tag</h2>
|
||||
<div class="empty-state-cta">
|
||||
<p class="empty-state-cta-text">Tippe unten um loszulegen...</p>
|
||||
<div class="empty-state-arrow">
|
||||
<ArrowDown size={20} weight="bold" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-state-examples">
|
||||
<p class="examples-label">Schnellstart-Tipps</p>
|
||||
<div class="examples-grid">
|
||||
{#each syntaxExamples as example}
|
||||
<button
|
||||
type="button"
|
||||
class="example-chip"
|
||||
onclick={() => handleExampleClick(example.text)}
|
||||
title={example.description}
|
||||
>
|
||||
{example.text}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Page tabs -->
|
||||
{#if pages.length > 1}
|
||||
<div class="page-tabs">
|
||||
{#each pages as page, i}
|
||||
<button class="page-tab" class:active={activePage === i} onclick={() => scrollToPage(i)}>
|
||||
{page.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="empty-state-examples">
|
||||
<p class="examples-label">Schnellstart-Tipps</p>
|
||||
<div class="examples-grid">
|
||||
{#each syntaxExamples as example}
|
||||
<button
|
||||
type="button"
|
||||
class="example-chip"
|
||||
onclick={() => handleExampleClick(example.text)}
|
||||
title={example.description}
|
||||
>
|
||||
{example.text}
|
||||
</button>
|
||||
<div class="notepad-page">
|
||||
<div class="scroll-track" bind:this={scrollContainer} onscroll={handleScroll}>
|
||||
<!-- Overdue page -->
|
||||
{#if overdueTasks.length > 0}
|
||||
<div class="notepad-sheet">
|
||||
<div class="sheet-header sheet-header-warning">
|
||||
<Warning size={18} weight="bold" />
|
||||
<span>Überfällig</span>
|
||||
<span class="section-count">{overdueTasks.length}</span>
|
||||
</div>
|
||||
<div class="sheet-content">
|
||||
<TaskList
|
||||
tasks={overdueTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate="overdue"
|
||||
onTaskDrop={handleTaskDrop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Today page -->
|
||||
{#if showTodaySection}
|
||||
<div class="notepad-sheet">
|
||||
<div class="sheet-header">
|
||||
<CalendarBlank size={18} weight="bold" />
|
||||
<span>Heute</span>
|
||||
{#if todayTasks.length > 0}
|
||||
<span class="section-count">{todayTasks.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sheet-content">
|
||||
<TaskList
|
||||
tasks={todayTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate={today}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
/>
|
||||
{#if showOnboardingTip && !tipDismissed}
|
||||
<div class="onboarding-tip">
|
||||
<span class="onboarding-tip-icon">💡</span>
|
||||
<span class="onboarding-tip-text">
|
||||
Tipp: Nutze <code>#tags</code> und <code>!priorität</code> für bessere Organisation
|
||||
</span>
|
||||
<button
|
||||
class="onboarding-tip-close"
|
||||
onclick={() => (tipDismissed = true)}
|
||||
title="Tipp ausblenden"
|
||||
aria-label="Tipp ausblenden"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tomorrow page -->
|
||||
{#if showTomorrowSection}
|
||||
<div class="notepad-sheet">
|
||||
<div class="sheet-header">
|
||||
<CalendarDots size={18} weight="bold" />
|
||||
<span>Morgen</span>
|
||||
<span class="section-count">{tomorrowTasks.length}</span>
|
||||
</div>
|
||||
<div class="sheet-content">
|
||||
<TaskList
|
||||
tasks={tomorrowTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate={tomorrow}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upcoming page -->
|
||||
{#if showUpcomingSection}
|
||||
<div class="notepad-sheet">
|
||||
<div class="sheet-header">
|
||||
<CalendarDots size={18} weight="bold" />
|
||||
<span>Demnächst</span>
|
||||
<span class="section-count">{upcomingCount}</span>
|
||||
</div>
|
||||
<div class="sheet-content">
|
||||
{#each groupedUpcomingTasks as group}
|
||||
<div class="subsection">
|
||||
<h3 class="subsection-label">{group.label}</h3>
|
||||
<TaskList
|
||||
tasks={group.tasks}
|
||||
enableDragDrop
|
||||
dropTargetDate={group.date}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="notepad">
|
||||
<div class="notepad-content">
|
||||
<div class="space-y-2">
|
||||
{#if overdueTasks.length > 0}
|
||||
<CollapsibleSection
|
||||
title="Überfällig"
|
||||
count={overdueTasks.length}
|
||||
icon="warning"
|
||||
variant="warning"
|
||||
defaultOpen={true}
|
||||
>
|
||||
<TaskList
|
||||
tasks={overdueTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate="overdue"
|
||||
onTaskDrop={handleTaskDrop}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if showTodaySection}
|
||||
<CollapsibleSection
|
||||
title="Heute"
|
||||
count={todayTasks.length}
|
||||
icon="today"
|
||||
variant="default"
|
||||
defaultOpen={true}
|
||||
>
|
||||
<TaskList
|
||||
tasks={todayTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate={today}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
{/if}
|
||||
|
||||
{#if showTomorrowSection}
|
||||
<CollapsibleSection
|
||||
title="Morgen"
|
||||
count={tomorrowTasks.length}
|
||||
icon="upcoming"
|
||||
variant="default"
|
||||
defaultOpen={true}
|
||||
>
|
||||
<TaskList
|
||||
tasks={tomorrowTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate={tomorrow}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
{/if}
|
||||
|
||||
{#if showUpcomingSection}
|
||||
<CollapsibleSection
|
||||
title="Demnächst"
|
||||
count={upcomingCount}
|
||||
icon="upcoming"
|
||||
variant="default"
|
||||
defaultOpen={true}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
{#each groupedUpcomingTasks as group}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-muted-foreground mb-2 pl-2">
|
||||
{group.label}
|
||||
</h3>
|
||||
<TaskList
|
||||
tasks={group.tasks}
|
||||
enableDragDrop
|
||||
dropTargetDate={group.date}
|
||||
onTaskDrop={handleTaskDrop}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
{/if}
|
||||
|
||||
{#if showCompletedSection}
|
||||
<CollapsibleSection
|
||||
title="Erledigt"
|
||||
count={completedTasks.length}
|
||||
icon="completed"
|
||||
variant="success"
|
||||
defaultOpen={true}
|
||||
>
|
||||
<TaskList
|
||||
tasks={completedTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate="completed"
|
||||
onTaskDrop={handleTaskDrop}
|
||||
showCompleted
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
{/if}
|
||||
|
||||
{#if showOnboardingTip && !tipDismissed}
|
||||
<div class="onboarding-tip">
|
||||
<span class="onboarding-tip-icon">💡</span>
|
||||
<span class="onboarding-tip-text">
|
||||
Tipp: Nutze <code>#tags</code> und <code>!priorität</code> für bessere Organisation
|
||||
</span>
|
||||
<button
|
||||
class="onboarding-tip-close"
|
||||
onclick={() => (tipDismissed = true)}
|
||||
title="Tipp ausblenden"
|
||||
aria-label="Tipp ausblenden"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Completed page -->
|
||||
{#if showCompletedSection}
|
||||
<div class="notepad-sheet sheet-completed">
|
||||
<div class="sheet-header">
|
||||
<CheckCircle size={18} weight="bold" />
|
||||
<span>Erledigt</span>
|
||||
<span class="section-count">{completedTasks.length}</span>
|
||||
</div>
|
||||
<div class="sheet-content">
|
||||
<TaskList
|
||||
tasks={completedTasks}
|
||||
enableDragDrop
|
||||
dropTargetDate="completed"
|
||||
onTaskDrop={handleTaskDrop}
|
||||
showCompleted
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.unified-view {
|
||||
/* ── Page tabs ── */
|
||||
.page-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 1.5rem 0;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.page-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-tab {
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #9ca3af;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.page-tab:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.page-tab.active {
|
||||
color: hsl(var(--color-primary));
|
||||
border-bottom-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
:global(.dark) .page-tab {
|
||||
color: #6b7280;
|
||||
}
|
||||
:global(.dark) .page-tab:hover {
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .page-tab.active {
|
||||
color: hsl(var(--color-primary-light, var(--color-primary)));
|
||||
border-bottom-color: hsl(var(--color-primary-light, var(--color-primary)));
|
||||
}
|
||||
|
||||
/* ── Notepad page — horizontal scroll wrapper ── */
|
||||
.notepad-page {
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.scroll-track {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-padding: 1.5rem;
|
||||
padding: 1rem 1.5rem 2rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scroll-track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Paper sheet ── */
|
||||
.notepad-sheet {
|
||||
flex: 0 0 auto;
|
||||
width: min(840px, 85vw);
|
||||
min-height: 60vh;
|
||||
scroll-snap-align: center;
|
||||
background: #fffef5;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.dark) .notepad-sheet {
|
||||
background-color: #252220;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sheet-completed {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ── Sheet header ── */
|
||||
.sheet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
:global(.dark) .sheet-header {
|
||||
color: #d1d5db;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sheet-header-warning {
|
||||
color: #dc2626;
|
||||
}
|
||||
:global(.dark) .sheet-header-warning {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #6b7280;
|
||||
padding: 0.0625rem 0.4375rem;
|
||||
border-radius: 9999px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
:global(.dark) .section-count {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #9ca3af;
|
||||
}
|
||||
.sheet-header-warning .section-count {
|
||||
background: rgba(220, 38, 38, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
:global(.dark) .sheet-header-warning .section-count {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* ── Sheet content ── */
|
||||
.sheet-content {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* ── Subsections (upcoming days) ── */
|
||||
.subsection {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.subsection-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
min-height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
:global(.dark) .subsection-label {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* ── Empty state ── */
|
||||
.empty-state-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
@ -350,9 +604,12 @@
|
|||
.empty-state-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
color: #374151;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
:global(.dark) .empty-state-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.empty-state-cta {
|
||||
display: flex;
|
||||
|
|
@ -360,15 +617,21 @@
|
|||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: hsl(var(--color-surface-hover));
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
:global(.dark) .empty-state-cta {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.empty-state-cta-text {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
color: #6b7280;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
:global(.dark) .empty-state-cta-text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.empty-state-arrow {
|
||||
color: hsl(var(--color-primary));
|
||||
|
|
@ -396,7 +659,7 @@
|
|||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.examples-grid {
|
||||
|
|
@ -410,34 +673,35 @@
|
|||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 9999px;
|
||||
color: hsl(var(--color-foreground));
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
:global(.dark) .example-chip {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.example-chip:hover {
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary));
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.example-chip:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── Onboarding tip ── */
|
||||
.onboarding-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
margin-top: 1rem;
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.2);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.875rem 1.5rem;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
|
|
@ -446,7 +710,10 @@
|
|||
}
|
||||
|
||||
.onboarding-tip-text {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
color: #6b7280;
|
||||
}
|
||||
:global(.dark) .onboarding-tip-text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.onboarding-tip-close {
|
||||
|
|
@ -454,17 +721,19 @@
|
|||
margin-left: auto;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.375rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
color: #9ca3af;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.onboarding-tip-close:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
color: #374151;
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
}
|
||||
:global(.dark) .onboarding-tip-close:hover {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.onboarding-tip-text code {
|
||||
padding: 0.125rem 0.375rem;
|
||||
|
|
@ -474,34 +743,4 @@
|
|||
border-radius: 0.25rem;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.notepad {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
background: #fffef5;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.08),
|
||||
0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:global(.dark) .notepad {
|
||||
background: #2a2520;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.3),
|
||||
0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.notepad-content {
|
||||
padding: 0.75rem 1rem 1.5rem 1rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.notepad {
|
||||
max-width: 100%;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { isLoading as i18nLoading } from 'svelte-i18n';
|
||||
import { theme } from '$lib/stores/theme';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { debugStore } from '$lib/stores/debug.svelte';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let { children } = $props();
|
||||
|
|
@ -13,14 +14,20 @@
|
|||
|
||||
let appReady = $derived(!loading && !$i18nLoading);
|
||||
|
||||
onMount(async () => {
|
||||
// Initialize theme
|
||||
onMount(() => {
|
||||
theme.initialize();
|
||||
authStore.initialize().then(() => {
|
||||
loading = false;
|
||||
});
|
||||
|
||||
// Initialize auth
|
||||
await authStore.initialize();
|
||||
|
||||
loading = false;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'D') {
|
||||
e.preventDefault();
|
||||
debugStore.toggle();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -40,7 +47,16 @@
|
|||
{#if !appReady}
|
||||
<AppLoadingSkeleton layout="tasks" listItemCount={4} />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
<div class="min-h-screen bg-background text-foreground" class:debug-mode={debugStore.enabled}>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
{#if debugStore.enabled}
|
||||
<button
|
||||
class="fixed top-2 right-2 z-[9999] rounded bg-red-600 px-2 py-0.5 font-mono text-xs text-white opacity-80 hover:opacity-100"
|
||||
onclick={() => debugStore.toggle()}
|
||||
>
|
||||
DEBUG
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue