mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(quiz): new Quiz module — build & play private quizzes (Phase 1)
Four question types (single/multi/truefalse/text), inline editor, play view with per-question feedback + final score review. Attempts are persisted per quiz. Encrypted at rest: title/description/tags on the container, questionText/explanation/options on questions. Attempts stay plaintext. Dexie v21, appId 'quiz', tier 'guest'. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0af50f0166
commit
3b99356464
18 changed files with 1978 additions and 0 deletions
|
|
@ -472,6 +472,24 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
// Free-form user text — encrypt the content, leave the fixed id plaintext.
|
||||
kontextDoc: { enabled: true, fields: ['content'] },
|
||||
|
||||
// ─── Quiz ────────────────────────────────────────────────
|
||||
// User-typed text on the container (title, description, category, tags)
|
||||
// plus the whole question payload (questionText, explanation, options).
|
||||
// `options` is QuestionOption[] — aes.ts JSON-stringifies before wrap,
|
||||
// same as food.foods / recipes.ingredients. The correctness flag inside
|
||||
// each option ships encrypted alongside the text, which is intentional:
|
||||
// a passive attacker with raw DB access should not be able to build an
|
||||
// answer key without the user's vault.
|
||||
// Plaintext (intentional): quizId foreign key, order, type discriminator,
|
||||
// questionCount denorm counter, isPinned / isArchived — all needed for
|
||||
// index/sort/filter.
|
||||
quizzes: { enabled: true, fields: ['title', 'description', 'category', 'tags'] },
|
||||
quizQuestions: { enabled: true, fields: ['questionText', 'explanation', 'options'] },
|
||||
// `quizAttempts` is intentionally NOT registered — only boolean `correct`
|
||||
// flags + a numeric score + timestamps + a small text-answer echo for
|
||||
// review. If post-launch review shows textAnswer should be encrypted,
|
||||
// add an entry here with fields: ['answers'].
|
||||
|
||||
// ─── AI Agents ───────────────────────────────────────────
|
||||
// Named AI personas (docs/plans/multi-agent-workbench.md). `name` +
|
||||
// `role` + `avatar` stay plaintext because `name` is the display-key
|
||||
|
|
|
|||
|
|
@ -536,6 +536,18 @@ db.version(20).stores({
|
|||
_aiDebugLog: 'iterationId, capturedAt',
|
||||
});
|
||||
|
||||
// v21 — Quiz module. Three tables: container (`quizzes`), per-quiz items
|
||||
// (`quizQuestions`, indexed on quizId for the play/edit view), and play-
|
||||
// through history (`quizAttempts`, indexed on quizId + startedAt for the
|
||||
// per-quiz leaderboard view). questionCount lives on `quizzes` as a
|
||||
// denormalised counter so the list view doesn't fan out into per-quiz
|
||||
// question scans.
|
||||
db.version(21).stores({
|
||||
quizzes: 'id, isPinned, isArchived, updatedAt',
|
||||
quizQuestions: 'id, quizId, order, [quizId+order]',
|
||||
quizAttempts: 'id, quizId, startedAt, [quizId+startedAt]',
|
||||
});
|
||||
|
||||
// ─── Sync Routing ──────────────────────────────────────────
|
||||
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
|
||||
// toSyncName() and fromSyncName() are now derived from per-module
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ import { meditateModuleConfig } from '$lib/modules/meditate/module.config';
|
|||
import { sleepModuleConfig } from '$lib/modules/sleep/module.config';
|
||||
import { moodModuleConfig } from '$lib/modules/mood/module.config';
|
||||
import { kontextModuleConfig } from '$lib/modules/kontext/module.config';
|
||||
import { quizModuleConfig } from '$lib/modules/quiz/module.config';
|
||||
import { aiModuleConfig } from '$lib/data/ai/module.config';
|
||||
|
||||
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
||||
|
|
@ -148,6 +149,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
sleepModuleConfig,
|
||||
moodModuleConfig,
|
||||
kontextModuleConfig,
|
||||
quizModuleConfig,
|
||||
aiModuleConfig,
|
||||
];
|
||||
|
||||
|
|
|
|||
583
apps/mana/apps/web/src/lib/modules/quiz/EditView.svelte
Normal file
583
apps/mana/apps/web/src/lib/modules/quiz/EditView.svelte
Normal file
|
|
@ -0,0 +1,583 @@
|
|||
<!--
|
||||
Quiz — EditView
|
||||
Edit quiz meta + add / edit / reorder questions inline.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useQuiz, useQuestions, blankOption } from './queries';
|
||||
import { quizzesStore } from './stores/quizzes.svelte';
|
||||
import { QUESTION_TYPE_LABELS } from './types';
|
||||
import type { QuestionType, QuestionOption, QuizQuestion } from './types';
|
||||
import { ArrowLeft, Plus, Trash, Check, Play } from '@mana/shared-icons';
|
||||
|
||||
interface Props {
|
||||
quizId: string;
|
||||
}
|
||||
let { quizId }: Props = $props();
|
||||
|
||||
const quiz$ = useQuiz(quizId);
|
||||
const quiz = $derived(quiz$.value);
|
||||
const questions$ = useQuestions(quizId);
|
||||
const questions = $derived(questions$.value);
|
||||
|
||||
// ── Inline meta editing (debounced on blur) ─────────
|
||||
let metaTitle = $state('');
|
||||
let metaDescription = $state('');
|
||||
let metaCategory = $state('');
|
||||
let metaTags = $state('');
|
||||
let metaLoaded = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!quiz || metaLoaded) return;
|
||||
metaTitle = quiz.title;
|
||||
metaDescription = quiz.description ?? '';
|
||||
metaCategory = quiz.category ?? '';
|
||||
metaTags = (quiz.tags ?? []).join(', ');
|
||||
metaLoaded = true;
|
||||
});
|
||||
|
||||
async function saveMeta() {
|
||||
if (!quiz) return;
|
||||
await quizzesStore.updateQuiz(quiz.id, {
|
||||
title: metaTitle.trim() || 'Unbenannt',
|
||||
description: metaDescription.trim() || null,
|
||||
category: metaCategory.trim() || null,
|
||||
tags: metaTags
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
// ── New question form ───────────────────────────────
|
||||
let newType = $state<QuestionType>('single');
|
||||
let newText = $state('');
|
||||
let newExplanation = $state('');
|
||||
let newOptions = $state<QuestionOption[]>([
|
||||
{ id: crypto.randomUUID(), text: '', isCorrect: true },
|
||||
{ id: crypto.randomUUID(), text: '', isCorrect: false },
|
||||
]);
|
||||
let newTextAnswer = $state('');
|
||||
|
||||
function resetNewForm() {
|
||||
newText = '';
|
||||
newExplanation = '';
|
||||
newTextAnswer = '';
|
||||
if (newType === 'truefalse') {
|
||||
newOptions = [
|
||||
{ id: 't', text: 'Wahr', isCorrect: true },
|
||||
{ id: 'f', text: 'Falsch', isCorrect: false },
|
||||
];
|
||||
} else if (newType === 'text') {
|
||||
newOptions = [];
|
||||
} else {
|
||||
newOptions = [
|
||||
{ id: crypto.randomUUID(), text: '', isCorrect: newType === 'single' },
|
||||
{ id: crypto.randomUUID(), text: '', isCorrect: false },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
// Rebuild option slots when question type changes.
|
||||
newType;
|
||||
resetNewForm();
|
||||
});
|
||||
|
||||
function toggleNewCorrect(id: string) {
|
||||
if (newType === 'single' || newType === 'truefalse') {
|
||||
newOptions = newOptions.map((o) => ({ ...o, isCorrect: o.id === id }));
|
||||
} else {
|
||||
newOptions = newOptions.map((o) => (o.id === id ? { ...o, isCorrect: !o.isCorrect } : o));
|
||||
}
|
||||
}
|
||||
|
||||
function addNewOption() {
|
||||
newOptions = [...newOptions, blankOption()];
|
||||
}
|
||||
|
||||
function removeNewOption(id: string) {
|
||||
newOptions = newOptions.filter((o) => o.id !== id);
|
||||
}
|
||||
|
||||
async function submitNewQuestion() {
|
||||
const text = newText.trim();
|
||||
if (!text) return;
|
||||
|
||||
let options: QuestionOption[];
|
||||
if (newType === 'text') {
|
||||
const answer = newTextAnswer.trim();
|
||||
if (!answer) return;
|
||||
options = [{ id: 'answer', text: answer, isCorrect: true }];
|
||||
} else {
|
||||
const valid = newOptions.filter((o) => o.text.trim());
|
||||
if (valid.length < 2) return;
|
||||
if (!valid.some((o) => o.isCorrect)) return;
|
||||
options = valid.map((o) => ({ ...o, text: o.text.trim() }));
|
||||
}
|
||||
|
||||
await quizzesStore.addQuestion(quizId, {
|
||||
type: newType,
|
||||
questionText: text,
|
||||
options,
|
||||
explanation: newExplanation.trim() || null,
|
||||
});
|
||||
resetNewForm();
|
||||
}
|
||||
|
||||
async function deleteQuestion(id: string) {
|
||||
if (!confirm('Frage löschen?')) return;
|
||||
await quizzesStore.deleteQuestion(id);
|
||||
}
|
||||
|
||||
function correctLabel(q: QuizQuestion): string {
|
||||
if (q.type === 'text') return q.options[0]?.text ?? '';
|
||||
return q.options
|
||||
.filter((o) => o.isCorrect)
|
||||
.map((o) => o.text)
|
||||
.join(', ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
<header class="header">
|
||||
<button class="back" onclick={() => goto('/quiz')} aria-label="Zurück">
|
||||
<ArrowLeft size={18} /> Quiz
|
||||
</button>
|
||||
{#if quiz}
|
||||
<button
|
||||
class="play-btn"
|
||||
disabled={questions.length === 0}
|
||||
onclick={() => goto(`/quiz/${quiz.id}/play`)}
|
||||
>
|
||||
<Play size={14} weight="fill" /> Spielen
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if !quiz}
|
||||
<p class="empty">Quiz nicht gefunden.</p>
|
||||
{:else}
|
||||
<section class="meta-section">
|
||||
<input
|
||||
class="title-input"
|
||||
type="text"
|
||||
bind:value={metaTitle}
|
||||
onblur={saveMeta}
|
||||
placeholder="Titel"
|
||||
/>
|
||||
<textarea
|
||||
class="desc-input"
|
||||
bind:value={metaDescription}
|
||||
onblur={saveMeta}
|
||||
placeholder="Beschreibung (optional)"
|
||||
rows="2"
|
||||
></textarea>
|
||||
<div class="meta-row">
|
||||
<input
|
||||
class="small-input"
|
||||
type="text"
|
||||
bind:value={metaCategory}
|
||||
onblur={saveMeta}
|
||||
placeholder="Kategorie"
|
||||
/>
|
||||
<input
|
||||
class="small-input"
|
||||
type="text"
|
||||
bind:value={metaTags}
|
||||
onblur={saveMeta}
|
||||
placeholder="Tags (Komma-getrennt)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="questions-section">
|
||||
<h2>Fragen ({questions.length})</h2>
|
||||
{#if questions.length === 0}
|
||||
<p class="empty">Noch keine Fragen — füge unten eine hinzu.</p>
|
||||
{:else}
|
||||
<ol class="question-list">
|
||||
{#each questions as q, i (q.id)}
|
||||
<li class="question-item">
|
||||
<div class="q-header">
|
||||
<span class="q-num">{i + 1}</span>
|
||||
<span class="q-type">{QUESTION_TYPE_LABELS[q.type]}</span>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Löschen"
|
||||
aria-label="Löschen"
|
||||
onclick={() => deleteQuestion(q.id)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<p class="q-text">{q.questionText}</p>
|
||||
<p class="q-answer">
|
||||
<Check size={12} /> <strong>{correctLabel(q)}</strong>
|
||||
</p>
|
||||
{#if q.explanation}
|
||||
<p class="q-expl">{q.explanation}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="new-section">
|
||||
<h2>Neue Frage</h2>
|
||||
<label class="field">
|
||||
<span>Typ</span>
|
||||
<select bind:value={newType}>
|
||||
<option value="single">Single Choice</option>
|
||||
<option value="multi">Multiple Choice</option>
|
||||
<option value="truefalse">Wahr / Falsch</option>
|
||||
<option value="text">Texteingabe</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Frage</span>
|
||||
<textarea bind:value={newText} rows="2" placeholder="Was möchtest du fragen?"></textarea>
|
||||
</label>
|
||||
|
||||
{#if newType === 'text'}
|
||||
<label class="field">
|
||||
<span>Korrekte Antwort</span>
|
||||
<input type="text" bind:value={newTextAnswer} placeholder="Erwartete Eingabe" />
|
||||
</label>
|
||||
{:else}
|
||||
<div class="options-block">
|
||||
<span class="options-label">
|
||||
Antworten {newType === 'multi' ? '(mehrere richtig möglich)' : '(eine richtig)'}
|
||||
</span>
|
||||
{#each newOptions as opt, i (opt.id)}
|
||||
<div class="option-row">
|
||||
<button
|
||||
class="correct-toggle"
|
||||
class:on={opt.isCorrect}
|
||||
title={opt.isCorrect ? 'Richtig' : 'Als richtig markieren'}
|
||||
aria-label="Richtig"
|
||||
onclick={() => toggleNewCorrect(opt.id)}
|
||||
>
|
||||
{#if opt.isCorrect}<Check size={14} weight="bold" />{/if}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newOptions[i].text}
|
||||
placeholder={`Antwort ${i + 1}`}
|
||||
disabled={newType === 'truefalse'}
|
||||
/>
|
||||
{#if newType !== 'truefalse' && newOptions.length > 2}
|
||||
<button
|
||||
class="icon-btn"
|
||||
aria-label="Entfernen"
|
||||
onclick={() => removeNewOption(opt.id)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if newType !== 'truefalse' && newOptions.length < 6}
|
||||
<button class="add-option" onclick={addNewOption}>
|
||||
<Plus size={12} /> Antwort hinzufügen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<label class="field">
|
||||
<span>Erklärung (optional)</span>
|
||||
<textarea
|
||||
bind:value={newExplanation}
|
||||
rows="2"
|
||||
placeholder="Wird nach dem Antworten angezeigt"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<button class="submit-btn" onclick={submitNewQuestion}>
|
||||
<Plus size={14} /> Frage hinzufügen
|
||||
</button>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem;
|
||||
max-width: 48rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.back:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.play-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ── Meta ── */
|
||||
.meta-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.title-input {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
outline: none;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.desc-input,
|
||||
.small-input {
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.small-input {
|
||||
flex: 1;
|
||||
}
|
||||
.desc-input:focus,
|
||||
.small-input:focus,
|
||||
.title-input:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
/* ── Question list ── */
|
||||
.question-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.question-item {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
.q-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.q-num {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.q-type {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.icon-btn {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
color: hsl(var(--color-error));
|
||||
background: hsl(var(--color-surface-hover));
|
||||
}
|
||||
.q-text {
|
||||
margin: 0.375rem 0 0.25rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.q-answer {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-primary));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.q-expl {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── New question form ── */
|
||||
.new-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.field > span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.field input,
|
||||
.field textarea,
|
||||
.field select {
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field input:focus,
|
||||
.field textarea:focus,
|
||||
.field select:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.options-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.options-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.option-row input {
|
||||
flex: 1;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
outline: none;
|
||||
}
|
||||
.correct-toggle {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 9999px;
|
||||
border: 1.5px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.correct-toggle.on {
|
||||
background: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.add-option {
|
||||
align-self: flex-start;
|
||||
background: transparent;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.add-option:hover {
|
||||
border-color: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
309
apps/mana/apps/web/src/lib/modules/quiz/ListView.svelte
Normal file
309
apps/mana/apps/web/src/lib/modules/quiz/ListView.svelte
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<!--
|
||||
Quiz — ListView
|
||||
Browse, create, pin and launch quizzes.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import { useAllQuizzes, searchQuizzes } from './queries';
|
||||
import { quizzesStore } from './stores/quizzes.svelte';
|
||||
import { PushPin, PencilSimple, Play, Trash } from '@mana/shared-icons';
|
||||
|
||||
let { navigate, goBack, params }: ViewProps = $props();
|
||||
|
||||
const quizzes$ = useAllQuizzes();
|
||||
const quizzes = $derived(quizzes$.value);
|
||||
|
||||
let searchQuery = $state('');
|
||||
let newTitle = $state('');
|
||||
|
||||
const filtered = $derived(searchQuizzes(quizzes, searchQuery));
|
||||
|
||||
async function handleCreate() {
|
||||
const title = newTitle.trim();
|
||||
if (!title) return;
|
||||
const quiz = await quizzesStore.createQuiz({ title });
|
||||
newTitle = '';
|
||||
goto(`/quiz/${quiz.id}/edit`);
|
||||
}
|
||||
|
||||
async function togglePin(id: string) {
|
||||
await quizzesStore.togglePin(id);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Quiz wirklich löschen?')) return;
|
||||
await quizzesStore.deleteQuiz(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Quiz - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="wrap">
|
||||
<header class="header">
|
||||
<h1>Quiz</h1>
|
||||
<input class="search-input" type="search" placeholder="Suchen…" bind:value={searchQuery} />
|
||||
</header>
|
||||
|
||||
<form class="create" onsubmit={(e) => (e.preventDefault(), handleCreate())}>
|
||||
<input
|
||||
class="create-input"
|
||||
type="text"
|
||||
placeholder="Neues Quiz — Titel eingeben und Enter"
|
||||
bind:value={newTitle}
|
||||
/>
|
||||
<button class="create-btn" type="submit" disabled={!newTitle.trim()}>Anlegen</button>
|
||||
</form>
|
||||
|
||||
{#if filtered.length === 0}
|
||||
<p class="empty">
|
||||
{searchQuery ? 'Keine Quizze gefunden.' : 'Noch keine Quizze. Leg eins oben an.'}
|
||||
</p>
|
||||
{:else}
|
||||
<ul class="quiz-list">
|
||||
{#each filtered as quiz (quiz.id)}
|
||||
<li class="quiz-item" class:pinned={quiz.isPinned}>
|
||||
<div class="quiz-main">
|
||||
<div class="quiz-top">
|
||||
{#if quiz.isPinned}
|
||||
<span class="pin-dot" aria-label="Angeheftet"></span>
|
||||
{/if}
|
||||
<span class="quiz-title">{quiz.title}</span>
|
||||
<span class="count">{quiz.questionCount} Fragen</span>
|
||||
</div>
|
||||
{#if quiz.description}
|
||||
<p class="quiz-desc">{quiz.description}</p>
|
||||
{/if}
|
||||
{#if quiz.tags.length > 0 || quiz.category}
|
||||
<div class="meta">
|
||||
{#if quiz.category}<span class="chip">{quiz.category}</span>{/if}
|
||||
{#each quiz.tags as tag}<span class="chip chip-tag">#{tag}</span>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Anheften"
|
||||
aria-label="Anheften"
|
||||
onclick={() => togglePin(quiz.id)}
|
||||
>
|
||||
<PushPin size={16} weight={quiz.isPinned ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Bearbeiten"
|
||||
aria-label="Bearbeiten"
|
||||
onclick={() => goto(`/quiz/${quiz.id}/edit`)}
|
||||
>
|
||||
<PencilSimple size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
title="Löschen"
|
||||
aria-label="Löschen"
|
||||
onclick={() => handleDelete(quiz.id)}
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
<button
|
||||
class="play-btn"
|
||||
disabled={quiz.questionCount === 0}
|
||||
onclick={() => goto(`/quiz/${quiz.id}/play`)}
|
||||
>
|
||||
<Play size={14} weight="fill" /> Spielen
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
.search-input {
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
outline: none;
|
||||
}
|
||||
.search-input:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.create {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.create-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
.create-input:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.create-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.create-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.quiz-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.quiz-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
.quiz-item.pinned {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
.quiz-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.quiz-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.pin-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-primary));
|
||||
}
|
||||
.quiz-title {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.count {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-left: auto;
|
||||
}
|
||||
.quiz-desc {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
.chip {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.chip-tag {
|
||||
background: hsl(var(--color-muted) / 0.3);
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.icon-btn {
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: hsl(var(--color-surface-hover));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.play-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
460
apps/mana/apps/web/src/lib/modules/quiz/PlayView.svelte
Normal file
460
apps/mana/apps/web/src/lib/modules/quiz/PlayView.svelte
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
<!--
|
||||
Quiz — PlayView
|
||||
Walk through all questions, grade, then show result.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { useQuiz, useQuestions, evaluateAnswer } from './queries';
|
||||
import { attemptsStore } from './stores/attempts.svelte';
|
||||
import type { AttemptAnswer } from './types';
|
||||
import { ArrowLeft, Check, X } from '@mana/shared-icons';
|
||||
|
||||
interface Props {
|
||||
quizId: string;
|
||||
}
|
||||
let { quizId }: Props = $props();
|
||||
|
||||
const quiz$ = useQuiz(quizId);
|
||||
const quiz = $derived(quiz$.value);
|
||||
const questions$ = useQuestions(quizId);
|
||||
const questions = $derived(questions$.value);
|
||||
|
||||
let attemptId = $state<string | null>(null);
|
||||
let currentIndex = $state(0);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
let textInput = $state('');
|
||||
let revealed = $state(false);
|
||||
let answers = $state<AttemptAnswer[]>([]);
|
||||
let finished = $state(false);
|
||||
|
||||
// Start an attempt once the questions list resolves.
|
||||
$effect(() => {
|
||||
if (!attemptId && questions.length > 0) {
|
||||
attemptsStore.startAttempt(quizId).then((a) => (attemptId = a.id));
|
||||
}
|
||||
});
|
||||
|
||||
const current = $derived(questions[currentIndex] ?? null);
|
||||
const total = $derived(questions.length);
|
||||
const correctCount = $derived(answers.filter((a) => a.correct).length);
|
||||
const scorePct = $derived(
|
||||
answers.length === 0 ? 0 : Math.round((correctCount / answers.length) * 100)
|
||||
);
|
||||
|
||||
function toggleSelect(optId: string) {
|
||||
if (revealed || !current) return;
|
||||
if (current.type === 'multi') {
|
||||
selectedIds = selectedIds.includes(optId)
|
||||
? selectedIds.filter((x) => x !== optId)
|
||||
: [...selectedIds, optId];
|
||||
} else {
|
||||
selectedIds = [optId];
|
||||
}
|
||||
}
|
||||
|
||||
function reveal() {
|
||||
if (!current) return;
|
||||
const correct = evaluateAnswer(
|
||||
current,
|
||||
selectedIds,
|
||||
current.type === 'text' ? textInput : null
|
||||
);
|
||||
answers = [
|
||||
...answers,
|
||||
{
|
||||
questionId: current.id,
|
||||
selectedOptionIds: selectedIds,
|
||||
textAnswer: current.type === 'text' ? textInput : null,
|
||||
correct,
|
||||
},
|
||||
];
|
||||
revealed = true;
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (currentIndex + 1 >= total) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
currentIndex += 1;
|
||||
selectedIds = [];
|
||||
textInput = '';
|
||||
revealed = false;
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
if (attemptId) {
|
||||
await attemptsStore.finishAttempt(attemptId, answers);
|
||||
}
|
||||
finished = true;
|
||||
}
|
||||
|
||||
function restart() {
|
||||
answers = [];
|
||||
currentIndex = 0;
|
||||
selectedIds = [];
|
||||
textInput = '';
|
||||
revealed = false;
|
||||
finished = false;
|
||||
attemptId = null;
|
||||
}
|
||||
|
||||
const canAnswer = $derived(
|
||||
!!current && (current.type === 'text' ? textInput.trim().length > 0 : selectedIds.length > 0)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="wrap">
|
||||
<header class="header">
|
||||
<button class="back" onclick={() => goto('/quiz')} aria-label="Zurück">
|
||||
<ArrowLeft size={18} /> Quiz
|
||||
</button>
|
||||
{#if quiz}
|
||||
<span class="title">{quiz.title}</span>
|
||||
{/if}
|
||||
{#if !finished && total > 0}
|
||||
<span class="progress">{currentIndex + 1} / {total}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if !quiz}
|
||||
<p class="empty">Quiz nicht gefunden.</p>
|
||||
{:else if total === 0}
|
||||
<p class="empty">Dieses Quiz hat noch keine Fragen.</p>
|
||||
{:else if finished}
|
||||
<section class="result">
|
||||
<div class="score">
|
||||
<span class="score-num">{scorePct}%</span>
|
||||
<span class="score-sub">{correctCount} von {total} richtig</span>
|
||||
</div>
|
||||
<ol class="review">
|
||||
{#each questions as q, i (q.id)}
|
||||
{@const ans = answers[i]}
|
||||
<li class="review-item" class:ok={ans?.correct} class:fail={ans && !ans.correct}>
|
||||
<div class="review-head">
|
||||
{#if ans?.correct}<Check size={14} weight="bold" />{:else}<X
|
||||
size={14}
|
||||
weight="bold"
|
||||
/>{/if}
|
||||
<span>{q.questionText}</span>
|
||||
</div>
|
||||
{#if q.type === 'text'}
|
||||
<p class="review-line">
|
||||
Deine Antwort: <strong>{ans?.textAnswer || '—'}</strong>
|
||||
</p>
|
||||
{#if !ans?.correct}
|
||||
<p class="review-line">Richtig: <strong>{q.options[0]?.text}</strong></p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="review-line">
|
||||
Richtig:
|
||||
<strong>
|
||||
{q.options
|
||||
.filter((o) => o.isCorrect)
|
||||
.map((o) => o.text)
|
||||
.join(', ')}
|
||||
</strong>
|
||||
</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
<div class="result-actions">
|
||||
<button class="secondary-btn" onclick={() => goto('/quiz')}>Zurück zur Liste</button>
|
||||
<button class="primary-btn" onclick={restart}>Nochmal spielen</button>
|
||||
</div>
|
||||
</section>
|
||||
{:else if current}
|
||||
<section class="play">
|
||||
<p class="q-text">{current.questionText}</p>
|
||||
|
||||
{#if current.type === 'text'}
|
||||
<input
|
||||
class="text-answer"
|
||||
type="text"
|
||||
bind:value={textInput}
|
||||
disabled={revealed}
|
||||
placeholder="Deine Antwort"
|
||||
/>
|
||||
{#if revealed}
|
||||
<p class="feedback" class:ok={answers.at(-1)?.correct}>
|
||||
{#if answers.at(-1)?.correct}
|
||||
<Check size={14} weight="bold" /> Richtig!
|
||||
{:else}
|
||||
<X size={14} weight="bold" /> Richtige Antwort:
|
||||
<strong>{current.options[0]?.text}</strong>
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<ul class="options">
|
||||
{#each current.options as opt (opt.id)}
|
||||
{@const isSelected = selectedIds.includes(opt.id)}
|
||||
{@const isCorrect = opt.isCorrect}
|
||||
<li>
|
||||
<button
|
||||
class="option"
|
||||
class:selected={isSelected}
|
||||
class:reveal-correct={revealed && isCorrect}
|
||||
class:reveal-wrong={revealed && isSelected && !isCorrect}
|
||||
disabled={revealed}
|
||||
onclick={() => toggleSelect(opt.id)}
|
||||
>
|
||||
<span class="option-text">{opt.text}</span>
|
||||
{#if revealed && isCorrect}
|
||||
<Check size={16} weight="bold" />
|
||||
{:else if revealed && isSelected && !isCorrect}
|
||||
<X size={16} weight="bold" />
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if revealed && current.explanation}
|
||||
<p class="explanation">{current.explanation}</p>
|
||||
{/if}
|
||||
|
||||
<div class="play-actions">
|
||||
{#if !revealed}
|
||||
<button class="primary-btn" disabled={!canAnswer} onclick={reveal}>
|
||||
Antwort prüfen
|
||||
</button>
|
||||
{:else}
|
||||
<button class="primary-btn" onclick={next}>
|
||||
{currentIndex + 1 >= total ? 'Ergebnis ansehen' : 'Weiter'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem;
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.back:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.title {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.progress {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
/* ── Play ── */
|
||||
.play {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.q-text {
|
||||
font-size: 1.0625rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
.options {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.option {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1.5px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.9375rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.option:hover:not(:disabled) {
|
||||
border-color: hsl(var(--color-primary) / 0.5);
|
||||
}
|
||||
.option.selected {
|
||||
border-color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
}
|
||||
.option.reveal-correct {
|
||||
border-color: hsl(var(--color-success, 142 76% 36%));
|
||||
background: hsl(var(--color-success, 142 76% 36%) / 0.12);
|
||||
}
|
||||
.option.reveal-wrong {
|
||||
border-color: hsl(var(--color-error));
|
||||
background: hsl(var(--color-error) / 0.1);
|
||||
}
|
||||
.option:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.text-answer {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1.5px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-surface));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.9375rem;
|
||||
outline: none;
|
||||
}
|
||||
.text-answer:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.feedback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-error));
|
||||
margin: 0;
|
||||
}
|
||||
.feedback.ok {
|
||||
color: hsl(var(--color-success, 142 76% 36%));
|
||||
}
|
||||
.explanation {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-muted) / 0.2);
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.play-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.primary-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.secondary-btn {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Result ── */
|
||||
.result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.score {
|
||||
text-align: center;
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.score-num {
|
||||
display: block;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.score-sub {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.review {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.review-item {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.review-item.ok {
|
||||
border-color: hsl(var(--color-success, 142 76% 36%) / 0.4);
|
||||
}
|
||||
.review-item.fail {
|
||||
border-color: hsl(var(--color-error) / 0.4);
|
||||
}
|
||||
.review-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.review-item.ok .review-head {
|
||||
color: hsl(var(--color-success, 142 76% 36%));
|
||||
}
|
||||
.review-item.fail .review-head {
|
||||
color: hsl(var(--color-error));
|
||||
}
|
||||
.review-line {
|
||||
margin: 0.25rem 0 0;
|
||||
padding-left: 1.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
66
apps/mana/apps/web/src/lib/modules/quiz/collections.ts
Normal file
66
apps/mana/apps/web/src/lib/modules/quiz/collections.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* Quiz module — Dexie collection accessors and guest seed.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalQuiz, LocalQuizQuestion, LocalQuizAttempt } from './types';
|
||||
|
||||
export const quizTable = db.table<LocalQuiz>('quizzes');
|
||||
export const quizQuestionTable = db.table<LocalQuizQuestion>('quizQuestions');
|
||||
export const quizAttemptTable = db.table<LocalQuizAttempt>('quizAttempts');
|
||||
|
||||
// ─── Guest Seed ────────────────────────────────────────────
|
||||
|
||||
const DEMO_QUIZ_ID = 'quiz-demo';
|
||||
|
||||
export const QUIZ_GUEST_SEED = {
|
||||
quizzes: [
|
||||
{
|
||||
id: DEMO_QUIZ_ID,
|
||||
title: 'Willkommen — Mini-Quiz',
|
||||
description: 'Drei Fragen, um alle Typen auszuprobieren.',
|
||||
category: 'Demo',
|
||||
tags: ['Start'],
|
||||
questionCount: 3,
|
||||
isPinned: true,
|
||||
isArchived: false,
|
||||
},
|
||||
] satisfies LocalQuiz[],
|
||||
quizQuestions: [
|
||||
{
|
||||
id: 'quiz-demo-q1',
|
||||
quizId: DEMO_QUIZ_ID,
|
||||
order: 0,
|
||||
type: 'single',
|
||||
questionText: 'Welche Hauptstadt gehört zu Frankreich?',
|
||||
options: [
|
||||
{ id: 'a', text: 'Berlin', isCorrect: false },
|
||||
{ id: 'b', text: 'Paris', isCorrect: true },
|
||||
{ id: 'c', text: 'Madrid', isCorrect: false },
|
||||
{ id: 'd', text: 'Rom', isCorrect: false },
|
||||
],
|
||||
explanation: 'Paris ist die Hauptstadt Frankreichs.',
|
||||
},
|
||||
{
|
||||
id: 'quiz-demo-q2',
|
||||
quizId: DEMO_QUIZ_ID,
|
||||
order: 1,
|
||||
type: 'truefalse',
|
||||
questionText: 'Die Erde ist eine Scheibe.',
|
||||
options: [
|
||||
{ id: 't', text: 'Wahr', isCorrect: false },
|
||||
{ id: 'f', text: 'Falsch', isCorrect: true },
|
||||
],
|
||||
explanation: null,
|
||||
},
|
||||
{
|
||||
id: 'quiz-demo-q3',
|
||||
quizId: DEMO_QUIZ_ID,
|
||||
order: 2,
|
||||
type: 'text',
|
||||
questionText: 'Wie heißt dieses Ökosystem? (Tipp: vier Buchstaben)',
|
||||
options: [{ id: 'answer', text: 'Mana', isCorrect: true }],
|
||||
explanation: 'Groß-/Kleinschreibung wird ignoriert.',
|
||||
},
|
||||
] satisfies LocalQuizQuestion[],
|
||||
};
|
||||
35
apps/mana/apps/web/src/lib/modules/quiz/index.ts
Normal file
35
apps/mana/apps/web/src/lib/modules/quiz/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Quiz module — barrel exports.
|
||||
*/
|
||||
|
||||
export { quizzesStore } from './stores/quizzes.svelte';
|
||||
export { attemptsStore } from './stores/attempts.svelte';
|
||||
|
||||
export {
|
||||
useAllQuizzes,
|
||||
useQuiz,
|
||||
useQuestions,
|
||||
useAttempts,
|
||||
toQuiz,
|
||||
toQuestion,
|
||||
toAttempt,
|
||||
evaluateAnswer,
|
||||
computeScore,
|
||||
searchQuizzes,
|
||||
blankOption,
|
||||
} from './queries';
|
||||
|
||||
export { quizTable, quizQuestionTable, quizAttemptTable, QUIZ_GUEST_SEED } from './collections';
|
||||
|
||||
export { QUESTION_TYPE_LABELS } from './types';
|
||||
export type {
|
||||
LocalQuiz,
|
||||
LocalQuizQuestion,
|
||||
LocalQuizAttempt,
|
||||
Quiz,
|
||||
QuizQuestion,
|
||||
QuizAttempt,
|
||||
QuestionOption,
|
||||
QuestionType,
|
||||
AttemptAnswer,
|
||||
} from './types';
|
||||
10
apps/mana/apps/web/src/lib/modules/quiz/module.config.ts
Normal file
10
apps/mana/apps/web/src/lib/modules/quiz/module.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const quizModuleConfig: ModuleConfig = {
|
||||
appId: 'quiz',
|
||||
tables: [
|
||||
{ name: 'quizzes' },
|
||||
{ name: 'quizQuestions', syncName: 'questions' },
|
||||
{ name: 'quizAttempts', syncName: 'attempts' },
|
||||
],
|
||||
};
|
||||
159
apps/mana/apps/web/src/lib/modules/quiz/queries.ts
Normal file
159
apps/mana/apps/web/src/lib/modules/quiz/queries.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Quiz — reactive queries, type converters, pure helpers.
|
||||
*
|
||||
* Content fields (title, description, questionText, explanation, options)
|
||||
* are encrypted at rest. Queries decrypt the visible slice before mapping
|
||||
* to public DTOs. Attempts stay plaintext (only scores + timestamps +
|
||||
* AttemptAnswer payloads, which hold no user-typed content).
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import type {
|
||||
LocalQuiz,
|
||||
LocalQuizQuestion,
|
||||
LocalQuizAttempt,
|
||||
Quiz,
|
||||
QuizQuestion,
|
||||
QuizAttempt,
|
||||
QuestionOption,
|
||||
AttemptAnswer,
|
||||
} from './types';
|
||||
|
||||
// ─── Converters ────────────────────────────────────────────
|
||||
|
||||
export function toQuiz(local: LocalQuiz): Quiz {
|
||||
return {
|
||||
id: local.id,
|
||||
title: local.title,
|
||||
description: local.description,
|
||||
category: local.category,
|
||||
tags: local.tags ?? [],
|
||||
questionCount: local.questionCount ?? 0,
|
||||
isPinned: local.isPinned ?? false,
|
||||
isArchived: local.isArchived ?? false,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toQuestion(local: LocalQuizQuestion): QuizQuestion {
|
||||
return {
|
||||
id: local.id,
|
||||
quizId: local.quizId,
|
||||
order: local.order,
|
||||
type: local.type,
|
||||
questionText: local.questionText,
|
||||
options: local.options ?? [],
|
||||
explanation: local.explanation,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toAttempt(local: LocalQuizAttempt): QuizAttempt {
|
||||
return {
|
||||
id: local.id,
|
||||
quizId: local.quizId,
|
||||
startedAt: local.startedAt,
|
||||
finishedAt: local.finishedAt,
|
||||
score: local.score ?? 0,
|
||||
answers: local.answers ?? [],
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Live Queries ──────────────────────────────────────────
|
||||
|
||||
export function useAllQuizzes() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const visible = (await db.table<LocalQuiz>('quizzes').toArray()).filter(
|
||||
(q) => !q.deletedAt && !q.isArchived
|
||||
);
|
||||
const decrypted = await decryptRecords('quizzes', visible);
|
||||
return decrypted.map(toQuiz).sort((a, b) => {
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
});
|
||||
}, [] as Quiz[]);
|
||||
}
|
||||
|
||||
export function useQuiz(id: string) {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const local = await db.table<LocalQuiz>('quizzes').get(id);
|
||||
if (!local || local.deletedAt) return null;
|
||||
const [decrypted] = await decryptRecords('quizzes', [local]);
|
||||
return decrypted ? toQuiz(decrypted) : null;
|
||||
},
|
||||
null as Quiz | null
|
||||
);
|
||||
}
|
||||
|
||||
export function useQuestions(quizId: string) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const visible = (
|
||||
await db.table<LocalQuizQuestion>('quizQuestions').where('quizId').equals(quizId).toArray()
|
||||
).filter((q) => !q.deletedAt);
|
||||
const decrypted = await decryptRecords('quizQuestions', visible);
|
||||
return decrypted.map(toQuestion).sort((a, b) => a.order - b.order);
|
||||
}, [] as QuizQuestion[]);
|
||||
}
|
||||
|
||||
export function useAttempts(quizId: string) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const visible = (
|
||||
await db.table<LocalQuizAttempt>('quizAttempts').where('quizId').equals(quizId).toArray()
|
||||
).filter((a) => !a.deletedAt);
|
||||
return visible.map(toAttempt).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
||||
}, [] as QuizAttempt[]);
|
||||
}
|
||||
|
||||
// ─── Scoring ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluate a single answer against a question. Pure — drives both the live
|
||||
* Play view and the post-hoc attempt summary.
|
||||
*/
|
||||
export function evaluateAnswer(
|
||||
question: QuizQuestion,
|
||||
selectedOptionIds: string[],
|
||||
textAnswer: string | null
|
||||
): boolean {
|
||||
if (question.type === 'text') {
|
||||
const expected = question.options[0]?.text?.trim().toLowerCase() ?? '';
|
||||
const given = (textAnswer ?? '').trim().toLowerCase();
|
||||
return expected.length > 0 && given === expected;
|
||||
}
|
||||
const correctIds = new Set(question.options.filter((o) => o.isCorrect).map((o) => o.id));
|
||||
const selected = new Set(selectedOptionIds);
|
||||
if (correctIds.size !== selected.size) return false;
|
||||
for (const id of correctIds) if (!selected.has(id)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function computeScore(answers: AttemptAnswer[]): number {
|
||||
if (answers.length === 0) return 0;
|
||||
const correct = answers.filter((a) => a.correct).length;
|
||||
return correct / answers.length;
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────
|
||||
|
||||
export function searchQuizzes(quizzes: Quiz[], query: string): Quiz[] {
|
||||
if (!query.trim()) return quizzes;
|
||||
const q = query.toLowerCase();
|
||||
return quizzes.filter((quiz) => {
|
||||
const hay = [quiz.title, quiz.description, quiz.category, ...(quiz.tags ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
export function blankOption(): QuestionOption {
|
||||
return { id: crypto.randomUUID(), text: '', isCorrect: false };
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Attempts — mutation service for quiz play-throughs.
|
||||
*
|
||||
* Not encrypted: attempts carry only references + booleans + a score.
|
||||
* No user-typed content leaks here (answer text on `text` questions IS
|
||||
* user input, but it's compared at evaluate-time and then stored as the
|
||||
* boolean `correct` + the raw string echo for review). We keep the raw
|
||||
* textAnswer plaintext for now — if review of incorrect attempts ever
|
||||
* shows it should be encrypted, flip the registry entry.
|
||||
*/
|
||||
|
||||
import { quizAttemptTable } from '../collections';
|
||||
import { toAttempt } from '../queries';
|
||||
import type { LocalQuizAttempt, AttemptAnswer, QuizAttempt } from '../types';
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export const attemptsStore = {
|
||||
async startAttempt(quizId: string): Promise<QuizAttempt> {
|
||||
const newLocal: LocalQuizAttempt = {
|
||||
id: crypto.randomUUID(),
|
||||
quizId,
|
||||
startedAt: now(),
|
||||
finishedAt: null,
|
||||
score: 0,
|
||||
answers: [],
|
||||
};
|
||||
const snapshot = toAttempt(newLocal);
|
||||
await quizAttemptTable.add(newLocal);
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async finishAttempt(id: string, answers: AttemptAnswer[]) {
|
||||
const score =
|
||||
answers.length === 0 ? 0 : answers.filter((a) => a.correct).length / answers.length;
|
||||
await quizAttemptTable.update(id, {
|
||||
answers,
|
||||
score,
|
||||
finishedAt: now(),
|
||||
updatedAt: now(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteAttempt(id: string) {
|
||||
await quizAttemptTable.update(id, { deletedAt: now(), updatedAt: now() });
|
||||
},
|
||||
};
|
||||
115
apps/mana/apps/web/src/lib/modules/quiz/stores/quizzes.svelte.ts
Normal file
115
apps/mana/apps/web/src/lib/modules/quiz/stores/quizzes.svelte.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Quizzes + Questions — mutation-only service.
|
||||
*
|
||||
* Encrypted fields: title, description, category, tags on quizzes;
|
||||
* questionText, explanation, options on quizQuestions.
|
||||
*/
|
||||
|
||||
import { quizTable, quizQuestionTable } from '../collections';
|
||||
import { toQuiz } from '../queries';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import type { LocalQuiz, LocalQuizQuestion, Quiz, QuestionOption, QuestionType } from '../types';
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export const quizzesStore = {
|
||||
async createQuiz(data: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
category?: string | null;
|
||||
tags?: string[];
|
||||
}): Promise<Quiz> {
|
||||
const newLocal: LocalQuiz = {
|
||||
id: crypto.randomUUID(),
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
category: data.category ?? null,
|
||||
tags: data.tags ?? [],
|
||||
questionCount: 0,
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
};
|
||||
const snapshot = toQuiz(newLocal);
|
||||
await encryptRecord('quizzes', newLocal);
|
||||
await quizTable.add(newLocal);
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async updateQuiz(
|
||||
id: string,
|
||||
data: Partial<
|
||||
Pick<LocalQuiz, 'title' | 'description' | 'category' | 'tags' | 'isPinned' | 'isArchived'>
|
||||
>
|
||||
) {
|
||||
const diff: Partial<LocalQuiz> = { ...data, updatedAt: now() };
|
||||
await encryptRecord('quizzes', diff);
|
||||
await quizTable.update(id, diff);
|
||||
},
|
||||
|
||||
async deleteQuiz(id: string) {
|
||||
await quizTable.update(id, { deletedAt: now(), updatedAt: now() });
|
||||
const questions = await quizQuestionTable.where('quizId').equals(id).toArray();
|
||||
await Promise.all(
|
||||
questions.map((q) => quizQuestionTable.update(q.id, { deletedAt: now(), updatedAt: now() }))
|
||||
);
|
||||
},
|
||||
|
||||
async togglePin(id: string) {
|
||||
const quiz = await quizTable.get(id);
|
||||
if (!quiz) return;
|
||||
await quizTable.update(id, { isPinned: !quiz.isPinned, updatedAt: now() });
|
||||
},
|
||||
|
||||
// ── Questions ──────────────────────────────────────────
|
||||
|
||||
async addQuestion(
|
||||
quizId: string,
|
||||
data: {
|
||||
type: QuestionType;
|
||||
questionText: string;
|
||||
options: QuestionOption[];
|
||||
explanation?: string | null;
|
||||
}
|
||||
) {
|
||||
const existing = await quizQuestionTable.where('quizId').equals(quizId).count();
|
||||
const newLocal: LocalQuizQuestion = {
|
||||
id: crypto.randomUUID(),
|
||||
quizId,
|
||||
order: existing,
|
||||
type: data.type,
|
||||
questionText: data.questionText,
|
||||
options: data.options,
|
||||
explanation: data.explanation ?? null,
|
||||
};
|
||||
await encryptRecord('quizQuestions', newLocal);
|
||||
await quizQuestionTable.add(newLocal);
|
||||
await this.recountQuestions(quizId);
|
||||
},
|
||||
|
||||
async updateQuestion(
|
||||
id: string,
|
||||
data: Partial<
|
||||
Pick<LocalQuizQuestion, 'type' | 'questionText' | 'options' | 'explanation' | 'order'>
|
||||
>
|
||||
) {
|
||||
const diff: Partial<LocalQuizQuestion> = { ...data, updatedAt: now() };
|
||||
await encryptRecord('quizQuestions', diff);
|
||||
await quizQuestionTable.update(id, diff);
|
||||
},
|
||||
|
||||
async deleteQuestion(id: string) {
|
||||
const q = await quizQuestionTable.get(id);
|
||||
if (!q) return;
|
||||
await quizQuestionTable.update(id, { deletedAt: now(), updatedAt: now() });
|
||||
await this.recountQuestions(q.quizId);
|
||||
},
|
||||
|
||||
async recountQuestions(quizId: string) {
|
||||
const live = (await quizQuestionTable.where('quizId').equals(quizId).toArray()).filter(
|
||||
(q) => !q.deletedAt
|
||||
);
|
||||
await quizTable.update(quizId, { questionCount: live.length, updatedAt: now() });
|
||||
},
|
||||
};
|
||||
101
apps/mana/apps/web/src/lib/modules/quiz/types.ts
Normal file
101
apps/mana/apps/web/src/lib/modules/quiz/types.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Quiz module types.
|
||||
*
|
||||
* Three tables: `quizzes` (container), `quizQuestions` (per-quiz items),
|
||||
* `quizAttempts` (one row per play-through with per-question results).
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
||||
export type QuestionType = 'single' | 'multi' | 'truefalse' | 'text';
|
||||
|
||||
export interface QuestionOption {
|
||||
id: string;
|
||||
text: string;
|
||||
isCorrect: boolean;
|
||||
}
|
||||
|
||||
// ─── Local (Dexie) Records ─────────────────────────────────
|
||||
|
||||
export interface LocalQuiz extends BaseRecord {
|
||||
title: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
tags: string[];
|
||||
questionCount: number;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
}
|
||||
|
||||
export interface LocalQuizQuestion extends BaseRecord {
|
||||
quizId: string;
|
||||
order: number;
|
||||
type: QuestionType;
|
||||
questionText: string;
|
||||
/** `single` / `multi` / `truefalse`: one entry per choice with isCorrect flag.
|
||||
* `text`: single entry whose `text` is the expected answer (case-insensitive compare). */
|
||||
options: QuestionOption[];
|
||||
explanation: string | null;
|
||||
}
|
||||
|
||||
export interface AttemptAnswer {
|
||||
questionId: string;
|
||||
selectedOptionIds: string[];
|
||||
textAnswer: string | null;
|
||||
correct: boolean;
|
||||
}
|
||||
|
||||
export interface LocalQuizAttempt extends BaseRecord {
|
||||
quizId: string;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
score: number; // 0..1
|
||||
answers: AttemptAnswer[];
|
||||
}
|
||||
|
||||
// ─── Public DTOs ───────────────────────────────────────────
|
||||
|
||||
export interface Quiz {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
tags: string[];
|
||||
questionCount: number;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface QuizQuestion {
|
||||
id: string;
|
||||
quizId: string;
|
||||
order: number;
|
||||
type: QuestionType;
|
||||
questionText: string;
|
||||
options: QuestionOption[];
|
||||
explanation: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface QuizAttempt {
|
||||
id: string;
|
||||
quizId: string;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
score: number;
|
||||
answers: AttemptAnswer[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ─── Labels ────────────────────────────────────────────────
|
||||
|
||||
export const QUESTION_TYPE_LABELS: Record<QuestionType, string> = {
|
||||
single: 'Single Choice',
|
||||
multi: 'Multiple Choice',
|
||||
truefalse: 'Wahr / Falsch',
|
||||
text: 'Texteingabe',
|
||||
};
|
||||
9
apps/mana/apps/web/src/routes/(app)/quiz/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/quiz/+page.svelte
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts">
|
||||
import ListView from '$lib/modules/quiz/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Quiz - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import EditView from '$lib/modules/quiz/EditView.svelte';
|
||||
|
||||
const quizId = $derived($page.params.id ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Quiz bearbeiten - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
{#key quizId}
|
||||
<EditView {quizId} />
|
||||
{/key}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import PlayView from '$lib/modules/quiz/PlayView.svelte';
|
||||
|
||||
const quizId = $derived($page.params.id ?? '');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Quiz spielen - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
{#key quizId}
|
||||
<PlayView {quizId} />
|
||||
{/key}
|
||||
|
|
@ -85,6 +85,11 @@ export const APP_ICONS = {
|
|||
chat: svgToDataUrl(chatSvg),
|
||||
presi: svgToDataUrl(presiSvg),
|
||||
cards: svgToDataUrl(cardsSvg),
|
||||
quiz: svgToDataUrl(
|
||||
// Speech-bubble question mark with a small checkmark — quiz / answer.
|
||||
// Pink→fuchsia gradient to stand apart from the purple Cards icon.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="qz" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ec4899"/><stop offset="100%" style="stop-color:#a21caf"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#qz)"/><path d="M22 34a8 8 0 0 1 8-8h40a8 8 0 0 1 8 8v24a8 8 0 0 1-8 8H42l-12 10v-10h0a8 8 0 0 1-8-8V34z" fill="white"/><path d="M42 42c0-5 4-8 8-8s8 3 8 8c0 4-4 5-6 7-1 1-2 3-2 5" stroke="#a21caf" stroke-width="3.5" stroke-linecap="round" fill="none"/><circle cx="50" cy="60" r="2.4" fill="#a21caf"/><path d="M60 72l5 5 11-11" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
|
||||
),
|
||||
picture: svgToDataUrl(pictureSvg),
|
||||
quotes: svgToDataUrl(quotesSvg),
|
||||
wisekeep: svgToDataUrl(wisekeepSvg),
|
||||
|
|
|
|||
|
|
@ -170,6 +170,23 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'development',
|
||||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'quiz',
|
||||
name: 'Quiz',
|
||||
description: {
|
||||
de: 'Wissen testen',
|
||||
en: 'Test your knowledge',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Eigene Quizze bauen und spielen — Single-, Multiple-Choice, Wahr/Falsch oder Freitext.',
|
||||
en: 'Build and play your own quizzes — single/multiple choice, true/false, or free text.',
|
||||
},
|
||||
icon: APP_ICONS.quiz,
|
||||
color: '#ec4899',
|
||||
comingSoon: false,
|
||||
status: 'beta',
|
||||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'picture',
|
||||
name: 'ManaPicture',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue