refactor(web): ClozeCardForm + MultipleChoiceCardForm extrahieren + Import-Bug fixen

- `ClozeCardForm.svelte`: Lückentext-Formular-Sektion aus cards/new herausgezogen
- `MultipleChoiceCardForm.svelte`: MC-Options-Builder (inkl. 85 Zeilen MC-CSS)
  aus cards/new herausgezogen — cards/new: 1010 → 856 Zeilen
- Import-Bug in 9 Dateien behoben: Python-Skript hatte apiErrorMessage-Import
  in mehrzeilige import-Blöcke eingefügt (Syntaxfehler)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 16:35:01 +02:00
parent c39bacc971
commit 595f1f9cb6
12 changed files with 286 additions and 168 deletions

View file

@ -0,0 +1,87 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
let {
text = $bindable(),
extra = $bindable(),
clusterIds,
}: {
text: string;
extra: string;
clusterIds: string[];
} = $props();
</script>
<label class="field">
<span class="field-label">{t('card_new.cloze_text_label')}</span>
<textarea
bind:value={text}
required
rows="6"
placeholder={t('card_new.cloze_text_placeholder')}
class="input mono"
></textarea>
<span class="field-hint">{t('card_new.cloze_help')}</span>
{#if text.trim() && clusterIds.length === 0}
<span class="field-error">{t('card_new.cloze_no_clusters')}</span>
{:else if clusterIds.length > 0}
<span class="field-hint">
{t('card_new.cloze_clusters_detected', { n: clusterIds.length, ids: clusterIds.join(', c') })}
</span>
{/if}
</label>
<label class="field">
<span class="field-label">{t('card_new.cloze_extra_label')}</span>
<textarea
bind:value={extra}
rows="3"
placeholder={t('card_new.cloze_extra_placeholder')}
class="input mono"
></textarea>
</label>
<style>
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field-label {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
letter-spacing: 0.01em;
}
.field-hint {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
.field-error {
font-size: 0.75rem;
color: hsl(var(--color-error));
}
.input {
display: block;
width: 100%;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
padding: 0.5rem 0.625rem;
font-size: 0.875rem;
line-height: 1.5;
transition: border-color 0.12s, box-shadow 0.12s;
resize: vertical;
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary) / 0.6);
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.input.mono { font-family: ui-monospace, 'Cascadia Code', monospace; }
</style>

View file

@ -0,0 +1,185 @@
<script lang="ts">
import { t } from '$lib/i18n/index.svelte.ts';
let {
front = $bindable(),
mcOptions = $bindable(),
mcCorrectIdx = $bindable(),
}: {
front: string;
mcOptions: string[];
mcCorrectIdx: number;
} = $props();
</script>
<label class="field">
<span class="field-label">{t('card_new.front_label')}</span>
<textarea
bind:value={front}
required
rows="4"
placeholder={t('card_new.front_placeholder')}
class="input mono"
></textarea>
</label>
<div class="field">
<span class="field-label">Antwortoptionen</span>
<span class="field-hint">Markiere die richtige Antwort. Leere Optionen werden ignoriert — KI ergänzt fehlende Distractors automatisch.</span>
<div class="mc-options">
{#each mcOptions as opt, i}
{@const letter = ['A', 'B', 'C', 'D'][i]}
{@const isCorrect = mcCorrectIdx === i}
<div class="mc-option" class:mc-option-correct={isCorrect}>
<button
type="button"
class="mc-radio"
class:mc-radio-selected={isCorrect}
onclick={() => { mcCorrectIdx = i; }}
aria-label="Option {letter} als richtig markieren"
aria-pressed={isCorrect}
>
{#if isCorrect}
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<circle cx="5" cy="5" r="3.5" />
</svg>
{/if}
</button>
<span class="mc-letter" class:mc-letter-correct={isCorrect}>{letter}</span>
<input
type="text"
bind:value={mcOptions[i]}
placeholder="Option {letter}"
class="mc-input"
/>
{#if isCorrect && opt.trim()}
<span class="mc-badge">✓ Richtig</span>
{/if}
</div>
{/each}
</div>
</div>
<style>
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.field-label {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
letter-spacing: 0.01em;
}
.field-hint {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
line-height: 1.4;
}
.input {
display: block;
width: 100%;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
color: hsl(var(--color-foreground));
padding: 0.5rem 0.625rem;
font-size: 0.875rem;
line-height: 1.5;
transition: border-color 0.12s, box-shadow 0.12s;
resize: vertical;
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary) / 0.6);
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.1);
}
.input.mono { font-family: ui-monospace, 'Cascadia Code', monospace; }
.mc-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.mc-option {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
transition: border-color 0.12s, background-color 0.12s;
}
.mc-option-correct {
border-color: hsl(var(--color-success) / 0.5);
background: hsl(var(--color-success) / 0.06);
}
.mc-radio {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
border: 2px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.12s;
padding: 0;
color: hsl(var(--color-success));
}
.mc-radio:hover { border-color: hsl(var(--color-primary) / 0.6); }
.mc-radio-selected {
border-color: hsl(var(--color-success));
background: hsl(var(--color-success) / 0.12);
}
.mc-letter {
flex-shrink: 0;
width: 1.375rem;
height: 1.375rem;
border-radius: 0.25rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6875rem;
font-weight: 700;
color: hsl(var(--color-muted-foreground));
}
.mc-letter-correct {
background: hsl(var(--color-success) / 0.15);
border-color: hsl(var(--color-success) / 0.4);
color: hsl(var(--color-success));
}
.mc-input {
flex: 1;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
padding: 0;
min-width: 0;
}
.mc-input:focus { outline: none; }
.mc-input::placeholder { color: hsl(var(--color-muted-foreground)); }
.mc-badge {
flex-shrink: 0;
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--color-success));
white-space: nowrap;
}
</style>

View file

@ -2,12 +2,12 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
hideDiscussion, hideDiscussion,
listDiscussions, listDiscussions,
postDiscussion, postDiscussion,
type Discussion, type Discussion,
} from '$lib/api/marketplace.ts'; } from '$lib/api/marketplace.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts';

View file

@ -2,13 +2,13 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { Card, Deck } from '@cards/domain'; import type { Card, Deck } from '@cards/domain';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
getMyAuthorProfile, getMyAuthorProfile,
getMarketplaceDeck, getMarketplaceDeck,
initMarketplaceDeck, initMarketplaceDeck,
publishMarketplaceVersion, publishMarketplaceVersion,
type MarketplaceAuthor, type MarketplaceAuthor,
} from '$lib/api/marketplace.ts'; } from '$lib/api/marketplace.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts';
interface Props { interface Props {

View file

@ -2,13 +2,13 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
closePullRequest, closePullRequest,
listPullRequests, listPullRequests,
mergePullRequest, mergePullRequest,
rejectPullRequest, rejectPullRequest,
type PullRequest, type PullRequest,
} from '$lib/api/marketplace.ts'; } from '$lib/api/marketplace.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts';

View file

@ -3,13 +3,13 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
extractClusterIds, extractClusterIds,
maskRegionCount, maskRegionCount,
renderClozePrompt, renderClozePrompt,
type Card, type Card,
type CardType, type CardType,
} from '@cards/domain'; } from '@cards/domain';
import { apiErrorMessage } from '$lib/api/error.ts';
import { getCard, updateCard, deleteCard } from '$lib/api/cards.ts'; import { getCard, updateCard, deleteCard } from '$lib/api/cards.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { renderMarkdown } from '$lib/markdown.ts'; import { renderMarkdown } from '$lib/markdown.ts';

View file

@ -3,13 +3,13 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
extractClusterIds, extractClusterIds,
maskRegionCount, maskRegionCount,
renderClozePrompt, renderClozePrompt,
type CardType, type CardType,
} from '@cards/domain'; } from '@cards/domain';
import { createCard } from '$lib/api/cards.ts'; import { createCard } from '$lib/api/cards.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import { listDecks, getDeck } from '$lib/api/decks.ts'; import { listDecks, getDeck } from '$lib/api/decks.ts';
import { API_BASE } from '$lib/api/client.ts'; import { API_BASE } from '$lib/api/client.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { devUser } from '$lib/auth/dev-stub.svelte.ts';
@ -17,6 +17,8 @@
import { toasts } from '$lib/stores/toasts.svelte.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts'; import { t } from '$lib/i18n/index.svelte.ts';
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte'; import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
import ClozeCardForm from '$lib/components/ClozeCardForm.svelte';
import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte';
import CardSurface from '$lib/components/CardSurface.svelte'; import CardSurface from '$lib/components/CardSurface.svelte';
type DeckLite = { id: string; name: string }; type DeckLite = { id: string; name: string };
@ -193,81 +195,10 @@
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson /> <ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
{:else if cardType === 'cloze'} {:else if cardType === 'cloze'}
<label class="field"> <ClozeCardForm bind:text bind:extra {clusterIds} />
<span class="field-label">{t('card_new.cloze_text_label')}</span>
<textarea
bind:value={text}
required
rows="6"
placeholder={t('card_new.cloze_text_placeholder')}
class="input mono"
></textarea>
<span class="field-hint">{t('card_new.cloze_help')}</span>
{#if text.trim() && clusterIds.length === 0}
<span class="field-error">{t('card_new.cloze_no_clusters')}</span>
{:else if clusterIds.length > 0}
<span class="field-hint">
{t('card_new.cloze_clusters_detected', { n: clusterIds.length, ids: clusterIds.join(', c') })}
</span>
{/if}
</label>
<label class="field">
<span class="field-label">{t('card_new.cloze_extra_label')}</span>
<textarea
bind:value={extra}
rows="3"
placeholder={t('card_new.cloze_extra_placeholder')}
class="input mono"
></textarea>
</label>
{:else if cardType === 'multiple-choice'} {:else if cardType === 'multiple-choice'}
<label class="field"> <MultipleChoiceCardForm bind:front bind:mcOptions bind:mcCorrectIdx />
<span class="field-label">{t('card_new.front_label')}</span>
<textarea
bind:value={front}
required
rows="4"
placeholder={t('card_new.front_placeholder')}
class="input mono"
></textarea>
</label>
<div class="field">
<span class="field-label">Antwortoptionen</span>
<span class="field-hint">Markiere die richtige Antwort. Leere Optionen werden ignoriert — KI ergänzt fehlende Distractors automatisch.</span>
<div class="mc-options">
{#each mcOptions as opt, i}
{@const letter = ['A', 'B', 'C', 'D'][i]}
{@const isCorrect = mcCorrectIdx === i}
<div class="mc-option" class:mc-option-correct={isCorrect}>
<button
type="button"
class="mc-radio"
class:mc-radio-selected={isCorrect}
onclick={() => { mcCorrectIdx = i; }}
aria-label="Option {letter} als richtig markieren"
aria-pressed={isCorrect}
>
{#if isCorrect}
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<circle cx="5" cy="5" r="3.5" />
</svg>
{/if}
</button>
<span class="mc-letter" class:mc-letter-correct={isCorrect}>{letter}</span>
<input
type="text"
bind:value={mcOptions[i]}
placeholder="Option {letter}"
class="mc-input"
/>
{#if isCorrect && opt.trim()}
<span class="mc-badge">✓ Richtig</span>
{/if}
</div>
{/each}
</div>
</div>
{:else if cardType === 'typing'} {:else if cardType === 'typing'}
<div class="grid-2"> <div class="grid-2">
@ -629,91 +560,6 @@
margin: 0; margin: 0;
} }
/* MC option builder */
.mc-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.mc-option {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.625rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-surface));
transition: border-color 0.12s, background-color 0.12s;
}
.mc-option-correct {
border-color: hsl(var(--color-success) / 0.5);
background: hsl(var(--color-success) / 0.06);
}
.mc-radio {
flex-shrink: 0;
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
border: 2px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.12s;
padding: 0;
color: hsl(var(--color-success));
}
.mc-radio:hover { border-color: hsl(var(--color-primary) / 0.6); }
.mc-radio-selected {
border-color: hsl(var(--color-success));
background: hsl(var(--color-success) / 0.12);
}
.mc-letter {
flex-shrink: 0;
width: 1.375rem;
height: 1.375rem;
border-radius: 0.25rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6875rem;
font-weight: 700;
color: hsl(var(--color-muted-foreground));
}
.mc-letter-correct {
background: hsl(var(--color-success) / 0.15);
border-color: hsl(var(--color-success) / 0.4);
color: hsl(var(--color-success));
}
.mc-input {
flex: 1;
border: none;
background: transparent;
color: hsl(var(--color-foreground));
font: inherit;
font-size: 0.875rem;
padding: 0;
min-width: 0;
}
.mc-input:focus { outline: none; }
.mc-input::placeholder { color: hsl(var(--color-muted-foreground)); }
.mc-badge {
flex-shrink: 0;
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--color-success));
white-space: nowrap;
}
/* Actions */ /* Actions */
.actions { .actions {
display: flex; display: flex;

View file

@ -5,7 +5,6 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
forkDeck, forkDeck,
getMarketplaceDeck, getMarketplaceDeck,
getMarketplaceVersion, getMarketplaceVersion,
@ -20,6 +19,7 @@
type MarketplaceVersion, type MarketplaceVersion,
type MarketplaceVersionCard, type MarketplaceVersionCard,
} from '$lib/api/marketplace.ts'; } from '$lib/api/marketplace.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import AuthorBadge from '$lib/components/marketplace/AuthorBadge.svelte'; import AuthorBadge from '$lib/components/marketplace/AuthorBadge.svelte';
import DiscussionThread from '$lib/components/marketplace/DiscussionThread.svelte'; import DiscussionThread from '$lib/components/marketplace/DiscussionThread.svelte';

View file

@ -2,11 +2,11 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
browseDecks, browseDecks,
getExplore, getExplore,
type DeckListEntry, type DeckListEntry,
} from '$lib/api/marketplace.ts'; } from '$lib/api/marketplace.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte'; import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
import EmptyState from '$lib/components/marketplace/EmptyState.svelte'; import EmptyState from '$lib/components/marketplace/EmptyState.svelte';
import SkeletonGrid from '$lib/components/marketplace/SkeletonGrid.svelte'; import SkeletonGrid from '$lib/components/marketplace/SkeletonGrid.svelte';

View file

@ -2,13 +2,13 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
browseDecks, browseDecks,
getMyAuthorProfile, getMyAuthorProfile,
upsertMyAuthorProfile, upsertMyAuthorProfile,
type DeckListEntry, type DeckListEntry,
type MarketplaceAuthor, type MarketplaceAuthor,
} from '$lib/api/marketplace.ts'; } from '$lib/api/marketplace.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts';

View file

@ -3,13 +3,13 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
clusterIdForSubIndex, clusterIdForSubIndex,
maskForSubIndex, maskForSubIndex,
renderClozePrompt, renderClozePrompt,
renderClozeAnswer, renderClozeAnswer,
type Rating, type Rating,
} from '@cards/domain'; } from '@cards/domain';
import { apiErrorMessage } from '$lib/api/error.ts';
import { getDeck } from '$lib/api/decks.ts'; import { getDeck } from '$lib/api/decks.ts';
import { listDueReviews, gradeReview, type DueReview } from '$lib/api/reviews.ts'; import { listDueReviews, gradeReview, type DueReview } from '$lib/api/reviews.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { devUser } from '$lib/auth/dev-stub.svelte.ts';

View file

@ -4,7 +4,6 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { import {
import { apiErrorMessage } from '$lib/api/error.ts';
browseDecks, browseDecks,
followAuthor, followAuthor,
getAuthor, getAuthor,
@ -13,6 +12,7 @@
type DeckListEntry, type DeckListEntry,
type MarketplaceAuthor, type MarketplaceAuthor,
} from '$lib/api/marketplace.ts'; } from '$lib/api/marketplace.ts';
import { apiErrorMessage } from '$lib/api/error.ts';
import { devUser } from '$lib/auth/dev-stub.svelte.ts'; import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte'; import DeckListGrid from '$lib/components/marketplace/DeckListGrid.svelte';
import { toasts } from '$lib/stores/toasts.svelte.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts';