feat(manacore/web): add notes and finance modules

Notes: lightweight markdown notes with search, color tags, pinning,
inline create, auto-save editor, and grid/detail views.

Finance: income/expense tracking with categories, monthly overview,
category breakdown bars, quick-add form, and transaction history.

Both modules include workbench ListView, full-page routes, entity
descriptors for drag/drop, and database/sync registration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 13:57:37 +02:00
parent a8480f6710
commit 9abbf9c70d
25 changed files with 2962 additions and 12 deletions

View file

@ -179,6 +179,15 @@ db.version(1).stores({
habits: 'id, order, isArchived, color',
habitLogs: 'id, habitId, timestamp, [habitId+timestamp]',
// ─── Notes (appId: 'notes') ───
notes: 'id, isPinned, isArchived, color, title, updatedAt',
noteTags: 'id, noteId, tagId, [noteId+tagId]',
// ─── Finance (appId: 'finance') ───
transactions: 'id, type, categoryId, date, amount, [date+type], [categoryId+date]',
financeCategories: 'id, type, order',
budgets: 'id, categoryId, month, [month+categoryId]',
// ─── Shared: Global Tags (appId: 'tags') ───
globalTags: 'id, name, groupId',
tagGroups: 'id',
@ -228,6 +237,8 @@ export const SYNC_APP_MAP: Record<string, string[]> = {
memoro: ['memos', 'memories', 'memoTags', 'memoroSpaces', 'spaceMembers', 'memoSpaces'],
guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs', 'guideTags'],
habits: ['habits', 'habitLogs'],
notes: ['notes', 'noteTags'],
finance: ['transactions', 'financeCategories', 'budgets'],
tags: ['globalTags', 'tagGroups'],
links: ['manaLinks'],
};
@ -294,6 +305,8 @@ export const TABLE_TO_SYNC_NAME: Record<string, string> = {
uloadFolders: 'folders',
// guides
guideCollections: 'collections',
// finance
financeCategories: 'categories',
// shared: tags
globalTags: 'tags',
tagGroups: 'tagGroups',

View file

@ -0,0 +1,470 @@
<!--
Finance — Workbench ListView
Monthly overview with quick-add transaction and category breakdown.
-->
<script lang="ts">
import {
useAllTransactions,
useAllCategories,
currentMonth,
getMonthTotal,
getMonthBalance,
getTransactionsForMonth,
formatCurrency,
formatDateLabel,
groupByDate,
} from './queries';
import { financeStore } from './stores/finance.svelte';
import type { Transaction, FinanceCategory, TransactionType } from './types';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
let txs$ = useAllTransactions();
let cats$ = useAllCategories();
let txs = $state<Transaction[]>([]);
let categories = $state<FinanceCategory[]>([]);
$effect(() => {
const sub = txs$.subscribe((val) => {
txs = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = cats$.subscribe((val) => {
categories = val ?? [];
});
return () => sub.unsubscribe();
});
let month = currentMonth();
let monthTxs = $derived(getTransactionsForMonth(txs, month));
let income = $derived(getMonthTotal(txs, month, 'income'));
let expenses = $derived(getMonthTotal(txs, month, 'expense'));
let balance = $derived(getMonthBalance(txs, month));
let catMap = $derived(new Map(categories.map((c) => [c.id, c])));
// Recent transactions (last 10)
let recentTxs = $derived(monthTxs.slice(0, 10));
let grouped = $derived(groupByDate(recentTxs));
// Quick add
let showAdd = $state(false);
let addType = $state<TransactionType>('expense');
let addAmount = $state('');
let addDesc = $state('');
let addCatId = $state<string | null>(null);
let filteredCats = $derived(categories.filter((c) => c.type === addType));
async function handleAdd(e: Event) {
e.preventDefault();
const amount = parseFloat(addAmount.replace(',', '.'));
if (!amount || !addDesc.trim()) return;
await financeStore.addTransaction({
type: addType,
amount,
description: addDesc.trim(),
categoryId: addCatId,
});
addAmount = '';
addDesc = '';
addCatId = null;
showAdd = false;
}
</script>
<div class="finance-list-view">
<!-- Monthly Summary -->
<div class="month-summary">
<div class="summary-row">
<span class="summary-label">Einnahmen</span>
<span class="summary-value income">+{formatCurrency(income)}</span>
</div>
<div class="summary-row">
<span class="summary-label">Ausgaben</span>
<span class="summary-value expense">-{formatCurrency(expenses)}</span>
</div>
<div class="summary-row balance">
<span class="summary-label">Bilanz</span>
<span class="summary-value" class:income={balance >= 0} class:expense={balance < 0}>
{balance >= 0 ? '+' : ''}{formatCurrency(balance)}
</span>
</div>
</div>
<!-- Add Button -->
{#if !showAdd}
<button class="add-btn" onclick={() => (showAdd = true)}>+ Transaktion</button>
{/if}
<!-- Quick Add Form -->
{#if showAdd}
<form class="add-form" onsubmit={handleAdd}>
<div class="type-toggle">
<button
type="button"
class="type-btn"
class:active={addType === 'expense'}
onclick={() => (addType = 'expense')}>Ausgabe</button
>
<button
type="button"
class="type-btn income"
class:active={addType === 'income'}
onclick={() => (addType = 'income')}>Einnahme</button
>
</div>
<div class="add-row">
<input
class="amount-input"
type="text"
inputmode="decimal"
placeholder="0,00"
bind:value={addAmount}
autofocus
/>
<span class="currency">\u20ac</span>
</div>
<input class="desc-input" type="text" placeholder="Beschreibung..." bind:value={addDesc} />
<div class="cat-row">
{#each filteredCats as cat (cat.id)}
<button
type="button"
class="cat-chip"
class:selected={addCatId === cat.id}
onclick={() => (addCatId = addCatId === cat.id ? null : cat.id)}
>
<span>{cat.emoji}</span>
<span>{cat.name}</span>
</button>
{/each}
</div>
<div class="add-actions">
<button type="button" class="btn-cancel" onclick={() => (showAdd = false)}>Abbrechen</button
>
<button type="submit" class="btn-save" disabled={!addAmount || !addDesc.trim()}
>Hinzufügen</button
>
</div>
</form>
{/if}
<!-- Recent Transactions -->
{#if recentTxs.length > 0}
<div class="tx-list">
{#each [...grouped.entries()] as [date, dayTxs] (date)}
<div class="day-label">{formatDateLabel(date)}</div>
{#each dayTxs as tx (tx.id)}
{@const cat = tx.categoryId ? catMap.get(tx.categoryId) : null}
<div class="tx-row">
<span class="tx-cat-emoji">{cat?.emoji ?? '\ud83d\udcb3'}</span>
<div class="tx-info">
<span class="tx-desc">{tx.description}</span>
{#if cat}<span class="tx-cat-name">{cat.name}</span>{/if}
</div>
<span
class="tx-amount"
class:income={tx.type === 'income'}
class:expense={tx.type === 'expense'}
>
{tx.type === 'income' ? '+' : '-'}{formatCurrency(tx.amount)}
</span>
</div>
{/each}
{/each}
</div>
{/if}
{#if txs.length === 0 && !showAdd}
<div class="empty">
<p>Noch keine Transaktionen.</p>
<button class="add-btn" onclick={() => (showAdd = true)}>Erste Transaktion</button>
</div>
{/if}
</div>
<style>
.finance-list-view {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.5rem;
}
/* ── Summary ─────────────────────────────────── */
.month-summary {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.625rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
}
.summary-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.8125rem;
}
.summary-row.balance {
padding-top: 0.375rem;
margin-top: 0.25rem;
border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
font-weight: 600;
}
.summary-label {
color: var(--color-muted-foreground);
}
.summary-value {
font-variant-numeric: tabular-nums;
}
.summary-value.income {
color: #22c55e;
}
.summary-value.expense {
color: #ef4444;
}
/* ── Add Form ────────────────────────────────── */
.add-btn {
width: 100%;
padding: 0.5rem;
border-radius: 0.5rem;
background: var(--color-primary, #6366f1);
color: white;
border: none;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s;
}
.add-btn:hover {
filter: brightness(1.1);
}
.add-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.06));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
}
.type-toggle {
display: flex;
gap: 0.25rem;
}
.type-btn {
flex: 1;
padding: 0.375rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
background: transparent;
color: var(--color-muted-foreground);
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
cursor: pointer;
}
.type-btn.active {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.type-btn.income.active {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
border-color: rgba(34, 197, 94, 0.3);
}
.add-row {
display: flex;
align-items: center;
gap: 0.25rem;
}
.amount-input {
flex: 1;
background: transparent;
border: none;
border-bottom: 2px solid var(--color-border, rgba(255, 255, 255, 0.15));
color: var(--color-foreground);
font-size: 1.25rem;
font-weight: 700;
padding: 0.25rem 0;
outline: none;
text-align: right;
font-variant-numeric: tabular-nums;
}
.amount-input:focus {
border-color: var(--color-primary, #6366f1);
}
.amount-input::placeholder {
color: var(--color-muted-foreground);
font-weight: 400;
}
.currency {
color: var(--color-muted-foreground);
font-size: 1rem;
}
.desc-input {
background: transparent;
border: none;
border-bottom: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
color: var(--color-foreground);
font-size: 0.8125rem;
padding: 0.375rem 0;
outline: none;
}
.desc-input::placeholder {
color: var(--color-muted-foreground);
}
.cat-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.cat-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
color: var(--color-foreground);
cursor: pointer;
transition: all 0.15s;
}
.cat-chip:hover {
background: rgba(255, 255, 255, 0.1);
}
.cat-chip.selected {
border-color: var(--color-primary, #6366f1);
background: rgba(99, 102, 241, 0.15);
}
.add-actions {
display: flex;
justify-content: flex-end;
gap: 0.375rem;
}
.btn-cancel,
.btn-save {
padding: 0.3rem 0.625rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-cancel {
background: transparent;
color: var(--color-muted-foreground);
}
.btn-cancel:hover {
background: var(--color-muted, rgba(255, 255, 255, 0.08));
}
.btn-save {
background: var(--color-primary, #6366f1);
color: white;
}
.btn-save:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-save:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Transaction List ────────────────────────── */
.tx-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.day-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted-foreground);
padding: 0.375rem 0 0.125rem;
}
.tx-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.25rem;
border-radius: 0.375rem;
}
.tx-cat-emoji {
font-size: 0.875rem;
flex-shrink: 0;
}
.tx-info {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.tx-desc {
font-size: 0.8125rem;
color: var(--color-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tx-cat-name {
font-size: 0.6875rem;
color: var(--color-muted-foreground);
}
.tx-amount {
font-size: 0.8125rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.tx-amount.income {
color: #22c55e;
}
.tx-amount.expense {
color: #ef4444;
}
/* ── Empty ──────────────────────────────────── */
.empty {
text-align: center;
color: var(--color-muted-foreground);
font-size: 0.875rem;
padding: 2rem 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
</style>

View file

@ -0,0 +1,73 @@
/**
* Finance module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalTransaction, LocalFinanceCategory, LocalBudget } from './types';
import { DEFAULT_EXPENSE_CATEGORIES, DEFAULT_INCOME_CATEGORIES } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const transactionTable = db.table<LocalTransaction>('transactions');
export const categoryTable = db.table<LocalFinanceCategory>('financeCategories');
export const budgetTable = db.table<LocalBudget>('budgets');
// ─── Guest Seed ────────────────────────────────────────────
function todayStr(): string {
return new Date().toISOString().split('T')[0];
}
function daysAgoStr(n: number): string {
const d = new Date();
d.setDate(d.getDate() - n);
return d.toISOString().split('T')[0];
}
const SEED_CATEGORIES = [
...DEFAULT_EXPENSE_CATEGORIES.map((c, i) => ({ ...c, id: `cat-exp-${i}` })),
...DEFAULT_INCOME_CATEGORIES.map((c, i) => ({ ...c, id: `cat-inc-${i}` })),
];
export const FINANCE_GUEST_SEED = {
financeCategories: SEED_CATEGORIES satisfies LocalFinanceCategory[],
transactions: [
{
id: 'tx-1',
type: 'expense' as const,
amount: 12.5,
categoryId: 'cat-exp-0',
description: 'Mittagessen',
date: todayStr(),
note: null,
},
{
id: 'tx-2',
type: 'expense' as const,
amount: 2.9,
categoryId: 'cat-exp-0',
description: 'Kaffee',
date: todayStr(),
note: null,
},
{
id: 'tx-3',
type: 'expense' as const,
amount: 49.99,
categoryId: 'cat-exp-6',
description: 'Spotify + Netflix',
date: daysAgoStr(2),
note: null,
},
{
id: 'tx-4',
type: 'income' as const,
amount: 3200,
categoryId: 'cat-inc-0',
description: 'Gehalt März',
date: daysAgoStr(5),
note: null,
},
] satisfies LocalTransaction[],
budgets: [] satisfies LocalBudget[],
};

View file

@ -0,0 +1,28 @@
import { registerEntity } from '$lib/entities/registry';
import { financeStore } from './stores/finance.svelte';
import type { EntityDescriptor } from '$lib/entities/types';
const financeEntity: EntityDescriptor = {
appId: 'finance',
collection: 'transactions',
paramKey: 'transactionId',
getDisplayData: (item) => ({
title: (item.description as string) || 'Transaktion',
subtitle: item.amount ? `${item.type === 'income' ? '+' : '-'}${item.amount}` : undefined,
}),
dragType: 'transaction',
acceptsDropFrom: [],
createItem: async (data) => {
const tx = await financeStore.addTransaction({
type: 'expense',
amount: (data.amount as number) ?? 0,
description: (data.title as string) ?? (data.description as string) ?? '',
});
return tx.id;
},
};
registerEntity(financeEntity);

View file

@ -0,0 +1,38 @@
/**
* Finance module barrel exports.
*/
// ─── Stores ──────────────────────────────────────────────
export { financeStore } from './stores/finance.svelte';
// ─── Queries ─────────────────────────────────────────────
export {
useAllTransactions,
useAllCategories,
toTransaction,
toCategory,
currentMonth,
todayStr,
getTransactionsForMonth,
getMonthTotal,
getMonthBalance,
groupByDate,
getSpendingByCategory,
formatCurrency,
formatDateLabel,
} from './queries';
// ─── Collections ─────────────────────────────────────────
export { transactionTable, categoryTable, budgetTable, FINANCE_GUEST_SEED } from './collections';
// ─── Types ───────────────────────────────────────────────
export { CATEGORY_COLORS } from './types';
export type {
LocalTransaction,
LocalFinanceCategory,
LocalBudget,
Transaction,
FinanceCategory,
Budget,
TransactionType,
} from './types';

View file

@ -0,0 +1,133 @@
/**
* Reactive Queries & Pure Helpers for Finance module.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type {
LocalTransaction,
LocalFinanceCategory,
Transaction,
FinanceCategory,
TransactionType,
} from './types';
// ─── Type Converters ───────────────────────────────────────
export function toTransaction(local: LocalTransaction): Transaction {
return {
id: local.id,
type: local.type,
amount: local.amount,
categoryId: local.categoryId,
description: local.description,
date: local.date,
note: local.note,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toCategory(local: LocalFinanceCategory): FinanceCategory {
return {
id: local.id,
name: local.name,
emoji: local.emoji,
color: local.color,
type: local.type,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
export function useAllTransactions() {
return liveQuery(async () => {
const locals = await db.table<LocalTransaction>('transactions').toArray();
return locals
.filter((t) => !t.deletedAt)
.map(toTransaction)
.sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt));
});
}
export function useAllCategories() {
return liveQuery(async () => {
const locals = await db.table<LocalFinanceCategory>('financeCategories').toArray();
return locals
.filter((c) => !c.deletedAt)
.map(toCategory)
.sort((a, b) => a.order - b.order);
});
}
// ─── Pure Helpers ──────────────────────────────────────────
/** Current month string YYYY-MM */
export function currentMonth(): string {
return new Date().toISOString().slice(0, 7);
}
/** Today string YYYY-MM-DD */
export function todayStr(): string {
return new Date().toISOString().split('T')[0];
}
/** Filter transactions for a month (YYYY-MM) */
export function getTransactionsForMonth(txs: Transaction[], month: string): Transaction[] {
return txs.filter((t) => t.date.startsWith(month));
}
/** Get total for a type in a month */
export function getMonthTotal(txs: Transaction[], month: string, type: TransactionType): number {
return getTransactionsForMonth(txs, month)
.filter((t) => t.type === type)
.reduce((sum, t) => sum + t.amount, 0);
}
/** Get balance (income - expenses) for a month */
export function getMonthBalance(txs: Transaction[], month: string): number {
const income = getMonthTotal(txs, month, 'income');
const expenses = getMonthTotal(txs, month, 'expense');
return income - expenses;
}
/** Group transactions by date */
export function groupByDate(txs: Transaction[]): Map<string, Transaction[]> {
const groups = new Map<string, Transaction[]>();
for (const tx of txs) {
const existing = groups.get(tx.date) || [];
existing.push(tx);
groups.set(tx.date, existing);
}
return groups;
}
/** Get spending by category for a month */
export function getSpendingByCategory(txs: Transaction[], month: string): Map<string, number> {
const result = new Map<string, number>();
for (const tx of getTransactionsForMonth(txs, month)) {
if (tx.type !== 'expense' || !tx.categoryId) continue;
result.set(tx.categoryId, (result.get(tx.categoryId) ?? 0) + tx.amount);
}
return result;
}
/** Format currency */
export function formatCurrency(amount: number): string {
return amount.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' });
}
/** Format date label */
export function formatDateLabel(date: string): string {
const today = todayStr();
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
if (date === today) return 'Heute';
if (date === yesterday) return 'Gestern';
return new Date(date).toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
}

View file

@ -0,0 +1,73 @@
/**
* Finance Store Mutation-Only Service
*/
import { transactionTable, categoryTable } from '../collections';
import { toTransaction, toCategory } from '../queries';
import type { LocalTransaction, LocalFinanceCategory, TransactionType } from '../types';
export const financeStore = {
async addTransaction(data: {
type: TransactionType;
amount: number;
categoryId?: string | null;
description: string;
date?: string;
note?: string;
}) {
const newLocal: LocalTransaction = {
id: crypto.randomUUID(),
type: data.type,
amount: Math.abs(data.amount),
categoryId: data.categoryId ?? null,
description: data.description,
date: data.date ?? new Date().toISOString().split('T')[0],
note: data.note ?? null,
};
await transactionTable.add(newLocal);
return toTransaction(newLocal);
},
async updateTransaction(
id: string,
data: Partial<
Pick<LocalTransaction, 'type' | 'amount' | 'categoryId' | 'description' | 'date' | 'note'>
>
) {
await transactionTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async deleteTransaction(id: string) {
await transactionTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async addCategory(data: { name: string; emoji: string; color: string; type: TransactionType }) {
const existing = await categoryTable.toArray();
const count = existing.filter((c) => !c.deletedAt && c.type === data.type).length;
const newLocal: LocalFinanceCategory = {
id: crypto.randomUUID(),
name: data.name,
emoji: data.emoji,
color: data.color,
type: data.type,
order: count,
};
await categoryTable.add(newLocal);
return toCategory(newLocal);
},
async deleteCategory(id: string) {
await categoryTable.update(id, {
deletedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,100 @@
/**
* Finance module types.
*
* Simple income/expense tracking with categories and monthly budgets.
*/
import type { BaseRecord } from '@manacore/local-store';
// ─── Local Record Types (Dexie) ───────────────────────────
export type TransactionType = 'income' | 'expense';
export interface LocalTransaction extends BaseRecord {
type: TransactionType;
amount: number; // always positive, type determines sign
categoryId: string | null;
description: string;
date: string; // YYYY-MM-DD
note: string | null;
}
export interface LocalFinanceCategory extends BaseRecord {
name: string;
emoji: string;
color: string;
type: TransactionType;
order: number;
}
export interface LocalBudget extends BaseRecord {
categoryId: string;
month: string; // YYYY-MM
amount: number;
}
// ─── Domain Types ─────────────────────────────────────────
export interface Transaction {
id: string;
type: TransactionType;
amount: number;
categoryId: string | null;
description: string;
date: string;
note: string | null;
createdAt: string;
updatedAt: string;
}
export interface FinanceCategory {
id: string;
name: string;
emoji: string;
color: string;
type: TransactionType;
order: number;
createdAt: string;
}
export interface Budget {
id: string;
categoryId: string;
month: string;
amount: number;
}
// ─── Constants ────────────────────────────────────────────
export const DEFAULT_EXPENSE_CATEGORIES: Omit<LocalFinanceCategory, keyof BaseRecord>[] = [
{ name: 'Essen', emoji: '\ud83c\udf54', color: '#f97316', type: 'expense', order: 0 },
{ name: 'Transport', emoji: '\ud83d\ude8c', color: '#3b82f6', type: 'expense', order: 1 },
{ name: 'Einkaufen', emoji: '\ud83d\udecd\ufe0f', color: '#ec4899', type: 'expense', order: 2 },
{ name: 'Wohnung', emoji: '\ud83c\udfe0', color: '#8b5cf6', type: 'expense', order: 3 },
{ name: 'Unterhaltung', emoji: '\ud83c\udfac', color: '#ef4444', type: 'expense', order: 4 },
{ name: 'Gesundheit', emoji: '\ud83d\udc8a', color: '#22c55e', type: 'expense', order: 5 },
{ name: 'Abos', emoji: '\ud83d\udd01', color: '#06b6d4', type: 'expense', order: 6 },
{ name: 'Sonstiges', emoji: '\ud83d\udce6', color: '#6b7280', type: 'expense', order: 7 },
];
export const DEFAULT_INCOME_CATEGORIES: Omit<LocalFinanceCategory, keyof BaseRecord>[] = [
{ name: 'Gehalt', emoji: '\ud83d\udcb0', color: '#22c55e', type: 'income', order: 0 },
{ name: 'Freelance', emoji: '\ud83d\udcbb', color: '#3b82f6', type: 'income', order: 1 },
{ name: 'Sonstiges', emoji: '\ud83d\udcb8', color: '#6b7280', type: 'income', order: 2 },
];
export const CATEGORY_COLORS: string[] = [
'#ef4444',
'#f97316',
'#f59e0b',
'#84cc16',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#a855f7',
'#d946ef',
'#ec4899',
];

View file

@ -5,6 +5,7 @@ import type { EntityDescriptor } from '$lib/entities/types';
const habitsEntity: EntityDescriptor = {
appId: 'habits',
collection: 'habits',
paramKey: 'habitId',
getDisplayData: (item) => ({
title: `${item.emoji as string} ${item.title as string}`,

View file

@ -0,0 +1,411 @@
<!--
Notes — Workbench ListView
Compact note list with search, inline create, and click to edit.
-->
<script lang="ts">
import { useAllNotes, searchNotes, getPreview, formatRelativeTime } from './queries';
import { notesStore } from './stores/notes.svelte';
import type { Note } from './types';
import { NOTE_COLORS } from './types';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
let notes$ = useAllNotes();
let notes = $state<Note[]>([]);
$effect(() => {
const sub = notes$.subscribe((val) => {
notes = val ?? [];
});
return () => sub.unsubscribe();
});
let searchQuery = $state('');
let showCreate = $state(false);
let editingId = $state<string | null>(null);
let editTitle = $state('');
let editContent = $state('');
let newTitle = $state('');
let newContent = $state('');
let newColor = $state<string | null>(null);
let filtered = $derived(searchNotes(notes, searchQuery));
async function handleCreate(e: Event) {
e.preventDefault();
if (!newTitle.trim() && !newContent.trim()) return;
await notesStore.createNote({
title: newTitle.trim() || 'Unbenannt',
content: newContent,
color: newColor,
});
newTitle = '';
newContent = '';
newColor = null;
showCreate = false;
}
function startEdit(note: Note) {
editingId = note.id;
editTitle = note.title;
editContent = note.content;
}
async function saveEdit() {
if (!editingId) return;
await notesStore.updateNote(editingId, {
title: editTitle.trim() || 'Unbenannt',
content: editContent,
});
editingId = null;
}
function cancelEdit() {
editingId = null;
}
function handleEditKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancelEdit();
}
</script>
<div class="notes-list-view">
<!-- Search -->
<div class="search-row">
<input class="search-input" type="text" placeholder="Suchen..." bind:value={searchQuery} />
<button class="add-btn" onclick={() => (showCreate = !showCreate)} title="Neue Notiz">
+
</button>
</div>
<!-- Inline Create -->
{#if showCreate}
<form class="create-form" onsubmit={handleCreate}>
<input
class="create-title"
type="text"
placeholder="Titel..."
bind:value={newTitle}
autofocus
/>
<textarea
class="create-content"
placeholder="Notiz schreiben..."
bind:value={newContent}
rows="3"
></textarea>
<div class="create-footer">
<div class="color-row">
{#each NOTE_COLORS as c}
<button
type="button"
class="color-dot"
class:selected={newColor === c}
style:background={c ?? 'var(--color-muted-foreground)'}
style:opacity={c ? 1 : 0.4}
onclick={() => (newColor = c)}
></button>
{/each}
</div>
<div class="create-actions">
<button type="button" class="btn-cancel" onclick={() => (showCreate = false)}
>Abbrechen</button
>
<button type="submit" class="btn-save">Erstellen</button>
</div>
</div>
</form>
{/if}
<!-- Note List -->
<div class="note-list">
{#each filtered as note (note.id)}
{#if editingId === note.id}
<!-- Inline Edit -->
<div class="note-card editing" onkeydown={handleEditKeydown}>
<input class="edit-title" type="text" bind:value={editTitle} autofocus />
<textarea class="edit-content" bind:value={editContent} rows="4"></textarea>
<div class="edit-actions">
<button class="btn-cancel" onclick={cancelEdit}>Abbrechen</button>
<button class="btn-save" onclick={saveEdit}>Speichern</button>
</div>
</div>
{:else}
<!-- Note Card -->
<button
class="note-card"
class:pinned={note.isPinned}
style:border-left-color={note.color ?? 'transparent'}
onclick={() => startEdit(note)}
>
<div class="note-header">
<span class="note-title">{note.title || 'Unbenannt'}</span>
{#if note.isPinned}
<span class="pin-icon" title="Angepinnt">&#x1f4cc;</span>
{/if}
</div>
<div class="note-preview">{getPreview(note.content)}</div>
<div class="note-meta">{formatRelativeTime(note.updatedAt)}</div>
</button>
{/if}
{/each}
</div>
{#if notes.length === 0 && !showCreate}
<div class="empty">
<p>Noch keine Notizen.</p>
<button class="empty-add-btn" onclick={() => (showCreate = true)}
>Erste Notiz erstellen</button
>
</div>
{/if}
</div>
<style>
.notes-list-view {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
}
.search-row {
display: flex;
gap: 0.375rem;
}
.search-input {
flex: 1;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
border-radius: 0.5rem;
color: var(--color-foreground);
font-size: 0.8125rem;
padding: 0.375rem 0.625rem;
outline: none;
}
.search-input:focus {
border-color: var(--color-primary, #6366f1);
}
.search-input::placeholder {
color: var(--color-muted-foreground);
}
.add-btn {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: var(--color-primary, #6366f1);
color: white;
border: none;
font-size: 1.125rem;
font-weight: 300;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: filter 0.15s;
}
.add-btn:hover {
filter: brightness(1.1);
}
/* ── Create / Edit Form ─────────────────────── */
.create-form {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.625rem;
border-radius: 0.625rem;
background: var(--color-surface, rgba(255, 255, 255, 0.06));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
}
.create-title,
.edit-title {
background: transparent;
border: none;
color: var(--color-foreground);
font-size: 0.875rem;
font-weight: 600;
padding: 0.25rem 0;
outline: none;
}
.create-title::placeholder,
.edit-title::placeholder {
color: var(--color-muted-foreground);
}
.create-content,
.edit-content {
background: transparent;
border: none;
color: var(--color-foreground);
font-size: 0.8125rem;
padding: 0.25rem 0;
outline: none;
resize: vertical;
min-height: 3rem;
font-family: inherit;
}
.create-content::placeholder {
color: var(--color-muted-foreground);
}
.create-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.color-row {
display: flex;
gap: 0.25rem;
}
.color-dot {
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s;
}
.color-dot:hover {
transform: scale(1.25);
}
.color-dot.selected {
border-color: white;
box-shadow: 0 0 0 1px var(--color-primary, #6366f1);
}
.create-actions,
.edit-actions {
display: flex;
gap: 0.375rem;
}
.btn-cancel,
.btn-save {
padding: 0.3rem 0.625rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-cancel {
background: transparent;
color: var(--color-muted-foreground);
}
.btn-cancel:hover {
background: var(--color-muted, rgba(255, 255, 255, 0.08));
}
.btn-save {
background: var(--color-primary, #6366f1);
color: white;
}
.btn-save:hover {
filter: brightness(1.1);
}
/* ── Note Cards ─────────────────────────────── */
.note-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.note-card {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
border-left: 3px solid transparent;
cursor: pointer;
text-align: left;
transition: background 0.15s;
width: 100%;
}
.note-card:hover {
background: var(--color-muted, rgba(255, 255, 255, 0.08));
}
.note-card.editing {
cursor: default;
border-left-color: var(--color-primary, #6366f1) !important;
}
.note-card.pinned {
background: rgba(99, 102, 241, 0.04);
}
.note-header {
display: flex;
align-items: center;
gap: 0.375rem;
}
.note-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-foreground);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pin-icon {
font-size: 0.6875rem;
flex-shrink: 0;
}
.note-preview {
font-size: 0.75rem;
color: var(--color-muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-meta {
font-size: 0.6875rem;
color: var(--color-muted-foreground);
opacity: 0.7;
}
/* ── Empty ──────────────────────────────────── */
.empty {
text-align: center;
color: var(--color-muted-foreground);
font-size: 0.875rem;
padding: 2rem 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.empty-add-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: var(--color-primary, #6366f1);
color: white;
border: none;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
}
.empty-add-btn:hover {
filter: brightness(1.1);
}
</style>

View file

@ -0,0 +1,34 @@
/**
* Notes module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalNote } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const noteTable = db.table<LocalNote>('notes');
// ─── Guest Seed ────────────────────────────────────────────
export const NOTES_GUEST_SEED = {
notes: [
{
id: 'note-welcome',
title: 'Willkommen bei Notes',
content:
'Schnelle Notizen für alles, was dir einfällt.\n\nDu kannst Notizen **pinnen**, farblich markieren und durchsuchen.',
color: '#3b82f6',
isPinned: true,
isArchived: false,
},
{
id: 'note-ideas',
title: 'Ideen',
content: '- Feature X ausprobieren\n- Blog-Post schreiben\n- Design Review planen',
color: '#f59e0b',
isPinned: false,
isArchived: false,
},
] satisfies LocalNote[],
};

View file

@ -0,0 +1,38 @@
import { registerEntity } from '$lib/entities/registry';
import { notesStore } from './stores/notes.svelte';
import type { EntityDescriptor } from '$lib/entities/types';
const notesEntity: EntityDescriptor = {
appId: 'notes',
collection: 'notes',
paramKey: 'noteId',
getDisplayData: (item) => ({
title: (item.title as string) || 'Notiz',
subtitle: undefined,
}),
dragType: 'note',
acceptsDropFrom: ['task', 'contact'],
transformIncoming: {
task: (source) => ({
title: source.title as string,
content: (source.description as string) ?? '',
}),
contact: (source) => ({
title: `${[source.firstName, source.lastName].filter(Boolean).join(' ')}`,
content: `Kontakt: ${[source.firstName, source.lastName].filter(Boolean).join(' ')}`,
}),
},
createItem: async (data) => {
const note = await notesStore.createNote({
title: data.title as string,
content: (data.content as string) ?? '',
});
return note.id;
},
};
registerEntity(notesEntity);

View file

@ -0,0 +1,16 @@
/**
* Notes module barrel exports.
*/
// ─── Stores ──────────────────────────────────────────────
export { notesStore } from './stores/notes.svelte';
// ─── Queries ─────────────────────────────────────────────
export { useAllNotes, toNote, searchNotes, getPreview, formatRelativeTime } from './queries';
// ─── Collections ─────────────────────────────────────────
export { noteTable, NOTES_GUEST_SEED } from './collections';
// ─── Types ───────────────────────────────────────────────
export { NOTE_COLORS } from './types';
export type { LocalNote, Note } from './types';

View file

@ -0,0 +1,68 @@
/**
* Reactive Queries & Pure Helpers for Notes module.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalNote, Note } from './types';
// ─── Type Converters ───────────────────────────────────────
export function toNote(local: LocalNote): Note {
return {
id: local.id,
title: local.title,
content: local.content,
color: local.color,
isPinned: local.isPinned,
isArchived: local.isArchived,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
export function useAllNotes() {
return liveQuery(async () => {
const locals = await db.table<LocalNote>('notes').toArray();
return locals
.filter((n) => !n.deletedAt && !n.isArchived)
.map(toNote)
.sort((a, b) => {
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
return b.updatedAt.localeCompare(a.updatedAt);
});
});
}
// ─── Pure Helpers ──────────────────────────────────────────
/** Search notes by title and content */
export function searchNotes(notes: Note[], query: string): Note[] {
if (!query.trim()) return notes;
const q = query.toLowerCase();
return notes.filter(
(n) => n.title.toLowerCase().includes(q) || n.content.toLowerCase().includes(q)
);
}
/** Get content preview (first line or truncated) */
export function getPreview(content: string, maxLen = 80): string {
const firstLine = content.split('\n').find((l) => l.trim()) ?? '';
const clean = firstLine.replace(/[#*_~`>\-]/g, '').trim();
return clean.length > maxLen ? clean.slice(0, maxLen) + '...' : clean;
}
/** Format relative time */
export function formatRelativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'gerade eben';
if (mins < 60) return `vor ${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `vor ${hours}h`;
const days = Math.floor(hours / 24);
if (days < 7) return `vor ${days}d`;
return new Date(iso).toLocaleDateString('de-DE', { day: 'numeric', month: 'short' });
}

View file

@ -0,0 +1,56 @@
/**
* Notes Store Mutation-Only Service
*/
import { noteTable } from '../collections';
import { toNote } from '../queries';
import type { LocalNote } from '../types';
export const notesStore = {
async createNote(data: { title?: string; content?: string; color?: string | null }) {
const newLocal: LocalNote = {
id: crypto.randomUUID(),
title: data.title ?? '',
content: data.content ?? '',
color: data.color ?? null,
isPinned: false,
isArchived: false,
};
await noteTable.add(newLocal);
return toNote(newLocal);
},
async updateNote(
id: string,
data: Partial<Pick<LocalNote, 'title' | 'content' | 'color' | 'isPinned' | 'isArchived'>>
) {
await noteTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async deleteNote(id: string) {
await noteTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async togglePin(id: string) {
const note = await noteTable.get(id);
if (!note) return;
await noteTable.update(id, {
isPinned: !note.isPinned,
updatedAt: new Date().toISOString(),
});
},
async archiveNote(id: string) {
await noteTable.update(id, {
isArchived: true,
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,46 @@
/**
* Notes module types.
*
* Lightweight markdown notes flat structure, no folders.
*/
import type { BaseRecord } from '@manacore/local-store';
// ─── Local Record Types (Dexie) ───────────────────────────
export interface LocalNote extends BaseRecord {
title: string;
content: string;
color: string | null;
isPinned: boolean;
isArchived: boolean;
}
// ─── Domain Types ─────────────────────────────────────────
export interface Note {
id: string;
title: string;
content: string;
color: string | null;
isPinned: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
// ─── Constants ────────────────────────────────────────────
export const NOTE_COLORS: (string | null)[] = [
null,
'#ef4444',
'#f97316',
'#f59e0b',
'#84cc16',
'#22c55e',
'#06b6d4',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#ec4899',
];

View file

@ -2,24 +2,20 @@
import AppPage from '$lib/components/workbench/AppPage.svelte';
import AppPagePicker from '$lib/components/workbench/AppPagePicker.svelte';
import { PageCarousel, type CarouselPage } from '$lib/components/page-carousel';
import { getAppEntry } from '$lib/components/workbench/app-registry';
import { getApp, getAppByDragType } from '$lib/app-registry';
import { createAppSettingsStore } from '@manacore/shared-stores';
import { DragPreview } from '@manacore/shared-ui/dnd';
import { getEntityByDragType, ensureEntitiesRegistered } from '$lib/entities';
import type { DragType } from '@manacore/shared-ui/dnd';
ensureEntitiesRegistered();
function resolveEntity(type: string, data: Record<string, unknown>) {
const entity = getEntityByDragType(type as DragType);
if (!entity) return null;
const display = entity.getDisplayData(data);
const appEntry = getAppEntry(entity.appId);
const app = getAppByDragType(type as DragType);
if (!app?.getDisplayData) return null;
const display = app.getDisplayData(data);
return {
title: display.title,
subtitle: display.subtitle,
color: appEntry?.color,
appName: appEntry?.name,
color: app.color,
appName: app.name,
};
}
@ -42,6 +38,8 @@
{ appId: 'calendar', minimized: false },
{ appId: 'contacts', minimized: false },
{ appId: 'habits', minimized: false },
{ appId: 'notes', minimized: false },
{ appId: 'finance', minimized: false },
],
});
@ -58,6 +56,8 @@
{ appId: 'calendar', minimized: false },
{ appId: 'contacts', minimized: false },
{ appId: 'habits', minimized: false },
{ appId: 'notes', minimized: false },
{ appId: 'finance', minimized: false },
]);
// Load persisted state once on mount (not reactive — avoids loop with persistState)
@ -82,7 +82,7 @@
// ── Map to CarouselPage[] ───────────────────────────────
let carouselPages = $derived<CarouselPage[]>(
openApps.map((a) => {
const entry = getAppEntry(a.appId);
const entry = getApp(a.appId);
return {
id: a.appId,
minimized: a.minimized,

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllTransactions, useAllCategories } from '$lib/modules/finance/queries';
let { children }: { children: Snippet } = $props();
const allTransactions = useAllTransactions();
const allCategories = useAllCategories();
setContext('transactions', allTransactions);
setContext('financeCategories', allCategories);
</script>
{@render children()}

View file

@ -0,0 +1,564 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import type { Transaction, FinanceCategory, TransactionType } from '$lib/modules/finance/types';
import {
currentMonth,
getMonthTotal,
getMonthBalance,
getTransactionsForMonth,
getSpendingByCategory,
formatCurrency,
formatDateLabel,
groupByDate,
} from '$lib/modules/finance/queries';
import { financeStore } from '$lib/modules/finance/stores/finance.svelte';
const txs$: Observable<Transaction[]> = getContext('transactions');
const cats$: Observable<FinanceCategory[]> = getContext('financeCategories');
let txs = $state<Transaction[]>([]);
let categories = $state<FinanceCategory[]>([]);
let isLoaded = $state(false);
$effect(() => {
const sub = txs$.subscribe((t) => {
txs = t;
isLoaded = true;
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = cats$.subscribe((c) => (categories = c));
return () => sub.unsubscribe();
});
let month = $state(currentMonth());
let monthTxs = $derived(getTransactionsForMonth(txs, month));
let income = $derived(getMonthTotal(txs, month, 'income'));
let expenses = $derived(getMonthTotal(txs, month, 'expense'));
let balance = $derived(getMonthBalance(txs, month));
let spending = $derived(getSpendingByCategory(txs, month));
let grouped = $derived(groupByDate(monthTxs));
let catMap = $derived(new Map(categories.map((c) => [c.id, c])));
let expenseCategories = $derived(categories.filter((c) => c.type === 'expense'));
// Add form
let showAdd = $state(false);
let addType = $state<TransactionType>('expense');
let addAmount = $state('');
let addDesc = $state('');
let addCatId = $state<string | null>(null);
let filteredCats = $derived(categories.filter((c) => c.type === addType));
async function handleAdd(e: Event) {
e.preventDefault();
const amount = parseFloat(addAmount.replace(',', '.'));
if (!amount || !addDesc.trim()) return;
await financeStore.addTransaction({
type: addType,
amount,
description: addDesc.trim(),
categoryId: addCatId,
});
addAmount = '';
addDesc = '';
addCatId = null;
showAdd = false;
}
function prevMonth() {
const d = new Date(month + '-01');
d.setMonth(d.getMonth() - 1);
month = d.toISOString().slice(0, 7);
}
function nextMonth() {
const d = new Date(month + '-01');
d.setMonth(d.getMonth() + 1);
month = d.toISOString().slice(0, 7);
}
let monthLabel = $derived(
new Date(month + '-01').toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
);
let maxSpend = $derived(Math.max(1, ...spending.values()));
</script>
<svelte:head>
<title>Finance - ManaCore</title>
</svelte:head>
<div class="finance-page">
<header class="page-header">
<h1 class="page-title">Finance</h1>
</header>
<!-- Month Navigation -->
<div class="month-nav">
<button class="nav-btn" onclick={prevMonth}>&larr;</button>
<span class="month-label">{monthLabel}</span>
<button class="nav-btn" onclick={nextMonth}>&rarr;</button>
</div>
{#if isLoaded}
<!-- Summary Cards -->
<div class="summary-cards">
<div class="summary-card">
<span class="card-label">Einnahmen</span>
<span class="card-value income">+{formatCurrency(income)}</span>
</div>
<div class="summary-card">
<span class="card-label">Ausgaben</span>
<span class="card-value expense">-{formatCurrency(expenses)}</span>
</div>
<div class="summary-card highlight">
<span class="card-label">Bilanz</span>
<span class="card-value" class:income={balance >= 0} class:expense={balance < 0}>
{balance >= 0 ? '+' : ''}{formatCurrency(balance)}
</span>
</div>
</div>
<!-- Category Breakdown -->
{#if spending.size > 0}
<section class="section">
<h2 class="section-title">Ausgaben nach Kategorie</h2>
<div class="cat-breakdown">
{#each expenseCategories as cat (cat.id)}
{@const amount = spending.get(cat.id) ?? 0}
{#if amount > 0}
<div class="cat-bar-row">
<span class="cat-emoji">{cat.emoji}</span>
<span class="cat-name">{cat.name}</span>
<div class="cat-bar-bg">
<div
class="cat-bar-fill"
style:width="{(amount / maxSpend) * 100}%"
style:background={cat.color}
></div>
</div>
<span class="cat-amount">{formatCurrency(amount)}</span>
</div>
{/if}
{/each}
</div>
</section>
{/if}
<!-- Add Button -->
<button class="add-btn" onclick={() => (showAdd = !showAdd)}>
{showAdd ? 'Abbrechen' : '+ Transaktion hinzufügen'}
</button>
<!-- Add Form -->
{#if showAdd}
<form class="add-form" onsubmit={handleAdd}>
<div class="type-toggle">
<button
type="button"
class="type-btn"
class:active={addType === 'expense'}
onclick={() => (addType = 'expense')}>Ausgabe</button
>
<button
type="button"
class="type-btn inc"
class:active={addType === 'income'}
onclick={() => (addType = 'income')}>Einnahme</button
>
</div>
<div class="amount-row">
<input
class="amount-input"
type="text"
inputmode="decimal"
placeholder="0,00"
bind:value={addAmount}
autofocus
/>
<span class="currency">\u20ac</span>
</div>
<input class="desc-input" type="text" placeholder="Beschreibung..." bind:value={addDesc} />
<div class="cat-chips">
{#each filteredCats as cat (cat.id)}
<button
type="button"
class="cat-chip"
class:selected={addCatId === cat.id}
onclick={() => (addCatId = addCatId === cat.id ? null : cat.id)}
>
{cat.emoji}
{cat.name}
</button>
{/each}
</div>
<button type="submit" class="submit-btn" disabled={!addAmount || !addDesc.trim()}
>Hinzufügen</button
>
</form>
{/if}
<!-- Transaction History -->
{#if monthTxs.length > 0}
<section class="section">
<h2 class="section-title">Transaktionen</h2>
<div class="tx-list">
{#each [...grouped.entries()] as [date, dayTxs] (date)}
<div class="day-header">{formatDateLabel(date)}</div>
{#each dayTxs as tx (tx.id)}
{@const cat = tx.categoryId ? catMap.get(tx.categoryId) : null}
<div class="tx-row">
<span class="tx-emoji">{cat?.emoji ?? '\ud83d\udcb3'}</span>
<div class="tx-details">
<span class="tx-desc">{tx.description}</span>
{#if cat}<span class="tx-cat">{cat.name}</span>{/if}
</div>
<span
class="tx-amount"
class:income={tx.type === 'income'}
class:expense={tx.type === 'expense'}
>
{tx.type === 'income' ? '+' : '-'}{formatCurrency(tx.amount)}
</span>
</div>
{/each}
{/each}
</div>
</section>
{/if}
{:else}
<div class="loading">Laden...</div>
{/if}
</div>
<style>
.finance-page {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 0 1rem;
max-width: 640px;
}
.page-header {
display: flex;
align-items: flex-start;
}
.page-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-foreground);
}
.month-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.nav-btn {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
color: var(--color-muted-foreground);
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
}
.nav-btn:hover {
background: var(--color-muted, rgba(255, 255, 255, 0.08));
}
.month-label {
font-size: 1rem;
font-weight: 600;
color: var(--color-foreground);
}
.summary-cards {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.5rem;
}
.summary-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0.5rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
}
.summary-card.highlight {
background: rgba(99, 102, 241, 0.06);
}
.card-label {
font-size: 0.75rem;
color: var(--color-muted-foreground);
}
.card-value {
font-size: 1rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
margin-top: 0.25rem;
}
.card-value.income {
color: #22c55e;
}
.card-value.expense {
color: #ef4444;
}
.section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-foreground);
}
.cat-breakdown {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cat-bar-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.cat-emoji {
flex-shrink: 0;
}
.cat-name {
width: 5rem;
color: var(--color-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cat-bar-bg {
flex: 1;
height: 0.5rem;
border-radius: 0.25rem;
background: var(--color-surface, rgba(255, 255, 255, 0.06));
overflow: hidden;
}
.cat-bar-fill {
height: 100%;
border-radius: 0.25rem;
transition: width 0.3s ease-out;
}
.cat-amount {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--color-foreground);
flex-shrink: 0;
}
.add-btn {
width: 100%;
padding: 0.625rem;
border-radius: 0.5rem;
background: var(--color-primary, #6366f1);
color: white;
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s;
}
.add-btn:hover {
filter: brightness(1.1);
}
.add-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.06));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
}
.type-toggle {
display: flex;
gap: 0.375rem;
}
.type-btn {
flex: 1;
padding: 0.5rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
background: transparent;
color: var(--color-muted-foreground);
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
cursor: pointer;
}
.type-btn.active {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
border-color: rgba(239, 68, 68, 0.3);
}
.type-btn.inc.active {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
border-color: rgba(34, 197, 94, 0.3);
}
.amount-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.amount-input {
flex: 1;
background: transparent;
border: none;
border-bottom: 2px solid var(--color-border, rgba(255, 255, 255, 0.15));
color: var(--color-foreground);
font-size: 1.75rem;
font-weight: 700;
padding: 0.25rem 0;
outline: none;
text-align: right;
font-variant-numeric: tabular-nums;
}
.amount-input:focus {
border-color: var(--color-primary, #6366f1);
}
.amount-input::placeholder {
color: var(--color-muted-foreground);
font-weight: 400;
}
.currency {
color: var(--color-muted-foreground);
font-size: 1.25rem;
}
.desc-input {
background: transparent;
border: none;
border-bottom: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
color: var(--color-foreground);
font-size: 0.875rem;
padding: 0.5rem 0;
outline: none;
}
.desc-input::placeholder {
color: var(--color-muted-foreground);
}
.cat-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.cat-chip {
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.8125rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
color: var(--color-foreground);
cursor: pointer;
transition: all 0.15s;
}
.cat-chip:hover {
background: rgba(255, 255, 255, 0.1);
}
.cat-chip.selected {
border-color: var(--color-primary, #6366f1);
background: rgba(99, 102, 241, 0.15);
}
.submit-btn {
width: 100%;
padding: 0.625rem;
border-radius: 0.5rem;
background: var(--color-primary, #6366f1);
color: white;
border: none;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
}
.submit-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.submit-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tx-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.day-header {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted-foreground);
padding: 0.5rem 0 0.25rem;
}
.tx-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.25rem;
}
.tx-emoji {
font-size: 1.125rem;
flex-shrink: 0;
}
.tx-details {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.tx-desc {
font-size: 0.875rem;
color: var(--color-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tx-cat {
font-size: 0.75rem;
color: var(--color-muted-foreground);
}
.tx-amount {
font-size: 0.875rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.tx-amount.income {
color: #22c55e;
}
.tx-amount.expense {
color: #ef4444;
}
.loading {
color: var(--color-muted-foreground);
text-align: center;
padding: 3rem 0;
}
</style>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllNotes } from '$lib/modules/notes/queries';
let { children }: { children: Snippet } = $props();
const allNotes = useAllNotes();
setContext('notes', allNotes);
</script>
{@render children()}

View file

@ -0,0 +1,403 @@
<script lang="ts">
import { getContext } from 'svelte';
import { goto } from '$app/navigation';
import type { Observable } from 'dexie';
import type { Note } from '$lib/modules/notes/types';
import { searchNotes, getPreview, formatRelativeTime } from '$lib/modules/notes/queries';
import { notesStore } from '$lib/modules/notes/stores/notes.svelte';
import { NOTE_COLORS } from '$lib/modules/notes/types';
const allNotes$: Observable<Note[]> = getContext('notes');
let notes = $state<Note[]>([]);
let isLoaded = $state(false);
$effect(() => {
const sub = allNotes$.subscribe((n) => {
notes = n;
isLoaded = true;
});
return () => sub.unsubscribe();
});
let searchQuery = $state('');
let showCreate = $state(false);
let newTitle = $state('');
let newContent = $state('');
let newColor = $state<string | null>(null);
let filtered = $derived(searchNotes(notes, searchQuery));
let pinnedNotes = $derived(filtered.filter((n) => n.isPinned));
let unpinnedNotes = $derived(filtered.filter((n) => !n.isPinned));
async function handleCreate(e: Event) {
e.preventDefault();
if (!newTitle.trim() && !newContent.trim()) return;
const note = await notesStore.createNote({
title: newTitle.trim() || 'Unbenannt',
content: newContent,
color: newColor,
});
newTitle = '';
newContent = '';
newColor = null;
showCreate = false;
goto(`/notes/${note.id}`);
}
</script>
<svelte:head>
<title>Notes - ManaCore</title>
</svelte:head>
<div class="notes-page">
<header class="notes-header">
<div>
<h1 class="notes-title">Notes</h1>
{#if isLoaded}
<div class="notes-stats">{notes.length} Notizen</div>
{/if}
</div>
</header>
<!-- Search + Add -->
<div class="toolbar">
<input
class="search-input"
type="text"
placeholder="Notizen durchsuchen..."
bind:value={searchQuery}
/>
<button class="add-btn" onclick={() => (showCreate = !showCreate)}>+ Neue Notiz</button>
</div>
<!-- Create Form -->
{#if showCreate}
<form class="create-form" onsubmit={handleCreate}>
<input
class="create-title"
type="text"
placeholder="Titel..."
bind:value={newTitle}
autofocus
/>
<textarea
class="create-content"
placeholder="Schreibe etwas..."
bind:value={newContent}
rows="4"
></textarea>
<div class="create-footer">
<div class="color-row">
{#each NOTE_COLORS as c}
<button
type="button"
class="color-dot"
class:selected={newColor === c}
style:background={c ?? 'var(--color-muted-foreground)'}
style:opacity={c ? 1 : 0.4}
onclick={() => (newColor = c)}
></button>
{/each}
</div>
<div class="create-actions">
<button type="button" class="btn-cancel" onclick={() => (showCreate = false)}
>Abbrechen</button
>
<button type="submit" class="btn-save">Erstellen</button>
</div>
</div>
</form>
{/if}
{#if isLoaded}
<!-- Pinned -->
{#if pinnedNotes.length > 0}
<section class="section">
<h2 class="section-label">Angepinnt</h2>
<div class="notes-grid">
{#each pinnedNotes as note (note.id)}
<a
href="/notes/{note.id}"
class="note-card"
style:border-top-color={note.color ?? 'transparent'}
>
<div class="card-title">{note.title || 'Unbenannt'}</div>
<div class="card-preview">{getPreview(note.content, 120)}</div>
<div class="card-meta">{formatRelativeTime(note.updatedAt)}</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- All -->
{#if unpinnedNotes.length > 0}
<section class="section">
{#if pinnedNotes.length > 0}
<h2 class="section-label">Weitere</h2>
{/if}
<div class="notes-grid">
{#each unpinnedNotes as note (note.id)}
<a
href="/notes/{note.id}"
class="note-card"
style:border-top-color={note.color ?? 'transparent'}
>
<div class="card-title">{note.title || 'Unbenannt'}</div>
<div class="card-preview">{getPreview(note.content, 120)}</div>
<div class="card-meta">{formatRelativeTime(note.updatedAt)}</div>
</a>
{/each}
</div>
</section>
{/if}
{#if notes.length === 0 && !showCreate}
<div class="empty">
<p>Noch keine Notizen.</p>
<button class="add-btn" onclick={() => (showCreate = true)}>Erste Notiz erstellen</button>
</div>
{/if}
{:else}
<div class="loading">Laden...</div>
{/if}
</div>
<style>
.notes-page {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 0 1rem;
max-width: 800px;
}
.notes-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.notes-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-foreground);
}
.notes-stats {
font-size: 0.875rem;
color: var(--color-muted-foreground);
margin-top: 0.25rem;
}
.toolbar {
display: flex;
gap: 0.5rem;
}
.search-input {
flex: 1;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
border-radius: 0.5rem;
color: var(--color-foreground);
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
outline: none;
}
.search-input:focus {
border-color: var(--color-primary, #6366f1);
}
.search-input::placeholder {
color: var(--color-muted-foreground);
}
.add-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: var(--color-primary, #6366f1);
color: white;
border: none;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: filter 0.15s;
}
.add-btn:hover {
filter: brightness(1.1);
}
.create-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.06));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
}
.create-title {
background: transparent;
border: none;
color: var(--color-foreground);
font-size: 1rem;
font-weight: 600;
padding: 0.25rem 0;
outline: none;
}
.create-title::placeholder {
color: var(--color-muted-foreground);
}
.create-content {
background: transparent;
border: none;
color: var(--color-foreground);
font-size: 0.875rem;
padding: 0.25rem 0;
outline: none;
resize: vertical;
min-height: 4rem;
font-family: inherit;
}
.create-content::placeholder {
color: var(--color-muted-foreground);
}
.create-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.color-row {
display: flex;
gap: 0.375rem;
}
.color-dot {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s;
}
.color-dot:hover {
transform: scale(1.25);
}
.color-dot.selected {
border-color: white;
box-shadow: 0 0 0 1px var(--color-primary, #6366f1);
}
.create-actions {
display: flex;
gap: 0.5rem;
}
.btn-cancel,
.btn-save {
padding: 0.4rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-cancel {
background: transparent;
color: var(--color-muted-foreground);
}
.btn-cancel:hover {
background: var(--color-muted, rgba(255, 255, 255, 0.08));
}
.btn-save {
background: var(--color-primary, #6366f1);
color: white;
}
.btn-save:hover {
filter: brightness(1.1);
}
.section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted-foreground);
}
.notes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.note-card {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.75rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
border-top: 3px solid transparent;
text-decoration: none;
transition:
background 0.15s,
transform 0.15s;
min-height: 6rem;
}
.note-card:hover {
background: var(--color-muted, rgba(255, 255, 255, 0.08));
transform: translateY(-1px);
}
.card-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-preview {
font-size: 0.8125rem;
color: var(--color-muted-foreground);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
}
.card-meta {
font-size: 0.6875rem;
color: var(--color-muted-foreground);
opacity: 0.7;
margin-top: auto;
}
.empty {
text-align: center;
color: var(--color-muted-foreground);
font-size: 0.875rem;
padding: 3rem 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.loading {
color: var(--color-muted-foreground);
text-align: center;
padding: 3rem 0;
}
</style>

View file

@ -0,0 +1,314 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import type { Note } from '$lib/modules/notes/types';
import { NOTE_COLORS } from '$lib/modules/notes/types';
import { notesStore } from '$lib/modules/notes/stores/notes.svelte';
import { formatRelativeTime } from '$lib/modules/notes/queries';
const allNotes$: Observable<Note[]> = getContext('notes');
let notes = $state<Note[]>([]);
$effect(() => {
const sub = allNotes$.subscribe((n) => (notes = n));
return () => sub.unsubscribe();
});
let noteId = $derived($page.params.id);
let note = $derived(notes.find((n) => n.id === noteId));
let title = $state('');
let content = $state('');
let initialized = $state(false);
// Initialize edit fields when note loads
$effect(() => {
if (note && !initialized) {
title = note.title;
content = note.content;
initialized = true;
}
});
let confirmDelete = $state(false);
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
function autoSave() {
if (!note) return;
if (saveTimeout) clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
if (note) {
notesStore.updateNote(note.id, {
title: title.trim() || 'Unbenannt',
content,
});
}
}, 500);
}
function handleBack() {
// Save immediately before navigating
if (note) {
notesStore.updateNote(note.id, {
title: title.trim() || 'Unbenannt',
content,
});
}
goto('/notes');
}
async function handleDelete() {
if (!note) return;
await notesStore.deleteNote(note.id);
goto('/notes');
}
async function handleTogglePin() {
if (!note) return;
await notesStore.togglePin(note.id);
}
async function handleColorChange(color: string | null) {
if (!note) return;
await notesStore.updateNote(note.id, { color });
}
</script>
<svelte:head>
<title>{note ? note.title || 'Notiz' : 'Notiz'} - ManaCore</title>
</svelte:head>
<div class="note-detail">
{#if note}
<header class="detail-header">
<button class="back-btn" onclick={handleBack}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="header-meta">{formatRelativeTime(note.updatedAt)}</div>
<div class="header-actions">
<button
class="action-icon"
class:active={note.isPinned}
onclick={handleTogglePin}
title={note.isPinned ? 'Lösen' : 'Anpinnen'}
>
&#x1f4cc;
</button>
</div>
</header>
<input
class="detail-title"
type="text"
placeholder="Titel..."
bind:value={title}
oninput={autoSave}
/>
<textarea
class="detail-content"
placeholder="Schreibe etwas..."
bind:value={content}
oninput={autoSave}
></textarea>
<!-- Color + Actions -->
<div class="detail-footer">
<div class="color-row">
{#each NOTE_COLORS as c}
<button
type="button"
class="color-dot"
class:selected={note.color === c}
style:background={c ?? 'var(--color-muted-foreground)'}
style:opacity={c ? 1 : 0.4}
onclick={() => handleColorChange(c)}
></button>
{/each}
</div>
<div class="danger-actions">
{#if !confirmDelete}
<button class="delete-btn" onclick={() => (confirmDelete = true)}>Löschen</button>
{:else}
<button class="delete-btn confirm" onclick={handleDelete}>Wirklich löschen?</button>
{/if}
</div>
</div>
{:else if notes.length > 0}
<div class="not-found">
<p>Notiz nicht gefunden.</p>
<button onclick={handleBack}>Zurück</button>
</div>
{:else}
<div class="loading">Laden...</div>
{/if}
</div>
<style>
.note-detail {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0 1rem;
max-width: 640px;
}
.detail-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
color: var(--color-muted-foreground);
border: none;
cursor: pointer;
}
.back-btn:hover {
background: var(--color-muted, rgba(255, 255, 255, 0.08));
}
.header-meta {
flex: 1;
font-size: 0.8125rem;
color: var(--color-muted-foreground);
}
.header-actions {
display: flex;
gap: 0.25rem;
}
.action-icon {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
cursor: pointer;
font-size: 0.875rem;
opacity: 0.5;
transition: opacity 0.15s;
}
.action-icon:hover,
.action-icon.active {
opacity: 1;
}
.detail-title {
background: transparent;
border: none;
color: var(--color-foreground);
font-size: 1.5rem;
font-weight: 700;
padding: 0;
outline: none;
}
.detail-title::placeholder {
color: var(--color-muted-foreground);
}
.detail-content {
background: transparent;
border: none;
color: var(--color-foreground);
font-size: 0.9375rem;
line-height: 1.6;
padding: 0;
outline: none;
resize: none;
min-height: 300px;
font-family: inherit;
flex: 1;
}
.detail-content::placeholder {
color: var(--color-muted-foreground);
}
.detail-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
}
.color-row {
display: flex;
gap: 0.375rem;
}
.color-dot {
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s;
}
.color-dot:hover {
transform: scale(1.25);
}
.color-dot.selected {
border-color: white;
box-shadow: 0 0 0 1px var(--color-primary, #6366f1);
}
.danger-actions {
display: flex;
}
.delete-btn {
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
background: transparent;
color: var(--color-destructive, #ef4444);
border: 1px solid rgba(239, 68, 68, 0.2);
cursor: pointer;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.1);
}
.delete-btn.confirm {
background: rgba(239, 68, 68, 0.15);
}
.not-found,
.loading {
text-align: center;
padding: 3rem 0;
color: var(--color-muted-foreground);
}
.not-found button {
margin-top: 1rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: var(--color-primary, #6366f1);
color: white;
border: none;
cursor: pointer;
}
</style>

View file

@ -134,6 +134,12 @@ export const APP_ICONS = {
habits: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="hb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#8b5cf6"/><stop offset="100%" style="stop-color:#6d28d9"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#hb)"/><path d="M30 55l8 8 16-16" stroke="white" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="50" cy="50" r="24" stroke="white" stroke-width="4" fill="none"/><path d="M50 26v6M50 68v6M26 50h6M68 50h6" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),
notes: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="nt" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#d97706"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#nt)"/><rect x="28" y="22" width="44" height="56" rx="4" stroke="white" stroke-width="4" fill="none"/><path d="M38 36h24M38 46h24M38 56h16" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),
finance: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="fn" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#22c55e"/><stop offset="100%" style="stop-color:#16a34a"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#fn)"/><circle cx="50" cy="50" r="22" stroke="white" stroke-width="4" fill="none"/><path d="M50 34v32M42 42c0-4 3.5-6 8-6s8 2 8 6-3.5 5-8 5-8 2-8 6 3.5 6 8 6 8-2 8-6" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/></svg>`
),
arcade: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ar" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ef4444"/><stop offset="100%" style="stop-color:#dc2626"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ar)"/><rect x="25" y="30" width="50" height="35" rx="5" stroke="white" stroke-width="4" fill="none"/><path d="M38 65v10M62 65v10M32 75h36" stroke="white" stroke-width="4" stroke-linecap="round"/><circle cx="60" cy="44" r="4" fill="white"/><circle cx="68" cy="50" r="3" fill="white" fill-opacity="0.7"/><path d="M35 44h10M40 39v10" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),

View file

@ -598,6 +598,40 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'founder',
},
{
id: 'notes',
name: 'Notes',
description: {
de: 'Schnelle Notizen',
en: 'Quick Notes',
},
longDescription: {
de: 'Leichtgewichtige Notizen mit Suche, Farbmarkierungen und Pin-Funktion. Kein Overhead, sofort losschreiben.',
en: 'Lightweight notes with search, color tags, and pinning. No overhead, start writing immediately.',
},
icon: APP_ICONS.notes,
color: '#f59e0b',
comingSoon: false,
status: 'development',
requiredTier: 'founder',
},
{
id: 'finance',
name: 'Finance',
description: {
de: 'Einnahmen & Ausgaben',
en: 'Income & Expenses',
},
longDescription: {
de: 'Einfaches Finanztracking mit Kategorien, Monatsbudgets und Übersicht deiner Einnahmen und Ausgaben.',
en: 'Simple finance tracking with categories, monthly budgets, and overview of your income and expenses.',
},
icon: APP_ICONS.finance,
color: '#22c55e',
comingSoon: false,
status: 'development',
requiredTier: 'founder',
},
{
id: 'arcade',
name: 'Arcade',
@ -727,6 +761,8 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
memoro: { dev: 'http://localhost:5173/memoro', prod: 'https://mana.how/memoro' },
guides: { dev: 'http://localhost:5173/guides', prod: 'https://mana.how/guides' },
habits: { dev: 'http://localhost:5173/habits', prod: 'https://mana.how/habits' },
notes: { dev: 'http://localhost:5173/notes', prod: 'https://mana.how/notes' },
finance: { dev: 'http://localhost:5173/finance', prod: 'https://mana.how/finance' },
wisekeep: { dev: 'http://localhost:5173/wisekeep', prod: 'https://mana.how/wisekeep' },
news: { dev: 'http://localhost:5173/news', prod: 'https://mana.how/news' },
mail: { dev: 'http://localhost:5173/mail', prod: 'https://mana.how/mail' },

View file

@ -20,7 +20,9 @@ export type DragType =
| 'event'
| 'link'
| 'contact'
| 'habit';
| 'habit'
| 'note'
| 'transaction';
export interface DragPayload<T = Record<string, unknown>> {
type: DragType;