Deck ratings (comment + popup), remove Published badge from My decks
This commit is contained in:
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user