wordeck/apps/web/src/lib/components/TypingView.svelte
Till JS 372832d266
Some checks are pending
CI / validate (push) Waiting to run
refactor(big-bang): cards → wordeck im gesamten Code-Layer
Phase 2 des cards→wordeck Big-Bang-Rebrand:

- 4 package.json: @cards/* → @wordeck/*
- packages/cards-domain/ → packages/wordeck-domain/
- 41+12 Files: from '@cards/domain' → '@wordeck/domain'
- pgSchema('cards') → pgSchema('wordeck') (Drizzle-Schema)
- 17 Files: process.env.CARDS_* → process.env.WORDECK_*
- docker-compose Service-Names: cards-* → wordeck-*
- docker-compose Volume: /Volumes/ManaData/cards → wordeck
- env-vars in compose: CARDS_DB_PASSWORD/_API_VERSION/_DSGVO_SERVICE_KEY etc. → WORDECK_*
- Log-Prefixes + Error-Strings + manifest-id 'cards' → 'wordeck'
- CORS-Origin cardecky.mana.how → wordeck.com
- .env.production.example umbenannt + S3-Key entfernt (kein MinIO mehr)

Type-Check 0 Errors in api+domain+web, 51/51 Domain-Tests grün.

DB-Rename + Container/Volume-Rename auf mana-server folgen in nächstem
Commit nach Verzeichnis-Rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:39:42 +02:00

278 lines
6.8 KiB
Svelte

<script lang="ts">
import { checkTypingAnswer, type TypingMatchResult } from '@wordeck/domain';
import type { Rating } from '@wordeck/domain';
let {
promptHtml,
answer,
aliases,
answerHtml,
ongrade,
}: {
promptHtml: string;
answer: string;
aliases?: string;
answerHtml: string;
ongrade: (r: Rating) => void;
} = $props();
let input = $state('');
let submitted = $state(false);
let result = $state<TypingMatchResult | null>(null);
let inputEl = $state<HTMLInputElement | null>(null);
$effect(() => {
if (inputEl && !submitted) inputEl.focus();
});
function submit() {
if (submitted || !input.trim()) return;
result = checkTypingAnswer(input, answer, aliases);
submitted = true;
}
function handleKey(e: KeyboardEvent) {
if (!submitted) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
submit();
}
return;
}
// Nach Submit: Keyboard-Shortcuts für Grade
if (result === 'correct') {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
ongrade('good');
}
} else if (result === 'close') {
if (e.key === '1') { e.stopPropagation(); ongrade('again'); }
else if (e.key === '3' || e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
ongrade('good');
}
} else if (result === 'wrong') {
if (e.key === '1') { e.stopPropagation(); ongrade('again'); }
else if (e.key === '2') { e.stopPropagation(); ongrade('hard'); }
else if (e.key === '3' || e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
ongrade('good');
}
else if (e.key === '4') { e.stopPropagation(); ongrade('easy'); }
}
}
</script>
<svelte:window onkeydown={handleKey} />
<div class="typing-view">
<div class="prose">{@html promptHtml}</div>
{#if !submitted}
<div class="input-row">
<input
bind:this={inputEl}
bind:value={input}
class="typing-input"
type="text"
placeholder="Antwort eingeben …"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck={false}
/>
<button class="submit-btn" onclick={submit} disabled={!input.trim()} aria-label="Antwort prüfen">
</button>
</div>
{:else}
<div class="result-row">
<span class="badge badge-{result}">
{#if result === 'correct'}✓ Richtig
{:else if result === 'close'}≈ Fast
{:else}✗ Falsch{/if}
</span>
<span class="your-input">{input}"</span>
</div>
<hr class="divider" />
<div class="prose answer">{@html answerHtml}</div>
<div class="grade-row grade-row-{result}">
{#if result === 'correct'}
<button class="grade-btn primary" onclick={() => ongrade('good')}>
Weiter <kbd>Space</kbd>
</button>
{:else if result === 'close'}
<button class="grade-btn" onclick={() => ongrade('again')}>
Nochmal <kbd>1</kbd>
</button>
<button class="grade-btn primary" onclick={() => ongrade('good')}>
War richtig <kbd>3</kbd>
</button>
{:else}
<button class="grade-btn danger" onclick={() => ongrade('again')}>
Wieder <kbd>1</kbd>
</button>
<button class="grade-btn" onclick={() => ongrade('hard')}>
Schwer <kbd>2</kbd>
</button>
<button class="grade-btn primary" onclick={() => ongrade('good')}>
Gut <kbd>3</kbd>
</button>
<button class="grade-btn success" onclick={() => ongrade('easy')}>
Leicht <kbd>4</kbd>
</button>
{/if}
</div>
{/if}
</div>
<style>
.typing-view {
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
}
.prose {
font-size: 1.125rem;
line-height: 1.55;
color: hsl(var(--color-foreground));
}
.prose :global(p) { margin: 0 0 0.875em; }
.prose :global(p:last-child) { margin-bottom: 0; }
.prose :global(h1) {
font-size: 4rem;
font-weight: 700;
text-align: center;
margin: 0;
line-height: 1;
letter-spacing: -0.02em;
}
.prose.answer { color: hsl(var(--color-foreground)); }
.input-row {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.typing-input {
flex: 1;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 1rem;
outline: none;
transition: border-color 0.15s ease;
}
.typing-input:focus {
border-color: hsl(var(--color-primary));
}
.submit-btn {
padding: 0 0.875rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 1.125rem;
cursor: pointer;
transition: background-color 0.15s ease;
}
.submit-btn:hover:not(:disabled) {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.submit-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.result-row {
display: flex;
align-items: center;
gap: 0.625rem;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.625rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
}
.badge-correct { background: hsl(var(--color-success) / 0.15); color: hsl(var(--color-success)); }
.badge-close { background: hsl(40 80% 50% / 0.15); color: hsl(40 80% 40%); }
.badge-wrong { background: hsl(var(--color-error) / 0.15); color: hsl(var(--color-error)); }
.your-input {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.divider {
border: none;
border-top: 1px solid hsl(var(--color-border));
margin: 0;
}
.grade-row {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.grade-row-wrong {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.grade-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.625rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.15s ease;
}
.grade-btn:hover { background: hsl(var(--color-surface-hover)); }
.grade-btn.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.grade-btn.primary:hover { background: hsl(var(--color-primary) / 0.9); }
.grade-btn.danger { border-color: hsl(var(--color-error) / 0.4); }
.grade-btn.danger:hover { background: hsl(var(--color-error) / 0.08); }
.grade-btn.success { border-color: hsl(var(--color-success) / 0.4); }
.grade-btn.success:hover { background: hsl(var(--color-success) / 0.08); }
.grade-btn kbd {
font-size: 0.6875rem;
color: inherit;
opacity: 0.6;
font-family: inherit;
}
.grade-btn.primary kbd { opacity: 0.7; }
</style>