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
+104
View File
@@ -0,0 +1,104 @@
<script>
/** @type {boolean} */
export let open = false;
/** @type {string} */
export let title = 'Notice';
/** @type {string} */
export let message = '';
/** @type {string} */
export let okLabel = 'OK';
export let onClose = () => {};
function handleBackdropClick(e) {
if (e.target === e.currentTarget) onClose();
}
function handleKeydown(e) {
if (e.key === 'Escape' || e.key === 'Enter') onClose();
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal-backdrop"
role="alertdialog"
aria-modal="true"
aria-labelledby="alert-modal-title"
aria-describedby="alert-modal-desc"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h2 id="alert-modal-title" class="modal-title">{title}</h2>
<p id="alert-modal-desc" class="modal-message">{message}</p>
<div class="modal-actions">
<button type="button" class="btn btn-primary" onclick={onClose}>
{okLabel}
</button>
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 12px;
padding: 1.5rem;
max-width: 400px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal-title {
margin: 0 0 0.75rem 0;
font-size: 1.15rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.modal-message {
margin: 0 0 1.25rem 0;
font-size: 0.95rem;
color: var(--text-muted, #a0a0a0);
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
}
.btn {
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
border: 1px solid transparent;
}
.btn-primary {
background: var(--accent, #3b82f6);
border-color: var(--accent, #3b82f6);
color: #fff;
}
.btn-primary:hover {
background: #2563eb;
border-color: #2563eb;
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<script>
/** @type {boolean} */
export let open = false;
/** @type {string} */
export let title = 'Confirm';
/** @type {string} */
export let message = '';
/** @type {string} */
export let confirmLabel = 'Confirm';
/** @type {string} */
export let cancelLabel = 'Cancel';
/** @type {'danger' | 'primary'} */
export let variant = 'primary';
export let onConfirm = () => {};
export let onCancel = () => {};
function handleBackdropClick(e) {
if (e.target === e.currentTarget) onCancel();
}
function handleConfirm() {
onConfirm();
}
function handleCancel() {
onCancel();
}
function handleKeydown(e) {
if (e.key === 'Escape') onCancel();
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
aria-describedby="confirm-modal-desc"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h2 id="confirm-modal-title" class="modal-title">{title}</h2>
<p id="confirm-modal-desc" class="modal-message">{message}</p>
<div class="modal-actions">
<button type="button" class="btn btn-ghost" onclick={handleCancel}>
{cancelLabel}
</button>
<button
type="button"
class="btn"
class:btn-danger={variant === 'danger'}
class:btn-primary={variant === 'primary'}
onclick={handleConfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 12px;
padding: 1.5rem;
max-width: 400px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal-title {
margin: 0 0 0.75rem 0;
font-size: 1.15rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.modal-message {
margin: 0 0 1.25rem 0;
font-size: 0.95rem;
color: var(--text-muted, #a0a0a0);
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
border: 1px solid transparent;
}
.btn-ghost {
background: transparent;
border-color: var(--border, #333);
color: var(--text-muted, #a0a0a0);
}
.btn-ghost:hover {
color: var(--text-primary, #f0f0f0);
border-color: var(--border-hover, #444);
}
.btn-primary {
background: var(--accent, #3b82f6);
border-color: var(--accent, #3b82f6);
color: #fff;
}
.btn-primary:hover {
background: #2563eb;
border-color: #2563eb;
}
.btn-danger {
background: #dc2626;
border-color: #dc2626;
color: #fff;
}
.btn-danger:hover {
background: #b91c1c;
border-color: #b91c1c;
}
</style>
+250 -10
View File
@@ -1,19 +1,26 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { push } from 'svelte-spa-router';
import { auth } from './stores/auth.js';
import { getProfile } from './api/profile.js';
let showPopup = false;
let mode = 'login'; // 'login' | 'register'
let email = '';
let username = '';
let password = '';
let submitting = false;
let registerSuccess = false;
let userMenuOpen = false;
let profile = null;
let profileLoading = false;
function openPopup() {
auth.clearError();
showPopup = true;
mode = 'login';
email = '';
username = '';
password = '';
registerSuccess = false;
}
@@ -21,6 +28,7 @@
function closePopup() {
showPopup = false;
email = '';
username = '';
password = '';
registerSuccess = false;
}
@@ -37,7 +45,7 @@
const ok = await auth.login(email.trim(), password);
if (ok) closePopup();
} else {
const result = await auth.register(email.trim(), password);
const result = await auth.register(email.trim(), password, username.trim());
if (result?.success) {
if (result.needsConfirmation) {
registerSuccess = true;
@@ -50,26 +58,111 @@
}
function handleLogout() {
userMenuOpen = false;
auth.logout();
push('/');
}
$: displayName = $auth.user?.user_metadata?.name ?? $auth.user?.email ?? null;
function goSettings(e) {
if (e) e.preventDefault();
userMenuOpen = false;
push('/settings');
}
function toggleUserMenu() {
userMenuOpen = !userMenuOpen;
if (userMenuOpen && $auth.user?.id && !profileLoading) {
profileLoading = true;
getProfile($auth.user.id).then((p) => {
profile = p;
profileLoading = false;
}).catch(() => { profileLoading = false; });
}
}
function handleClickOutside(e) {
if (userMenuOpen && !e.target.closest('.user-menu-wrap')) userMenuOpen = false;
}
$: userId = $auth.user?.id;
$: if (!$auth.user) {
profile = null;
userMenuOpen = false;
}
function onProfileUpdated(e) {
const p = e?.detail;
if (p && p.id && $auth.user?.id && p.id === $auth.user.id) {
profile = p;
}
}
onMount(() => {
window.addEventListener('profile-updated', onProfileUpdated);
});
onDestroy(() => {
window.removeEventListener('profile-updated', onProfileUpdated);
});
</script>
<svelte:window on:click={handleClickOutside} />
<nav class="navbar">
<div class="nav-left">
<a href="/" class="app-name" onclick={(e) => { e.preventDefault(); push('/'); }}>Omotomo</a>
<a href="/" class="app-brand" onclick={(e) => { e.preventDefault(); push('/'); }}>
<img src="/omotomo.png" alt="Omotomo" class="app-logo" width="36" height="36" />
<span class="app-name">Omotomo</span>
</a>
{#if $auth.user}
<a href="/" class="nav-link" onclick={(e) => { e.preventDefault(); push('/'); }}>My decks</a>
{/if}
<a href="/community" class="nav-link" onclick={(e) => { e.preventDefault(); push('/community'); }}>Community</a>
</div>
<div class="nav-actions">
{#if $auth.user}
<button type="button" class="btn btn-primary" onclick={() => push('/decks/new')}>Create deck</button>
{/if}
{#if $auth.loading}
<span class="username"></span>
{:else if $auth.user}
<span class="username">{displayName}</span>
<button type="button" class="btn btn-logout" onclick={handleLogout}>Logout</button>
<div class="user-menu-wrap">
<button
type="button"
class="user-menu-trigger"
onclick={toggleUserMenu}
aria-haspopup="true"
aria-expanded={userMenuOpen}
aria-label="User menu"
>
{#if profile?.avatar_url}
<img src={profile.avatar_url} alt="" class="user-avatar" width="32" height="32" />
{:else}
<span class="user-icon" 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" stroke-linecap="round" stroke-linejoin="round">
<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>
</span>
{/if}
</button>
{#if userMenuOpen}
<div class="user-dropdown" role="menu">
{#if profile}
<div class="user-dropdown-header">
<span class="user-dropdown-name">{profile.display_name || profile.email || 'User'}</span>
<span class="user-dropdown-email">{profile.email}</span>
</div>
{/if}
<a href="/settings" class="user-dropdown-item" role="menuitem" onclick={goSettings}>
Settings
</a>
<button type="button" class="user-dropdown-item user-dropdown-item-logout" role="menuitem" onclick={handleLogout}>
Logout
</button>
</div>
{/if}
</div>
{:else}
<button type="button" class="btn btn-login" onclick={openPopup}>Login</button>
{/if}
@@ -80,7 +173,7 @@
<div class="popup-backdrop" onclick={handleBackdropClick} role="presentation">
<div class="popup" role="dialog" aria-modal="true" aria-labelledby="auth-popup-title">
<div class="popup-header">
<h2 id="auth-popup-title" class="popup-title">Sign in</h2>
<h2 id="auth-popup-title" class="popup-title">{mode === 'login' ? 'Sign in' : 'Register'}</h2>
<button type="button" class="popup-close" onclick={closePopup} aria-label="Close">×</button>
</div>
@@ -110,13 +203,23 @@
{:else}
<form class="popup-form" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<input
type="email"
type={mode === 'login' ? 'text' : 'email'}
bind:value={email}
placeholder="Email"
placeholder={mode === 'login' ? 'Email or username' : 'Email'}
class="input"
disabled={submitting}
autocomplete={mode === 'login' ? 'email' : 'email'}
autocomplete={mode === 'login' ? 'username' : 'email'}
/>
{#if mode === 'register'}
<input
type="text"
bind:value={username}
placeholder="Username"
class="input"
disabled={submitting}
autocomplete="username"
/>
{/if}
<input
type="password"
bind:value={password}
@@ -127,6 +230,11 @@
/>
{#if $auth.error}
<p class="auth-error">{$auth.error}</p>
{#if mode === 'login'}
<p class="auth-switch">
Do you have an account? <button type="button" class="auth-link" onclick={() => { mode = 'register'; auth.clearError(); }}>Register here</button>.
</p>
{/if}
{/if}
<button
type="submit"
@@ -165,6 +273,21 @@
gap: 1.25rem;
}
.app-brand {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.app-logo {
display: block;
width: 36px;
height: 36px;
object-fit: contain;
border-radius: 6px;
}
.app-name {
font-size: 1.25rem;
font-weight: 600;
@@ -172,7 +295,7 @@
text-decoration: none;
}
.app-name:hover {
.app-brand:hover .app-name {
color: var(--text-primary, #f0f0f0);
}
@@ -197,6 +320,102 @@
color: var(--text-muted, #a0a0a0);
}
.user-menu-wrap {
position: relative;
}
.user-menu-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--card-bg, #1e1e1e);
color: var(--text-muted, #a0a0a0);
cursor: pointer;
transition: color 0.2s, background 0.2s, box-shadow 0.2s;
}
.user-menu-trigger:hover {
color: var(--text-primary, #f0f0f0);
background: var(--hover-bg, #2a2a2a);
}
.user-menu-trigger:focus {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 2px;
}
.user-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.user-icon {
display: flex;
align-items: center;
justify-content: center;
}
.user-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 200px;
padding: 0.5rem 0;
background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 200;
}
.user-dropdown-header {
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border, #333);
margin-bottom: 0.25rem;
}
.user-dropdown-name {
display: block;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary, #f0f0f0);
}
.user-dropdown-email {
display: block;
font-size: 0.8rem;
color: var(--text-muted, #a0a0a0);
}
.user-dropdown-item {
display: block;
width: 100%;
padding: 0.5rem 1rem;
font-size: 0.9rem;
text-align: left;
border: none;
background: none;
color: var(--text-primary, #f0f0f0);
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
.user-dropdown-item:hover {
background: var(--hover-bg, #2a2a2a);
}
.user-dropdown-item-logout {
color: var(--text-muted, #a0a0a0);
}
.btn {
padding: 0.5rem 1rem;
font-size: 0.9rem;
@@ -358,6 +577,27 @@
color: #ef4444;
}
.auth-switch {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
}
.auth-link {
padding: 0;
font-size: inherit;
font-weight: 500;
border: none;
background: none;
color: var(--accent, #3b82f6);
cursor: pointer;
text-decoration: underline;
}
.auth-link:hover {
color: #60a5fa;
}
.auth-message {
margin: 0;
font-size: 0.9rem;
+182
View File
@@ -0,0 +1,182 @@
<script>
/** @type {{ id: string, title: string } | null} */
export let deck = null;
/** @type {number} 1-5 */
export let initialRating = 0;
/** @type {string} */
export let initialComment = '';
export let onSubmit = () => {};
export let onClose = () => {};
let selectedStars = 0;
let comment = '';
$: if (deck) {
selectedStars = initialRating || 0;
comment = initialComment ?? '';
}
function setStars(n) {
selectedStars = n;
}
function handleSubmit() {
if (selectedStars < 1) return;
onSubmit({ rating: selectedStars, comment: comment.trim() || null });
onClose();
}
function handleBackdropClick(e) {
if (e.target === e.currentTarget) onClose();
}
</script>
{#if deck}
<div class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="rating-modal-title" onclick={handleBackdropClick}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h2 id="rating-modal-title" class="modal-title">Rate: {deck.title}</h2>
<div class="stars-row">
{#each [1, 2, 3, 4, 5] as n}
<button
type="button"
class="star-btn"
aria-label="{n} star{n === 1 ? '' : 's'}"
onclick={() => setStars(n)}
>
{n <= selectedStars ? '★' : '☆'}
</button>
{/each}
</div>
<label class="comment-label" for="rating-comment">Comment (optional)</label>
<textarea
id="rating-comment"
class="comment-input"
placeholder="Add a comment…"
bind:value={comment}
rows="3"
/>
<div class="modal-actions">
<button type="button" class="btn btn-ghost" onclick={onClose}>Cancel</button>
<button type="button" class="btn btn-primary" onclick={handleSubmit} disabled={selectedStars < 1}>
Submit
</button>
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 12px;
padding: 1.5rem;
max-width: 400px;
width: 100%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal-title {
margin: 0 0 1rem 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.stars-row {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
}
.star-btn {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
font-size: 1.75rem;
line-height: 1;
color: #eab308;
transition: transform 0.15s;
}
.star-btn:hover {
transform: scale(1.15);
}
.comment-label {
display: block;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
margin-bottom: 0.35rem;
}
.comment-input {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.95rem;
font-family: inherit;
color: var(--text-primary, #f0f0f0);
background: var(--input-bg, #252525);
border: 1px solid var(--border, #333);
border-radius: 8px;
resize: vertical;
margin-bottom: 1.25rem;
box-sizing: border-box;
}
.comment-input::placeholder {
color: var(--text-muted, #666);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
}
.btn-ghost {
background: transparent;
border: 1px solid var(--border, #333);
color: var(--text-muted, #a0a0a0);
}
.btn-ghost:hover {
color: var(--text-primary, #f0f0f0);
border-color: var(--border-hover, #444);
}
.btn-primary {
background: var(--accent, #3b82f6);
border: 1px solid var(--accent, #3b82f6);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<script>
/** @type {boolean} */
export let open = false;
/** @type {string} */
export let deckTitle = '';
/** @type {{ rating: number, comment: string | null, display_name: string | null, email: string | null }[]} */
export let reviews = [];
/** @type {boolean} */
export let loading = false;
export let onClose = () => {};
function handleBackdropClick(e) {
if (e.target === e.currentTarget) onClose();
}
function handleKeydown(e) {
if (e.key === 'Escape') onClose();
}
function reviewerName(r) {
return (r.display_name && r.display_name.trim()) || r.email || 'Anonymous';
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="reviews-modal-title"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h2 id="reviews-modal-title" class="modal-title">Reviews: {deckTitle}</h2>
{#if loading}
<p class="modal-message">Loading…</p>
{:else if reviews.length === 0}
<p class="modal-message">No reviews yet.</p>
{:else}
<ul class="reviews-list">
{#each reviews as review (review.user_id)}
<li class="review-item">
<div class="review-header">
<span class="review-stars" aria-label="{review.rating} out of 5 stars">
{['★', '★', '★', '★', '★'].map((_, i) => (i < review.rating ? '★' : '☆')).join('')}
</span>
<span class="reviewer-name">{reviewerName(review)}</span>
</div>
{#if review.comment}
<p class="review-comment">{review.comment}</p>
{/if}
</li>
{/each}
</ul>
{/if}
<div class="modal-actions">
<button type="button" class="btn btn-primary" onclick={onClose}>Close</button>
</div>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal {
background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 12px;
padding: 1.5rem;
max-width: 440px;
width: 100%;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal-title {
margin: 0 0 1rem 0;
font-size: 1.15rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
flex-shrink: 0;
}
.modal-message {
margin: 0 0 1.25rem 0;
font-size: 0.95rem;
color: var(--text-muted, #a0a0a0);
line-height: 1.5;
}
.reviews-list {
list-style: none;
margin: 0 0 1.25rem 0;
padding: 0;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.review-item {
padding: 0.75rem 0;
border-bottom: 1px solid var(--border, #333);
}
.review-item:last-child {
border-bottom: none;
}
.review-header {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.review-stars {
color: #eab308;
letter-spacing: 0.05em;
font-size: 1rem;
}
.reviewer-name {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary, #f0f0f0);
}
.review-comment {
margin: 0.35rem 0 0 0;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
line-height: 1.45;
}
.modal-actions {
flex-shrink: 0;
}
.btn {
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
border: 1px solid transparent;
}
.btn-primary {
background: var(--accent, #3b82f6);
border-color: var(--accent, #3b82f6);
color: #fff;
}
.btn-primary:hover {
background: #2563eb;
border-color: #2563eb;
}
</style>
+316 -31
View File
@@ -3,43 +3,238 @@ import { supabase } from '../supabase.js';
export async function fetchMyDecks(userId) {
const { data, error } = await supabase
.from('decks')
.select('id, title, description, config, published, created_at, updated_at')
.select('id, title, description, config, published, created_at, updated_at, copied_from_deck_id')
.eq('owner_id', userId)
.order('updated_at', { ascending: false });
if (error) throw error;
const decks = data ?? [];
const questionCounts = await Promise.all(
decks.map(async (d) => {
const { count, error: e } = await supabase
.from('questions')
.select('*', { count: 'exact', head: true })
.eq('deck_id', d.id);
if (e) return 0;
return count ?? 0;
})
);
return decks.map((d, i) => ({ ...d, question_count: questionCounts[i] }));
if (decks.length === 0) return [];
const deckIds = decks.map((d) => d.id);
const sourceDeckIds = [...new Set(decks.map((d) => d.copied_from_deck_id).filter(Boolean))];
const ratingDeckIds = [...new Set([...deckIds, ...sourceDeckIds])];
const [questionCounts, ratingsRows, sourceTitlesRows] = await Promise.all([
Promise.all(
decks.map(async (d) => {
const { count, error: e } = await supabase
.from('questions')
.select('*', { count: 'exact', head: true })
.eq('deck_id', d.id);
if (e) return 0;
return count ?? 0;
})
),
ratingDeckIds.length > 0
? supabase.from('deck_ratings').select('deck_id, rating').in('deck_id', ratingDeckIds)
: Promise.resolve({ data: [] }),
sourceDeckIds.length > 0
? supabase.from('decks').select('id, title').in('id', sourceDeckIds)
: Promise.resolve({ data: [] }),
]);
const ratingByDeck = new Map();
for (const r of ratingsRows.data ?? []) {
if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []);
ratingByDeck.get(r.deck_id).push(r.rating);
}
const getRating = (deckId) => {
const arr = ratingByDeck.get(deckId);
if (!arr?.length) return { average_rating: 0, rating_count: 0 };
const sum = arr.reduce((a, b) => a + b, 0);
return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length };
};
const sourceTitleById = new Map((sourceTitlesRows.data ?? []).map((d) => [d.id, d.title]));
return decks.map((d, i) => {
const showRatingDeckId = d.copied_from_deck_id || d.id;
const rating = getRating(showRatingDeckId);
const canRate = !!d.copied_from_deck_id;
const source_deck_title = d.copied_from_deck_id ? sourceTitleById.get(d.copied_from_deck_id) ?? 'Deck' : null;
return {
...d,
question_count: questionCounts[i],
...rating,
can_rate: canRate,
rateable_deck_id: d.copied_from_deck_id || null,
source_deck_title,
};
});
}
export async function fetchPublishedDecks() {
/**
* @param {string | null | undefined} [userId] - Current user id; when set, each deck gets user_has_this (true if user owns it or already has a copy).
*/
export async function fetchPublishedDecks(userId) {
const { data, error } = await supabase
.from('decks')
.select('id, title, description, config, published, created_at, updated_at')
.select('id, title, description, config, published, created_at, updated_at, owner_id')
.eq('published', true)
.order('updated_at', { ascending: false });
if (error) throw error;
const decks = data ?? [];
const questionCounts = await Promise.all(
decks.map(async (d) => {
const { count, error: e } = await supabase
.from('questions')
.select('*', { count: 'exact', head: true })
.eq('deck_id', d.id);
if (e) return 0;
return count ?? 0;
})
if (decks.length === 0) return [];
const deckIds = decks.map((d) => d.id);
const ownerIds = [...new Set(decks.map((d) => d.owner_id).filter(Boolean))];
const promises = [
Promise.all(
decks.map(async (d) => {
const { count, error: e } = await supabase
.from('questions')
.select('*', { count: 'exact', head: true })
.eq('deck_id', d.id);
if (e) return 0;
return count ?? 0;
})
),
ownerIds.length > 0
? supabase.from('profiles').select('id, display_name, email').in('id', ownerIds)
: Promise.resolve({ data: [] }),
supabase.from('deck_ratings').select('deck_id, rating').in('deck_id', deckIds),
];
if (userId) {
promises.push(
supabase
.from('decks')
.select('copied_from_deck_id')
.eq('owner_id', userId)
.not('copied_from_deck_id', 'is', null)
);
}
const results = await Promise.all(promises);
const [questionCounts, profilesRows, ratingsRows, myDecksRows] = results;
const myCopiedFromIds = new Set();
if (userId && myDecksRows?.data) {
for (const row of myDecksRows.data) {
if (row.copied_from_deck_id) myCopiedFromIds.add(row.copied_from_deck_id);
}
}
const profilesById = new Map(
(profilesRows.data ?? []).map((p) => [p.id, { display_name: p.display_name, email: p.email }])
);
return decks.map((d, i) => ({ ...d, question_count: questionCounts[i] }));
const ratingByDeck = new Map();
for (const r of ratingsRows.data ?? []) {
if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []);
ratingByDeck.get(r.deck_id).push(r.rating);
}
const getRating = (deckId) => {
const arr = ratingByDeck.get(deckId);
if (!arr?.length) return { average_rating: 0, rating_count: 0 };
const sum = arr.reduce((a, b) => a + b, 0);
return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length };
};
return decks.map((d, i) => {
const profile = profilesById.get(d.owner_id);
const owner_email = profile?.email ?? profile?.display_name ?? 'User';
const user_has_this =
!!userId && (d.owner_id === userId || myCopiedFromIds.has(d.id));
return {
...d,
question_count: questionCounts[i],
owner_display_name: profile?.display_name ?? 'User',
owner_email,
...getRating(d.id),
user_has_this,
};
});
}
/**
* @param {string} ownerId - Owner of the decks to list.
* @param {string | null | undefined} [viewerId] - Current user id; when set, each deck gets user_has_this (true if viewer owns it or already has a copy).
*/
export async function fetchPublishedDecksByOwner(ownerId, viewerId) {
const { data: decks, error } = await supabase
.from('decks')
.select('id, title, description, config, published, created_at, updated_at, owner_id')
.eq('published', true)
.eq('owner_id', ownerId)
.order('updated_at', { ascending: false });
if (error) throw error;
const deckList = decks ?? [];
if (deckList.length === 0) {
const { data: profile } = await supabase.from('profiles').select('id, display_name, email').eq('id', ownerId).single();
return {
decks: [],
owner_email: profile?.email ?? profile?.display_name ?? 'User',
owner_display_name: profile?.display_name ?? profile?.email ?? 'User',
};
}
const deckIds = deckList.map((d) => d.id);
const promises = [
Promise.all(
deckList.map(async (d) => {
const { count, error: e } = await supabase
.from('questions')
.select('*', { count: 'exact', head: true })
.eq('deck_id', d.id);
if (e) return 0;
return count ?? 0;
})
),
supabase.from('deck_ratings').select('deck_id, rating').in('deck_id', deckIds),
];
if (viewerId && viewerId !== ownerId) {
promises.push(
supabase
.from('decks')
.select('copied_from_deck_id')
.eq('owner_id', viewerId)
.not('copied_from_deck_id', 'is', null)
);
}
const results = await Promise.all(promises);
const [questionCounts, ratingsRows, myDecksRows] = results;
const myCopiedFromIds = new Set();
if (viewerId && viewerId !== ownerId && myDecksRows?.data) {
for (const row of myDecksRows.data) {
if (row.copied_from_deck_id) myCopiedFromIds.add(row.copied_from_deck_id);
}
}
const ratingByDeck = new Map();
for (const r of ratingsRows.data ?? []) {
if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []);
ratingByDeck.get(r.deck_id).push(r.rating);
}
const getRating = (deckId) => {
const arr = ratingByDeck.get(deckId);
if (!arr?.length) return { average_rating: 0, rating_count: 0 };
const sum = arr.reduce((a, b) => a + b, 0);
return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length };
};
const { data: profile } = await supabase.from('profiles').select('id, display_name, email').eq('id', ownerId).single();
const owner_email = profile?.email ?? profile?.display_name ?? 'User';
const owner_display_name = profile?.display_name ?? profile?.email ?? 'User';
const decksWithMeta = deckList.map((d, i) => {
const user_has_this =
!!viewerId &&
(viewerId === ownerId || myCopiedFromIds.has(d.id));
return {
...d,
question_count: questionCounts[i],
owner_email,
owner_display_name,
...getRating(d.id),
user_has_this,
};
});
return { decks: decksWithMeta, owner_email, owner_display_name };
}
export async function fetchDeckWithQuestions(deckId) {
@@ -58,15 +253,36 @@ export async function fetchDeckWithQuestions(deckId) {
return { ...deck, questions: questions ?? [] };
}
export async function createDeck(ownerId, { title, description, config, questions }) {
/** Copy a published deck (and its questions) into the current user's account. New deck is unpublished. */
export async function copyDeckToUser(deckId, userId) {
const source = await fetchDeckWithQuestions(deckId);
if (!source.published) throw new Error('Deck is not available to copy');
const questions = (source.questions ?? []).map((q) => ({
prompt: q.prompt ?? '',
explanation: q.explanation ?? '',
answers: Array.isArray(q.answers) ? q.answers : [],
correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [],
}));
return createDeck(userId, {
title: source.title,
description: source.description ?? '',
config: source.config ?? {},
questions,
copiedFromDeckId: deckId,
});
}
export async function createDeck(ownerId, { title, description, config, questions, copiedFromDeckId }) {
const row = {
owner_id: ownerId,
title: title.trim(),
description: (description ?? '').trim(),
config: config ?? {},
};
if (copiedFromDeckId != null) row.copied_from_deck_id = copiedFromDeckId;
const { data: deck, error: deckError } = await supabase
.from('decks')
.insert({
owner_id: ownerId,
title: title.trim(),
description: (description ?? '').trim(),
config: config ?? {},
})
.insert(row)
.select('id')
.single();
if (deckError || !deck) throw deckError || new Error('Failed to create deck');
@@ -120,3 +336,72 @@ export async function deleteDeck(deckId) {
const { error } = await supabase.from('decks').delete().eq('id', deckId);
if (error) throw error;
}
/** True if the user already has this deck (owns it or has added a copy). */
export async function userHasDeck(deckId, userId) {
if (!userId) return false;
const { data, error } = await supabase
.from('decks')
.select('id')
.eq('owner_id', userId)
.or(`id.eq.${deckId},copied_from_deck_id.eq.${deckId}`)
.limit(1);
if (error) throw error;
return (data?.length ?? 0) > 0;
}
/** Get all reviews (ratings + comments) for a deck with reviewer display names. */
export async function getDeckReviews(deckId) {
const { data: ratings, error: rError } = await supabase
.from('deck_ratings')
.select('user_id, rating, comment, created_at')
.eq('deck_id', deckId)
.order('created_at', { ascending: false });
if (rError) throw rError;
const list = ratings ?? [];
if (list.length === 0) return [];
const userIds = [...new Set(list.map((r) => r.user_id).filter(Boolean))];
const { data: profiles } = await supabase
.from('profiles')
.select('id, display_name, email, avatar_url')
.in('id', userIds);
const byId = new Map((profiles ?? []).map((p) => [p.id, p]));
return list.map((r) => {
const p = byId.get(r.user_id);
return {
rating: r.rating,
comment: r.comment || null,
user_id: r.user_id,
display_name: p?.display_name ?? null,
email: p?.email ?? null,
avatar_url: p?.avatar_url ?? null,
};
});
}
/** Get the current user's rating (and comment) for a deck, or null if none. */
export async function getMyDeckRating(deckId, userId) {
if (!userId) return null;
const { data, error } = await supabase
.from('deck_ratings')
.select('rating, comment')
.eq('deck_id', deckId)
.eq('user_id', userId)
.maybeSingle();
if (error) throw error;
return data;
}
/** Submit or update rating (and optional comment) for a deck. Upserts by (deck_id, user_id). */
export async function submitDeckRating(deckId, userId, { rating, comment }) {
const row = {
deck_id: deckId,
user_id: userId,
rating: Math.min(5, Math.max(1, Math.round(rating))),
comment: (comment ?? '').trim() || null,
};
const { error } = await supabase.from('deck_ratings').upsert(row, {
onConflict: 'deck_id,user_id',
});
if (error) throw error;
}
+56
View File
@@ -0,0 +1,56 @@
import { supabase } from '../supabase.js';
/** Resolve email for login when user enters a username (display_name). Returns null if not found. */
export async function getEmailForUsername(username) {
const trimmed = (username ?? '').trim();
if (!trimmed) return null;
const { data, error } = await supabase
.from('profiles')
.select('email')
.ilike('display_name', trimmed)
.limit(1);
if (error || !data?.length || !data[0]?.email) return null;
return data[0].email;
}
export async function getProfile(userId) {
const { data, error } = await supabase
.from('profiles')
.select('id, display_name, email, avatar_url')
.eq('id', userId)
.single();
if (error && error.code !== 'PGRST116') throw error;
return data ?? null;
}
export async function updateProfile(userId, { display_name, avatar_url }) {
const updates = {};
if (display_name !== undefined) updates.display_name = display_name?.trim() || null;
if (avatar_url !== undefined) updates.avatar_url = avatar_url || null;
updates.updated_at = new Date().toISOString();
const { data, error } = await supabase
.from('profiles')
.update(updates)
.eq('id', userId)
.select('id, display_name, email, avatar_url')
.single();
if (error) throw error;
return data;
}
export function getAvatarPublicUrl(path) {
const { data } = supabase.storage.from('avatars').getPublicUrl(path);
return data?.publicUrl ?? '';
}
export async function uploadAvatar(userId, file) {
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg';
const path = `${userId}/avatar.${ext}`;
const { error } = await supabase.storage.from('avatars').upload(path, file, {
upsert: true,
contentType: file.type,
});
if (error) throw error;
return getAvatarPublicUrl(path);
}
+26 -3
View File
@@ -1,5 +1,6 @@
import { writable } from 'svelte/store';
import { supabase } from '../supabase.js';
import { getEmailForUsername, updateProfile } from '../api/profile.js';
function createAuthStore() {
const { subscribe, set, update } = writable({
@@ -35,8 +36,21 @@ function createAuthStore() {
setSession(current);
});
},
login: async (email, password) => {
login: async (emailOrUsername, password) => {
update((s) => ({ ...s, error: null }));
let email = (emailOrUsername ?? '').trim();
if (!email) {
update((s) => ({ ...s, error: 'Enter your email or username.' }));
return false;
}
if (!email.includes('@')) {
const resolved = await getEmailForUsername(email);
if (!resolved) {
update((s) => ({ ...s, error: 'No account with that username.' }));
return false;
}
email = resolved;
}
try {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
@@ -50,15 +64,24 @@ function createAuthStore() {
return false;
}
},
register: async (email, password) => {
register: async (email, password, displayName) => {
update((s) => ({ ...s, error: null }));
try {
const { data, error } = await supabase.auth.signUp({ email, password });
const { data, error } = await supabase.auth.signUp({
email,
password,
options: { data: { display_name: (displayName || '').trim() || null } },
});
if (error) {
update((s) => ({ ...s, error: error.message }));
return { success: false };
}
update((s) => ({ ...s, error: null }));
if (data?.user?.id && data?.session && (displayName || '').trim()) {
try {
await updateProfile(data.user.id, { display_name: (displayName || '').trim() });
} catch (_) {}
}
const needsConfirmation = data?.user && !data?.session;
return { success: true, needsConfirmation, data };
} catch (e) {