@ -1,10 +1,16 @@
< script >
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router';
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 ConfirmModal from '../lib/ConfirmModal.svelte';
onMount(() => {
navContext.set('my-decks');
});
let decks = [];
let loading = true;
let error = null;
@ -12,6 +18,14 @@
let ratingInitial = { rating : 0 , comment : '' } ;
let showUnpublishConfirm = false;
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;
@ -70,6 +84,10 @@
push(`/decks/${ id } /edit`);
}
function goPreview(deck) {
push(`/decks/${ deck . id } /preview`);
}
let togglingId = null;
async function handlePublish(e, deck) {
e.stopPropagation();
@ -112,6 +130,72 @@
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 >
< div class = "page" >
@ -133,10 +217,11 @@
< ul class = "deck-grid" >
{ #each decks as deck ( deck . id )}
< li class = "deck-card" >
<!-- svelte - ignore a11y_no_noninteractive_tabindex -->
< div
class="deck-card-inner"
onclick={() => go Edit( deck . id )}
onkeydown={( e ) => e . key === 'Enter' && go Edit( deck . id )}
onclick={() => go Preview( deck )}
onkeydown={( e ) => e . key === 'Enter' && go Preview( deck )}
role="button"
tabindex="0"
>
@ -148,23 +233,50 @@
< span class = "deck-count" > { deck . question_count ?? 0 } questions</ span >
< / div >
{ #if ( deck . average_rating ?? 0 ) > 0 || ( deck . rating_count ?? 0 ) > 0 || deck . can_rate }
< div
class="deck-rating"
class:clickable={ deck . can_rate }
aria-label="Rating: { deck . average_rating ?? 0 } out of 5 stars"
onclick={ deck . can_rate ? ( e ) => openRatingModal ( deck , e ) : undefined }
role={ deck . can_rate ? 'button' : undefined }
tabindex={ deck . can_rate ? 0 : undefined }
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 = "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 deck . can_rate }
< button
type="button"
class="deck-rating clickable"
aria-label="Rating: { deck . average_rating ?? 0 } out of 5 stars"
onclick={( e ) => openRatingModal ( deck , e )}
onkeydown={( e ) => e . key === 'Enter' && openRatingModal ( deck , e )}
>
< 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 }
< / button >
{ : 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 }
< 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 . published }
< button
@ -187,12 +299,26 @@
< / button >
{ /if }
{ /if }
< button type = "button" class = "btn btn-icon" onclick = {( e ) => { e . stopPropagation (); goEdit ( deck . id ); }} title="Edit deck " aria-label = "Edit 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 = "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" / >
< path d = "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" / >
< / svg >
< / button >
{ #if ! deck . copied_from_deck_id }
< button type = "button" class = "btn btn-icon" onclick = {( e ) => { e . stopPropagation (); goEdit ( deck . id ); }} title="Edit deck " aria-label = "Edit 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 = "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" / >
< path d = "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" / >
< / 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 >
< / li >
@ -218,6 +344,68 @@
onConfirm={ confirmUnpublish }
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 >
< style >
@ -326,6 +514,16 @@
color: var(--text-muted, #a0a0a0);
margin-bottom: 0.25rem;
margin-top: auto;
border: none;
background: transparent;
padding: 0;
cursor: default;
font: inherit;
}
button.deck-rating {
cursor: pointer;
text-align: left;
}
.deck-rating.clickable {
@ -346,18 +544,6 @@
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 {
display: flex;
flex-wrap: wrap;
@ -366,6 +552,106 @@
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 {
background: transparent;
}
@ -420,4 +706,12 @@
.btn-icon svg {
display: block;
}
.btn-icon-danger {
color: var(--text-muted, #a0a0a0);
}
.btn-icon-danger:hover {
color: #ef4444;
}
< / style >