mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 06:39:41 +02:00
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:
parent
a8480f6710
commit
9abbf9c70d
25 changed files with 2962 additions and 12 deletions
|
|
@ -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',
|
||||
|
|
|
|||
470
apps/manacore/apps/web/src/lib/modules/finance/ListView.svelte
Normal file
470
apps/manacore/apps/web/src/lib/modules/finance/ListView.svelte
Normal 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>
|
||||
|
|
@ -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[],
|
||||
};
|
||||
28
apps/manacore/apps/web/src/lib/modules/finance/entity.ts
Normal file
28
apps/manacore/apps/web/src/lib/modules/finance/entity.ts
Normal 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);
|
||||
38
apps/manacore/apps/web/src/lib/modules/finance/index.ts
Normal file
38
apps/manacore/apps/web/src/lib/modules/finance/index.ts
Normal 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';
|
||||
133
apps/manacore/apps/web/src/lib/modules/finance/queries.ts
Normal file
133
apps/manacore/apps/web/src/lib/modules/finance/queries.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
100
apps/manacore/apps/web/src/lib/modules/finance/types.ts
Normal file
100
apps/manacore/apps/web/src/lib/modules/finance/types.ts
Normal 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',
|
||||
];
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
411
apps/manacore/apps/web/src/lib/modules/notes/ListView.svelte
Normal file
411
apps/manacore/apps/web/src/lib/modules/notes/ListView.svelte
Normal 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">📌</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>
|
||||
34
apps/manacore/apps/web/src/lib/modules/notes/collections.ts
Normal file
34
apps/manacore/apps/web/src/lib/modules/notes/collections.ts
Normal 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[],
|
||||
};
|
||||
38
apps/manacore/apps/web/src/lib/modules/notes/entity.ts
Normal file
38
apps/manacore/apps/web/src/lib/modules/notes/entity.ts
Normal 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);
|
||||
16
apps/manacore/apps/web/src/lib/modules/notes/index.ts
Normal file
16
apps/manacore/apps/web/src/lib/modules/notes/index.ts
Normal 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';
|
||||
68
apps/manacore/apps/web/src/lib/modules/notes/queries.ts
Normal file
68
apps/manacore/apps/web/src/lib/modules/notes/queries.ts
Normal 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' });
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
46
apps/manacore/apps/web/src/lib/modules/notes/types.ts
Normal file
46
apps/manacore/apps/web/src/lib/modules/notes/types.ts
Normal 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',
|
||||
];
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
564
apps/manacore/apps/web/src/routes/(app)/finance/+page.svelte
Normal file
564
apps/manacore/apps/web/src/routes/(app)/finance/+page.svelte
Normal 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}>←</button>
|
||||
<span class="month-label">{monthLabel}</span>
|
||||
<button class="nav-btn" onclick={nextMonth}>→</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>
|
||||
12
apps/manacore/apps/web/src/routes/(app)/notes/+layout.svelte
Normal file
12
apps/manacore/apps/web/src/routes/(app)/notes/+layout.svelte
Normal 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()}
|
||||
403
apps/manacore/apps/web/src/routes/(app)/notes/+page.svelte
Normal file
403
apps/manacore/apps/web/src/routes/(app)/notes/+page.svelte
Normal 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>
|
||||
314
apps/manacore/apps/web/src/routes/(app)/notes/[id]/+page.svelte
Normal file
314
apps/manacore/apps/web/src/routes/(app)/notes/[id]/+page.svelte
Normal 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'}
|
||||
>
|
||||
📌
|
||||
</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>
|
||||
|
|
@ -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>`
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ export type DragType =
|
|||
| 'event'
|
||||
| 'link'
|
||||
| 'contact'
|
||||
| 'habit';
|
||||
| 'habit'
|
||||
| 'note'
|
||||
| 'transaction';
|
||||
|
||||
export interface DragPayload<T = Record<string, unknown>> {
|
||||
type: DragType;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue