mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
chore: delete 25 web-archived directories, remove stale stubs, clean workspace config
- Delete all 25 apps/*/apps/web-archived/ directories (superseded by unified ManaCore app) - Remove stale +page.server.ts stubs from teams, organizations, settings (always returned empty data) - Simplify teams and organizations pages to static empty-state (no server load dependency) - Delete empty apps/context/apps/mobile/components/variants/index.ts - Remove commented-out apps-archived entries from pnpm-workspace.yaml Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e1077e261f
commit
6ced238571
1940 changed files with 41 additions and 223288 deletions
|
|
@ -1,30 +0,0 @@
|
|||
# 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 5026
|
||||
ENV NODE_ENV=production PORT=5026 HOST=0.0.0.0
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5026/health || exit 1
|
||||
|
||||
CMD ["node", "build"]
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
{
|
||||
"name": "@calc/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"type-check": "svelte-kit sync && svelte-check --threshold error"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-pwa": "workspace:*",
|
||||
"@manacore/shared-vite-config": "workspace:*",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calc/shared": "workspace:*",
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-app-onboarding": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-stores": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-error-tracking": "workspace:*",
|
||||
"@manacore/feedback": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/help": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-stores": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/subscriptions": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
@import "@manacore/shared-tailwind/themes.css";
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../../packages/shared-ui/src";
|
||||
@source "../../../../packages/shared-auth-ui/src";
|
||||
@source "../../../../packages/shared-branding/src";
|
||||
@source "../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../packages/shared-theme-ui/src/components";
|
||||
@source "../../../../packages/shared-theme-ui/src/pages";
|
||||
2
apps/calc/apps/web-archived/src/app.d.ts
vendored
2
apps/calc/apps/web-archived/src/app.d.ts
vendored
|
|
@ -1,2 +0,0 @@
|
|||
declare const __BUILD_HASH__: string;
|
||||
declare const __BUILD_TIME__: string;
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { initErrorTracking, handleSvelteError } from '@manacore/shared-error-tracking/browser';
|
||||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
|
||||
initErrorTracking({
|
||||
serviceName: 'calc-web',
|
||||
dsn: (window as any).__PUBLIC_GLITCHTIP_DSN__,
|
||||
environment: import.meta.env.MODE,
|
||||
});
|
||||
|
||||
export const handleError: HandleClientError = ({ error }) => {
|
||||
handleSvelteError(error);
|
||||
};
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import type { Handle } from '@sveltejs/kit';
|
||||
import { setSecurityHeaders } from '@manacore/shared-utils/security-headers';
|
||||
|
||||
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
|
||||
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const PUBLIC_BACKEND_URL_CLIENT =
|
||||
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
|
||||
const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
|
||||
window.__PUBLIC_BACKEND_URL__ = ${JSON.stringify(PUBLIC_BACKEND_URL_CLIENT)};
|
||||
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
|
||||
</script>`;
|
||||
return html.replace('<head>', `<head>${envScript}`);
|
||||
},
|
||||
});
|
||||
|
||||
setSecurityHeaders(response, {
|
||||
connectSrc: [PUBLIC_MANA_CORE_AUTH_URL_CLIENT, PUBLIC_BACKEND_URL_CLIENT],
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="app-loading-skeleton" role="status" aria-label="App wird geladen...">
|
||||
<div class="header-skeleton">
|
||||
<SkeletonBox width="120px" height="32px" borderRadius="8px" />
|
||||
<div class="header-nav">
|
||||
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
|
||||
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
|
||||
</div>
|
||||
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
|
||||
</div>
|
||||
|
||||
<div class="content-skeleton">
|
||||
<div class="calc-placeholder">
|
||||
<SkeletonBox width="360px" height="80px" borderRadius="12px" />
|
||||
<div class="buttons-placeholder">
|
||||
{#each Array(16) as _}
|
||||
<SkeletonBox width="72px" height="56px" borderRadius="8px" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-loading-skeleton {
|
||||
min-height: 100vh;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.header-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 80px);
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.calc-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.buttons-placeholder {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
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';
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
/**
|
||||
* Guest seed data for the Calc app.
|
||||
*
|
||||
* Sample calculations loaded on first guest visit.
|
||||
*/
|
||||
|
||||
import type { LocalCalculation } from './local-store';
|
||||
|
||||
export const guestCalculations: LocalCalculation[] = [
|
||||
{
|
||||
id: 'calc-demo-1',
|
||||
mode: 'standard',
|
||||
expression: '42 * 23',
|
||||
result: '966',
|
||||
},
|
||||
{
|
||||
id: 'calc-demo-2',
|
||||
mode: 'scientific',
|
||||
expression: 'sin(π/4)',
|
||||
result: '0.7071067812',
|
||||
},
|
||||
{
|
||||
id: 'calc-demo-3',
|
||||
mode: 'standard',
|
||||
expression: '1024 / 8',
|
||||
result: '128',
|
||||
},
|
||||
];
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
/**
|
||||
* Calc App — Local-First Data Layer
|
||||
*
|
||||
* Defines the IndexedDB database, collections, and guest seed data.
|
||||
* This is the single source of truth for all Calc data.
|
||||
*/
|
||||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
import { guestCalculations } from './guest-seed';
|
||||
import type { CalculatorMode, CalculatorSkin } from '@calc/shared';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
||||
export const calcStore = createLocalStore({
|
||||
appId: 'calc',
|
||||
collections: [
|
||||
{
|
||||
name: 'calculations',
|
||||
indexes: ['mode'],
|
||||
guestSeed: guestCalculations,
|
||||
},
|
||||
{
|
||||
name: 'savedFormulas',
|
||||
indexes: ['mode', 'name'],
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
serverUrl: SYNC_SERVER_URL,
|
||||
},
|
||||
});
|
||||
|
||||
// Typed collection accessors
|
||||
export const calculationCollection = calcStore.collection<LocalCalculation>('calculations');
|
||||
export const savedFormulaCollection = calcStore.collection<LocalSavedFormula>('savedFormulas');
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
/**
|
||||
* Reactive Queries for Calc
|
||||
*
|
||||
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
||||
import {
|
||||
calculationCollection,
|
||||
savedFormulaCollection,
|
||||
type LocalCalculation,
|
||||
type LocalSavedFormula,
|
||||
} from './local-store';
|
||||
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 Query Hooks ──────────────────────────────────────
|
||||
|
||||
/** All calculations (history), newest first. */
|
||||
export function useAllCalculations() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await calculationCollection.getAll();
|
||||
return locals.map(toCalculation).reverse();
|
||||
}, [] as Calculation[]);
|
||||
}
|
||||
|
||||
/** All saved formulas. */
|
||||
export function useAllSavedFormulas() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await savedFormulaCollection.getAll();
|
||||
return locals.map(toSavedFormula);
|
||||
}, [] as SavedFormula[]);
|
||||
}
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { init, register, locale, waitLocale } from 'svelte-i18n';
|
||||
|
||||
export const supportedLocales = ['de', 'en'] as const;
|
||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
||||
|
||||
const defaultLocale = 'de';
|
||||
|
||||
register('de', () => import('./locales/de.json'));
|
||||
register('en', () => import('./locales/en.json'));
|
||||
|
||||
function getInitialLocale(): SupportedLocale {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem('calc_locale');
|
||||
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
|
||||
return stored as SupportedLocale;
|
||||
}
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (supportedLocales.includes(browserLang as SupportedLocale)) {
|
||||
return browserLang as SupportedLocale;
|
||||
}
|
||||
}
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: getInitialLocale(),
|
||||
});
|
||||
|
||||
export function setLocale(newLocale: SupportedLocale) {
|
||||
locale.set(newLocale);
|
||||
if (browser) {
|
||||
localStorage.setItem('calc_locale', newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
export { waitLocale };
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Calc",
|
||||
"tagline": "Dein Taschenrechner-Hub"
|
||||
},
|
||||
"nav": {
|
||||
"overview": "Übersicht",
|
||||
"standard": "Standard",
|
||||
"scientific": "Wissenschaftlich",
|
||||
"programmer": "Programmierer",
|
||||
"converter": "Einheiten",
|
||||
"currency": "Währung",
|
||||
"finance": "Finanzen",
|
||||
"date": "Datum",
|
||||
"percentage": "Prozent",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"calc": {
|
||||
"result": "Ergebnis",
|
||||
"history": "Verlauf",
|
||||
"clearHistory": "Verlauf löschen",
|
||||
"noHistory": "Noch keine Berechnungen",
|
||||
"error": "Fehler",
|
||||
"copied": "Kopiert!",
|
||||
"clear": "Löschen",
|
||||
"equals": "Gleich"
|
||||
},
|
||||
"converter": {
|
||||
"from": "Von",
|
||||
"to": "Nach",
|
||||
"swap": "Tauschen"
|
||||
},
|
||||
"finance": {
|
||||
"principal": "Anfangskapital",
|
||||
"rate": "Zinssatz",
|
||||
"years": "Laufzeit (Jahre)",
|
||||
"result": "Ergebnis",
|
||||
"monthlyPayment": "Monatliche Rate",
|
||||
"totalInterest": "Gesamtzinsen",
|
||||
"totalAmount": "Gesamtbetrag"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Calc",
|
||||
"tagline": "Your Calculator Hub"
|
||||
},
|
||||
"nav": {
|
||||
"overview": "Overview",
|
||||
"standard": "Standard",
|
||||
"scientific": "Scientific",
|
||||
"programmer": "Programmer",
|
||||
"converter": "Units",
|
||||
"currency": "Currency",
|
||||
"finance": "Finance",
|
||||
"date": "Date",
|
||||
"percentage": "Percent",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"calc": {
|
||||
"result": "Result",
|
||||
"history": "History",
|
||||
"clearHistory": "Clear History",
|
||||
"noHistory": "No calculations yet",
|
||||
"error": "Error",
|
||||
"copied": "Copied!",
|
||||
"clear": "Clear",
|
||||
"equals": "Equals"
|
||||
},
|
||||
"converter": {
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"swap": "Swap"
|
||||
},
|
||||
"finance": {
|
||||
"principal": "Principal",
|
||||
"rate": "Interest Rate",
|
||||
"years": "Term (Years)",
|
||||
"result": "Result",
|
||||
"monthlyPayment": "Monthly Payment",
|
||||
"totalInterest": "Total Interest",
|
||||
"totalAmount": "Total Amount"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
|
||||
import { userSettings } from './user-settings.svelte';
|
||||
|
||||
const calcOnboardingSteps: AppOnboardingStep[] = [
|
||||
{
|
||||
id: 'features',
|
||||
type: 'info',
|
||||
question: 'Willkommen bei Calc!',
|
||||
description: 'Das kann Calc:',
|
||||
emoji: '🧮',
|
||||
gradient: { from: 'pink-500', to: 'pink-700' },
|
||||
bullets: [
|
||||
'Standard, Wissenschaftlich & Programmierer',
|
||||
'Einheiten- & Währungsrechner',
|
||||
'Finanzrechner (Zins, Kredit, Sparplan)',
|
||||
'Historische Taschenrechner-Skins',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'defaultMode',
|
||||
type: 'select',
|
||||
question: 'Welchen Modus nutzt du am häufigsten?',
|
||||
description: 'Du kannst jederzeit wechseln.',
|
||||
emoji: '🔢',
|
||||
gradient: { from: 'pink-500', to: 'pink-700' },
|
||||
options: [
|
||||
{ id: 'standard', label: 'Standard', description: 'Grundrechenarten', emoji: '➕' },
|
||||
{
|
||||
id: 'scientific',
|
||||
label: 'Wissenschaftlich',
|
||||
description: 'sin, cos, log & mehr',
|
||||
emoji: '🔬',
|
||||
},
|
||||
{ id: 'programmer', label: 'Programmierer', description: 'HEX, BIN, OCT', emoji: '💻' },
|
||||
{ id: 'converter', label: 'Einheiten', description: 'Umrechnen leicht gemacht', emoji: '📏' },
|
||||
],
|
||||
defaultValue: 'standard',
|
||||
},
|
||||
{
|
||||
id: 'welcome',
|
||||
type: 'info',
|
||||
question: 'Dein Rechner ist bereit!',
|
||||
description: 'Tipps:',
|
||||
emoji: '🎉',
|
||||
gradient: { from: 'primary', to: 'primary/70' },
|
||||
bullets: [
|
||||
'Tastatur-Eingabe funktioniert überall',
|
||||
'Verlauf speichert alle Berechnungen',
|
||||
'Wechsle Skins für verschiedene Looks',
|
||||
'Drücke Cmd/Ctrl+K für Schnellzugriff',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const calcOnboarding = createAppOnboardingStore({
|
||||
appId: 'calc',
|
||||
steps: calcOnboardingSteps,
|
||||
userSettings,
|
||||
onComplete: async () => {},
|
||||
onSkip: async () => {},
|
||||
});
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
/**
|
||||
* Auth Store — uses centralized Mana auth factory.
|
||||
*/
|
||||
|
||||
import { createManaAuthStore } from '@manacore/shared-auth-stores';
|
||||
|
||||
export const authStore = createManaAuthStore({
|
||||
devBackendPort: 3017,
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/**
|
||||
* Calculation mutation store — write operations only.
|
||||
* Reads come from live query hooks in queries.ts.
|
||||
*/
|
||||
|
||||
import { calculationCollection, type LocalCalculation } from '$lib/data/local-store';
|
||||
import type { CreateCalculationInput } from '@calc/shared';
|
||||
|
||||
export const calculationsStore = {
|
||||
async addCalculation(input: CreateCalculationInput) {
|
||||
await calculationCollection.insert({
|
||||
mode: input.mode,
|
||||
expression: input.expression,
|
||||
result: input.result,
|
||||
skin: input.skin,
|
||||
} as Omit<LocalCalculation, 'id'>);
|
||||
},
|
||||
|
||||
async deleteCalculation(id: string) {
|
||||
await calculationCollection.delete(id);
|
||||
},
|
||||
|
||||
async clearHistory() {
|
||||
const all = await calculationCollection.getAll();
|
||||
for (const item of all) {
|
||||
await calculationCollection.delete(item.id);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { createSimpleNavigationStores } from '@manacore/shared-stores';
|
||||
|
||||
export const { isNavCollapsed } = createSimpleNavigationStores({
|
||||
storageKey: 'calc',
|
||||
});
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
/**
|
||||
* Saved formula mutation store — write operations only.
|
||||
*/
|
||||
|
||||
import { savedFormulaCollection, type LocalSavedFormula } from '$lib/data/local-store';
|
||||
import type { CreateFormulaInput, UpdateFormulaInput } from '@calc/shared';
|
||||
|
||||
export const savedFormulasStore = {
|
||||
async saveFormula(input: CreateFormulaInput) {
|
||||
await savedFormulaCollection.insert({
|
||||
name: input.name,
|
||||
expression: input.expression,
|
||||
description: input.description ?? null,
|
||||
mode: input.mode,
|
||||
} as Omit<LocalSavedFormula, 'id'>);
|
||||
},
|
||||
|
||||
async updateFormula(id: string, input: UpdateFormulaInput) {
|
||||
await savedFormulaCollection.update(id, input);
|
||||
},
|
||||
|
||||
async deleteFormula(id: string) {
|
||||
await savedFormulaCollection.delete(id);
|
||||
},
|
||||
};
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
export const theme = createThemeStore({
|
||||
appId: 'calc',
|
||||
defaultVariant: 'lume',
|
||||
});
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
if (injectedUrl) return injectedUrl;
|
||||
}
|
||||
return import.meta.env.DEV ? 'http://localhost:3001' : '';
|
||||
}
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'calc',
|
||||
authUrl: getAuthUrl,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -1,428 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { setContext } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillNavigation, CommandBar, TagStrip } from '@manacore/shared-ui';
|
||||
import { SyncIndicator } from '@manacore/shared-ui';
|
||||
import type {
|
||||
PillNavItem,
|
||||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickAction,
|
||||
SpotlightAction,
|
||||
} from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { useAllCalculations, useAllSavedFormulas } from '$lib/data/queries';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
DEFAULT_THEME_VARIANTS,
|
||||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import { filterHiddenNavItems } from '@manacore/shared-theme';
|
||||
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems, getManaApp } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { calcOnboarding } from '$lib/stores/app-onboarding.svelte';
|
||||
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
||||
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { calcStore } from '$lib/data/local-store';
|
||||
import {
|
||||
tagLocalStore,
|
||||
tagMutations,
|
||||
useAllTags as useAllSharedTags,
|
||||
} from '@manacore/shared-stores';
|
||||
|
||||
const allCalculations = useAllCalculations();
|
||||
const allSavedFormulas = useAllSavedFormulas();
|
||||
const allTags = useAllSharedTags();
|
||||
|
||||
setContext('calculations', allCalculations);
|
||||
setContext('savedFormulas', allSavedFormulas);
|
||||
setContext('tags', allTags);
|
||||
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
function initGuestWelcome() {
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('calc')) {
|
||||
showGuestWelcome = true;
|
||||
}
|
||||
}
|
||||
|
||||
let appItems = $derived(getPillAppItems('calc', undefined, undefined, authStore.user?.tier));
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let commandBarOpen = $state(false);
|
||||
|
||||
const commandBarQuickActions: QuickAction[] = [
|
||||
{
|
||||
id: 'standard',
|
||||
label: 'Standard-Rechner',
|
||||
icon: 'calculator',
|
||||
href: '/standard',
|
||||
shortcut: '1',
|
||||
},
|
||||
{
|
||||
id: 'scientific',
|
||||
label: 'Wissenschaftlich',
|
||||
icon: 'flask',
|
||||
href: '/scientific',
|
||||
shortcut: '2',
|
||||
},
|
||||
{ id: 'programmer', label: 'Programmierer', icon: 'code', href: '/programmer', shortcut: '3' },
|
||||
{
|
||||
id: 'converter',
|
||||
label: 'Einheiten-Rechner',
|
||||
icon: 'ruler',
|
||||
href: '/converter',
|
||||
shortcut: '4',
|
||||
},
|
||||
{ id: 'currency', label: 'Währungsrechner', icon: 'coins', href: '/currency' },
|
||||
{ id: 'finance', label: 'Finanzrechner', icon: 'piggy-bank', href: '/finance' },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
|
||||
if (!query.trim()) return [];
|
||||
const queryLower = query.toLowerCase();
|
||||
const results: CommandBarItem[] = [];
|
||||
|
||||
const matchingCalcs = allCalculations.value
|
||||
.filter((c) => c.expression.toLowerCase().includes(queryLower))
|
||||
.slice(0, 5)
|
||||
.map((c) => ({
|
||||
id: `calc-${c.id}`,
|
||||
title: c.expression,
|
||||
subtitle: `= ${c.result}`,
|
||||
}));
|
||||
results.push(...matchingCalcs);
|
||||
|
||||
const matchingFormulas = allSavedFormulas.value
|
||||
.filter(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes(queryLower) ||
|
||||
f.expression.toLowerCase().includes(queryLower)
|
||||
)
|
||||
.slice(0, 5)
|
||||
.map((f) => ({
|
||||
id: `formula-${f.id}`,
|
||||
title: f.name,
|
||||
subtitle: f.expression,
|
||||
}));
|
||||
results.push(...matchingFormulas);
|
||||
|
||||
return results.slice(0, 10);
|
||||
}
|
||||
|
||||
function handleCommandBarSelect(item: CommandBarItem) {
|
||||
if (item.id.startsWith('calc-') || item.id.startsWith('formula-')) {
|
||||
goto('/standard');
|
||||
}
|
||||
}
|
||||
|
||||
let isCollapsed = $state(false);
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
let pinnedThemes = $derived<ThemeVariant[]>(
|
||||
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
|
||||
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
|
||||
)
|
||||
);
|
||||
|
||||
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
|
||||
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>([
|
||||
...visibleThemes.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant]?.label || variant,
|
||||
icon: THEME_DEFINITIONS[variant]?.icon || '🎨',
|
||||
onClick: () => theme.setVariant(variant),
|
||||
active: (theme.variant || 'lume') === variant,
|
||||
})),
|
||||
{
|
||||
id: 'all-themes',
|
||||
label: 'Alle Themes',
|
||||
icon: 'palette',
|
||||
onClick: () => goto('/themes'),
|
||||
active: false,
|
||||
},
|
||||
]);
|
||||
|
||||
let currentThemeVariantLabel = $derived(
|
||||
THEME_DEFINITIONS[theme.variant]?.label || THEME_DEFINITIONS.lume?.label || 'Lume'
|
||||
);
|
||||
|
||||
let currentLocale = $derived($locale || 'de');
|
||||
function handleLocaleChange(newLocale: string) {
|
||||
setLocale(newLocale as any);
|
||||
}
|
||||
let languageItems = $derived(
|
||||
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
|
||||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
|
||||
|
||||
let isTagStripVisible = $state(false);
|
||||
function handleTagStripToggle() {
|
||||
isTagStripVisible = !isTagStripVisible;
|
||||
}
|
||||
|
||||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Übersicht', icon: 'home' },
|
||||
{ href: '/standard', label: 'Standard', icon: 'calculator' },
|
||||
{ href: '/scientific', label: 'Wissenschaftlich', icon: 'flask' },
|
||||
{ href: '/programmer', label: 'Programmierer', icon: 'code' },
|
||||
{ href: '/converter', label: 'Einheiten', icon: 'ruler' },
|
||||
{ href: '/currency', label: 'Währung', icon: 'coins' },
|
||||
{ href: '/finance', label: 'Finanzen', icon: 'piggy-bank' },
|
||||
{ href: '/date', label: 'Datum', icon: 'calendar' },
|
||||
{ href: '/percentage', label: 'Prozent', icon: 'percent' },
|
||||
{ href: '/skins', label: 'Skins', icon: 'palette' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
{
|
||||
href: '/',
|
||||
label: 'Tags',
|
||||
icon: 'tag',
|
||||
onClick: handleTagStripToggle,
|
||||
active: isTagStripVisible,
|
||||
},
|
||||
];
|
||||
|
||||
const navItems = $derived(
|
||||
filterHiddenNavItems('calc', baseNavItems, userSettings.nav?.hiddenNavItems || {})
|
||||
);
|
||||
|
||||
const navRoutes = baseNavItems.map((item) => item.href);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
commandBarOpen = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
|
||||
const num = parseInt(event.key);
|
||||
if (num >= 1 && num <= navRoutes.length) {
|
||||
event.preventDefault();
|
||||
const route = navRoutes[num - 1];
|
||||
if (route) {
|
||||
goto(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
collapsedStore.set(collapsed);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('calc-nav-collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
|
||||
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
|
||||
theme.setMode(mode);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
const spotlightActions: SpotlightAction[] = [
|
||||
{
|
||||
id: 'calculator',
|
||||
label: 'Rechner',
|
||||
category: 'Navigation',
|
||||
onExecute: () => goto('/standard'),
|
||||
},
|
||||
{
|
||||
id: 'converter',
|
||||
label: 'Umrechner',
|
||||
category: 'Navigation',
|
||||
onExecute: () => goto('/converter'),
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Einstellungen',
|
||||
category: 'Navigation',
|
||||
onExecute: () => goto('/settings'),
|
||||
},
|
||||
];
|
||||
|
||||
async function handleAuthReady() {
|
||||
await Promise.all([calcStore.initialize(), tagLocalStore.initialize()]);
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
const getToken = () => authStore.getValidToken();
|
||||
calcStore.startSync(getToken);
|
||||
tagMutations.startSync(getToken);
|
||||
}
|
||||
|
||||
const savedCollapsed = localStorage.getItem('calc-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
|
||||
initGuestWelcome();
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
await userSettings.load();
|
||||
}
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') {
|
||||
goto(userSettings.startPage, { replaceState: true });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<AuthGate
|
||||
{authStore}
|
||||
{goto}
|
||||
allowGuest={true}
|
||||
onReady={handleAuthReady}
|
||||
requiredTier={getManaApp('calc')?.requiredTier}
|
||||
appName={getManaApp('calc')?.name}
|
||||
>
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Calc"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#ec4899"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
themesHref="/themes"
|
||||
helpHref="/help"
|
||||
allAppsHref="/apps"
|
||||
{spotlightActions}
|
||||
/>
|
||||
|
||||
{#if isTagStripVisible}
|
||||
<TagStrip
|
||||
tags={allTags.value.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
color: t.color || '#3b82f6',
|
||||
}))}
|
||||
selectedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onClear={() => {}}
|
||||
managementHref="/tags"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<main class="main-content bg-background">
|
||||
<div class="content-wrapper">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<CommandBar
|
||||
bind:open={commandBarOpen}
|
||||
onClose={() => (commandBarOpen = false)}
|
||||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Schnellzugriff..."
|
||||
emptyText="Keine Ergebnisse"
|
||||
searchingText="Suche..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if calcOnboarding.shouldShow}
|
||||
<MiniOnboardingModal store={calcOnboarding} appName="Calc" appEmoji="🧮" />
|
||||
{/if}
|
||||
|
||||
<GuestWelcomeModal
|
||||
appId="calc"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
/>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
{/if}
|
||||
<SyncIndicator />
|
||||
</AuthGate>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content-wrapper {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content-wrapper {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Calculator,
|
||||
Flask,
|
||||
Code,
|
||||
Ruler,
|
||||
Coins,
|
||||
PiggyBank,
|
||||
Calendar,
|
||||
Percent,
|
||||
} from '@manacore/shared-icons';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let isLoading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
href: '/standard',
|
||||
icon: Calculator,
|
||||
label: 'Standard',
|
||||
description: 'Grundrechenarten',
|
||||
color: 'bg-pink-500',
|
||||
},
|
||||
{
|
||||
href: '/scientific',
|
||||
icon: Flask,
|
||||
label: 'Wissenschaftlich',
|
||||
description: 'sin, cos, log & mehr',
|
||||
color: 'bg-violet-500',
|
||||
},
|
||||
{
|
||||
href: '/programmer',
|
||||
icon: Code,
|
||||
label: 'Programmierer',
|
||||
description: 'HEX, BIN, OCT',
|
||||
color: 'bg-cyan-500',
|
||||
},
|
||||
{
|
||||
href: '/converter',
|
||||
icon: Ruler,
|
||||
label: 'Einheiten',
|
||||
description: 'Umrechnen',
|
||||
color: 'bg-emerald-500',
|
||||
},
|
||||
{
|
||||
href: '/currency',
|
||||
icon: Coins,
|
||||
label: 'Währung',
|
||||
description: 'Wechselkurse',
|
||||
color: 'bg-amber-500',
|
||||
},
|
||||
{
|
||||
href: '/finance',
|
||||
icon: PiggyBank,
|
||||
label: 'Finanzen',
|
||||
description: 'Zins & Kredit',
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
href: '/date',
|
||||
icon: Calendar,
|
||||
label: 'Datum',
|
||||
description: 'Tage berechnen',
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
href: '/percentage',
|
||||
icon: Percent,
|
||||
label: 'Prozent & Trinkgeld',
|
||||
description: 'Aufteilen & Berechnen',
|
||||
color: 'bg-rose-500',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if isLoading}
|
||||
<AppLoadingSkeleton />
|
||||
{:else}
|
||||
<div class="dashboard">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-foreground">Calc</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">Dein Taschenrechner-Hub</p>
|
||||
</header>
|
||||
|
||||
<!-- Quick display -->
|
||||
<div class="mb-8 p-6 rounded-xl bg-card border border-border">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 rounded-full bg-primary/10">
|
||||
<Calculator size={32} class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-4xl font-bold text-foreground tabular-nums font-mono">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 md:grid-cols-4 gap-4">
|
||||
{#each quickLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="quick-link p-4 rounded-xl bg-card border border-border hover:border-primary/50 transition-all hover:shadow-lg group"
|
||||
>
|
||||
<div class="flex flex-col items-center text-center gap-3">
|
||||
<div
|
||||
class="{link.color} p-3 rounded-full text-white group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { UNIT_CATEGORIES, UNITS_BY_CATEGORY } from '@calc/shared/constants';
|
||||
import type { UnitCategory, UnitDefinition } from '@calc/shared';
|
||||
|
||||
let selectedCategory = $state<UnitCategory>('length');
|
||||
let fromUnit = $state('m');
|
||||
let toUnit = $state('km');
|
||||
let fromValue = $state('1');
|
||||
|
||||
let units = $derived(UNITS_BY_CATEGORY[selectedCategory] || []);
|
||||
|
||||
let result = $derived(() => {
|
||||
const val = parseFloat(fromValue);
|
||||
if (isNaN(val)) return '';
|
||||
const from = units.find((u: UnitDefinition) => u.id === fromUnit);
|
||||
const to = units.find((u: UnitDefinition) => u.id === toUnit);
|
||||
if (!from || !to) return '';
|
||||
const baseValue = from.toBase(val);
|
||||
const converted = to.fromBase(baseValue);
|
||||
// Format nicely
|
||||
if (Math.abs(converted) < 0.001 && converted !== 0) return converted.toExponential(4);
|
||||
if (Math.abs(converted) > 1e12) return converted.toExponential(4);
|
||||
return parseFloat(converted.toPrecision(10)).toString();
|
||||
});
|
||||
|
||||
function swapUnits() {
|
||||
const tmp = fromUnit;
|
||||
fromUnit = toUnit;
|
||||
toUnit = tmp;
|
||||
}
|
||||
|
||||
function selectCategory(cat: UnitCategory) {
|
||||
selectedCategory = cat;
|
||||
const newUnits = UNITS_BY_CATEGORY[cat] || [];
|
||||
fromUnit = newUnits[0]?.id || '';
|
||||
toUnit = newUnits[1]?.id || newUnits[0]?.id || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - Einheiten</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="converter-page">
|
||||
<!-- Category pills -->
|
||||
<div class="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||
{#each UNIT_CATEGORIES.filter((c) => UNITS_BY_CATEGORY[c.id]) as cat}
|
||||
<button
|
||||
class="shrink-0 px-3 py-1.5 rounded-full text-sm transition-all border
|
||||
{selectedCategory === cat.id
|
||||
? 'bg-emerald-500 text-white border-emerald-500'
|
||||
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
|
||||
onclick={() => selectCategory(cat.id)}
|
||||
>
|
||||
{cat.label.de}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Converter Card -->
|
||||
<div class="p-6 rounded-xl bg-card border border-border space-y-4">
|
||||
<!-- From -->
|
||||
<div>
|
||||
<label class="text-xs text-muted-foreground mb-1 block">Von</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
bind:value={fromValue}
|
||||
class="flex-1 h-12 px-3 rounded-lg bg-background border border-border text-foreground font-mono text-xl focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<select
|
||||
bind:value={fromUnit}
|
||||
class="h-12 px-3 rounded-lg bg-background border border-border text-foreground"
|
||||
>
|
||||
{#each units as unit}
|
||||
<option value={unit.id}>{unit.symbol} — {unit.name.de}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Swap button -->
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="p-2 rounded-full bg-muted hover:bg-muted/80 transition-colors text-muted-foreground"
|
||||
onclick={swapUnits}
|
||||
>
|
||||
↕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- To -->
|
||||
<div>
|
||||
<label class="text-xs text-muted-foreground mb-1 block">Nach</label>
|
||||
<div class="flex gap-2">
|
||||
<div
|
||||
class="flex-1 h-12 px-3 rounded-lg bg-muted/30 border border-border flex items-center font-mono text-xl text-foreground"
|
||||
>
|
||||
{result()}
|
||||
</div>
|
||||
<select
|
||||
bind:value={toUnit}
|
||||
class="h-12 px-3 rounded-lg bg-background border border-border text-foreground"
|
||||
>
|
||||
{#each units as unit}
|
||||
<option value={unit.id}>{unit.symbol} — {unit.name.de}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick reference -->
|
||||
{#if fromValue && result()}
|
||||
<div
|
||||
class="mt-4 p-3 rounded-lg bg-muted/30 text-sm text-muted-foreground text-center font-mono"
|
||||
>
|
||||
{fromValue}
|
||||
{units.find((u: UnitDefinition) => u.id === fromUnit)?.symbol} = {result()}
|
||||
{units.find((u: UnitDefinition) => u.id === toUnit)?.symbol}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.converter-page {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
<script lang="ts">
|
||||
let amount = $state(100);
|
||||
let fromCurrency = $state('EUR');
|
||||
let toCurrency = $state('USD');
|
||||
let rates = $state<Record<string, number>>({});
|
||||
let loading = $state(false);
|
||||
let lastUpdated = $state('');
|
||||
|
||||
const currencies = [
|
||||
{ code: 'EUR', name: 'Euro', symbol: '€' },
|
||||
{ code: 'USD', name: 'US Dollar', symbol: '$' },
|
||||
{ code: 'GBP', name: 'Brit. Pfund', symbol: '£' },
|
||||
{ code: 'CHF', name: 'Schweizer Franken', symbol: 'CHF' },
|
||||
{ code: 'JPY', name: 'Japanischer Yen', symbol: '¥' },
|
||||
{ code: 'CNY', name: 'Chinesischer Yuan', symbol: '¥' },
|
||||
{ code: 'CAD', name: 'Kanadischer Dollar', symbol: 'C$' },
|
||||
{ code: 'AUD', name: 'Australischer Dollar', symbol: 'A$' },
|
||||
{ code: 'SEK', name: 'Schwedische Krone', symbol: 'kr' },
|
||||
{ code: 'NOK', name: 'Norwegische Krone', symbol: 'kr' },
|
||||
{ code: 'DKK', name: 'Dänische Krone', symbol: 'kr' },
|
||||
{ code: 'PLN', name: 'Polnischer Zloty', symbol: 'zł' },
|
||||
{ code: 'CZK', name: 'Tschechische Krone', symbol: 'Kč' },
|
||||
{ code: 'TRY', name: 'Türkische Lira', symbol: '₺' },
|
||||
{ code: 'INR', name: 'Indische Rupie', symbol: '₹' },
|
||||
{ code: 'BRL', name: 'Brasilianischer Real', symbol: 'R$' },
|
||||
{ code: 'KRW', name: 'Südkoreanischer Won', symbol: '₩' },
|
||||
];
|
||||
|
||||
// Static fallback rates (EUR-based, approximate)
|
||||
const FALLBACK_RATES: Record<string, number> = {
|
||||
EUR: 1,
|
||||
USD: 1.08,
|
||||
GBP: 0.86,
|
||||
CHF: 0.95,
|
||||
JPY: 162.5,
|
||||
CNY: 7.85,
|
||||
CAD: 1.47,
|
||||
AUD: 1.66,
|
||||
SEK: 11.2,
|
||||
NOK: 11.5,
|
||||
DKK: 7.46,
|
||||
PLN: 4.32,
|
||||
CZK: 25.3,
|
||||
TRY: 34.8,
|
||||
INR: 90.5,
|
||||
BRL: 5.35,
|
||||
KRW: 1420,
|
||||
};
|
||||
|
||||
async function fetchRates() {
|
||||
loading = true;
|
||||
try {
|
||||
// Try free API first
|
||||
const res = await fetch(`https://open.er-api.com/v6/latest/${fromCurrency}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
rates = data.rates || {};
|
||||
lastUpdated = data.time_last_update_utc || '';
|
||||
} else {
|
||||
throw new Error('API unavailable');
|
||||
}
|
||||
} catch {
|
||||
// Fallback to static rates
|
||||
const fromRate = FALLBACK_RATES[fromCurrency] || 1;
|
||||
const converted: Record<string, number> = {};
|
||||
for (const [code, rate] of Object.entries(FALLBACK_RATES)) {
|
||||
converted[code] = rate / fromRate;
|
||||
}
|
||||
rates = converted;
|
||||
lastUpdated = 'Offline-Kurse (Richtwerte)';
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
// Fetch on mount and when fromCurrency changes
|
||||
$effect(() => {
|
||||
fetchRates();
|
||||
});
|
||||
|
||||
let result = $derived(() => {
|
||||
const rate = rates[toCurrency];
|
||||
if (!rate) return null;
|
||||
return amount * rate;
|
||||
});
|
||||
|
||||
function swapCurrencies() {
|
||||
const tmp = fromCurrency;
|
||||
fromCurrency = toCurrency;
|
||||
toCurrency = tmp;
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - Währung</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="currency-page">
|
||||
<div class="p-6 rounded-xl bg-card border border-border space-y-4">
|
||||
<h2 class="text-lg font-bold text-foreground">Währungsrechner</h2>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-muted-foreground mb-1 block">Betrag</label>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={amount}
|
||||
class="w-full h-12 px-3 rounded-lg bg-background border border-border text-foreground font-mono text-xl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[1fr,auto,1fr] gap-2 items-end">
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Von</span>
|
||||
<select
|
||||
bind:value={fromCurrency}
|
||||
class="mt-1 w-full h-10 px-2 rounded-lg bg-background border border-border text-foreground text-sm"
|
||||
>
|
||||
{#each currencies as c}
|
||||
<option value={c.code}>{c.code} — {c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
class="h-10 px-3 rounded-lg bg-muted hover:bg-muted/80 text-muted-foreground"
|
||||
onclick={swapCurrencies}>↔</button
|
||||
>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Nach</span>
|
||||
<select
|
||||
bind:value={toCurrency}
|
||||
class="mt-1 w-full h-10 px-2 rounded-lg bg-background border border-border text-foreground text-sm"
|
||||
>
|
||||
{#each currencies as c}
|
||||
<option value={c.code}>{c.code} — {c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-center text-muted-foreground text-sm py-4">Kurse laden...</div>
|
||||
{:else if result() !== null}
|
||||
<div class="pt-4 border-t border-border text-center">
|
||||
<div class="text-3xl font-bold font-mono text-foreground">
|
||||
{fmt(result()!)}
|
||||
{toCurrency}
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground mt-1">
|
||||
1 {fromCurrency} = {(rates[toCurrency] || 0).toFixed(4)}
|
||||
{toCurrency}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if lastUpdated}
|
||||
<div class="text-xs text-muted-foreground/60 text-center">{lastUpdated}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Quick conversions -->
|
||||
{#if Object.keys(rates).length > 0}
|
||||
<div class="mt-6 p-4 rounded-xl bg-card border border-border">
|
||||
<h3 class="text-sm font-medium text-muted-foreground mb-3">Schnellübersicht</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each currencies.filter((c) => c.code !== fromCurrency).slice(0, 8) as c}
|
||||
<div class="flex justify-between p-2 rounded-lg bg-muted/30 text-sm">
|
||||
<span class="text-muted-foreground">{c.code}</span>
|
||||
<span class="font-mono text-foreground">{fmt(amount * (rates[c.code] || 0))}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.currency-page {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
<script lang="ts">
|
||||
let date1 = $state(new Date().toISOString().split('T')[0]);
|
||||
let date2 = $state('');
|
||||
let addDays = $state(0);
|
||||
|
||||
let daysBetween = $derived(() => {
|
||||
if (!date1 || !date2) return null;
|
||||
const d1 = new Date(date1);
|
||||
const d2 = new Date(date2);
|
||||
const diff = Math.abs(d2.getTime() - d1.getTime());
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
const weeks = Math.floor(days / 7);
|
||||
const months = Math.round(days / 30.44);
|
||||
return { days, weeks, months };
|
||||
});
|
||||
|
||||
let addedDate = $derived(() => {
|
||||
if (!date1 || !addDays) return null;
|
||||
const d = new Date(date1);
|
||||
d.setDate(d.getDate() + addDays);
|
||||
return d;
|
||||
});
|
||||
|
||||
function formatDate(d: Date): string {
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - Datum</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="date-page">
|
||||
<!-- Days between dates -->
|
||||
<div class="p-6 rounded-xl bg-card border border-border space-y-4 mb-6">
|
||||
<h2 class="text-lg font-bold text-foreground">Tage zwischen Daten</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Von</span>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={date1}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Bis</span>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={date2}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{#if daysBetween()}
|
||||
<div class="pt-4 border-t border-border">
|
||||
<div class="text-3xl font-bold text-foreground font-mono text-center">
|
||||
{daysBetween()?.days} Tage
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground text-center mt-1">
|
||||
= {daysBetween()?.weeks} Wochen = ~{daysBetween()?.months} Monate
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Add/subtract days -->
|
||||
<div class="p-6 rounded-xl bg-card border border-border space-y-4">
|
||||
<h2 class="text-lg font-bold text-foreground">Tage addieren/subtrahieren</h2>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Startdatum</span>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={date1}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Tage (+/-)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={addDays}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
{#each [7, 14, 30, 90, 365] as days}
|
||||
<button
|
||||
class="flex-1 py-1.5 rounded-lg text-xs transition-all border {addDays === days
|
||||
? 'bg-orange-500 text-white border-orange-500'
|
||||
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
|
||||
onclick={() => (addDays = days)}>+{days}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{#if addedDate()}
|
||||
<div class="pt-4 border-t border-border text-center">
|
||||
<div class="text-lg font-bold text-foreground">{formatDate(addedDate()!)}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{addedDate()!.toISOString().split('T')[0]}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.date-page {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { FinanceMode } from '@calc/shared';
|
||||
|
||||
let mode = $state<FinanceMode>('compound-interest');
|
||||
|
||||
// Compound Interest
|
||||
let principal = $state(10000);
|
||||
let rate = $state(5);
|
||||
let years = $state(10);
|
||||
let compoundsPerYear = $state(12);
|
||||
|
||||
// Loan
|
||||
let loanAmount = $state(200000);
|
||||
let loanRate = $state(3.5);
|
||||
let loanYears = $state(25);
|
||||
|
||||
// Savings
|
||||
let monthlyDeposit = $state(200);
|
||||
let savingsRate = $state(5);
|
||||
let savingsYears = $state(20);
|
||||
let initialDeposit = $state(1000);
|
||||
|
||||
// Tip
|
||||
let billAmount = $state(50);
|
||||
let tipPercent = $state(15);
|
||||
let splitCount = $state(2);
|
||||
|
||||
// Compound Interest Result
|
||||
let compoundResult = $derived(() => {
|
||||
const r = rate / 100 / compoundsPerYear;
|
||||
const n = compoundsPerYear * years;
|
||||
const total = principal * Math.pow(1 + r, n);
|
||||
return { total, interest: total - principal };
|
||||
});
|
||||
|
||||
// Loan Result
|
||||
let loanResult = $derived(() => {
|
||||
const r = loanRate / 100 / 12;
|
||||
const n = loanYears * 12;
|
||||
const monthly = (loanAmount * (r * Math.pow(1 + r, n))) / (Math.pow(1 + r, n) - 1);
|
||||
const total = monthly * n;
|
||||
return { monthly, total, interest: total - loanAmount };
|
||||
});
|
||||
|
||||
// Savings Result
|
||||
let savingsResult = $derived(() => {
|
||||
const r = savingsRate / 100 / 12;
|
||||
const n = savingsYears * 12;
|
||||
const futureValue =
|
||||
initialDeposit * Math.pow(1 + r, n) + monthlyDeposit * ((Math.pow(1 + r, n) - 1) / r);
|
||||
const totalDeposited = initialDeposit + monthlyDeposit * n;
|
||||
return {
|
||||
total: futureValue,
|
||||
deposited: totalDeposited,
|
||||
interest: futureValue - totalDeposited,
|
||||
};
|
||||
});
|
||||
|
||||
// Tip Result
|
||||
let tipResult = $derived(() => {
|
||||
const tip = (billAmount * tipPercent) / 100;
|
||||
const total = billAmount + tip;
|
||||
const perPerson = total / splitCount;
|
||||
return { tip, total, perPerson };
|
||||
});
|
||||
|
||||
function fmt(n: number): string {
|
||||
return n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
const modes: { id: FinanceMode; label: string }[] = [
|
||||
{ id: 'compound-interest', label: 'Zinseszins' },
|
||||
{ id: 'loan', label: 'Kredit' },
|
||||
{ id: 'savings', label: 'Sparplan' },
|
||||
{ id: 'tip', label: 'Trinkgeld' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - Finanzen</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="finance-page">
|
||||
<!-- Mode tabs -->
|
||||
<div class="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||
{#each modes as m}
|
||||
<button
|
||||
class="shrink-0 px-3 py-1.5 rounded-full text-sm transition-all border
|
||||
{mode === m.id
|
||||
? 'bg-blue-500 text-white border-blue-500'
|
||||
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
|
||||
onclick={() => (mode = m.id)}
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="p-6 rounded-xl bg-card border border-border space-y-4">
|
||||
{#if mode === 'compound-interest'}
|
||||
<h2 class="text-lg font-bold text-foreground">Zinseszinsrechner</h2>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Anfangskapital (€)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={principal}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Zinssatz (% p.a.)</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={rate}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Laufzeit (Jahre)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={years}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Zinsperioden/Jahr</span>
|
||||
<select
|
||||
bind:value={compoundsPerYear}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground"
|
||||
>
|
||||
<option value={1}>Jährlich</option>
|
||||
<option value={4}>Vierteljährlich</option>
|
||||
<option value={12}>Monatlich</option>
|
||||
<option value={365}>Täglich</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="pt-4 border-t border-border space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Endkapital</span><span
|
||||
class="font-bold font-mono text-foreground">{fmt(compoundResult().total)} €</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Zinsen</span><span class="font-mono text-emerald-500"
|
||||
>{fmt(compoundResult().interest)} €</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mode === 'loan'}
|
||||
<h2 class="text-lg font-bold text-foreground">Kreditrechner</h2>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Darlehensbetrag (€)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={loanAmount}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Zinssatz (% p.a.)</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={loanRate}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Laufzeit (Jahre)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={loanYears}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<div class="pt-4 border-t border-border space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Monatliche Rate</span><span
|
||||
class="font-bold font-mono text-foreground">{fmt(loanResult().monthly)} €</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Gesamtkosten</span><span
|
||||
class="font-mono text-foreground">{fmt(loanResult().total)} €</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Zinskosten</span><span class="font-mono text-red-400"
|
||||
>{fmt(loanResult().interest)} €</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mode === 'savings'}
|
||||
<h2 class="text-lg font-bold text-foreground">Sparplanrechner</h2>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Anfangseinlage (€)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={initialDeposit}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Monatliche Sparrate (€)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={monthlyDeposit}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Zinssatz (% p.a.)</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
bind:value={savingsRate}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Laufzeit (Jahre)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={savingsYears}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<div class="pt-4 border-t border-border space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Endkapital</span><span
|
||||
class="font-bold font-mono text-foreground">{fmt(savingsResult().total)} €</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Eingezahlt</span><span
|
||||
class="font-mono text-foreground">{fmt(savingsResult().deposited)} €</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Zinsen</span><span class="font-mono text-emerald-500"
|
||||
>{fmt(savingsResult().interest)} €</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else if mode === 'tip'}
|
||||
<h2 class="text-lg font-bold text-foreground">Trinkgeld & Split</h2>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Rechnungsbetrag (€)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={billAmount}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Trinkgeld (%)</span>
|
||||
<div class="flex gap-2 mt-1">
|
||||
{#each [10, 15, 20, 25] as pct}
|
||||
<button
|
||||
class="flex-1 h-10 rounded-lg text-sm transition-all border {tipPercent === pct
|
||||
? 'bg-amber-500 text-white border-amber-500'
|
||||
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
|
||||
onclick={() => (tipPercent = pct)}>{pct}%</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={tipPercent}
|
||||
class="mt-2 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Aufteilen auf (Personen)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={splitCount}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<div class="pt-4 border-t border-border space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Trinkgeld</span><span
|
||||
class="font-mono text-foreground">{fmt(tipResult().tip)} €</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Gesamt</span><span
|
||||
class="font-bold font-mono text-foreground">{fmt(tipResult().total)} €</span
|
||||
>
|
||||
</div>
|
||||
{#if splitCount > 1}
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Pro Person</span><span
|
||||
class="font-bold font-mono text-amber-500">{fmt(tipResult().perPerson)} €</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.finance-page {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
<script lang="ts">
|
||||
// Percentage calculator modes
|
||||
let mode = $state<'of' | 'change' | 'markup' | 'discount'>('of');
|
||||
|
||||
// X% of Y
|
||||
let percentValue = $state(15);
|
||||
let baseValue = $state(200);
|
||||
|
||||
// Percentage change
|
||||
let oldValue = $state(100);
|
||||
let newValue = $state(125);
|
||||
|
||||
// Markup/Discount
|
||||
let price = $state(100);
|
||||
let percentChange = $state(20);
|
||||
|
||||
let percentOfResult = $derived((baseValue * percentValue) / 100);
|
||||
|
||||
let changeResult = $derived(() => {
|
||||
if (oldValue === 0) return { percent: 0, diff: 0 };
|
||||
const diff = newValue - oldValue;
|
||||
const percent = (diff / oldValue) * 100;
|
||||
return { percent, diff };
|
||||
});
|
||||
|
||||
let markupResult = $derived(price * (1 + percentChange / 100));
|
||||
let discountResult = $derived(price * (1 - percentChange / 100));
|
||||
|
||||
function fmt(n: number): string {
|
||||
return parseFloat(n.toPrecision(10)).toLocaleString('de-DE', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const modes: { id: typeof mode; label: string }[] = [
|
||||
{ id: 'of', label: 'X% von Y' },
|
||||
{ id: 'change', label: 'Änderung' },
|
||||
{ id: 'markup', label: 'Aufschlag' },
|
||||
{ id: 'discount', label: 'Rabatt' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - Prozent</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="percent-page">
|
||||
<div class="flex gap-2 mb-6">
|
||||
{#each modes as m}
|
||||
<button
|
||||
class="flex-1 py-1.5 rounded-full text-sm transition-all border
|
||||
{mode === m.id
|
||||
? 'bg-rose-500 text-white border-rose-500'
|
||||
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
|
||||
onclick={() => (mode = m.id)}>{m.label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="p-6 rounded-xl bg-card border border-border space-y-4">
|
||||
{#if mode === 'of'}
|
||||
<h2 class="text-lg font-bold text-foreground">X% von Y</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
bind:value={percentValue}
|
||||
class="w-20 h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono text-center"
|
||||
/>
|
||||
<span class="text-muted-foreground">% von</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={baseValue}
|
||||
class="flex-1 h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div class="pt-4 border-t border-border text-center">
|
||||
<div class="text-3xl font-bold font-mono text-foreground">{fmt(percentOfResult)}</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{percentValue}% von {baseValue} = {fmt(percentOfResult)}
|
||||
</div>
|
||||
</div>
|
||||
{:else if mode === 'change'}
|
||||
<h2 class="text-lg font-bold text-foreground">Prozentuale Änderung</h2>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Alter Wert</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={oldValue}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Neuer Wert</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={newValue}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<div class="pt-4 border-t border-border text-center">
|
||||
<div
|
||||
class="text-3xl font-bold font-mono {changeResult().percent >= 0
|
||||
? 'text-emerald-500'
|
||||
: 'text-red-400'}"
|
||||
>
|
||||
{changeResult().percent >= 0 ? '+' : ''}{fmt(changeResult().percent)}%
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground">Differenz: {fmt(changeResult().diff)}</div>
|
||||
</div>
|
||||
{:else if mode === 'markup'}
|
||||
<h2 class="text-lg font-bold text-foreground">Preisaufschlag</h2>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Preis (€)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={price}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Aufschlag (%)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={percentChange}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<div class="pt-4 border-t border-border text-center">
|
||||
<div class="text-3xl font-bold font-mono text-foreground">{fmt(markupResult)} €</div>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{fmt(price)} + {percentChange}% = {fmt(markupResult)} €
|
||||
</div>
|
||||
</div>
|
||||
{:else if mode === 'discount'}
|
||||
<h2 class="text-lg font-bold text-foreground">Rabatt</h2>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Originalpreis (€)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={price}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-xs text-muted-foreground">Rabatt (%)</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={percentChange}
|
||||
class="mt-1 w-full h-10 px-3 rounded-lg bg-background border border-border text-foreground font-mono"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex gap-2 mt-1">
|
||||
{#each [10, 15, 20, 25, 50] as pct}
|
||||
<button
|
||||
class="flex-1 py-1 rounded text-xs border {percentChange === pct
|
||||
? 'bg-rose-500 text-white border-rose-500'
|
||||
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
|
||||
onclick={() => (percentChange = pct)}>{pct}%</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="pt-4 border-t border-border text-center">
|
||||
<div class="text-3xl font-bold font-mono text-foreground">{fmt(discountResult)} €</div>
|
||||
<div class="text-sm text-muted-foreground">Ersparnis: {fmt(price - discountResult)} €</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.percent-page {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { convertBase } from '$lib/engine/evaluate';
|
||||
import type { NumberBase } from '@calc/shared';
|
||||
|
||||
let inputValue = $state('0');
|
||||
let activeBase = $state<NumberBase>('dec');
|
||||
let error = $state('');
|
||||
|
||||
const bases: { id: NumberBase; label: string; radix: number }[] = [
|
||||
{ id: 'hex', label: 'HEX', radix: 16 },
|
||||
{ id: 'dec', label: 'DEC', radix: 10 },
|
||||
{ id: 'oct', label: 'OCT', radix: 8 },
|
||||
{ id: 'bin', label: 'BIN', radix: 2 },
|
||||
];
|
||||
|
||||
function getRadix(base: NumberBase): number {
|
||||
return bases.find((b) => b.id === base)!.radix;
|
||||
}
|
||||
|
||||
function getConverted(targetBase: NumberBase): string {
|
||||
if (!inputValue || inputValue === '0') return '0';
|
||||
try {
|
||||
return convertBase(inputValue, getRadix(activeBase), getRadix(targetBase));
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function appendDigit(digit: string) {
|
||||
error = '';
|
||||
if (inputValue === '0') {
|
||||
inputValue = digit;
|
||||
} else {
|
||||
inputValue += digit;
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
inputValue = '0';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function backspace() {
|
||||
inputValue = inputValue.length > 1 ? inputValue.slice(0, -1) : '0';
|
||||
}
|
||||
|
||||
function switchBase(newBase: NumberBase) {
|
||||
try {
|
||||
if (inputValue !== '0') {
|
||||
inputValue = convertBase(inputValue, getRadix(activeBase), getRadix(newBase));
|
||||
}
|
||||
} catch {
|
||||
inputValue = '0';
|
||||
}
|
||||
activeBase = newBase;
|
||||
}
|
||||
|
||||
// Available digits per base
|
||||
function isDigitValid(digit: string): boolean {
|
||||
const val = parseInt(digit, 16);
|
||||
return val < getRadix(activeBase);
|
||||
}
|
||||
|
||||
const hexDigits = [
|
||||
'0',
|
||||
'1',
|
||||
'2',
|
||||
'3',
|
||||
'4',
|
||||
'5',
|
||||
'6',
|
||||
'7',
|
||||
'8',
|
||||
'9',
|
||||
'A',
|
||||
'B',
|
||||
'C',
|
||||
'D',
|
||||
'E',
|
||||
'F',
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - Programmierer</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="programmer-page">
|
||||
<!-- Base selector -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
{#each bases as base}
|
||||
<button
|
||||
class="flex-1 py-2 rounded-lg text-sm font-medium transition-all border {activeBase ===
|
||||
base.id
|
||||
? 'bg-cyan-500 text-white border-cyan-500'
|
||||
: 'bg-card border-border text-muted-foreground hover:bg-muted'}"
|
||||
onclick={() => switchBase(base.id)}
|
||||
>
|
||||
{base.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Display all bases -->
|
||||
<div class="space-y-1 mb-6 p-4 rounded-xl bg-card border border-border">
|
||||
{#each bases as base}
|
||||
<div class="flex items-center gap-3 py-1">
|
||||
<span class="text-xs font-medium text-muted-foreground w-8">{base.label}</span>
|
||||
<span
|
||||
class="font-mono text-sm flex-1 truncate {activeBase === base.id
|
||||
? 'text-foreground font-bold text-lg'
|
||||
: 'text-muted-foreground'}"
|
||||
>
|
||||
{activeBase === base.id ? inputValue : getConverted(base.id)}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#if error}
|
||||
<div class="text-red-400 text-xs mt-1">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Keypad -->
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
{#each hexDigits as digit}
|
||||
<button
|
||||
class="h-12 rounded-lg border border-border font-mono text-sm transition-all active:scale-95
|
||||
{isDigitValid(digit)
|
||||
? 'bg-card text-foreground hover:bg-muted cursor-pointer'
|
||||
: 'bg-muted/30 text-muted-foreground/30 cursor-not-allowed'}"
|
||||
onclick={() => isDigitValid(digit) && appendDigit(digit)}
|
||||
disabled={!isDigitValid(digit)}
|
||||
>
|
||||
{digit}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
class="flex-1 h-10 rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||
onclick={clear}>C</button
|
||||
>
|
||||
<button
|
||||
class="flex-1 h-10 rounded-lg bg-muted/50 text-muted-foreground hover:bg-muted"
|
||||
onclick={backspace}>← DEL</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Bit info -->
|
||||
{#if activeBase === 'dec' && inputValue !== '0'}
|
||||
{@const num = parseInt(inputValue, 10)}
|
||||
{#if !isNaN(num)}
|
||||
<div class="mt-4 p-3 rounded-lg bg-muted/30 text-xs text-muted-foreground">
|
||||
<div class="font-mono">
|
||||
{num} = 0x{num.toString(16).toUpperCase()} = 0b{num.toString(2)}
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
Bits: {num.toString(2).length} | Bytes: {Math.ceil(num.toString(2).length / 8)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.programmer-page {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { evaluate, formatResult } from '$lib/engine/evaluate';
|
||||
import { calculationsStore } from '$lib/stores/calculations.svelte';
|
||||
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');
|
||||
|
||||
let expression = $state('');
|
||||
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)) {
|
||||
expression = '';
|
||||
display = '';
|
||||
hasResult = false;
|
||||
} else if (hasResult) {
|
||||
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: 'scientific',
|
||||
expression,
|
||||
result: formatted,
|
||||
skin: activeSkin,
|
||||
});
|
||||
display = formatted;
|
||||
hasResult = true;
|
||||
error = '';
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler';
|
||||
}
|
||||
}
|
||||
|
||||
let recentHistory = $derived(
|
||||
allCalculations.value.filter((c) => c.mode === 'scientific').slice(0, 8)
|
||||
);
|
||||
|
||||
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(', '^'],
|
||||
];
|
||||
|
||||
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';
|
||||
return 'bg-muted text-foreground hover:bg-muted/80';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - Wissenschaftlich</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="scientific-page">
|
||||
<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-1 rounded bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
onclick={() => (angleMode = angleMode === 'rad' ? 'deg' : 'rad')}
|
||||
>
|
||||
{angleMode.toUpperCase()}
|
||||
</button>
|
||||
<button
|
||||
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)}
|
||||
>
|
||||
f(x)
|
||||
</button>
|
||||
</div>
|
||||
</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}
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="history">
|
||||
<h3 class="text-sm font-medium text-muted-foreground mb-3">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 text-left p-2 rounded-lg hover:bg-muted/50 transition-colors"
|
||||
onclick={() => {
|
||||
expression = calc.result;
|
||||
display = calc.result;
|
||||
hasResult = true;
|
||||
}}
|
||||
>
|
||||
<div class="text-xs text-muted-foreground truncate font-mono">{calc.expression}</div>
|
||||
<div class="text-sm font-medium text-foreground font-mono">= {calc.result}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scientific-page {
|
||||
max-width: 750px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 180px;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.calculator-column {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.scientific-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.calculator-column {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,300 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { CALCULATOR_SKINS } from '@calc/shared/constants';
|
||||
import type { CalculatorSkin } from '@calc/shared';
|
||||
import { ModernSkin, HP35Skin, CasioSkin, TI84Skin, MinimalSkin } from '$lib/components/skins';
|
||||
|
||||
let previewSkin = $state<CalculatorSkin | null>(null);
|
||||
|
||||
// Demo props for preview
|
||||
const demoProps = {
|
||||
expression: '42 × 23',
|
||||
display: '966',
|
||||
error: '',
|
||||
onButton: () => {},
|
||||
onClear: () => {},
|
||||
onBackspace: () => {},
|
||||
onEquals: () => {},
|
||||
};
|
||||
|
||||
function selectSkin(skin: CalculatorSkin) {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('calc-skin', skin);
|
||||
}
|
||||
previewSkin = skin;
|
||||
}
|
||||
|
||||
const skinComponents: Record<CalculatorSkin, any> = {
|
||||
modern: ModernSkin,
|
||||
hp35: HP35Skin,
|
||||
'casio-fx': CasioSkin,
|
||||
ti84: TI84Skin,
|
||||
minimal: MinimalSkin,
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - Skins</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="skins-page">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-foreground">Rechner-Skins</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">Wähle das Aussehen deines Taschenrechners</p>
|
||||
</header>
|
||||
|
||||
<!-- Skin cards grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each CALCULATOR_SKINS as skin}
|
||||
<div
|
||||
class="skin-card rounded-2xl border border-border overflow-hidden transition-all hover:border-pink-500/50 hover:shadow-lg cursor-pointer"
|
||||
class:ring-2={previewSkin === skin.id}
|
||||
class:ring-pink-500={previewSkin === skin.id}
|
||||
onclick={() => selectSkin(skin.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<!-- Preview -->
|
||||
<div
|
||||
class="skin-preview p-4 bg-card overflow-hidden"
|
||||
style="max-height: 320px; pointer-events: none;"
|
||||
>
|
||||
<div class="scale-[0.65] origin-top-left" style="width: 153%; height: 153%;">
|
||||
{#if skin.id === 'modern'}
|
||||
<ModernSkin {...demoProps} />
|
||||
{:else if skin.id === 'hp35'}
|
||||
<HP35Skin {...demoProps} />
|
||||
{:else if skin.id === 'casio-fx'}
|
||||
<CasioSkin {...demoProps} />
|
||||
{:else if skin.id === 'ti84'}
|
||||
<TI84Skin {...demoProps} />
|
||||
{:else if skin.id === 'minimal'}
|
||||
<MinimalSkin {...demoProps} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="p-4 bg-background border-t border-border">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-bold text-foreground">{skin.label}</h3>
|
||||
{#if skin.year}
|
||||
<span class="text-xs text-pink-500 font-medium">{skin.year}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if previewSkin === skin.id}
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-pink-500 text-white">Aktiv</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground mt-1">{skin.description.de}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- History section -->
|
||||
<div class="mt-12">
|
||||
<h2 class="text-lg font-bold text-foreground mb-4">Geschichte des Taschenrechners</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 rounded-xl bg-card border border-border">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="text-2xl shrink-0">🏛️</span>
|
||||
<div>
|
||||
<h3 class="font-bold text-foreground">HP-35 (1972)</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Der HP-35 war der weltweit erste wissenschaftliche Taschenrechner. Entwickelt von
|
||||
Hewlett-Packard, machte er den Rechenschieber über Nacht obsolet. Sein Name kam daher,
|
||||
dass er 35 Tasten hatte. Preis: $395 (heute ~$2.800).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 rounded-xl bg-card border border-border">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="text-2xl shrink-0">🎒</span>
|
||||
<div>
|
||||
<h3 class="font-bold text-foreground">Casio fx-82 (1985)</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Die Casio fx-Serie wurde zum Synonym für Schulrechner weltweit. Mit Solarzelle und dem
|
||||
charakteristischen grün-grauen LCD-Display war er in fast jeder Schultasche zu finden.
|
||||
Über 100 Millionen Stück verkauft.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 rounded-xl bg-card border border-border">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="text-2xl shrink-0">📊</span>
|
||||
<div>
|
||||
<h3 class="font-bold text-foreground">TI-84 Plus (2004)</h3>
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
Der TI-84 Plus von Texas Instruments wurde zum Standard-Grafikrechner an
|
||||
amerikanischen High Schools und Universitäten. Er konnte Funktionen plotten, Programme
|
||||
ausführen und wurde trotz Smartphones nie abgelöst.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.skins-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.skin-preview {
|
||||
background-image: radial-gradient(circle at 50% 0%, hsl(var(--muted)) 0%, transparent 70%);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,264 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { evaluate, formatResult } from '$lib/engine/evaluate';
|
||||
import { calculationsStore } from '$lib/stores/calculations.svelte';
|
||||
import { CALCULATOR_SKINS } 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');
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
// Skin state — persisted to localStorage
|
||||
let activeSkin = $state<CalculatorSkin>('modern');
|
||||
let showSkinPicker = $state(false);
|
||||
|
||||
// Load saved skin
|
||||
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.value.filter((c) => c.mode === 'standard').slice(0, 10)
|
||||
);
|
||||
|
||||
// Shared props for all skins
|
||||
let skinProps = $derived({
|
||||
expression,
|
||||
display,
|
||||
error,
|
||||
copied,
|
||||
onButton: appendToExpression,
|
||||
onClear: clear,
|
||||
onBackspace: backspace,
|
||||
onEquals: calculate,
|
||||
onCopy: copyToClipboard,
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - Standard</title>
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="calculator-page">
|
||||
<div class="calculator-column">
|
||||
<!-- Skin picker toggle -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<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'}
|
||||
{#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="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}
|
||||
|
||||
<!-- 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="text-sm font-medium text-muted-foreground mb-3">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 text-left p-2 rounded-lg hover:bg-muted/50 transition-colors group"
|
||||
onclick={() => {
|
||||
expression = calc.result;
|
||||
display = calc.result;
|
||||
hasResult = true;
|
||||
}}
|
||||
>
|
||||
<div class="text-xs text-muted-foreground truncate font-mono">{calc.expression}</div>
|
||||
<div class="text-sm font-medium text-foreground font-mono">= {calc.result}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
class="mt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
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>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - forgot-password</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<p class="text-muted-foreground">Auth: forgot-password</p>
|
||||
</div>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - login</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<p class="text-muted-foreground">Auth: login</p>
|
||||
</div>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - register</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<p class="text-muted-foreground">Auth: register</p>
|
||||
</div>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Calc - reset-password</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<p class="text-muted-foreground">Auth: reset-password</p>
|
||||
</div>
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { isLoading as isLocaleLoading } from 'svelte-i18n';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { waitLocale } from '$lib/i18n';
|
||||
import { ToastContainer, setupGlobalErrorHandler } from '@manacore/shared-ui';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
const cleanupErrorHandler = setupGlobalErrorHandler();
|
||||
|
||||
const init = async () => {
|
||||
await waitLocale();
|
||||
theme.initialize();
|
||||
await authStore.initialize();
|
||||
loading = false;
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return cleanupErrorHandler;
|
||||
});
|
||||
</script>
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
{#if $isLocaleLoading || loading}
|
||||
<AppLoadingSkeleton />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
// Disable SSR — all data is local-first (IndexedDB + mana-sync)
|
||||
export const ssr = false;
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
return json({ status: 'ok', app: 'calc-web' });
|
||||
};
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<svelte:head>
|
||||
<title>Calc - Offline</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="text-6xl mb-4">🧮</div>
|
||||
<h1 class="text-2xl font-bold text-foreground mb-2">Offline</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Calc funktioniert auch offline. Deine Daten sind lokal gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build',
|
||||
}),
|
||||
prerender: {
|
||||
handleHttpError: ({ path, message }) => {
|
||||
if (path === '/favicon.png') return;
|
||||
throw new Error(message);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
import { createPWAConfig } from '@manacore/shared-pwa';
|
||||
import { MANACORE_SHARED_PACKAGES, getBuildDefines } from '@manacore/shared-vite-config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
SvelteKitPWA(
|
||||
createPWAConfig({
|
||||
name: 'Calc - Taschenrechner',
|
||||
shortName: 'Calc',
|
||||
description: 'Taschenrechner, Einheiten & Finanzen',
|
||||
themeColor: '#ec4899',
|
||||
preset: 'minimal',
|
||||
})
|
||||
),
|
||||
],
|
||||
server: {
|
||||
port: 5198,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [...MANACORE_SHARED_PACKAGES],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [...MANACORE_SHARED_PACKAGES],
|
||||
},
|
||||
define: {
|
||||
...getBuildDefines(),
|
||||
},
|
||||
});
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM sveltekit-base:local AS builder
|
||||
|
||||
ARG PUBLIC_BACKEND_URL=http://calendar-server
|
||||
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-auth:3001
|
||||
ENV PUBLIC_BACKEND_URL=$PUBLIC_BACKEND_URL
|
||||
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
||||
|
||||
COPY apps/calendar/packages/shared ./apps/calendar/packages/shared
|
||||
COPY apps/calendar/apps/web ./apps/calendar/apps/web
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --no-frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app/apps/calendar/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/calendar/apps/web
|
||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
COPY --from=builder /app/apps/calendar/apps/web/node_modules ./node_modules
|
||||
COPY --from=builder /app/apps/calendar/apps/web/build ./build
|
||||
COPY --from=builder /app/apps/calendar/apps/web/package.json ./
|
||||
|
||||
EXPOSE 5012
|
||||
ENV NODE_ENV=production PORT=5012 HOST=0.0.0.0
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5012/health || exit 1
|
||||
|
||||
CMD ["node", "build"]
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Auth tests run WITHOUT storageState (unauthenticated)
|
||||
|
||||
// Helper: wait for the app to finish loading (skeleton disappears)
|
||||
async function waitForAppReady(page: import('@playwright/test').Page) {
|
||||
// The root layout shows AppLoadingSkeleton until auth initializes
|
||||
// Wait for it to disappear and the actual page content to render
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
// Check if loading skeleton is gone
|
||||
const skeleton = document.querySelector('.app-loading-skeleton, [data-skeleton]');
|
||||
return !skeleton || skeleton.children.length === 0;
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
// Give Svelte time to render
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe('Authentication', () => {
|
||||
test('login page renders with email and password fields', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await waitForAppReady(page);
|
||||
|
||||
// LoginPage uses id="email" and id="password" (from shared-auth-ui)
|
||||
const emailInput = page.locator('input[type="email"], input[name="email"], #email');
|
||||
const passwordInput = page.locator('input[type="password"], input[name="password"], #password');
|
||||
|
||||
await expect(emailInput.first()).toBeVisible({ timeout: 10000 });
|
||||
await expect(passwordInput.first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('button[type="submit"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('invalid credentials show error message', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const emailInput = page.locator('input[type="email"], input[name="email"], #email').first();
|
||||
const passwordInput = page
|
||||
.locator('input[type="password"], input[name="password"], #password')
|
||||
.first();
|
||||
|
||||
await emailInput.fill('nonexistent@test.local');
|
||||
await passwordInput.fill('WrongPassword123!');
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// Error alert should appear
|
||||
const errorAlert = page.locator('#form-error, [role="alert"]');
|
||||
await expect(errorAlert.first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('successful login redirects to calendar', async ({ page }) => {
|
||||
const email = process.env.E2E_TEST_EMAIL || 'e2e-calendar@test.local';
|
||||
const password = process.env.E2E_TEST_PASSWORD || 'TestPassword123';
|
||||
|
||||
// Listen for console errors and network failures
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
page.on('requestfailed', (req) => {
|
||||
errors.push(`Request failed: ${req.url()} - ${req.failure()?.errorText}`);
|
||||
});
|
||||
|
||||
await page.goto('/login');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const emailInput = page.locator('input[type="email"], input[name="email"], #email').first();
|
||||
const passwordInput = page
|
||||
.locator('input[type="password"], input[name="password"], #password')
|
||||
.first();
|
||||
|
||||
await emailInput.fill(email);
|
||||
await passwordInput.fill(password);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// Wait for either redirect or error
|
||||
try {
|
||||
await page.waitForURL('/', { timeout: 20000 });
|
||||
} catch {
|
||||
// Log any errors for debugging
|
||||
console.log('Login errors:', errors);
|
||||
const authUrl = await page.evaluate(
|
||||
() => (window as any).__PUBLIC_MANA_CORE_AUTH_URL__ || 'NOT SET'
|
||||
);
|
||||
console.log('Auth URL on page:', authUrl);
|
||||
throw new Error(`Login did not redirect. Auth URL: ${authUrl}. Errors: ${errors.join('; ')}`);
|
||||
}
|
||||
await expect(page.locator('main[aria-label="Kalender"]')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('unauthenticated access to / redirects to /login', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// The app layout's onMount redirects unauthenticated users to /login
|
||||
await page.waitForURL(/\/login/, { timeout: 30000 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import { test, expect, dismissOnboarding } from './fixtures/auth';
|
||||
|
||||
test.describe('Calendar Views', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await dismissOnboarding(page);
|
||||
// Wait for calendar to be fully loaded
|
||||
await expect(page.locator('main[aria-label="Kalender"]')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('week view loads as default with day columns', async ({ page }) => {
|
||||
// ViewsBar "7" button should be active (week view)
|
||||
const weekButton = page.locator('button[title="Wochenansicht"]');
|
||||
await expect(weekButton).toBeVisible();
|
||||
await expect(weekButton).toHaveClass(/active/);
|
||||
|
||||
// Week view grid should show day columns with hour rows
|
||||
const calendarContent = page.locator('.calendar-content');
|
||||
await expect(calendarContent).toBeVisible();
|
||||
});
|
||||
|
||||
test('switch to month view via header button', async ({ page }) => {
|
||||
const monthButton = page.locator('button[title="Monatsansicht"]');
|
||||
await expect(monthButton).toBeVisible();
|
||||
await monthButton.click();
|
||||
|
||||
// Month button should now be active
|
||||
await expect(monthButton).toHaveClass(/active/);
|
||||
|
||||
// Week button should no longer be active
|
||||
const weekButton = page.locator('button[title="Wochenansicht"]');
|
||||
await expect(weekButton).not.toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('switch to agenda view', async ({ page }) => {
|
||||
const agendaButton = page.locator('button[title="Agenda"]');
|
||||
await expect(agendaButton).toBeVisible();
|
||||
await agendaButton.click();
|
||||
|
||||
// Agenda button should now be active
|
||||
await expect(agendaButton).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('navigate forward and backward with arrow keys', async ({ page }) => {
|
||||
// Click on the day-header area (non-interactive) to ensure body focus
|
||||
await page.locator('body').click({ position: { x: 10, y: 10 } });
|
||||
// Dismiss any overlay that might have opened
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Get all day-header aria-labels to identify the current week
|
||||
const dayHeaders = page.locator('.day-header[aria-label]');
|
||||
const initialLabel = await dayHeaders.first().getAttribute('aria-label');
|
||||
|
||||
// Navigate forward one week with ArrowRight
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const afterForwardLabel = await dayHeaders.first().getAttribute('aria-label');
|
||||
// The first day header should show a different date after navigating
|
||||
expect(afterForwardLabel).not.toBe(initialLabel);
|
||||
|
||||
// Navigate backward with ArrowLeft
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const afterBackLabel = await dayHeaders.first().getAttribute('aria-label');
|
||||
// After going forward then back, we should be at the same date
|
||||
expect(afterBackLabel).toBe(initialLabel);
|
||||
});
|
||||
|
||||
test('today button returns to current date after navigation', async ({ page }) => {
|
||||
// Click on the day-header area and dismiss any overlay
|
||||
await page.locator('body').click({ position: { x: 10, y: 10 } });
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Get today's day header
|
||||
const todayHeader = page.locator('.day-header.today');
|
||||
await expect(todayHeader).toBeVisible();
|
||||
|
||||
// Navigate away from today
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Today should no longer be visible (navigated 2 weeks ahead)
|
||||
await expect(todayHeader).not.toBeVisible();
|
||||
|
||||
// Click the "Heute" (Today) button - find by its title attribute
|
||||
const todayButton = page.locator(
|
||||
'.today-button, button[title*="heute" i], button[title*="today" i]'
|
||||
);
|
||||
await expect(todayButton.first()).toBeVisible({ timeout: 5000 });
|
||||
await todayButton.first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Today header should be visible again
|
||||
await expect(todayHeader).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { test, expect } from './fixtures/auth';
|
||||
|
||||
const BACKEND_URL = process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
|
||||
|
||||
test.describe('Calendar Management', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Skip all calendar management tests if the backend is not running
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/v1/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) test.skip(true, 'Calendar backend is not running');
|
||||
} catch {
|
||||
test.skip(true, 'Calendar backend is not reachable');
|
||||
}
|
||||
});
|
||||
|
||||
test('default calendar exists on first load', async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
await expect(page.getByRole('heading', { name: 'Einstellungen', exact: true })).toBeVisible();
|
||||
|
||||
// The calendar list should have at least one calendar with "Standard" badge
|
||||
const defaultBadge = page.locator('.badge-primary', { hasText: 'Standard' });
|
||||
await expect(defaultBadge).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('create new calendar with name and color', async ({ page }) => {
|
||||
const calendarName = `E2E Calendar ${Date.now()}`;
|
||||
|
||||
await page.goto('/settings');
|
||||
await expect(page.getByRole('heading', { name: 'Einstellungen', exact: true })).toBeVisible();
|
||||
|
||||
// Click "Neuer Kalender" button
|
||||
const newCalButton = page.getByRole('button', { name: /neuer kalender/i });
|
||||
await expect(newCalButton).toBeVisible({ timeout: 10000 });
|
||||
await newCalButton.click();
|
||||
|
||||
// Fill in the calendar name
|
||||
const nameInput = page.locator('.new-calendar-form input[type="text"]');
|
||||
await expect(nameInput).toBeVisible();
|
||||
await nameInput.fill(calendarName);
|
||||
|
||||
// Submit the form
|
||||
const createButton = page.getByRole('button', { name: /erstellen/i });
|
||||
await createButton.click();
|
||||
|
||||
// Verify the new calendar appears in the list
|
||||
const calendarCard = page.locator('.calendar-card', { hasText: calendarName });
|
||||
await expect(calendarCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Cleanup: delete the calendar
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
const deleteButton = calendarCard.getByRole('button', { name: /löschen/i });
|
||||
await deleteButton.click();
|
||||
await expect(calendarCard).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('toggle calendar visibility in sidebar', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('main[aria-label="Kalender"]')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const calendarSelector = page.locator('.pill-calendar-selector, .calendar-selector');
|
||||
|
||||
if (await calendarSelector.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
const toggles = calendarSelector.locator('button, input[type="checkbox"]');
|
||||
const count = await toggles.count();
|
||||
|
||||
if (count > 0) {
|
||||
await toggles.first().click();
|
||||
await page.waitForTimeout(500);
|
||||
await toggles.first().click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('delete non-default calendar from settings', async ({ page }) => {
|
||||
const calendarName = `E2E Delete Test ${Date.now()}`;
|
||||
|
||||
await page.goto('/settings');
|
||||
await expect(page.getByRole('heading', { name: 'Einstellungen', exact: true })).toBeVisible();
|
||||
|
||||
// Create a calendar first
|
||||
await page.getByRole('button', { name: /neuer kalender/i }).click();
|
||||
await page.locator('.new-calendar-form input[type="text"]').fill(calendarName);
|
||||
await page.getByRole('button', { name: /erstellen/i }).click();
|
||||
|
||||
const calendarCard = page.locator('.calendar-card', { hasText: calendarName });
|
||||
await expect(calendarCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
page.on('dialog', (dialog) => dialog.accept());
|
||||
|
||||
const deleteButton = calendarCard.getByRole('button', { name: /löschen/i });
|
||||
await expect(deleteButton).toBeVisible();
|
||||
await deleteButton.click();
|
||||
|
||||
await expect(calendarCard).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Error Page', () => {
|
||||
test('visiting a nonexistent route shows error page with status code', async ({ page }) => {
|
||||
const response = await page.goto('/nonexistent-route-that-does-not-exist');
|
||||
|
||||
// SvelteKit should return a 404 status
|
||||
expect(response?.status()).toBe(404);
|
||||
|
||||
// The error page should display the status code
|
||||
const statusHeading = page.locator('h1');
|
||||
await expect(statusHeading).toBeVisible({ timeout: 10000 });
|
||||
await expect(statusHeading).toContainText('404');
|
||||
|
||||
// Should show a "back to home" link
|
||||
const backLink = page.locator('a[href="/"]');
|
||||
await expect(backLink).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import { test, expect } from './fixtures/auth';
|
||||
|
||||
const BACKEND_URL = process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
|
||||
|
||||
test.describe('Event CRUD', () => {
|
||||
test.beforeAll(async () => {
|
||||
// Skip all event tests if the backend is not running
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/v1/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) test.skip(true, 'Calendar backend is not running');
|
||||
} catch {
|
||||
test.skip(true, 'Calendar backend is not reachable');
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('main[aria-label="Kalender"]')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('create event via quick overlay, see it in view, then delete it', async ({ page }) => {
|
||||
const uniqueTitle = `E2E Test Event ${Date.now()}`;
|
||||
|
||||
// Click on a time slot in the week view to trigger quick create
|
||||
const weekGrid = page.locator('.week-grid, .carousel-page.current .week-grid');
|
||||
if (await weekGrid.first().isVisible()) {
|
||||
const box = await weekGrid.first().boundingBox();
|
||||
if (box) {
|
||||
await weekGrid.first().click({
|
||||
position: { x: box.width * 0.5, y: box.height * 0.3 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the quick event overlay to appear
|
||||
const overlay = page.locator('.quick-event-overlay');
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Type the event title (the title input is auto-focused)
|
||||
await page.keyboard.type(uniqueTitle);
|
||||
|
||||
// Click "Speichern" (Save)
|
||||
await overlay.getByRole('button', { name: /speichern/i }).click();
|
||||
await expect(overlay).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the event appears in the calendar view
|
||||
const eventCard = page.locator('.event-card, .event-block').filter({ hasText: uniqueTitle });
|
||||
await expect(eventCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the event to open it
|
||||
await eventCard.click();
|
||||
|
||||
// The quick event overlay should open with event details
|
||||
const editOverlay = page.locator('.quick-event-overlay');
|
||||
await expect(editOverlay).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Delete the event
|
||||
const deleteButton = editOverlay.getByRole('button', { name: /löschen/i });
|
||||
if (await deleteButton.isVisible()) {
|
||||
await deleteButton.click();
|
||||
|
||||
const confirmButton = page.getByRole('button', { name: /löschen|ja|bestätigen/i });
|
||||
if (await confirmButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await confirmButton.click();
|
||||
}
|
||||
|
||||
await expect(eventCard).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('edit event title and verify update', async ({ page }) => {
|
||||
const originalTitle = `E2E Edit Test ${Date.now()}`;
|
||||
const updatedTitle = `${originalTitle} Updated`;
|
||||
|
||||
// Create an event first via the grid
|
||||
const weekGrid = page.locator('.week-grid, .carousel-page.current .week-grid');
|
||||
if (await weekGrid.first().isVisible()) {
|
||||
const box = await weekGrid.first().boundingBox();
|
||||
if (box) {
|
||||
await weekGrid.first().click({
|
||||
position: { x: box.width * 0.5, y: box.height * 0.4 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const overlay = page.locator('.quick-event-overlay');
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 });
|
||||
await page.keyboard.type(originalTitle);
|
||||
await overlay.getByRole('button', { name: /speichern/i }).click();
|
||||
await expect(overlay).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Find and click the created event
|
||||
const eventCard = page.locator('.event-card, .event-block').filter({ hasText: originalTitle });
|
||||
await expect(eventCard).toBeVisible({ timeout: 5000 });
|
||||
await eventCard.click();
|
||||
|
||||
// Edit the title
|
||||
const editOverlay = page.locator('.quick-event-overlay');
|
||||
await expect(editOverlay).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const titleInput = editOverlay.locator('input[type="text"]').first();
|
||||
await expect(titleInput).toHaveValue(originalTitle);
|
||||
|
||||
await titleInput.clear();
|
||||
await titleInput.fill(updatedTitle);
|
||||
|
||||
await editOverlay.getByRole('button', { name: /speichern/i }).click();
|
||||
await expect(editOverlay).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify updated title is visible
|
||||
const updatedCard = page.locator('.event-card, .event-block').filter({ hasText: updatedTitle });
|
||||
await expect(updatedCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Cleanup: delete the event
|
||||
await updatedCard.click();
|
||||
const cleanupOverlay = page.locator('.quick-event-overlay');
|
||||
await expect(cleanupOverlay).toBeVisible({ timeout: 5000 });
|
||||
const deleteBtn = cleanupOverlay.getByRole('button', { name: /löschen/i });
|
||||
if (await deleteBtn.isVisible()) {
|
||||
await deleteBtn.click();
|
||||
const confirmBtn = page.getByRole('button', { name: /löschen|ja|bestätigen/i });
|
||||
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await confirmBtn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('click event to open detail overlay', async ({ page }) => {
|
||||
const title = `E2E Detail Test ${Date.now()}`;
|
||||
|
||||
// Create an event
|
||||
const weekGrid = page.locator('.week-grid, .carousel-page.current .week-grid');
|
||||
if (await weekGrid.first().isVisible()) {
|
||||
const box = await weekGrid.first().boundingBox();
|
||||
if (box) {
|
||||
await weekGrid.first().click({
|
||||
position: { x: box.width * 0.5, y: box.height * 0.5 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const overlay = page.locator('.quick-event-overlay');
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 });
|
||||
await page.keyboard.type(title);
|
||||
await overlay.getByRole('button', { name: /speichern/i }).click();
|
||||
await expect(overlay).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Click the event to see details
|
||||
const eventCard = page.locator('.event-card, .event-block').filter({ hasText: title });
|
||||
await expect(eventCard).toBeVisible({ timeout: 5000 });
|
||||
await eventCard.click();
|
||||
|
||||
const detailOverlay = page.locator('.quick-event-overlay');
|
||||
await expect(detailOverlay).toBeVisible({ timeout: 5000 });
|
||||
const titleInput = detailOverlay.locator('input[type="text"]').first();
|
||||
await expect(titleInput).toHaveValue(title);
|
||||
|
||||
// Close and cleanup
|
||||
await detailOverlay.getByRole('button', { name: /abbrechen/i }).click();
|
||||
await expect(detailOverlay).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
await eventCard.click();
|
||||
const cleanupOverlay = page.locator('.quick-event-overlay');
|
||||
const deleteBtn = cleanupOverlay.getByRole('button', { name: /löschen/i });
|
||||
if (await deleteBtn.isVisible()) {
|
||||
await deleteBtn.click();
|
||||
const confirmBtn = page.getByRole('button', { name: /löschen|ja|bestätigen/i });
|
||||
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await confirmBtn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
import { test as base, expect, type Page, type BrowserContext } from '@playwright/test';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-calendar@test.local';
|
||||
const TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'TestPassword123';
|
||||
const AUTH_URL = process.env.MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const STORAGE_STATE_PATH = path.join(__dirname, '..', '.auth-state.json');
|
||||
|
||||
/**
|
||||
* Ensures a test user exists via the auth API.
|
||||
*/
|
||||
async function ensureTestUser(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`${AUTH_URL}/api/v1/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: TEST_EMAIL, password: TEST_PASSWORD, name: 'E2E Test User' }),
|
||||
});
|
||||
if (!res.ok && res.status !== 409 && res.status !== 422) {
|
||||
const body = await res.text();
|
||||
console.warn(`Register returned ${res.status}: ${body}`);
|
||||
}
|
||||
} catch {
|
||||
// User may already exist
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`${AUTH_URL}/api/v1/auth/verify-email-dev`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: TEST_EMAIL }),
|
||||
});
|
||||
} catch {
|
||||
// Verification endpoint may not exist
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAppReady(page: Page): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('main, form, input[type="email"], #email') !== null,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the onboarding modal by clicking "Überspringen".
|
||||
* Waits briefly for it to appear, then dismisses it.
|
||||
*/
|
||||
async function dismissOnboarding(page: Page): Promise<void> {
|
||||
try {
|
||||
const skipButton = page.getByText('Überspringen', { exact: true });
|
||||
await skipButton.waitFor({ state: 'visible', timeout: 3000 });
|
||||
await skipButton.click();
|
||||
// Wait for modal to close
|
||||
await page.locator('.fixed.inset-0.z-50').waitFor({ state: 'hidden', timeout: 5000 });
|
||||
} catch {
|
||||
// No onboarding modal — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
function hasValidStorageState(): boolean {
|
||||
try {
|
||||
const stat = fs.statSync(STORAGE_STATE_PATH);
|
||||
const ageMs = Date.now() - stat.mtimeMs;
|
||||
if (ageMs > 60 * 60 * 1000) return false;
|
||||
const content = JSON.parse(fs.readFileSync(STORAGE_STATE_PATH, 'utf-8'));
|
||||
return content.origins?.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginViaUI(page: Page): Promise<void> {
|
||||
await page.goto('/login');
|
||||
await waitForAppReady(page);
|
||||
|
||||
const emailInput = page.locator('input[type="email"], input[name="email"], #email').first();
|
||||
const passwordInput = page
|
||||
.locator('input[type="password"], input[name="password"], #password')
|
||||
.first();
|
||||
|
||||
await emailInput.fill(TEST_EMAIL);
|
||||
await passwordInput.fill(TEST_PASSWORD);
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30000 });
|
||||
await expect(page.locator('main').first()).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Dismiss onboarding wizard if it appears
|
||||
await dismissOnboarding(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended test fixture that provides an authenticated page.
|
||||
*/
|
||||
export const test = base.extend<object, { workerStorageState: string }>({
|
||||
workerStorageState: [
|
||||
async ({ browser }, use) => {
|
||||
if (hasValidStorageState()) {
|
||||
await use(STORAGE_STATE_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureTestUser();
|
||||
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await loginViaUI(page);
|
||||
|
||||
await context.storageState({ path: STORAGE_STATE_PATH });
|
||||
await page.close();
|
||||
await context.close();
|
||||
|
||||
await use(STORAGE_STATE_PATH);
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
context: async ({ browser, workerStorageState }, use) => {
|
||||
const context = await browser.newContext({ storageState: workerStorageState });
|
||||
await use(context);
|
||||
await context.close();
|
||||
},
|
||||
|
||||
page: async ({ context }, use) => {
|
||||
const page = await context.newPage();
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect, dismissOnboarding };
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import { test, expect, dismissOnboarding } from './fixtures/auth';
|
||||
|
||||
test.describe('Settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
await dismissOnboarding(page);
|
||||
await expect(page.getByRole('heading', { name: 'Einstellungen', exact: true })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
});
|
||||
|
||||
test('settings page renders all sections', async ({ page }) => {
|
||||
// Check that the main setting sections are visible (use headings to avoid ambiguity)
|
||||
await expect(page.getByText('Meine Kalender', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Kalender-Ansicht', { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Termine' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Konto' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('change time format between 24h and 12h', async ({ page }) => {
|
||||
// Find the time format buttons
|
||||
const button24h = page.getByRole('button', { name: '24h (14:00)' });
|
||||
const button12h = page.getByRole('button', { name: '12h (2:00 PM)' });
|
||||
|
||||
await expect(button24h).toBeVisible();
|
||||
await expect(button12h).toBeVisible();
|
||||
|
||||
// Switch to 12h
|
||||
await button12h.click();
|
||||
await expect(button12h).toHaveClass(/active/);
|
||||
await expect(button24h).not.toHaveClass(/active/);
|
||||
|
||||
// Switch back to 24h
|
||||
await button24h.click();
|
||||
await expect(button24h).toHaveClass(/active/);
|
||||
await expect(button12h).not.toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('toggle show week numbers', async ({ page }) => {
|
||||
// Find the "Wochennummern anzeigen" checkbox
|
||||
const weekNumbersLabel = page.getByText('Wochennummern anzeigen');
|
||||
await expect(weekNumbersLabel).toBeVisible();
|
||||
|
||||
// The checkbox is inside a label with this text
|
||||
const checkbox = page
|
||||
.locator('label')
|
||||
.filter({ hasText: 'Wochennummern anzeigen' })
|
||||
.locator('input[type="checkbox"]');
|
||||
const wasChecked = await checkbox.isChecked();
|
||||
|
||||
// Toggle it
|
||||
await checkbox.click();
|
||||
await expect(checkbox).toBeChecked({ checked: !wasChecked });
|
||||
|
||||
// Toggle it back
|
||||
await checkbox.click();
|
||||
await expect(checkbox).toBeChecked({ checked: wasChecked });
|
||||
});
|
||||
|
||||
test('toggle show only weekdays', async ({ page }) => {
|
||||
const checkbox = page
|
||||
.locator('label')
|
||||
.filter({ hasText: 'Nur Werktage anzeigen' })
|
||||
.locator('input[type="checkbox"]');
|
||||
await expect(checkbox).toBeVisible();
|
||||
|
||||
const wasChecked = await checkbox.isChecked();
|
||||
await checkbox.click();
|
||||
await expect(checkbox).toBeChecked({ checked: !wasChecked });
|
||||
|
||||
// Restore original state
|
||||
await checkbox.click();
|
||||
await expect(checkbox).toBeChecked({ checked: wasChecked });
|
||||
});
|
||||
|
||||
test('settings persist after page reload', async ({ page }) => {
|
||||
// Switch to 12h format
|
||||
const button12h = page.getByRole('button', { name: '12h (2:00 PM)' });
|
||||
await button12h.click();
|
||||
await expect(button12h).toHaveClass(/active/);
|
||||
|
||||
// Reload
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { name: 'Einstellungen', exact: true })).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Verify 12h is still active
|
||||
const button12hAfterReload = page.getByRole('button', { name: '12h (2:00 PM)' });
|
||||
await expect(button12hAfterReload).toHaveClass(/active/);
|
||||
|
||||
// Restore to 24h
|
||||
const button24h = page.getByRole('button', { name: '24h (14:00)' });
|
||||
await button24h.click();
|
||||
await expect(button24h).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('user email is displayed in account section', async ({ page }) => {
|
||||
const testEmail = process.env.E2E_TEST_EMAIL || 'e2e-calendar@test.local';
|
||||
|
||||
// The account section shows the user's email
|
||||
const emailDisplay = page.locator('.setting-value');
|
||||
await expect(emailDisplay.first()).toContainText(testEmail);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
import { test, expect, dismissOnboarding } from './fixtures/auth';
|
||||
|
||||
const BACKEND_URL = process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
|
||||
|
||||
test.describe('WeekView Interactions', () => {
|
||||
test.beforeAll(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/v1/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (!res.ok) test.skip(true, 'Calendar backend is not running');
|
||||
} catch {
|
||||
test.skip(true, 'Calendar backend is not reachable');
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await dismissOnboarding(page);
|
||||
await expect(page.locator('main[aria-label="Kalender"]')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('drag-to-create: clicking on empty time slot opens quick create overlay', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Find a day column in the week view
|
||||
const dayColumn = page.locator('.day-column').first();
|
||||
await expect(dayColumn).toBeVisible();
|
||||
|
||||
const box = await dayColumn.boundingBox();
|
||||
if (!box) return;
|
||||
|
||||
// Click in the middle of the day column (should open quick create)
|
||||
await dayColumn.click({ position: { x: box.width / 2, y: box.height * 0.4 } });
|
||||
|
||||
// Quick event overlay should appear
|
||||
const overlay = page.locator('.quick-event-overlay');
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Close it
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('drag-to-create: drag creates event with correct time range', async ({ page }) => {
|
||||
const dayColumn = page.locator('.day-column').first();
|
||||
await expect(dayColumn).toBeVisible();
|
||||
|
||||
const box = await dayColumn.boundingBox();
|
||||
if (!box) return;
|
||||
|
||||
// Drag from ~10am to ~12pm area
|
||||
const startY = box.y + box.height * 0.35;
|
||||
const endY = box.y + box.height * 0.5;
|
||||
const centerX = box.x + box.width / 2;
|
||||
|
||||
await page.mouse.move(centerX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX, endY, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Quick event overlay should appear
|
||||
const overlay = page.locator('.quick-event-overlay');
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Type a title and save
|
||||
const uniqueTitle = `Drag Create ${Date.now()}`;
|
||||
await page.keyboard.type(uniqueTitle);
|
||||
await overlay.getByRole('button', { name: /speichern/i }).click();
|
||||
await expect(overlay).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Event should appear in the grid
|
||||
const eventCard = page.locator('.event-card').filter({ hasText: uniqueTitle });
|
||||
await expect(eventCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Cleanup: delete the event
|
||||
await eventCard.click();
|
||||
const editOverlay = page.locator('.quick-event-overlay');
|
||||
await expect(editOverlay).toBeVisible({ timeout: 5000 });
|
||||
const deleteBtn = editOverlay.getByRole('button', { name: /löschen/i });
|
||||
if (await deleteBtn.isVisible()) {
|
||||
await deleteBtn.click();
|
||||
const confirmBtn = page.getByRole('button', { name: /löschen|ja|bestätigen/i });
|
||||
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await confirmBtn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('escape cancels drag-to-create', async ({ page }) => {
|
||||
const dayColumn = page.locator('.day-column').first();
|
||||
await expect(dayColumn).toBeVisible();
|
||||
|
||||
const box = await dayColumn.boundingBox();
|
||||
if (!box) return;
|
||||
|
||||
const startY = box.y + box.height * 0.3;
|
||||
const centerX = box.x + box.width / 2;
|
||||
|
||||
// Start dragging
|
||||
await page.mouse.move(centerX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(centerX, startY + 50, { steps: 3 });
|
||||
|
||||
// Press escape to cancel
|
||||
await page.keyboard.press('Escape');
|
||||
await page.mouse.up();
|
||||
|
||||
// No overlay should appear
|
||||
const overlay = page.locator('.quick-event-overlay');
|
||||
await expect(overlay).not.toBeVisible({ timeout: 1000 });
|
||||
});
|
||||
|
||||
test('event card shows in correct position within time grid', async ({ page }) => {
|
||||
const uniqueTitle = `Position Test ${Date.now()}`;
|
||||
|
||||
// Create an event by clicking on the grid
|
||||
const dayColumn = page.locator('.day-column').first();
|
||||
await expect(dayColumn).toBeVisible();
|
||||
const box = await dayColumn.boundingBox();
|
||||
if (!box) return;
|
||||
|
||||
await dayColumn.click({ position: { x: box.width / 2, y: box.height * 0.5 } });
|
||||
|
||||
const overlay = page.locator('.quick-event-overlay');
|
||||
await expect(overlay).toBeVisible({ timeout: 5000 });
|
||||
await page.keyboard.type(uniqueTitle);
|
||||
await overlay.getByRole('button', { name: /speichern/i }).click();
|
||||
await expect(overlay).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify the event card exists and has a top style (positioned in grid)
|
||||
const eventCard = page.locator('.event-card').filter({ hasText: uniqueTitle });
|
||||
await expect(eventCard).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const style = await eventCard.getAttribute('style');
|
||||
expect(style).toContain('top:');
|
||||
expect(style).toContain('height:');
|
||||
|
||||
// Cleanup
|
||||
await eventCard.click();
|
||||
const editOverlay = page.locator('.quick-event-overlay');
|
||||
const deleteBtn = editOverlay.getByRole('button', { name: /löschen/i });
|
||||
if (await deleteBtn.isVisible()) {
|
||||
await deleteBtn.click();
|
||||
const confirmBtn = page.getByRole('button', { name: /löschen|ja|bestätigen/i });
|
||||
if (await confirmBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await confirmBtn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('week view shows current time indicator on today', async ({ page }) => {
|
||||
const timeIndicator = page.locator('.time-indicator');
|
||||
// There should be at least one time indicator (on today's column)
|
||||
await expect(timeIndicator.first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// It should have a top percentage style
|
||||
const style = await timeIndicator.first().getAttribute('style');
|
||||
expect(style).toContain('top:');
|
||||
});
|
||||
|
||||
test('week view shows correct day headers', async ({ page }) => {
|
||||
const dayHeaders = page.locator('.day-header');
|
||||
const count = await dayHeaders.count();
|
||||
|
||||
// Should have 5 (weekdays only) or 7 (full week) day headers
|
||||
expect(count === 5 || count === 7).toBe(true);
|
||||
|
||||
// Each header should have a day name and number
|
||||
for (let i = 0; i < count; i++) {
|
||||
const dayName = dayHeaders.nth(i).locator('.day-name');
|
||||
const dayNumber = dayHeaders.nth(i).locator('.day-number');
|
||||
await expect(dayName).toBeVisible();
|
||||
await expect(dayNumber).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('today column is highlighted', async ({ page }) => {
|
||||
const todayColumn = page.locator('.day-column.today');
|
||||
await expect(todayColumn).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const todayHeader = page.locator('.day-header.today');
|
||||
await expect(todayHeader).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
// @ts-check
|
||||
import {
|
||||
baseConfig,
|
||||
typescriptConfig,
|
||||
svelteConfig,
|
||||
prettierConfig,
|
||||
} from '@manacore/eslint-config';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', '.svelte-kit/**', 'node_modules/**'],
|
||||
},
|
||||
...baseConfig,
|
||||
...typescriptConfig,
|
||||
...svelteConfig,
|
||||
...prettierConfig,
|
||||
];
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
{
|
||||
"name": "@calendar/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-pwa": "workspace:*",
|
||||
"@manacore/shared-vite-config": "workspace:*",
|
||||
"@playwright/test": "^1.51.0",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@types/d3-force": "^3.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/suncalc": "^1.9.2",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calendar/shared": "workspace:*",
|
||||
"@manacore/shared-links": "workspace:*",
|
||||
"@manacore/shared-api-client": "workspace:*",
|
||||
"@manacore/shared-app-onboarding": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-stores": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-error-tracking": "workspace:*",
|
||||
"@manacore/feedback": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/help": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-splitscreen": "workspace:*",
|
||||
"@manacore/shared-stores": "workspace:*",
|
||||
"@manacore/subscriptions": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@neodrag/svelte": "^2.3.3",
|
||||
"@sqlite.org/sqlite-wasm": "^3.49.1-build1",
|
||||
"d3-force": "^3.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"suncalc": "^1.9.0",
|
||||
"svelte-dnd-action": "^0.9.68",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : [['html']],
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:5179',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
actionTimeout: 10000,
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
|
||||
timeout: 60000,
|
||||
expect: { timeout: 5000 },
|
||||
|
||||
projects: process.env.CI
|
||||
? [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
]
|
||||
: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
|
||||
webServer: {
|
||||
command: 'pnpm run build && pnpm run preview --port 5179',
|
||||
port: 5179,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120000,
|
||||
},
|
||||
|
||||
outputDir: 'test-results/',
|
||||
});
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate PWA icons from SVG favicon
|
||||
* Run: node scripts/generate-icons.mjs
|
||||
* Requires: sharp (available in workspace)
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const staticDir = join(__dirname, '..', 'static');
|
||||
|
||||
const sizes = [
|
||||
{ name: 'favicon.png', size: 32 },
|
||||
{ name: 'pwa-192x192.png', size: 192 },
|
||||
{ name: 'pwa-512x512.png', size: 512 },
|
||||
{ name: 'apple-touch-icon.png', size: 180 },
|
||||
];
|
||||
|
||||
async function generateIcons() {
|
||||
try {
|
||||
const sharp = (await import('sharp')).default;
|
||||
const svgPath = join(staticDir, 'favicon.svg');
|
||||
const svgBuffer = readFileSync(svgPath);
|
||||
|
||||
for (const { name, size } of sizes) {
|
||||
const outputPath = join(staticDir, name);
|
||||
await sharp(svgBuffer).resize(size, size).png().toFile(outputPath);
|
||||
console.log(`Generated: ${name} (${size}x${size})`);
|
||||
}
|
||||
|
||||
console.log('\nAll icons generated successfully!');
|
||||
} catch (error) {
|
||||
if (error.code === 'ERR_MODULE_NOT_FOUND') {
|
||||
console.error('Sharp is not installed. Run: pnpm add -D sharp');
|
||||
} else {
|
||||
console.error('Error generating icons:', error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generateIcons();
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
@import "@manacore/shared-tailwind/themes.css";
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../packages/shared/src";
|
||||
@source "../../../../../packages/shared-ui/src";
|
||||
@source "../../../../../packages/shared-auth-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../../packages/shared-theme-ui/src/components";
|
||||
@source "../../../../../packages/shared-theme-ui/src/pages";
|
||||
|
||||
/* Calendar-specific CSS Variables */
|
||||
@layer base {
|
||||
:root {
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
/* Calendar-specific */
|
||||
--hour-height: 48px;
|
||||
--day-header-height: 40px;
|
||||
--time-column-width: 56px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Calendar Grid Styles - Using plain CSS (not @layer) for guaranteed inclusion */
|
||||
/* Hour slot in day/week view */
|
||||
.hour-slot {
|
||||
height: var(--hour-height);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hour-slot:hover {
|
||||
background-color: color-mix(in srgb, var(--color-muted) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Event card in calendar */
|
||||
.event-card {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
font-size: 0.75rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.event-card:hover {
|
||||
transform: scale(1.02);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Day cell in month view */
|
||||
.day-cell {
|
||||
min-height: 100px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: var(--spacing-xs);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.day-cell:hover {
|
||||
background-color: color-mix(in srgb, var(--color-muted) 30%, transparent);
|
||||
}
|
||||
|
||||
.day-cell.today {
|
||||
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.day-cell.other-month {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Time indicator (current time line) */
|
||||
.time-indicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: var(--color-error);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.time-indicator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -4px;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Mini calendar */
|
||||
.mini-calendar {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mini-calendar .day {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.mini-calendar .day:hover {
|
||||
background-color: var(--color-muted);
|
||||
}
|
||||
|
||||
.mini-calendar .day.today {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
.mini-calendar .day.selected {
|
||||
border: 2px solid var(--color-primary);
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
background-color: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: all var(--transition-base);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-foreground);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-secondary-foreground);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Input styles */
|
||||
.input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-size: 0.875rem;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* Select styling */
|
||||
select.input {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
/* Text colors */
|
||||
.text-destructive {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
@layer utilities {
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
}
|
||||
2
apps/calendar/apps/web-archived/src/app.d.ts
vendored
2
apps/calendar/apps/web-archived/src/app.d.ts
vendored
|
|
@ -1,2 +0,0 @@
|
|||
declare const __BUILD_HASH__: string;
|
||||
declare const __BUILD_TIME__: string;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="theme-color" content="#3b82f6" />
|
||||
<meta name="application-name" content="Calendar" />
|
||||
<meta name="description" content="Kalender und Terminverwaltung" />
|
||||
|
||||
<!-- PWA/iOS Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Calendar" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon.png" />
|
||||
|
||||
<title>Calendar</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import { initErrorTracking, handleSvelteError } from '@manacore/shared-error-tracking/browser';
|
||||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
|
||||
initErrorTracking({
|
||||
serviceName: 'calendar-web',
|
||||
dsn: (window as any).__PUBLIC_GLITCHTIP_DSN__,
|
||||
environment: import.meta.env.MODE,
|
||||
});
|
||||
|
||||
export const handleError: HandleClientError = ({ error }) => {
|
||||
handleSvelteError(error);
|
||||
};
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
/**
|
||||
* Server Hooks for SvelteKit
|
||||
* - Injects runtime environment variables for client-side use
|
||||
* - Adds security headers
|
||||
* - Auth is handled client-side via Mana Core Auth
|
||||
*/
|
||||
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { setSecurityHeaders } from '@manacore/shared-utils/security-headers';
|
||||
|
||||
// Get client-side URLs from environment (Docker runtime)
|
||||
// In dev mode, Vite exposes .env vars via import.meta.env, not process.env
|
||||
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
|
||||
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT ||
|
||||
process.env.PUBLIC_MANA_CORE_AUTH_URL ||
|
||||
'http://localhost:3001';
|
||||
const PUBLIC_BACKEND_URL_CLIENT =
|
||||
process.env.PUBLIC_BACKEND_URL_CLIENT ||
|
||||
process.env.PUBLIC_BACKEND_URL ||
|
||||
'http://localhost:3014';
|
||||
const PUBLIC_STT_URL = process.env.PUBLIC_STT_URL || 'https://stt-api.mana.how';
|
||||
|
||||
// Cross-app integration URLs (for contacts API)
|
||||
const PUBLIC_CONTACTS_API_URL = process.env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015';
|
||||
const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
// Inject runtime environment variables into the HTML
|
||||
// These will be available on window.__PUBLIC_*__ for client-side code
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
|
||||
window.__PUBLIC_BACKEND_URL__ = ${JSON.stringify(PUBLIC_BACKEND_URL_CLIENT)};
|
||||
window.__PUBLIC_STT_URL__ = ${JSON.stringify(PUBLIC_STT_URL)};
|
||||
window.__PUBLIC_CONTACTS_API_URL__ = ${JSON.stringify(PUBLIC_CONTACTS_API_URL)};
|
||||
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
|
||||
</script>`;
|
||||
return html.replace('<head>', `<head>${envScript}`);
|
||||
},
|
||||
});
|
||||
|
||||
setSecurityHeaders(response, {
|
||||
connectSrc: [
|
||||
PUBLIC_MANA_CORE_AUTH_URL_CLIENT,
|
||||
PUBLIC_BACKEND_URL_CLIENT,
|
||||
PUBLIC_STT_URL,
|
||||
PUBLIC_CONTACTS_API_URL,
|
||||
],
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
/**
|
||||
* Cross-App API Client for Contacts Backend - Birthday Data
|
||||
* Allows Calendar app to fetch contact birthdays for display
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { createApiClient } from '@manacore/shared-api-client';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Get contacts API base URL from injected window variable (browser) or env (SSR)
|
||||
function getContactsApiBase(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_CONTACTS_API_URL__?: string })
|
||||
.__PUBLIC_CONTACTS_API_URL__;
|
||||
if (injectedUrl) return injectedUrl;
|
||||
}
|
||||
return env.PUBLIC_CONTACTS_API_URL || 'http://localhost:3015';
|
||||
}
|
||||
|
||||
let _contactsClient: ReturnType<typeof createApiClient> | null = null;
|
||||
|
||||
function getContactsClient() {
|
||||
if (!_contactsClient) {
|
||||
_contactsClient = createApiClient({
|
||||
baseUrl: getContactsApiBase(),
|
||||
apiPrefix: '/api/v1',
|
||||
getAuthToken: () => authStore.getValidToken(),
|
||||
timeout: 30000,
|
||||
debug: import.meta.env.DEV,
|
||||
useRuntimeUrl: false,
|
||||
});
|
||||
}
|
||||
return _contactsClient;
|
||||
}
|
||||
|
||||
// For backwards compatibility
|
||||
const contactsClient = {
|
||||
get: <T>(endpoint: string) => getContactsClient().get<T>(endpoint),
|
||||
post: <T>(endpoint: string, body?: unknown) => getContactsClient().post<T>(endpoint, body),
|
||||
put: <T>(endpoint: string, body?: unknown) => getContactsClient().put<T>(endpoint, body),
|
||||
patch: <T>(endpoint: string, body?: unknown) => getContactsClient().patch<T>(endpoint, body),
|
||||
delete: <T>(endpoint: string) => getContactsClient().delete<T>(endpoint),
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Types for Birthday Integration
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Lightweight contact data for birthday display
|
||||
* Only essential fields from Contacts API
|
||||
*/
|
||||
export interface ContactBirthdaySummary {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
birthday: string; // YYYY-MM-DD format
|
||||
photoUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Birthday event for calendar display
|
||||
* Generated from ContactBirthdaySummary with display date
|
||||
*/
|
||||
export interface BirthdayEvent {
|
||||
id: string; // Format: birthday-{contactId}-{date}
|
||||
contactId: string;
|
||||
title: string; // "{Name}'s Geburtstag"
|
||||
displayName: string;
|
||||
photoUrl: string | null;
|
||||
birthday: string; // Original birthday date
|
||||
age: number; // Age on this birthday (0 if birth year unknown)
|
||||
startTime: string; // ISO date of the birthday occurrence
|
||||
endTime: string; // Same as startTime (all-day event)
|
||||
isAllDay: true;
|
||||
isBirthday: true; // Type discriminator
|
||||
calendarId: string; // Virtual calendar ID
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Response Types
|
||||
// ============================================
|
||||
|
||||
interface BirthdaysResponse {
|
||||
contacts: ContactBirthdaySummary[];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Fetch all contacts with birthdays from Contacts service
|
||||
*/
|
||||
export async function getBirthdays(): Promise<{
|
||||
data: ContactBirthdaySummary[] | null;
|
||||
error: Error | null;
|
||||
}> {
|
||||
const result = await contactsClient.get<BirthdaysResponse>('/contacts/birthdays');
|
||||
if (result.error) {
|
||||
return { data: null, error: new Error(result.error.message) };
|
||||
}
|
||||
return {
|
||||
data: result.data?.contacts || null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get display name from contact, with fallback
|
||||
*/
|
||||
export function getContactDisplayName(contact: ContactBirthdaySummary): string {
|
||||
if (contact.displayName) return contact.displayName;
|
||||
const fullName = [contact.firstName, contact.lastName].filter(Boolean).join(' ');
|
||||
return fullName || 'Unbekannt';
|
||||
}
|
||||
|
||||
/**
|
||||
* Birthday calendar constants
|
||||
*/
|
||||
export const BIRTHDAY_CALENDAR = {
|
||||
id: '__birthdays__',
|
||||
name: 'Geburtstage',
|
||||
color: '#EC4899', // Pink
|
||||
} as const;
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* Calendar API Client
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared';
|
||||
|
||||
export async function getCalendars() {
|
||||
return fetchApi<Calendar[]>('/calendars');
|
||||
}
|
||||
|
||||
export async function getCalendar(id: string) {
|
||||
return fetchApi<Calendar>(`/calendars/${id}`);
|
||||
}
|
||||
|
||||
export async function createCalendar(data: CreateCalendarInput) {
|
||||
const result = await fetchApi<{ calendar: Calendar }>('/calendars', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendar, error: null };
|
||||
}
|
||||
|
||||
export async function updateCalendar(id: string, data: UpdateCalendarInput) {
|
||||
const result = await fetchApi<{ calendar: Calendar }>(`/calendars/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendar, error: null };
|
||||
}
|
||||
|
||||
export async function deleteCalendar(id: string) {
|
||||
return fetchApi<void>(`/calendars/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
/**
|
||||
* API Client for Calendar Backend
|
||||
* Uses @manacore/shared-api-client for consistent error handling
|
||||
*
|
||||
* Token handling: Uses authStore.getValidToken() which automatically
|
||||
* refreshes expired tokens before making requests.
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { createApiClient, type ApiResult, type ApiClient } from '@manacore/shared-api-client';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
// Use client URL in browser (injected by hooks.server.ts), SSR URL on server
|
||||
function getApiBase(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
|
||||
.__PUBLIC_BACKEND_URL__;
|
||||
if (injectedUrl) return injectedUrl;
|
||||
}
|
||||
return env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendar API client instance (lazy initialized)
|
||||
* - Auto token handling via authStore.getValidToken()
|
||||
* - Consistent ApiResult<T> response format
|
||||
*/
|
||||
let _api: ApiClient | null = null;
|
||||
|
||||
function getApi(): ApiClient {
|
||||
if (!_api) {
|
||||
_api = createApiClient({
|
||||
baseUrl: getApiBase(),
|
||||
apiPrefix: '/api/v1',
|
||||
getAuthToken: () => authStore.getValidToken(),
|
||||
timeout: 30000,
|
||||
debug: import.meta.env.DEV,
|
||||
});
|
||||
}
|
||||
return _api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request deduplication for GET requests
|
||||
* Prevents identical concurrent requests from being sent multiple times
|
||||
*/
|
||||
const pendingRequests = new Map<string, Promise<ApiResult<unknown>>>();
|
||||
|
||||
/**
|
||||
* Legacy fetchApi interface for backwards compatibility
|
||||
*/
|
||||
export interface FetchOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
token?: string;
|
||||
isFormData?: boolean;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch API wrapper using shared client
|
||||
* Maintains backward compatibility with existing code
|
||||
* GET requests are deduplicated — identical concurrent GETs share one in-flight request
|
||||
*/
|
||||
export async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: FetchOptions = {}
|
||||
): Promise<ApiResult<T>> {
|
||||
const { method = 'GET', body, isFormData = false } = options;
|
||||
const api = getApi();
|
||||
|
||||
if (isFormData && body instanceof FormData) {
|
||||
return api.upload<T>(endpoint, body);
|
||||
}
|
||||
|
||||
// Deduplicate GET requests
|
||||
if (method === 'GET') {
|
||||
const existing = pendingRequests.get(endpoint);
|
||||
if (existing) {
|
||||
return existing as Promise<ApiResult<T>>;
|
||||
}
|
||||
const promise = api.get<T>(endpoint).finally(() => {
|
||||
pendingRequests.delete(endpoint);
|
||||
});
|
||||
pendingRequests.set(endpoint, promise as Promise<ApiResult<unknown>>);
|
||||
return promise;
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case 'POST':
|
||||
return api.post<T>(endpoint, body);
|
||||
case 'PUT':
|
||||
return api.put<T>(endpoint, body);
|
||||
case 'PATCH':
|
||||
return api.patch<T>(endpoint, body);
|
||||
case 'DELETE':
|
||||
return api.delete<T>(endpoint);
|
||||
default:
|
||||
return api.get<T>(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types for backwards compatibility
|
||||
export type { ApiResult };
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
/**
|
||||
* Event Tags API Client - Uses Calendar Backend API
|
||||
*
|
||||
* This module provides the event tags interface for the Calendar app,
|
||||
* using the calendar backend's /event-tags endpoint which supports
|
||||
* tag groups (groupId).
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import type { EventTag } from '@calendar/shared';
|
||||
|
||||
// Re-export EventTag from shared
|
||||
export type { EventTag };
|
||||
|
||||
export interface CreateEventTagInput {
|
||||
name: string;
|
||||
color?: string;
|
||||
groupId?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateEventTagInput {
|
||||
name?: string;
|
||||
color?: string;
|
||||
groupId?: string | null;
|
||||
}
|
||||
|
||||
export async function getEventTags() {
|
||||
const result = await fetchApi<{ tags: EventTag[] }>('/event-tags');
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.tags, error: null };
|
||||
}
|
||||
|
||||
export async function getEventTag(id: string) {
|
||||
const result = await fetchApi<{ tag: EventTag }>(`/event-tags/${id}`);
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.tag, error: null };
|
||||
}
|
||||
|
||||
export async function createEventTag(data: CreateEventTagInput) {
|
||||
const result = await fetchApi<{ tag: EventTag }>('/event-tags', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.tag, error: null };
|
||||
}
|
||||
|
||||
export async function updateEventTag(id: string, data: UpdateEventTagInput) {
|
||||
const result = await fetchApi<{ tag: EventTag }>(`/event-tags/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.tag, error: null };
|
||||
}
|
||||
|
||||
export async function deleteEventTag(id: string) {
|
||||
const result = await fetchApi<{ success: boolean }>(`/event-tags/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
|
||||
// Mock the client module
|
||||
vi.mock('./client', () => ({
|
||||
fetchApi: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import { getEvents, getEvent, createEvent, updateEvent, deleteEvent } from './events';
|
||||
|
||||
const mockFetchApi = vi.mocked(fetchApi);
|
||||
|
||||
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
|
||||
return {
|
||||
id: 'evt-1',
|
||||
calendarId: 'cal-1',
|
||||
userId: 'user-1',
|
||||
title: 'Test Event',
|
||||
description: null,
|
||||
location: null,
|
||||
startTime: '2026-03-15T10:00:00Z',
|
||||
endTime: '2026-03-15T11:00:00Z',
|
||||
isAllDay: false,
|
||||
timezone: 'Europe/Berlin',
|
||||
recurrenceRule: null,
|
||||
recurrenceEndDate: null,
|
||||
recurrenceExceptions: null,
|
||||
parentEventId: null,
|
||||
color: null,
|
||||
status: 'confirmed',
|
||||
externalId: null,
|
||||
metadata: null,
|
||||
createdAt: '2026-03-01T00:00:00Z',
|
||||
updatedAt: '2026-03-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('events API client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getEvents', () => {
|
||||
it('should build query params with startDate and endDate', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { events: [], pagination: { offset: 0, count: 0 } },
|
||||
error: null,
|
||||
});
|
||||
|
||||
await getEvents({
|
||||
startDate: '2026-03-01T00:00:00',
|
||||
endDate: '2026-03-31T23:59:59',
|
||||
});
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledOnce();
|
||||
const url = mockFetchApi.mock.calls[0][0];
|
||||
expect(url).toContain('startDate=2026-03-01T00%3A00%3A00');
|
||||
expect(url).toContain('endDate=2026-03-31T23%3A59%3A59');
|
||||
});
|
||||
|
||||
it('should include calendarIds when provided', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { events: [], pagination: { offset: 0, count: 0 } },
|
||||
error: null,
|
||||
});
|
||||
|
||||
await getEvents({
|
||||
startDate: '2026-03-01T00:00:00',
|
||||
endDate: '2026-03-31T23:59:59',
|
||||
calendarIds: ['cal-1', 'cal-2'],
|
||||
});
|
||||
|
||||
const url = mockFetchApi.mock.calls[0][0];
|
||||
expect(url).toContain('calendarIds=cal-1%2Ccal-2');
|
||||
});
|
||||
|
||||
it('should include search param when provided', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { events: [], pagination: { offset: 0, count: 0 } },
|
||||
error: null,
|
||||
});
|
||||
|
||||
await getEvents({
|
||||
startDate: '2026-03-01T00:00:00',
|
||||
endDate: '2026-03-31T23:59:59',
|
||||
search: 'meeting',
|
||||
});
|
||||
|
||||
const url = mockFetchApi.mock.calls[0][0];
|
||||
expect(url).toContain('search=meeting');
|
||||
});
|
||||
|
||||
it('should include limit and offset when provided', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { events: [], pagination: { offset: 0, count: 0 } },
|
||||
error: null,
|
||||
});
|
||||
|
||||
await getEvents({
|
||||
startDate: '2026-03-01T00:00:00',
|
||||
endDate: '2026-03-31T23:59:59',
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
});
|
||||
|
||||
const url = mockFetchApi.mock.calls[0][0];
|
||||
expect(url).toContain('limit=10');
|
||||
expect(url).toContain('offset=20');
|
||||
});
|
||||
|
||||
it('should extract events array from response', async () => {
|
||||
const events = [makeEvent(), makeEvent({ id: 'evt-2', title: 'Second' })];
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { events, pagination: { offset: 0, count: 2 } },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await getEvents({
|
||||
startDate: '2026-03-01T00:00:00',
|
||||
endDate: '2026-03-31T23:59:59',
|
||||
});
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.pagination).toEqual({ offset: 0, count: 2 });
|
||||
});
|
||||
|
||||
it('should return error when API fails', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'Server error', code: 'SERVER_ERROR', status: 500 },
|
||||
});
|
||||
|
||||
const result = await getEvents({
|
||||
startDate: '2026-03-01T00:00:00',
|
||||
endDate: '2026-03-31T23:59:59',
|
||||
});
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toEqual({
|
||||
message: 'Server error',
|
||||
code: 'SERVER_ERROR',
|
||||
status: 500,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEvent', () => {
|
||||
it('should fetch a single event by ID', async () => {
|
||||
const event = makeEvent();
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { event },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await getEvent('evt-1');
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/events/evt-1');
|
||||
expect(result.data).toEqual(event);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error when event not found', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'Not found', code: 'NOT_FOUND', status: 404 },
|
||||
});
|
||||
|
||||
const result = await getEvent('nonexistent');
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error?.code).toBe('NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEvent', () => {
|
||||
it('should send POST request with event data', async () => {
|
||||
const event = makeEvent();
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { event },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const input = {
|
||||
calendarId: 'cal-1',
|
||||
title: 'Test Event',
|
||||
startTime: '2026-03-15T10:00:00Z',
|
||||
endTime: '2026-03-15T11:00:00Z',
|
||||
};
|
||||
|
||||
const result = await createEvent(input);
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/events', {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
});
|
||||
expect(result.data).toEqual(event);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error on creation failure', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'Validation failed', code: 'VALIDATION_ERROR', status: 400 },
|
||||
});
|
||||
|
||||
const result = await createEvent({
|
||||
title: '',
|
||||
startTime: '2026-03-15T10:00:00Z',
|
||||
endTime: '2026-03-15T11:00:00Z',
|
||||
});
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error?.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateEvent', () => {
|
||||
it('should send PUT request with update data', async () => {
|
||||
const event = makeEvent({ title: 'Updated Title' });
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { event },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const updateData = { title: 'Updated Title' };
|
||||
const result = await updateEvent('evt-1', updateData);
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/events/evt-1', {
|
||||
method: 'PUT',
|
||||
body: updateData,
|
||||
});
|
||||
expect(result.data).toEqual(event);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error on update failure', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'Forbidden', code: 'FORBIDDEN', status: 403 },
|
||||
});
|
||||
|
||||
const result = await updateEvent('evt-1', { title: 'Updated' });
|
||||
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error?.code).toBe('FORBIDDEN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteEvent', () => {
|
||||
it('should send DELETE request', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await deleteEvent('evt-1');
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/events/evt-1', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error on delete failure', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'Not found', code: 'NOT_FOUND', status: 404 },
|
||||
});
|
||||
|
||||
const result = await deleteEvent('nonexistent');
|
||||
|
||||
expect(result.error?.code).toBe('NOT_FOUND');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
/**
|
||||
* Events API Client
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
|
||||
|
||||
export interface PaginationMeta {
|
||||
limit?: number;
|
||||
offset: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface QueryEventsParams {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
calendarIds?: string[];
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function getEvents(params: QueryEventsParams) {
|
||||
const searchParams = new URLSearchParams({
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
});
|
||||
if (params.calendarIds?.length) {
|
||||
searchParams.set('calendarIds', params.calendarIds.join(','));
|
||||
}
|
||||
if (params.search) {
|
||||
searchParams.set('search', params.search);
|
||||
}
|
||||
if (params.limit !== undefined) {
|
||||
searchParams.set('limit', String(params.limit));
|
||||
}
|
||||
if (params.offset !== undefined) {
|
||||
searchParams.set('offset', String(params.offset));
|
||||
}
|
||||
const result = await fetchApi<{ events: CalendarEvent[]; pagination: PaginationMeta }>(
|
||||
`/events?${searchParams.toString()}`
|
||||
);
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, pagination: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.events, pagination: result.data.pagination, error: null };
|
||||
}
|
||||
|
||||
export async function searchEvents(query: string, limit: number = 10) {
|
||||
// Search events within a wide range (1 year past to 1 year future)
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||
const oneYearFromNow = new Date();
|
||||
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
|
||||
|
||||
return getEvents({
|
||||
startDate: oneYearAgo.toISOString(),
|
||||
endDate: oneYearFromNow.toISOString(),
|
||||
search: query,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEvent(id: string) {
|
||||
const result = await fetchApi<{ event: CalendarEvent }>(`/events/${id}`);
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.event, error: null };
|
||||
}
|
||||
|
||||
export async function getEventsByCalendar(calendarId: string) {
|
||||
return fetchApi<CalendarEvent[]>(`/events/calendar/${calendarId}`);
|
||||
}
|
||||
|
||||
export async function createEvent(data: CreateEventInput) {
|
||||
const result = await fetchApi<{ event: CalendarEvent }>('/events', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.event, error: null };
|
||||
}
|
||||
|
||||
export async function updateEvent(id: string, data: UpdateEventInput) {
|
||||
const result = await fetchApi<{ event: CalendarEvent }>(`/events/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.event, error: null };
|
||||
}
|
||||
|
||||
export async function deleteEvent(id: string) {
|
||||
return fetchApi<void>(`/events/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
fetchApi: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import { getReminders, createReminder, deleteReminder } from './reminders';
|
||||
|
||||
const mockFetchApi = vi.mocked(fetchApi);
|
||||
|
||||
describe('reminders API client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getReminders', () => {
|
||||
it('should GET /events/:eventId/reminders', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'rem-1', eventId: 'evt-1', minutesBefore: 15, status: 'pending' },
|
||||
{ id: 'rem-2', eventId: 'evt-1', minutesBefore: 60, status: 'sent' },
|
||||
],
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await getReminders('evt-1');
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/events/evt-1/reminders');
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return error on failure', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'Not found', code: 'NOT_FOUND', status: 404 },
|
||||
});
|
||||
|
||||
const result = await getReminders('nonexistent');
|
||||
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createReminder', () => {
|
||||
it('should POST to /events/:eventId/reminders with body', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { id: 'rem-new', eventId: 'evt-1', minutesBefore: 30, status: 'pending' },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const result = await createReminder('evt-1', {
|
||||
eventId: 'evt-1',
|
||||
minutesBefore: 30,
|
||||
notifyPush: true,
|
||||
notifyEmail: false,
|
||||
});
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/events/evt-1/reminders', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
eventId: 'evt-1',
|
||||
minutesBefore: 30,
|
||||
notifyPush: true,
|
||||
notifyEmail: false,
|
||||
},
|
||||
});
|
||||
expect(result.data).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteReminder', () => {
|
||||
it('should DELETE /reminders/:id', async () => {
|
||||
mockFetchApi.mockResolvedValue({ data: null, error: null });
|
||||
|
||||
await deleteReminder('rem-1');
|
||||
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/reminders/rem-1', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
/**
|
||||
* Reminders API Client
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import type { Reminder, CreateReminderInput } from '@calendar/shared';
|
||||
|
||||
export async function getReminders(eventId: string) {
|
||||
return fetchApi<Reminder[]>(`/events/${eventId}/reminders`);
|
||||
}
|
||||
|
||||
export async function createReminder(eventId: string, data: CreateReminderInput) {
|
||||
return fetchApi<Reminder>(`/events/${eventId}/reminders`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteReminder(id: string) {
|
||||
return fetchApi<void>(`/reminders/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
/**
|
||||
* Calendar Shares API Client
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import type { CalendarShare, CreateShareInput, UpdateShareInput } from '@calendar/shared';
|
||||
|
||||
export async function getShares(calendarId: string) {
|
||||
return fetchApi<CalendarShare[]>(`/calendars/${calendarId}/shares`);
|
||||
}
|
||||
|
||||
export async function createShare(calendarId: string, data: CreateShareInput) {
|
||||
return fetchApi<CalendarShare>(`/calendars/${calendarId}/shares`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function acceptShare(shareId: string) {
|
||||
return fetchApi<CalendarShare>(`/shares/${shareId}/accept`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function declineShare(shareId: string) {
|
||||
return fetchApi<void>(`/shares/${shareId}/decline`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateShare(shareId: string, data: UpdateShareInput) {
|
||||
return fetchApi<CalendarShare>(`/shares/${shareId}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteShare(calendarId: string, shareId: string) {
|
||||
return fetchApi<void>(`/calendars/${calendarId}/shares/${shareId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInvitations() {
|
||||
return fetchApi<CalendarShare[]>('/shares/invitations');
|
||||
}
|
||||
|
||||
export async function getSharedWithMe() {
|
||||
return fetchApi<CalendarShare[]>('/shares/shared-with-me');
|
||||
}
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
fetchApi: vi.fn(),
|
||||
}));
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import {
|
||||
getExternalCalendars,
|
||||
connectExternalCalendar,
|
||||
updateExternalCalendar,
|
||||
disconnectExternalCalendar,
|
||||
triggerSync,
|
||||
discoverCalDav,
|
||||
getGoogleAuthUrl,
|
||||
getICalExportUrl,
|
||||
} from './sync';
|
||||
|
||||
const mockFetchApi = vi.mocked(fetchApi);
|
||||
|
||||
describe('sync API client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getExternalCalendars', () => {
|
||||
it('should fetch external calendars', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { calendars: [{ id: 'ext-1', name: 'Test' }] },
|
||||
error: null,
|
||||
});
|
||||
const result = await getExternalCalendars();
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/sync/external');
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data![0].name).toBe('Test');
|
||||
});
|
||||
|
||||
it('should return error on failure', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'Not found', code: 'NOT_FOUND', status: 404 },
|
||||
});
|
||||
const result = await getExternalCalendars();
|
||||
expect(result.data).toBeNull();
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectExternalCalendar', () => {
|
||||
it('should POST to /sync/external', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { calendar: { id: 'ext-new', name: 'New Cal' } },
|
||||
error: null,
|
||||
});
|
||||
const result = await connectExternalCalendar({
|
||||
name: 'New Cal',
|
||||
provider: 'ical_url',
|
||||
calendarUrl: 'https://example.com/cal.ics',
|
||||
});
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/sync/external', {
|
||||
method: 'POST',
|
||||
body: { name: 'New Cal', provider: 'ical_url', calendarUrl: 'https://example.com/cal.ics' },
|
||||
});
|
||||
expect(result.data!.name).toBe('New Cal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disconnectExternalCalendar', () => {
|
||||
it('should DELETE /sync/external/:id', async () => {
|
||||
mockFetchApi.mockResolvedValue({ data: { success: true }, error: null });
|
||||
await disconnectExternalCalendar('ext-1');
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/sync/external/ext-1', { method: 'DELETE' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerSync', () => {
|
||||
it('should POST to /sync/external/:id/sync', async () => {
|
||||
mockFetchApi.mockResolvedValue({ data: { success: true, eventsImported: 10 }, error: null });
|
||||
const result = await triggerSync('ext-1');
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/sync/external/ext-1/sync', { method: 'POST' });
|
||||
expect(result.data!.eventsImported).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoverCalDav', () => {
|
||||
it('should POST credentials to /sync/caldav/discover', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { calendars: [{ url: 'https://cal.example.com/personal', name: 'Personal' }] },
|
||||
error: null,
|
||||
});
|
||||
const result = await discoverCalDav('https://cal.example.com', 'user@example.com', 'pass');
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/sync/caldav/discover', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
serverUrl: 'https://cal.example.com',
|
||||
username: 'user@example.com',
|
||||
password: 'pass',
|
||||
},
|
||||
});
|
||||
expect(result.data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGoogleAuthUrl', () => {
|
||||
it('should GET /sync/google/auth-url', async () => {
|
||||
mockFetchApi.mockResolvedValue({
|
||||
data: { url: 'https://accounts.google.com/auth' },
|
||||
error: null,
|
||||
});
|
||||
const result = await getGoogleAuthUrl();
|
||||
expect(mockFetchApi).toHaveBeenCalledWith('/sync/google/auth-url');
|
||||
expect(result.data).toContain('google');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getICalExportUrl', () => {
|
||||
it('should return the correct export URL', () => {
|
||||
expect(getICalExportUrl('cal-123')).toBe('/api/v1/calendars/cal-123/export.ics');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
/**
|
||||
* External Calendar Sync API Client
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import type {
|
||||
ExternalCalendar,
|
||||
ConnectExternalCalendarInput,
|
||||
CalDavDiscoveryResult,
|
||||
} from '@calendar/shared';
|
||||
|
||||
export interface UpdateExternalCalendarInput {
|
||||
name?: string;
|
||||
syncEnabled?: boolean;
|
||||
syncDirection?: 'import' | 'export' | 'both';
|
||||
syncInterval?: number;
|
||||
color?: string;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
// ==================== External Calendars CRUD ====================
|
||||
|
||||
export async function getExternalCalendars() {
|
||||
const result = await fetchApi<{ calendars: ExternalCalendar[] }>('/sync/external');
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendars, error: null };
|
||||
}
|
||||
|
||||
export async function getExternalCalendar(id: string) {
|
||||
const result = await fetchApi<{ calendar: ExternalCalendar }>(`/sync/external/${id}`);
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendar, error: null };
|
||||
}
|
||||
|
||||
export async function connectExternalCalendar(data: ConnectExternalCalendarInput) {
|
||||
const result = await fetchApi<{ calendar: ExternalCalendar }>('/sync/external', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendar, error: null };
|
||||
}
|
||||
|
||||
export async function updateExternalCalendar(id: string, data: UpdateExternalCalendarInput) {
|
||||
const result = await fetchApi<{ calendar: ExternalCalendar }>(`/sync/external/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendar, error: null };
|
||||
}
|
||||
|
||||
export async function disconnectExternalCalendar(id: string) {
|
||||
return fetchApi<{ success: boolean }>(`/sync/external/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Sync Operations ====================
|
||||
|
||||
export async function triggerSync(id: string) {
|
||||
const result = await fetchApi<{
|
||||
success: boolean;
|
||||
eventsImported?: number;
|
||||
eventsExported?: number;
|
||||
}>(`/sync/external/${id}/sync`, { method: 'POST' });
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data, error: null };
|
||||
}
|
||||
|
||||
// ==================== CalDAV Discovery ====================
|
||||
|
||||
export async function discoverCalDav(serverUrl: string, username: string, password: string) {
|
||||
const result = await fetchApi<CalDavDiscoveryResult>('/sync/caldav/discover', {
|
||||
method: 'POST',
|
||||
body: { serverUrl, username, password },
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.calendars, error: null };
|
||||
}
|
||||
|
||||
// ==================== Google OAuth ====================
|
||||
|
||||
export async function getGoogleAuthUrl() {
|
||||
const result = await fetchApi<{ url: string }>('/sync/google/auth-url');
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.url, error: null };
|
||||
}
|
||||
|
||||
// ==================== iCal Export ====================
|
||||
|
||||
export function getICalExportUrl(calendarId: string): string {
|
||||
// This returns the URL for direct browser download
|
||||
return `/api/v1/calendars/${calendarId}/export.ics`;
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { AppSlider } from '@manacore/shared-ui';
|
||||
import type { AppItem } from '@manacore/shared-ui';
|
||||
import { getActiveManaApps, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
|
||||
|
||||
// Convert active apps to AppItem format (German)
|
||||
const apps: AppItem[] = getActiveManaApps().map((app) => ({
|
||||
name: app.name,
|
||||
description: app.description.de,
|
||||
longDescription: app.longDescription.de,
|
||||
icon: app.icon,
|
||||
color: app.color,
|
||||
comingSoon: app.comingSoon,
|
||||
status: app.status,
|
||||
}));
|
||||
|
||||
const statusLabels = APP_STATUS_LABELS.de;
|
||||
const labels = APP_SLIDER_LABELS.de;
|
||||
|
||||
function handleAppClick(app: AppItem, index: number) {
|
||||
console.log('Opening app:', app.name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppSlider
|
||||
{apps}
|
||||
title={labels.title}
|
||||
isDark={false}
|
||||
{statusLabels}
|
||||
comingSoonLabel={labels.comingSoon}
|
||||
openAppLabel={labels.openApp}
|
||||
onAppClick={handleAppClick}
|
||||
/>
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillDropdown } from '@manacore/shared-ui';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
|
||||
let currentLocale = $derived($locale || 'de');
|
||||
|
||||
function handleLocaleChange(newLocale: string) {
|
||||
setLocale(newLocale as any);
|
||||
}
|
||||
|
||||
let languageItems = $derived(
|
||||
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
|
||||
);
|
||||
let currentLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
</script>
|
||||
|
||||
<PillDropdown items={languageItems} label={currentLabel} direction="down" />
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Warning, ArrowsClockwise } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
serviceName: string;
|
||||
available: boolean;
|
||||
error?: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
let { serviceName, available, error = null, onRetry }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if !available}
|
||||
<div class="service-banner" role="alert">
|
||||
<div class="banner-content">
|
||||
<Warning size={16} />
|
||||
<span>
|
||||
{serviceName} ist nicht erreichbar
|
||||
{#if error}
|
||||
<span class="error-detail">({error})</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if onRetry}
|
||||
<button class="retry-btn" onclick={onRetry}>
|
||||
<ArrowsClockwise size={14} />
|
||||
Erneut versuchen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.service-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: hsl(38 92% 50% / 0.1);
|
||||
border: 1px solid hsl(38 92% 50% / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(38 92% 50%);
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
opacity: 0.7;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid hsl(38 92% 50% / 0.3);
|
||||
background: transparent;
|
||||
color: hsl(38 92% 50%);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: hsl(38 92% 50% / 0.15);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Funnel } from '@manacore/shared-icons';
|
||||
import { FilterDropdown, type FilterDropdownOption } from '@manacore/shared-ui';
|
||||
|
||||
interface Props {
|
||||
timeRange: '7' | '30' | 'all';
|
||||
onRangeChange?: (range: '7' | '30' | 'all') => void;
|
||||
}
|
||||
|
||||
let { timeRange = '30', onRangeChange }: Props = $props();
|
||||
|
||||
const rangeOptions: FilterDropdownOption[] = [
|
||||
{ value: '7', label: '7 Tage' },
|
||||
{ value: '30', label: '30 Tage' },
|
||||
{ value: 'all', label: 'Alle' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="agenda-filters">
|
||||
<div class="filter-group">
|
||||
<div class="range-selector">
|
||||
<Funnel size={14} />
|
||||
<FilterDropdown
|
||||
options={rangeOptions}
|
||||
value={timeRange}
|
||||
onChange={(v) => onRangeChange?.(v as '7' | '30' | 'all')}
|
||||
placeholder="Zeitraum"
|
||||
embedded={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.agenda-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.range-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.agenda-filters {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.filter-group {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import type { CalendarEvent, Calendar as CalendarType } from '@calendar/shared';
|
||||
import { getCalendarColorWithBirthdays } from '$lib/data/queries';
|
||||
import { Calendar, MapPin } from '@manacore/shared-icons';
|
||||
import { format } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
|
||||
interface Props {
|
||||
event: CalendarEvent;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { event, onclick }: Props = $props();
|
||||
|
||||
const calendarsCtx: { readonly value: CalendarType[] } = getContext('calendars');
|
||||
|
||||
const eventColor = $derived(getCalendarColorWithBirthdays(calendarsCtx.value, event.calendarId));
|
||||
const eventTimeLabel = $derived.by(() => {
|
||||
if (event.isAllDay) return 'Ganztägig';
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
return `${format(start, 'HH:mm')} - ${format(end, 'HH:mm')}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<button type="button" class="agenda-item event" style="--item-color: {eventColor};" {onclick}>
|
||||
<div class="item-indicator">
|
||||
<Calendar size={14} />
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-header">
|
||||
<span class="item-time">{eventTimeLabel}</span>
|
||||
</div>
|
||||
<span class="item-title">{event.title}</span>
|
||||
{#if event.location}
|
||||
<div class="item-meta">
|
||||
<MapPin size={12} />
|
||||
<span>{event.location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.agenda-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: hsl(var(--color-surface));
|
||||
transition: all 150ms ease;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
border-left: 4px solid var(--item-color);
|
||||
}
|
||||
.agenda-item:hover {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.item-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--item-color);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.item-time {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.item-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.item-meta :global(svg) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import type { BirthdayEvent } from '$lib/api/birthdays';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { X, User, ArrowSquareOut, Cake } from '@manacore/shared-icons';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
birthday: BirthdayEvent;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { birthday, position, onClose }: Props = $props();
|
||||
|
||||
const CONTACTS_WEB_URL = env.PUBLIC_CONTACTS_WEB_URL || 'http://localhost:5184';
|
||||
const contactUrl = `${CONTACTS_WEB_URL}/contacts/${birthday.contactId}`;
|
||||
|
||||
// Format the original birthday date
|
||||
let birthdayDateFormatted = $derived(() => {
|
||||
try {
|
||||
const date = parseISO(birthday.birthday);
|
||||
return format(date, 'd. MMMM', { locale: de });
|
||||
} catch {
|
||||
return birthday.birthday;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate popover position to stay within viewport
|
||||
let adjustedPosition = $derived(() => {
|
||||
const popoverWidth = 280;
|
||||
const popoverHeight = 200;
|
||||
const padding = 16;
|
||||
|
||||
let x = position.x;
|
||||
let y = position.y;
|
||||
|
||||
// Check right boundary
|
||||
if (x + popoverWidth + padding > window.innerWidth) {
|
||||
x = window.innerWidth - popoverWidth - padding;
|
||||
}
|
||||
|
||||
// Check bottom boundary
|
||||
if (y + popoverHeight + padding > window.innerHeight) {
|
||||
y = position.y - popoverHeight - 8; // Show above
|
||||
}
|
||||
|
||||
// Check left boundary
|
||||
if (x < padding) {
|
||||
x = padding;
|
||||
}
|
||||
|
||||
// Check top boundary
|
||||
if (y < padding) {
|
||||
y = padding;
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
});
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50"
|
||||
onclick={handleBackdropClick}
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Popover -->
|
||||
<div
|
||||
class="birthday-popover"
|
||||
style="left: {adjustedPosition().x}px; top: {adjustedPosition().y}px;"
|
||||
role="dialog"
|
||||
aria-label="Geburtstag Details"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="popover-header">
|
||||
<div class="header-content">
|
||||
{#if birthday.photoUrl}
|
||||
<img src={birthday.photoUrl} alt={birthday.displayName} class="contact-avatar" />
|
||||
{:else}
|
||||
<div class="contact-avatar-placeholder">
|
||||
<Cake size={24} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="header-info">
|
||||
<h3 class="contact-name">{birthday.displayName}</h3>
|
||||
{#if settingsStore.showBirthdayAge && birthday.age > 0}
|
||||
<p class="contact-age">wird {birthday.age} Jahre alt</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="close-btn" onclick={onClose} aria-label="Schließen">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="popover-content">
|
||||
<div class="info-row">
|
||||
<Cake size={16} class="info-icon" />
|
||||
<span>Geburtstag: {birthdayDateFormatted()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="popover-actions">
|
||||
<a href={contactUrl} target="_blank" rel="noopener noreferrer" class="action-btn primary">
|
||||
<User size={16} />
|
||||
<span>Kontakt öffnen</span>
|
||||
<ArrowSquareOut size={14} class="external-icon" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.birthday-popover {
|
||||
position: fixed;
|
||||
width: 280px;
|
||||
background: hsl(var(--color-surface));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px hsl(var(--color-foreground) / 0.15);
|
||||
overflow: hidden;
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
.popover-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #ec4899 0%, #f472b6 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.contact-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.contact-avatar-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--color-surface) / 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid white;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contact-age {
|
||||
font-size: 0.875rem;
|
||||
margin: 0.125rem 0 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: hsl(var(--color-surface) / 0.2);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: hsl(var(--color-surface) / 0.3);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.info-row :global(.info-icon) {
|
||||
color: #ec4899;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.popover-actions {
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #ec4899;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: #db2777;
|
||||
}
|
||||
|
||||
.action-btn :global(.external-icon) {
|
||||
opacity: 0.7;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,440 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
getVisibleCalendars,
|
||||
getCalendarColorWithBirthdays,
|
||||
getEventsForRange,
|
||||
} from '$lib/data/queries';
|
||||
import { filterByTags } from '$lib/utils/eventFiltering';
|
||||
import { format, parseISO, isToday, isTomorrow, startOfDay, addMonths } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import type { CalendarEvent, Calendar, CreateEventInput } from '@calendar/shared';
|
||||
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
|
||||
import { CalendarBlank, MapPin, CaretRight } from '@manacore/shared-icons';
|
||||
import { _ } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
/** Optional date override for carousel navigation (uses viewStore.currentDate if not provided) */
|
||||
date?: Date;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
let { date, onEventClick }: Props = $props();
|
||||
|
||||
// Get calendars and events from layout context (live queries)
|
||||
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
|
||||
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('events');
|
||||
let visibleCalendars = $derived(getVisibleCalendars(calendarsCtx.value));
|
||||
|
||||
// Use provided date or fall back to viewStore
|
||||
let effectiveDate = $derived(date ?? viewStore.currentDate);
|
||||
|
||||
// Expand recurring events for agenda range (3 months ahead)
|
||||
let rangeEvents = $derived(
|
||||
getEventsForRange(eventsCtx.value, effectiveDate, addMonths(effectiveDate, 3))
|
||||
);
|
||||
|
||||
// Group events by date
|
||||
let groupedEvents = $derived.by(() => {
|
||||
const currentEvents = rangeEvents ?? [];
|
||||
if (!Array.isArray(currentEvents)) return [];
|
||||
|
||||
// Filter by visible calendars
|
||||
const visibleCalendarIds = new Set(visibleCalendars.map((c) => c.id));
|
||||
|
||||
// Filter events that start from current date onwards
|
||||
const startDate = startOfDay(effectiveDate);
|
||||
|
||||
const groups: Map<string, CalendarEvent[]> = new Map();
|
||||
|
||||
// Get selected tag IDs for filtering
|
||||
const selectedTagIds = settingsStore.selectedTagIds;
|
||||
|
||||
for (const event of currentEvents) {
|
||||
// Skip events from hidden calendars
|
||||
if (!visibleCalendarIds.has(event.calendarId)) continue;
|
||||
|
||||
const start = toDate(event.startTime);
|
||||
|
||||
// Skip events before the start date
|
||||
if (start < startDate) continue;
|
||||
|
||||
const dateKey = format(start, 'yyyy-MM-dd');
|
||||
|
||||
if (!groups.has(dateKey)) {
|
||||
groups.set(dateKey, []);
|
||||
}
|
||||
groups.get(dateKey)!.push(event);
|
||||
}
|
||||
|
||||
// Sort groups by date and apply tag filtering
|
||||
return Array.from(groups.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([dateKey, events]) => ({
|
||||
date: parseISO(dateKey),
|
||||
events: filterByTags(
|
||||
events.sort((a, b) => {
|
||||
const aStart = toDate(a.startTime);
|
||||
const bStart = toDate(b.startTime);
|
||||
return aStart.getTime() - bStart.getTime();
|
||||
}),
|
||||
selectedTagIds
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.events.length > 0); // Remove empty groups after tag filtering
|
||||
});
|
||||
|
||||
function formatDateHeader(date: Date) {
|
||||
if (isToday(date)) {
|
||||
return 'Heute';
|
||||
}
|
||||
if (isTomorrow(date)) {
|
||||
return 'Morgen';
|
||||
}
|
||||
return format(date, 'EEEE, d. MMMM', { locale: de });
|
||||
}
|
||||
|
||||
function handleEventClick(event: CalendarEvent) {
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Inline title editing
|
||||
function handleTitleBlur(event: CalendarEvent, el: HTMLSpanElement) {
|
||||
const trimmed = (el.textContent || '').trim();
|
||||
if (trimmed && trimmed !== event.title) {
|
||||
eventsStore.updateEvent(event.id, { title: trimmed });
|
||||
} else {
|
||||
el.textContent = event.title;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTitleKeydown(e: KeyboardEvent, event: CalendarEvent) {
|
||||
const target = e.target as HTMLSpanElement;
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
target.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
target.textContent = event.title;
|
||||
target.blur();
|
||||
} else if (e.key === 'Tab' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const direction = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey) ? -1 : 1;
|
||||
e.preventDefault();
|
||||
const allTitles = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('.agenda-event-title[contenteditable]')
|
||||
);
|
||||
const currentIndex = allTitles.indexOf(target);
|
||||
const next = allTitles[currentIndex + direction];
|
||||
target.blur();
|
||||
if (next) {
|
||||
next.focus();
|
||||
} else {
|
||||
document.querySelector<HTMLInputElement>('.quick-input-bar input')?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu state
|
||||
let contextMenuVisible = $state(false);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
let contextMenuEvent = $state<CalendarEvent | null>(null);
|
||||
|
||||
function handleContextMenu(event: CalendarEvent, e: MouseEvent) {
|
||||
contextMenuX = e.clientX;
|
||||
contextMenuY = e.clientY;
|
||||
contextMenuEvent = event;
|
||||
contextMenuVisible = true;
|
||||
}
|
||||
|
||||
function getContextMenuItems(): ContextMenuItem[] {
|
||||
if (!contextMenuEvent) return [];
|
||||
const event = contextMenuEvent;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'edit',
|
||||
label: $_('calendar.contextMenu.edit'),
|
||||
action: () => {
|
||||
handleEventClick(event);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
label: $_('calendar.contextMenu.duplicate'),
|
||||
action: async () => {
|
||||
await eventsStore.createEvent({
|
||||
calendarId: event.calendarId,
|
||||
title: `${event.title} (${$_('calendar.contextMenu.copy')})`,
|
||||
description: event.description ?? undefined,
|
||||
location: event.location ?? undefined,
|
||||
startTime: event.startTime,
|
||||
endTime: event.endTime,
|
||||
isAllDay: event.isAllDay,
|
||||
timezone: event.timezone ?? undefined,
|
||||
color: event.color ?? undefined,
|
||||
status: event.status ?? undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
{ id: 'divider-1', label: '', type: 'divider' },
|
||||
{
|
||||
id: 'delete',
|
||||
label: $_('calendar.contextMenu.delete'),
|
||||
variant: 'danger',
|
||||
action: () => eventsStore.deleteEvent(event.id),
|
||||
},
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="agenda-view">
|
||||
{#if groupedEvents.length === 0}
|
||||
<div class="empty-state">
|
||||
<CalendarBlank size={64} class="empty-icon" />
|
||||
<p>Keine Termine in diesem Zeitraum</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="event-list">
|
||||
{#each groupedEvents as group}
|
||||
<div class="date-group">
|
||||
<h2 class="date-header" class:today={isToday(group.date)}>
|
||||
{formatDateHeader(group.date)}
|
||||
</h2>
|
||||
|
||||
<div class="events-for-date">
|
||||
{#each group.events as event}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="event-item"
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleContextMenu(event, e);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="color-bar"
|
||||
style="background-color: {getCalendarColorWithBirthdays(
|
||||
calendarsCtx.value,
|
||||
event.calendarId
|
||||
)}"
|
||||
></div>
|
||||
<div class="event-content">
|
||||
<div class="event-time">
|
||||
{#if event.isAllDay}
|
||||
Ganztägig
|
||||
{:else}
|
||||
{format(toDate(event.startTime), 'HH:mm')} - {format(
|
||||
toDate(event.endTime),
|
||||
'HH:mm'
|
||||
)}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span
|
||||
class="event-title agenda-event-title"
|
||||
contenteditable="true"
|
||||
role="textbox"
|
||||
spellcheck="true"
|
||||
onkeydown={(e) => handleTitleKeydown(e, event)}
|
||||
onblur={(e) => handleTitleBlur(event, e.target as HTMLSpanElement)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{event.title}
|
||||
</span>
|
||||
{#if event.location}
|
||||
<div class="event-location">
|
||||
<MapPin size={14} class="location-icon" />
|
||||
{event.location}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="expand-btn"
|
||||
onclick={() => handleEventClick(event)}
|
||||
title="Details öffnen"
|
||||
aria-label="Details öffnen"
|
||||
>
|
||||
<CaretRight size={16} class="chevron-icon" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
visible={contextMenuVisible}
|
||||
x={contextMenuX}
|
||||
y={contextMenuY}
|
||||
items={getContextMenuItems()}
|
||||
onClose={() => {
|
||||
contextMenuVisible = false;
|
||||
contextMenuEvent = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.agenda-view {
|
||||
padding: 1rem;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.date-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.date-header {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
padding-left: 0.5rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
|
||||
}
|
||||
|
||||
.date-header.today {
|
||||
color: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.events-for-date {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.color-bar {
|
||||
width: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.event-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
cursor: text;
|
||||
outline: none;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.0625rem 0.125rem;
|
||||
margin: -0.0625rem -0.125rem;
|
||||
}
|
||||
|
||||
.event-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.expand-btn:hover {
|
||||
opacity: 1;
|
||||
background: hsl(var(--color-surface-hover, var(--color-surface)));
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { viewStore } from '$lib/stores/view.svelte';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
// Format title based on view type
|
||||
let title = $derived.by(() => {
|
||||
const date = viewStore.currentDate;
|
||||
const rangeStart = viewStore.viewRange.start;
|
||||
const rangeEnd = viewStore.viewRange.end;
|
||||
|
||||
const formatRange = () => {
|
||||
if (rangeStart.getMonth() === rangeEnd.getMonth()) {
|
||||
return (
|
||||
format(rangeStart, 'd.', { locale: de }) +
|
||||
' - ' +
|
||||
format(rangeEnd, 'd. MMMM yyyy', { locale: de })
|
||||
);
|
||||
}
|
||||
return (
|
||||
format(rangeStart, 'd. MMM', { locale: de }) +
|
||||
' - ' +
|
||||
format(rangeEnd, 'd. MMM yyyy', { locale: de })
|
||||
);
|
||||
};
|
||||
|
||||
switch (viewStore.viewType) {
|
||||
case 'week':
|
||||
return formatRange();
|
||||
case 'month':
|
||||
return format(date, 'MMMM yyyy', { locale: de });
|
||||
case 'agenda':
|
||||
return 'Agenda';
|
||||
default:
|
||||
return format(date, 'MMMM yyyy', { locale: de });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class="calendar-header" role="banner">
|
||||
<h1 class="header-title" aria-live="polite">{title}</h1>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.calendar-header {
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
cursor: context-menu;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { calendarsStore } from '$lib/stores/calendars.svelte';
|
||||
import { externalCalendarsStore } from '$lib/stores/external-calendars.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import type { Calendar } from '@calendar/shared';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { Plus, ArrowsClockwise } from '@manacore/shared-icons';
|
||||
|
||||
// Get calendars from layout context (live query)
|
||||
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
|
||||
|
||||
function handleToggle(calendarId: string) {
|
||||
calendarsStore.toggleVisibility(calendarId, calendarsCtx.value);
|
||||
}
|
||||
|
||||
function handleExternalToggle(id: string, currentVisible: boolean) {
|
||||
externalCalendarsStore.update(id, { isVisible: !currentVisible });
|
||||
}
|
||||
|
||||
function handleAddCalendar() {
|
||||
goto('/settings');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (authStore.isAuthenticated && externalCalendarsStore.calendars.length === 0) {
|
||||
externalCalendarsStore.fetchCalendars();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="calendar-sidebar-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Meine Kalender</h3>
|
||||
<button class="add-btn" onclick={handleAddCalendar} aria-label="Kalender hinzufügen">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-list" role="group" aria-label="Kalender Sichtbarkeit">
|
||||
{#each calendarsCtx.value as calendar}
|
||||
<label class="calendar-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={calendar.isVisible}
|
||||
onchange={() => handleToggle(calendar.id)}
|
||||
style="accent-color: {calendar.color}"
|
||||
aria-label="{calendar.name} {calendar.isVisible ? 'sichtbar' : 'ausgeblendet'}"
|
||||
/>
|
||||
<span class="color-dot" style="background-color: {calendar.color}" aria-hidden="true"
|
||||
></span>
|
||||
<span class="calendar-name">{calendar.name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
|
||||
{#if calendarsCtx.value.length === 0}
|
||||
<p class="empty-message">Keine Kalender vorhanden</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if externalCalendarsStore.calendars.length > 0}
|
||||
<div class="section-header external-header">
|
||||
<h3 class="section-title">Externe Kalender</h3>
|
||||
<button class="add-btn" onclick={() => goto('/settings/sync')} aria-label="Sync verwalten">
|
||||
<ArrowsClockwise size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="calendar-list" role="group" aria-label="Externe Kalender Sichtbarkeit">
|
||||
{#each externalCalendarsStore.calendars as cal}
|
||||
<label class="calendar-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={cal.isVisible}
|
||||
onchange={() => handleExternalToggle(cal.id, cal.isVisible)}
|
||||
style="accent-color: {cal.color}"
|
||||
aria-label="{cal.name} {cal.isVisible ? 'sichtbar' : 'ausgeblendet'}"
|
||||
/>
|
||||
<span class="color-dot" style="background-color: {cal.color}" aria-hidden="true"></span>
|
||||
<span class="calendar-name">{cal.name}</span>
|
||||
{#if cal.lastSyncError}
|
||||
<span class="sync-error-dot" title="Sync-Fehler"></span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.calendar-sidebar-section {
|
||||
background: hsl(var(--color-surface));
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.calendar-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.calendar-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.calendar-item:hover {
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
}
|
||||
|
||||
.calendar-item input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.calendar-name {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.external-header {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.sync-error-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: hsl(0 84% 60%);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { ExpandableToolbar } from '@manacore/shared-ui';
|
||||
import CalendarToolbarContent from './CalendarToolbarContent.svelte';
|
||||
|
||||
interface Props {
|
||||
isCollapsed?: boolean;
|
||||
isMobile?: boolean;
|
||||
bottomOffset?: string;
|
||||
onCollapsedChange?: (isCollapsed: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
isCollapsed = true,
|
||||
isMobile = false,
|
||||
bottomOffset = '70px',
|
||||
onCollapsedChange,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<ExpandableToolbar
|
||||
{isCollapsed}
|
||||
{onCollapsedChange}
|
||||
{bottomOffset}
|
||||
collapsedTitle="Kalender-Optionen"
|
||||
expandedTitle="Schließen"
|
||||
>
|
||||
<CalendarToolbarContent />
|
||||
</ExpandableToolbar>
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import {
|
||||
PillToolbarButton,
|
||||
PillToolbarDivider,
|
||||
PillTimeRangeSelector,
|
||||
} from '@manacore/shared-ui';
|
||||
import PillCalendarSelector from './PillCalendarSelector.svelte';
|
||||
|
||||
interface Props {
|
||||
vertical?: boolean;
|
||||
}
|
||||
|
||||
let { vertical = false }: Props = $props();
|
||||
|
||||
// Hours change handlers
|
||||
function handleStartHourChange(hour: number) {
|
||||
settingsStore.set('dayStartHour', hour);
|
||||
}
|
||||
|
||||
function handleEndHourChange(hour: number) {
|
||||
settingsStore.set('dayEndHour', hour);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toolbar-content" class:vertical>
|
||||
<!-- Calendar selector -->
|
||||
<PillCalendarSelector direction={vertical ? 'down' : 'up'} embedded={true} />
|
||||
|
||||
{#if !vertical}
|
||||
<PillToolbarDivider />
|
||||
{/if}
|
||||
|
||||
<!-- Weekdays filter -->
|
||||
<PillToolbarButton
|
||||
onclick={() => settingsStore.set('showOnlyWeekdays', !settingsStore.showOnlyWeekdays)}
|
||||
active={settingsStore.showOnlyWeekdays}
|
||||
title="Nur Wochentage anzeigen (Mo-Fr)"
|
||||
>
|
||||
Mo-Fr
|
||||
</PillToolbarButton>
|
||||
|
||||
<!-- Hours filter with time range selector -->
|
||||
<PillTimeRangeSelector
|
||||
startHour={settingsStore.dayStartHour}
|
||||
endHour={settingsStore.dayEndHour}
|
||||
onStartHourChange={handleStartHourChange}
|
||||
onEndHourChange={handleEndHourChange}
|
||||
direction={vertical ? 'down' : 'up'}
|
||||
embedded={true}
|
||||
toggleMode={true}
|
||||
active={settingsStore.filterHoursEnabled}
|
||||
onToggle={() => settingsStore.set('filterHoursEnabled', !settingsStore.filterHoursEnabled)}
|
||||
labelFormat="range"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toolbar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.toolbar-content.vertical {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* All elements in vertical mode - full width, left aligned */
|
||||
.toolbar-content.vertical :global(.pill-toolbar-btn),
|
||||
.toolbar-content.vertical :global(.pill-dropdown .trigger-button),
|
||||
.toolbar-content.vertical :global(button) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* PillTimeRangeSelector in vertical mode */
|
||||
.toolbar-content.vertical :global(.pill-time-range-selector),
|
||||
.toolbar-content.vertical :global(.pill-dropdown) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar-content.vertical :global(.pill-time-range-selector .trigger-button),
|
||||
.toolbar-content.vertical :global(.pill-dropdown .trigger-button) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* PillCalendarSelector in vertical mode */
|
||||
.toolbar-content.vertical :global(.calendar-selector) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar-content.vertical :global(.calendar-selector .trigger-button) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue