{#if $auth.loading}
…
@@ -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 {
diff --git a/src/lib/QuestionEditor.svelte b/src/lib/QuestionEditor.svelte
new file mode 100644
index 0000000..ec68ccd
--- /dev/null
+++ b/src/lib/QuestionEditor.svelte
@@ -0,0 +1,180 @@
+
+
+
+
+ Question {index + 1}
+
+
+
+
+
+
+
+
diff --git a/src/lib/api/decks.js b/src/lib/api/decks.js
new file mode 100644
index 0000000..b7564ec
--- /dev/null
+++ b/src/lib/api/decks.js
@@ -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;
+}
diff --git a/src/lib/cards.js b/src/lib/cards.js
deleted file mode 100644
index 24cc1de..0000000
--- a/src/lib/cards.js
+++ /dev/null
@@ -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',
- },
-];
diff --git a/src/lib/deckConfig.js b/src/lib/deckConfig.js
new file mode 100644
index 0000000..6ea0544
--- /dev/null
+++ b/src/lib/deckConfig.js
@@ -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,
+};
diff --git a/src/lib/stores/auth.js b/src/lib/stores/auth.js
index 00d5a86..0560cc4 100644
--- a/src/lib/stores/auth.js
+++ b/src/lib/stores/auth.js
@@ -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) => {
diff --git a/src/lib/supabase.js b/src/lib/supabase.js
index 9341261..0cac3eb 100644
--- a/src/lib/supabase.js
+++ b/src/lib/supabase.js
@@ -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' },
+});
diff --git a/src/routes/Community.svelte b/src/routes/Community.svelte
new file mode 100644
index 0000000..fc4c13e
--- /dev/null
+++ b/src/routes/Community.svelte
@@ -0,0 +1,135 @@
+
+
+
+
+
+ {#if loading}
+
Loading…
+ {:else if error}
+
{error}
+ {:else if decks.length === 0}
+
No published decks yet. Create and publish a deck from My decks to see it here.
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/routes/CreateDeck.svelte b/src/routes/CreateDeck.svelte
new file mode 100644
index 0000000..980d369
--- /dev/null
+++ b/src/routes/CreateDeck.svelte
@@ -0,0 +1,337 @@
+
+
+
+
+
+ {#if view === 'loading'}
+
Loading…
+ {:else if view === 'signin'}
+
Sign in to create a deck.
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/routes/EditDeck.svelte b/src/routes/EditDeck.svelte
new file mode 100644
index 0000000..da11549
--- /dev/null
+++ b/src/routes/EditDeck.svelte
@@ -0,0 +1,341 @@
+
+
+
+
+
+ {#if $auth.loading}
+
Loading…
+ {:else if !userId}
+
Sign in to edit decks.
+ {:else if loading}
+
Loading…
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/routes/MyDecks.svelte b/src/routes/MyDecks.svelte
new file mode 100644
index 0000000..b3dd826
--- /dev/null
+++ b/src/routes/MyDecks.svelte
@@ -0,0 +1,211 @@
+
+
+
+
+
+ {#if $auth.loading}
+
Loading…
+ {:else if !userId}
+
Sign in to create and manage your decks.
+ {:else if loading}
+
Loading…
+ {:else if error}
+
{error}
+ {:else if decks.length === 0}
+
No decks yet. Create your first deck to get started.
+ {:else}
+
+ {/if}
+
+
+
diff --git a/supabase/migrations/20250213000000_create_decks_and_questions.sql b/supabase/migrations/20250213000000_create_decks_and_questions.sql
new file mode 100644
index 0000000..9bf9d12
--- /dev/null
+++ b/supabase/migrations/20250213000000_create_decks_and_questions.sql
@@ -0,0 +1,117 @@
+-- Decks table (metadata + config for Decky quiz/flashcard app)
+CREATE TABLE IF NOT EXISTS public.decks (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ title text NOT NULL,
+ description text NOT NULL DEFAULT '',
+ config jsonb NOT NULL DEFAULT '{
+ "requiredConsecutiveCorrect": 3,
+ "defaultAttemptSize": 10,
+ "priorityIncreaseOnIncorrect": 5,
+ "priorityDecreaseOnCorrect": 2,
+ "immediateFeedbackEnabled": true,
+ "includeKnownInAttempts": false,
+ "shuffleAnswerOrder": true,
+ "excludeFlaggedQuestions": false,
+ "timeLimitSeconds": null
+ }'::jsonb,
+ published boolean NOT NULL DEFAULT false,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- Questions table (deck content)
+CREATE TABLE IF NOT EXISTS public.questions (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ deck_id uuid NOT NULL REFERENCES public.decks(id) ON DELETE CASCADE,
+ sort_order int NOT NULL DEFAULT 0,
+ prompt text NOT NULL,
+ explanation text,
+ answers jsonb NOT NULL DEFAULT '[]'::jsonb,
+ correct_answer_indices jsonb NOT NULL DEFAULT '[]'::jsonb
+);
+
+CREATE INDEX IF NOT EXISTS idx_questions_deck_id ON public.questions(deck_id);
+CREATE INDEX IF NOT EXISTS idx_decks_owner_id ON public.decks(owner_id);
+CREATE INDEX IF NOT EXISTS idx_decks_published ON public.decks(published) WHERE published = true;
+
+-- updated_at trigger for decks
+CREATE OR REPLACE FUNCTION public.set_decks_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS decks_updated_at ON public.decks;
+CREATE TRIGGER decks_updated_at
+ BEFORE UPDATE ON public.decks
+ FOR EACH ROW
+ EXECUTE PROCEDURE public.set_decks_updated_at();
+
+-- RLS
+ALTER TABLE public.decks ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.questions ENABLE ROW LEVEL SECURITY;
+
+-- decks: SELECT if owner or published; mutate only if owner
+CREATE POLICY decks_select ON public.decks
+ FOR SELECT
+ USING (owner_id = auth.uid() OR published = true);
+
+CREATE POLICY decks_insert ON public.decks
+ FOR INSERT
+ WITH CHECK (owner_id = auth.uid());
+
+CREATE POLICY decks_update ON public.decks
+ FOR UPDATE
+ USING (owner_id = auth.uid())
+ WITH CHECK (owner_id = auth.uid());
+
+CREATE POLICY decks_delete ON public.decks
+ FOR DELETE
+ USING (owner_id = auth.uid());
+
+-- questions: SELECT if deck is visible; mutate only if deck is owned
+CREATE POLICY questions_select ON public.questions
+ FOR SELECT
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.decks d
+ WHERE d.id = questions.deck_id
+ AND (d.owner_id = auth.uid() OR d.published = true)
+ )
+ );
+
+CREATE POLICY questions_insert ON public.questions
+ FOR INSERT
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.decks d
+ WHERE d.id = questions.deck_id AND d.owner_id = auth.uid()
+ )
+ );
+
+CREATE POLICY questions_update ON public.questions
+ FOR UPDATE
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.decks d
+ WHERE d.id = questions.deck_id AND d.owner_id = auth.uid()
+ )
+ )
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.decks d
+ WHERE d.id = questions.deck_id AND d.owner_id = auth.uid()
+ )
+ );
+
+CREATE POLICY questions_delete ON public.questions
+ FOR DELETE
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.decks d
+ WHERE d.id = questions.deck_id AND d.owner_id = auth.uid()
+ )
+ );