mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
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:
parent
9aedc89ce5
commit
d3807b4bea
17 changed files with 2179 additions and 0 deletions
231
apps/manacore/apps/web/src/lib/data/database.ts
Normal file
231
apps/manacore/apps/web/src/lib/data/database.ts
Normal 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]))
|
||||
);
|
||||
36
apps/manacore/apps/web/src/lib/modules/calc/collections.ts
Normal file
36
apps/manacore/apps/web/src/lib/modules/calc/collections.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
261
apps/manacore/apps/web/src/lib/modules/calc/engine/evaluate.ts
Normal file
261
apps/manacore/apps/web/src/lib/modules/calc/engine/evaluate.ts
Normal 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();
|
||||
}
|
||||
9
apps/manacore/apps/web/src/lib/modules/calc/index.ts
Normal file
9
apps/manacore/apps/web/src/lib/modules/calc/index.ts
Normal 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';
|
||||
56
apps/manacore/apps/web/src/lib/modules/calc/queries.ts
Normal file
56
apps/manacore/apps/web/src/lib/modules/calc/queries.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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 }))
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
20
apps/manacore/apps/web/src/lib/modules/calc/types.ts
Normal file
20
apps/manacore/apps/web/src/lib/modules/calc/types.ts
Normal 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;
|
||||
}
|
||||
117
apps/manacore/apps/web/src/routes/(app)/calc/+page.svelte
Normal file
117
apps/manacore/apps/web/src/routes/(app)/calc/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue