feat(manacore): start unified same-origin app — database schema + calc module

Phase 0-2 of the unified app migration:
- Unified Dexie database with all 26 app schemas (120+ collections)
- Table name collisions resolved with prefixes (e.g., pictureTags, storageTags)
- SYNC_APP_MAP for routing sync changes to correct /sync/{appId} endpoints
- Calc module migrated as first proof-of-concept:
  - components/skins, engine/evaluate, stores, queries
  - Routes at /calc and /calc/standard
  - Writes directly to unified db.calculations table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 16:35:54 +02:00
parent 9aedc89ce5
commit d3807b4bea
17 changed files with 2179 additions and 0 deletions

View file

@ -0,0 +1,231 @@
/**
* Unified Dexie Database Single IndexedDB for all ManaCore apps.
*
* All collections from all app modules are registered in one database.
* Table names that collide across apps are prefixed (e.g., pictureTags, storageTags).
*
* The SYNC_APP_MAP maps each table back to its appId for sync routing.
*/
import Dexie, { type EntityTable } from 'dexie';
// ─── Database ──────────────────────────────────────────────
export const db = new Dexie('manacore');
db.version(1).stores({
// ─── Sync Infrastructure ───
_pendingChanges: '++id, appId, collection, recordId, createdAt',
_syncMeta: '[appId+collection]',
// ─── Core / ManaCore (appId: 'manacore') ───
userSettings: 'id, key',
dashboardConfigs: 'id',
// ─── Todo (appId: 'todo') ───
tasks:
'id, dueDate, isCompleted, priority, order, projectId, [isCompleted+order], [projectId+order]',
todoProjects: 'id, order, isArchived, isDefault',
labels: 'id',
taskLabels: 'id, taskId, labelId',
reminders: 'id, taskId',
boardViews: 'id, order, groupBy',
// ─── Calendar (appId: 'calendar') ───
calendars: 'id, isDefault, isVisible',
events: 'id, calendarId, startDate, endDate, allDay, [calendarId+startDate]',
// ─── Contacts (appId: 'contacts') ───
contacts: 'id, firstName, lastName, email, company, isFavorite, isArchived',
// ─── Chat (appId: 'chat') ───
conversations: 'id, isArchived, isPinned, spaceId, templateId',
messages: 'id, conversationId, sender, [conversationId+sender]',
chatTemplates: 'id, isDefault',
// ─── Picture (appId: 'picture') ───
images: 'id, isFavorite, isPublic, archivedAt, prompt',
boards: 'id, isPublic',
boardItems: 'id, boardId, itemType, zIndex, [boardId+zIndex]',
pictureTags: 'id, name',
imageTags: 'id, imageId, tagId, [imageId+tagId]',
// ─── Cards (appId: 'cards') ───
cardDecks: 'id, isPublic',
cards: 'id, deckId, difficulty, nextReview, order, [deckId+order]',
// ─── Zitare (appId: 'zitare') ───
zitareFavorites: 'id, quoteId',
zitareLists: 'id',
// ─── Clock (appId: 'clock') ───
alarms: 'id, enabled, time',
timers: 'id, status',
worldClocks: 'id, sortOrder, timezone',
// ─── Mukke (appId: 'mukke') ───
songs: 'id, artist, album, genre, favorite, title',
mukkePlaylists: 'id, name',
playlistSongs: 'id, playlistId, songId, sortOrder, [playlistId+sortOrder]',
mukkeProjects: 'id, title, songId',
markers: 'id, beatId, type, sortOrder',
// ─── Storage (appId: 'storage') ───
files: 'id, parentFolderId, mimeType, isFavorite, isDeleted, name',
storageFolders: 'id, parentFolderId, path, depth, isFavorite, isDeleted',
storageTags: 'id, name',
fileTags: 'id, fileId, tagId, [fileId+tagId]',
// ─── Presi (appId: 'presi') ───
presiDecks: 'id, isPublic',
slides: 'id, deckId, order, [deckId+order]',
// ─── Inventar (appId: 'inventar') ───
invCollections: 'id, order, templateId',
invItems: 'id, collectionId, locationId, categoryId, status, name, [collectionId+order]',
invLocations: 'id, parentId, path, depth, order',
invCategories: 'id, parentId, order',
// ─── Photos (appId: 'photos') ───
albums: 'id, isAutoGenerated, name',
albumItems: 'id, albumId, mediaId, sortOrder, [albumId+sortOrder]',
photoFavorites: 'id, mediaId',
photoTags: 'id, name',
photoMediaTags: 'id, mediaId, tagId, [mediaId+tagId]',
// ─── SkillTree (appId: 'skilltree') ───
skills: 'id, branch, parentId, level',
activities: 'id, skillId, timestamp',
achievements: 'id, key, unlockedAt',
// ─── CityCorners (appId: 'citycorners') ───
cities: 'id, slug, country, name',
ccLocations: 'id, cityId, category, name',
ccFavorites: 'id, locationId',
// ─── Times (appId: 'times') ───
timeClients: 'id, order, isArchived, shortCode',
timeProjects: 'id, clientId, isArchived, isBillable, guildId, visibility, order',
timeEntries:
'id, projectId, clientId, date, isRunning, [date+projectId], [date+clientId], guildId, visibility',
timeTags: 'id, name, order',
timeTemplates: 'id, usageCount, lastUsedAt, projectId',
timeSettings: 'id',
// ─── Context (appId: 'context') ───
contextSpaces: 'id, pinned, prefix',
documents: 'id, spaceId, type, pinned, title, [spaceId+type]',
// ─── Questions (appId: 'questions') ───
qCollections: 'id, sortOrder, isDefault',
questions: 'id, collectionId, status, priority, [collectionId+status]',
answers: 'id, questionId, isAccepted',
// ─── NutriPhi (appId: 'nutriphi') ───
meals: 'id, date, mealType, [date+mealType]',
goals: 'id',
nutriFavorites: 'id, mealType, usageCount',
// ─── Planta (appId: 'planta') ───
plants: 'id, isActive, healthStatus',
plantPhotos: 'id, plantId, isPrimary, [plantId+isPrimary]',
wateringSchedules: 'id, plantId, nextWateringAt',
wateringLogs: 'id, plantId, wateredAt',
// ─── uLoad (appId: 'uload') ───
links: 'id, shortCode, isActive, folderId, order, clickCount, [folderId+order], [isActive+order]',
uloadTags: 'id, slug, name',
uloadFolders: 'id, order',
linkTags: 'id, linkId, tagId, [linkId+tagId]',
// ─── Calc (appId: 'calc') ───
calculations: 'id, mode',
savedFormulas: 'id, mode, name',
// ─── Moodlit (appId: 'moodlit') ───
moods: 'id, name, animation, isDefault',
sequences: 'id, name',
// ─── Memoro (appId: 'memoro') ───
memos: 'id, processingStatus, isArchived, isPinned, language, [isArchived+createdAt]',
memories: 'id, memoId',
memoroTags: 'id, name, sortOrder',
memoTags: 'id, memoId, tagId',
memoroSpaces: 'id, ownerId',
spaceMembers: 'id, spaceId, userId',
memoSpaces: 'id, memoId, spaceId',
// ─── Guides (appId: 'guides') ───
guides: 'id, category, difficulty, collectionId, tags',
sections: 'id, guideId, order',
steps: 'id, guideId, sectionId, order, [guideId+order]',
guideCollections: 'id',
runs: 'id, guideId, startedAt, completedAt',
// ─── Playground (appId: 'playground') ───
// No persistent data — stateless LLM playground
// ─── Shared: Global Tags (appId: 'tags') ───
globalTags: 'id, name, groupId',
tagGroups: 'id',
// ─── Shared: Links (appId: 'links') ───
manaLinks: 'id, sourceAppId, sourceRecordId, targetAppId, targetRecordId',
});
// ─── Sync App Map ──────────────────────────────────────────
// Maps each table to its appId for sync routing.
// The SyncEngine uses this to group pending changes and push to /sync/{appId}.
export const SYNC_APP_MAP: Record<string, string[]> = {
manacore: ['userSettings', 'dashboardConfigs'],
todo: ['tasks', 'todoProjects', 'labels', 'taskLabels', 'reminders', 'boardViews'],
calendar: ['calendars', 'events'],
contacts: ['contacts'],
chat: ['conversations', 'messages', 'chatTemplates'],
picture: ['images', 'boards', 'boardItems', 'pictureTags', 'imageTags'],
cards: ['cardDecks', 'cards'],
zitare: ['zitareFavorites', 'zitareLists'],
clock: ['alarms', 'timers', 'worldClocks'],
mukke: ['songs', 'mukkePlaylists', 'playlistSongs', 'mukkeProjects', 'markers'],
storage: ['files', 'storageFolders', 'storageTags', 'fileTags'],
presi: ['presiDecks', 'slides'],
inventar: ['invCollections', 'invItems', 'invLocations', 'invCategories'],
photos: ['albums', 'albumItems', 'photoFavorites', 'photoTags', 'photoMediaTags'],
skilltree: ['skills', 'activities', 'achievements'],
citycorners: ['cities', 'ccLocations', 'ccFavorites'],
times: [
'timeClients',
'timeProjects',
'timeEntries',
'timeTags',
'timeTemplates',
'timeSettings',
],
context: ['contextSpaces', 'documents'],
questions: ['qCollections', 'questions', 'answers'],
nutriphi: ['meals', 'goals', 'nutriFavorites'],
planta: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs'],
uload: ['links', 'uloadTags', 'uloadFolders', 'linkTags'],
calc: ['calculations', 'savedFormulas'],
moodlit: ['moods', 'sequences'],
memoro: [
'memos',
'memories',
'memoroTags',
'memoTags',
'memoroSpaces',
'spaceMembers',
'memoSpaces',
],
guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs'],
tags: ['globalTags', 'tagGroups'],
links: ['manaLinks'],
};
// ─── Reverse Map: Table → AppId ────────────────────────────
// Used by _pendingChanges to determine which appId to tag a change with.
export const TABLE_TO_APP: Record<string, string> = Object.fromEntries(
Object.entries(SYNC_APP_MAP).flatMap(([appId, tables]) => tables.map((table) => [table, appId]))
);

View file

@ -0,0 +1,36 @@
/**
* Calc module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalCalculation, LocalSavedFormula } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const calculationTable = db.table<LocalCalculation>('calculations');
export const savedFormulaTable = db.table<LocalSavedFormula>('savedFormulas');
// ─── Guest Seed ────────────────────────────────────────────
export const CALC_GUEST_SEED = {
calculations: [
{
id: 'calc-demo-1',
mode: 'standard' as const,
expression: '42 * 23',
result: '966',
},
{
id: 'calc-demo-2',
mode: 'scientific' as const,
expression: 'sin(π/4)',
result: '0.7071067812',
},
{
id: 'calc-demo-3',
mode: 'standard' as const,
expression: '1024 / 8',
result: '128',
},
],
};

View file

@ -0,0 +1,284 @@
<script lang="ts">
import type { CalcSkinProps } from './types';
let {
expression,
display,
error,
copied,
onButton,
onClear,
onBackspace,
onEquals,
onCopy,
}: CalcSkinProps = $props();
const buttons = [
['C', '(', ')', '%'],
['7', '8', '9', '/'],
['4', '5', '6', '*'],
['1', '2', '3', '-'],
['0', '.', '=', '+'],
];
function handleButton(btn: string) {
if (btn === 'C') onClear();
else if (btn === '=') onEquals();
else onButton(btn);
}
function isOp(btn: string): boolean {
return ['+', '-', '*', '/', '%', '(', ')'].includes(btn);
}
</script>
<div class="casio">
<div class="casio-body">
<!-- Brand header -->
<div class="casio-header">
<span class="casio-brand">CASIO</span>
<span class="casio-model">fx-82</span>
</div>
<!-- Solar panel strip -->
<div class="casio-solar">
{#each Array(8) as _}
<div class="casio-solar-cell"></div>
{/each}
</div>
<!-- LCD Display (green-gray) -->
<div class="casio-display">
<div class="casio-expression">{expression || ' '}</div>
<div style="display: flex; align-items: flex-end; gap: 4px;">
<div class="casio-result" style="flex: 1;" class:casio-error={!!error}>
{error || display}
</div>
{#if display !== '0' && !error}
<button class="casio-copy" onclick={onCopy} title="Kopieren">
{copied ? '✓' : '⎘'}
</button>
{/if}
</div>
</div>
<!-- Keypad -->
<div class="casio-keypad">
{#each buttons as row}
{#each row as btn}
<button
class="casio-btn"
class:casio-btn-eq={btn === '='}
class:casio-btn-clear={btn === 'C'}
class:casio-btn-op={isOp(btn)}
class:casio-btn-num={!isOp(btn) && btn !== '=' && btn !== 'C'}
onclick={() => handleButton(btn)}
>
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
</button>
{/each}
{/each}
</div>
<button class="casio-backspace" onclick={onBackspace}>DEL</button>
<!-- Footer -->
<div class="casio-footer">
<span>S-V.P.A.M.</span>
</div>
</div>
</div>
<style>
.casio {
display: flex;
justify-content: center;
}
.casio-body {
width: 310px;
background: linear-gradient(180deg, #e8e8e8 0%, #d0d0d0 30%, #c0c0c0 100%);
border-radius: 16px 16px 20px 20px;
padding: 16px 14px 20px;
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.25),
inset 0 1px 0 rgba(255, 255, 255, 0.6),
0 0 0 1px #aaa;
}
.casio-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
padding: 0 4px;
}
.casio-brand {
font-family: 'Arial', sans-serif;
font-size: 16px;
font-weight: bold;
color: #333;
letter-spacing: 2px;
}
.casio-model {
font-family: 'Arial', sans-serif;
font-size: 12px;
color: #666;
font-style: italic;
}
.casio-solar {
display: flex;
gap: 2px;
margin-bottom: 8px;
padding: 0 4px;
}
.casio-solar-cell {
flex: 1;
height: 8px;
background: linear-gradient(180deg, #2a2a4a, #1a1a3a);
border-radius: 1px;
}
.casio-display {
background: #b8c8a0;
border: 2px solid #8a9a70;
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 14px;
min-height: 68px;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.15);
}
.casio-expression {
font-family: 'Courier New', monospace;
font-size: 11px;
color: #3a4a2a;
opacity: 0.7;
min-height: 14px;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.casio-result {
font-family: 'Courier New', monospace;
font-size: 28px;
font-weight: bold;
color: #1a2a0a;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 1px;
}
.casio-error {
color: #8a2020;
font-size: 16px;
}
.casio-copy {
background: none;
border: none;
color: #3a4a2a;
opacity: 0.4;
font-size: 14px;
cursor: pointer;
padding: 2px 4px;
}
.casio-copy:hover {
opacity: 0.8;
}
.casio-keypad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 5px;
}
.casio-btn {
height: 44px;
border: none;
border-radius: 4px;
font-family: 'Arial', sans-serif;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.08s;
position: relative;
top: 0;
}
.casio-btn:active {
top: 1px;
filter: brightness(0.9);
}
.casio-btn-num {
background: #f0f0f0;
color: #222;
box-shadow:
0 2px 0 #bbb,
0 3px 6px rgba(0, 0, 0, 0.15);
}
.casio-btn-op {
background: #e0e0e0;
color: #333;
box-shadow:
0 2px 0 #aaa,
0 3px 6px rgba(0, 0, 0, 0.15);
}
.casio-btn-eq {
background: #3366cc;
color: white;
font-size: 18px;
box-shadow:
0 2px 0 #2244aa,
0 3px 6px rgba(0, 0, 0, 0.2);
}
.casio-btn-clear {
background: #cc3333;
color: white;
box-shadow:
0 2px 0 #992222,
0 3px 6px rgba(0, 0, 0, 0.2);
}
.casio-backspace {
width: 100%;
margin-top: 6px;
height: 30px;
border: none;
border-radius: 4px;
font-family: 'Arial', sans-serif;
font-size: 11px;
font-weight: 600;
cursor: pointer;
background: #d8d8d8;
color: #555;
box-shadow: 0 1px 0 #bbb;
}
.casio-backspace:hover {
background: #ccc;
}
.casio-footer {
text-align: right;
margin-top: 10px;
font-family: 'Arial', sans-serif;
font-size: 8px;
color: #999;
letter-spacing: 1px;
padding-right: 4px;
}
</style>

View file

@ -0,0 +1,263 @@
<script lang="ts">
import type { CalcSkinProps } from './types';
let {
expression,
display,
error,
copied,
onButton,
onClear,
onBackspace,
onEquals,
onCopy,
}: CalcSkinProps = $props();
// HP-35 had a distinctive layout - we adapt it for standard calc use
const buttons = [
['C', '(', ')', '%'],
['7', '8', '9', '/'],
['4', '5', '6', '*'],
['1', '2', '3', '-'],
['0', '.', '=', '+'],
];
function handleButton(btn: string) {
if (btn === 'C') onClear();
else if (btn === '=') onEquals();
else onButton(btn);
}
function isOp(btn: string): boolean {
return ['+', '-', '*', '/', '%', '(', ')'].includes(btn);
}
</script>
<div class="hp35">
<!-- Device frame -->
<div class="hp35-body">
<!-- HP Logo -->
<div class="hp35-logo">
<span class="hp35-hp">HP</span>
<span class="hp35-model">35</span>
</div>
<!-- LED Display (red on dark) -->
<div class="hp35-display">
<div class="hp35-expression">{expression || ' '}</div>
<div style="display: flex; align-items: flex-end; gap: 6px;">
<div class="hp35-result" style="flex: 1;" class:hp35-error={!!error}>
{error || display}
</div>
{#if display !== '0' && !error}
<button class="hp35-copy" onclick={onCopy} title="Kopieren">
{copied ? '✓' : '⎘'}
</button>
{/if}
</div>
</div>
<!-- Keypad -->
<div class="hp35-keypad">
{#each buttons as row}
{#each row as btn}
<button
class="hp35-btn"
class:hp35-btn-eq={btn === '='}
class:hp35-btn-clear={btn === 'C'}
class:hp35-btn-op={isOp(btn)}
onclick={() => handleButton(btn)}
>
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
</button>
{/each}
{/each}
</div>
<!-- Backspace -->
<button class="hp35-backspace" onclick={onBackspace}> CLR </button>
<!-- Footer -->
<div class="hp35-footer">
<span>HEWLETT · PACKARD</span>
</div>
</div>
</div>
<style>
.hp35 {
display: flex;
justify-content: center;
}
.hp35-body {
width: 320px;
background: linear-gradient(145deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
border-radius: 20px 20px 24px 24px;
padding: 20px 16px 24px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.6),
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 0 0 2px #0a0a1a;
position: relative;
}
.hp35-logo {
text-align: center;
margin-bottom: 12px;
font-family: 'Courier New', monospace;
}
.hp35-hp {
font-size: 18px;
font-weight: bold;
color: #c4c4c4;
letter-spacing: 4px;
}
.hp35-model {
font-size: 14px;
color: #888;
margin-left: 4px;
}
.hp35-display {
background: #0a0a0a;
border: 2px solid #333;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
min-height: 72px;
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.8);
}
.hp35-expression {
font-family: 'Courier New', monospace;
font-size: 11px;
color: #ff3333;
opacity: 0.6;
min-height: 16px;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hp35-result {
font-family: 'Courier New', monospace;
font-size: 28px;
font-weight: bold;
color: #ff2200;
text-align: right;
text-shadow: 0 0 12px rgba(255, 34, 0, 0.6);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 2px;
}
.hp35-error {
color: #ff6644;
font-size: 18px;
}
.hp35-keypad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.hp35-btn {
height: 48px;
border: none;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.1s;
background: #2a2a4a;
color: #e0e0e0;
box-shadow:
0 3px 0 #1a1a30,
0 4px 8px rgba(0, 0, 0, 0.3);
position: relative;
top: 0;
}
.hp35-btn:active {
top: 2px;
box-shadow:
0 1px 0 #1a1a30,
0 2px 4px rgba(0, 0, 0, 0.3);
}
.hp35-btn-eq {
background: #c63030;
color: white;
box-shadow:
0 3px 0 #8a2020,
0 4px 8px rgba(0, 0, 0, 0.3);
}
.hp35-btn-eq:active {
box-shadow:
0 1px 0 #8a2020,
0 2px 4px rgba(0, 0, 0, 0.3);
}
.hp35-btn-clear {
background: #4a3020;
color: #ff9966;
box-shadow:
0 3px 0 #2a1810,
0 4px 8px rgba(0, 0, 0, 0.3);
}
.hp35-btn-op {
background: #3a3a5a;
color: #aaccff;
}
.hp35-copy {
background: none;
border: none;
color: #ff3333;
opacity: 0.5;
font-size: 14px;
cursor: pointer;
padding: 2px 4px;
}
.hp35-copy:hover {
opacity: 1;
}
.hp35-backspace {
width: 100%;
margin-top: 8px;
height: 32px;
border: none;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 11px;
cursor: pointer;
background: #1a1a30;
color: #888;
letter-spacing: 1px;
}
.hp35-backspace:hover {
color: #aaa;
}
.hp35-footer {
text-align: center;
margin-top: 16px;
font-family: 'Courier New', monospace;
font-size: 9px;
color: #555;
letter-spacing: 3px;
text-transform: uppercase;
}
</style>

View file

@ -0,0 +1,178 @@
<script lang="ts">
import type { CalcSkinProps } from './types';
let {
expression,
display,
error,
copied,
onButton,
onClear,
onBackspace,
onEquals,
onCopy,
}: CalcSkinProps = $props();
const buttons = [
['C', '(', ')', '%'],
['7', '8', '9', '/'],
['4', '5', '6', '*'],
['1', '2', '3', '-'],
['0', '.', '=', '+'],
];
function handleButton(btn: string) {
if (btn === 'C') onClear();
else if (btn === '=') onEquals();
else onButton(btn);
}
</script>
<div class="minimal">
<!-- Display: just big text -->
<div class="minimal-display">
<div class="minimal-expression">{expression || ' '}</div>
<div style="display: flex; align-items: flex-end; gap: 4px; justify-content: flex-end;">
<div class="minimal-result" style="flex: 1;" class:minimal-error={!!error}>
{error || display}
</div>
{#if display !== '0' && !error}
<button class="minimal-copy" onclick={onCopy}>
{copied ? '✓' : '⎘'}
</button>
{/if}
</div>
</div>
<!-- Buttons: clean, borderless -->
<div class="minimal-grid">
{#each buttons as row}
{#each row as btn}
<button
class="minimal-btn"
class:minimal-btn-eq={btn === '='}
class:minimal-btn-clear={btn === 'C'}
onclick={() => handleButton(btn)}
>
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
</button>
{/each}
{/each}
</div>
<button class="minimal-backspace" onclick={onBackspace}>←</button>
</div>
<style>
.minimal {
max-width: 300px;
margin: 0 auto;
}
.minimal-display {
padding: 24px 8px 16px;
text-align: right;
}
.minimal-expression {
font-family: system-ui, sans-serif;
font-size: 14px;
color: hsl(var(--muted-foreground));
opacity: 0.5;
min-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.minimal-result {
font-family: system-ui, sans-serif;
font-size: 48px;
font-weight: 200;
color: hsl(var(--foreground));
letter-spacing: -1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.1;
}
.minimal-error {
color: hsl(var(--destructive, 0 84% 60%));
font-size: 24px;
}
.minimal-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
}
.minimal-btn {
height: 56px;
border: none;
border-radius: 50%;
font-family: system-ui, sans-serif;
font-size: 20px;
font-weight: 300;
cursor: pointer;
transition: background 0.15s;
background: transparent;
color: hsl(var(--foreground));
}
.minimal-btn:hover {
background: hsl(var(--muted));
}
.minimal-btn:active {
background: hsl(var(--muted));
opacity: 0.7;
}
.minimal-btn-eq {
background: hsl(var(--foreground));
color: hsl(var(--background));
font-weight: 400;
}
.minimal-btn-eq:hover {
background: hsl(var(--foreground));
opacity: 0.8;
}
.minimal-btn-clear {
color: hsl(var(--destructive, 0 84% 60%));
font-weight: 400;
}
.minimal-backspace {
width: 100%;
margin-top: 4px;
height: 36px;
border: none;
border-radius: 18px;
font-size: 16px;
cursor: pointer;
background: transparent;
color: hsl(var(--muted-foreground));
}
.minimal-copy {
background: none;
border: none;
color: hsl(var(--muted-foreground));
opacity: 0.3;
font-size: 16px;
cursor: pointer;
padding: 2px;
}
.minimal-copy:hover {
opacity: 0.7;
}
.minimal-backspace:hover {
background: hsl(var(--muted));
}
</style>

View file

@ -0,0 +1,84 @@
<script lang="ts">
import type { CalcSkinProps } from './types';
let {
expression,
display,
error,
copied,
onButton,
onClear,
onBackspace,
onEquals,
onCopy,
}: CalcSkinProps = $props();
const buttons = [
['C', '(', ')', '%'],
['7', '8', '9', '/'],
['4', '5', '6', '*'],
['1', '2', '3', '-'],
['0', '.', '=', '+'],
];
function getButtonClass(btn: string): string {
if (btn === '=') return 'bg-pink-500 text-white hover:bg-pink-600 font-bold text-xl';
if (btn === 'C') return 'bg-red-500/20 text-red-400 hover:bg-red-500/30 font-bold';
if (['+', '-', '*', '/', '%', '(', ')'].includes(btn))
return 'bg-muted text-foreground hover:bg-muted/80 font-medium';
return 'bg-card text-foreground hover:bg-card/80 font-medium';
}
function handleButton(btn: string) {
if (btn === 'C') onClear();
else if (btn === '=') onEquals();
else onButton(btn);
}
</script>
<div class="modern-skin">
<div class="display rounded-xl bg-card border border-border p-4 mb-4">
<div class="text-sm text-muted-foreground min-h-[1.5rem] font-mono truncate">
{expression || ' '}
</div>
<div class="flex items-end gap-2">
<div
class="flex-1 text-4xl font-bold text-foreground font-mono text-right tabular-nums truncate"
class:text-red-400={!!error}
>
{error || display}
</div>
{#if display !== '0' && !error}
<button
class="shrink-0 p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors text-xs"
onclick={onCopy}
title="Kopieren"
>
{copied ? '✓' : '⎘'}
</button>
{/if}
</div>
</div>
<div class="grid grid-cols-4 gap-2">
{#each buttons as row}
{#each row as btn}
<button
class="h-14 rounded-xl border border-border transition-all active:scale-95 text-lg {getButtonClass(
btn
)}"
onclick={() => handleButton(btn)}
>
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
</button>
{/each}
{/each}
</div>
<button
class="mt-2 w-full h-10 rounded-lg bg-muted/50 text-muted-foreground hover:bg-muted transition-all text-sm"
onclick={onBackspace}
>
← Löschen
</button>
</div>

View file

@ -0,0 +1,281 @@
<script lang="ts">
import type { CalcSkinProps } from './types';
let {
expression,
display,
error,
copied,
onButton,
onClear,
onBackspace,
onEquals,
onCopy,
}: CalcSkinProps = $props();
const buttons = [
['C', '(', ')', '%'],
['7', '8', '9', '/'],
['4', '5', '6', '*'],
['1', '2', '3', '-'],
['0', '.', '=', '+'],
];
function handleButton(btn: string) {
if (btn === 'C') onClear();
else if (btn === '=') onEquals();
else onButton(btn);
}
function isOp(btn: string): boolean {
return ['+', '-', '*', '/', '%', '(', ')'].includes(btn);
}
</script>
<div class="ti84">
<div class="ti84-body">
<!-- Brand -->
<div class="ti84-header">
<span class="ti84-brand">TEXAS INSTRUMENTS</span>
<span class="ti84-model">TI-84 Plus</span>
</div>
<!-- Screen (blue LCD with pixel feel) -->
<div class="ti84-screen">
<div class="ti84-screen-inner">
<div class="ti84-expression">{expression || ' '}</div>
<div style="display: flex; align-items: flex-end; gap: 6px;">
<div class="ti84-result" style="flex: 1;" class:ti84-error={!!error}>
{error || display}
</div>
{#if display !== '0' && !error}
<button class="ti84-copy" onclick={onCopy} title="Kopieren">
{copied ? '✓' : '⎘'}
</button>
{/if}
</div>
</div>
</div>
<!-- Navigation cluster -->
<div class="ti84-nav">
<button class="ti84-nav-btn" onclick={onBackspace}>DEL</button>
<button class="ti84-nav-btn ti84-nav-mode">2ND</button>
</div>
<!-- Keypad -->
<div class="ti84-keypad">
{#each buttons as row}
{#each row as btn}
<button
class="ti84-btn"
class:ti84-btn-eq={btn === '='}
class:ti84-btn-clear={btn === 'C'}
class:ti84-btn-op={isOp(btn)}
onclick={() => handleButton(btn)}
>
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
</button>
{/each}
{/each}
</div>
<div class="ti84-footer">
<div class="ti84-usb"></div>
</div>
</div>
</div>
<style>
.ti84 {
display: flex;
justify-content: center;
}
.ti84-body {
width: 320px;
background: linear-gradient(180deg, #1a1a1a 0%, #222 50%, #1a1a1a 100%);
border-radius: 18px 18px 22px 22px;
padding: 16px;
box-shadow:
0 16px 50px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 0 0 2px #111;
}
.ti84-header {
text-align: center;
margin-bottom: 10px;
}
.ti84-brand {
display: block;
font-family: 'Arial', sans-serif;
font-size: 9px;
color: #888;
letter-spacing: 3px;
text-transform: uppercase;
}
.ti84-model {
font-family: 'Arial', sans-serif;
font-size: 13px;
font-weight: bold;
color: #ccc;
letter-spacing: 1px;
}
.ti84-screen {
background: #1a2a1a;
border: 3px solid #333;
border-radius: 8px;
padding: 3px;
margin-bottom: 12px;
box-shadow:
inset 0 2px 10px rgba(0, 0, 0, 0.8),
0 1px 0 rgba(255, 255, 255, 0.05);
}
.ti84-screen-inner {
background: #2a4a3a;
border-radius: 4px;
padding: 12px 14px;
min-height: 72px;
}
.ti84-expression {
font-family: 'Courier New', monospace;
font-size: 11px;
color: #88cc88;
opacity: 0.7;
min-height: 14px;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ti84-result {
font-family: 'Courier New', monospace;
font-size: 26px;
font-weight: bold;
color: #aaffaa;
text-align: right;
text-shadow: 0 0 8px rgba(170, 255, 170, 0.3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 2px;
}
.ti84-error {
color: #ffaa88;
font-size: 16px;
}
.ti84-copy {
background: none;
border: none;
color: #aaffaa;
opacity: 0.4;
font-size: 14px;
cursor: pointer;
padding: 2px 4px;
}
.ti84-copy:hover {
opacity: 1;
}
.ti84-nav {
display: flex;
gap: 6px;
margin-bottom: 10px;
justify-content: center;
}
.ti84-nav-btn {
padding: 4px 16px;
border: none;
border-radius: 4px;
font-family: 'Arial', sans-serif;
font-size: 10px;
font-weight: 600;
cursor: pointer;
background: #444;
color: #ccc;
letter-spacing: 1px;
}
.ti84-nav-btn:hover {
background: #555;
}
.ti84-nav-mode {
background: #3366aa;
color: #ddeeff;
}
.ti84-keypad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 5px;
}
.ti84-btn {
height: 46px;
border: none;
border-radius: 6px;
font-family: 'Arial', sans-serif;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.08s;
background: #3a3a3a;
color: #e0e0e0;
box-shadow:
0 3px 0 #222,
0 4px 8px rgba(0, 0, 0, 0.3);
position: relative;
top: 0;
}
.ti84-btn:active {
top: 2px;
box-shadow:
0 1px 0 #222,
0 2px 4px rgba(0, 0, 0, 0.3);
}
.ti84-btn-op {
background: #4a4a4a;
color: #aaccff;
}
.ti84-btn-eq {
background: #2255aa;
color: white;
font-size: 18px;
box-shadow:
0 3px 0 #153888,
0 4px 8px rgba(0, 0, 0, 0.3);
}
.ti84-btn-clear {
background: #555;
color: #ff9966;
}
.ti84-footer {
display: flex;
justify-content: center;
margin-top: 12px;
}
.ti84-usb {
width: 20px;
height: 6px;
background: #333;
border-radius: 0 0 3px 3px;
}
</style>

View file

@ -0,0 +1,6 @@
export { default as ModernSkin } from './ModernSkin.svelte';
export { default as HP35Skin } from './HP35Skin.svelte';
export { default as CasioSkin } from './CasioSkin.svelte';
export { default as TI84Skin } from './TI84Skin.svelte';
export { default as MinimalSkin } from './MinimalSkin.svelte';
export type { CalcSkinProps } from './types';

View file

@ -0,0 +1,14 @@
/**
* Shared interface for all calculator skin components.
*/
export interface CalcSkinProps {
expression: string;
display: string;
error: string;
copied: boolean;
onButton: (btn: string) => void;
onClear: () => void;
onBackspace: () => void;
onEquals: () => void;
onCopy: () => void;
}

View file

@ -0,0 +1,261 @@
/**
* Safe math expression evaluator.
*
* Supports: +, -, *, /, %, ^, parentheses, and scientific functions.
* Does NOT use eval() parses manually for safety.
*/
const FUNCTIONS: Record<string, (x: number) => number> = {
sin: Math.sin,
cos: Math.cos,
tan: Math.tan,
asin: Math.asin,
acos: Math.acos,
atan: Math.atan,
sinh: Math.sinh,
cosh: Math.cosh,
tanh: Math.tanh,
log: Math.log10,
ln: Math.log,
sqrt: Math.sqrt,
cbrt: Math.cbrt,
abs: Math.abs,
ceil: Math.ceil,
floor: Math.floor,
round: Math.round,
exp: Math.exp,
};
const CONSTANTS: Record<string, number> = {
pi: Math.PI,
PI: Math.PI,
π: Math.PI,
e: Math.E,
E: Math.E,
φ: 1.6180339887,
phi: 1.6180339887,
};
type Token =
| { type: 'number'; value: number }
| { type: 'op'; value: string }
| { type: 'func'; value: string }
| { type: 'paren'; value: '(' | ')' };
function tokenize(expr: string): Token[] {
const tokens: Token[] = [];
let i = 0;
const s = expr.replace(/\s+/g, '');
while (i < s.length) {
// Numbers (including decimals)
if (/[0-9.]/.test(s[i])) {
let num = '';
while (i < s.length && /[0-9.eE]/.test(s[i])) {
num += s[i++];
// Handle scientific notation sign
if ((s[i] === '+' || s[i] === '-') && /[eE]/.test(s[i - 1])) {
num += s[i++];
}
}
tokens.push({ type: 'number', value: parseFloat(num) });
continue;
}
// Parentheses
if (s[i] === '(' || s[i] === ')') {
tokens.push({ type: 'paren', value: s[i] as '(' | ')' });
i++;
continue;
}
// Operators
if ('+-*/%^'.includes(s[i])) {
// Handle unary minus
if (
s[i] === '-' &&
(tokens.length === 0 ||
tokens[tokens.length - 1].type === 'op' ||
(tokens[tokens.length - 1].type === 'paren' && tokens[tokens.length - 1].value === '('))
) {
let num = '-';
i++;
while (i < s.length && /[0-9.eE]/.test(s[i])) {
num += s[i++];
}
if (num.length > 1) {
tokens.push({ type: 'number', value: parseFloat(num) });
continue;
}
// It's just a minus, push as operator
tokens.push({ type: 'op', value: '-' });
continue;
}
tokens.push({ type: 'op', value: s[i] });
i++;
continue;
}
// Special characters (π, etc.)
if (s[i] === 'π' || s[i] === 'φ') {
tokens.push({ type: 'number', value: CONSTANTS[s[i]] });
i++;
continue;
}
// Functions and constants (letters)
if (/[a-zA-Z_]/.test(s[i])) {
let name = '';
while (i < s.length && /[a-zA-Z_0-9]/.test(s[i])) {
name += s[i++];
}
if (CONSTANTS[name] !== undefined) {
tokens.push({ type: 'number', value: CONSTANTS[name] });
} else if (FUNCTIONS[name]) {
tokens.push({ type: 'func', value: name });
} else {
throw new Error(`Unknown: ${name}`);
}
continue;
}
// Factorial
if (s[i] === '!') {
tokens.push({ type: 'op', value: '!' });
i++;
continue;
}
throw new Error(`Unexpected character: ${s[i]}`);
}
return tokens;
}
function precedence(op: string): number {
if (op === '+' || op === '-') return 1;
if (op === '*' || op === '/' || op === '%') return 2;
if (op === '^') return 3;
return 0;
}
function factorial(n: number): number {
if (n < 0 || !Number.isInteger(n)) throw new Error('Factorial of non-integer');
if (n > 170) return Infinity;
let result = 1;
for (let i = 2; i <= n; i++) result *= i;
return result;
}
function applyOp(op: string, a: number, b: number): number {
switch (op) {
case '+':
return a + b;
case '-':
return a - b;
case '*':
return a * b;
case '/':
if (b === 0) throw new Error('Division by zero');
return a / b;
case '%':
return a % b;
case '^':
return Math.pow(a, b);
default:
throw new Error(`Unknown op: ${op}`);
}
}
/**
* Evaluate a mathematical expression string.
* Returns the numeric result or throws on error.
*/
export function evaluate(expression: string): number {
const tokens = tokenize(expression);
const output: number[] = [];
const ops: Token[] = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === 'number') {
output.push(token.value);
} else if (token.type === 'func') {
ops.push(token);
} else if (token.type === 'op') {
if (token.value === '!') {
const val = output.pop();
if (val === undefined) throw new Error('Missing operand');
output.push(factorial(val));
} else {
while (
ops.length > 0 &&
ops[ops.length - 1].type === 'op' &&
precedence(ops[ops.length - 1].value as string) >= precedence(token.value)
) {
const op = ops.pop()!;
const b = output.pop()!;
const a = output.pop()!;
output.push(applyOp(op.value as string, a, b));
}
ops.push(token);
}
} else if (token.type === 'paren' && token.value === '(') {
ops.push(token);
} else if (token.type === 'paren' && token.value === ')') {
while (
ops.length > 0 &&
!(ops[ops.length - 1].type === 'paren' && ops[ops.length - 1].value === '(')
) {
const op = ops.pop()!;
const b = output.pop()!;
const a = output.pop()!;
output.push(applyOp(op.value as string, a, b));
}
ops.pop(); // remove '('
// If there's a function on the stack, apply it
if (ops.length > 0 && ops[ops.length - 1].type === 'func') {
const func = ops.pop()!;
const val = output.pop()!;
output.push(FUNCTIONS[func.value as string](val));
}
}
}
while (ops.length > 0) {
const op = ops.pop()!;
const b = output.pop()!;
const a = output.pop()!;
output.push(applyOp(op.value as string, a, b));
}
if (output.length !== 1) throw new Error('Invalid expression');
return output[0];
}
/**
* Format a number for display removes trailing zeros, handles very large/small numbers.
*/
export function formatResult(value: number, precision: number = 10): string {
if (!isFinite(value)) return value > 0 ? '∞' : '-∞';
if (isNaN(value)) return 'NaN';
// Use scientific notation for very large/small numbers
if (Math.abs(value) > 1e15 || (Math.abs(value) < 1e-10 && value !== 0)) {
return value.toExponential(precision - 1);
}
// Round to precision and strip trailing zeros
const result = parseFloat(value.toPrecision(precision));
return String(result);
}
/**
* Convert between number bases.
*/
export function convertBase(value: string, fromBase: number, toBase: number): string {
const decimal = parseInt(value, fromBase);
if (isNaN(decimal)) throw new Error('Invalid number');
return decimal.toString(toBase).toUpperCase();
}

