feat(manacore/web): add overlay detail views with inline editing, consolidate routes

- Remove /dashboard (redundant), merge /home into / (app root)
- Update all redirects and navigation references accordingly
- Add panel navigation stack with overlay detail views for workbench
- Implement inline-editable DetailViews for todo, calendar, contacts
- Auto-save on blur, prev/next navigation with sibling arrows
- Fix minimized tabs z-index behind quick input bar
- Fix showNewEvent undefined error in calendar AppView

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 22:43:05 +02:00
parent c8daa443fc
commit a08f1501f2
24 changed files with 1899 additions and 184 deletions

View file

@ -33,7 +33,7 @@
<!-- Quick actions -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-left mb-8">
<a
href="/dashboard"
href="/"
class="p-4 rounded-xl bg-card border hover:border-primary/50 hover:shadow-md transition-all group"
>
<div class="flex items-center gap-3">

View file

@ -1,8 +1,10 @@
<!--
AppPage — Workbench app card. Lazy-loads the app's component inside a PageShell.
AppPage — Workbench app card with overlay detail views.
The list view is always rendered. Detail/edit views open as an overlay
that floats slightly larger than the panel underneath.
-->
<script lang="ts">
import { SpinnerGap } from '@manacore/shared-icons';
import { X, CaretUp, CaretDown, SpinnerGap } from '@manacore/shared-icons';
import { PageShell } from '$lib/components/page-carousel';
import { getAppEntry } from './app-registry';
import type { Component } from 'svelte';
@ -31,46 +33,210 @@
let appName = $derived(appEntry?.name ?? appId);
let appColor = $derived(appEntry?.color ?? '#6B7280');
// Lazy-load app component
let AppComponent = $state<Component | null>(null);
// ── List View (always loaded) ───────────────────────────
let ListComponent = $state<Component | null>(null);
let loadError = $state(false);
$effect(() => {
AppComponent = null;
ListComponent = null;
loadError = false;
if (appEntry) {
appEntry.load().then(
(mod) => (AppComponent = mod.default),
const loader = appEntry.views?.list?.load ?? appEntry.load;
loader().then(
(mod) => (ListComponent = mod.default),
() => (loadError = true)
);
}
});
// ── Overlay ─────────────────────────────────────────────
interface OverlayFrame {
viewName: string;
params: Record<string, unknown>;
component: Component | null;
}
let overlay = $state<OverlayFrame | null>(null);
let hasOverlay = $derived(overlay !== null);
// Sibling item IDs for prev/next navigation
let siblingIds = $state<string[]>([]);
let siblingKey = $state<string>('');
let cachedOverlayComponent = $state<Component | null>(null);
let currentSiblingIndex = $derived(() => {
if (!overlay || !siblingKey || siblingIds.length === 0) return -1;
const currentId = overlay.params[siblingKey] as string;
return siblingIds.indexOf(currentId);
});
let hasPrev = $derived(currentSiblingIndex() > 0);
let hasNext = $derived(
currentSiblingIndex() >= 0 && currentSiblingIndex() < siblingIds.length - 1
);
function navigate(viewName: string, params: Record<string, unknown> = {}) {
if (viewName === 'list') {
overlay = null;
return;
}
const viewEntry = appEntry?.views?.[viewName];
if (!viewEntry) {
console.warn(`View "${viewName}" not registered for app "${appId}"`);
return;
}
const ids = params._siblingIds as string[] | undefined;
const key = params._siblingKey as string | undefined;
if (ids && key) {
siblingIds = ids;
siblingKey = key;
} else if (!overlay) {
siblingIds = [];
siblingKey = '';
}
const viewParams = { ...params };
delete viewParams._siblingIds;
delete viewParams._siblingKey;
viewEntry.load().then((mod) => {
cachedOverlayComponent = mod.default;
overlay = { viewName, params: viewParams, component: mod.default };
});
}
function goBack() {
overlay = null;
siblingIds = [];
siblingKey = '';
}
function goToPrev() {
const idx = currentSiblingIndex();
if (idx > 0 && overlay && siblingKey && cachedOverlayComponent) {
overlay = {
viewName: overlay.viewName,
params: { ...overlay.params, [siblingKey]: siblingIds[idx - 1] },
component: cachedOverlayComponent,
};
}
}
function goToNext() {
const idx = currentSiblingIndex();
if (
idx >= 0 &&
idx < siblingIds.length - 1 &&
overlay &&
siblingKey &&
cachedOverlayComponent
) {
overlay = {
viewName: overlay.viewName,
params: { ...overlay.params, [siblingKey]: siblingIds[idx + 1] },
component: cachedOverlayComponent,
};
}
}
let overlayCardEl = $state<HTMLDivElement | null>(null);
// Close overlay on any click outside the overlay card
$effect(() => {
if (!overlay) return;
function handleGlobalClick(e: MouseEvent) {
if (overlayCardEl && !overlayCardEl.contains(e.target as Node)) {
overlay = null;
siblingIds = [];
siblingKey = '';
}
}
const timer = setTimeout(() => {
window.addEventListener('click', handleGlobalClick, true);
}, 0);
return () => {
clearTimeout(timer);
window.removeEventListener('click', handleGlobalClick, true);
};
});
</script>
<PageShell
{widthPx}
{maximized}
title={appName}
color={appColor}
{onClose}
{onMinimize}
{onMaximize}
{onResize}
>
{#if loadError}
<div class="load-state">
<p>App konnte nicht geladen werden</p>
</div>
{:else if AppComponent}
<AppComponent />
{:else}
<div class="load-state">
<SpinnerGap size={24} class="spinner" />
<div class="app-page-wrapper">
<!-- Base: PageShell with list view (always visible) -->
<PageShell
{widthPx}
{maximized}
title={appName}
color={appColor}
{onClose}
{onMinimize}
{onMaximize}
{onResize}
>
{#if loadError}
<div class="load-state">
<p>App konnte nicht geladen werden</p>
</div>
{:else if ListComponent}
<div class="list-container" class:dimmed={hasOverlay}>
<ListComponent {navigate} {goBack} params={{}} />
</div>
{:else}
<div class="load-state">
<SpinnerGap size={24} class="spinner" />
</div>
{/if}
</PageShell>
<!-- Overlay: Detail view floating above -->
{#if overlay?.component}
{@const OverlayComponent = overlay.component}
<div class="overlay-backdrop">
<div class="overlay-card" bind:this={overlayCardEl}>
<!-- Nav: prev arrow -->
{#if hasPrev}
<button class="nav-arrow" onclick={goToPrev} title="Vorheriger">
<CaretUp size={14} weight="bold" />
</button>
{/if}
<!-- Header -->
<div class="overlay-header">
<span class="color-dot" style="background-color: {appColor}"></span>
<span class="overlay-title">{appName}</span>
{#if siblingIds.length > 1}
<span class="nav-counter">
{currentSiblingIndex() + 1}/{siblingIds.length}
</span>
{/if}
<button class="close-btn" onclick={goBack} title="Schließen">
<X size={14} />
</button>
</div>
<!-- Body -->
<div class="overlay-body">
{#key overlay.params[siblingKey] ?? ''}
<OverlayComponent {navigate} {goBack} params={overlay.params} />
{/key}
</div>
<!-- Nav: next arrow -->
{#if hasNext}
<button class="nav-arrow" onclick={goToNext} title="Nächster">
<CaretDown size={14} weight="bold" />
</button>
{/if}
</div>
</div>
{/if}
</PageShell>
</div>
<style>
.app-page-wrapper {
position: relative;
}
.load-state {
display: flex;
align-items: center;
@ -80,11 +246,9 @@
color: #9ca3af;
font-size: 0.8125rem;
}
.load-state :global(.spinner) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
@ -93,4 +257,149 @@
transform: rotate(360deg);
}
}
.list-container {
height: 100%;
transition: filter 0.2s ease;
}
.list-container.dimmed {
filter: brightness(0.7) blur(1px);
pointer-events: none;
}
/* ── Overlay ──────────────────────────────────────────── */
.overlay-backdrop {
position: absolute;
inset: -12px;
z-index: 10;
display: flex;
align-items: stretch;
justify-content: center;
animation: fadeIn 0.15s ease-out;
}
.overlay-card {
width: 100%;
display: flex;
flex-direction: column;
background: #fffef5;
border-radius: 0.5rem;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.15),
0 0 0 1px rgba(0, 0, 0, 0.06);
overflow: hidden;
animation: scaleIn 0.2s ease-out;
}
:global(.dark) .overlay-card {
background: #252220;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.08);
}
/* Nav arrows — inside the card, flush with edges */
.nav-arrow {
display: flex;
align-items: center;
justify-content: center;
height: 28px;
flex-shrink: 0;
border: none;
background: transparent;
color: #b0afa8;
cursor: pointer;
transition: all 0.15s;
}
.nav-arrow:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
:global(.dark) .nav-arrow {
color: #4b5563;
}
:global(.dark) .nav-arrow:hover {
background: rgba(255, 255, 255, 0.06);
color: #f3f4f6;
}
.nav-counter {
font-size: 0.6875rem;
color: #9ca3af;
font-variant-numeric: tabular-nums;
}
.overlay-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .overlay-header {
border-color: rgba(255, 255, 255, 0.06);
}
.color-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
flex-shrink: 0;
}
.overlay-title {
flex: 1;
font-size: 0.8125rem;
font-weight: 600;
color: #374151;
}
:global(.dark) .overlay-title {
color: #f3f4f6;
}
.close-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;
}
.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;
}
.overlay-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>

View file

@ -1,16 +1,24 @@
/**
* App Component Registry Maps app IDs to lazy-loaded AppView components.
* App Component Registry Maps app IDs to lazy-loaded views.
*
* Each entry provides the dynamic import for embedding in the workbench carousel.
* Each entry provides a default `load` (list view) and optional named `views`
* for in-panel navigation (detail, create, edit, etc.).
*/
import type { Component } from 'svelte';
export interface ViewEntry {
load: () => Promise<{ default: Component }>;
}
export interface AppEntry {
id: string;
name: string;
color: string;
/** Default view loader (list/main view). */
load: () => Promise<{ default: Component }>;
/** Named views for in-panel navigation. Fallback: { list: load }. */
views?: Record<string, ViewEntry>;
}
export const APP_REGISTRY: AppEntry[] = [
@ -19,18 +27,30 @@ export const APP_REGISTRY: AppEntry[] = [
name: 'Todo',
color: '#8B5CF6',
load: () => import('$lib/modules/todo/AppView.svelte'),
views: {
list: { load: () => import('$lib/modules/todo/AppView.svelte') },
detail: { load: () => import('$lib/modules/todo/views/DetailView.svelte') },
},
},
{
id: 'calendar',
name: 'Kalender',
color: '#3B82F6',
load: () => import('$lib/modules/calendar/AppView.svelte'),
views: {
list: { load: () => import('$lib/modules/calendar/AppView.svelte') },
detail: { load: () => import('$lib/modules/calendar/views/DetailView.svelte') },
},
},
{
id: 'contacts',
name: 'Kontakte',
color: '#22C55E',
load: () => import('$lib/modules/contacts/AppView.svelte'),
views: {
list: { load: () => import('$lib/modules/contacts/AppView.svelte') },
detail: { load: () => import('$lib/modules/contacts/views/DetailView.svelte') },
},
},
{
id: 'chat',

View file

@ -0,0 +1,20 @@
/**
* Panel Navigation Stack Types for in-panel view navigation.
*
* Each workbench panel manages its own navigation stack.
* Views are pushed/popped within the panel (list detail edit).
*/
import type { Component } from 'svelte';
export interface NavFrame {
viewName: string;
params: Record<string, unknown>;
component: Component | null;
}
export interface ViewProps {
navigate: (viewName: string, params?: Record<string, unknown>) => void;
goBack: () => void;
params: Record<string, unknown>;
}

View file

@ -64,7 +64,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#10B981',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
},
// ============================================
@ -104,7 +104,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#10B981',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://chat.mana.how',
},
@ -142,7 +142,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#F59E0B',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://picture.mana.how',
},
@ -180,7 +180,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#10B981',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://presi.mana.how',
},
@ -219,7 +219,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#0EA5E9',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://mail.mana.how',
},
@ -260,7 +260,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#F59E0B',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://cards.mana.how',
},
@ -298,7 +298,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#EC4899',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://todo.mana.how',
},
@ -337,7 +337,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#8B5CF6',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://calendar.mana.how',
},
@ -375,7 +375,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#EC4899',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://contacts.mana.how',
},
@ -414,7 +414,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#8B5CF6',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://finance.mana.how',
},
@ -457,7 +457,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#10B981',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://zitare.mana.how',
},
@ -495,7 +495,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#F59E0B',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://storage.mana.how',
},
@ -534,7 +534,7 @@ export const appConfigs: Record<string, AppConfig> = {
color: '#6366F1',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
website: 'https://moodlit.mana.how',
},
};
@ -589,7 +589,7 @@ export const defaultManaConfig: AppConfig = {
color: '#F59E0B',
},
],
dashboardRoute: '/dashboard',
dashboardRoute: '/',
};
/**

View file

@ -1,6 +1,7 @@
<!--
Calendar — Workbench AppView
Calendar — Workbench AppView (List View)
Mini week view with today's events + quick event creation.
Clicking an event opens the detail view.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
@ -8,6 +9,9 @@
import type { LocalEvent } from './types';
import { eventsStore } from './stores/events.svelte';
import { Plus } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
let { navigate, goBack, params }: ViewProps = $props();
let events = $state<LocalEvent[]>([]);
@ -117,7 +121,15 @@
</form>
{#each todayEvents as event (event.id)}
<div class="event-card">
<button
class="event-card"
onclick={() =>
navigate('detail', {
eventId: event.id,
_siblingIds: todayEvents.map((e) => e.id),
_siblingKey: 'eventId',
})}
>
<p class="event-title">{event.title}</p>
<p class="event-time-label">
{#if event.allDay}
@ -129,7 +141,7 @@
{#if event.location}
<p class="event-location">{event.location}</p>
{/if}
</div>
</button>
{/each}
{#if todayEvents.length === 0}
@ -245,15 +257,26 @@
color: #4b5563;
}
.event-card {
display: block;
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(0, 0, 0, 0.02);
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.event-card:hover {
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .event-card {
border-color: rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
}
:global(.dark) .event-card:hover {
background: rgba(255, 255, 255, 0.06);
}
.event-title {
font-size: 0.8125rem;
font-weight: 500;

View file

@ -0,0 +1,396 @@
<!--
Calendar — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { eventsStore } from '../stores/events.svelte';
import { Trash, MapPin, Clock } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalEvent } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
let eventId = $derived(params.eventId as string);
let event = $state<LocalEvent | null>(null);
let confirmDelete = $state(false);
let editTitle = $state('');
let editDate = $state('');
let editStartTime = $state('');
let editEndTime = $state('');
let editLocation = $state('');
let editDescription = $state('');
let editAllDay = $state(false);
let focused = $state(false);
$effect(() => {
eventId; // track
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalEvent>('events').get(eventId)).subscribe((val) => {
event = val ?? null;
if (val && !focused) {
editTitle = val.title;
editDate = val.startDate.split('T')[0];
editStartTime = val.startDate.includes('T')
? val.startDate.split('T')[1]?.substring(0, 5)
: '';
editEndTime = val.endDate.includes('T') ? val.endDate.split('T')[1]?.substring(0, 5) : '';
editLocation = val.location ?? '';
editDescription = val.description ?? '';
editAllDay = val.allDay;
}
});
return () => sub.unsubscribe();
});
async function saveField() {
focused = false;
const startTime = editAllDay ? `${editDate}T00:00:00` : `${editDate}T${editStartTime}:00`;
const endTime = editAllDay ? `${editDate}T23:59:59` : `${editDate}T${editEndTime}:00`;
await eventsStore.updateEvent(eventId, {
title: editTitle.trim() || event?.title || 'Untitled',
startTime,
endTime,
isAllDay: editAllDay,
location: editLocation.trim() || null,
description: editDescription.trim() || null,
});
}
async function handleAllDayChange() {
await saveField();
}
async function deleteEvent() {
await eventsStore.deleteEvent(eventId);
goBack();
}
</script>
<div class="detail-view">
{#if !event}
<p class="empty">Termin nicht gefunden</p>
{:else}
<!-- Title -->
<input
class="title-input"
bind:value={editTitle}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Titel..."
/>
<!-- Time -->
<div class="properties">
<div class="prop-row">
<span class="prop-icon"><Clock size={14} /></span>
<div class="time-fields">
<input
type="date"
class="prop-input"
bind:value={editDate}
onfocus={() => (focused = true)}
onblur={saveField}
/>
{#if !editAllDay}
<div class="time-range">
<input
type="time"
class="prop-input"
bind:value={editStartTime}
onfocus={() => (focused = true)}
onblur={saveField}
/>
<span class="time-sep"></span>
<input
type="time"
class="prop-input"
bind:value={editEndTime}
onfocus={() => (focused = true)}
onblur={saveField}
/>
</div>
{/if}
<label class="allday-label">
<input type="checkbox" bind:checked={editAllDay} onchange={handleAllDayChange} />
Ganztägig
</label>
</div>
</div>
<div class="prop-row">
<span class="prop-icon"><MapPin size={14} /></span>
<input
class="prop-input wide"
bind:value={editLocation}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Ort hinzufügen..."
/>
</div>
{#if event.recurrenceRule}
<div class="prop-row">
<span class="prop-icon">🔁</span>
<span class="prop-value recurrence">{event.recurrenceRule}</span>
</div>
{/if}
</div>
<!-- Description -->
<div class="section">
<span class="section-label">Beschreibung</span>
<textarea
class="description-input"
bind:value={editDescription}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Beschreibung hinzufügen..."
rows={3}
></textarea>
</div>
<!-- Metadata -->
<div class="meta">
{#if event.createdAt}
<span>Erstellt: {new Date(event.createdAt).toLocaleDateString('de')}</span>
{/if}
{#if event.updatedAt}
<span>Bearbeitet: {new Date(event.updatedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Termin wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteEvent}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
.title-input {
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
.properties {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.prop-row {
display: flex;
align-items: flex-start;
gap: 0.625rem;
}
.prop-icon {
color: #9ca3af;
display: flex;
margin-top: 0.25rem;
flex-shrink: 0;
}
.prop-value.recurrence {
font-size: 0.75rem;
color: #6b7280;
}
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-input:hover,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.prop-input.wide {
flex: 1;
}
.prop-input::placeholder {
color: #c0bfba;
}
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-input:hover,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .prop-input::placeholder {
color: #4b5563;
}
.time-fields {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.time-range {
display: flex;
align-items: center;
gap: 0.375rem;
}
.time-sep {
color: #9ca3af;
font-size: 0.75rem;
}
.allday-label {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
}
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
</style>

View file

@ -1,6 +1,7 @@
<!--
Contacts — Workbench AppView
Contacts — Workbench AppView (List View)
Contact list with search + quick create.
Clicking a contact opens the detail view.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
@ -8,6 +9,9 @@
import type { LocalContact } from './types';
import { contactsStore } from './stores/contacts.svelte';
import { Plus, Star } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
let { navigate, goBack, params }: ViewProps = $props();
let contacts = $state<LocalContact[]>([]);
let search = $state('');
@ -82,7 +86,15 @@
<div class="contact-list">
{#each filtered() as contact (contact.id)}
<div class="contact-item">
<button
class="contact-item"
onclick={() =>
navigate('detail', {
contactId: contact.id,
_siblingIds: filtered().map((c) => c.id),
_siblingKey: 'contactId',
})}
>
<div class="avatar">{initials(contact)}</div>
<div class="contact-info">
<p class="contact-name">{displayName(contact)}</p>
@ -93,7 +105,7 @@
{#if contact.isFavorite}
<span class="fav"><Star size={12} weight="fill" /></span>
{/if}
</div>
</button>
{/each}
{#if filtered().length === 0}
@ -184,8 +196,13 @@
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.375rem 0.25rem;
border-radius: 0.25rem;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.contact-item:hover {

View file

@ -0,0 +1,546 @@
<!--
Contacts — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { contactsStore } from '../stores/contacts.svelte';
import {
Trash,
Star,
EnvelopeSimple,
Phone,
MapPin,
Briefcase,
Globe,
} from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalContact } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
let contactId = $derived(params.contactId as string);
let contact = $state<LocalContact | null>(null);
let confirmDelete = $state(false);
let focused = $state(false);
let editFirstName = $state('');
let editLastName = $state('');
let editEmail = $state('');
let editPhone = $state('');
let editMobile = $state('');
let editCompany = $state('');
let editJobTitle = $state('');
let editStreet = $state('');
let editCity = $state('');
let editPostalCode = $state('');
let editCountry = $state('');
let editBirthday = $state('');
let editWebsite = $state('');
let editNotes = $state('');
$effect(() => {
contactId; // track
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalContact>('contacts').get(contactId)).subscribe(
(val) => {
contact = val ?? null;
if (val && !focused) syncFields(val);
}
);
return () => sub.unsubscribe();
});
function syncFields(c: LocalContact) {
editFirstName = c.firstName ?? '';
editLastName = c.lastName ?? '';
editEmail = c.email ?? '';
editPhone = c.phone ?? '';
editMobile = c.mobile ?? '';
editCompany = c.company ?? '';
editJobTitle = c.jobTitle ?? '';
editStreet = c.street ?? '';
editCity = c.city ?? '';
editPostalCode = c.postalCode ?? '';
editCountry = c.country ?? '';
editBirthday = c.birthday ?? '';
editWebsite = c.website ?? '';
editNotes = c.notes ?? '';
}
function initials(c: LocalContact): string {
const f = c.firstName?.[0] ?? '';
const l = c.lastName?.[0] ?? '';
return (f + l).toUpperCase() || '?';
}
async function saveField() {
focused = false;
await contactsStore.updateContact(contactId, {
firstName: editFirstName.trim() || null,
lastName: editLastName.trim() || null,
email: editEmail.trim() || null,
phone: editPhone.trim() || null,
mobile: editMobile.trim() || null,
company: editCompany.trim() || null,
jobTitle: editJobTitle.trim() || null,
street: editStreet.trim() || null,
city: editCity.trim() || null,
postalCode: editPostalCode.trim() || null,
country: editCountry.trim() || null,
birthday: editBirthday || null,
website: editWebsite.trim() || null,
notes: editNotes.trim() || null,
} as Record<string, unknown>);
}
async function toggleFavorite() {
await contactsStore.toggleFavorite(contactId);
}
async function deleteContact() {
await contactsStore.deleteContact(contactId);
goBack();
}
</script>
<div class="detail-view">
{#if !contact}
<p class="empty">Kontakt nicht gefunden</p>
{:else}
<!-- Profile header -->
<div class="profile-header">
<div class="avatar-large">{initials(contact)}</div>
<div class="name-fields">
<input
class="name-input"
bind:value={editFirstName}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Vorname"
/>
<input
class="name-input"
bind:value={editLastName}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Nachname"
/>
</div>
<button class="fav-btn" class:active={contact.isFavorite} onclick={toggleFavorite}>
<Star size={18} weight={contact.isFavorite ? 'fill' : 'regular'} />
</button>
</div>
<!-- Contact fields -->
<div class="fields">
<div class="field-row">
<span class="field-icon"><EnvelopeSimple size={14} /></span>
<input
class="field-input"
bind:value={editEmail}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="E-Mail"
type="email"
/>
</div>
<div class="field-row">
<span class="field-icon"><Phone size={14} /></span>
<input
class="field-input"
bind:value={editPhone}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Telefon"
type="tel"
/>
</div>
<div class="field-row">
<span class="field-icon"><Phone size={14} /></span>
<input
class="field-input"
bind:value={editMobile}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Mobil"
type="tel"
/>
</div>
<div class="field-row">
<span class="field-icon"><Briefcase size={14} /></span>
<div class="field-group">
<input
class="field-input"
bind:value={editCompany}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Firma"
/>
<input
class="field-input"
bind:value={editJobTitle}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Position"
/>
</div>
</div>
<div class="field-row">
<span class="field-icon"><MapPin size={14} /></span>
<div class="field-group">
<input
class="field-input"
bind:value={editStreet}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Straße"
/>
<div class="field-row-inline">
<input
class="field-input small"
bind:value={editPostalCode}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="PLZ"
/>
<input
class="field-input"
bind:value={editCity}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Stadt"
/>
</div>
<input
class="field-input"
bind:value={editCountry}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Land"
/>
</div>
</div>
<div class="field-row">
<span class="field-icon"><Globe size={14} /></span>
<input
class="field-input"
bind:value={editWebsite}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Website"
type="url"
/>
</div>
<div class="field-row">
<span class="field-icon">🎂</span>
<input
class="field-input"
bind:value={editBirthday}
onfocus={() => (focused = true)}
onblur={saveField}
type="date"
/>
</div>
</div>
<!-- Notes -->
<div class="section">
<span class="section-label">Notizen</span>
<textarea
class="notes-input"
bind:value={editNotes}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Notizen hinzufügen..."
rows={3}
></textarea>
</div>
<!-- Metadata -->
<div class="meta">
{#if contact.createdAt}
<span>Erstellt: {new Date(contact.createdAt).toLocaleDateString('de')}</span>
{/if}
{#if contact.updatedAt}
<span>Bearbeitet: {new Date(contact.updatedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Kontakt wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteContact}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Profile header */
.profile-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.avatar-large {
width: 48px;
height: 48px;
border-radius: 9999px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.06);
font-size: 1rem;
font-weight: 600;
color: #6b7280;
}
:global(.dark) .avatar-large {
background: rgba(255, 255, 255, 0.08);
color: #9ca3af;
}
.name-fields {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.name-input {
font-size: 0.9375rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0.125rem 0;
}
.name-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.name-input::placeholder {
color: #c0bfba;
font-weight: 400;
}
:global(.dark) .name-input {
color: #f3f4f6;
}
:global(.dark) .name-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .name-input::placeholder {
color: #4b5563;
}
.fav-btn {
border: none;
background: transparent;
cursor: pointer;
color: #d1d5db;
padding: 0.25rem;
transition: color 0.15s;
flex-shrink: 0;
}
.fav-btn.active {
color: #f59e0b;
}
.fav-btn:hover {
color: #f59e0b;
}
/* Fields */
.fields {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-row {
display: flex;
align-items: flex-start;
gap: 0.625rem;
}
.field-icon {
color: #9ca3af;
display: flex;
margin-top: 0.3rem;
flex-shrink: 0;
}
.field-input {
font-size: 0.8125rem;
padding: 0.25rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
flex: 1;
transition: border-color 0.15s;
}
.field-input:hover,
.field-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.field-input::placeholder {
color: #c0bfba;
}
.field-input.small {
max-width: 5rem;
}
:global(.dark) .field-input {
color: #e5e7eb;
}
:global(.dark) .field-input:hover,
:global(.dark) .field-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .field-input::placeholder {
color: #4b5563;
}
.field-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.field-row-inline {
display: flex;
gap: 0.375rem;
}
/* Notes */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.notes-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.notes-input:hover,
.notes-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.notes-input::placeholder {
color: #c0bfba;
}
:global(.dark) .notes-input {
color: #f3f4f6;
}
:global(.dark) .notes-input:hover,
:global(.dark) .notes-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .notes-input::placeholder {
color: #4b5563;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
</style>

View file

@ -1,6 +1,7 @@
<!--
Todo — Workbench AppView
Todo — Workbench AppView (List View)
Compact task list with quick add, filter by inbox/today/overdue.
Clicking a task opens the detail view; checkbox toggles completion.
-->
<script lang="ts">
import {
@ -13,6 +14,9 @@
} from './queries';
import { tasksStore } from './stores/tasks.svelte';
import { Circle, Check } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
let { navigate, goBack, params }: ViewProps = $props();
type ViewFilter = 'inbox' | 'today' | 'overdue';
@ -50,7 +54,8 @@
newTitle = '';
}
async function toggle(id: string) {
async function toggleComplete(e: Event, id: string) {
e.stopPropagation();
await tasksStore.toggleComplete(id);
}
</script>
@ -87,8 +92,24 @@
<div class="task-list">
{#each filtered() as task (task.id)}
<button onclick={() => toggle(task.id)} class="task-item">
<div class="checkbox" class:checked={task.isCompleted}>
<button
onclick={() =>
navigate('detail', {
taskId: task.id,
_siblingIds: filtered().map((t) => t.id),
_siblingKey: 'taskId',
})}
class="task-item"
>
<div
class="checkbox"
class:checked={task.isCompleted}
onclick={(e) => toggleComplete(e, task.id)}
onkeydown={(e) => e.key === 'Enter' && toggleComplete(e, task.id)}
role="checkbox"
aria-checked={task.isCompleted}
tabindex={0}
>
{#if task.isCompleted}<Check size={12} />{/if}
</div>
<div class="task-content">
@ -228,6 +249,9 @@
flex-shrink: 0;
transition: all 0.15s;
}
.checkbox:hover {
border-color: #9ca3af;
}
.checkbox.checked {
border-color: #22c55e;
background: #22c55e;

View file

@ -0,0 +1,478 @@
<!--
Todo — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { tasksStore } from '../stores/tasks.svelte';
import { Check, Trash } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
import type { LocalTask, TaskPriority } from '../types';
let { navigate, goBack, params }: ViewProps = $props();
let taskId = $derived(params.taskId as string);
let task = $state<LocalTask | null>(null);
let confirmDelete = $state(false);
// Edit fields — always live
let editTitle = $state('');
let editDescription = $state('');
let editDueDate = $state('');
let editPriority = $state<TaskPriority>('medium');
// Track whether user is actively editing to prevent overwrite from liveQuery
let focused = $state(false);
$effect(() => {
taskId; // track
confirmDelete = false;
focused = false;
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalTask>('tasks').get(taskId)).subscribe((val) => {
task = val ?? null;
if (val && !focused) {
editTitle = val.title;
editDescription = val.description ?? '';
editDueDate = val.dueDate?.split('T')[0] ?? '';
editPriority = val.priority;
}
});
return () => sub.unsubscribe();
});
async function saveField() {
focused = false;
await tasksStore.updateTask(taskId, {
title: editTitle.trim() || task?.title || 'Untitled',
description: editDescription.trim() || undefined,
dueDate: editDueDate ? new Date(editDueDate).toISOString() : null,
priority: editPriority,
});
}
async function handlePriorityChange() {
await tasksStore.updateTask(taskId, { priority: editPriority });
}
async function toggleComplete() {
await tasksStore.toggleComplete(taskId);
}
async function toggleSubtask(subtaskId: string) {
if (!task?.subtasks) return;
const updated = task.subtasks.map((s) =>
s.id === subtaskId
? {
...s,
isCompleted: !s.isCompleted,
completedAt: !s.isCompleted ? new Date().toISOString() : null,
}
: s
);
await tasksStore.updateTask(taskId, { subtasks: updated });
}
async function deleteTask() {
await tasksStore.deleteTask(taskId);
goBack();
}
const priorityLabels: Record<TaskPriority, string> = {
low: 'Niedrig',
medium: 'Mittel',
high: 'Hoch',
urgent: 'Dringend',
};
const priorityColors: Record<TaskPriority, string> = {
low: '#9ca3af',
medium: '#3b82f6',
high: '#f59e0b',
urgent: '#ef4444',
};
</script>
<div class="detail-view">
{#if !task}
<p class="empty">Aufgabe nicht gefunden</p>
{:else}
<!-- Title row with checkbox -->
<div class="title-row">
<button class="complete-btn" onclick={toggleComplete}>
<div class="checkbox" class:checked={task.isCompleted}>
{#if task.isCompleted}<Check size={12} />{/if}
</div>
</button>
<input
class="title-input"
class:completed={task.isCompleted}
bind:value={editTitle}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Titel..."
/>
</div>
<!-- Properties -->
<div class="properties">
<div class="prop-row">
<span class="prop-label">Priorität</span>
<select
class="prop-select"
bind:value={editPriority}
onchange={handlePriorityChange}
style="color: {priorityColors[editPriority]}"
>
{#each ['low', 'medium', 'high', 'urgent'] as const as p}
<option value={p}>{priorityLabels[p]}</option>
{/each}
</select>
</div>
<div class="prop-row">
<span class="prop-label">Fällig</span>
<input
type="date"
class="prop-input"
bind:value={editDueDate}
onfocus={() => (focused = true)}
onblur={saveField}
/>
</div>
{#if task.estimatedDuration}
<div class="prop-row">
<span class="prop-label">Dauer</span>
<span class="prop-value">{task.estimatedDuration} Min.</span>
</div>
{/if}
</div>
<!-- Description -->
<div class="section">
<span class="section-label">Beschreibung</span>
<textarea
class="description-input"
bind:value={editDescription}
onfocus={() => (focused = true)}
onblur={saveField}
placeholder="Beschreibung hinzufügen..."
rows={3}
></textarea>
</div>
<!-- Subtasks -->
{#if task.subtasks && task.subtasks.length > 0}
<div class="section">
<span class="section-label">
Unteraufgaben ({task.subtasks.filter((s) => s.isCompleted).length}/{task.subtasks.length})
</span>
<div class="subtask-list">
{#each task.subtasks as subtask (subtask.id)}
<button class="subtask-item" onclick={() => toggleSubtask(subtask.id)}>
<div class="checkbox small" class:checked={subtask.isCompleted}>
{#if subtask.isCompleted}<Check size={10} />{/if}
</div>
<span class="subtask-title" class:completed={subtask.isCompleted}>
{subtask.title}
</span>
</button>
{/each}
</div>
</div>
{/if}
<!-- Metadata -->
<div class="meta">
<span>Erstellt: {new Date(task.createdAt ?? '').toLocaleDateString('de')}</span>
{#if task.updatedAt}
<span>Bearbeitet: {new Date(task.updatedAt).toLocaleDateString('de')}</span>
{/if}
</div>
<!-- Delete -->
<div class="danger-zone">
{#if confirmDelete}
<p class="confirm-text">Aufgabe wirklich löschen?</p>
<div class="confirm-actions">
<button class="action-btn danger" onclick={deleteTask}>Löschen</button>
<button class="action-btn" onclick={() => (confirmDelete = false)}>Abbrechen</button>
</div>
{:else}
<button class="action-btn danger-subtle" onclick={() => (confirmDelete = true)}>
<Trash size={14} /> Löschen
</button>
{/if}
</div>
{/if}
</div>
<style>
.detail-view {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
/* Title row */
.title-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.complete-btn {
border: none;
background: transparent;
cursor: pointer;
padding: 0.25rem 0 0 0;
flex-shrink: 0;
}
.title-input {
flex: 1;
font-size: 1.125rem;
font-weight: 600;
border: none;
background: transparent;
outline: none;
color: #374151;
padding: 0;
}
.title-input.completed {
text-decoration: line-through;
color: #9ca3af;
}
.title-input:focus {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .title-input {
color: #f3f4f6;
}
:global(.dark) .title-input.completed {
color: #6b7280;
}
:global(.dark) .title-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Checkbox */
.checkbox {
width: 20px;
height: 20px;
border-radius: 0.25rem;
border: 2px solid #d1d5db;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.checkbox.checked {
border-color: #22c55e;
background: #22c55e;
color: white;
}
.checkbox.small {
width: 16px;
height: 16px;
border-width: 1.5px;
}
:global(.dark) .checkbox {
border-color: #4b5563;
}
/* Properties */
.properties {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.prop-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0;
}
.prop-label {
font-size: 0.75rem;
color: #9ca3af;
}
.prop-value {
font-size: 0.8125rem;
color: #374151;
}
:global(.dark) .prop-value {
color: #e5e7eb;
}
.prop-select,
.prop-input {
font-size: 0.8125rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid transparent;
background: transparent;
color: #374151;
outline: none;
transition: border-color 0.15s;
}
.prop-select:hover,
.prop-input:hover,
.prop-select:focus,
.prop-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
:global(.dark) .prop-select,
:global(.dark) .prop-input {
color: #e5e7eb;
}
:global(.dark) .prop-select:hover,
:global(.dark) .prop-input:hover,
:global(.dark) .prop-select:focus,
:global(.dark) .prop-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
/* Sections */
.section {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.section-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #9ca3af;
}
.description-input {
font-size: 0.8125rem;
border: 1px solid transparent;
border-radius: 0.375rem;
background: transparent;
color: #374151;
padding: 0.5rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color 0.15s;
}
.description-input:hover,
.description-input:focus {
border-color: rgba(0, 0, 0, 0.1);
}
.description-input::placeholder {
color: #c0bfba;
}
:global(.dark) .description-input {
color: #f3f4f6;
}
:global(.dark) .description-input:hover,
:global(.dark) .description-input:focus {
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark) .description-input::placeholder {
color: #4b5563;
}
/* Subtasks */
.subtask-list {
display: flex;
flex-direction: column;
}
.subtask-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
}
.subtask-title {
font-size: 0.8125rem;
color: #374151;
}
.subtask-title.completed {
text-decoration: line-through;
color: #9ca3af;
}
:global(.dark) .subtask-title {
color: #e5e7eb;
}
:global(.dark) .subtask-title.completed {
color: #6b7280;
}
/* Meta & actions */
.meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
color: #9ca3af;
padding-top: 0.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .meta {
border-color: rgba(255, 255, 255, 0.06);
}
.danger-zone {
padding-top: 0.5rem;
}
.confirm-text {
font-size: 0.8125rem;
color: #ef4444;
margin: 0 0 0.5rem;
}
.confirm-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
.action-btn.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.action-btn.danger-subtle {
color: #ef4444;
border-color: transparent;
display: flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .action-btn {
border-color: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
:global(.dark) .action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
</style>

View file

@ -11,7 +11,7 @@
let isAdmin = $derived(authStore.user?.role === 'admin');
$effect(() => {
if (authStore.initialized && !authStore.loading && !isAdmin) {
goto('/home');
goto('/');
}
});

View file

@ -1,17 +0,0 @@
import type { PageServerLoad } from './$types';
/**
* Dashboard page server load
*
* Note: Auth is now handled client-side via Mana Core Auth.
* Data fetching will need to be done client-side with the auth token.
*/
export const load: PageServerLoad = async () => {
// Return empty data - auth is handled client-side
// TODO: Implement client-side data fetching with Mana Core Auth token
return {
profile: null,
organizationCount: 0,
teamCount: 0,
};
};

View file

@ -1,86 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { PageHeader } from '@manacore/shared-ui';
import { authStore } from '$lib/stores/auth.svelte';
import { tilingStore } from '$lib/stores/tiling.svelte';
import { ManaCoreEvents } from '@manacore/shared-utils/analytics';
import TilingLayout from '$lib/components/dashboard/TilingLayout.svelte';
import { collectLeaves } from '$lib/utils/tiling-tree';
import TilePanel from '$lib/components/dashboard/TilePanel.svelte';
import { Check, PencilSimple } from '@manacore/shared-icons';
let isMobile = $state(false);
onMount(async () => {
await tilingStore.initialize();
isMobile = window.innerWidth < 768;
const handleResize = () => (isMobile = window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
function handleToggleEditing() {
tilingStore.toggleEditing();
ManaCoreEvents.dashboardEditToggled(tilingStore.isEditing);
}
const mobileLeaves = $derived(tilingStore.initialized ? collectLeaves(tilingStore.root) : []);
</script>
<div class="flex h-[calc(100vh-10rem)] flex-col">
<div class="mb-4 flex flex-shrink-0 items-center justify-between">
<PageHeader
title={$_('dashboard.title')}
description="{$_('dashboard.welcome')}, {authStore.user?.email || 'User'}"
size="lg"
/>
<div class="flex items-center gap-2">
{#if tilingStore.isEditing}
<button
type="button"
onclick={() => tilingStore.resetToDefault()}
class="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted/80"
>
Reset
</button>
{/if}
<button
type="button"
onclick={handleToggleEditing}
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors {tilingStore.isEditing
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
>
{#if tilingStore.isEditing}
<span class="flex items-center gap-2">
<Check size={20} />
{$_('dashboard.done')}
</span>
{:else}
<span class="flex items-center gap-2">
<PencilSimple size={16} />
{$_('dashboard.customize')}
</span>
{/if}
</button>
</div>
</div>
{#if tilingStore.initialized}
<div class="min-h-0 flex-1">
{#if isMobile}
<!-- Mobile: stacked vertical layout -->
<div class="space-y-4 overflow-y-auto pb-8">
{#each mobileLeaves as leaf (leaf.id)}
<div class="h-72">
<TilePanel {leaf} />
</div>
{/each}
</div>
{:else}
<TilingLayout node={tilingStore.root} />
{/if}
</div>
{/if}
</div>

View file

@ -25,7 +25,7 @@
appId="manacore"
{translations}
showBackButton
onBack={() => goto('/dashboard')}
onBack={() => goto('/')}
showGettingStarted={false}
showChangelog={false}
defaultSection="faq"

View file

@ -15,5 +15,5 @@
currentMode={theme.mode}
onModeChange={(m) => theme.setMode(m)}
showBackButton={true}
onBack={() => goto('/dashboard')}
onBack={() => goto('/')}
/>

View file

@ -12,7 +12,7 @@
onMount(async () => {
await authStore.initialize();
if (authStore.isAuthenticated) {
goto('/home');
goto('/');
}
});
</script>

View file

@ -38,7 +38,7 @@
onVerifyBackupCode={(code) => authStore.verifyBackupCode(code)}
onSendMagicLink={(email) => authStore.sendMagicLink(email)}
{goto}
successRedirect="/home"
successRedirect="/"
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#f3f4f6"

View file

@ -35,7 +35,7 @@
onResendVerification={handleResendVerification}
baseSignupCredits={25}
{goto}
successRedirect="/dashboard"
successRedirect="/"
loginPath="/login"
lightBackground="#f3f4f6"
darkBackground="#121212"

View file

@ -5,5 +5,5 @@
<div class="flex min-h-[60vh] flex-col items-center justify-center text-center">
<h1 class="text-6xl font-bold text-indigo-600 mb-4">{$page.status}</h1>
<p class="text-xl text-muted-foreground mb-8">{$page.error?.message || 'Seite nicht gefunden'}</p>
<a href="/dashboard" class="btn btn-primary">Zurück zum Dashboard</a>
<a href="/" class="btn btn-primary">Zurück zum Dashboard</a>
</div>

View file

@ -1,15 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
goto('/home', { replaceState: true });
});
</script>
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<h1 class="mb-4">ManaCore</h1>
<p class="text-gray-600 dark:text-gray-400">Redirecting...</p>
</div>
</div>

View file

@ -26,7 +26,7 @@
}
// Determine redirect destination
let redirectUrl = next || '/dashboard';
let redirectUrl = next || '/';
// For email verification/signup, redirect to welcome page
if (type === 'signup' || type === 'email_verification') {

View file

@ -25,12 +25,12 @@
localStorage.setItem(STORAGE_KEYS.HAS_SEEN_WELCOME, 'true');
// Redirect to app's dashboard
goto(appConfig?.dashboardRoute || '/dashboard');
goto(appConfig?.dashboardRoute || '/');
}
function handleSkip() {
localStorage.setItem(STORAGE_KEYS.HAS_SEEN_WELCOME, 'true');
goto(appConfig?.dashboardRoute || '/dashboard');
goto(appConfig?.dashboardRoute || '/');
}
</script>