mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(manacore/web): add custom pages system to unified todo module
Port the paper-sheet pages system from standalone todo app into the ManaCore unified app's todo module at mana.how/todo: - Paper-sheet horizontal carousel with snap-scroll - Edit FAB (pencil icon) toggles inline edit mode - Custom pages with configurable filter rules (priority, date range, completed) - PageEditBar with icon picker, filter pills, reorder arrows - Width pills (S/M/L/XL) in edit mode - PagePicker with preset pages + "Eigene Seite" creation - Custom pages persisted to todoSettings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ee831992de
commit
a4a8ff06b5
5 changed files with 1776 additions and 276 deletions
|
|
@ -0,0 +1,369 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
Warning,
|
||||
Calendar,
|
||||
CalendarDots,
|
||||
CheckCircle,
|
||||
Star,
|
||||
Lightning,
|
||||
Clock,
|
||||
Fire,
|
||||
Leaf,
|
||||
Heart,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Trash,
|
||||
} from '@manacore/shared-icons';
|
||||
import type { PageIcon, PageConfig } from '../../stores/settings.svelte';
|
||||
|
||||
interface Props {
|
||||
config: PageConfig;
|
||||
onUpdate: (data: Partial<PageConfig>) => void;
|
||||
onMoveLeft?: () => void;
|
||||
onMoveRight?: () => void;
|
||||
onDelete: () => void;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
let { config, onUpdate, onMoveLeft, onMoveRight, onDelete, isFirst, isLast }: Props = $props();
|
||||
|
||||
const ICONS: { id: PageIcon; component: typeof Warning }[] = [
|
||||
{ id: 'warning', component: Warning },
|
||||
{ id: 'calendar', component: Calendar },
|
||||
{ id: 'calendar-dots', component: CalendarDots },
|
||||
{ id: 'check', component: CheckCircle },
|
||||
{ id: 'star', component: Star },
|
||||
{ id: 'lightning', component: Lightning },
|
||||
{ id: 'clock', component: Clock },
|
||||
{ id: 'fire', component: Fire },
|
||||
{ id: 'leaf', component: Leaf },
|
||||
{ id: 'heart', component: Heart },
|
||||
];
|
||||
|
||||
const PRIORITIES = [
|
||||
{ id: 'urgent' as const, label: 'Dringend', color: '#EF4444' },
|
||||
{ id: 'high' as const, label: 'Hoch', color: '#F59E0B' },
|
||||
{ id: 'medium' as const, label: 'Mittel', color: '#3B82F6' },
|
||||
{ id: 'low' as const, label: 'Niedrig', color: '#6B7280' },
|
||||
];
|
||||
|
||||
const DATE_RANGES = [
|
||||
{ id: 'overdue' as const, label: 'Überfällig' },
|
||||
{ id: 'today' as const, label: 'Heute' },
|
||||
{ id: 'tomorrow' as const, label: 'Morgen' },
|
||||
{ id: 'upcoming' as const, label: 'Demnächst' },
|
||||
{ id: 'any' as const, label: 'Alle' },
|
||||
];
|
||||
|
||||
function togglePriority(p: 'low' | 'medium' | 'high' | 'urgent') {
|
||||
const current = config.filter.priorities ?? [];
|
||||
const next = current.includes(p) ? current.filter((x) => x !== p) : [...current, p];
|
||||
onUpdate({ filter: { ...config.filter, priorities: next.length ? next : undefined } });
|
||||
}
|
||||
|
||||
function setDateRange(range: typeof config.filter.dateRange) {
|
||||
onUpdate({
|
||||
filter: {
|
||||
...config.filter,
|
||||
dateRange: range === config.filter.dateRange ? undefined : range,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCompleted() {
|
||||
onUpdate({ filter: { ...config.filter, completed: !config.filter.completed } });
|
||||
}
|
||||
|
||||
function setIcon(icon: PageIcon) {
|
||||
onUpdate({ icon });
|
||||
}
|
||||
|
||||
let showFilters = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="edit-bar">
|
||||
<div class="edit-row icons-row">
|
||||
{#each ICONS as icon (icon.id)}
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:active={config.icon === icon.id}
|
||||
onclick={() => setIcon(icon.id)}
|
||||
title={icon.id}
|
||||
>
|
||||
<icon.component size={16} weight={config.icon === icon.id ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button class="filter-toggle" onclick={() => (showFilters = !showFilters)}>
|
||||
<span class="filter-toggle-label">Filter</span>
|
||||
<span class="filter-toggle-arrow" class:open={showFilters}>▾</span>
|
||||
</button>
|
||||
|
||||
{#if showFilters}
|
||||
<div class="edit-row filter-section">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Priorität</span>
|
||||
<div class="filter-pills">
|
||||
{#each PRIORITIES as p (p.id)}
|
||||
<button
|
||||
class="filter-pill"
|
||||
class:active={config.filter.priorities?.includes(p.id)}
|
||||
style="--pill-color: {p.color}"
|
||||
onclick={() => togglePriority(p.id)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">Zeitraum</span>
|
||||
<div class="filter-pills">
|
||||
{#each DATE_RANGES as dr (dr.id)}
|
||||
<button
|
||||
class="filter-pill"
|
||||
class:active={config.filter.dateRange === dr.id}
|
||||
onclick={() => setDateRange(dr.id)}
|
||||
>
|
||||
{dr.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label class="completed-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.filter.completed ?? false}
|
||||
onchange={toggleCompleted}
|
||||
/>
|
||||
<span>Nur erledigte</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="edit-row actions-row">
|
||||
<div class="move-btns">
|
||||
{#if !isFirst && onMoveLeft}
|
||||
<button class="action-btn" onclick={onMoveLeft} title="Nach links">
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isLast && onMoveRight}
|
||||
<button class="action-btn" onclick={onMoveRight} title="Nach rechts">
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="action-btn delete-btn" onclick={onDelete} title="Seite löschen">
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.edit-bar {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
:global(.dark) .edit-bar {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
.edit-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.icons-row {
|
||||
gap: 0.125rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.icon-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
}
|
||||
.icon-btn.active {
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
color: white;
|
||||
}
|
||||
:global(.dark) .icon-btn {
|
||||
color: #6b7280;
|
||||
}
|
||||
:global(.dark) .icon-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:global(.dark) .icon-btn.active {
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
color: white;
|
||||
}
|
||||
.filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.filter-toggle:hover {
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .filter-toggle:hover {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.filter-toggle-arrow {
|
||||
font-size: 0.625rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.filter-toggle-arrow.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.filter-section {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.filter-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.filter-pill {
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter-pill:hover {
|
||||
border-color: rgba(0, 0, 0, 0.2);
|
||||
color: #374151;
|
||||
}
|
||||
.filter-pill.active {
|
||||
background: var(--pill-color, var(--color-primary, #8b5cf6));
|
||||
border-color: var(--pill-color, var(--color-primary, #8b5cf6));
|
||||
color: white;
|
||||
}
|
||||
:global(.dark) .filter-pill {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .filter-pill:hover {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
:global(.dark) .filter-pill.active {
|
||||
background: var(--pill-color, var(--color-primary, #8b5cf6));
|
||||
border-color: var(--pill-color, var(--color-primary, #8b5cf6));
|
||||
color: white;
|
||||
}
|
||||
.completed-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
}
|
||||
.completed-toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--color-primary, #8b5cf6);
|
||||
cursor: pointer;
|
||||
}
|
||||
:global(.dark) .completed-toggle {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.actions-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.move-btns {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
:global(.dark) .delete-btn:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
Circle,
|
||||
CheckCircle,
|
||||
CalendarCheck,
|
||||
Warning,
|
||||
ListChecks,
|
||||
Flag,
|
||||
Calendar,
|
||||
TagSimple,
|
||||
X,
|
||||
Plus,
|
||||
} from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
onSelect: (pageId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateCustom?: () => void;
|
||||
activePageIds?: string[];
|
||||
}
|
||||
|
||||
let { onSelect, onClose, onCreateCustom, activePageIds = [] }: Props = $props();
|
||||
|
||||
const PAGE_OPTIONS = [
|
||||
{ id: 'todo', title: 'To Do', description: 'Offene Aufgaben', icon: Circle, color: '#6B7280' },
|
||||
{
|
||||
id: 'completed',
|
||||
title: 'Erledigt',
|
||||
description: 'Alle abgeschlossenen Aufgaben',
|
||||
icon: CheckCircle,
|
||||
color: '#22C55E',
|
||||
},
|
||||
{
|
||||
id: 'today',
|
||||
title: 'Heute',
|
||||
description: 'Fällig heute & überfällig',
|
||||
icon: CalendarCheck,
|
||||
color: '#F59E0B',
|
||||
},
|
||||
{
|
||||
id: 'overdue',
|
||||
title: 'Überfällig',
|
||||
description: 'Aufgaben nach Fälligkeitsdatum',
|
||||
icon: Warning,
|
||||
color: '#EF4444',
|
||||
},
|
||||
{
|
||||
id: 'all',
|
||||
title: 'Alle Aufgaben',
|
||||
description: 'Vollständige Aufgabenliste',
|
||||
icon: ListChecks,
|
||||
color: '#3B82F6',
|
||||
},
|
||||
{
|
||||
id: 'high-priority',
|
||||
title: 'Hohe Priorität',
|
||||
description: 'Dringend & hoch priorisiert',
|
||||
icon: Flag,
|
||||
color: '#EF4444',
|
||||
},
|
||||
{
|
||||
id: 'this-week',
|
||||
title: 'Diese Woche',
|
||||
description: 'Aufgaben der nächsten 7 Tage',
|
||||
icon: Calendar,
|
||||
color: '#8B5CF6',
|
||||
},
|
||||
{
|
||||
id: 'no-date',
|
||||
title: 'Ohne Datum',
|
||||
description: 'Aufgaben ohne Fälligkeitsdatum',
|
||||
icon: TagSimple,
|
||||
color: '#6B7280',
|
||||
},
|
||||
];
|
||||
|
||||
let availableOptions = $derived(PAGE_OPTIONS.filter((opt) => !activePageIds.includes(opt.id)));
|
||||
</script>
|
||||
|
||||
<div class="page-picker">
|
||||
<div class="picker-header">
|
||||
<h3 class="picker-title">Neue Seite</h3>
|
||||
<button class="close-btn" onclick={onClose} title="Schließen"><X size={16} /></button>
|
||||
</div>
|
||||
<div class="picker-list">
|
||||
{#each availableOptions as option, i (option.id)}
|
||||
{#if i > 0}<div class="divider"></div>{/if}
|
||||
<button class="page-option" onclick={() => onSelect(option.id)}>
|
||||
<div class="option-icon" style="color: {option.color}"><option.icon size={20} /></div>
|
||||
<div class="option-text">
|
||||
<span class="option-title">{option.title}</span>
|
||||
<span class="option-desc">{option.description}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{#if availableOptions.length > 0 && onCreateCustom}<div class="divider"></div>{/if}
|
||||
{#if onCreateCustom}
|
||||
<button class="page-option custom-option" onclick={onCreateCustom}>
|
||||
<div class="option-icon custom-icon"><Plus size={20} /></div>
|
||||
<div class="option-text">
|
||||
<span class="option-title">Eigene Seite</span>
|
||||
<span class="option-desc">Seite mit eigenen Filtern erstellen</span>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{#if availableOptions.length === 0 && !onCreateCustom}
|
||||
<div class="empty-state"><p>Alle Seiten sind bereits geöffnet</p></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-picker {
|
||||
flex: 0 0 auto;
|
||||
width: min(320px, 85vw);
|
||||
min-height: 60vh;
|
||||
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;
|
||||
animation: slideIn 0.25s ease-out;
|
||||
}
|
||||
:global(.dark) .page-picker {
|
||||
background-color: #252220;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.picker-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.picker-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
}
|
||||
:global(.dark) .picker-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.close-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.picker-list {
|
||||
flex: 1;
|
||||
padding: 0 0.5rem 0.75rem;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
:global(.dark) .divider {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.page-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 0.375rem;
|
||||
transition: background 0.15s;
|
||||
text-align: left;
|
||||
}
|
||||
.page-option:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
:global(.dark) .page-option:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.option-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 0.5rem;
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
.custom-icon {
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
background: color-mix(in srgb, var(--color-primary, #8b5cf6) 10%, transparent);
|
||||
}
|
||||
.custom-option {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.option-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .option-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.option-desc {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .option-desc {
|
||||
color: #6b7280;
|
||||
}
|
||||
.empty-state {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 0.8125rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,606 @@
|
|||
<script lang="ts">
|
||||
import { isToday, isTomorrow, isPast, startOfDay, addDays, subHours, format } from 'date-fns';
|
||||
import type { Task } from '../../types';
|
||||
import { tasksStore } from '../../stores/tasks.svelte';
|
||||
import { todoSettings } from '../../stores/settings.svelte';
|
||||
import type { PageConfig, PageIcon, PageWidth } from '../../stores/settings.svelte';
|
||||
import PageEditBar from './PageEditBar.svelte';
|
||||
import TaskItem from '../TaskItem.svelte';
|
||||
import {
|
||||
X,
|
||||
Circle,
|
||||
Minus,
|
||||
DotsSixVertical,
|
||||
CornersOut,
|
||||
CornersIn,
|
||||
Warning,
|
||||
Calendar,
|
||||
CalendarDots,
|
||||
CheckCircle,
|
||||
Star,
|
||||
Lightning,
|
||||
Clock,
|
||||
Fire,
|
||||
Leaf,
|
||||
Heart,
|
||||
} from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
pageId: string;
|
||||
allTasks: Task[];
|
||||
title?: string;
|
||||
maximized?: boolean;
|
||||
editMode?: boolean;
|
||||
filterConfig?: PageConfig['filter'];
|
||||
pageIcon?: PageIcon;
|
||||
customPageConfig?: PageConfig;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
onClose: () => void;
|
||||
onMinimize?: () => void;
|
||||
onMaximize?: () => void;
|
||||
onRename?: (name: string) => void;
|
||||
onUpdateConfig?: (data: Partial<PageConfig>) => void;
|
||||
onMoveLeft?: () => void;
|
||||
onMoveRight?: () => void;
|
||||
onDelete?: () => void;
|
||||
onOpenTask?: (task: Task) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
pageId,
|
||||
allTasks,
|
||||
title: customTitle,
|
||||
maximized = false,
|
||||
editMode = false,
|
||||
filterConfig,
|
||||
pageIcon,
|
||||
customPageConfig,
|
||||
isFirst = false,
|
||||
isLast = false,
|
||||
onClose,
|
||||
onMinimize,
|
||||
onMaximize,
|
||||
onRename,
|
||||
onUpdateConfig,
|
||||
onMoveLeft,
|
||||
onMoveRight,
|
||||
onDelete,
|
||||
onOpenTask,
|
||||
}: Props = $props();
|
||||
|
||||
let titleEl = $state<HTMLSpanElement | null>(null);
|
||||
let isTitleFocused = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (titleEl && !isTitleFocused) {
|
||||
titleEl.textContent = displayTitle;
|
||||
}
|
||||
});
|
||||
|
||||
function handleTitleInput() {
|
||||
const text = titleEl?.textContent?.trim() ?? '';
|
||||
if (text && onRename) onRename(text);
|
||||
}
|
||||
|
||||
function handleTitleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).blur();
|
||||
}
|
||||
}
|
||||
|
||||
const ICON_MAP: Record<PageIcon, typeof Warning> = {
|
||||
warning: Warning,
|
||||
calendar: Calendar,
|
||||
'calendar-dots': CalendarDots,
|
||||
check: CheckCircle,
|
||||
star: Star,
|
||||
lightning: Lightning,
|
||||
clock: Clock,
|
||||
fire: Fire,
|
||||
leaf: Leaf,
|
||||
heart: Heart,
|
||||
};
|
||||
|
||||
const PAGE_META: Record<string, { title: string; color: string; icon?: PageIcon }> = {
|
||||
todo: { title: 'To Do', color: '#6B7280' },
|
||||
completed: { title: 'Erledigt', color: '#22C55E', icon: 'check' },
|
||||
today: { title: 'Heute', color: '#F59E0B', icon: 'calendar' },
|
||||
overdue: { title: 'Überfällig', color: '#EF4444', icon: 'warning' },
|
||||
all: { title: 'Alle Aufgaben', color: '#3B82F6' },
|
||||
'high-priority': { title: 'Hohe Priorität', color: '#EF4444', icon: 'fire' },
|
||||
'this-week': { title: 'Diese Woche', color: '#8B5CF6', icon: 'calendar-dots' },
|
||||
'no-date': { title: 'Ohne Datum', color: '#6B7280' },
|
||||
};
|
||||
|
||||
let pageMeta = $derived(PAGE_META[pageId] ?? { title: pageId, color: '#6B7280' });
|
||||
let displayTitle = $derived(customTitle ?? pageMeta.title);
|
||||
let displayColor = $derived(pageMeta.color);
|
||||
let displayIcon = $derived(pageIcon ?? pageMeta.icon);
|
||||
let IconComponent = $derived(displayIcon ? ICON_MAP[displayIcon] : undefined);
|
||||
let isCustom = $derived(pageId.startsWith('custom-'));
|
||||
|
||||
let filteredTasks = $derived.by(() => {
|
||||
const tasks = allTasks;
|
||||
const today = startOfDay(new Date());
|
||||
const weekEnd = addDays(today, 7);
|
||||
|
||||
if (filterConfig) {
|
||||
return tasks.filter((task) => {
|
||||
if (filterConfig.completed) {
|
||||
if (!task.isCompleted) return false;
|
||||
} else {
|
||||
if (task.isCompleted) return false;
|
||||
}
|
||||
if (filterConfig.priorities?.length) {
|
||||
if (!filterConfig.priorities.includes(task.priority)) return false;
|
||||
}
|
||||
if (filterConfig.dateRange && filterConfig.dateRange !== 'any') {
|
||||
if (!task.dueDate) return false;
|
||||
const dueDate = startOfDay(new Date(task.dueDate));
|
||||
switch (filterConfig.dateRange) {
|
||||
case 'overdue':
|
||||
if (!isPast(dueDate) || isToday(dueDate)) return false;
|
||||
break;
|
||||
case 'today':
|
||||
if (!isToday(dueDate)) return false;
|
||||
break;
|
||||
case 'tomorrow':
|
||||
if (!isTomorrow(dueDate)) return false;
|
||||
break;
|
||||
case 'upcoming':
|
||||
if (isPast(dueDate) && !isToday(dueDate)) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
switch (pageId) {
|
||||
case 'todo': {
|
||||
const recentCutoff = subHours(new Date(), 24);
|
||||
return tasks.filter(
|
||||
(t) =>
|
||||
!t.isCompleted ||
|
||||
(t.isCompleted && t.completedAt && new Date(t.completedAt) >= recentCutoff)
|
||||
);
|
||||
}
|
||||
case 'completed':
|
||||
return tasks.filter((t) => t.isCompleted);
|
||||
case 'today':
|
||||
return tasks.filter(
|
||||
(t) =>
|
||||
!t.isCompleted &&
|
||||
t.dueDate &&
|
||||
(isToday(new Date(t.dueDate)) || isPast(startOfDay(new Date(t.dueDate))))
|
||||
);
|
||||
case 'overdue':
|
||||
return tasks.filter(
|
||||
(t) =>
|
||||
!t.isCompleted &&
|
||||
t.dueDate &&
|
||||
isPast(startOfDay(new Date(t.dueDate))) &&
|
||||
!isToday(new Date(t.dueDate))
|
||||
);
|
||||
case 'all':
|
||||
return tasks;
|
||||
case 'high-priority':
|
||||
return tasks.filter(
|
||||
(t) => !t.isCompleted && (t.priority === 'urgent' || t.priority === 'high')
|
||||
);
|
||||
case 'this-week':
|
||||
return tasks.filter(
|
||||
(t) =>
|
||||
!t.isCompleted &&
|
||||
t.dueDate &&
|
||||
new Date(t.dueDate) <= weekEnd &&
|
||||
new Date(t.dueDate) >= today
|
||||
);
|
||||
case 'no-date':
|
||||
return tasks.filter((t) => !t.isCompleted && !t.dueDate);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const PAGE_WIDTH_MAP: Record<string, string> = {
|
||||
narrow: 'min(360px, 85vw)',
|
||||
medium: 'min(480px, 85vw)',
|
||||
wide: 'min(640px, 90vw)',
|
||||
full: 'min(840px, 95vw)',
|
||||
};
|
||||
|
||||
let sheetWidth = $derived(PAGE_WIDTH_MAP[todoSettings.pageWidth] || PAGE_WIDTH_MAP.medium);
|
||||
|
||||
let showCompleted = $derived(filterConfig?.completed ?? false);
|
||||
let openTasks = $derived(
|
||||
pageId === 'todo' ? filteredTasks.filter((t) => !t.isCompleted) : filteredTasks
|
||||
);
|
||||
let recentlyCompleted = $derived(
|
||||
pageId === 'todo' ? filteredTasks.filter((t) => t.isCompleted) : []
|
||||
);
|
||||
|
||||
let newTaskTitle = $state('');
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
async function handleInlineCreate() {
|
||||
const title = newTaskTitle.trim();
|
||||
if (!title) return;
|
||||
const data: Record<string, unknown> = { title };
|
||||
if (pageId === 'today' || filterConfig?.dateRange === 'today') {
|
||||
data.dueDate = new Date().toISOString();
|
||||
}
|
||||
if (filterConfig?.priorities?.length === 1) {
|
||||
data.priority = filterConfig.priorities[0];
|
||||
}
|
||||
await tasksStore.createTask(
|
||||
data as { title: string; dueDate?: string; priority?: 'low' | 'medium' | 'high' | 'urgent' }
|
||||
);
|
||||
newTaskTitle = '';
|
||||
inputEl?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="todo-page"
|
||||
class:maximized
|
||||
class:editing={editMode}
|
||||
style="width: {maximized ? '100%' : sheetWidth}"
|
||||
>
|
||||
<div class="drag-handle-bar">
|
||||
<span class="drag-handle"><DotsSixVertical size={14} /></span>
|
||||
</div>
|
||||
|
||||
{#if editMode && isCustom && customPageConfig && onUpdateConfig && onDelete}
|
||||
<PageEditBar
|
||||
config={customPageConfig}
|
||||
onUpdate={onUpdateConfig}
|
||||
{onMoveLeft}
|
||||
{onMoveRight}
|
||||
{onDelete}
|
||||
{isFirst}
|
||||
{isLast}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="page-header" ondragstart={(e) => e.preventDefault()}>
|
||||
<div class="header-left">
|
||||
{#if IconComponent}
|
||||
<span class="header-icon" style="color: {displayColor}">
|
||||
<IconComponent size={16} weight="fill" />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="color-dot" style="background-color: {displayColor}"></span>
|
||||
{/if}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
bind:this={titleEl}
|
||||
class="page-title"
|
||||
contenteditable={!!onRename}
|
||||
oninput={handleTitleInput}
|
||||
onkeydown={handleTitleKeydown}
|
||||
onfocus={() => (isTitleFocused = true)}
|
||||
onblur={() => (isTitleFocused = false)}
|
||||
></span>
|
||||
<span class="task-count">{filteredTasks.length}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{#if editMode && !isCustom && onDelete}
|
||||
<button class="header-btn delete-preset" onclick={onDelete} title="Seite entfernen">
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if !editMode}
|
||||
{#if onMinimize}
|
||||
<button class="header-btn" onclick={onMinimize} title="Minimieren">
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if onMaximize}
|
||||
<button
|
||||
class="header-btn"
|
||||
onclick={onMaximize}
|
||||
title={maximized ? 'Verkleinern' : 'Maximieren'}
|
||||
>
|
||||
{#if maximized}<CornersIn size={14} />{:else}<CornersOut size={14} />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="header-btn" onclick={onClose} title="Seite schließen">
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="page-body" ondragstart={(e) => e.preventDefault()}>
|
||||
{#each openTasks as task (task.id)}
|
||||
<div class="task-card-wrapper" class:completed-task={task.isCompleted}>
|
||||
<TaskItem
|
||||
{task}
|
||||
compact={false}
|
||||
onToggleComplete={() => tasksStore.toggleComplete(task.id)}
|
||||
onClick={() => onOpenTask?.(task)}
|
||||
onContextMenu={() => {}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if recentlyCompleted.length > 0}
|
||||
<div class="completed-section">
|
||||
<span class="completed-label">Kürzlich erledigt</span>
|
||||
{#each recentlyCompleted as task (task.id)}
|
||||
<div class="task-card-wrapper completed-task">
|
||||
<TaskItem
|
||||
{task}
|
||||
compact={false}
|
||||
onOpen={onOpenTask ? () => onOpenTask(task) : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !editMode && !showCompleted && pageId !== 'completed'}
|
||||
<div class="inline-create">
|
||||
<span class="inline-create-icon"><Circle size={18} /></span>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={newTaskTitle}
|
||||
class="inline-create-input"
|
||||
placeholder="Neue Aufgabe..."
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') handleInlineCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.todo-page {
|
||||
flex: 0 0 auto;
|
||||
min-height: 60vh;
|
||||
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;
|
||||
animation: fadeIn 0.25s ease-out;
|
||||
}
|
||||
:global(.dark) .todo-page {
|
||||
background-color: #252220;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.25),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.todo-page.editing {
|
||||
box-shadow:
|
||||
0 2px 12px rgba(139, 92, 246, 0.12),
|
||||
0 0 0 2px rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
:global(.dark) .todo-page.editing {
|
||||
box-shadow:
|
||||
0 2px 12px rgba(139, 92, 246, 0.2),
|
||||
0 0 0 2px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
.todo-page.maximized {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
width: 100% !important;
|
||||
min-height: 100vh;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
animation: fadeInScale 0.2s ease-out;
|
||||
align-items: center;
|
||||
}
|
||||
.todo-page.maximized .page-header,
|
||||
.todo-page.maximized .page-body {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
}
|
||||
.todo-page.maximized .page-header,
|
||||
.todo-page.maximized .page-body {
|
||||
margin: 0 auto;
|
||||
}
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.drag-handle-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0 0;
|
||||
}
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 14px;
|
||||
color: #d1d5db;
|
||||
cursor: grab;
|
||||
border-radius: 0.25rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.drag-handle:hover {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
:global(.dark) .drag-handle {
|
||||
color: #3f3b38;
|
||||
}
|
||||
:global(.dark) .drag-handle:hover {
|
||||
color: #6b7280;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.header-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.color-dot {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
outline: none;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
.page-title[contenteditable='true'] {
|
||||
cursor: text;
|
||||
}
|
||||
:global(.dark) .page-title {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.task-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
:global(.dark) .task-count {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #6b7280;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.header-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;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #374151;
|
||||
}
|
||||
:global(.dark) .header-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.delete-preset:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
:global(.dark) .delete-preset:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
.page-body {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.task-card-wrapper {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.task-card-wrapper:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.completed-task {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.completed-section {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
:global(.dark) .completed-section {
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.completed-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
.inline-create {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.inline-create-icon {
|
||||
flex-shrink: 0;
|
||||
color: #d1d5db;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
:global(.dark) .inline-create-icon {
|
||||
color: #4b5563;
|
||||
}
|
||||
.inline-create-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.8125rem;
|
||||
color: #374151;
|
||||
outline: none;
|
||||
}
|
||||
.inline-create-input::placeholder {
|
||||
color: #c0bfba;
|
||||
}
|
||||
:global(.dark) .inline-create-input {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
:global(.dark) .inline-create-input::placeholder {
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -12,6 +12,29 @@ export type KanbanCardSize = 'compact' | 'normal' | 'large';
|
|||
export type LayoutMode = 'fokus' | 'uebersicht' | 'matrix';
|
||||
export type PageWidth = 'narrow' | 'medium' | 'wide' | 'full';
|
||||
|
||||
export type PageIcon =
|
||||
| 'warning'
|
||||
| 'calendar'
|
||||
| 'calendar-dots'
|
||||
| 'check'
|
||||
| 'star'
|
||||
| 'lightning'
|
||||
| 'clock'
|
||||
| 'fire'
|
||||
| 'leaf'
|
||||
| 'heart';
|
||||
|
||||
export interface PageConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: PageIcon;
|
||||
filter: {
|
||||
priorities?: ('low' | 'medium' | 'high' | 'urgent')[];
|
||||
completed?: boolean;
|
||||
dateRange?: 'overdue' | 'today' | 'tomorrow' | 'upcoming' | 'any';
|
||||
};
|
||||
}
|
||||
|
||||
export interface TodoAppSettings extends Record<string, unknown> {
|
||||
// Task Behavior
|
||||
defaultPriority: TaskPriority;
|
||||
|
|
@ -57,6 +80,9 @@ export interface TodoAppSettings extends Record<string, unknown> {
|
|||
|
||||
// Page width
|
||||
pageWidth: PageWidth;
|
||||
|
||||
// Custom pages
|
||||
customPages: PageConfig[];
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: TodoAppSettings = {
|
||||
|
|
@ -91,6 +117,7 @@ const DEFAULT_SETTINGS: TodoAppSettings = {
|
|||
filterStripCollapsed: false,
|
||||
activeLayoutMode: 'fokus' as LayoutMode,
|
||||
pageWidth: 'medium' as PageWidth,
|
||||
customPages: [] as PageConfig[],
|
||||
};
|
||||
|
||||
const baseStore = createAppSettingsStore<TodoAppSettings>('todo-settings', DEFAULT_SETTINGS);
|
||||
|
|
@ -149,6 +176,10 @@ export const todoSettings = {
|
|||
return baseStore.settings.filterStripCollapsed;
|
||||
},
|
||||
|
||||
get customPages() {
|
||||
return baseStore.settings.customPages;
|
||||
},
|
||||
|
||||
toggleFilterStrip() {
|
||||
baseStore.update({ filterStripCollapsed: !baseStore.settings.filterStripCollapsed });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -12,54 +12,28 @@
|
|||
type LocalTodoProject,
|
||||
tasksStore,
|
||||
taskTable,
|
||||
viewStore,
|
||||
filterIncomplete,
|
||||
filterCompleted,
|
||||
filterOverdue,
|
||||
filterToday,
|
||||
filterUpcoming,
|
||||
filterByProject,
|
||||
searchTasks,
|
||||
sortTasks,
|
||||
getTaskStats,
|
||||
} from '$lib/modules/todo';
|
||||
import {
|
||||
Tray,
|
||||
CalendarBlank,
|
||||
CalendarCheck,
|
||||
CheckCircle,
|
||||
MagnifyingGlass,
|
||||
Gear,
|
||||
} from '@manacore/shared-icons';
|
||||
import { Plus, PencilSimple, X, Gear } from '@manacore/shared-icons';
|
||||
import { ShareModal } from '@manacore/shared-uload';
|
||||
|
||||
// Components
|
||||
import TaskList from '$lib/modules/todo/components/TaskList.svelte';
|
||||
import TaskEditModal from '$lib/modules/todo/components/TaskEditModal.svelte';
|
||||
import QuickAddTask from '$lib/modules/todo/components/QuickAddTask.svelte';
|
||||
import TodoToolbar from '$lib/modules/todo/components/TodoToolbar.svelte';
|
||||
import TagStrip from '$lib/modules/todo/components/TagStrip.svelte';
|
||||
import SyncIndicator from '$lib/modules/todo/components/SyncIndicator.svelte';
|
||||
import SyntaxHelpOverlay from '$lib/modules/todo/components/SyntaxHelpOverlay.svelte';
|
||||
import OnboardingModal from '$lib/modules/todo/components/OnboardingModal.svelte';
|
||||
import { TaskListSkeleton, StatisticsSkeleton } from '$lib/modules/todo/components/skeletons';
|
||||
import {
|
||||
BoardViewRenderer,
|
||||
ViewSelector,
|
||||
ViewEditorModal,
|
||||
} from '$lib/modules/todo/components/board-views';
|
||||
import TodoPage from '$lib/modules/todo/components/pages/TodoPage.svelte';
|
||||
import PagePicker from '$lib/modules/todo/components/pages/PagePicker.svelte';
|
||||
import { todoSettings } from '$lib/modules/todo/stores/settings.svelte';
|
||||
import type { PageConfig, PageWidth } from '$lib/modules/todo/stores/settings.svelte';
|
||||
import { getTaskStats } from '$lib/modules/todo';
|
||||
|
||||
// Get data from layout context
|
||||
const allTasks$: Observable<Task[]> = getContext('tasks');
|
||||
const allLabels$: Observable<LocalLabel[]> = getContext('labels');
|
||||
const allProjects$: Observable<LocalTodoProject[]> = getContext('projects');
|
||||
const allBoardViews$: Observable<LocalBoardView[]> = getContext('boardViews');
|
||||
|
||||
let allTasks = $state<Task[]>([]);
|
||||
let allLabels = $state<LocalLabel[]>([]);
|
||||
let allProjects = $state<LocalTodoProject[]>([]);
|
||||
let allBoardViews = $state<LocalBoardView[]>([]);
|
||||
let isLoaded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
|
|
@ -75,69 +49,13 @@
|
|||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = allProjects$.subscribe((p) => (allProjects = p));
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const sub = allBoardViews$.subscribe((v) => (allBoardViews = v));
|
||||
return () => sub.unsubscribe();
|
||||
});
|
||||
|
||||
// Tags for resolving labelIds
|
||||
const globalTags = useAllTags();
|
||||
const tagList = $derived(
|
||||
(globalTags.value ?? []).map((t) => ({ id: t.id, name: t.name, color: t.color }))
|
||||
);
|
||||
|
||||
// Stats
|
||||
let stats = $derived(getTaskStats(allTasks));
|
||||
|
||||
// Filtered tasks
|
||||
let displayTasks = $derived.by(() => {
|
||||
let tasks = allTasks;
|
||||
switch (viewStore.currentView) {
|
||||
case 'today':
|
||||
tasks = [...filterOverdue(allTasks), ...filterToday(allTasks)];
|
||||
break;
|
||||
case 'upcoming':
|
||||
tasks = filterUpcoming(allTasks);
|
||||
break;
|
||||
case 'completed':
|
||||
tasks = filterCompleted(allTasks);
|
||||
break;
|
||||
case 'search':
|
||||
tasks = searchTasks(allTasks, viewStore.searchQuery);
|
||||
break;
|
||||
case 'label':
|
||||
tasks = filterIncomplete(allTasks).filter((t) => {
|
||||
const ids: string[] = (t.metadata as { labelIds?: string[] })?.labelIds ?? [];
|
||||
return ids.includes(viewStore.currentLabelId ?? '');
|
||||
});
|
||||
break;
|
||||
default:
|
||||
tasks = filterIncomplete(allTasks);
|
||||
}
|
||||
if (viewStore.showCompleted && viewStore.currentView !== 'completed') {
|
||||
tasks = [...tasks, ...filterCompleted(allTasks)];
|
||||
}
|
||||
return sortTasks(tasks, viewStore.sortBy, viewStore.sortOrder);
|
||||
});
|
||||
|
||||
// Board view state
|
||||
let isBoardView = $state(false);
|
||||
let activeBoardId = $state<string | null>(null);
|
||||
let activeBoard = $derived(
|
||||
allBoardViews.find((v) => v.id === activeBoardId) ?? allBoardViews[0] ?? null
|
||||
);
|
||||
|
||||
// Modal states
|
||||
let editTask = $state<Task | null>(null);
|
||||
let shareTask = $state<Task | null>(null);
|
||||
let showSyntaxHelp = $state(false);
|
||||
let showBoardEditor = $state(false);
|
||||
let editBoardView = $state<LocalBoardView | null>(null);
|
||||
let showOnboarding = $state(false);
|
||||
|
||||
let shareUrl = $derived(
|
||||
|
|
@ -146,7 +64,6 @@
|
|||
: ''
|
||||
);
|
||||
|
||||
// Check onboarding
|
||||
onMount(() => {
|
||||
try {
|
||||
if (!localStorage.getItem('todo-onboarding-done')) {
|
||||
|
|
@ -163,7 +80,7 @@
|
|||
|
||||
onMount(() => {
|
||||
tagDropCtx?.set(async (tagId: string, payload: DragPayload) => {
|
||||
const taskData = payload.data as TagDragData;
|
||||
const taskData = payload.data as { id: string };
|
||||
const task = await taskTable.get(taskData.id);
|
||||
if (!task) return;
|
||||
const currentLabels: string[] = (task.metadata as { labelIds?: string[] })?.labelIds ?? [];
|
||||
|
|
@ -174,217 +91,306 @@
|
|||
return () => tagDropCtx?.clear();
|
||||
});
|
||||
|
||||
// Board view callbacks
|
||||
async function handleBoardQuickAdd(title: string, _columnId: string) {
|
||||
await tasksStore.createTask({ title });
|
||||
// ── Edit mode ──────────────────────────────────────────
|
||||
let editMode = $state(false);
|
||||
|
||||
// ── Pages ───────────────────────────────────────────────
|
||||
let showPagePicker = $state(false);
|
||||
let openPages = $state<
|
||||
{ id: string; minimized: boolean; maximized?: boolean; customTitle?: string }[]
|
||||
>([{ id: 'todo', minimized: false }]);
|
||||
|
||||
let expandedPages = $derived(openPages.filter((p) => !p.minimized));
|
||||
let customPages = $derived(todoSettings.customPages);
|
||||
|
||||
function handleAddPage(pageId: string) {
|
||||
if (!openPages.some((p) => p.id === pageId)) {
|
||||
openPages = [...openPages, { id: pageId, minimized: false }];
|
||||
} else {
|
||||
openPages = openPages.map((p) => (p.id === pageId ? { ...p, minimized: false } : p));
|
||||
}
|
||||
showPagePicker = false;
|
||||
}
|
||||
|
||||
// View navigation
|
||||
let views = $derived([
|
||||
{ id: 'inbox', label: $_('todo.inbox'), icon: Tray },
|
||||
{ id: 'today', label: $_('todo.todayView'), icon: CalendarBlank },
|
||||
{ id: 'upcoming', label: $_('todo.upcoming'), icon: CalendarCheck },
|
||||
{ id: 'completed', label: $_('todo.completedView'), icon: CheckCircle },
|
||||
] as const);
|
||||
function handleRemovePage(pageId: string) {
|
||||
openPages = openPages.filter((p) => p.id !== pageId);
|
||||
}
|
||||
|
||||
function handleMinimizePage(pageId: string) {
|
||||
openPages = openPages.map((p) => (p.id === pageId ? { ...p, minimized: true } : p));
|
||||
}
|
||||
|
||||
function handleMaximizePage(pageId: string) {
|
||||
openPages = openPages.map((p) =>
|
||||
p.id === pageId ? { ...p, maximized: !p.maximized, minimized: false } : p
|
||||
);
|
||||
}
|
||||
|
||||
function handleRenamePage(pageId: string, name: string) {
|
||||
openPages = openPages.map((p) => (p.id === pageId ? { ...p, customTitle: name } : p));
|
||||
if (pageId.startsWith('custom-')) {
|
||||
const updated = customPages.map((cp) => (cp.id === pageId ? { ...cp, label: name } : cp));
|
||||
todoSettings.update({ customPages: updated });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Custom page CRUD ────────────────────────────────────
|
||||
function handleCreateCustomPage() {
|
||||
const id = `custom-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const newPage: PageConfig = { id, label: 'Neue Seite', icon: 'star', filter: {} };
|
||||
todoSettings.update({ customPages: [...customPages, newPage] });
|
||||
openPages = [...openPages, { id, minimized: false }];
|
||||
showPagePicker = false;
|
||||
editMode = true;
|
||||
}
|
||||
|
||||
function handleUpdateCustomPage(pageId: string, data: Partial<PageConfig>) {
|
||||
const updated = customPages.map((cp) => {
|
||||
if (cp.id !== pageId) return cp;
|
||||
return { ...cp, ...data, filter: data.filter ?? cp.filter };
|
||||
});
|
||||
todoSettings.update({ customPages: updated });
|
||||
}
|
||||
|
||||
function handleDeletePage(pageId: string) {
|
||||
openPages = openPages.filter((p) => p.id !== pageId);
|
||||
if (pageId.startsWith('custom-')) {
|
||||
todoSettings.update({ customPages: customPages.filter((cp) => cp.id !== pageId) });
|
||||
}
|
||||
}
|
||||
|
||||
function getCustomPageConfig(pageId: string): PageConfig | undefined {
|
||||
return customPages.find((cp) => cp.id === pageId);
|
||||
}
|
||||
|
||||
// ── Page reorder ────────────────────────────────────────
|
||||
function handleMovePageLeft(pageId: string) {
|
||||
const idx = openPages.findIndex((p) => p.id === pageId);
|
||||
if (idx <= 0) return;
|
||||
const pages = [...openPages];
|
||||
[pages[idx - 1], pages[idx]] = [pages[idx], pages[idx - 1]];
|
||||
openPages = pages;
|
||||
}
|
||||
|
||||
function handleMovePageRight(pageId: string) {
|
||||
const idx = openPages.findIndex((p) => p.id === pageId);
|
||||
if (idx === -1 || idx >= openPages.length - 1) return;
|
||||
const pages = [...openPages];
|
||||
[pages[idx], pages[idx + 1]] = [pages[idx + 1], pages[idx]];
|
||||
openPages = pages;
|
||||
}
|
||||
|
||||
// ── Page drag reorder ───────────────────────────────────
|
||||
let dragPageId = $state<string | null>(null);
|
||||
|
||||
function handlePageDragStart(e: DragEvent, pageId: string) {
|
||||
dragPageId = pageId;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', pageId);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageDragOver(e: DragEvent) {
|
||||
if (!dragPageId) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
function handlePageDrop(e: DragEvent, targetPageId: string) {
|
||||
e.preventDefault();
|
||||
if (!dragPageId || dragPageId === targetPageId) return;
|
||||
const fromIdx = openPages.findIndex((p) => p.id === dragPageId);
|
||||
const toIdx = openPages.findIndex((p) => p.id === targetPageId);
|
||||
if (fromIdx === -1 || toIdx === -1) return;
|
||||
const pages = [...openPages];
|
||||
const [moved] = pages.splice(fromIdx, 1);
|
||||
pages.splice(toIdx, 0, moved);
|
||||
openPages = pages;
|
||||
dragPageId = null;
|
||||
}
|
||||
|
||||
function handlePageDragEnd() {
|
||||
dragPageId = null;
|
||||
}
|
||||
|
||||
function togglePagePicker() {
|
||||
showPagePicker = !showPagePicker;
|
||||
}
|
||||
|
||||
function toggleEditMode() {
|
||||
editMode = !editMode;
|
||||
if (!editMode) showPagePicker = false;
|
||||
}
|
||||
|
||||
// ── Width pills ─────────────────────────────────────────
|
||||
const WIDTH_OPTIONS: { id: PageWidth; label: string }[] = [
|
||||
{ id: 'narrow', label: 'S' },
|
||||
{ id: 'medium', label: 'M' },
|
||||
{ id: 'wide', label: 'L' },
|
||||
{ id: 'full', label: 'XL' },
|
||||
];
|
||||
|
||||
function setPageWidth(width: PageWidth) {
|
||||
todoSettings.update({ pageWidth: width });
|
||||
}
|
||||
|
||||
const PAGE_WIDTH_MAP: Record<string, string> = {
|
||||
narrow: 'min(360px, 85vw)',
|
||||
medium: 'min(480px, 85vw)',
|
||||
wide: 'min(640px, 90vw)',
|
||||
full: 'min(840px, 95vw)',
|
||||
};
|
||||
|
||||
let sheetWidthVar = $derived(PAGE_WIDTH_MAP[todoSettings.pageWidth] || PAGE_WIDTH_MAP.medium);
|
||||
|
||||
let pagePickerEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (showPagePicker && pagePickerEl) {
|
||||
pagePickerEl.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Todo - ManaCore</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<div class="todo-board">
|
||||
<!-- Header -->
|
||||
<header class="mb-4 flex items-start justify-between">
|
||||
<header class="todo-header">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('todo.title')}</h1>
|
||||
<h1 class="todo-title">Todo</h1>
|
||||
{#if isLoaded}
|
||||
<div class="mt-1 flex gap-4 text-sm text-muted-foreground">
|
||||
<div class="todo-stats">
|
||||
<span>{stats.total} {$_('todo.tasks')}</span>
|
||||
<span>{stats.completed} {$_('todo.completed')}</span>
|
||||
{#if stats.overdue > 0}
|
||||
<span class="text-red-500">{stats.overdue} {$_('todo.overdue')}</span>
|
||||
{/if}
|
||||
{#if stats.today > 0}
|
||||
<span class="text-amber-500">{stats.today} {$_('todo.today')}</span>
|
||||
<span class="text-red-500">{stats.overdue} überfällig</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<StatisticsSkeleton />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="todo-header-actions">
|
||||
<SyncIndicator />
|
||||
<a
|
||||
href="/todo/settings"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted"
|
||||
title={$_('todo.settings.title')}
|
||||
>
|
||||
<a href="/todo/settings" class="settings-btn" title="Einstellungen">
|
||||
<Gear size={16} />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- View Tabs + Toolbar -->
|
||||
<div class="mb-3 flex items-center justify-between gap-2">
|
||||
<div class="flex gap-1 rounded-lg border border-border bg-card p-1">
|
||||
{#each views as view}
|
||||
<button
|
||||
onclick={() => {
|
||||
isBoardView = false;
|
||||
switch (view.id) {
|
||||
case 'inbox':
|
||||
viewStore.setInbox();
|
||||
break;
|
||||
case 'today':
|
||||
viewStore.setToday();
|
||||
break;
|
||||
case 'upcoming':
|
||||
viewStore.setUpcoming();
|
||||
break;
|
||||
case 'completed':
|
||||
viewStore.setCompleted();
|
||||
break;
|
||||
}
|
||||
}}
|
||||
class="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-colors
|
||||
{!isBoardView && viewStore.currentView === view.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'}"
|
||||
>
|
||||
<view.icon size={16} />
|
||||
<span class="hidden sm:inline">{view.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<TodoToolbar
|
||||
showBoardToggle={allBoardViews.length > 0}
|
||||
{isBoardView}
|
||||
onToggleBoard={() => (isBoardView = !isBoardView)}
|
||||
/>
|
||||
<!-- Quick Add -->
|
||||
<div class="quick-add-wrapper">
|
||||
<QuickAddTask labels={allLabels} onShowSyntaxHelp={() => (showSyntaxHelp = true)} />
|
||||
</div>
|
||||
|
||||
<!-- Tag Strip -->
|
||||
<TagStrip
|
||||
labels={allLabels}
|
||||
collapsed={todoSettings.filterStripCollapsed}
|
||||
onToggleCollapse={() => todoSettings.toggleFilterStrip()}
|
||||
/>
|
||||
|
||||
<!-- Search -->
|
||||
{#if viewStore.currentView === 'search'}
|
||||
<div class="relative mb-4">
|
||||
<MagnifyingGlass
|
||||
size={18}
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={$_('todo.search') + '...'}
|
||||
value={viewStore.searchQuery}
|
||||
oninput={(e) => viewStore.updateSearchQuery(e.currentTarget.value)}
|
||||
class="w-full rounded-lg border border-border bg-card py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Board View Selector -->
|
||||
{#if isBoardView}
|
||||
<div class="mb-4">
|
||||
<ViewSelector
|
||||
views={allBoardViews}
|
||||
activeViewId={activeBoardId}
|
||||
onSelect={(id) => (activeBoardId = id)}
|
||||
onCreateView={() => {
|
||||
editBoardView = null;
|
||||
showBoardEditor = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick Add -->
|
||||
{#if !isBoardView}
|
||||
<QuickAddTask labels={allLabels} onShowSyntaxHelp={() => (showSyntaxHelp = true)} />
|
||||
{/if}
|
||||
|
||||
<!-- Main Content -->
|
||||
{#if !isLoaded}
|
||||
<TaskListSkeleton />
|
||||
{:else if isBoardView && activeBoard}
|
||||
<BoardViewRenderer
|
||||
view={activeBoard}
|
||||
tasks={allTasks}
|
||||
labels={allLabels}
|
||||
wipLimit={todoSettings.wipLimitPerColumn}
|
||||
cardSize={todoSettings.kanbanCardSize}
|
||||
onToggleComplete={(id) => tasksStore.toggleComplete(id)}
|
||||
onSaveTask={(id, data) => tasksStore.updateTask(id, data)}
|
||||
onDeleteTask={(id) => tasksStore.deleteTask(id)}
|
||||
onQuickAdd={handleBoardQuickAdd}
|
||||
onOpenTask={(task) => (editTask = task)}
|
||||
/>
|
||||
{:else if displayTasks.length === 0}
|
||||
<div class="flex flex-col items-center py-12 text-center">
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Tray size={32} class="text-muted-foreground" />
|
||||
</div>
|
||||
<h2 class="mb-1 text-lg font-semibold text-foreground">
|
||||
{#if viewStore.currentView === 'completed'}
|
||||
{$_('todo.noTasksCompleted')}
|
||||
{:else if viewStore.currentView === 'today'}
|
||||
{$_('todo.noTasksToday')}
|
||||
{:else if viewStore.currentView === 'upcoming'}
|
||||
{$_('todo.noTasksUpcoming')}
|
||||
{:else if viewStore.currentView === 'search'}
|
||||
{$_('todo.noTasks')}
|
||||
{:else}
|
||||
{$_('todo.noTasksInbox')}
|
||||
{/if}
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{#if viewStore.currentView === 'inbox'}
|
||||
{$_('todo.firstTaskHint')}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<TaskList
|
||||
tasks={displayTasks}
|
||||
tags={tagList}
|
||||
compact={todoSettings.compactMode}
|
||||
dragEnabled={viewStore.sortBy === 'order'}
|
||||
onOpenTask={(task) => (editTask = task)}
|
||||
/>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-muted-foreground">
|
||||
{displayTasks.length}
|
||||
{$_('todo.tasks')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Projects Section -->
|
||||
{#if !isBoardView && allProjects.length > 0}
|
||||
<div class="mt-8">
|
||||
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{$_('todo.projects')}
|
||||
</h2>
|
||||
<div class="space-y-1">
|
||||
{#each allProjects as project (project.id)}
|
||||
<!-- Width pills (visible in edit mode) -->
|
||||
{#if editMode}
|
||||
<div class="edit-toolbar">
|
||||
<div class="width-pills">
|
||||
{#each WIDTH_OPTIONS as opt (opt.id)}
|
||||
<button
|
||||
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-colors hover:bg-card"
|
||||
class="width-pill"
|
||||
class:active={todoSettings.pageWidth === opt.id}
|
||||
onclick={() => setPageWidth(opt.id)}
|
||||
>
|
||||
<div
|
||||
class="h-3 w-3 rounded-sm"
|
||||
style="background-color: {project.color ?? '#6b7280'}"
|
||||
></div>
|
||||
<span class="flex-1 text-left text-foreground">{project.name}</span>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pages carousel -->
|
||||
<div class="fokus-track" style="--sheet-width: {sheetWidthVar}">
|
||||
{#each expandedPages as page, pageIdx (page.id)}
|
||||
{@const config = getCustomPageConfig(page.id)}
|
||||
{@const isCustom = page.id.startsWith('custom-')}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="page-drag-wrapper"
|
||||
class:dragging={dragPageId === page.id}
|
||||
draggable={!editMode}
|
||||
ondragstart={(e) => handlePageDragStart(e, page.id)}
|
||||
ondragover={handlePageDragOver}
|
||||
ondrop={(e) => handlePageDrop(e, page.id)}
|
||||
ondragend={handlePageDragEnd}
|
||||
>
|
||||
<TodoPage
|
||||
pageId={page.id}
|
||||
{allTasks}
|
||||
title={page.customTitle ?? config?.label}
|
||||
maximized={page.maximized}
|
||||
{editMode}
|
||||
filterConfig={isCustom ? config?.filter : undefined}
|
||||
pageIcon={isCustom ? config?.icon : undefined}
|
||||
customPageConfig={isCustom ? config : undefined}
|
||||
isFirst={pageIdx === 0}
|
||||
isLast={pageIdx === expandedPages.length - 1}
|
||||
onClose={() => handleRemovePage(page.id)}
|
||||
onMinimize={() => handleMinimizePage(page.id)}
|
||||
onMaximize={() => handleMaximizePage(page.id)}
|
||||
onRename={(name) => handleRenamePage(page.id, name)}
|
||||
onUpdateConfig={isCustom ? (data) => handleUpdateCustomPage(page.id, data) : undefined}
|
||||
onMoveLeft={editMode ? () => handleMovePageLeft(page.id) : undefined}
|
||||
onMoveRight={editMode ? () => handleMovePageRight(page.id) : undefined}
|
||||
onDelete={editMode ? () => handleDeletePage(page.id) : undefined}
|
||||
onOpenTask={(task) => (editTask = task)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Page picker / add button -->
|
||||
{#if expandedPages.length === 0}
|
||||
<div class="empty-pages-wrapper">
|
||||
{#if showPagePicker}
|
||||
<PagePicker
|
||||
onSelect={handleAddPage}
|
||||
onClose={() => (showPagePicker = false)}
|
||||
onCreateCustom={handleCreateCustomPage}
|
||||
activePageIds={openPages.map((p) => p.id)}
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
class="neue-seite-card alone"
|
||||
onclick={togglePagePicker}
|
||||
title="Neue Seite hinzufügen"
|
||||
>
|
||||
<Plus size={24} />
|
||||
<span class="neue-seite-label">Seite hinzufügen</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if showPagePicker}
|
||||
<div bind:this={pagePickerEl}>
|
||||
<PagePicker
|
||||
onSelect={handleAddPage}
|
||||
onClose={() => (showPagePicker = false)}
|
||||
onCreateCustom={handleCreateCustomPage}
|
||||
activePageIds={openPages.map((p) => p.id)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="neue-seite-card" onclick={togglePagePicker} title="Neue Seite hinzufügen">
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Edit FAB -->
|
||||
<button
|
||||
class="edit-fab"
|
||||
class:active={editMode}
|
||||
onclick={toggleEditMode}
|
||||
title={editMode ? 'Bearbeitung beenden' : 'Seiten bearbeiten'}
|
||||
>
|
||||
{#if editMode}
|
||||
<X size={20} />
|
||||
{:else}
|
||||
<PencilSimple size={20} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Task Edit Modal -->
|
||||
|
|
@ -392,13 +398,6 @@
|
|||
<TaskEditModal task={editTask} open={true} onClose={() => (editTask = null)} />
|
||||
{/if}
|
||||
|
||||
<!-- Board Editor Modal -->
|
||||
<ViewEditorModal
|
||||
view={editBoardView}
|
||||
open={showBoardEditor}
|
||||
onClose={() => (showBoardEditor = false)}
|
||||
/>
|
||||
|
||||
<!-- Syntax Help Overlay -->
|
||||
<SyntaxHelpOverlay open={showSyntaxHelp} onClose={() => (showSyntaxHelp = false)} />
|
||||
|
||||
|
|
@ -416,17 +415,257 @@
|
|||
/>
|
||||
|
||||
<style>
|
||||
.todo-board {
|
||||
min-height: calc(100vh - 140px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.todo-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.todo-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.todo-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.todo-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--color-muted-foreground);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.settings-btn:hover {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.quick-add-wrapper {
|
||||
padding: 0 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Edit toolbar */
|
||||
.edit-toolbar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
animation: fadeDown 0.2s ease-out;
|
||||
}
|
||||
@keyframes fadeDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.width-pills {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.125rem;
|
||||
}
|
||||
:global(.dark) .width-pills {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.width-pill {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.width-pill:hover {
|
||||
color: #374151;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.width-pill.active {
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
color: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
:global(.dark) .width-pill {
|
||||
color: #9ca3af;
|
||||
}
|
||||
:global(.dark) .width-pill:hover {
|
||||
color: #e5e7eb;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
:global(.dark) .width-pill.active {
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Pages carousel */
|
||||
.fokus-track {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
overflow-x: auto;
|
||||
padding: 1rem calc(50% - var(--sheet-width) / 2);
|
||||
scrollbar-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
.fokus-track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-drag-wrapper {
|
||||
flex: 0 0 auto;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.page-drag-wrapper.dragging {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Add page button */
|
||||
.neue-seite-card {
|
||||
flex: 0 0 auto;
|
||||
width: 48px;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
border: 2px dashed rgba(0, 0, 0, 0.08);
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.empty-pages-wrapper {
|
||||
flex: 0 0 auto;
|
||||
width: var(--sheet-width, min(480px, 85vw));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
.neue-seite-card.alone {
|
||||
width: 100%;
|
||||
min-height: 60vh;
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.neue-seite-card:hover {
|
||||
border-color: var(--color-primary, #8b5cf6);
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
background: color-mix(in srgb, var(--color-primary, #8b5cf6) 4%, transparent);
|
||||
}
|
||||
:global(.dark) .neue-seite-card {
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
color: #4b5563;
|
||||
}
|
||||
:global(.dark) .neue-seite-card.alone {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #6b7280;
|
||||
}
|
||||
:global(.dark) .neue-seite-card:hover {
|
||||
border-color: var(--color-primary, #8b5cf6);
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
background: color-mix(in srgb, var(--color-primary, #8b5cf6) 8%, transparent);
|
||||
}
|
||||
.neue-seite-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Edit FAB */
|
||||
.edit-fab {
|
||||
position: fixed;
|
||||
bottom: 5.5rem;
|
||||
right: 1.25rem;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
background: #fffef5;
|
||||
color: #6b7280;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.12),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
z-index: 40;
|
||||
}
|
||||
.edit-fab:hover {
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
box-shadow:
|
||||
0 4px 12px rgba(0, 0, 0, 0.15),
|
||||
0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.edit-fab.active {
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
color: white;
|
||||
box-shadow:
|
||||
0 4px 12px rgba(139, 92, 246, 0.3),
|
||||
0 0 0 1px rgba(139, 92, 246, 0.5);
|
||||
}
|
||||
.edit-fab.active:hover {
|
||||
background: color-mix(in srgb, var(--color-primary, #8b5cf6) 85%, black);
|
||||
color: white;
|
||||
}
|
||||
:global(.dark) .edit-fab {
|
||||
background: #252220;
|
||||
color: #9ca3af;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
:global(.dark) .edit-fab:hover {
|
||||
color: var(--color-primary, #8b5cf6);
|
||||
}
|
||||
:global(.dark) .edit-fab.active {
|
||||
background: var(--color-primary, #8b5cf6);
|
||||
color: white;
|
||||
}
|
||||
|
||||
:global(.mana-drop-target-hover) {
|
||||
outline: 2px solid var(--color-primary, #6366f1);
|
||||
outline-offset: -2px;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(99, 102, 241, 0.06) !important;
|
||||
}
|
||||
|
||||
:global(.mana-drop-target-success) {
|
||||
animation: drop-success 400ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes drop-success {
|
||||
0% {
|
||||
outline-color: #10b981;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue