mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(calc): add copy-to-clipboard, skins in scientific, Dockerfile, settings
- Copy-to-clipboard button on all 5 skins (Modern, HP-35, Casio, TI-84, Minimal) - Scientific calculator now supports skin switching like standard mode - Production Dockerfile using sveltekit-base (port 5018) - Real settings page: default mode/skin, decimal places, thousands separator, angle mode, history size, keyboard hints, shortcuts reference Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
49df3ead09
commit
0841f6b334
11 changed files with 709 additions and 97 deletions
30
apps/calc/apps/web/Dockerfile
Normal file
30
apps/calc/apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM sveltekit-base:local AS builder
|
||||
|
||||
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-auth:3001
|
||||
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
||||
|
||||
COPY apps/calc/apps/web ./apps/calc/apps/web
|
||||
COPY apps/calc/packages ./apps/calc/packages
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --no-frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app/apps/calc/apps/web
|
||||
RUN pnpm exec svelte-kit sync
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
WORKDIR /app/apps/calc/apps/web
|
||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
COPY --from=builder /app/apps/calc/apps/web/node_modules ./node_modules
|
||||
COPY --from=builder /app/apps/calc/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/calc/apps/web/package.json ./
|
||||
|
||||
EXPOSE 5018
|
||||
ENV NODE_ENV=production PORT=5018 HOST=0.0.0.0
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5018/health || exit 1
|
||||
|
||||
CMD ["node", "build"]
|
||||
|
|
@ -1,8 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { CalcSkinProps } from './types';
|
||||
|
||||
let { expression, display, error, onButton, onClear, onBackspace, onEquals }: CalcSkinProps =
|
||||
$props();
|
||||
let {
|
||||
expression,
|
||||
display,
|
||||
error,
|
||||
copied,
|
||||
onButton,
|
||||
onClear,
|
||||
onBackspace,
|
||||
onEquals,
|
||||
onCopy,
|
||||
}: CalcSkinProps = $props();
|
||||
|
||||
const buttons = [
|
||||
['C', '(', ')', '%'],
|
||||
|
|
@ -41,8 +50,15 @@
|
|||
<!-- LCD Display (green-gray) -->
|
||||
<div class="casio-display">
|
||||
<div class="casio-expression">{expression || ' '}</div>
|
||||
<div class="casio-result" class:casio-error={!!error}>
|
||||
{error || display}
|
||||
<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>
|
||||
|
||||
|
|
@ -166,6 +182,20 @@
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { CalcSkinProps } from './types';
|
||||
|
||||
let { expression, display, error, onButton, onClear, onBackspace, onEquals }: CalcSkinProps =
|
||||
$props();
|
||||
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 = [
|
||||
|
|
@ -36,8 +45,15 @@
|
|||
<!-- LED Display (red on dark) -->
|
||||
<div class="hp35-display">
|
||||
<div class="hp35-expression">{expression || ' '}</div>
|
||||
<div class="hp35-result" class:hp35-error={!!error}>
|
||||
{error || display}
|
||||
<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>
|
||||
|
||||
|
|
@ -203,6 +219,20 @@
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { CalcSkinProps } from './types';
|
||||
|
||||
let { expression, display, error, onButton, onClear, onBackspace, onEquals }: CalcSkinProps =
|
||||
$props();
|
||||
let {
|
||||
expression,
|
||||
display,
|
||||
error,
|
||||
copied,
|
||||
onButton,
|
||||
onClear,
|
||||
onBackspace,
|
||||
onEquals,
|
||||
onCopy,
|
||||
}: CalcSkinProps = $props();
|
||||
|
||||
const buttons = [
|
||||
['C', '(', ')', '%'],
|
||||
|
|
@ -23,8 +32,15 @@
|
|||
<!-- Display: just big text -->
|
||||
<div class="minimal-display">
|
||||
<div class="minimal-expression">{expression || ' '}</div>
|
||||
<div class="minimal-result" class:minimal-error={!!error}>
|
||||
{error || display}
|
||||
<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>
|
||||
|
||||
|
|
@ -142,6 +158,20 @@
|
|||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { CalcSkinProps } from './types';
|
||||
|
||||
let { expression, display, error, onButton, onClear, onBackspace, onEquals }: CalcSkinProps =
|
||||
$props();
|
||||
let {
|
||||
expression,
|
||||
display,
|
||||
error,
|
||||
copied,
|
||||
onButton,
|
||||
onClear,
|
||||
onBackspace,
|
||||
onEquals,
|
||||
onCopy,
|
||||
}: CalcSkinProps = $props();
|
||||
|
||||
const buttons = [
|
||||
['C', '(', ')', '%'],
|
||||
|
|
@ -32,11 +41,22 @@
|
|||
<div class="text-sm text-muted-foreground min-h-[1.5rem] font-mono truncate">
|
||||
{expression || ' '}
|
||||
</div>
|
||||
<div
|
||||
class="text-4xl font-bold text-foreground font-mono text-right tabular-nums truncate"
|
||||
class:text-red-400={!!error}
|
||||
>
|
||||
{error || display}
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,17 @@
|
|||
<script lang="ts">
|
||||
import type { CalcSkinProps } from './types';
|
||||
|
||||
let { expression, display, error, onButton, onClear, onBackspace, onEquals }: CalcSkinProps =
|
||||
$props();
|
||||
let {
|
||||
expression,
|
||||
display,
|
||||
error,
|
||||
copied,
|
||||
onButton,
|
||||
onClear,
|
||||
onBackspace,
|
||||
onEquals,
|
||||
onCopy,
|
||||
}: CalcSkinProps = $props();
|
||||
|
||||
const buttons = [
|
||||
['C', '(', ')', '%'],
|
||||
|
|
@ -35,8 +44,15 @@
|
|||
<div class="ti84-screen">
|
||||
<div class="ti84-screen-inner">
|
||||
<div class="ti84-expression">{expression || ' '}</div>
|
||||
<div class="ti84-result" class:ti84-error={!!error}>
|
||||
{error || display}
|
||||
<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>
|
||||
|
|
@ -157,6 +173,20 @@
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ export interface CalcSkinProps {
|
|||
expression: string;
|
||||
display: string;
|
||||
error: string;
|
||||
copied: boolean;
|
||||
onButton: (btn: string) => void;
|
||||
onClear: () => void;
|
||||
onBackspace: () => void;
|
||||
onEquals: () => void;
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
|
|
|||
65
apps/calc/apps/web/src/lib/stores/calc-settings.svelte.ts
Normal file
65
apps/calc/apps/web/src/lib/stores/calc-settings.svelte.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
/**
|
||||
* Calc-specific settings — persisted to localStorage.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import type { CalculatorMode, CalculatorSkin } from '@calc/shared';
|
||||
|
||||
const STORAGE_KEY = 'calc-settings';
|
||||
|
||||
interface CalcSettings {
|
||||
defaultMode: CalculatorMode;
|
||||
defaultSkin: CalculatorSkin;
|
||||
decimalPlaces: number;
|
||||
thousandsSeparator: boolean;
|
||||
angleMode: 'deg' | 'rad';
|
||||
historySize: number;
|
||||
showKeyboardHints: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: CalcSettings = {
|
||||
defaultMode: 'standard',
|
||||
defaultSkin: 'modern',
|
||||
decimalPlaces: 10,
|
||||
thousandsSeparator: false,
|
||||
angleMode: 'rad',
|
||||
historySize: 50,
|
||||
showKeyboardHints: true,
|
||||
};
|
||||
|
||||
function load(): CalcSettings {
|
||||
if (!browser) return { ...DEFAULTS };
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return { ...DEFAULTS, ...JSON.parse(raw) };
|
||||
} catch {}
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
|
||||
function save(settings: CalcSettings) {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
// Reactive settings store using Svelte 5 runes
|
||||
let current = $state<CalcSettings>(load());
|
||||
|
||||
export const calcSettings = {
|
||||
get value() {
|
||||
return current;
|
||||
},
|
||||
|
||||
update(partial: Partial<CalcSettings>) {
|
||||
current = { ...current, ...partial };
|
||||
save(current);
|
||||
},
|
||||
|
||||
reset() {
|
||||
current = { ...DEFAULTS };
|
||||
save(current);
|
||||
},
|
||||
|
||||
get defaults() {
|
||||
return DEFAULTS;
|
||||
},
|
||||
};
|
||||
|
|
@ -2,8 +2,9 @@
|
|||
import { getContext } from 'svelte';
|
||||
import { evaluate, formatResult } from '$lib/engine/evaluate';
|
||||
import { calculationsStore } from '$lib/stores/calculations.svelte';
|
||||
import { SCIENTIFIC_CONSTANTS } from '@calc/shared/constants';
|
||||
import type { Calculation } from '@calc/shared';
|
||||
import { CALCULATOR_SKINS, SCIENTIFIC_CONSTANTS } from '@calc/shared/constants';
|
||||
import type { CalculatorSkin, Calculation } from '@calc/shared';
|
||||
import { ModernSkin, HP35Skin, CasioSkin, TI84Skin, MinimalSkin } from '$lib/components/skins';
|
||||
|
||||
const allCalculations = getContext<{ value: Calculation[] }>('calculations');
|
||||
|
||||
|
|
@ -11,7 +12,37 @@
|
|||
let display = $state('0');
|
||||
let hasResult = $state(false);
|
||||
let error = $state('');
|
||||
let copied = $state(false);
|
||||
let angleMode = $state<'deg' | 'rad'>('rad');
|
||||
let showExtraKeys = $state(true);
|
||||
|
||||
// Skin state
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard() {
|
||||
if (display === '0' || error) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(display);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function append(char: string) {
|
||||
if (hasResult && /[0-9.]/.test(char)) {
|
||||
|
|
@ -52,6 +83,7 @@
|
|||
mode: 'scientific',
|
||||
expression,
|
||||
result: formatted,
|
||||
skin: activeSkin,
|
||||
});
|
||||
display = formatted;
|
||||
hasResult = true;
|
||||
|
|
@ -61,37 +93,32 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleButton(btn: string) {
|
||||
if (btn === 'C') clear();
|
||||
else if (btn === '=') calculate();
|
||||
else if (btn === 'DEL') backspace();
|
||||
else append(btn);
|
||||
}
|
||||
|
||||
let recentHistory = $derived(
|
||||
allCalculations.value.filter((c) => c.mode === 'scientific').slice(0, 8)
|
||||
);
|
||||
|
||||
const sciButtons = [
|
||||
let skinProps = $derived({
|
||||
expression,
|
||||
display,
|
||||
error,
|
||||
copied,
|
||||
onButton: append,
|
||||
onClear: clear,
|
||||
onBackspace: backspace,
|
||||
onEquals: calculate,
|
||||
onCopy: copyToClipboard,
|
||||
});
|
||||
|
||||
const sciExtraButtons = [
|
||||
['sin(', 'cos(', 'tan(', 'π'],
|
||||
['asin(', 'acos(', 'atan(', 'e'],
|
||||
['log(', 'ln(', 'sqrt(', '^'],
|
||||
['(', ')', '!', '%'],
|
||||
['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';
|
||||
function getSciButtonClass(btn: string): string {
|
||||
if (['sin(', 'cos(', 'tan(', 'asin(', 'acos(', 'atan(', 'log(', 'ln(', 'sqrt('].includes(btn))
|
||||
return 'bg-violet-500/20 text-violet-400 hover:bg-violet-500/30 text-xs';
|
||||
if (['π', 'e', '^', '!', '%'].includes(btn))
|
||||
return 'bg-muted text-foreground hover:bg-muted/80';
|
||||
if (['+', '-', '*', '/', '(', ')'].includes(btn))
|
||||
return 'bg-muted text-foreground hover:bg-muted/80';
|
||||
return 'bg-card text-foreground hover:bg-card/80';
|
||||
return 'bg-muted text-foreground hover:bg-muted/80';
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -100,65 +127,101 @@
|
|||
</svelte:head>
|
||||
|
||||
<div class="scientific-page">
|
||||
<div class="calculator">
|
||||
<div class="display rounded-xl bg-card border border-border p-4 mb-4">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="text-xs text-muted-foreground font-mono truncate flex-1"
|
||||
>{expression || ' '}</span
|
||||
>
|
||||
<div class="calculator-column">
|
||||
<!-- Skin picker + angle mode -->
|
||||
<div class="flex items-center justify-between mb-3 gap-2">
|
||||
<button
|
||||
class="skin-toggle text-xs px-3 py-1.5 rounded-full border transition-all
|
||||
{showSkinPicker
|
||||
? 'bg-pink-500 text-white border-pink-500'
|
||||
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
|
||||
onclick={() => (showSkinPicker = !showSkinPicker)}
|
||||
>
|
||||
🎨 {CALCULATOR_SKINS.find((s) => s.id === activeSkin)?.label || 'Modern'}
|
||||
</button>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="text-xs px-2 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
class="text-xs px-2 py-1 rounded bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
onclick={() => (angleMode = angleMode === 'rad' ? 'deg' : 'rad')}
|
||||
>
|
||||
{angleMode.toUpperCase()}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="text-3xl font-bold text-foreground font-mono text-right tabular-nums truncate"
|
||||
class:text-red-400={!!error}
|
||||
>
|
||||
{error || display}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Constants quick insert -->
|
||||
<div class="flex gap-1 mb-3 overflow-x-auto pb-1">
|
||||
{#each SCIENTIFIC_CONSTANTS.slice(0, 6) as constant}
|
||||
<button
|
||||
class="shrink-0 text-xs px-2 py-1 rounded-lg bg-muted/50 text-muted-foreground hover:bg-muted transition-colors"
|
||||
onclick={() => append(String(constant.value))}
|
||||
title={constant.name}
|
||||
class="text-xs px-2 py-1 rounded transition-all {showExtraKeys
|
||||
? 'bg-violet-500 text-white'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||
onclick={() => (showExtraKeys = !showExtraKeys)}
|
||||
>
|
||||
{constant.symbol}
|
||||
f(x)
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-1.5">
|
||||
{#each sciButtons as row}
|
||||
{#each row as btn}
|
||||
<button
|
||||
class="calc-btn h-12 rounded-lg border border-border transition-all active:scale-95 {getButtonClass(
|
||||
btn
|
||||
)}"
|
||||
onclick={() => handleButton(btn)}
|
||||
>
|
||||
{btn === '/' ? '÷' : btn === '*' ? '×' : btn}
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{#if showSkinPicker}
|
||||
<div class="skin-picker mb-4 p-3 rounded-xl bg-card border border-border">
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
{#each CALCULATOR_SKINS as skin}
|
||||
<button
|
||||
class="skin-option p-2 rounded-lg text-center transition-all border
|
||||
{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}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button
|
||||
class="flex-1 h-9 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 text-sm"
|
||||
onclick={clear}>C</button
|
||||
>
|
||||
<button
|
||||
class="flex-1 h-9 rounded-lg bg-muted/50 text-muted-foreground hover:bg-muted text-sm"
|
||||
onclick={backspace}>← DEL</button
|
||||
>
|
||||
</div>
|
||||
<!-- Scientific extra keys (above the skin) -->
|
||||
{#if showExtraKeys}
|
||||
<div class="mb-3 space-y-1.5">
|
||||
<!-- Constants -->
|
||||
<div class="flex gap-1 overflow-x-auto pb-1">
|
||||
{#each SCIENTIFIC_CONSTANTS.slice(0, 6) as constant}
|
||||
<button
|
||||
class="shrink-0 text-xs px-2 py-1 rounded-lg bg-muted/50 text-muted-foreground hover:bg-muted transition-colors"
|
||||
onclick={() => append(String(constant.value))}
|
||||
title={constant.name}
|
||||
>
|
||||
{constant.symbol}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Function buttons -->
|
||||
<div class="grid grid-cols-4 gap-1.5">
|
||||
{#each sciExtraButtons as row}
|
||||
{#each row as btn}
|
||||
<button
|
||||
class="h-9 rounded-lg border border-border transition-all active:scale-95 {getSciButtonClass(
|
||||
btn
|
||||
)}"
|
||||
onclick={() => append(btn)}
|
||||
>
|
||||
{btn}
|
||||
</button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Active Skin (handles standard buttons: 0-9, +-*/%, =, C, backspace) -->
|
||||
{#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 -->
|
||||
|
|
@ -188,7 +251,7 @@
|
|||
|
||||
<style>
|
||||
.scientific-page {
|
||||
max-width: 700px;
|
||||
max-width: 750px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 180px;
|
||||
|
|
@ -196,15 +259,15 @@
|
|||
align-items: start;
|
||||
}
|
||||
|
||||
.calculator {
|
||||
max-width: 380px;
|
||||
.calculator-column {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.scientific-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.calculator {
|
||||
.calculator-column {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,300 @@
|
|||
<script lang="ts">
|
||||
import { calcSettings } from '$lib/stores/calc-settings.svelte';
|
||||
import { CALCULATOR_SKINS, CALCULATOR_MODES } from '@calc/shared/constants';
|
||||
import type { CalculatorMode, CalculatorSkin } from '@calc/shared';
|
||||
|
||||
let settings = $derived(calcSettings.value);
|
||||
|
||||
function updateDefaultMode(mode: CalculatorMode) {
|
||||
calcSettings.update({ defaultMode: mode });
|
||||
}
|
||||
|
||||
function updateDefaultSkin(skin: CalculatorSkin) {
|
||||
calcSettings.update({ defaultSkin: skin });
|
||||
// Also update the shared localStorage key
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('calc-skin', skin);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDecimalPlaces(event: Event) {
|
||||
const val = parseInt((event.target as HTMLInputElement).value);
|
||||
if (val >= 1 && val <= 15) {
|
||||
calcSettings.update({ decimalPlaces: val });
|
||||
}
|
||||
}
|
||||
|
||||
function updateHistorySize(event: Event) {
|
||||
const val = parseInt((event.target as HTMLInputElement).value);
|
||||
if (val >= 10 && val <= 500) {
|
||||
calcSettings.update({ historySize: val });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleThousandsSeparator() {
|
||||
calcSettings.update({ thousandsSeparator: !settings.thousandsSeparator });
|
||||
}
|
||||
|
||||
function toggleAngleMode() {
|
||||
calcSettings.update({ angleMode: settings.angleMode === 'rad' ? 'deg' : 'rad' });
|
||||
}
|
||||
|
||||
function toggleKeyboardHints() {
|
||||
calcSettings.update({ showKeyboardHints: !settings.showKeyboardHints });
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
calcSettings.reset();
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('calc-skin', 'modern');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - Einstellungen</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="settings-page">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-foreground">Einstellungen</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">Passe Calc an deine Bedürfnisse an</p>
|
||||
</header>
|
||||
|
||||
<!-- General -->
|
||||
<section class="settings-section">
|
||||
<h2 class="text-lg font-bold text-foreground mb-4">Allgemein</h2>
|
||||
|
||||
<!-- Default Mode -->
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Standard-Modus</div>
|
||||
<div class="setting-desc">Wird beim Öffnen der App gezeigt</div>
|
||||
</div>
|
||||
<select
|
||||
value={settings.defaultMode}
|
||||
onchange={(e) => updateDefaultMode((e.target as HTMLSelectElement).value as CalculatorMode)}
|
||||
class="h-9 px-3 rounded-lg bg-background border border-border text-foreground text-sm"
|
||||
>
|
||||
{#each CALCULATOR_MODES as mode}
|
||||
<option value={mode.id}>{mode.label.de}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Default Skin -->
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Standard-Skin</div>
|
||||
<div class="setting-desc">Look des Rechners</div>
|
||||
</div>
|
||||
<select
|
||||
value={settings.defaultSkin}
|
||||
onchange={(e) => updateDefaultSkin((e.target as HTMLSelectElement).value as CalculatorSkin)}
|
||||
class="h-9 px-3 rounded-lg bg-background border border-border text-foreground text-sm"
|
||||
>
|
||||
{#each CALCULATOR_SKINS as skin}
|
||||
<option value={skin.id}>{skin.label}{skin.year ? ` (${skin.year})` : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Calculation -->
|
||||
<section class="settings-section">
|
||||
<h2 class="text-lg font-bold text-foreground mb-4">Berechnung</h2>
|
||||
|
||||
<!-- Decimal Places -->
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Dezimalstellen</div>
|
||||
<div class="setting-desc">Genauigkeit der Ergebnisse (1–15)</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="15"
|
||||
value={settings.decimalPlaces}
|
||||
oninput={updateDecimalPlaces}
|
||||
class="w-24"
|
||||
/>
|
||||
<span class="text-sm font-mono text-foreground w-6 text-center"
|
||||
>{settings.decimalPlaces}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thousands Separator -->
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Tausendertrennzeichen</div>
|
||||
<div class="setting-desc">1.000.000 statt 1000000</div>
|
||||
</div>
|
||||
<button
|
||||
class="toggle-btn {settings.thousandsSeparator ? 'toggle-on' : 'toggle-off'}"
|
||||
onclick={toggleThousandsSeparator}
|
||||
role="switch"
|
||||
aria-checked={settings.thousandsSeparator}
|
||||
>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Angle Mode -->
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Winkel-Modus</div>
|
||||
<div class="setting-desc">Für sin, cos, tan im wissenschaftlichen Rechner</div>
|
||||
</div>
|
||||
<button
|
||||
class="h-9 px-4 rounded-lg border transition-all text-sm font-medium
|
||||
{settings.angleMode === 'rad'
|
||||
? 'bg-violet-500 text-white border-violet-500'
|
||||
: 'bg-amber-500 text-white border-amber-500'}"
|
||||
onclick={toggleAngleMode}
|
||||
>
|
||||
{settings.angleMode === 'rad' ? 'Radiant' : 'Grad'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Display -->
|
||||
<section class="settings-section">
|
||||
<h2 class="text-lg font-bold text-foreground mb-4">Anzeige</h2>
|
||||
|
||||
<!-- History Size -->
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Verlauf-Größe</div>
|
||||
<div class="setting-desc">Maximale Anzahl gespeicherter Berechnungen</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
step="10"
|
||||
value={settings.historySize}
|
||||
oninput={updateHistorySize}
|
||||
class="w-20 h-9 px-2 rounded-lg bg-background border border-border text-foreground font-mono text-sm text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keyboard Hints -->
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Tastatur-Hinweise</div>
|
||||
<div class="setting-desc">Zeige Keyboard-Shortcuts in der UI</div>
|
||||
</div>
|
||||
<button
|
||||
class="toggle-btn {settings.showKeyboardHints ? 'toggle-on' : 'toggle-off'}"
|
||||
onclick={toggleKeyboardHints}
|
||||
role="switch"
|
||||
aria-checked={settings.showKeyboardHints}
|
||||
>
|
||||
<span class="toggle-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Keyboard shortcuts reference -->
|
||||
<section class="settings-section">
|
||||
<h2 class="text-lg font-bold text-foreground mb-4">Tastaturkürzel</h2>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each [['0–9, .', 'Ziffern eingeben'], ['+ - * /', 'Operatoren'], ['( )', 'Klammern'], ['Enter / =', 'Berechnen'], ['Backspace', 'Letzte Stelle löschen'], ['Esc / C', 'Alles löschen'], ['Cmd+K', 'Schnellzugriff'], ['Cmd+1–9', 'Navigation']] as [key, desc]}
|
||||
<div class="flex items-center gap-3 p-2 rounded-lg bg-card border border-border">
|
||||
<kbd class="shrink-0 px-2 py-0.5 rounded bg-muted text-xs font-mono text-muted-foreground"
|
||||
>{key}</kbd
|
||||
>
|
||||
<span class="text-sm text-foreground">{desc}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reset -->
|
||||
<section class="settings-section">
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors text-sm"
|
||||
onclick={resetAll}
|
||||
>
|
||||
Alle Einstellungen zurücksetzen
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-on {
|
||||
background: #ec4899;
|
||||
}
|
||||
|
||||
.toggle-off {
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: white;
|
||||
transition: left 0.2s;
|
||||
}
|
||||
|
||||
.toggle-on .toggle-knob {
|
||||
left: 22px;
|
||||
}
|
||||
|
||||
.toggle-off .toggle-knob {
|
||||
left: 2px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -12,6 +12,16 @@
|
|||
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 {}
|
||||
}
|
||||
|
||||
// Skin state — persisted to localStorage
|
||||
let activeSkin = $state<CalculatorSkin>('modern');
|
||||
|
|
@ -123,10 +133,12 @@
|
|||
expression,
|
||||
display,
|
||||
error,
|
||||
copied,
|
||||
onButton: appendToExpression,
|
||||
onClear: clear,
|
||||
onBackspace: backspace,
|
||||
onEquals: calculate,
|
||||
onCopy: copyToClipboard,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue