Deck version update

master
gitea 2 weeks ago
parent 32c786fa24
commit 6009ce5ec2

@ -39,10 +39,18 @@
aria-modal="true" aria-modal="true"
aria-labelledby="confirm-modal-title" aria-labelledby="confirm-modal-title"
aria-describedby="confirm-modal-desc" aria-describedby="confirm-modal-desc"
tabindex="-1"
onclick={handleBackdropClick} onclick={handleBackdropClick}
onkeydown={handleKeydown} onkeydown={handleKeydown}
> >
<div class="modal" onclick={(e) => e.stopPropagation()}> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="modal"
role="document"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={handleKeydown}
>
<h2 id="confirm-modal-title" class="modal-title">{title}</h2> <h2 id="confirm-modal-title" class="modal-title">{title}</h2>
<p id="confirm-modal-desc" class="modal-message">{message}</p> <p id="confirm-modal-desc" class="modal-message">{message}</p>
<div class="modal-actions"> <div class="modal-actions">

@ -1,9 +1,14 @@
<script> <script>
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { push } from 'svelte-spa-router'; import { push, location } from 'svelte-spa-router';
import { auth } from './stores/auth.js'; import { auth } from './stores/auth.js';
import { navContext } from './stores/navContext.js';
import { getProfile } from './api/profile.js'; import { getProfile } from './api/profile.js';
$: loc = $location || '';
$: myDecksActive = loc === '/' || loc === '/decks/new' || /^\/decks\/[^/]+\/edit$/.test(loc) || $navContext === 'my-decks';
$: communityActive = loc === '/community' || loc.startsWith('/community/') || $navContext === 'community';
let showPopup = false; let showPopup = false;
let mode = 'login'; // 'login' | 'register' let mode = 'login'; // 'login' | 'register'
let email = ''; let email = '';
@ -90,6 +95,19 @@
userMenuOpen = false; userMenuOpen = false;
} }
/** Load profile (including avatar) when user is logged in, so avatar shows after refresh */
$: if (!$auth.loading && $auth.user?.id && !profile && !profileLoading) {
profileLoading = true;
getProfile($auth.user.id)
.then((p) => {
profile = p;
profileLoading = false;
})
.catch(() => {
profileLoading = false;
});
}
function onProfileUpdated(e) { function onProfileUpdated(e) {
const p = e?.detail; const p = e?.detail;
if (p && p.id && $auth.user?.id && p.id === $auth.user.id) { if (p && p.id && $auth.user?.id && p.id === $auth.user.id) {
@ -115,9 +133,9 @@
<span class="app-name">Omotomo</span> <span class="app-name">Omotomo</span>
</a> </a>
{#if $auth.user} {#if $auth.user}
<a href="/" class="nav-link" onclick={(e) => { e.preventDefault(); push('/'); }}>My decks</a> <a href="/" class="nav-link" class:active={myDecksActive} onclick={(e) => { e.preventDefault(); push('/'); }}>My decks</a>
{/if} {/if}
<a href="/community" class="nav-link" onclick={(e) => { e.preventDefault(); push('/community'); }}>Community</a> <a href="/community" class="nav-link" class:active={communityActive} onclick={(e) => { e.preventDefault(); push('/community'); }}>Community</a>
</div> </div>
<div class="nav-actions"> <div class="nav-actions">
{#if $auth.user} {#if $auth.user}
@ -309,6 +327,11 @@
color: var(--text-primary, #f0f0f0); color: var(--text-primary, #f0f0f0);
} }
.nav-link.active {
color: var(--text-primary, #f0f0f0);
font-weight: 500;
}
.nav-actions { .nav-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@ -450,10 +473,6 @@
border-color: #2563eb; border-color: #2563eb;
} }
.btn-logout {
background: transparent;
}
.btn-block { .btn-block {
width: 100%; width: 100%;
} }

@ -32,8 +32,24 @@
</script> </script>
{#if deck} {#if deck}
<div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="rating-modal-title" onclick={handleBackdropClick}> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal" onclick={(e) => e.stopPropagation()}> <div
class="modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="rating-modal-title"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && onClose()}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="modal"
role="document"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.key === 'Escape' && onClose()}
>
<h2 id="rating-modal-title" class="modal-title">Rate: {deck.title}</h2> <h2 id="rating-modal-title" class="modal-title">Rate: {deck.title}</h2>
<div class="stars-row"> <div class="stars-row">
{#each [1, 2, 3, 4, 5] as n} {#each [1, 2, 3, 4, 5] as n}
@ -54,7 +70,7 @@
placeholder="Add a comment…" placeholder="Add a comment…"
bind:value={comment} bind:value={comment}
rows="3" rows="3"
/> ></textarea>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-ghost" onclick={onClose}>Cancel</button> <button type="button" class="btn btn-ghost" onclick={onClose}>Cancel</button>
<button type="button" class="btn btn-primary" onclick={handleSubmit} disabled={selectedStars < 1}> <button type="button" class="btn btn-primary" onclick={handleSubmit} disabled={selectedStars < 1}>

@ -3,7 +3,7 @@ import { supabase } from '../supabase.js';
export async function fetchMyDecks(userId) { export async function fetchMyDecks(userId) {
const { data, error } = await supabase const { data, error } = await supabase
.from('decks') .from('decks')
.select('id, title, description, config, published, created_at, updated_at, copied_from_deck_id') .select('id, title, description, config, published, created_at, updated_at, copied_from_deck_id, version, copied_from_version')
.eq('owner_id', userId) .eq('owner_id', userId)
.order('updated_at', { ascending: false }); .order('updated_at', { ascending: false });
if (error) throw error; if (error) throw error;
@ -29,7 +29,7 @@ export async function fetchMyDecks(userId) {
? supabase.from('deck_ratings').select('deck_id, rating').in('deck_id', ratingDeckIds) ? supabase.from('deck_ratings').select('deck_id, rating').in('deck_id', ratingDeckIds)
: Promise.resolve({ data: [] }), : Promise.resolve({ data: [] }),
sourceDeckIds.length > 0 sourceDeckIds.length > 0
? supabase.from('decks').select('id, title').in('id', sourceDeckIds) ? supabase.from('decks').select('id, title, version, updated_at').in('id', sourceDeckIds)
: Promise.resolve({ data: [] }), : Promise.resolve({ data: [] }),
]); ]);
@ -45,13 +45,23 @@ export async function fetchMyDecks(userId) {
return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length }; return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length };
}; };
const sourceTitleById = new Map((sourceTitlesRows.data ?? []).map((d) => [d.id, d.title])); const sourceById = new Map((sourceTitlesRows.data ?? []).map((d) => [d.id, d]));
return decks.map((d, i) => { return decks.map((d, i) => {
const showRatingDeckId = d.copied_from_deck_id || d.id; const showRatingDeckId = d.copied_from_deck_id || d.id;
const rating = getRating(showRatingDeckId); const rating = getRating(showRatingDeckId);
const canRate = !!d.copied_from_deck_id; const canRate = !!d.copied_from_deck_id;
const source_deck_title = d.copied_from_deck_id ? sourceTitleById.get(d.copied_from_deck_id) ?? 'Deck' : null; const sourceDeck = d.copied_from_deck_id ? sourceById.get(d.copied_from_deck_id) : null;
const source_deck_title = sourceDeck?.title ?? 'Deck';
const source_version = sourceDeck?.version ?? 1;
const my_version = d.copied_from_version ?? 0;
const versionOutdated = source_version > my_version;
const sourceNewerByTime =
sourceDeck?.updated_at &&
d.updated_at &&
new Date(sourceDeck.updated_at).getTime() > new Date(d.updated_at).getTime();
const needs_update =
!!d.copied_from_deck_id && (versionOutdated || sourceNewerByTime);
return { return {
...d, ...d,
question_count: questionCounts[i], question_count: questionCounts[i],
@ -59,6 +69,8 @@ export async function fetchMyDecks(userId) {
can_rate: canRate, can_rate: canRate,
rateable_deck_id: d.copied_from_deck_id || null, rateable_deck_id: d.copied_from_deck_id || null,
source_deck_title, source_deck_title,
source_version,
needs_update,
}; };
}); });
} }
@ -269,10 +281,11 @@ export async function copyDeckToUser(deckId, userId) {
config: source.config ?? {}, config: source.config ?? {},
questions, questions,
copiedFromDeckId: deckId, copiedFromDeckId: deckId,
copiedFromVersion: source.version ?? 1,
}); });
} }
export async function createDeck(ownerId, { title, description, config, questions, copiedFromDeckId }) { export async function createDeck(ownerId, { title, description, config, questions, copiedFromDeckId, copiedFromVersion }) {
const row = { const row = {
owner_id: ownerId, owner_id: ownerId,
title: title.trim(), title: title.trim(),
@ -280,6 +293,7 @@ export async function createDeck(ownerId, { title, description, config, question
config: config ?? {}, config: config ?? {},
}; };
if (copiedFromDeckId != null) row.copied_from_deck_id = copiedFromDeckId; if (copiedFromDeckId != null) row.copied_from_deck_id = copiedFromDeckId;
if (copiedFromVersion != null) row.copied_from_version = copiedFromVersion;
const { data: deck, error: deckError } = await supabase const { data: deck, error: deckError } = await supabase
.from('decks') .from('decks')
.insert(row) .insert(row)
@ -302,13 +316,23 @@ export async function createDeck(ownerId, { title, description, config, question
} }
export async function updateDeck(deckId, { title, description, config, questions }) { export async function updateDeck(deckId, { title, description, config, questions }) {
const { data: current, error: fetchErr } = await supabase
.from('decks')
.select('version, published')
.eq('id', deckId)
.single();
if (fetchErr) throw fetchErr;
const bumpVersion = current?.published === true;
const nextVersion = bumpVersion ? (current?.version ?? 1) + 1 : (current?.version ?? 1);
const updatePayload = {
title: title.trim(),
description: (description ?? '').trim(),
config: config ?? {},
version: nextVersion,
};
const { error: deckError } = await supabase const { error: deckError } = await supabase
.from('decks') .from('decks')
.update({ .update(updatePayload)
title: title.trim(),
description: (description ?? '').trim(),
config: config ?? {},
})
.eq('id', deckId); .eq('id', deckId);
if (deckError) throw deckError; if (deckError) throw deckError;
const { error: delError } = await supabase.from('questions').delete().eq('deck_id', deckId); const { error: delError } = await supabase.from('questions').delete().eq('deck_id', deckId);
@ -337,6 +361,94 @@ export async function deleteDeck(deckId) {
if (error) throw error; if (error) throw error;
} }
/**
* Get source deck and copy deck with questions for the "Update from community" preview.
* Copy must be owned by userId and have copied_from_deck_id.
* @returns {{ source: object, copy: object, changes: string[] }}
*/
export async function getSourceUpdatePreview(copyDeckId, userId) {
const { data: copy, error: copyErr } = await supabase
.from('decks')
.select('*')
.eq('id', copyDeckId)
.eq('owner_id', userId)
.single();
if (copyErr || !copy?.copied_from_deck_id) throw new Error('Deck not found or not a copy');
const sourceId = copy.copied_from_deck_id;
const [source, copyQuestionsRows] = await Promise.all([
fetchDeckWithQuestions(sourceId),
supabase.from('questions').select('*').eq('deck_id', copyDeckId).order('sort_order', { ascending: true }),
]);
if (!source.published) throw new Error('Source deck is no longer available');
const copyQuestions = copyQuestionsRows.data ?? [];
const copyWithQuestions = { ...copy, questions: copyQuestions };
const changes = [];
if ((source.title ?? '').trim() !== (copy.title ?? '').trim()) {
changes.push(`Title: "${(copy.title ?? '').trim()}" → "${(source.title ?? '').trim()}"`);
}
if ((source.description ?? '').trim() !== (copy.description ?? '').trim()) {
changes.push('Description updated');
}
const srcLen = (source.questions ?? []).length;
const copyLen = copyQuestions.length;
if (srcLen !== copyLen) {
changes.push(`Questions: ${copyLen}${srcLen}`);
} else {
const anyDifferent = (source.questions ?? []).some((sq, i) => {
const cq = copyQuestions[i];
if (!cq) return true;
return (sq.prompt ?? '') !== (cq.prompt ?? '') || (sq.explanation ?? '') !== (cq.explanation ?? '');
});
if (anyDifferent) changes.push('Some question content updated');
}
if (changes.length === 0) changes.push('Content is in sync (version metadata will update)');
return { source, copy: copyWithQuestions, changes };
}
/**
* Update a community copy to match the current source deck (title, description, config, questions, copied_from_version).
*/
export async function applySourceUpdate(copyDeckId, userId) {
const { data: copy, error: copyErr } = await supabase
.from('decks')
.select('id, copied_from_deck_id')
.eq('id', copyDeckId)
.eq('owner_id', userId)
.single();
if (copyErr || !copy?.copied_from_deck_id) throw new Error('Deck not found or not a copy');
const source = await fetchDeckWithQuestions(copy.copied_from_deck_id);
if (!source.published) throw new Error('Source deck is no longer available');
const { error: deckError } = await supabase
.from('decks')
.update({
title: source.title ?? '',
description: source.description ?? '',
config: source.config ?? {},
copied_from_version: source.version ?? 1,
})
.eq('id', copyDeckId);
if (deckError) throw deckError;
const { error: delError } = await supabase.from('questions').delete().eq('deck_id', copyDeckId);
if (delError) throw delError;
const questions = source.questions ?? [];
if (questions.length > 0) {
const rows = questions.map((q, i) => ({
deck_id: copyDeckId,
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;
}
}
/** True if the user already has this deck (owns it or has added a copy). */ /** True if the user already has this deck (owns it or has added a copy). */
export async function userHasDeck(deckId, userId) { export async function userHasDeck(deckId, userId) {
if (!userId) return false; if (!userId) return false;

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
/** 'my-decks' | 'community' | null - used by Navbar to highlight the correct menu item. */
export const navContext = writable(null);

@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { push } from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
import { auth } from '../lib/stores/auth.js'; import { auth } from '../lib/stores/auth.js';
import { navContext } from '../lib/stores/navContext.js';
import { fetchPublishedDecks, copyDeckToUser, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js'; import { fetchPublishedDecks, copyDeckToUser, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js';
import RatingModal from '../lib/RatingModal.svelte'; import RatingModal from '../lib/RatingModal.svelte';
@ -59,7 +60,10 @@
return list; return list;
})(); })();
onMount(load); onMount(() => {
navContext.set('community');
load();
});
function formatRating(n) { function formatRating(n) {
return Number(n).toFixed(1); return Number(n).toFixed(1);
@ -182,6 +186,7 @@
<ul class="deck-grid"> <ul class="deck-grid">
{#each filteredDecks as deck (deck.id)} {#each filteredDecks as deck (deck.id)}
<li class="deck-card"> <li class="deck-card">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="deck-card-inner" role="button" tabindex="0" onclick={() => goPreview(deck.id)} onkeydown={(e) => e.key === 'Enter' && goPreview(deck.id)}> <div class="deck-card-inner" role="button" tabindex="0" onclick={() => goPreview(deck.id)} onkeydown={(e) => e.key === 'Enter' && goPreview(deck.id)}>
<h3 class="deck-title">{deck.title}</h3> <h3 class="deck-title">{deck.title}</h3>
{#if deck.description} {#if deck.description}
@ -193,21 +198,29 @@
<div class="deck-creator"> <div class="deck-creator">
By <a href="/community/user/{deck.owner_id}" class="creator-link" onclick={(e) => { e.stopPropagation(); goUser(deck.owner_id, e); }}>{deck.owner_display_name ?? deck.owner_email ?? 'User'}</a> By <a href="/community/user/{deck.owner_id}" class="creator-link" onclick={(e) => { e.stopPropagation(); goUser(deck.owner_id, e); }}>{deck.owner_display_name ?? deck.owner_email ?? 'User'}</a>
</div> </div>
<div {#if userId && deck.owner_id !== userId}
class="deck-rating" <button
class:clickable={userId && deck.owner_id !== userId} type="button"
aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars" class="deck-rating clickable"
onclick={userId && deck.owner_id !== userId ? (e) => openRatingModal(deck, e) : undefined} aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars"
role={userId && deck.owner_id !== userId ? 'button' : undefined} onclick={(e) => openRatingModal(deck, e)}
tabindex={userId && deck.owner_id !== userId ? 0 : undefined} onkeydown={(e) => e.key === 'Enter' && openRatingModal(deck, e)}
onkeydown={userId && deck.owner_id !== userId ? (e) => e.key === 'Enter' && openRatingModal(deck, e) : undefined} >
> <span class="stars">{['★', '★', '★', '★', '★'].map((_, i) => (i < Math.round(deck.average_rating ?? 0) ? '★' : '☆')).join('')}</span>
<span class="stars">{['★', '★', '★', '★', '★'].map((_, i) => (i < Math.round(deck.average_rating ?? 0) ? '★' : '☆')).join('')}</span> <span class="rating-text">{formatRating(deck.average_rating ?? 0)}</span>
<span class="rating-text">{formatRating(deck.average_rating ?? 0)}</span> {#if (deck.rating_count ?? 0) > 0}
{#if (deck.rating_count ?? 0) > 0} <span class="rating-count">({deck.rating_count})</span>
<span class="rating-count">({deck.rating_count})</span> {/if}
{/if} </button>
</div> {:else}
<div class="deck-rating" aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars">
<span class="stars">{['★', '★', '★', '★', '★'].map((_, i) => (i < Math.round(deck.average_rating ?? 0) ? '★' : '☆')).join('')}</span>
<span class="rating-text">{formatRating(deck.average_rating ?? 0)}</span>
{#if (deck.rating_count ?? 0) > 0}
<span class="rating-count">({deck.rating_count})</span>
{/if}
</div>
{/if}
<div class="deck-card-actions"> <div class="deck-card-actions">
{#if !deck.user_has_this} {#if !deck.user_has_this}
<button type="button" class="btn btn-small btn-primary" onclick={(e) => { e.stopPropagation(); handleUse(deck.id); }} disabled={addingDeckId === deck.id}> <button type="button" class="btn btn-small btn-primary" onclick={(e) => { e.stopPropagation(); handleUse(deck.id); }} disabled={addingDeckId === deck.id}>
@ -428,6 +441,15 @@
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted, #a0a0a0); color: var(--text-muted, #a0a0a0);
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
border: none;
background: transparent;
padding: 0;
font: inherit;
text-align: left;
}
button.deck-rating {
cursor: pointer;
} }
.deck-rating.clickable { .deck-rating.clickable {

@ -1,9 +1,13 @@
<script> <script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
import { auth } from '../lib/stores/auth.js'; import { auth } from '../lib/stores/auth.js';
import { navContext } from '../lib/stores/navContext.js';
import { fetchPublishedDecksByOwner, copyDeckToUser, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js'; import { fetchPublishedDecksByOwner, copyDeckToUser, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js';
import RatingModal from '../lib/RatingModal.svelte'; import RatingModal from '../lib/RatingModal.svelte';
onMount(() => navContext.set('community'));
export let params = {}; export let params = {};
let decks = []; let decks = [];
@ -123,6 +127,7 @@
<ul class="deck-grid"> <ul class="deck-grid">
{#each decks as deck (deck.id)} {#each decks as deck (deck.id)}
<li class="deck-card"> <li class="deck-card">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="deck-card-inner" role="button" tabindex="0" onclick={() => goPreview(deck.id)} onkeydown={(e) => e.key === 'Enter' && goPreview(deck.id)}> <div class="deck-card-inner" role="button" tabindex="0" onclick={() => goPreview(deck.id)} onkeydown={(e) => e.key === 'Enter' && goPreview(deck.id)}>
<h3 class="deck-title">{deck.title}</h3> <h3 class="deck-title">{deck.title}</h3>
{#if deck.description} {#if deck.description}
@ -131,21 +136,29 @@
<div class="deck-meta"> <div class="deck-meta">
<span class="deck-count">{deck.question_count ?? 0} questions</span> <span class="deck-count">{deck.question_count ?? 0} questions</span>
</div> </div>
<div {#if userId && deck.owner_id !== userId}
class="deck-rating" <button
class:clickable={userId && deck.owner_id !== userId} type="button"
aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars" class="deck-rating clickable"
onclick={userId && deck.owner_id !== userId ? (e) => openRatingModal(deck, e) : undefined} aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars"
role={userId && deck.owner_id !== userId ? 'button' : undefined} onclick={(e) => openRatingModal(deck, e)}
tabindex={userId && deck.owner_id !== userId ? 0 : undefined} onkeydown={(e) => e.key === 'Enter' && openRatingModal(deck, e)}
onkeydown={userId && deck.owner_id !== userId ? (e) => e.key === 'Enter' && openRatingModal(deck, e) : undefined} >
> <span class="stars">{['★', '★', '★', '★', '★'].map((_, i) => (i < Math.round(deck.average_rating ?? 0) ? '★' : '☆')).join('')}</span>
<span class="stars">{['★', '★', '★', '★', '★'].map((_, i) => (i < Math.round(deck.average_rating ?? 0) ? '★' : '☆')).join('')}</span> <span class="rating-text">{formatRating(deck.average_rating ?? 0)}</span>
<span class="rating-text">{formatRating(deck.average_rating ?? 0)}</span> {#if (deck.rating_count ?? 0) > 0}
{#if (deck.rating_count ?? 0) > 0} <span class="rating-count">({deck.rating_count})</span>
<span class="rating-count">({deck.rating_count})</span> {/if}
{/if} </button>
</div> {:else}
<div class="deck-rating" aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars">
<span class="stars">{['★', '★', '★', '★', '★'].map((_, i) => (i < Math.round(deck.average_rating ?? 0) ? '★' : '☆')).join('')}</span>
<span class="rating-text">{formatRating(deck.average_rating ?? 0)}</span>
{#if (deck.rating_count ?? 0) > 0}
<span class="rating-count">({deck.rating_count})</span>
{/if}
</div>
{/if}
<div class="deck-card-actions"> <div class="deck-card-actions">
{#if !deck.user_has_this} {#if !deck.user_has_this}
<button type="button" class="btn btn-small btn-primary" onclick={(e) => { e.stopPropagation(); handleUse(deck.id); }} disabled={addingDeckId === deck.id}> <button type="button" class="btn btn-small btn-primary" onclick={(e) => { e.stopPropagation(); handleUse(deck.id); }} disabled={addingDeckId === deck.id}>
@ -291,6 +304,15 @@
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted, #a0a0a0); color: var(--text-muted, #a0a0a0);
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
border: none;
background: transparent;
padding: 0;
font: inherit;
text-align: left;
}
button.deck-rating {
cursor: pointer;
} }
.deck-rating.clickable { .deck-rating.clickable {

@ -1,10 +1,14 @@
<script> <script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
import { auth } from '../lib/stores/auth.js'; import { auth } from '../lib/stores/auth.js';
import { navContext } from '../lib/stores/navContext.js';
import { createDeck } from '../lib/api/decks.js'; import { createDeck } from '../lib/api/decks.js';
import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js'; import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js';
import QuestionEditor from '../lib/QuestionEditor.svelte'; import QuestionEditor from '../lib/QuestionEditor.svelte';
onMount(() => navContext.set('my-decks'));
let title = ''; let title = '';
let description = ''; let description = '';
let config = { ...DEFAULT_DECK_CONFIG }; let config = { ...DEFAULT_DECK_CONFIG };

@ -1,6 +1,7 @@
<script> <script>
import { push } from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
import { auth } from '../lib/stores/auth.js'; import { auth } from '../lib/stores/auth.js';
import { navContext } from '../lib/stores/navContext.js';
import { fetchDeckWithQuestions, copyDeckToUser, userHasDeck } from '../lib/api/decks.js'; import { fetchDeckWithQuestions, copyDeckToUser, userHasDeck } from '../lib/api/decks.js';
export let params = {}; export let params = {};
@ -28,22 +29,43 @@
userAlreadyHasDeck = false; userAlreadyHasDeck = false;
try { try {
deck = await fetchDeckWithQuestions(deckId); deck = await fetchDeckWithQuestions(deckId);
if (!deck.published) { const isMyCopy = !!(userId && deck.owner_id === userId && deck.copied_from_deck_id);
const isOwnDeckLoad = !!(userId && deck.owner_id === userId && !deck.copied_from_deck_id);
const canView = deck.published || isMyCopy || isOwnDeckLoad;
if (!canView) {
error = 'This deck is not available.'; error = 'This deck is not available.';
deck = null; deck = null;
} else if (userId) { } else {
userAlreadyHasDeck = await userHasDeck(deckId, userId); navContext.set(isMyCopy || isOwnDeckLoad ? 'my-decks' : 'community');
if (isMyCopy) {
userAlreadyHasDeck = true;
} else if (userId) {
userAlreadyHasDeck = await userHasDeck(deckId, userId);
}
} }
} catch (e) { } catch (e) {
error = e?.message ?? 'Failed to load deck'; error = e?.message ?? 'Failed to load deck';
deck = null; deck = null;
navContext.set(null);
} finally { } finally {
loading = false; loading = false;
} }
} }
function goCommunity() { $: isMyCopy = !!(deck && userId && deck.owner_id === userId && deck.copied_from_deck_id);
push('/community'); $: isOwnDeck = !!(deck && userId && deck.owner_id === userId && !deck.copied_from_deck_id);
/** Only show "In My decks" when viewing from Community and user already has this deck */
$: showInMyDecksLabel = !!(deck && !isMyCopy && !isOwnDeck && userAlreadyHasDeck);
function goBack() {
if (loading) {
if (typeof window !== 'undefined' && window.history.length > 1) window.history.back();
else push('/community');
} else if (isMyCopy || isOwnDeck) {
push('/');
} else {
push('/community');
}
} }
async function addToMyDecks() { async function addToMyDecks() {
@ -65,39 +87,63 @@
} }
function goReviews() { function goReviews() {
if (!deckId) return; if (!deck) return;
push(`/decks/${deckId}/reviews`); const reviewsDeckId = isMyCopy ? deck.copied_from_deck_id : deck.id;
if (reviewsDeckId) push(`/decks/${reviewsDeckId}/reviews`);
}
function goEdit() {
if (deck?.id) push(`/decks/${deck.id}/edit`);
} }
</script> </script>
<header class="page-header page-header-fixed"> <header class="page-header page-header-fixed">
<div class="page-header-inner"> <div class="page-header-inner">
<div class="page-header-actions"> <div class="header-top-row">
<button type="button" class="btn btn-back" onclick={goCommunity}> Community</button> <h1 class="page-header-title">Preview</h1>
{#if deck && deck.published && !userAlreadyHasDeck} <div class="page-header-actions">
<button <button type="button" class="btn btn-back" onclick={goBack}>
type="button" {#if loading}
class="btn btn-add" ← Back
onclick={addToMyDecks} {:else if isMyCopy || isOwnDeck}
disabled={adding} ← My decks
title="Add to my decks"
>
{#if adding}
Adding…
{:else} {:else}
Add to my decks ← Community
{/if} {/if}
</button> </button>
{:else if deck && deck.published && userAlreadyHasDeck} {#if loading}
<span class="already-have">In My decks</span> <span class="header-spinner" aria-hidden="true"></span>
{/if} {:else if deck && (deck.published || isMyCopy || isOwnDeck)}
{#if deck && deck.published} {#if isOwnDeck}
<button type="button" class="btn btn-reviews" onclick={goReviews} title="View reviews"> <button type="button" class="btn btn-edit" onclick={goEdit} title="Edit deck">
Reviews Edit
</button> </button>
{/if} {/if}
{#if !userAlreadyHasDeck}
<button
type="button"
class="btn btn-add"
onclick={addToMyDecks}
disabled={adding}
title="Add to my decks"
>
{#if adding}
Adding…
{:else}
Add to my decks
{/if}
</button>
{/if}
<button type="button" class="btn btn-reviews" onclick={goReviews} title="View reviews">
Reviews
</button>
{/if}
</div>
</div> </div>
{#if deck && deck.published && !userAlreadyHasDeck && addError} {#if showInMyDecksLabel}
<p class="header-in-my-decks">In My decks</p>
{/if}
{#if deck && (deck.published || isMyCopy || isOwnDeck) && !userAlreadyHasDeck && addError}
<p class="page-header-error">{addError}</p> <p class="page-header-error">{addError}</p>
{/if} {/if}
</div> </div>
@ -105,7 +151,10 @@
<div class="page page-with-fixed-header"> <div class="page page-with-fixed-header">
{#if loading} {#if loading}
<p class="muted">Loading…</p> <div class="loading-state" aria-busy="true" aria-live="polite">
<span class="loading-spinner" aria-hidden="true"></span>
<p class="loading-text">Loading deck…</p>
</div>
{:else if error} {:else if error}
<p class="error">{error}</p> <p class="error">{error}</p>
{:else if deck} {:else if deck}
@ -165,15 +214,55 @@
max-width: 700px; max-width: 700px;
margin: 0 auto; margin: 0 auto;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.header-top-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.page-header-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.header-in-my-decks {
margin: 0;
font-size: 0.9rem;
color: #22c55e;
font-style: italic;
} }
.page-header-actions { .page-header-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 0.75rem; gap: 0.75rem;
} }
.header-spinner {
display: inline-block;
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--border, #333);
border-top-color: var(--accent, #3b82f6);
border-radius: 50%;
animation: header-spin 0.7s linear infinite;
}
@keyframes header-spin {
to { transform: rotate(360deg); }
}
.btn-back { .btn-back {
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
font-size: 0.9rem; font-size: 0.9rem;
@ -225,18 +314,29 @@
border-color: var(--border-hover, #444); border-color: var(--border-hover, #444);
} }
.btn-edit {
padding: 0.4rem 1rem;
font-size: 0.9rem;
font-weight: 500;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: transparent;
color: var(--text-muted, #a0a0a0);
cursor: pointer;
}
.btn-edit:hover {
color: var(--text-primary, #f0f0f0);
border-color: var(--border-hover, #444);
}
.page-header-error { .page-header-error {
width: 100%;
margin: 0.5rem 0 0 0; margin: 0.5rem 0 0 0;
font-size: 0.85rem; font-size: 0.85rem;
color: #ef4444; color: #ef4444;
} }
.already-have {
font-size: 0.9rem;
color: #22c55e;
font-style: italic;
}
.deck-title { .deck-title {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
font-size: 1.5rem; font-size: 1.5rem;
@ -303,16 +403,38 @@
font-style: italic; font-style: italic;
} }
.muted, .loading-state {
.error { display: flex;
margin: 0; flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
min-height: 12rem;
padding: 2rem;
} }
.error { .loading-spinner {
color: #ef4444; display: block;
width: 2.5rem;
height: 2.5rem;
border: 3px solid var(--border, #333);
border-top-color: var(--accent, #3b82f6);
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
}
@keyframes loading-spin {
to { transform: rotate(360deg); }
} }
.muted { .loading-text {
margin: 0;
font-size: 0.95rem;
color: var(--text-muted, #a0a0a0); color: var(--text-muted, #a0a0a0);
} }
.error {
margin: 0;
color: #ef4444;
}
</style> </style>

@ -1,11 +1,17 @@
<script> <script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
import { auth } from '../lib/stores/auth.js'; import { auth } from '../lib/stores/auth.js';
import { navContext } from '../lib/stores/navContext.js';
import { fetchDeckWithQuestions, updateDeck, togglePublished, deleteDeck } from '../lib/api/decks.js'; import { fetchDeckWithQuestions, updateDeck, togglePublished, deleteDeck } from '../lib/api/decks.js';
import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js'; import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js';
import QuestionEditor from '../lib/QuestionEditor.svelte'; import QuestionEditor from '../lib/QuestionEditor.svelte';
import ConfirmModal from '../lib/ConfirmModal.svelte'; import ConfirmModal from '../lib/ConfirmModal.svelte';
onMount(() => {
navContext.set('my-decks');
});
export let params = {}; export let params = {};
$: deckId = params?.id; $: deckId = params?.id;
@ -18,8 +24,13 @@
let saving = false; let saving = false;
let error = null; let error = null;
let showDeleteConfirm = false; let showDeleteConfirm = false;
let showJsonModal = false;
let jsonEditText = '';
let jsonModalError = null;
let isCopiedDeck = false; let isCopiedDeck = false;
const CONFIG_KEYS = Object.keys(DEFAULT_DECK_CONFIG);
$: userId = $auth.user?.id; $: userId = $auth.user?.id;
let prevDeckId = ''; let prevDeckId = '';
@ -148,17 +159,59 @@
function cancel() { function cancel() {
push('/'); push('/');
} }
function openJsonModal() {
jsonEditText = getDeckJson();
jsonModalError = null;
showJsonModal = true;
}
function closeJsonModal() {
showJsonModal = false;
}
function getDeckJson() {
const normalized = questions.map(normalizeQuestion).filter(Boolean);
const payload = {
title: title.trim(),
description: description.trim(),
config: { ...config },
questions: normalized,
};
return JSON.stringify(payload, null, 2);
}
async function copyJsonToClipboard() {
try {
await navigator.clipboard.writeText(getDeckJson());
closeJsonModal();
} catch (_) {}
}
</script> </script>
<header class="page-header page-header-fixed"> <header class="page-header page-header-fixed">
<div class="page-header-inner"> <div class="page-header-inner">
<h1 class="page-header-title">Edit deck</h1> <h1 class="page-header-title">Edit deck</h1>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn btn-danger" onclick={openDeleteConfirm} disabled={saving}>Delete</button> {#if loading}
<button type="button" class="btn btn-secondary" onclick={cancel} disabled={saving}>Cancel</button> <span class="header-spinner" aria-hidden="true"></span>
<button type="button" class="btn btn-primary" onclick={save} disabled={saving}> {:else}
{saving ? 'Saving…' : 'Save'} <button type="button" class="btn btn-secondary btn-icon-text" onclick={openJsonModal} disabled={saving} title="See JSON">
</button> <span class="btn-icon-svg" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/><path d="M16 21h1a2 2 0 0 0 2-2v-5a2 2 0 0 1 2-2 2 2 0 0 1-2-2V5a2 2 0 0 0-2-2h-1"/></svg></span>
See JSON
</button>
<button type="button" class="btn btn-secondary btn-icon-text" onclick={cancel} disabled={saving} title="Cancel">
<span class="btn-icon-svg" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg></span>
Cancel
</button>
<button type="button" class="btn btn-primary btn-icon-text" onclick={save} disabled={saving} title={saving ? 'Saving…' : 'Save'}>
<span class="btn-icon-svg" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></span>
{saving ? 'Saving…' : 'Save'}
</button>
<button type="button" class="btn btn-danger btn-icon-only" onclick={openDeleteConfirm} disabled={saving} title="Delete deck" aria-label="Delete deck">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
</button>
{/if}
</div> </div>
</div> </div>
</header> </header>
@ -169,7 +222,10 @@
{:else if !userId} {:else if !userId}
<p class="auth-required">Sign in to edit decks.</p> <p class="auth-required">Sign in to edit decks.</p>
{:else if loading} {:else if loading}
<p class="muted">Loading…</p> <div class="loading-state" aria-busy="true" aria-live="polite">
<span class="loading-spinner" aria-hidden="true"></span>
<p class="loading-text">Loading deck…</p>
</div>
{:else} {:else}
<form class="deck-form" onsubmit={(e) => { e.preventDefault(); save(); }}> <form class="deck-form" onsubmit={(e) => { e.preventDefault(); save(); }}>
{#if !isCopiedDeck} {#if !isCopiedDeck}
@ -201,6 +257,38 @@
</form> </form>
{/if} {/if}
{#if showJsonModal}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="json-modal-backdrop"
role="dialog"
aria-modal="true"
aria-label="Deck JSON"
tabindex="-1"
onclick={(e) => e.target === e.currentTarget && closeJsonModal()}
onkeydown={(e) => e.key === 'Escape' && closeJsonModal()}
>
<div
class="json-modal"
role="document"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.key === 'Escape' && closeJsonModal()}
>
<h2 class="json-modal-title">Deck JSON</h2>
<textarea class="json-modal-textarea" bind:value={jsonEditText} spellcheck="false" aria-label="Edit deck JSON"></textarea>
{#if jsonModalError}
<p class="json-modal-error">{jsonModalError}</p>
{/if}
<div class="json-modal-actions">
<button type="button" class="btn btn-secondary" onclick={closeJsonModal}>Close</button>
<button type="button" class="btn btn-secondary" onclick={copyJsonToClipboard}>Copy</button>
<button type="button" class="btn btn-primary" onclick={applyJsonFromModal}>Apply</button>
</div>
</div>
</div>
{/if}
<ConfirmModal <ConfirmModal
open={showDeleteConfirm} open={showDeleteConfirm}
title="Delete deck" title="Delete deck"
@ -260,6 +348,37 @@
.header-actions { .header-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.btn-icon-text {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.btn-icon-text .btn-icon-svg {
display: inline-flex;
}
.btn-icon-only {
padding: 0.5rem;
margin-left: auto;
}
.header-spinner {
display: inline-block;
width: 1.25rem;
height: 1.25rem;
border: 2px solid var(--border, #333);
border-top-color: var(--accent, #3b82f6);
border-radius: 50%;
animation: header-spin 0.7s linear infinite;
}
@keyframes header-spin {
to { transform: rotate(360deg); }
} }
.publish-row { .publish-row {
@ -341,6 +460,104 @@
border-color: var(--border-hover, #444); border-color: var(--border-hover, #444);
} }
.json-modal-backdrop {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.json-modal {
background: var(--card-bg, #1e1e1e);
border: 1px solid var(--border, #333);
border-radius: 8px;
width: 94vw;
max-width: 94vw;
height: 90vh;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.json-modal-title {
margin: 0;
padding: 1rem 1.25rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
border-bottom: 1px solid var(--border, #333);
}
.json-modal-textarea {
margin: 0;
padding: 1rem 1.25rem;
width: 100%;
min-height: 280px;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
color: var(--text-primary, #f0f0f0);
background: var(--card-bg, #1e1e1e);
border: 1px solid var(--border, #333);
border-radius: 6px;
resize: vertical;
flex: 1;
box-sizing: border-box;
}
.json-modal-textarea:focus {
outline: none;
border-color: var(--accent, #3b82f6);
}
.json-modal-error {
margin: 0 1.25rem;
font-size: 0.85rem;
color: #ef4444;
}
.json-modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border, #333);
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
min-height: 12rem;
padding: 2rem;
}
.loading-spinner {
display: block;
width: 2.5rem;
height: 2.5rem;
border: 3px solid var(--border, #333);
border-top-color: var(--accent, #3b82f6);
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
}
@keyframes loading-spin {
to { transform: rotate(360deg); }
}
.loading-text {
margin: 0;
font-size: 0.95rem;
color: var(--text-muted, #a0a0a0);
}
.error { .error {
margin: 0 0 1rem 0; margin: 0 0 1rem 0;
font-size: 0.9rem; font-size: 0.9rem;

@ -1,10 +1,16 @@
<script> <script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
import { auth } from '../lib/stores/auth.js'; import { auth } from '../lib/stores/auth.js';
import { fetchMyDecks, togglePublished, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js'; import { navContext } from '../lib/stores/navContext.js';
import { fetchMyDecks, togglePublished, getMyDeckRating, submitDeckRating, getSourceUpdatePreview, applySourceUpdate, deleteDeck } from '../lib/api/decks.js';
import RatingModal from '../lib/RatingModal.svelte'; import RatingModal from '../lib/RatingModal.svelte';
import ConfirmModal from '../lib/ConfirmModal.svelte'; import ConfirmModal from '../lib/ConfirmModal.svelte';
onMount(() => {
navContext.set('my-decks');
});
let decks = []; let decks = [];
let loading = true; let loading = true;
let error = null; let error = null;
@ -12,6 +18,14 @@
let ratingInitial = { rating: 0, comment: '' }; let ratingInitial = { rating: 0, comment: '' };
let showUnpublishConfirm = false; let showUnpublishConfirm = false;
let deckToUnpublish = null; let deckToUnpublish = null;
let showUpdateModal = false;
let updateDeck = null;
let updatePreview = null;
let updateLoading = false;
let updateError = null;
let updateApplying = false;
let showRemoveConfirm = false;
let deckToRemove = null;
$: userId = $auth.user?.id; $: userId = $auth.user?.id;
@ -70,6 +84,10 @@
push(`/decks/${id}/edit`); push(`/decks/${id}/edit`);
} }
function goPreview(deck) {
push(`/decks/${deck.id}/preview`);
}
let togglingId = null; let togglingId = null;
async function handlePublish(e, deck) { async function handlePublish(e, deck) {
e.stopPropagation(); e.stopPropagation();
@ -112,6 +130,72 @@
togglingId = null; togglingId = null;
} }
} }
async function openUpdateModal(deck, e) {
e?.stopPropagation();
e?.preventDefault();
if (!deck?.needs_update || !userId) return;
updateDeck = deck;
showUpdateModal = true;
updatePreview = null;
updateError = null;
updateLoading = true;
try {
updatePreview = await getSourceUpdatePreview(deck.id, userId);
} catch (err) {
updateError = err?.message ?? 'Failed to load update preview';
} finally {
updateLoading = false;
}
}
function closeUpdateModal() {
showUpdateModal = false;
updateDeck = null;
updatePreview = null;
updateError = null;
updateApplying = false;
}
async function confirmSourceUpdate() {
if (!updateDeck || !userId) return;
updateApplying = true;
updateError = null;
try {
await applySourceUpdate(updateDeck.id, userId);
await load();
closeUpdateModal();
} catch (err) {
updateError = err?.message ?? 'Failed to update deck';
} finally {
updateApplying = false;
}
}
function openRemoveConfirm(deck, e) {
e?.stopPropagation();
e?.preventDefault();
if (!deck?.copied_from_deck_id) return;
deckToRemove = deck;
showRemoveConfirm = true;
}
function closeRemoveConfirm() {
showRemoveConfirm = false;
deckToRemove = null;
}
async function confirmRemoveFromMyDecks() {
if (!deckToRemove) return;
const deck = deckToRemove;
closeRemoveConfirm();
try {
await deleteDeck(deck.id);
await load();
} catch (err) {
error = err?.message ?? 'Failed to remove deck';
}
}
</script> </script>
<div class="page"> <div class="page">
@ -133,10 +217,11 @@
<ul class="deck-grid"> <ul class="deck-grid">
{#each decks as deck (deck.id)} {#each decks as deck (deck.id)}
<li class="deck-card"> <li class="deck-card">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div <div
class="deck-card-inner" class="deck-card-inner"
onclick={() => goEdit(deck.id)} onclick={() => goPreview(deck)}
onkeydown={(e) => e.key === 'Enter' && goEdit(deck.id)} onkeydown={(e) => e.key === 'Enter' && goPreview(deck)}
role="button" role="button"
tabindex="0" tabindex="0"
> >
@ -148,23 +233,50 @@
<span class="deck-count">{deck.question_count ?? 0} questions</span> <span class="deck-count">{deck.question_count ?? 0} questions</span>
</div> </div>
{#if (deck.average_rating ?? 0) > 0 || (deck.rating_count ?? 0) > 0 || deck.can_rate} {#if (deck.average_rating ?? 0) > 0 || (deck.rating_count ?? 0) > 0 || deck.can_rate}
<div {#if deck.can_rate}
class="deck-rating" <button
class:clickable={deck.can_rate} type="button"
aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars" class="deck-rating clickable"
onclick={deck.can_rate ? (e) => openRatingModal(deck, e) : undefined} aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars"
role={deck.can_rate ? 'button' : undefined} onclick={(e) => openRatingModal(deck, e)}
tabindex={deck.can_rate ? 0 : undefined} onkeydown={(e) => e.key === 'Enter' && openRatingModal(deck, e)}
onkeydown={deck.can_rate ? (e) => e.key === 'Enter' && openRatingModal(deck, e) : undefined} >
> <span class="stars">{['★', '★', '★', '★', '★'].map((_, i) => (i < Math.round(deck.average_rating ?? 0) ? '★' : '☆')).join('')}</span>
<span class="stars">{['★', '★', '★', '★', '★'].map((_, i) => (i < Math.round(deck.average_rating ?? 0) ? '★' : '☆')).join('')}</span> <span class="rating-text">{formatRating(deck.average_rating ?? 0)}</span>
<span class="rating-text">{formatRating(deck.average_rating ?? 0)}</span> {#if (deck.rating_count ?? 0) > 0}
{#if (deck.rating_count ?? 0) > 0} <span class="rating-count">({deck.rating_count})</span>
<span class="rating-count">({deck.rating_count})</span> {/if}
{/if} </button>
</div> {:else}
<div class="deck-rating" aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars">
<span class="stars">{['★', '★', '★', '★', '★'].map((_, i) => (i < Math.round(deck.average_rating ?? 0) ? '★' : '☆')).join('')}</span>
<span class="rating-text">{formatRating(deck.average_rating ?? 0)}</span>
{#if (deck.rating_count ?? 0) > 0}
<span class="rating-count">({deck.rating_count})</span>
{/if}
</div>
{/if}
{/if} {/if}
<div class="deck-card-actions"> <div class="deck-card-actions">
{#if deck.needs_update}
<button
type="button"
class="btn btn-icon btn-icon-update"
onclick={(e) => openUpdateModal(deck, e)}
title="Update from community (v{deck.source_version ?? 1} available)"
aria-label="Update from community (v{deck.source_version ?? 1} available)"
>
<span class="update-icon-wrap">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
<path d="M16 21h5v-5"/>
</svg>
</span>
<span class="update-badge" aria-hidden="true">{deck.source_version ?? 1}</span>
</button>
{/if}
{#if !deck.copied_from_deck_id} {#if !deck.copied_from_deck_id}
{#if deck.published} {#if deck.published}
<button <button
@ -187,12 +299,26 @@
</button> </button>
{/if} {/if}
{/if} {/if}
<button type="button" class="btn btn-icon" onclick={(e) => { e.stopPropagation(); goEdit(deck.id); }} title="Edit deck" aria-label="Edit deck"> {#if !deck.copied_from_deck_id}
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <button type="button" class="btn btn-icon" onclick={(e) => { e.stopPropagation(); goEdit(deck.id); }} title="Edit deck" aria-label="Edit deck">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
</svg> <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</button> </svg>
</button>
{:else}
<button
type="button"
class="btn btn-icon btn-icon-danger"
onclick={(e) => openRemoveConfirm(deck, e)}
title="Remove from My decks"
aria-label="Remove from My decks"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
{/if}
</div> </div>
</div> </div>
</li> </li>
@ -218,6 +344,68 @@
onConfirm={confirmUnpublish} onConfirm={confirmUnpublish}
onCancel={closeUnpublishConfirm} onCancel={closeUnpublishConfirm}
/> />
<ConfirmModal
open={showRemoveConfirm}
title="Remove from My decks"
message="Remove this deck from My decks? You can add it again from Community anytime."
confirmLabel="Remove"
cancelLabel="Cancel"
variant="danger"
onConfirm={confirmRemoveFromMyDecks}
onCancel={closeRemoveConfirm}
/>
{#if showUpdateModal}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="update-modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="update-modal-title"
tabindex="-1"
onclick={(e) => e.target === e.currentTarget && closeUpdateModal()}
onkeydown={(e) => e.key === 'Escape' && closeUpdateModal()}
>
<div
class="update-modal"
role="document"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.key === 'Escape' && closeUpdateModal()}
>
<h2 id="update-modal-title" class="update-modal-title">Update from Community</h2>
{#if updateLoading}
<p class="update-modal-loading">Loading changes…</p>
{:else if updateError}
<p class="update-modal-error">{updateError}</p>
{:else if updatePreview}
<p class="update-modal-intro">
<strong>{updateDeck?.title ?? 'This deck'}</strong> has a newer version (v{updatePreview.source?.version ?? 1}) available.
</p>
<ul class="update-modal-changes">
{#each updatePreview.changes as change}
<li>{change}</li>
{/each}
</ul>
<p class="update-modal-warn">Your copy will be replaced with the current version. This cannot be undone.</p>
{/if}
<div class="update-modal-actions">
<button type="button" class="btn btn-secondary" onclick={closeUpdateModal} disabled={updateApplying}>
Reject
</button>
<button
type="button"
class="btn btn-primary"
onclick={confirmSourceUpdate}
disabled={updateApplying || updateLoading || !!updateError}
>
{updateApplying ? 'Updating…' : 'Confirm update'}
</button>
</div>
</div>
</div>
{/if}
</div> </div>
<style> <style>
@ -326,6 +514,16 @@
color: var(--text-muted, #a0a0a0); color: var(--text-muted, #a0a0a0);
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
margin-top: auto; margin-top: auto;
border: none;
background: transparent;
padding: 0;
cursor: default;
font: inherit;
}
button.deck-rating {
cursor: pointer;
text-align: left;
} }
.deck-rating.clickable { .deck-rating.clickable {
@ -346,18 +544,6 @@
color: var(--text-muted, #888); color: var(--text-muted, #888);
} }
.badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.badge.published {
background: rgba(34, 197, 94, 0.2);
color: #22c55e;
}
.deck-card-actions { .deck-card-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -366,6 +552,106 @@
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.btn-icon-update {
position: relative;
color: var(--accent, #3b82f6);
}
.btn-icon-update:hover {
color: #60a5fa;
}
.update-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
}
.update-badge {
position: absolute;
top: -6px;
right: -6px;
width: 22px;
height: 22px;
font-size: 0.8rem;
font-weight: 700;
color: #fff;
background: #dc2626;
border: 2px solid var(--card-bg, #1a1a1a);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
line-height: 1;
}
.update-modal-backdrop {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.update-modal {
background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 12px;
max-width: 420px;
width: 100%;
padding: 1.5rem;
}
.update-modal-title {
margin: 0 0 1rem 0;
font-size: 1.2rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.update-modal-loading,
.update-modal-error {
margin: 0 0 1rem 0;
font-size: 0.95rem;
}
.update-modal-error {
color: #ef4444;
}
.update-modal-intro {
margin: 0 0 0.75rem 0;
font-size: 0.95rem;
color: var(--text-muted, #a0a0a0);
}
.update-modal-changes {
margin: 0 0 1rem 0;
padding-left: 1.25rem;
font-size: 0.9rem;
color: var(--text-primary, #f0f0f0);
}
.update-modal-changes li {
margin-bottom: 0.25rem;
}
.update-modal-warn {
margin: 0 0 1rem 0;
font-size: 0.85rem;
color: var(--text-muted, #a0a0a0);
}
.update-modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn-secondary { .btn-secondary {
background: transparent; background: transparent;
} }
@ -420,4 +706,12 @@
.btn-icon svg { .btn-icon svg {
display: block; display: block;
} }
.btn-icon-danger {
color: var(--text-muted, #a0a0a0);
}
.btn-icon-danger:hover {
color: #ef4444;
}
</style> </style>

@ -126,7 +126,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Avatar</label> <span class="form-label">Avatar</span>
<div class="avatar-row"> <div class="avatar-row">
<div class="avatar-wrap"> <div class="avatar-wrap">
<label for="avatar-input" class="avatar-clickable" title="Choose image"> <label for="avatar-input" class="avatar-clickable" title="Choose image">
@ -364,9 +364,12 @@
background: transparent; background: transparent;
} }
.btn-small { .form-group .form-label {
padding: 0.35rem 0.75rem; display: block;
font-size: 0.85rem; font-size: 0.9rem;
font-weight: 500;
color: var(--text-muted, #a0a0a0);
margin-bottom: 0.35rem;
} }
.error { .error {

@ -0,0 +1,13 @@
-- Versioning for published decks; used to detect when community copies are outdated.
-- Run in Supabase SQL Editor (Dashboard → SQL Editor) if not using Supabase CLI migrations.
-- Version of this deck (v1, v2, ...). Bumped when a published deck is updated.
ALTER TABLE omotomo.decks
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;
-- For copies: version of the source deck at copy time. NULL = copied before versioning.
ALTER TABLE omotomo.decks
ADD COLUMN IF NOT EXISTS copied_from_version integer NULL;
COMMENT ON COLUMN omotomo.decks.version IS 'Incremented when deck (that is or was published) is updated; displayed as v1, v2, etc.';
COMMENT ON COLUMN omotomo.decks.copied_from_version IS 'Version of the source deck at copy time; used to show "Update" when source has a newer version.';

@ -0,0 +1,9 @@
-- Run this in Supabase SQL Editor if "Update" never appears on community copies
-- (or you see errors about version / copied_from_version).
-- Uses schema omotomo (match your app's db.schema in src/lib/supabase.js).
ALTER TABLE omotomo.decks
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;
ALTER TABLE omotomo.decks
ADD COLUMN IF NOT EXISTS copied_from_version integer NULL;
Loading…
Cancel
Save

Powered by TurnKey Linux.