create deck with json

This commit is contained in:
gitea
2026-02-13 15:31:25 +01:00
parent 43486d7c39
commit 9eede674b6
16 changed files with 1537 additions and 91 deletions
+29 -1
View File
@@ -1,4 +1,5 @@
<script>
import { push } from 'svelte-spa-router';
import { auth } from './stores/auth.js';
let showPopup = false;
@@ -56,7 +57,13 @@
</script>
<nav class="navbar">
<span class="app-name">Omotomo</span>
<div class="nav-left">
<a href="/" class="app-name" onclick={(e) => { e.preventDefault(); push('/'); }}>Omotomo</a>
{#if $auth.user}
<a href="/" class="nav-link" onclick={(e) => { e.preventDefault(); push('/'); }}>My decks</a>
{/if}
<a href="/community" class="nav-link" onclick={(e) => { e.preventDefault(); push('/community'); }}>Community</a>
</div>
<div class="nav-actions">
{#if $auth.loading}
<span class="username"></span>
@@ -152,10 +159,31 @@
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
}
.nav-left {
display: flex;
align-items: center;
gap: 1.25rem;
}
.app-name {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
text-decoration: none;
}
.app-name:hover {
color: var(--text-primary, #f0f0f0);
}
.nav-link {
font-size: 0.95rem;
color: var(--text-muted, #a0a0a0);
text-decoration: none;
}
.nav-link:hover {
color: var(--text-primary, #f0f0f0);
}
.nav-actions {
+180
View File
@@ -0,0 +1,180 @@
<script>
export let question = { prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] };
export let index = 0;
export let onRemove = () => {};
function addAnswer() {
if (!question.answers) question.answers = [''];
question.answers = [...question.answers, ''];
if (!question.correct_answer_indices?.length) question.correct_answer_indices = [0];
}
function removeAnswer(i) {
const answers = [...(question.answers || [])];
let indices = (question.correct_answer_indices ?? [0]).filter((idx) => idx !== i).map((idx) => (idx > i ? idx - 1 : idx));
answers.splice(i, 1);
if (indices.length === 0) indices = [0];
question.answers = answers;
question.correct_answer_indices = indices;
}
function setAnswer(i, value) {
const answers = [...(question.answers || [])];
answers[i] = value;
question.answers = answers;
}
function toggleCorrect(i) {
const indices = [...(question.correct_answer_indices ?? [0])];
const pos = indices.indexOf(i);
if (pos >= 0) {
indices.splice(pos, 1);
} else {
indices.push(i);
indices.sort((a, b) => a - b);
}
question.correct_answer_indices = indices.length ? indices : [0];
}
</script>
<div class="question-editor">
<div class="q-row">
<span class="q-label">Question {index + 1}</span>
<button type="button" class="btn-remove" onclick={onRemove} title="Remove question">×</button>
</div>
<input
type="text"
class="input"
placeholder="Prompt"
bind:value={question.prompt}
/>
<textarea
class="input textarea"
placeholder="Explanation (optional)"
bind:value={question.explanation}
rows="2"
></textarea>
<div class="answers-section">
<span class="answers-label">Answers (check correct)</span>
{#each (question.answers || ['']) as answer, i}
<div class="answer-row">
<input
type="checkbox"
checked={(question.correct_answer_indices ?? [0]).includes(i)}
onchange={() => toggleCorrect(i)}
class="correct-check"
/>
<input
type="text"
class="input answer-input"
placeholder="Answer {i + 1}"
value={answer}
oninput={(e) => setAnswer(i, e.target.value)}
/>
{#if (question.answers || []).length > 1}
<button type="button" class="btn-remove" onclick={() => removeAnswer(i)} title="Remove answer">×</button>
{/if}
</div>
{/each}
<button type="button" class="btn-add" onclick={addAnswer}>+ Add answer</button>
</div>
</div>
<style>
.question-editor {
padding: 1rem;
background: var(--bg-muted, #252525);
border: 1px solid var(--border, #333);
border-radius: 8px;
margin-bottom: 1rem;
}
.q-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.q-label {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-muted, #a0a0a0);
}
.btn-remove {
width: 1.5rem;
height: 1.5rem;
padding: 0;
font-size: 1.2rem;
line-height: 1;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-muted, #a0a0a0);
cursor: pointer;
}
.btn-remove:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.95rem;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: var(--card-bg, #1e1e1e);
color: var(--text-primary, #f0f0f0);
margin-bottom: 0.5rem;
}
.textarea {
resize: vertical;
min-height: 2.5rem;
}
.answers-section {
margin-top: 0.75rem;
}
.answers-label {
font-size: 0.85rem;
color: var(--text-muted, #a0a0a0);
display: block;
margin-bottom: 0.5rem;
}
.answer-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.correct-check {
flex-shrink: 0;
}
.answer-input {
flex: 1;
margin-bottom: 0;
}
.btn-add {
padding: 0.35rem 0.75rem;
font-size: 0.85rem;
border: 1px dashed var(--border, #333);
border-radius: 6px;
background: transparent;
color: var(--text-muted, #a0a0a0);
cursor: pointer;
}
.btn-add:hover {
color: var(--text-primary, #f0f0f0);
border-color: var(--border-hover, #444);
}
</style>
+122
View File
@@ -0,0 +1,122 @@
import { supabase } from '../supabase.js';
export async function fetchMyDecks(userId) {
const { data, error } = await supabase
.from('decks')
.select('id, title, description, config, published, created_at, updated_at')
.eq('owner_id', userId)
.order('updated_at', { ascending: false });
if (error) throw error;
const decks = data ?? [];
const questionCounts = await Promise.all(
decks.map(async (d) => {
const { count, error: e } = await supabase
.from('questions')
.select('*', { count: 'exact', head: true })
.eq('deck_id', d.id);
if (e) return 0;
return count ?? 0;
})
);
return decks.map((d, i) => ({ ...d, question_count: questionCounts[i] }));
}
export async function fetchPublishedDecks() {
const { data, error } = await supabase
.from('decks')
.select('id, title, description, config, published, created_at, updated_at')
.eq('published', true)
.order('updated_at', { ascending: false });
if (error) throw error;
const decks = data ?? [];
const questionCounts = await Promise.all(
decks.map(async (d) => {
const { count, error: e } = await supabase
.from('questions')
.select('*', { count: 'exact', head: true })
.eq('deck_id', d.id);
if (e) return 0;
return count ?? 0;
})
);
return decks.map((d, i) => ({ ...d, question_count: questionCounts[i] }));
}
export async function fetchDeckWithQuestions(deckId) {
const { data: deck, error: deckError } = await supabase
.from('decks')
.select('*')
.eq('id', deckId)
.single();
if (deckError || !deck) throw deckError || new Error('Deck not found');
const { data: questions, error: qError } = await supabase
.from('questions')
.select('*')
.eq('deck_id', deckId)
.order('sort_order', { ascending: true });
if (qError) throw qError;
return { ...deck, questions: questions ?? [] };
}
export async function createDeck(ownerId, { title, description, config, questions }) {
const { data: deck, error: deckError } = await supabase
.from('decks')
.insert({
owner_id: ownerId,
title: title.trim(),
description: (description ?? '').trim(),
config: config ?? {},
})
.select('id')
.single();
if (deckError || !deck) throw deckError || new Error('Failed to create deck');
if (questions && questions.length > 0) {
const rows = questions.map((q, i) => ({
deck_id: deck.id,
sort_order: i,
prompt: (q.prompt ?? '').trim(),
explanation: (q.explanation ?? '').trim() || null,
answers: Array.isArray(q.answers) ? q.answers : [],
correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [],
}));
const { error: qError } = await supabase.from('questions').insert(rows);
if (qError) throw qError;
}
return deck.id;
}
export async function updateDeck(deckId, { title, description, config, questions }) {
const { error: deckError } = await supabase
.from('decks')
.update({
title: title.trim(),
description: (description ?? '').trim(),
config: config ?? {},
})
.eq('id', deckId);
if (deckError) throw deckError;
const { error: delError } = await supabase.from('questions').delete().eq('deck_id', deckId);
if (delError) throw delError;
if (questions && questions.length > 0) {
const rows = questions.map((q, i) => ({
deck_id: deckId,
sort_order: i,
prompt: (q.prompt ?? '').trim(),
explanation: (q.explanation ?? '').trim() || null,
answers: Array.isArray(q.answers) ? q.answers : [],
correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [],
}));
const { error: qError } = await supabase.from('questions').insert(rows);
if (qError) throw qError;
}
}
export async function togglePublished(deckId, published) {
const { error } = await supabase.from('decks').update({ published }).eq('id', deckId);
if (error) throw error;
}
export async function deleteDeck(deckId) {
const { error } = await supabase.from('decks').delete().eq('id', deckId);
if (error) throw error;
}
-50
View File
@@ -1,50 +0,0 @@
export const cards = [
{
id: 1,
title: 'Alpha',
description: 'First card with a short description for the grid.',
imageUrl: 'https://picsum.photos/seed/alpha/400/240',
},
{
id: 2,
title: 'Beta',
description: 'Second card. Another brief description here.',
imageUrl: 'https://picsum.photos/seed/beta/400/240',
},
{
id: 3,
title: 'Gamma',
description: 'Third card. Keep descriptions short and clear.',
imageUrl: 'https://picsum.photos/seed/gamma/400/240',
},
{
id: 4,
title: 'Delta',
description: 'Fourth card in the responsive grid layout.',
imageUrl: 'https://picsum.photos/seed/delta/400/240',
},
{
id: 5,
title: 'Epsilon',
description: 'Fifth card. Placeholder image from Picsum.',
imageUrl: 'https://picsum.photos/seed/epsilon/400/240',
},
{
id: 6,
title: 'Zeta',
description: 'Sixth card with a subtle shadow and dark theme.',
imageUrl: 'https://picsum.photos/seed/zeta/400/240',
},
{
id: 7,
title: 'Eta',
description: 'Seventh card. Full-width grid, 4+ columns on desktop.',
imageUrl: 'https://picsum.photos/seed/eta/400/240',
},
{
id: 8,
title: 'Theta',
description: 'Eighth card. Click Use to log the card name.',
imageUrl: 'https://picsum.photos/seed/theta/400/240',
},
];
+12
View File
@@ -0,0 +1,12 @@
// Defaults matching Decky practice_engine DeckConfig
export const DEFAULT_DECK_CONFIG = {
requiredConsecutiveCorrect: 3,
defaultAttemptSize: 10,
priorityIncreaseOnIncorrect: 5,
priorityDecreaseOnCorrect: 2,
immediateFeedbackEnabled: true,
includeKnownInAttempts: false,
shuffleAnswerOrder: true,
excludeFlaggedQuestions: false,
timeLimitSeconds: null,
};
+7 -2
View File
@@ -26,8 +26,13 @@ function createAuthStore() {
} catch (e) {
set({ user: null, loading: false, error: e?.message ?? 'Auth error' });
}
supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
supabase.auth.onAuthStateChange(async (_event, session) => {
if (session != null) {
setSession(session);
return;
}
const { data: { session: current } } = await supabase.auth.getSession();
setSession(current);
});
},
login: async (email, password) => {
+3 -1
View File
@@ -3,4 +3,6 @@ import { createClient } from '@supabase/supabase-js';
const url = import.meta.env.VITE_SUPABASE_URL;
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(url ?? '', anonKey ?? '');
export const supabase = createClient(url ?? '', anonKey ?? '', {
db: { schema: 'omotomo' },
});