mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 05:21:10 +02:00
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:
parent
c8daa443fc
commit
a08f1501f2
24 changed files with 1899 additions and 184 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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: '/',
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
let isAdmin = $derived(authStore.user?.role === 'admin');
|
||||
$effect(() => {
|
||||
if (authStore.initialized && !authStore.loading && !isAdmin) {
|
||||
goto('/home');
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
appId="manacore"
|
||||
{translations}
|
||||
showBackButton
|
||||
onBack={() => goto('/dashboard')}
|
||||
onBack={() => goto('/')}
|
||||
showGettingStarted={false}
|
||||
showChangelog={false}
|
||||
defaultSection="faq"
|
||||
|
|
|
|||
|
|
@ -15,5 +15,5 @@
|
|||
currentMode={theme.mode}
|
||||
onModeChange={(m) => theme.setMode(m)}
|
||||
showBackButton={true}
|
||||
onBack={() => goto('/dashboard')}
|
||||
onBack={() => goto('/')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
onMount(async () => {
|
||||
await authStore.initialize();
|
||||
if (authStore.isAuthenticated) {
|
||||
goto('/home');
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
onResendVerification={handleResendVerification}
|
||||
baseSignupCredits={25}
|
||||
{goto}
|
||||
successRedirect="/dashboard"
|
||||
successRedirect="/"
|
||||
loginPath="/login"
|
||||
lightBackground="#f3f4f6"
|
||||
darkBackground="#121212"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue