refactor(cards/new): UI-Redesign + Multiple-Choice Option-Builder
- Section-Cards statt flaches Form-Layout - Typ-Beschreibungszeile unter dem Dropdown - Multiple-Choice: 4-Options-Builder mit Radio-Auswahl für richtige Antwort; Distractors werden aus den anderen Optionen extrahiert - Typing: Alias-Hinweis im Formular - Audio-Front: Hinweis zum media_ref-Flow - Einheitliche Input-/Button-Styles mit Theme-Tokens Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a612ad05d6
commit
b5d3a29335
1 changed files with 494 additions and 198 deletions
|
|
@ -28,10 +28,13 @@
|
|||
let imageRef = $state('');
|
||||
let maskRegionsJson = $state('[]');
|
||||
let answer = $state('');
|
||||
let distractorPool = $state('');
|
||||
let audioFileRef = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
// Multiple-Choice builder state
|
||||
let mcOptions = $state(['', '', '', '']);
|
||||
let mcCorrectIdx = $state(0);
|
||||
|
||||
const frontHtml = $derived(renderMarkdown(front));
|
||||
const backHtml = $derived(renderMarkdown(back));
|
||||
const clusterIds = $derived(extractClusterIds(text));
|
||||
|
|
@ -41,6 +44,16 @@
|
|||
return renderMarkdown(renderClozePrompt(text, firstCluster));
|
||||
});
|
||||
|
||||
const TYPE_DESCRIPTIONS: Record<CardType, string> = {
|
||||
basic: 'Klassische Karteikarte: Vorderseite → Rückseite.',
|
||||
'basic-reverse': 'Wie Basic, aber beide Richtungen werden abgefragt (2 Reviews).',
|
||||
cloze: 'Lückentext — jeder {{cN::…}}-Cluster wird einzeln abgefragt.',
|
||||
'image-occlusion': 'Bild mit N ausgeblendeten Bereichen — 1 Review pro Maske.',
|
||||
typing: 'Du tippst die Antwort; Fuzzy-Match erlaubt kleine Tippfehler.',
|
||||
'multiple-choice': '4 Antwortoptionen, KI generiert fehlende Distractors beim Lernen.',
|
||||
'audio-front': 'Audioclip als Vorderseite, Text als Antwort.',
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if (!devUser.id) {
|
||||
goto('/');
|
||||
|
|
@ -52,7 +65,6 @@
|
|||
if (!deckId && decks.length > 0) {
|
||||
deckId = decks[0].id;
|
||||
} else if (deckId) {
|
||||
// Verify deck exists for current user
|
||||
try {
|
||||
await getDeck(deckId);
|
||||
} catch {
|
||||
|
|
@ -70,7 +82,11 @@
|
|||
if (saving || !deckId) return false;
|
||||
if (cardType === 'cloze') return text.trim().length > 0 && clusterIds.length > 0;
|
||||
if (cardType === 'image-occlusion') return imageRef.length > 0 && maskCount > 0;
|
||||
if (cardType === 'typing' || cardType === 'multiple-choice') return front.trim().length > 0 && answer.trim().length > 0;
|
||||
if (cardType === 'typing') return front.trim().length > 0 && answer.trim().length > 0;
|
||||
if (cardType === 'multiple-choice') {
|
||||
const filledCount = mcOptions.filter((o) => o.trim()).length;
|
||||
return front.trim().length > 0 && mcOptions[mcCorrectIdx].trim().length > 0 && filledCount >= 2;
|
||||
}
|
||||
if (cardType === 'audio-front') return audioFileRef.trim().length > 0 && back.trim().length > 0;
|
||||
return front.trim().length > 0 && back.trim().length > 0;
|
||||
});
|
||||
|
|
@ -90,8 +106,12 @@
|
|||
} else if (cardType === 'typing') {
|
||||
fields = { front: front.trim(), answer: answer.trim() };
|
||||
} else if (cardType === 'multiple-choice') {
|
||||
fields = { front: front.trim(), answer: answer.trim() };
|
||||
if (distractorPool.trim()) fields.distractor_pool = distractorPool.trim();
|
||||
const distractors = mcOptions
|
||||
.filter((_, i) => i !== mcCorrectIdx)
|
||||
.filter((o) => o.trim())
|
||||
.join('\n');
|
||||
fields = { front: front.trim(), answer: mcOptions[mcCorrectIdx].trim() };
|
||||
if (distractors) fields.distractor_pool = distractors;
|
||||
} else if (cardType === 'audio-front') {
|
||||
fields = { audio_ref: audioFileRef.trim(), back: back.trim() };
|
||||
} else {
|
||||
|
|
@ -121,32 +141,26 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<a href={deckId ? `/decks/${deckId}` : '/decks'} class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
|
||||
>{t('card_new.back')}</a
|
||||
>
|
||||
<h1 class="mt-2 text-2xl font-semibold">{t('card_new.title')}</h1>
|
||||
<div class="page-shell">
|
||||
<a href={deckId ? `/decks/${deckId}` : '/decks'} class="back-link">{t('card_new.back')}</a>
|
||||
<h1 class="page-title">{t('card_new.title')}</h1>
|
||||
|
||||
<form class="mt-6 space-y-5" onsubmit={onSubmit}>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('card_new.deck_label')}</span>
|
||||
<select
|
||||
bind:value={deckId}
|
||||
required
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
>
|
||||
<form class="card-form" onsubmit={onSubmit}>
|
||||
<!-- Deck + Typ -->
|
||||
<section class="form-section">
|
||||
<div class="grid-2">
|
||||
<label class="field">
|
||||
<span class="field-label">{t('card_new.deck_label')}</span>
|
||||
<select bind:value={deckId} required class="input">
|
||||
{#each decks as d}
|
||||
<option value={d.id}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('card_new.type_label')}</span>
|
||||
<select
|
||||
bind:value={cardType}
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 text-sm"
|
||||
>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('card_new.type_label')}</span>
|
||||
<select bind:value={cardType} class="input">
|
||||
<option value="basic">{t('card_new.type_basic')}</option>
|
||||
<option value="basic-reverse">{t('card_new.type_basic_reverse')}</option>
|
||||
<option value="cloze">{t('card_new.type_cloze')}</option>
|
||||
|
|
@ -157,178 +171,460 @@
|
|||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p class="type-hint">{TYPE_DESCRIPTIONS[cardType]}</p>
|
||||
</section>
|
||||
|
||||
<!-- Type-specific content -->
|
||||
<section class="form-section">
|
||||
{#if cardType === 'image-occlusion'}
|
||||
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
|
||||
|
||||
{:else if cardType === 'cloze'}
|
||||
<div>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('card_new.cloze_text_label')}</span>
|
||||
<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="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
|
||||
class="input mono"
|
||||
></textarea>
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">{t('card_new.cloze_help')}</p>
|
||||
<span class="field-hint">{t('card_new.cloze_help')}</span>
|
||||
{#if text.trim() && clusterIds.length === 0}
|
||||
<p class="mt-1 text-xs text-[hsl(var(--color-error))]">{t('card_new.cloze_no_clusters')}</p>
|
||||
<span class="field-error">{t('card_new.cloze_no_clusters')}</span>
|
||||
{:else if clusterIds.length > 0}
|
||||
<p class="mt-1 text-xs text-[hsl(var(--color-muted-foreground))]">
|
||||
{t('card_new.cloze_clusters_detected', {
|
||||
n: clusterIds.length,
|
||||
ids: clusterIds.join(', c'),
|
||||
})}
|
||||
</p>
|
||||
<span class="field-hint">
|
||||
{t('card_new.cloze_clusters_detected', { n: clusterIds.length, ids: clusterIds.join(', c') })}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{#if clozePreviewHtml}
|
||||
<div class="rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
|
||||
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">
|
||||
{t('card_new.cloze_preview_label', { first: clusterIds[0] ?? 1 })}
|
||||
</div>
|
||||
<div class="prose prose-sm max-w-none">{@html clozePreviewHtml}</div>
|
||||
<div class="preview-box">
|
||||
<span class="preview-label">{t('card_new.cloze_preview_label', { first: clusterIds[0] ?? 1 })}</span>
|
||||
<div class="prose prose-sm">{@html clozePreviewHtml}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('card_new.cloze_extra_label')}</span>
|
||||
<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="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
|
||||
class="input mono"
|
||||
></textarea>
|
||||
</label>
|
||||
{:else if cardType === 'typing' || cardType === 'multiple-choice'}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('card_new.front_label')}</span>
|
||||
|
||||
{:else if cardType === 'multiple-choice'}
|
||||
<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>
|
||||
{#if front.trim()}
|
||||
<div class="preview-box mt-2">
|
||||
<span class="preview-label">{t('card_new.preview_label')}</span>
|
||||
<div class="prose prose-sm">{@html frontHtml}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="field-label">Antwortoptionen</span>
|
||||
<span class="field-hint">Markiere die richtige Antwort. Nicht befüllte Optionen werden ignoriert — KI ergänzt fehlende Distractors beim Lernen.</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; }}
|
||||
title="Als richtige Antwort markieren"
|
||||
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}
|
||||
<span class="mc-badge">✓ Richtig</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if cardType === 'typing'}
|
||||
<div class="grid-2">
|
||||
<label class="field">
|
||||
<span class="field-label">{t('card_new.front_label')}</span>
|
||||
<textarea
|
||||
bind:value={front}
|
||||
required
|
||||
rows="8"
|
||||
placeholder={t('card_new.front_placeholder')}
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
|
||||
class="input mono"
|
||||
></textarea>
|
||||
</label>
|
||||
{#if front.trim()}
|
||||
<div class="mt-2 rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
|
||||
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">{t('card_new.preview_label')}</div>
|
||||
<div class="prose prose-sm max-w-none">{@html frontHtml}</div>
|
||||
<div class="preview-box mt-2">
|
||||
<span class="preview-label">{t('card_new.preview_label')}</span>
|
||||
<div class="prose prose-sm">{@html frontHtml}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('card_new.answer_label')}</span>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('card_new.answer_label')}</span>
|
||||
<textarea
|
||||
bind:value={answer}
|
||||
required
|
||||
rows="8"
|
||||
placeholder={t('card_new.answer_placeholder')}
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
|
||||
class="input mono"
|
||||
></textarea>
|
||||
<span class="field-hint">Kommagetrennte Aliase sind erlaubt: <code>Paris, Paris (Frankreich)</code></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if cardType === 'multiple-choice'}
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('card_new.distractor_pool_label')}</span>
|
||||
<textarea
|
||||
bind:value={distractorPool}
|
||||
rows="4"
|
||||
placeholder={t('card_new.distractor_pool_placeholder')}
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
|
||||
></textarea>
|
||||
</label>
|
||||
{/if}
|
||||
{:else if cardType === 'audio-front'}
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('card_new.audio_ref_label')}</span>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('card_new.audio_ref_label')}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={audioFileRef}
|
||||
required
|
||||
placeholder={t('card_new.audio_ref_placeholder')}
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
|
||||
class="input mono"
|
||||
/>
|
||||
<span class="field-hint">Die media_ref-ID aus dem Media-Upload-Endpoint.</span>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('card_new.back_audio_label')}</span>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('card_new.back_audio_label')}</span>
|
||||
<textarea
|
||||
bind:value={back}
|
||||
required
|
||||
rows="6"
|
||||
rows="5"
|
||||
placeholder={t('card_new.back_placeholder')}
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
|
||||
class="input mono"
|
||||
></textarea>
|
||||
</label>
|
||||
{#if back.trim()}
|
||||
<div class="rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
|
||||
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">{t('card_new.preview_label')}</div>
|
||||
<div class="prose prose-sm max-w-none">{@html backHtml}</div>
|
||||
<div class="preview-box">
|
||||
<span class="preview-label">{t('card_new.preview_label')}</span>
|
||||
<div class="prose prose-sm">{@html backHtml}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('card_new.front_label')}</span>
|
||||
<!-- basic / basic-reverse -->
|
||||
<div class="grid-2">
|
||||
<label class="field">
|
||||
<span class="field-label">{t('card_new.front_label')}</span>
|
||||
<textarea
|
||||
bind:value={front}
|
||||
required
|
||||
rows="8"
|
||||
placeholder={t('card_new.front_placeholder')}
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
|
||||
class="input mono"
|
||||
></textarea>
|
||||
</label>
|
||||
{#if front.trim()}
|
||||
<div class="mt-2 rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
|
||||
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">{t('card_new.preview_label')}</div>
|
||||
<div class="prose prose-sm max-w-none">{@html frontHtml}</div>
|
||||
<div class="preview-box mt-2">
|
||||
<span class="preview-label">{t('card_new.preview_label')}</span>
|
||||
<div class="prose prose-sm">{@html frontHtml}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label class="block">
|
||||
<span class="text-sm font-medium">{t('card_new.back_label')}</span>
|
||||
<label class="field">
|
||||
<span class="field-label">{t('card_new.back_label')}</span>
|
||||
<textarea
|
||||
bind:value={back}
|
||||
required
|
||||
rows="8"
|
||||
placeholder={t('card_new.back_placeholder')}
|
||||
class="mt-1 block w-full rounded border bg-[hsl(var(--color-card))] border-[hsl(var(--color-border))] px-3 py-2 font-mono text-sm"
|
||||
class="input mono"
|
||||
></textarea>
|
||||
</label>
|
||||
{#if back.trim()}
|
||||
<div class="mt-2 rounded border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3 text-sm">
|
||||
<div class="mb-1 text-xs uppercase text-[hsl(var(--color-muted-foreground))]">{t('card_new.preview_label')}</div>
|
||||
<div class="prose prose-sm max-w-none">{@html backHtml}</div>
|
||||
<div class="preview-box mt-2">
|
||||
<span class="preview-label">{t('card_new.preview_label')}</span>
|
||||
<div class="prose prose-sm">{@html backHtml}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSave}
|
||||
class="rounded bg-[hsl(var(--color-primary))] px-4 py-2 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-50"
|
||||
>
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={!canSave} class="btn-primary">
|
||||
{saving ? t('card_new.creating') : t('card_new.create')}
|
||||
</button>
|
||||
<a
|
||||
href={deckId ? `/decks/${deckId}` : '/decks'}
|
||||
class="text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]">{t('card_new.cancel')}</a
|
||||
>
|
||||
<a href={deckId ? `/decks/${deckId}` : '/decks'} class="btn-ghost">{t('card_new.cancel')}</a>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page-shell {
|
||||
max-width: 52rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-decoration: none;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.back-link:hover { color: hsl(var(--color-foreground)); }
|
||||
|
||||
.page-title {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.card-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
background: hsl(var(--color-card));
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.125rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.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;
|
||||
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; }
|
||||
|
||||
.type-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0;
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-surface));
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
/* Multiple-Choice 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;
|
||||
color: hsl(var(--color-primary));
|
||||
transition: border-color 0.12s, background-color 0.12s;
|
||||
padding: 0;
|
||||
}
|
||||
.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);
|
||||
color: hsl(var(--color-success));
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 1.125rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: hsl(var(--color-primary) / 0.88); }
|
||||
.btn-primary:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
.btn-ghost {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-decoration: none;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.btn-ghost:hover { color: hsl(var(--color-foreground)); }
|
||||
|
||||
/* prose reset inside preview-box */
|
||||
.prose :global(p) { margin: 0 0 0.5em; font-size: 0.875rem; }
|
||||
.prose :global(p:last-child) { margin-bottom: 0; }
|
||||
.prose :global(code) {
|
||||
font-size: 0.8125rem;
|
||||
background: hsl(var(--color-muted) / 0.5);
|
||||
padding: 0.1em 0.3em;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue