Deck ratings (comment + popup), remove Published badge from My decks

This commit is contained in:
gitea
2026-02-13 19:28:29 +01:00
parent 9eede674b6
commit 32c786fa24
31 changed files with 3618 additions and 140 deletions
+379 -24
View File
@@ -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>
+353
View File
@@ -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>
+318
View File
@@ -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>
+232
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+383
View File
@@ -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>