View file

@ -0,0 +1,9 @@
/**
* Calc module barrel exports.
*/
export { calculationsStore } from './stores/calculations.svelte';
export { savedFormulasStore } from './stores/saved-formulas.svelte';
export { useAllCalculations, useAllSavedFormulas, toCalculation, toSavedFormula } from './queries';
export { calculationTable, savedFormulaTable, CALC_GUEST_SEED } from './collections';
export type { LocalCalculation, LocalSavedFormula } from './types';

View file

@ -0,0 +1,56 @@
/**
* Reactive queries for Calc uses Dexie liveQuery on the unified DB.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalCalculation, LocalSavedFormula } from './types';
import type { Calculation, SavedFormula } from '@calc/shared';
// ─── Type Converters ───────────────────────────────────────
export function toCalculation(local: LocalCalculation): Calculation {
return {
id: local.id,
userId: 'local',
mode: local.mode,
expression: local.expression,
result: local.result,
skin: local.skin,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
export function toSavedFormula(local: LocalSavedFormula): SavedFormula {
return {
id: local.id,
userId: 'local',
name: local.name,
expression: local.expression,
description: local.description ?? undefined,
mode: local.mode,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
/** All calculations (history), newest first. */
export function useAllCalculations() {
return liveQuery(async () => {
const locals = await db.table<LocalCalculation>('calculations').toArray();
return locals
.filter((c) => !c.deletedAt)
.map(toCalculation)
.reverse();
});
}
/** All saved formulas. */
export function useAllSavedFormulas() {
return liveQuery(async () => {
const locals = await db.table<LocalSavedFormula>('savedFormulas').toArray();
return locals.filter((f) => !f.deletedAt).map(toSavedFormula);
});
}

