create deck with json
This commit is contained in:
+29
-1
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user