Deck version update
This commit is contained in:
@@ -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
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user