View file

@ -0,0 +1,37 @@
/**
* Calculation mutation store write operations for the unified DB.
*/
import { db } from '$lib/data/database';
import type { LocalCalculation } from '../types';
import type { CreateCalculationInput } from '@calc/shared';
export const calculationsStore = {
async addCalculation(input: CreateCalculationInput) {
await db.table<LocalCalculation>('calculations').add({
id: crypto.randomUUID(),
mode: input.mode,
expression: input.expression,
result: input.result,
skin: input.skin,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async deleteCalculation(id: string) {
await db.table('calculations').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async clearHistory() {
const now = new Date().toISOString();
const all = await db.table<LocalCalculation>('calculations').toArray();
const active = all.filter((c) => !c.deletedAt);
await Promise.all(
active.map((c) => db.table('calculations').update(c.id, { deletedAt: now, updatedAt: now }))
);
},
};

View file

@ -0,0 +1,35 @@
/**
* Saved formula mutation store write operations for the unified DB.
*/
import { db } from '$lib/data/database';
import type { LocalSavedFormula } from '../types';
import type { CreateFormulaInput, UpdateFormulaInput } from '@calc/shared';
export const savedFormulasStore = {
async saveFormula(input: CreateFormulaInput) {
await db.table<LocalSavedFormula>('savedFormulas').add({
id: crypto.randomUUID(),
name: input.name,
expression: input.expression,
description: input.description ?? null,
mode: input.mode,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async updateFormula(id: string, input: UpdateFormulaInput) {
await db.table('savedFormulas').update(id, {
...input,
updatedAt: new Date().toISOString(),
});
},
async deleteFormula(id: string) {
await db.table('savedFormulas').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,20 @@
/**
* Calc module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
import type { CalculatorMode, CalculatorSkin } from '@calc/shared';
export interface LocalCalculation extends BaseRecord {
mode: CalculatorMode;
expression: string;
result: string;
skin?: CalculatorSkin;
}
export interface LocalSavedFormula extends BaseRecord {
name: string;
expression: string;
description: string | null;
mode: CalculatorMode;
}

View file

@ -0,0 +1,117 @@
<script lang="ts">
import {
Calculator,
Flask,
Code,
Ruler,
Coins,
PiggyBank,
Calendar,
Percent,
} from '@manacore/shared-icons';
const quickLinks = [
{
href: '/calc/standard',
icon: Calculator,
label: 'Standard',
description: 'Grundrechenarten',
color: 'bg-pink-500',
},
{
href: '/calc/scientific',
icon: Flask,
label: 'Wissenschaftlich',
description: 'sin, cos, log & mehr',
color: 'bg-violet-500',
},
{
href: '/calc/programmer',
icon: Code,
label: 'Programmierer',
description: 'HEX, BIN, OCT',
color: 'bg-cyan-500',
},
{
href: '/calc/converter',
icon: Ruler,
label: 'Einheiten',
description: 'Umrechnen',
color: 'bg-emerald-500',
},
{
href: '/calc/currency',
icon: Coins,
label: 'Währung',
description: 'Wechselkurse',
color: 'bg-amber-500',
},
{
href: '/calc/finance',
icon: PiggyBank,
label: 'Finanzen',
description: 'Zins & Kredit',
color: 'bg-blue-500',
},
{
href: '/calc/date',
icon: Calendar,
label: 'Datum',
description: 'Tage berechnen',
color: 'bg-orange-500',
},
{
href: '/calc/percentage',
icon: Percent,
label: 'Prozent & Trinkgeld',
description: 'Aufteilen & Berechnen',
color: 'bg-rose-500',
},
];
</script>
<svelte:head>
<title>Calc - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Calc</h1>
<p class="text-muted-foreground mt-1 text-sm">Dein Taschenrechner-Hub</p>
</header>
<!-- Quick display -->
<div class="mb-8 rounded-xl border border-border bg-card p-6">
<div class="flex items-center gap-4">
<div class="rounded-full bg-primary/10 p-3">
<Calculator size={32} class="text-primary" />
</div>
<div>
<div class="text-4xl font-bold tabular-nums font-mono text-foreground">0</div>
<div class="text-muted-foreground text-sm">Wähle einen Rechner-Modus</div>
</div>
</div>
</div>
<!-- Quick Links Grid -->
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
{#each quickLinks as link}
<a
href={link.href}
class="rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-lg group"
>
<div class="flex flex-col items-center gap-3 text-center">
<div
class="{link.color} rounded-full p-3 text-white transition-transform group-hover:scale-110"
>
<link.icon size={24} />
</div>
<div>
<div class="font-medium text-foreground">{link.label}</div>
<div class="text-xs text-muted-foreground">{link.description}</div>
</div>
</div>
</a>
{/each}
</div>
</div>

View file

@ -0,0 +1,267 @@
<script lang="ts">
import { evaluate, formatResult } from '$lib/modules/calc/engine/evaluate';
import { calculationsStore } from '$lib/modules/calc/stores/calculations.svelte';
import { useAllCalculations } from '$lib/modules/calc/queries';
import { CALCULATOR_SKINS } from '@calc/shared/constants';
import type { CalculatorSkin } from '@calc/shared';
import {
ModernSkin,
HP35Skin,
CasioSkin,
TI84Skin,
MinimalSkin,
} from '$lib/modules/calc/components/skins';
const allCalculations = useAllCalculations();
let expression = $state('');
let display = $state('0');
let hasResult = $state(false);
let error = $state('');
let copied = $state(false);
async function copyToClipboard() {
if (display === '0' || error) return;
try {
await navigator.clipboard.writeText(display);
copied = true;
setTimeout(() => (copied = false), 1500);
} catch {}
}
let activeSkin = $state<CalculatorSkin>('modern');
let showSkinPicker = $state(false);
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('calc-skin');
if (saved && CALCULATOR_SKINS.some((s) => s.id === saved)) {
activeSkin = saved as CalculatorSkin;
}
}
function setSkin(skin: CalculatorSkin) {
activeSkin = skin;
showSkinPicker = false;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('calc-skin', skin);
}
}
function appendToExpression(char: string) {
if (hasResult) {
if (/[0-9.]/.test(char)) {
expression = '';
display = '';
hasResult = false;
} else {
expression = display;
hasResult = false;
}
}
error = '';
expression += char;
display = expression;
}
function clear() {
expression = '';
display = '0';
hasResult = false;
error = '';
}
function backspace() {
if (hasResult) {
clear();
return;
}
expression = expression.slice(0, -1);
display = expression || '0';
}
async function calculate() {
if (!expression.trim()) return;
try {
const result = evaluate(expression);
const formatted = formatResult(result);
await calculationsStore.addCalculation({
mode: 'standard',
expression: expression,
result: formatted,
skin: activeSkin,
});
display = formatted;
hasResult = true;
error = '';
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler';
}
}
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
if (event.metaKey || event.ctrlKey) return;
if (/^[0-9.]$/.test(event.key)) {
event.preventDefault();
appendToExpression(event.key);
} else if (['+', '-', '*', '/', '%', '^'].includes(event.key)) {
event.preventDefault();
appendToExpression(event.key);
} else if (event.key === '(' || event.key === ')') {
event.preventDefault();
appendToExpression(event.key);
} else if (event.key === 'Enter' || event.key === '=') {
event.preventDefault();
calculate();
} else if (event.key === 'Backspace') {
event.preventDefault();
backspace();
} else if (event.key === 'Escape') {
event.preventDefault();
if (showSkinPicker) showSkinPicker = false;
else clear();
}
}
let recentHistory = $derived(
($allCalculations ?? []).filter((c) => c.mode === 'standard').slice(0, 10)
);
let skinProps = $derived({
expression,
display,
error,
copied,
onButton: appendToExpression,
onClear: clear,
onBackspace: backspace,
onEquals: calculate,
onCopy: copyToClipboard,
});
</script>
<svelte:head>
<title>Calc - Standard | ManaCore</title>
</svelte:head>
<svelte:window onkeydown={handleKeydown} />
<div class="calculator-page">
<div class="calculator-column">
<!-- Skin picker toggle -->
<div class="mb-3 flex items-center justify-between">
<button
class="text-xs rounded-full border px-3 py-1.5 transition-all
{showSkinPicker
? 'border-pink-500 bg-pink-500 text-white'
: 'border-border bg-card text-muted-foreground hover:bg-muted'}"
onclick={() => (showSkinPicker = !showSkinPicker)}
>
🎨 {CALCULATOR_SKINS.find((s) => s.id === activeSkin)?.label || 'Modern'}
{#if CALCULATOR_SKINS.find((s) => s.id === activeSkin)?.year}
<span class="opacity-60">({CALCULATOR_SKINS.find((s) => s.id === activeSkin)?.year})</span
>
{/if}
</button>
</div>
<!-- Skin picker panel -->
{#if showSkinPicker}
<div class="mb-4 rounded-xl border border-border bg-card p-3">
<div class="grid grid-cols-5 gap-2">
{#each CALCULATOR_SKINS as skin}
<button
class="rounded-lg border p-2 text-center transition-all
{activeSkin === skin.id ? 'border-pink-500 bg-pink-500/10' : 'border-transparent hover:bg-muted'}"
onclick={() => setSkin(skin.id)}
>
<div class="text-sm font-medium text-foreground">{skin.label}</div>
{#if skin.year}
<div class="text-xs text-muted-foreground">{skin.year}</div>
{/if}
</button>
{/each}
</div>
</div>
{/if}
<!-- Active Skin -->
{#if activeSkin === 'modern'}
<ModernSkin {...skinProps} />
{:else if activeSkin === 'hp35'}
<HP35Skin {...skinProps} />
{:else if activeSkin === 'casio-fx'}
<CasioSkin {...skinProps} />
{:else if activeSkin === 'ti84'}
<TI84Skin {...skinProps} />
{:else if activeSkin === 'minimal'}
<MinimalSkin {...skinProps} />
{/if}
</div>
<!-- History Sidebar -->
<div class="history">
<h3 class="mb-3 text-sm font-medium text-muted-foreground">Verlauf</h3>
{#if recentHistory.length === 0}
<p class="text-xs text-muted-foreground/60">Noch keine Berechnungen</p>
{:else}
<div class="space-y-2">
{#each recentHistory as calc}
<button
class="w-full rounded-lg p-2 text-left transition-colors hover:bg-muted/50 group"
onclick={() => {
expression = calc.result;
display = calc.result;
hasResult = true;
}}
>
<div class="truncate font-mono text-xs text-muted-foreground">{calc.expression}</div>
<div class="font-mono text-sm font-medium text-foreground">= {calc.result}</div>
</button>
{/each}
</div>
<button
class="mt-3 text-xs text-muted-foreground transition-colors hover:text-foreground"
onclick={() => calculationsStore.clearHistory()}
>
Verlauf löschen
</button>
{/if}
</div>
</div>
<style>
.calculator-page {
max-width: 750px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 200px;
gap: 2rem;
align-items: start;
}
.calculator-column {
max-width: 400px;
}
@media (max-width: 640px) {
.calculator-page {
grid-template-columns: 1fr;
}
.calculator-column {
max-width: 100%;
}
.history {
order: -1;
max-height: 120px;
overflow-y: auto;
}
}
</style>