Deck version update

This commit is contained in:
gitea
2026-02-14 10:59:22 +01:00
parent 32c786fa24
commit 6009ce5ec2
14 changed files with 1008 additions and 143 deletions
+9 -1
View File
@@ -39,10 +39,18 @@
aria-modal="true"
aria-labelledby="confirm-modal-title"
aria-describedby="confirm-modal-desc"
tabindex="-1"
onclick={handleBackdropClick}
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>
<p id="confirm-modal-desc" class="modal-message">{message}</p>
<div class="modal-actions">
+26 -7
View File
@@ -1,9 +1,14 @@
<script>
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 { navContext } from './stores/navContext.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 mode = 'login'; // 'login' | 'register'
let email = '';
@@ -90,6 +95,19 @@
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) {
const p = e?.detail;
if (p && p.id && $auth.user?.id && p.id === $auth.user.id) {
@@ -115,9 +133,9 @@
<span class="app-name">Omotomo</span>
</a>
{#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}
<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 class="nav-actions">
{#if $auth.user}
@@ -309,6 +327,11 @@
color: var(--text-primary, #f0f0f0);
}
.nav-link.active {
color: var(--text-primary, #f0f0f0);
font-weight: 500;
}
.nav-actions {
display: flex;
align-items: center;
@@ -450,10 +473,6 @@
border-color: #2563eb;
}
.btn-logout {
background: transparent;
}
.btn-block {
width: 100%;
}
+19 -3
View File
@@ -32,8 +32,24 @@
</script>
{#if deck}
<div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="rating-modal-title" onclick={handleBackdropClick}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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>
<div class="stars-row">
{#each [1, 2, 3, 4, 5] as n}
@@ -54,7 +70,7 @@
placeholder="Add a comment…"
bind:value={comment}
rows="3"
/>
></textarea>
<div class="modal-actions">
<button type="button" class="btn btn-ghost" onclick={onClose}>Cancel</button>
<button type="button" class="btn btn-primary" onclick={handleSubmit} disabled={selectedStars < 1}>
+122 -10
View File
@@ -3,7 +3,7 @@ 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, 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)
.order('updated_at', { ascending: false });
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)
: Promise.resolve({ data: [] }),
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: [] }),
]);
@@ -45,13 +45,23 @@ export async function fetchMyDecks(userId) {
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) => {
const showRatingDeckId = d.copied_from_deck_id || d.id;
const rating = getRating(showRatingDeckId);
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 {
...d,
question_count: questionCounts[i],
@@ -59,6 +69,8 @@ export async function fetchMyDecks(userId) {
can_rate: canRate,
rateable_deck_id: d.copied_from_deck_id || null,
source_deck_title,
source_version,
needs_update,
};
});
}
@@ -269,10 +281,11 @@ export async function copyDeckToUser(deckId, userId) {
config: source.config ?? {},
questions,
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 = {
owner_id: ownerId,
title: title.trim(),
@@ -280,6 +293,7 @@ export async function createDeck(ownerId, { title, description, config, question
config: config ?? {},
};
if (copiedFromDeckId != null) row.copied_from_deck_id = copiedFromDeckId;
if (copiedFromVersion != null) row.copied_from_version = copiedFromVersion;
const { data: deck, error: deckError } = await supabase
.from('decks')
.insert(row)
@@ -302,13 +316,23 @@ export async function createDeck(ownerId, { title, description, config, question
}
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
.from('decks')
.update({
title: title.trim(),
description: (description ?? '').trim(),
config: config ?? {},
})
.update(updatePayload)
.eq('id', deckId);
if (deckError) throw deckError;
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;
}
/**
* 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). */
export async function userHasDeck(deckId, userId) {
if (!userId) return false;
+4
View File
@@ -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);