Deck ratings (comment + popup), remove Published badge from My decks
This commit is contained in:
+379
-24
@@ -1,18 +1,133 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchPublishedDecks } from '../lib/api/decks.js';
|
||||
import { push } from 'svelte-spa-router';
|
||||
import { auth } from '../lib/stores/auth.js';
|
||||
import { fetchPublishedDecks, copyDeckToUser, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js';
|
||||
import RatingModal from '../lib/RatingModal.svelte';
|
||||
|
||||
let decks = [];
|
||||
let loading = true;
|
||||
let error = null;
|
||||
let addingDeckId = null;
|
||||
let useError = null;
|
||||
let ratingDeck = null;
|
||||
let ratingInitial = { rating: 0, comment: '' };
|
||||
const STORAGE_KEY_INCLUDE_MY = 'community_include_my_decks';
|
||||
const STORAGE_KEY_INCLUDE_ADDED = 'community_include_already_added';
|
||||
|
||||
function getSessionBool(key, fallback) {
|
||||
if (typeof sessionStorage === 'undefined') return fallback;
|
||||
try {
|
||||
const v = sessionStorage.getItem(key);
|
||||
return v !== null ? v === 'true' : fallback;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
let searchQuery = '';
|
||||
let includeMyDecks = getSessionBool(STORAGE_KEY_INCLUDE_MY, true);
|
||||
let includeAlreadyAdded = getSessionBool(STORAGE_KEY_INCLUDE_ADDED, true);
|
||||
|
||||
$: userId = $auth.user?.id;
|
||||
|
||||
$: if (typeof sessionStorage !== 'undefined') {
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY_INCLUDE_MY, String(includeMyDecks));
|
||||
sessionStorage.setItem(STORAGE_KEY_INCLUDE_ADDED, String(includeAlreadyAdded));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
$: filteredDecks = (() => {
|
||||
let list = decks;
|
||||
if (!includeMyDecks && userId) {
|
||||
list = list.filter((d) => d.owner_id !== userId);
|
||||
}
|
||||
if (!includeAlreadyAdded && userId) {
|
||||
list = list.filter((d) => !d.user_has_this);
|
||||
}
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(d) =>
|
||||
(d.title ?? '').toLowerCase().includes(q) ||
|
||||
(d.description ?? '').toLowerCase().includes(q) ||
|
||||
(d.owner_display_name ?? '').toLowerCase().includes(q) ||
|
||||
(d.owner_email ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return list;
|
||||
})();
|
||||
|
||||
onMount(load);
|
||||
|
||||
function formatRating(n) {
|
||||
return Number(n).toFixed(1);
|
||||
}
|
||||
|
||||
function goPreview(deckId) {
|
||||
push(`/decks/${deckId}/preview`);
|
||||
}
|
||||
|
||||
function goUser(ownerId, e) {
|
||||
if (e) e.preventDefault();
|
||||
push(`/community/user/${ownerId}`);
|
||||
}
|
||||
|
||||
async function openRatingModal(deck, e) {
|
||||
e?.stopPropagation();
|
||||
if (!userId) {
|
||||
useError = 'Sign in to rate this deck.';
|
||||
return;
|
||||
}
|
||||
if (deck.owner_id === userId) return; // cannot rate own deck
|
||||
useError = null;
|
||||
ratingDeck = { id: deck.id, title: deck.title };
|
||||
try {
|
||||
const data = await getMyDeckRating(deck.id, userId);
|
||||
ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' };
|
||||
} catch (_) {
|
||||
ratingInitial = { rating: 0, comment: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function closeRatingModal() {
|
||||
ratingDeck = null;
|
||||
}
|
||||
|
||||
async function handleRatingSubmit(payload) {
|
||||
if (!ratingDeck || !userId) return;
|
||||
try {
|
||||
await submitDeckRating(ratingDeck.id, userId, payload);
|
||||
await load();
|
||||
} catch (e) {
|
||||
useError = e?.message ?? 'Failed to save rating';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUse(deckId) {
|
||||
useError = null;
|
||||
if (!userId) {
|
||||
useError = 'Sign in to add this deck to My decks.';
|
||||
return;
|
||||
}
|
||||
if (addingDeckId) return;
|
||||
addingDeckId = deckId;
|
||||
try {
|
||||
await copyDeckToUser(deckId, userId);
|
||||
await load();
|
||||
} catch (e) {
|
||||
useError = e?.message ?? 'Failed to add deck';
|
||||
} finally {
|
||||
addingDeckId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
decks = await fetchPublishedDecks();
|
||||
decks = await fetchPublishedDecks(userId ?? undefined);
|
||||
} catch (e) {
|
||||
error = e?.message ?? 'Failed to load community decks';
|
||||
decks = [];
|
||||
@@ -35,27 +150,93 @@
|
||||
{:else if decks.length === 0}
|
||||
<p class="muted">No published decks yet. Create and publish a deck from My decks to see it here.</p>
|
||||
{:else}
|
||||
<ul class="deck-list">
|
||||
{#each decks as deck (deck.id)}
|
||||
<div class="community-toolbar">
|
||||
<label class="search-wrap" for="community-search">
|
||||
<span class="sr-only">Search decks</span>
|
||||
<input
|
||||
id="community-search"
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Search by title, description or creator…"
|
||||
bind:value={searchQuery}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
{#if userId}
|
||||
<label class="filter-checkbox">
|
||||
<input type="checkbox" class="filter-checkbox-input" bind:checked={includeMyDecks} />
|
||||
<span>Include my own decks</span>
|
||||
</label>
|
||||
<label class="filter-checkbox">
|
||||
<input type="checkbox" class="filter-checkbox-input" bind:checked={includeAlreadyAdded} />
|
||||
<span>Include already added</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if useError}
|
||||
<p class="use-error">{useError}</p>
|
||||
{/if}
|
||||
{#if filteredDecks.length === 0}
|
||||
<p class="muted">No decks match your filters.</p>
|
||||
{:else}
|
||||
<ul class="deck-grid">
|
||||
{#each filteredDecks as deck (deck.id)}
|
||||
<li class="deck-card">
|
||||
<h3 class="deck-title">{deck.title}</h3>
|
||||
{#if deck.description}
|
||||
<p class="deck-description">{deck.description}</p>
|
||||
{/if}
|
||||
<div class="deck-meta">
|
||||
<span class="deck-count">{deck.question_count ?? 0} questions</span>
|
||||
<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>
|
||||
{#if deck.description}
|
||||
<p class="deck-description">{deck.description}</p>
|
||||
{/if}
|
||||
<div class="deck-meta">
|
||||
<span class="deck-count">{deck.question_count ?? 0} questions</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div
|
||||
class="deck-rating"
|
||||
class:clickable={userId && deck.owner_id !== userId}
|
||||
aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars"
|
||||
onclick={userId && deck.owner_id !== userId ? (e) => openRatingModal(deck, e) : undefined}
|
||||
role={userId && deck.owner_id !== userId ? 'button' : undefined}
|
||||
tabindex={userId && deck.owner_id !== userId ? 0 : undefined}
|
||||
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="rating-text">{formatRating(deck.average_rating ?? 0)}</span>
|
||||
{#if (deck.rating_count ?? 0) > 0}
|
||||
<span class="rating-count">({deck.rating_count})</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="deck-card-actions">
|
||||
{#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}>
|
||||
{addingDeckId === deck.id ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="already-have">In My decks</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="use-hint">Use the Decky app to discover and import this deck from the same Supabase project.</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<RatingModal
|
||||
deck={ratingDeck}
|
||||
initialRating={ratingInitial.rating}
|
||||
initialComment={ratingInitial.comment}
|
||||
onSubmit={handleRatingSubmit}
|
||||
onClose={closeRatingModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 1.5rem;
|
||||
max-width: 900px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -70,6 +251,72 @@
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
}
|
||||
|
||||
.community-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
background: var(--input-bg, #252525);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--text-muted, #666);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent, #3b82f6);
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-checkbox:hover {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
.filter-checkbox-input {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
@@ -89,21 +336,44 @@
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
}
|
||||
|
||||
.deck-list {
|
||||
.use-error {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.deck-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.deck-card {
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.deck-card-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 160px;
|
||||
padding: 1.25rem;
|
||||
background: var(--card-bg, #1a1a1a);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deck-card-inner:hover {
|
||||
border-color: var(--border-hover, #444);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.deck-title {
|
||||
@@ -118,18 +388,103 @@
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deck-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.deck-creator {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
.creator-link {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: var(--accent, #3b82f6);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.creator-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.deck-rating {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.deck-rating.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deck-rating.clickable:hover {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.deck-rating .stars {
|
||||
color: #eab308;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.deck-rating .rating-count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.deck-card-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.use-hint {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
.deck-card-actions .btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg, #1e1e1e);
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deck-card-actions .btn:hover {
|
||||
background: var(--hover-bg, #2a2a2a);
|
||||
}
|
||||
|
||||
.deck-card-actions .btn-primary {
|
||||
background: var(--accent, #3b82f6);
|
||||
border-color: var(--accent, #3b82f6);
|
||||
}
|
||||
|
||||
.deck-card-actions .btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.already-have {
|
||||
font-size: 0.85rem;
|
||||
color: #22c55e;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
<script>
|
||||
import { push } from 'svelte-spa-router';
|
||||
import { auth } from '../lib/stores/auth.js';
|
||||
import { fetchPublishedDecksByOwner, copyDeckToUser, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js';
|
||||
import RatingModal from '../lib/RatingModal.svelte';
|
||||
|
||||
export let params = {};
|
||||
|
||||
let decks = [];
|
||||
let ownerName = '';
|
||||
let loading = true;
|
||||
let error = null;
|
||||
let addingDeckId = null;
|
||||
let useError = null;
|
||||
let ratingDeck = null;
|
||||
let ratingInitial = { rating: 0, comment: '' };
|
||||
|
||||
$: userId = $auth.user?.id;
|
||||
|
||||
let prevOwnerId = null;
|
||||
$: ownerId = params?.id;
|
||||
$: if (ownerId && ownerId !== prevOwnerId) {
|
||||
prevOwnerId = ownerId;
|
||||
load();
|
||||
}
|
||||
|
||||
function formatRating(n) {
|
||||
return Number(n).toFixed(1);
|
||||
}
|
||||
|
||||
function goPreview(deckId) {
|
||||
push(`/decks/${deckId}/preview`);
|
||||
}
|
||||
|
||||
async function openRatingModal(deck, e) {
|
||||
e?.stopPropagation();
|
||||
if (!userId) {
|
||||
useError = 'Sign in to rate this deck.';
|
||||
return;
|
||||
}
|
||||
if (deck.owner_id === userId) return; // cannot rate own deck
|
||||
useError = null;
|
||||
ratingDeck = { id: deck.id, title: deck.title };
|
||||
try {
|
||||
const data = await getMyDeckRating(deck.id, userId);
|
||||
ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' };
|
||||
} catch (_) {
|
||||
ratingInitial = { rating: 0, comment: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function closeRatingModal() {
|
||||
ratingDeck = null;
|
||||
}
|
||||
|
||||
async function handleRatingSubmit(payload) {
|
||||
if (!ratingDeck || !userId) return;
|
||||
try {
|
||||
await submitDeckRating(ratingDeck.id, userId, payload);
|
||||
await load();
|
||||
} catch (e) {
|
||||
useError = e?.message ?? 'Failed to save rating';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUse(deckId) {
|
||||
useError = null;
|
||||
if (!userId) {
|
||||
useError = 'Sign in to add this deck to My decks.';
|
||||
return;
|
||||
}
|
||||
if (addingDeckId) return;
|
||||
addingDeckId = deckId;
|
||||
try {
|
||||
await copyDeckToUser(deckId, userId);
|
||||
await load();
|
||||
} catch (e) {
|
||||
useError = e?.message ?? 'Failed to add deck';
|
||||
} finally {
|
||||
addingDeckId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function goCommunity(e) {
|
||||
if (e) e.preventDefault();
|
||||
push('/community');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!ownerId) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await fetchPublishedDecksByOwner(ownerId, userId ?? undefined);
|
||||
decks = result.decks ?? [];
|
||||
ownerName = result.owner_display_name ?? result.owner_email ?? 'User';
|
||||
} catch (e) {
|
||||
error = e?.message ?? 'Failed to load decks';
|
||||
decks = [];
|
||||
ownerName = '';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<button type="button" class="btn btn-back" onclick={goCommunity}>← Community</button>
|
||||
<h1 class="page-title">Decks by {ownerName}</h1>
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if decks.length === 0}
|
||||
<p class="muted">No published decks by this user.</p>
|
||||
{:else}
|
||||
{#if useError}
|
||||
<p class="use-error">{useError}</p>
|
||||
{/if}
|
||||
<ul class="deck-grid">
|
||||
{#each decks as deck (deck.id)}
|
||||
<li class="deck-card">
|
||||
<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>
|
||||
{#if deck.description}
|
||||
<p class="deck-description">{deck.description}</p>
|
||||
{/if}
|
||||
<div class="deck-meta">
|
||||
<span class="deck-count">{deck.question_count ?? 0} questions</span>
|
||||
</div>
|
||||
<div
|
||||
class="deck-rating"
|
||||
class:clickable={userId && deck.owner_id !== userId}
|
||||
aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars"
|
||||
onclick={userId && deck.owner_id !== userId ? (e) => openRatingModal(deck, e) : undefined}
|
||||
role={userId && deck.owner_id !== userId ? 'button' : undefined}
|
||||
tabindex={userId && deck.owner_id !== userId ? 0 : undefined}
|
||||
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="rating-text">{formatRating(deck.average_rating ?? 0)}</span>
|
||||
{#if (deck.rating_count ?? 0) > 0}
|
||||
<span class="rating-count">({deck.rating_count})</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="deck-card-actions">
|
||||
{#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}>
|
||||
{addingDeckId === deck.id ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="already-have">In My decks</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<RatingModal
|
||||
deck={ratingDeck}
|
||||
initialRating={ratingInitial.rating}
|
||||
initialComment={ratingInitial.comment}
|
||||
onSubmit={handleRatingSubmit}
|
||||
onClose={closeRatingModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
border-color: var(--border-hover, #444);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
}
|
||||
|
||||
.muted,
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
}
|
||||
|
||||
.use-error {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.deck-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.deck-card {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.deck-card-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 160px;
|
||||
padding: 1.25rem;
|
||||
background: var(--card-bg, #1a1a1a);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deck-card-inner:hover {
|
||||
border-color: var(--border-hover, #444);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.deck-title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
}
|
||||
|
||||
.deck-description {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deck-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.deck-rating {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.deck-rating.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deck-rating.clickable:hover {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.deck-rating .stars {
|
||||
color: #eab308;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.deck-rating .rating-count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.deck-card-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.deck-card-actions .btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg, #1e1e1e);
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deck-card-actions .btn:hover {
|
||||
background: var(--hover-bg, #2a2a2a);
|
||||
}
|
||||
|
||||
.deck-card-actions .btn-primary {
|
||||
background: var(--accent, #3b82f6);
|
||||
border-color: var(--accent, #3b82f6);
|
||||
}
|
||||
|
||||
.deck-card-actions .btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.already-have {
|
||||
font-size: 0.85rem;
|
||||
color: #22c55e;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,318 @@
|
||||
<script>
|
||||
import { push } from 'svelte-spa-router';
|
||||
import { auth } from '../lib/stores/auth.js';
|
||||
import { fetchDeckWithQuestions, copyDeckToUser, userHasDeck } from '../lib/api/decks.js';
|
||||
|
||||
export let params = {};
|
||||
|
||||
let deck = null;
|
||||
let loading = true;
|
||||
let error = null;
|
||||
let adding = false;
|
||||
let addError = null;
|
||||
let userAlreadyHasDeck = false;
|
||||
|
||||
$: userId = $auth.user?.id;
|
||||
$: deckId = params?.id;
|
||||
let prevDeckId = null;
|
||||
$: if (deckId && deckId !== prevDeckId) {
|
||||
prevDeckId = deckId;
|
||||
load();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!deckId) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
addError = null;
|
||||
userAlreadyHasDeck = false;
|
||||
try {
|
||||
deck = await fetchDeckWithQuestions(deckId);
|
||||
if (!deck.published) {
|
||||
error = 'This deck is not available.';
|
||||
deck = null;
|
||||
} else if (userId) {
|
||||
userAlreadyHasDeck = await userHasDeck(deckId, userId);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e?.message ?? 'Failed to load deck';
|
||||
deck = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goCommunity() {
|
||||
push('/community');
|
||||
}
|
||||
|
||||
async function addToMyDecks() {
|
||||
if (!deckId || !deck?.published) return;
|
||||
addError = null;
|
||||
if (!userId) {
|
||||
addError = 'Sign in to add this deck to My decks.';
|
||||
return;
|
||||
}
|
||||
adding = true;
|
||||
try {
|
||||
await copyDeckToUser(deckId, userId);
|
||||
userAlreadyHasDeck = true;
|
||||
} catch (e) {
|
||||
addError = e?.message ?? 'Failed to add deck';
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goReviews() {
|
||||
if (!deckId) return;
|
||||
push(`/decks/${deckId}/reviews`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="page-header page-header-fixed">
|
||||
<div class="page-header-inner">
|
||||
<div class="page-header-actions">
|
||||
<button type="button" class="btn btn-back" onclick={goCommunity}>← Community</button>
|
||||
{#if deck && deck.published && !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>
|
||||
{:else if deck && deck.published && userAlreadyHasDeck}
|
||||
<span class="already-have">In My decks</span>
|
||||
{/if}
|
||||
{#if deck && deck.published}
|
||||
<button type="button" class="btn btn-reviews" onclick={goReviews} title="View reviews">
|
||||
Reviews
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if deck && deck.published && !userAlreadyHasDeck && addError}
|
||||
<p class="page-header-error">{addError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page page-with-fixed-header">
|
||||
{#if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if deck}
|
||||
<h1 class="deck-title">{deck.title}</h1>
|
||||
{#if deck.description}
|
||||
<p class="deck-description">{deck.description}</p>
|
||||
{/if}
|
||||
<p class="deck-meta">{deck.questions?.length ?? 0} questions</p>
|
||||
|
||||
<h2 class="section-title">Questions</h2>
|
||||
<ol class="question-list">
|
||||
{#each deck.questions ?? [] as q, i}
|
||||
<li class="question-item">
|
||||
<div class="question-prompt">{q.prompt}</div>
|
||||
<ul class="question-answers">
|
||||
{#each (q.answers ?? []) as ans, j}
|
||||
<li class:correct={(q.correct_answer_indices ?? []).includes(j)}>{ans}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if q.explanation}
|
||||
<p class="question-explanation">{q.explanation}</p>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 1.5rem;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-with-fixed-header {
|
||||
padding-top: calc(var(--navbar-height, 60px) + 3.5rem);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.page-header-fixed {
|
||||
position: fixed;
|
||||
top: var(--navbar-height, 60px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
background: var(--bg, #0f0f0f);
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.page-header-inner {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.page-header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
border-color: var(--border-hover, #444);
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent, #3b82f6);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-add:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-add:disabled {
|
||||
opacity: 0.85;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-reviews {
|
||||
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-reviews:hover {
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
border-color: var(--border-hover, #444);
|
||||
}
|
||||
|
||||
.page-header-error {
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.already-have {
|
||||
font-size: 0.9rem;
|
||||
color: #22c55e;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.deck-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
}
|
||||
|
||||
.deck-description {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.deck-meta {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
}
|
||||
|
||||
.question-list {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.question-item {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--card-bg, #1a1a1a);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.question-prompt {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.question-answers {
|
||||
margin: 0 0 0.5rem 0;
|
||||
padding-left: 1.25rem;
|
||||
list-style: disc;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
}
|
||||
|
||||
.question-answers li.correct {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.question-explanation {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #888);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.muted,
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script>
|
||||
import { push } from 'svelte-spa-router';
|
||||
import { fetchDeckWithQuestions, getDeckReviews } from '../lib/api/decks.js';
|
||||
|
||||
export let params = {};
|
||||
|
||||
let deck = null;
|
||||
let reviews = [];
|
||||
let loading = true;
|
||||
let error = null;
|
||||
|
||||
$: deckId = params?.id;
|
||||
let prevDeckId = null;
|
||||
$: if (deckId && deckId !== prevDeckId) {
|
||||
prevDeckId = deckId;
|
||||
load();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!deckId) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const [deckData, reviewsData] = await Promise.all([
|
||||
fetchDeckWithQuestions(deckId),
|
||||
getDeckReviews(deckId),
|
||||
]);
|
||||
if (!deckData.published) {
|
||||
error = 'This deck is not available.';
|
||||
deck = null;
|
||||
reviews = [];
|
||||
} else {
|
||||
deck = deckData;
|
||||
reviews = reviewsData;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e?.message ?? 'Failed to load';
|
||||
deck = null;
|
||||
reviews = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
push(`/decks/${deckId}/preview`);
|
||||
}
|
||||
|
||||
function reviewerName(r) {
|
||||
return (r.display_name && r.display_name.trim()) || r.email || 'Anonymous';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<button type="button" class="btn btn-back" onclick={goBack}>← Back to deck</button>
|
||||
<h1 class="page-title">Reviews</h1>
|
||||
{#if deck}
|
||||
<p class="deck-subtitle">{deck.title}</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if reviews.length === 0}
|
||||
<p class="muted">No reviews yet.</p>
|
||||
{:else}
|
||||
<ul class="reviews-list">
|
||||
{#each reviews as review (review.user_id)}
|
||||
<li class="review-item">
|
||||
<div class="review-row">
|
||||
<div class="review-avatar-wrap">
|
||||
{#if review.avatar_url}
|
||||
<img src={review.avatar_url} alt="" class="review-avatar" width="40" height="40" />
|
||||
{:else}
|
||||
<div class="review-avatar-placeholder" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="review-body">
|
||||
<div class="review-meta">
|
||||
<span class="reviewer-name">{reviewerName(review)}</span>
|
||||
<span class="review-stars" aria-label="{review.rating} out of 5 stars">
|
||||
{['★', '★', '★', '★', '★'].map((_, i) => (i < review.rating ? '★' : '☆')).join('')}
|
||||
</span>
|
||||
</div>
|
||||
{#if review.comment}
|
||||
<p class="review-comment">{review.comment}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 1.5rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
border-color: var(--border-hover, #444);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
}
|
||||
|
||||
.deck-subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
}
|
||||
|
||||
.muted,
|
||||
.error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
}
|
||||
|
||||
.reviews-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.review-item {
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
}
|
||||
|
||||
.review-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.review-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.review-avatar-wrap {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.review-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.review-avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg, #1a1a1a);
|
||||
border: 1px solid var(--border, #333);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.review-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.review-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.reviewer-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
}
|
||||
|
||||
.review-stars {
|
||||
color: #eab308;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.review-comment {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
+66
-15
@@ -4,6 +4,7 @@
|
||||
import { fetchDeckWithQuestions, updateDeck, togglePublished, deleteDeck } from '../lib/api/decks.js';
|
||||
import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js';
|
||||
import QuestionEditor from '../lib/QuestionEditor.svelte';
|
||||
import ConfirmModal from '../lib/ConfirmModal.svelte';
|
||||
|
||||
export let params = {};
|
||||
$: deckId = params?.id;
|
||||
@@ -16,6 +17,8 @@
|
||||
let loading = true;
|
||||
let saving = false;
|
||||
let error = null;
|
||||
let showDeleteConfirm = false;
|
||||
let isCopiedDeck = false;
|
||||
|
||||
$: userId = $auth.user?.id;
|
||||
|
||||
@@ -41,6 +44,7 @@
|
||||
description = deck.description ?? '';
|
||||
config = { ...DEFAULT_DECK_CONFIG, ...(deck.config || {}) };
|
||||
published = !!deck.published;
|
||||
isCopiedDeck = !!deck.copied_from_deck_id;
|
||||
questions = (deck.questions || []).map((q) => ({
|
||||
prompt: q.prompt ?? '',
|
||||
explanation: q.explanation ?? '',
|
||||
@@ -53,6 +57,7 @@
|
||||
} catch (e) {
|
||||
error = e?.message ?? 'Failed to load deck';
|
||||
questions = [{ prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] }];
|
||||
isCopiedDeck = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -120,8 +125,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!deckId || !confirm('Delete this deck? This cannot be undone.')) return;
|
||||
function openDeleteConfirm() {
|
||||
if (!deckId) return;
|
||||
showDeleteConfirm = true;
|
||||
}
|
||||
|
||||
function closeDeleteConfirm() {
|
||||
showDeleteConfirm = false;
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!deckId) return;
|
||||
closeDeleteConfirm();
|
||||
try {
|
||||
await deleteDeck(deckId);
|
||||
push('/');
|
||||
@@ -135,18 +150,20 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h1>Edit deck</h1>
|
||||
<header class="page-header page-header-fixed">
|
||||
<div class="page-header-inner">
|
||||
<h1 class="page-header-title">Edit deck</h1>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn btn-danger" onclick={remove} disabled={saving}>Delete</button>
|
||||
<button type="button" class="btn btn-danger" onclick={openDeleteConfirm} disabled={saving}>Delete</button>
|
||||
<button type="button" class="btn btn-secondary" onclick={cancel} disabled={saving}>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick={save} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page page-with-fixed-header">
|
||||
{#if $auth.loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if !userId}
|
||||
@@ -155,12 +172,14 @@
|
||||
<p class="muted">Loading…</p>
|
||||
{:else}
|
||||
<form class="deck-form" onsubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<div class="publish-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" checked={published} onchange={togglePublish} />
|
||||
Published (visible in Community)
|
||||
</label>
|
||||
</div>
|
||||
{#if !isCopiedDeck}
|
||||
<div class="publish-row">
|
||||
<label class="toggle-label">
|
||||
<input type="checkbox" checked={published} onchange={togglePublish} />
|
||||
Published (visible in Community)
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input id="title" type="text" class="input" bind:value={title} placeholder="Deck title" required />
|
||||
@@ -181,6 +200,17 @@
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<ConfirmModal
|
||||
open={showDeleteConfirm}
|
||||
title="Delete deck"
|
||||
message="Delete this deck? This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
variant="danger"
|
||||
onConfirm={confirmRemove}
|
||||
onCancel={closeDeleteConfirm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -190,16 +220,37 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-with-fixed-header {
|
||||
padding-top: calc(var(--navbar-height, 60px) + 3.5rem);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.page-header-fixed {
|
||||
position: fixed;
|
||||
top: var(--navbar-height, 60px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
background: var(--bg, #0f0f0f);
|
||||
border-bottom: 1px solid var(--border, #333);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.page-header-inner {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
.page-header-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
|
||||
+243
-31
@@ -1,14 +1,48 @@
|
||||
<script>
|
||||
import { push } from 'svelte-spa-router';
|
||||
import { auth } from '../lib/stores/auth.js';
|
||||
import { fetchMyDecks } from '../lib/api/decks.js';
|
||||
import { fetchMyDecks, togglePublished, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js';
|
||||
import RatingModal from '../lib/RatingModal.svelte';
|
||||
import ConfirmModal from '../lib/ConfirmModal.svelte';
|
||||
|
||||
let decks = [];
|
||||
let loading = true;
|
||||
let error = null;
|
||||
let ratingDeck = null;
|
||||
let ratingInitial = { rating: 0, comment: '' };
|
||||
let showUnpublishConfirm = false;
|
||||
let deckToUnpublish = null;
|
||||
|
||||
$: userId = $auth.user?.id;
|
||||
|
||||
function formatRating(n) {
|
||||
return Number(n).toFixed(1);
|
||||
}
|
||||
|
||||
async function openRatingModal(deck, e) {
|
||||
e?.stopPropagation();
|
||||
if (!deck.can_rate || !deck.rateable_deck_id || !userId) return;
|
||||
ratingDeck = { id: deck.rateable_deck_id, title: deck.source_deck_title ?? 'Deck' };
|
||||
try {
|
||||
const data = await getMyDeckRating(deck.rateable_deck_id, userId);
|
||||
ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' };
|
||||
} catch (_) {
|
||||
ratingInitial = { rating: 0, comment: '' };
|
||||
}
|
||||
}
|
||||
|
||||
function closeRatingModal() {
|
||||
ratingDeck = null;
|
||||
}
|
||||
|
||||
async function handleRatingSubmit(payload) {
|
||||
if (!ratingDeck || !userId) return;
|
||||
try {
|
||||
await submitDeckRating(ratingDeck.id, userId, payload);
|
||||
await load();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
let prevUserId = null;
|
||||
$: if (!$auth.loading && userId && userId !== prevUserId) {
|
||||
prevUserId = userId;
|
||||
@@ -32,19 +66,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
function goCreate() {
|
||||
push('/decks/new');
|
||||
}
|
||||
|
||||
function goEdit(id) {
|
||||
push(`/decks/${id}/edit`);
|
||||
}
|
||||
|
||||
let togglingId = null;
|
||||
async function handlePublish(e, deck) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (togglingId === deck.id) return;
|
||||
togglingId = deck.id;
|
||||
try {
|
||||
await togglePublished(deck.id, true);
|
||||
decks = decks.map((d) => (d.id === deck.id ? { ...d, published: true } : d));
|
||||
} catch (err) {
|
||||
error = err?.message ?? 'Failed to update publish status';
|
||||
} finally {
|
||||
togglingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openUnpublishConfirm(e, deck) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
deckToUnpublish = deck;
|
||||
showUnpublishConfirm = true;
|
||||
}
|
||||
|
||||
function closeUnpublishConfirm() {
|
||||
showUnpublishConfirm = false;
|
||||
deckToUnpublish = null;
|
||||
}
|
||||
|
||||
async function confirmUnpublish() {
|
||||
if (!deckToUnpublish) return;
|
||||
const deck = deckToUnpublish;
|
||||
closeUnpublishConfirm();
|
||||
togglingId = deck.id;
|
||||
try {
|
||||
await togglePublished(deck.id, false);
|
||||
decks = decks.map((d) => (d.id === deck.id ? { ...d, published: false } : d));
|
||||
} catch (err) {
|
||||
error = err?.message ?? 'Failed to update publish status';
|
||||
} finally {
|
||||
togglingId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h1>My decks</h1>
|
||||
<button type="button" class="btn btn-primary" onclick={goCreate}>Create deck</button>
|
||||
</header>
|
||||
|
||||
{#if $auth.loading}
|
||||
@@ -58,34 +130,100 @@
|
||||
{:else if decks.length === 0}
|
||||
<p class="muted">No decks yet. Create your first deck to get started.</p>
|
||||
{:else}
|
||||
<ul class="deck-list">
|
||||
<ul class="deck-grid">
|
||||
{#each decks as deck (deck.id)}
|
||||
<li class="deck-card">
|
||||
<div class="deck-card-main" onclick={() => goEdit(deck.id)} onkeydown={(e) => e.key === 'Enter' && goEdit(deck.id)} role="button" tabindex="0">
|
||||
<div
|
||||
class="deck-card-inner"
|
||||
onclick={() => goEdit(deck.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && goEdit(deck.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<h3 class="deck-title">{deck.title}</h3>
|
||||
{#if deck.description}
|
||||
<p class="deck-description">{deck.description}</p>
|
||||
{/if}
|
||||
<div class="deck-meta">
|
||||
<span class="deck-count">{deck.question_count ?? 0} questions</span>
|
||||
{#if deck.published}
|
||||
<span class="badge published">Published</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="deck-card-actions">
|
||||
<button type="button" class="btn btn-small" onclick={() => goEdit(deck.id)}>Edit</button>
|
||||
{#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}
|
||||
<div class="deck-card-actions">
|
||||
{#if !deck.copied_from_deck_id}
|
||||
{#if deck.published}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-small btn-published"
|
||||
onclick={(e) => openUnpublishConfirm(e, deck)}
|
||||
disabled={togglingId === deck.id}
|
||||
title="Click to unpublish"
|
||||
>
|
||||
{togglingId === deck.id ? '…' : 'Published'}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-small btn-secondary"
|
||||
onclick={(e) => handlePublish(e, deck)}
|
||||
disabled={togglingId === deck.id}
|
||||
>
|
||||
{togglingId === deck.id ? '…' : 'Publish'}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<RatingModal
|
||||
deck={ratingDeck}
|
||||
initialRating={ratingInitial.rating}
|
||||
initialComment={ratingInitial.comment}
|
||||
onSubmit={handleRatingSubmit}
|
||||
onClose={closeRatingModal}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={showUnpublishConfirm}
|
||||
title="Unpublish deck"
|
||||
message="Unpublish this deck? It will no longer be visible in Community."
|
||||
confirmLabel="Unpublish"
|
||||
cancelLabel="Cancel"
|
||||
variant="danger"
|
||||
onConfirm={confirmUnpublish}
|
||||
onCancel={closeUnpublishConfirm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 1.5rem;
|
||||
max-width: 900px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -115,31 +253,41 @@
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.deck-list {
|
||||
.deck-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.deck-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--card-bg, #1a1a1a);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.deck-card-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.deck-card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 140px;
|
||||
padding: 1.25rem;
|
||||
background: var(--card-bg, #1a1a1a);
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.deck-card-inner:hover {
|
||||
border-color: var(--border-hover, #444);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.deck-card-inner:focus {
|
||||
outline: 2px solid var(--accent, #3b82f6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.deck-title {
|
||||
@@ -154,6 +302,11 @@
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deck-meta {
|
||||
@@ -162,6 +315,35 @@
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.deck-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
margin-bottom: 0.25rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.deck-rating.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deck-rating.clickable:hover {
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.deck-rating .stars {
|
||||
color: #eab308;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.deck-rating .rating-count {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -177,7 +359,15 @@
|
||||
}
|
||||
|
||||
.deck-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -208,4 +398,26 @@
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-published {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.btn-published:hover:not(:disabled) {
|
||||
background: rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem;
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
.btn-icon svg {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { push } from 'svelte-spa-router';
|
||||
import { auth } from '../lib/stores/auth.js';
|
||||
import { getProfile, updateProfile, uploadAvatar } from '../lib/api/profile.js';
|
||||
|
||||
let profile = null;
|
||||
let loading = true;
|
||||
let saving = false;
|
||||
let error = null;
|
||||
let success = null;
|
||||
let displayName = '';
|
||||
let avatarFile = null;
|
||||
let avatarPreview = null;
|
||||
|
||||
$: userId = $auth.user?.id;
|
||||
|
||||
$: if (userId && !profile && !loading) load();
|
||||
|
||||
$: if (!$auth.loading && !userId) push('/');
|
||||
|
||||
onMount(() => {
|
||||
if (userId) load();
|
||||
else if (!$auth.loading) loading = false;
|
||||
});
|
||||
|
||||
async function load() {
|
||||
if (!userId) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
profile = await getProfile(userId);
|
||||
displayName = profile?.display_name ?? '';
|
||||
avatarPreview = profile?.avatar_url ?? null;
|
||||
} catch (e) {
|
||||
error = e?.message ?? 'Failed to load profile';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAvatarChange(e) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error = 'Please choose an image file (JPEG, PNG, GIF, or WebP).';
|
||||
return;
|
||||
}
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
error = 'Image must be under 2 MB.';
|
||||
return;
|
||||
}
|
||||
error = null;
|
||||
avatarFile = file;
|
||||
avatarPreview = URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
function clearAvatar() {
|
||||
avatarFile = null;
|
||||
if (avatarPreview && avatarPreview.startsWith('blob:')) URL.revokeObjectURL(avatarPreview);
|
||||
avatarPreview = null;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!userId) return;
|
||||
saving = true;
|
||||
error = null;
|
||||
success = null;
|
||||
try {
|
||||
let avatarUrl = profile?.avatar_url ?? null;
|
||||
if (avatarFile) {
|
||||
avatarUrl = await uploadAvatar(userId, avatarFile);
|
||||
} else if (avatarPreview === null && profile?.avatar_url) {
|
||||
avatarUrl = null;
|
||||
}
|
||||
await updateProfile(userId, {
|
||||
display_name: displayName.trim() || null,
|
||||
avatar_url: avatarUrl,
|
||||
});
|
||||
profile = await getProfile(userId);
|
||||
displayName = profile?.display_name ?? '';
|
||||
avatarPreview = profile?.avatar_url ?? null;
|
||||
avatarFile = null;
|
||||
success = 'Settings saved.';
|
||||
window.dispatchEvent(new CustomEvent('profile-updated', { detail: profile }));
|
||||
} catch (e) {
|
||||
error = e?.message ?? 'Failed to save';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancel(e) {
|
||||
if (e) e.preventDefault();
|
||||
push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h1>Settings</h1>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn btn-secondary" onclick={cancel} disabled={saving}>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick={save} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if $auth.loading || (userId && loading)}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if !userId}
|
||||
<p class="auth-required">Sign in to manage your settings.</p>
|
||||
{:else}
|
||||
<form class="settings-form" onsubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<div class="form-group">
|
||||
<label for="display-name">Username</label>
|
||||
<input
|
||||
id="display-name"
|
||||
type="text"
|
||||
class="input"
|
||||
bind:value={displayName}
|
||||
placeholder="Display name"
|
||||
maxlength="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Avatar</label>
|
||||
<div class="avatar-row">
|
||||
<div class="avatar-wrap">
|
||||
<label for="avatar-input" class="avatar-clickable" title="Choose image">
|
||||
<div class="avatar-preview-wrap">
|
||||
{#if avatarPreview}
|
||||
<img src={avatarPreview} alt="Avatar" class="avatar-preview" />
|
||||
{:else}
|
||||
<div class="avatar-placeholder">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
id="avatar-input"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
onchange={onAvatarChange}
|
||||
class="sr-only"
|
||||
/>
|
||||
<div class="avatar-edge-actions">
|
||||
<button type="button" class="avatar-edge-btn" title="Remove" onclick={clearAvatar} disabled={!avatarPreview && !avatarFile}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-hint">JPEG, PNG, GIF or WebP. Max 2 MB.</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
{#if success}
|
||||
<p class="success">{success}</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
padding: 1.5rem;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-required,
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg, #1e1e1e);
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
}
|
||||
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-clickable {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar-preview-wrap {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avatar-preview {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--border, #333);
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg, #1e1e1e);
|
||||
border: 2px solid var(--border, #333);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
}
|
||||
|
||||
.avatar-edge-actions {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.avatar-edge-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 50%;
|
||||
background: var(--card-bg, #1a1a1a);
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background 0.2s, border-color 0.2s;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.avatar-edge-btn:hover:not(:disabled) {
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
background: var(--hover-bg, #2a2a2a);
|
||||
border-color: var(--border-hover, #444);
|
||||
}
|
||||
|
||||
.avatar-edge-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.avatar-edge-btn svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin: 0.35rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted, #888);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg, #1e1e1e);
|
||||
color: var(--text-primary, #f0f0f0);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--hover-bg, #2a2a2a);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent, #3b82f6);
|
||||
border-color: var(--accent, #3b82f6);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.success {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: #22c55e;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user