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

master
gitea 3 weeks ago
parent 9eede674b6
commit 32c786fa24

@ -0,0 +1,41 @@
# Create the `avatars` storage bucket
If you see **"Bucket not found"** when saving a profile with an uploaded image, the `avatars` bucket does not exist yet. Create it once using one of the methods below.
## Option 1: Supabase Dashboard (recommended)
1. Open your project: **https://supabase.com/dashboard** (or your selfhosted URL, e.g. `https://supa1.satoshinakamoto.win`).
2. Go to **Storage** in the left sidebar.
3. Click **New bucket**.
4. Set:
- **Name:** `avatars` (must be exactly this).
- **Public bucket:** **ON** (so avatar URLs work without signed links).
- **File size limit:** `2` MB (optional).
- **Allowed MIME types:** `image/jpeg`, `image/png`, `image/gif`, `image/webp` (optional).
5. Click **Create bucket**.
Then add policies so users can upload only to their own folder and everyone can read:
1. Open the **avatars** bucket.
2. Go to **Policies** (or **Storage****Policies**).
3. Add policies equivalent to the ones in `supabase/migrations/20250213200000_profiles_avatar_and_storage.sql` (from the line `-- RLS: allow authenticated users...` onward), or run that part of the migration in the SQL Editor.
## Option 2: SQL Editor (if your Supabase allows it)
Run in **SQL Editor**:
```sql
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'avatars',
'avatars',
true,
2097152,
ARRAY['image/jpeg', 'image/png', 'image/gif', 'image/webp']
)
ON CONFLICT (id) DO NOTHING;
```
If you get an error (e.g. column names or schema differ), use **Option 1** instead.
After the bucket exists, run the **storage RLS policies** from `supabase/migrations/20250213200000_profiles_avatar_and_storage.sql` (the `CREATE POLICY` statements for `storage.objects`) so uploads and public read work correctly.

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/png" href="/omotomo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Omotomo</title> <title>Omotomo</title>
</head> </head>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

@ -6,14 +6,22 @@
import MyDecks from './routes/MyDecks.svelte'; import MyDecks from './routes/MyDecks.svelte';
import CreateDeck from './routes/CreateDeck.svelte'; import CreateDeck from './routes/CreateDeck.svelte';
import EditDeck from './routes/EditDeck.svelte'; import EditDeck from './routes/EditDeck.svelte';
import DeckPreview from './routes/DeckPreview.svelte';
import DeckReviews from './routes/DeckReviews.svelte';
import Community from './routes/Community.svelte'; import Community from './routes/Community.svelte';
import CommunityUser from './routes/CommunityUser.svelte';
import Settings from './routes/Settings.svelte';
import { auth } from './lib/stores/auth.js'; import { auth } from './lib/stores/auth.js';
const routes = { const routes = {
'/': MyDecks, '/': MyDecks,
'/decks/new': CreateDeck, '/decks/new': CreateDeck,
'/decks/:id/edit': EditDeck, '/decks/:id/edit': EditDeck,
'/decks/:id/preview': DeckPreview,
'/decks/:id/reviews': DeckReviews,
'/community': Community, '/community': Community,
'/community/user/:id': CommunityUser,
'/settings': Settings,
}; };
onMount(() => { onMount(() => {

@ -19,6 +19,7 @@
--border-hover: #444; --border-hover: #444;
--hover-bg: #222; --hover-bg: #222;
--accent: #3b82f6; --accent: #3b82f6;
--navbar-height: 60px;
} }
*, *,

@ -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>

@ -1,19 +1,26 @@
<script> <script>
import { onMount, onDestroy } from 'svelte';
import { push } from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
import { auth } from './stores/auth.js'; import { auth } from './stores/auth.js';
import { getProfile } from './api/profile.js';
let showPopup = false; let showPopup = false;
let mode = 'login'; // 'login' | 'register' let mode = 'login'; // 'login' | 'register'
let email = ''; let email = '';
let username = '';
let password = ''; let password = '';
let submitting = false; let submitting = false;
let registerSuccess = false; let registerSuccess = false;
let userMenuOpen = false;
let profile = null;
let profileLoading = false;
function openPopup() { function openPopup() {
auth.clearError(); auth.clearError();
showPopup = true; showPopup = true;
mode = 'login'; mode = 'login';
email = ''; email = '';
username = '';
password = ''; password = '';
registerSuccess = false; registerSuccess = false;
} }
@ -21,6 +28,7 @@
function closePopup() { function closePopup() {
showPopup = false; showPopup = false;
email = ''; email = '';
username = '';
password = ''; password = '';
registerSuccess = false; registerSuccess = false;
} }
@ -37,7 +45,7 @@
const ok = await auth.login(email.trim(), password); const ok = await auth.login(email.trim(), password);
if (ok) closePopup(); if (ok) closePopup();
} else { } else {
const result = await auth.register(email.trim(), password); const result = await auth.register(email.trim(), password, username.trim());
if (result?.success) { if (result?.success) {
if (result.needsConfirmation) { if (result.needsConfirmation) {
registerSuccess = true; registerSuccess = true;
@ -50,26 +58,111 @@
} }
function handleLogout() { function handleLogout() {
userMenuOpen = false;
auth.logout(); 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> </script>
<svelte:window on:click={handleClickOutside} />
<nav class="navbar"> <nav class="navbar">
<div class="nav-left"> <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} {#if $auth.user}
<a href="/" class="nav-link" onclick={(e) => { e.preventDefault(); push('/'); }}>My decks</a> <a href="/" class="nav-link" onclick={(e) => { e.preventDefault(); push('/'); }}>My decks</a>
{/if} {/if}
<a href="/community" class="nav-link" onclick={(e) => { e.preventDefault(); push('/community'); }}>Community</a> <a href="/community" class="nav-link" onclick={(e) => { e.preventDefault(); push('/community'); }}>Community</a>
</div> </div>
<div class="nav-actions"> <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} {#if $auth.loading}
<span class="username"></span> <span class="username"></span>
{:else if $auth.user} {:else if $auth.user}
<span class="username">{displayName}</span> <div class="user-menu-wrap">
<button type="button" class="btn btn-logout" onclick={handleLogout}>Logout</button> <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} {:else}
<button type="button" class="btn btn-login" onclick={openPopup}>Login</button> <button type="button" class="btn btn-login" onclick={openPopup}>Login</button>
{/if} {/if}
@ -80,7 +173,7 @@
<div class="popup-backdrop" onclick={handleBackdropClick} role="presentation"> <div class="popup-backdrop" onclick={handleBackdropClick} role="presentation">
<div class="popup" role="dialog" aria-modal="true" aria-labelledby="auth-popup-title"> <div class="popup" role="dialog" aria-modal="true" aria-labelledby="auth-popup-title">
<div class="popup-header"> <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> <button type="button" class="popup-close" onclick={closePopup} aria-label="Close">×</button>
</div> </div>
@ -110,13 +203,23 @@
{:else} {:else}
<form class="popup-form" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}> <form class="popup-form" onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<input <input
type="email" type={mode === 'login' ? 'text' : 'email'}
bind:value={email} bind:value={email}
placeholder="Email" placeholder={mode === 'login' ? 'Email or username' : 'Email'}
class="input" class="input"
disabled={submitting} 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 <input
type="password" type="password"
bind:value={password} bind:value={password}
@ -127,6 +230,11 @@
/> />
{#if $auth.error} {#if $auth.error}
<p class="auth-error">{$auth.error}</p> <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} {/if}
<button <button
type="submit" type="submit"
@ -165,6 +273,21 @@
gap: 1.25rem; 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 { .app-name {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
@ -172,7 +295,7 @@
text-decoration: none; text-decoration: none;
} }
.app-name:hover { .app-brand:hover .app-name {
color: var(--text-primary, #f0f0f0); color: var(--text-primary, #f0f0f0);
} }
@ -197,6 +320,102 @@
color: var(--text-muted, #a0a0a0); 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 { .btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
font-size: 0.9rem; font-size: 0.9rem;
@ -358,6 +577,27 @@
color: #ef4444; 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 { .auth-message {
margin: 0; margin: 0;
font-size: 0.9rem; 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>

@ -3,12 +3,19 @@ import { supabase } from '../supabase.js';
export async function fetchMyDecks(userId) { export async function fetchMyDecks(userId) {
const { data, error } = await supabase const { data, error } = await supabase
.from('decks') .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) .eq('owner_id', userId)
.order('updated_at', { ascending: false }); .order('updated_at', { ascending: false });
if (error) throw error; if (error) throw error;
const decks = data ?? []; const decks = data ?? [];
const questionCounts = await Promise.all( 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) => { decks.map(async (d) => {
const { count, error: e } = await supabase const { count, error: e } = await supabase
.from('questions') .from('questions')
@ -17,19 +24,63 @@ export async function fetchMyDecks(userId) {
if (e) return 0; if (e) return 0;
return count ?? 0; return count ?? 0;
}) })
); ),
return decks.map((d, i) => ({ ...d, question_count: questionCounts[i] })); 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 const { data, error } = await supabase
.from('decks') .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) .eq('published', true)
.order('updated_at', { ascending: false }); .order('updated_at', { ascending: false });
if (error) throw error; if (error) throw error;
const decks = data ?? []; const decks = data ?? [];
const questionCounts = await Promise.all( 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) => { decks.map(async (d) => {
const { count, error: e } = await supabase const { count, error: e } = await supabase
.from('questions') .from('questions')
@ -38,8 +89,152 @@ export async function fetchPublishedDecks() {
if (e) return 0; if (e) return 0;
return count ?? 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 }])
);
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)
); );
return decks.map((d, i) => ({ ...d, question_count: questionCounts[i] })); }
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) { export async function fetchDeckWithQuestions(deckId) {
@ -58,15 +253,36 @@ export async function fetchDeckWithQuestions(deckId) {
return { ...deck, questions: questions ?? [] }; 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. */
const { data: deck, error: deckError } = await supabase export async function copyDeckToUser(deckId, userId) {
.from('decks') const source = await fetchDeckWithQuestions(deckId);
.insert({ 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, owner_id: ownerId,
title: title.trim(), title: title.trim(),
description: (description ?? '').trim(), description: (description ?? '').trim(),
config: config ?? {}, config: config ?? {},
}) };
if (copiedFromDeckId != null) row.copied_from_deck_id = copiedFromDeckId;
const { data: deck, error: deckError } = await supabase
.from('decks')
.insert(row)
.select('id') .select('id')
.single(); .single();
if (deckError || !deck) throw deckError || new Error('Failed to create deck'); 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); const { error } = await supabase.from('decks').delete().eq('id', deckId);
if (error) throw error; 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);
}

@ -1,5 +1,6 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { supabase } from '../supabase.js'; import { supabase } from '../supabase.js';
import { getEmailForUsername, updateProfile } from '../api/profile.js';
function createAuthStore() { function createAuthStore() {
const { subscribe, set, update } = writable({ const { subscribe, set, update } = writable({
@ -35,8 +36,21 @@ function createAuthStore() {
setSession(current); setSession(current);
}); });
}, },
login: async (email, password) => { login: async (emailOrUsername, password) => {
update((s) => ({ ...s, error: null })); 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 { try {
const { error } = await supabase.auth.signInWithPassword({ email, password }); const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) { if (error) {
@ -50,15 +64,24 @@ function createAuthStore() {
return false; return false;
} }
}, },
register: async (email, password) => { register: async (email, password, displayName) => {
update((s) => ({ ...s, error: null })); update((s) => ({ ...s, error: null }));
try { 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) { if (error) {
update((s) => ({ ...s, error: error.message })); update((s) => ({ ...s, error: error.message }));
return { success: false }; return { success: false };
} }
update((s) => ({ ...s, error: null })); 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; const needsConfirmation = data?.user && !data?.session;
return { success: true, needsConfirmation, data }; return { success: true, needsConfirmation, data };
} catch (e) { } catch (e) {

@ -1,18 +1,133 @@
<script> <script>
import { onMount } from 'svelte'; 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 decks = [];
let loading = true; let loading = true;
let error = null; 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); 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() { async function load() {
loading = true; loading = true;
error = null; error = null;
try { try {
decks = await fetchPublishedDecks(); decks = await fetchPublishedDecks(userId ?? undefined);
} catch (e) { } catch (e) {
error = e?.message ?? 'Failed to load community decks'; error = e?.message ?? 'Failed to load community decks';
decks = []; decks = [];
@ -35,9 +150,39 @@
{:else if decks.length === 0} {: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> <p class="muted">No published decks yet. Create and publish a deck from My decks to see it here.</p>
{:else} {:else}
<ul class="deck-list"> <div class="community-toolbar">
{#each decks as deck (deck.id)} <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"> <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> <h3 class="deck-title">{deck.title}</h3>
{#if deck.description} {#if deck.description}
<p class="deck-description">{deck.description}</p> <p class="deck-description">{deck.description}</p>
@ -45,17 +190,53 @@
<div class="deck-meta"> <div class="deck-meta">
<span class="deck-count">{deck.question_count ?? 0} questions</span> <span class="deck-count">{deck.question_count ?? 0} questions</span>
</div> </div>
<p class="use-hint">Use the Decky app to discover and import this deck from the same Supabase project.</p> <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>
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
{/if}
<RatingModal
deck={ratingDeck}
initialRating={ratingInitial.rating}
initialComment={ratingInitial.comment}
onSubmit={handleRatingSubmit}
onClose={closeRatingModal}
/>
</div> </div>
<style> <style>
.page { .page {
padding: 1.5rem; padding: 1.5rem;
max-width: 900px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@ -70,6 +251,72 @@
color: var(--text-primary, #f0f0f0); 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 { .subtitle {
margin: 0; margin: 0;
font-size: 0.95rem; font-size: 0.95rem;
@ -89,21 +336,44 @@
color: var(--text-muted, #a0a0a0); 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; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: grid;
flex-direction: column; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem; gap: 1.25rem;
} }
.deck-card { .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); background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333); border: 1px solid var(--border, #333);
border-radius: 10px; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 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 { .deck-title {
@ -118,18 +388,103 @@
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted, #a0a0a0); color: var(--text-muted, #a0a0a0);
line-height: 1.4; line-height: 1.4;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.deck-meta { .deck-meta {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted, #a0a0a0); color: var(--text-muted, #a0a0a0);
margin-bottom: 0.5rem; margin-bottom: 0.25rem;
} }
.use-hint { .deck-creator {
margin: 0; font-size: 0.85rem;
font-size: 0.8rem; 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); 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; font-style: italic;
} }
</style> </style>

@ -0,0 +1,353 @@
<script>
import { push } from 'svelte-spa-router';
import { auth } from '../lib/stores/auth.js';
import { fetchPublishedDecksByOwner, copyDeckToUser, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js';
import RatingModal from '../lib/RatingModal.svelte';
export let params = {};
let decks = [];
let ownerName = '';
let loading = true;
let error = null;
let addingDeckId = null;
let useError = null;
let ratingDeck = null;
let ratingInitial = { rating: 0, comment: '' };
$: userId = $auth.user?.id;
let prevOwnerId = null;
$: ownerId = params?.id;
$: if (ownerId && ownerId !== prevOwnerId) {
prevOwnerId = ownerId;
load();
}
function formatRating(n) {
return Number(n).toFixed(1);
}
function goPreview(deckId) {
push(`/decks/${deckId}/preview`);
}
async function openRatingModal(deck, e) {
e?.stopPropagation();
if (!userId) {
useError = 'Sign in to rate this deck.';
return;
}
if (deck.owner_id === userId) return; // cannot rate own deck
useError = null;
ratingDeck = { id: deck.id, title: deck.title };
try {
const data = await getMyDeckRating(deck.id, userId);
ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' };
} catch (_) {
ratingInitial = { rating: 0, comment: '' };
}
}
function closeRatingModal() {
ratingDeck = null;
}
async function handleRatingSubmit(payload) {
if (!ratingDeck || !userId) return;
try {
await submitDeckRating(ratingDeck.id, userId, payload);
await load();
} catch (e) {
useError = e?.message ?? 'Failed to save rating';
}
}
async function handleUse(deckId) {
useError = null;
if (!userId) {
useError = 'Sign in to add this deck to My decks.';
return;
}
if (addingDeckId) return;
addingDeckId = deckId;
try {
await copyDeckToUser(deckId, userId);
await load();
} catch (e) {
useError = e?.message ?? 'Failed to add deck';
} finally {
addingDeckId = null;
}
}
function goCommunity(e) {
if (e) e.preventDefault();
push('/community');
}
async function load() {
if (!ownerId) return;
loading = true;
error = null;
try {
const result = await fetchPublishedDecksByOwner(ownerId, userId ?? undefined);
decks = result.decks ?? [];
ownerName = result.owner_display_name ?? result.owner_email ?? 'User';
} catch (e) {
error = e?.message ?? 'Failed to load decks';
decks = [];
ownerName = '';
} finally {
loading = false;
}
}
</script>
<div class="page">
<header class="page-header">
<button type="button" class="btn btn-back" onclick={goCommunity}> Community</button>
<h1 class="page-title">Decks by {ownerName}</h1>
</header>
{#if loading}
<p class="muted">Loading…</p>
{:else if error}
<p class="error">{error}</p>
{:else if decks.length === 0}
<p class="muted">No published decks by this user.</p>
{:else}
{#if useError}
<p class="use-error">{useError}</p>
{/if}
<ul class="deck-grid">
{#each decks as deck (deck.id)}
<li class="deck-card">
<div class="deck-card-inner" role="button" tabindex="0" onclick={() => goPreview(deck.id)} onkeydown={(e) => e.key === 'Enter' && goPreview(deck.id)}>
<h3 class="deck-title">{deck.title}</h3>
{#if deck.description}
<p class="deck-description">{deck.description}</p>
{/if}
<div class="deck-meta">
<span class="deck-count">{deck.question_count ?? 0} questions</span>
</div>
<div
class="deck-rating"
class:clickable={userId && deck.owner_id !== userId}
aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars"
onclick={userId && deck.owner_id !== userId ? (e) => openRatingModal(deck, e) : undefined}
role={userId && deck.owner_id !== userId ? 'button' : undefined}
tabindex={userId && deck.owner_id !== userId ? 0 : undefined}
onkeydown={userId && deck.owner_id !== userId ? (e) => e.key === 'Enter' && openRatingModal(deck, e) : undefined}
>
<span class="stars">{['★', '★', '★', '★', '★'].map((_, i) => (i < Math.round(deck.average_rating ?? 0) ? '★' : '☆')).join('')}</span>
<span class="rating-text">{formatRating(deck.average_rating ?? 0)}</span>
{#if (deck.rating_count ?? 0) > 0}
<span class="rating-count">({deck.rating_count})</span>
{/if}
</div>
<div class="deck-card-actions">
{#if !deck.user_has_this}
<button type="button" class="btn btn-small btn-primary" onclick={(e) => { e.stopPropagation(); handleUse(deck.id); }} disabled={addingDeckId === deck.id}>
{addingDeckId === deck.id ? 'Adding…' : 'Add'}
</button>
{:else}
<span class="already-have">In My decks</span>
{/if}
</div>
</div>
</li>
{/each}
</ul>
{/if}
<RatingModal
deck={ratingDeck}
initialRating={ratingInitial.rating}
initialComment={ratingInitial.comment}
onSubmit={handleRatingSubmit}
onClose={closeRatingModal}
/>
</div>
<style>
.page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 1.5rem;
}
.btn-back {
padding: 0.4rem 0.75rem;
font-size: 0.9rem;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: transparent;
color: var(--text-muted, #a0a0a0);
cursor: pointer;
margin-bottom: 0.75rem;
}
.btn-back:hover {
color: var(--text-primary, #f0f0f0);
border-color: var(--border-hover, #444);
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.muted,
.error {
margin: 0;
}
.error {
color: #ef4444;
}
.muted {
color: var(--text-muted, #a0a0a0);
}
.use-error {
margin: 0 0 1rem 0;
font-size: 0.9rem;
color: #ef4444;
}
.deck-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.25rem;
}
.deck-card {
margin: 0;
}
.deck-card-inner {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 160px;
padding: 1.25rem;
background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.2s ease;
cursor: pointer;
}
.deck-card-inner:hover {
border-color: var(--border-hover, #444);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
transform: translateY(-2px);
}
.deck-title {
margin: 0 0 0.25rem 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.deck-description {
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
line-height: 1.4;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.deck-meta {
font-size: 0.85rem;
color: var(--text-muted, #a0a0a0);
margin-bottom: 0.25rem;
}
.deck-rating {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
margin-bottom: 0.25rem;
}
.deck-rating.clickable {
cursor: pointer;
}
.deck-rating.clickable:hover {
color: #eab308;
}
.deck-rating .stars {
color: #eab308;
letter-spacing: 0.05em;
}
.deck-rating .rating-count {
font-size: 0.8rem;
color: var(--text-muted, #888);
}
.deck-card-actions {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
margin-bottom: 0.5rem;
}
.deck-card-actions .btn {
padding: 0.35rem 0.75rem;
font-size: 0.85rem;
font-weight: 500;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: var(--card-bg, #1e1e1e);
color: var(--text-primary, #f0f0f0);
cursor: pointer;
}
.deck-card-actions .btn:hover {
background: var(--hover-bg, #2a2a2a);
}
.deck-card-actions .btn-primary {
background: var(--accent, #3b82f6);
border-color: var(--accent, #3b82f6);
}
.deck-card-actions .btn-primary:hover {
background: #2563eb;
}
.already-have {
font-size: 0.85rem;
color: #22c55e;
font-style: italic;
}
</style>

@ -0,0 +1,318 @@
<script>
import { push } from 'svelte-spa-router';
import { auth } from '../lib/stores/auth.js';
import { fetchDeckWithQuestions, copyDeckToUser, userHasDeck } from '../lib/api/decks.js';
export let params = {};
let deck = null;
let loading = true;
let error = null;
let adding = false;
let addError = null;
let userAlreadyHasDeck = false;
$: userId = $auth.user?.id;
$: deckId = params?.id;
let prevDeckId = null;
$: if (deckId && deckId !== prevDeckId) {
prevDeckId = deckId;
load();
}
async function load() {
if (!deckId) return;
loading = true;
error = null;
addError = null;
userAlreadyHasDeck = false;
try {
deck = await fetchDeckWithQuestions(deckId);
if (!deck.published) {
error = 'This deck is not available.';
deck = null;
} else if (userId) {
userAlreadyHasDeck = await userHasDeck(deckId, userId);
}
} catch (e) {
error = e?.message ?? 'Failed to load deck';
deck = null;
} finally {
loading = false;
}
}
function goCommunity() {
push('/community');
}
async function addToMyDecks() {
if (!deckId || !deck?.published) return;
addError = null;
if (!userId) {
addError = 'Sign in to add this deck to My decks.';
return;
}
adding = true;
try {
await copyDeckToUser(deckId, userId);
userAlreadyHasDeck = true;
} catch (e) {
addError = e?.message ?? 'Failed to add deck';
} finally {
adding = false;
}
}
function goReviews() {
if (!deckId) return;
push(`/decks/${deckId}/reviews`);
}
</script>
<header class="page-header page-header-fixed">
<div class="page-header-inner">
<div class="page-header-actions">
<button type="button" class="btn btn-back" onclick={goCommunity}> Community</button>
{#if deck && deck.published && !userAlreadyHasDeck}
<button
type="button"
class="btn btn-add"
onclick={addToMyDecks}
disabled={adding}
title="Add to my decks"
>
{#if adding}
Adding…
{:else}
Add to my decks
{/if}
</button>
{:else if deck && deck.published && userAlreadyHasDeck}
<span class="already-have">In My decks</span>
{/if}
{#if deck && deck.published}
<button type="button" class="btn btn-reviews" onclick={goReviews} title="View reviews">
Reviews
</button>
{/if}
</div>
{#if deck && deck.published && !userAlreadyHasDeck && addError}
<p class="page-header-error">{addError}</p>
{/if}
</div>
</header>
<div class="page page-with-fixed-header">
{#if loading}
<p class="muted">Loading…</p>
{:else if error}
<p class="error">{error}</p>
{:else if deck}
<h1 class="deck-title">{deck.title}</h1>
{#if deck.description}
<p class="deck-description">{deck.description}</p>
{/if}
<p class="deck-meta">{deck.questions?.length ?? 0} questions</p>
<h2 class="section-title">Questions</h2>
<ol class="question-list">
{#each deck.questions ?? [] as q, i}
<li class="question-item">
<div class="question-prompt">{q.prompt}</div>
<ul class="question-answers">
{#each (q.answers ?? []) as ans, j}
<li class:correct={(q.correct_answer_indices ?? []).includes(j)}>{ans}</li>
{/each}
</ul>
{#if q.explanation}
<p class="question-explanation">{q.explanation}</p>
{/if}
</li>
{/each}
</ol>
{/if}
</div>
<style>
.page {
padding: 1.5rem;
max-width: 700px;
margin: 0 auto;
}
.page-with-fixed-header {
padding-top: calc(var(--navbar-height, 60px) + 3.5rem);
}
.page-header {
margin-bottom: 0;
}
.page-header-fixed {
position: fixed;
top: var(--navbar-height, 60px);
left: 0;
right: 0;
z-index: 50;
background: var(--bg, #0f0f0f);
border-bottom: 1px solid var(--border, #333);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.page-header-inner {
max-width: 700px;
margin: 0 auto;
padding: 0.75rem 1.5rem;
}
.page-header-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.btn-back {
padding: 0.4rem 0.75rem;
font-size: 0.9rem;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: transparent;
color: var(--text-muted, #a0a0a0);
cursor: pointer;
}
.btn-back:hover {
color: var(--text-primary, #f0f0f0);
border-color: var(--border-hover, #444);
}
.btn-add {
padding: 0.4rem 1rem;
font-size: 0.9rem;
font-weight: 500;
border: none;
border-radius: 6px;
background: var(--accent, #3b82f6);
color: white;
cursor: pointer;
}
.btn-add:hover:not(:disabled) {
background: #2563eb;
}
.btn-add:disabled {
opacity: 0.85;
cursor: not-allowed;
}
.btn-reviews {
padding: 0.4rem 1rem;
font-size: 0.9rem;
font-weight: 500;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: transparent;
color: var(--text-muted, #a0a0a0);
cursor: pointer;
}
.btn-reviews:hover {
color: var(--text-primary, #f0f0f0);
border-color: var(--border-hover, #444);
}
.page-header-error {
margin: 0.5rem 0 0 0;
font-size: 0.85rem;
color: #ef4444;
}
.already-have {
font-size: 0.9rem;
color: #22c55e;
font-style: italic;
}
.deck-title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.deck-description {
margin: 0 0 0.5rem 0;
font-size: 0.95rem;
color: var(--text-muted, #a0a0a0);
line-height: 1.5;
}
.deck-meta {
margin: 0 0 1.5rem 0;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
}
.section-title {
margin: 0 0 0.75rem 0;
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.question-list {
margin: 0;
padding-left: 1.5rem;
list-style: decimal;
}
.question-item {
margin-bottom: 1.25rem;
padding: 0.75rem;
background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333);
border-radius: 8px;
}
.question-prompt {
font-weight: 500;
color: var(--text-primary, #f0f0f0);
margin-bottom: 0.5rem;
}
.question-answers {
margin: 0 0 0.5rem 0;
padding-left: 1.25rem;
list-style: disc;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
}
.question-answers li.correct {
color: #22c55e;
}
.question-explanation {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted, #888);
font-style: italic;
}
.muted,
.error {
margin: 0;
}
.error {
color: #ef4444;
}
.muted {
color: var(--text-muted, #a0a0a0);
}
</style>

@ -0,0 +1,232 @@
<script>
import { push } from 'svelte-spa-router';
import { fetchDeckWithQuestions, getDeckReviews } from '../lib/api/decks.js';
export let params = {};
let deck = null;
let reviews = [];
let loading = true;
let error = null;
$: deckId = params?.id;
let prevDeckId = null;
$: if (deckId && deckId !== prevDeckId) {
prevDeckId = deckId;
load();
}
async function load() {
if (!deckId) return;
loading = true;
error = null;
try {
const [deckData, reviewsData] = await Promise.all([
fetchDeckWithQuestions(deckId),
getDeckReviews(deckId),
]);
if (!deckData.published) {
error = 'This deck is not available.';
deck = null;
reviews = [];
} else {
deck = deckData;
reviews = reviewsData;
}
} catch (e) {
error = e?.message ?? 'Failed to load';
deck = null;
reviews = [];
} finally {
loading = false;
}
}
function goBack() {
push(`/decks/${deckId}/preview`);
}
function reviewerName(r) {
return (r.display_name && r.display_name.trim()) || r.email || 'Anonymous';
}
</script>
<div class="page">
<header class="page-header">
<button type="button" class="btn btn-back" onclick={goBack}> Back to deck</button>
<h1 class="page-title">Reviews</h1>
{#if deck}
<p class="deck-subtitle">{deck.title}</p>
{/if}
</header>
{#if loading}
<p class="muted">Loading…</p>
{:else if error}
<p class="error">{error}</p>
{:else if reviews.length === 0}
<p class="muted">No reviews yet.</p>
{:else}
<ul class="reviews-list">
{#each reviews as review (review.user_id)}
<li class="review-item">
<div class="review-row">
<div class="review-avatar-wrap">
{#if review.avatar_url}
<img src={review.avatar_url} alt="" class="review-avatar" width="40" height="40" />
{:else}
<div class="review-avatar-placeholder" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
{/if}
</div>
<div class="review-body">
<div class="review-meta">
<span class="reviewer-name">{reviewerName(review)}</span>
<span class="review-stars" aria-label="{review.rating} out of 5 stars">
{['★', '★', '★', '★', '★'].map((_, i) => (i < review.rating ? '★' : '☆')).join('')}
</span>
</div>
{#if review.comment}
<p class="review-comment">{review.comment}</p>
{/if}
</div>
</div>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.page {
padding: 1.5rem;
max-width: 600px;
margin: 0 auto;
}
.page-header {
margin-bottom: 1.5rem;
}
.btn-back {
padding: 0.4rem 0.75rem;
font-size: 0.9rem;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: transparent;
color: var(--text-muted, #a0a0a0);
cursor: pointer;
margin-bottom: 0.75rem;
}
.btn-back:hover {
color: var(--text-primary, #f0f0f0);
border-color: var(--border-hover, #444);
}
.page-title {
margin: 0 0 0.25rem 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.deck-subtitle {
margin: 0;
font-size: 0.95rem;
color: var(--text-muted, #a0a0a0);
}
.muted,
.error {
margin: 0;
}
.error {
color: #ef4444;
}
.muted {
color: var(--text-muted, #a0a0a0);
}
.reviews-list {
list-style: none;
margin: 0;
padding: 0;
}
.review-item {
padding: 1rem 0;
border-bottom: 1px solid var(--border, #333);
}
.review-item:last-child {
border-bottom: none;
}
.review-row {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.review-avatar-wrap {
flex-shrink: 0;
}
.review-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.review-avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted, #888);
}
.review-body {
flex: 1;
min-width: 0;
}
.review-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.reviewer-name {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.review-stars {
color: #eab308;
letter-spacing: 0.05em;
font-size: 0.95rem;
}
.review-comment {
margin: 0;
font-size: 0.9rem;
color: var(--text-muted, #a0a0a0);
line-height: 1.5;
}
</style>

@ -4,6 +4,7 @@
import { fetchDeckWithQuestions, updateDeck, togglePublished, deleteDeck } from '../lib/api/decks.js'; import { fetchDeckWithQuestions, updateDeck, togglePublished, deleteDeck } from '../lib/api/decks.js';
import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js'; import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js';
import QuestionEditor from '../lib/QuestionEditor.svelte'; import QuestionEditor from '../lib/QuestionEditor.svelte';
import ConfirmModal from '../lib/ConfirmModal.svelte';
export let params = {}; export let params = {};
$: deckId = params?.id; $: deckId = params?.id;
@ -16,6 +17,8 @@
let loading = true; let loading = true;
let saving = false; let saving = false;
let error = null; let error = null;
let showDeleteConfirm = false;
let isCopiedDeck = false;
$: userId = $auth.user?.id; $: userId = $auth.user?.id;
@ -41,6 +44,7 @@
description = deck.description ?? ''; description = deck.description ?? '';
config = { ...DEFAULT_DECK_CONFIG, ...(deck.config || {}) }; config = { ...DEFAULT_DECK_CONFIG, ...(deck.config || {}) };
published = !!deck.published; published = !!deck.published;
isCopiedDeck = !!deck.copied_from_deck_id;
questions = (deck.questions || []).map((q) => ({ questions = (deck.questions || []).map((q) => ({
prompt: q.prompt ?? '', prompt: q.prompt ?? '',
explanation: q.explanation ?? '', explanation: q.explanation ?? '',
@ -53,6 +57,7 @@
} catch (e) { } catch (e) {
error = e?.message ?? 'Failed to load deck'; error = e?.message ?? 'Failed to load deck';
questions = [{ prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] }]; questions = [{ prompt: '', explanation: '', answers: [''], correct_answer_indices: [0] }];
isCopiedDeck = false;
} finally { } finally {
loading = false; loading = false;
} }
@ -120,8 +125,18 @@
} }
} }
async function remove() { function openDeleteConfirm() {
if (!deckId || !confirm('Delete this deck? This cannot be undone.')) return; if (!deckId) return;
showDeleteConfirm = true;
}
function closeDeleteConfirm() {
showDeleteConfirm = false;
}
async function confirmRemove() {
if (!deckId) return;
closeDeleteConfirm();
try { try {
await deleteDeck(deckId); await deleteDeck(deckId);
push('/'); push('/');
@ -135,18 +150,20 @@
} }
</script> </script>
<div class="page"> <header class="page-header page-header-fixed">
<header class="page-header"> <div class="page-header-inner">
<h1>Edit deck</h1> <h1 class="page-header-title">Edit deck</h1>
<div class="header-actions"> <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-secondary" onclick={cancel} disabled={saving}>Cancel</button>
<button type="button" class="btn btn-primary" onclick={save} disabled={saving}> <button type="button" class="btn btn-primary" onclick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save'} {saving ? 'Saving…' : 'Save'}
</button> </button>
</div> </div>
</div>
</header> </header>
<div class="page page-with-fixed-header">
{#if $auth.loading} {#if $auth.loading}
<p class="muted">Loading…</p> <p class="muted">Loading…</p>
{:else if !userId} {:else if !userId}
@ -155,12 +172,14 @@
<p class="muted">Loading…</p> <p class="muted">Loading…</p>
{:else} {:else}
<form class="deck-form" onsubmit={(e) => { e.preventDefault(); save(); }}> <form class="deck-form" onsubmit={(e) => { e.preventDefault(); save(); }}>
{#if !isCopiedDeck}
<div class="publish-row"> <div class="publish-row">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" checked={published} onchange={togglePublish} /> <input type="checkbox" checked={published} onchange={togglePublish} />
Published (visible in Community) Published (visible in Community)
</label> </label>
</div> </div>
{/if}
<div class="form-group"> <div class="form-group">
<label for="title">Title</label> <label for="title">Title</label>
<input id="title" type="text" class="input" bind:value={title} placeholder="Deck title" required /> <input id="title" type="text" class="input" bind:value={title} placeholder="Deck title" required />
@ -181,6 +200,17 @@
{/if} {/if}
</form> </form>
{/if} {/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> </div>
<style> <style>
@ -190,16 +220,37 @@
margin: 0 auto; margin: 0 auto;
} }
.page-with-fixed-header {
padding-top: calc(var(--navbar-height, 60px) + 3.5rem);
}
.page-header { .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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem; gap: 1rem;
margin-bottom: 1.5rem;
} }
.page-header h1 { .page-header-title {
margin: 0; margin: 0;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;

@ -1,14 +1,48 @@
<script> <script>
import { push } from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
import { auth } from '../lib/stores/auth.js'; 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 decks = [];
let loading = true; let loading = true;
let error = null; let error = null;
let ratingDeck = null;
let ratingInitial = { rating: 0, comment: '' };
let showUnpublishConfirm = false;
let deckToUnpublish = null;
$: userId = $auth.user?.id; $: 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; let prevUserId = null;
$: if (!$auth.loading && userId && userId !== prevUserId) { $: if (!$auth.loading && userId && userId !== prevUserId) {
prevUserId = userId; prevUserId = userId;
@ -32,19 +66,57 @@
} }
} }
function goCreate() {
push('/decks/new');
}
function goEdit(id) { function goEdit(id) {
push(`/decks/${id}/edit`); 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> </script>
<div class="page"> <div class="page">
<header class="page-header"> <header class="page-header">
<h1>My decks</h1> <h1>My decks</h1>
<button type="button" class="btn btn-primary" onclick={goCreate}>Create deck</button>
</header> </header>
{#if $auth.loading} {#if $auth.loading}
@ -58,34 +130,100 @@
{:else if decks.length === 0} {:else if decks.length === 0}
<p class="muted">No decks yet. Create your first deck to get started.</p> <p class="muted">No decks yet. Create your first deck to get started.</p>
{:else} {:else}
<ul class="deck-list"> <ul class="deck-grid">
{#each decks as deck (deck.id)} {#each decks as deck (deck.id)}
<li class="deck-card"> <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> <h3 class="deck-title">{deck.title}</h3>
{#if deck.description} {#if deck.description}
<p class="deck-description">{deck.description}</p> <p class="deck-description">{deck.description}</p>
{/if} {/if}
<div class="deck-meta"> <div class="deck-meta">
<span class="deck-count">{deck.question_count ?? 0} questions</span> <span class="deck-count">{deck.question_count ?? 0} questions</span>
{#if deck.published}
<span class="badge published">Published</span>
{/if}
</div> </div>
{#if (deck.average_rating ?? 0) > 0 || (deck.rating_count ?? 0) > 0 || deck.can_rate}
<div
class="deck-rating"
class:clickable={deck.can_rate}
aria-label="Rating: {deck.average_rating ?? 0} out of 5 stars"
onclick={deck.can_rate ? (e) => openRatingModal(deck, e) : undefined}
role={deck.can_rate ? 'button' : undefined}
tabindex={deck.can_rate ? 0 : undefined}
onkeydown={deck.can_rate ? (e) => e.key === 'Enter' && openRatingModal(deck, e) : undefined}
>
<span class="stars">{['★', '★', '★', '★', '★'].map((_, i) => (i < Math.round(deck.average_rating ?? 0) ? '★' : '☆')).join('')}</span>
<span class="rating-text">{formatRating(deck.average_rating ?? 0)}</span>
{#if (deck.rating_count ?? 0) > 0}
<span class="rating-count">({deck.rating_count})</span>
{/if}
</div> </div>
{/if}
<div class="deck-card-actions"> <div class="deck-card-actions">
<button type="button" class="btn btn-small" onclick={() => goEdit(deck.id)}>Edit</button> {#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> </div>
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/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> </div>
<style> <style>
.page { .page {
padding: 1.5rem; padding: 1.5rem;
max-width: 900px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
@ -115,31 +253,41 @@
color: #ef4444; color: #ef4444;
} }
.deck-list { .deck-grid {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
display: flex; display: grid;
flex-direction: column; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.75rem; gap: 1.25rem;
} }
.deck-card { .deck-card {
margin: 0;
}
.deck-card-inner {
display: flex; display: flex;
align-items: flex-start; flex-direction: column;
justify-content: space-between; height: 100%;
gap: 1rem; min-height: 140px;
padding: 1rem 1.25rem; padding: 1.25rem;
background: var(--card-bg, #1a1a1a); background: var(--card-bg, #1a1a1a);
border: 1px solid var(--border, #333); border: 1px solid var(--border, #333);
border-radius: 10px; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
} }
.deck-card-main { .deck-card-inner:hover {
flex: 1; border-color: var(--border-hover, #444);
min-width: 0; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
cursor: pointer; }
.deck-card-inner:focus {
outline: 2px solid var(--accent, #3b82f6);
outline-offset: 2px;
} }
.deck-title { .deck-title {
@ -154,6 +302,11 @@
font-size: 0.9rem; font-size: 0.9rem;
color: var(--text-muted, #a0a0a0); color: var(--text-muted, #a0a0a0);
line-height: 1.4; line-height: 1.4;
flex: 1;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.deck-meta { .deck-meta {
@ -162,6 +315,35 @@
gap: 0.5rem; gap: 0.5rem;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted, #a0a0a0); 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 { .badge {
@ -177,7 +359,15 @@
} }
.deck-card-actions { .deck-card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
flex-shrink: 0; flex-shrink: 0;
margin-top: 0.75rem;
}
.btn-secondary {
background: transparent;
} }
.btn { .btn {
@ -208,4 +398,26 @@
padding: 0.35rem 0.75rem; padding: 0.35rem 0.75rem;
font-size: 0.85rem; 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> </style>

@ -0,0 +1,383 @@
<script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router';
import { auth } from '../lib/stores/auth.js';
import { getProfile, updateProfile, uploadAvatar } from '../lib/api/profile.js';
let profile = null;
let loading = true;
let saving = false;
let error = null;
let success = null;
let displayName = '';
let avatarFile = null;
let avatarPreview = null;
$: userId = $auth.user?.id;
$: if (userId && !profile && !loading) load();
$: if (!$auth.loading && !userId) push('/');
onMount(() => {
if (userId) load();
else if (!$auth.loading) loading = false;
});
async function load() {
if (!userId) return;
loading = true;
error = null;
try {
profile = await getProfile(userId);
displayName = profile?.display_name ?? '';
avatarPreview = profile?.avatar_url ?? null;
} catch (e) {
error = e?.message ?? 'Failed to load profile';
} finally {
loading = false;
}
}
function onAvatarChange(e) {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
error = 'Please choose an image file (JPEG, PNG, GIF, or WebP).';
return;
}
if (file.size > 2 * 1024 * 1024) {
error = 'Image must be under 2 MB.';
return;
}
error = null;
avatarFile = file;
avatarPreview = URL.createObjectURL(file);
}
function clearAvatar() {
avatarFile = null;
if (avatarPreview && avatarPreview.startsWith('blob:')) URL.revokeObjectURL(avatarPreview);
avatarPreview = null;
}
async function save() {
if (!userId) return;
saving = true;
error = null;
success = null;
try {
let avatarUrl = profile?.avatar_url ?? null;
if (avatarFile) {
avatarUrl = await uploadAvatar(userId, avatarFile);
} else if (avatarPreview === null && profile?.avatar_url) {
avatarUrl = null;
}
await updateProfile(userId, {
display_name: displayName.trim() || null,
avatar_url: avatarUrl,
});
profile = await getProfile(userId);
displayName = profile?.display_name ?? '';
avatarPreview = profile?.avatar_url ?? null;
avatarFile = null;
success = 'Settings saved.';
window.dispatchEvent(new CustomEvent('profile-updated', { detail: profile }));
} catch (e) {
error = e?.message ?? 'Failed to save';
} finally {
saving = false;
}
}
function cancel(e) {
if (e) e.preventDefault();
push('/');
}
</script>
<div class="page">
<header class="page-header">
<h1>Settings</h1>
<div class="header-actions">
<button type="button" class="btn btn-secondary" onclick={cancel} disabled={saving}>Cancel</button>
<button type="button" class="btn btn-primary" onclick={save} disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</header>
{#if $auth.loading || (userId && loading)}
<p class="muted">Loading…</p>
{:else if !userId}
<p class="auth-required">Sign in to manage your settings.</p>
{:else}
<form class="settings-form" onsubmit={(e) => { e.preventDefault(); save(); }}>
<div class="form-group">
<label for="display-name">Username</label>
<input
id="display-name"
type="text"
class="input"
bind:value={displayName}
placeholder="Display name"
maxlength="100"
/>
</div>
<div class="form-group">
<label>Avatar</label>
<div class="avatar-row">
<div class="avatar-wrap">
<label for="avatar-input" class="avatar-clickable" title="Choose image">
<div class="avatar-preview-wrap">
{#if avatarPreview}
<img src={avatarPreview} alt="Avatar" class="avatar-preview" />
{:else}
<div class="avatar-placeholder">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
{/if}
</div>
</label>
<input
id="avatar-input"
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onchange={onAvatarChange}
class="sr-only"
/>
<div class="avatar-edge-actions">
<button type="button" class="avatar-edge-btn" title="Remove" onclick={clearAvatar} disabled={!avatarPreview && !avatarFile}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</div>
</div>
</div>
<p class="form-hint">JPEG, PNG, GIF or WebP. Max 2 MB.</p>
</div>
{#if error}
<p class="error">{error}</p>
{/if}
{#if success}
<p class="success">{success}</p>
{/if}
</form>
{/if}
</div>
<style>
.page {
padding: 1.5rem;
max-width: 560px;
margin: 0 auto;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #f0f0f0);
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.auth-required,
.muted {
margin: 0;
color: var(--text-muted, #a0a0a0);
}
.settings-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-group label {
display: block;
font-size: 0.9rem;
font-weight: 500;
color: var(--text-muted, #a0a0a0);
margin-bottom: 0.35rem;
}
.input {
width: 100%;
padding: 0.6rem 0.75rem;
font-size: 0.95rem;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: var(--card-bg, #1e1e1e);
color: var(--text-primary, #f0f0f0);
}
.avatar-row {
display: flex;
align-items: center;
}
.avatar-wrap {
position: relative;
flex-shrink: 0;
}
.avatar-clickable {
display: block;
cursor: pointer;
}
.avatar-preview-wrap {
display: block;
}
.avatar-preview {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border, #333);
}
.avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--card-bg, #1e1e1e);
border: 2px solid var(--border, #333);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted, #a0a0a0);
}
.avatar-edge-actions {
position: absolute;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 4px;
z-index: 1;
}
.avatar-edge-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--border, #333);
border-radius: 50%;
background: var(--card-bg, #1a1a1a);
color: var(--text-muted, #a0a0a0);
cursor: pointer;
transition: color 0.2s, background 0.2s, border-color 0.2s;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.avatar-edge-btn:hover:not(:disabled) {
color: var(--text-primary, #f0f0f0);
background: var(--hover-bg, #2a2a2a);
border-color: var(--border-hover, #444);
}
.avatar-edge-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.avatar-edge-btn svg {
display: block;
}
.form-hint {
margin: 0.35rem 0 0 0;
font-size: 0.85rem;
color: var(--text-muted, #888);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.btn {
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: 500;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: var(--card-bg, #1e1e1e);
color: var(--text-primary, #f0f0f0);
cursor: pointer;
}
.btn:hover:not(:disabled) {
background: var(--hover-bg, #2a2a2a);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent, #3b82f6);
border-color: var(--accent, #3b82f6);
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: transparent;
}
.btn-small {
padding: 0.35rem 0.75rem;
font-size: 0.85rem;
}
.error {
margin: 0;
font-size: 0.9rem;
color: #ef4444;
}
.success {
margin: 0;
font-size: 0.9rem;
color: #22c55e;
}
</style>

@ -1,5 +1,8 @@
-- Schema for app tables (Supabase API uses db.schema: 'omotomo' in config)
CREATE SCHEMA IF NOT EXISTS omotomo;
-- Decks table (metadata + config for Decky quiz/flashcard app) -- Decks table (metadata + config for Decky quiz/flashcard app)
CREATE TABLE IF NOT EXISTS public.decks ( CREATE TABLE IF NOT EXISTS omotomo.decks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
title text NOT NULL, title text NOT NULL,
@ -21,9 +24,9 @@ CREATE TABLE IF NOT EXISTS public.decks (
); );
-- Questions table (deck content) -- Questions table (deck content)
CREATE TABLE IF NOT EXISTS public.questions ( CREATE TABLE IF NOT EXISTS omotomo.questions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
deck_id uuid NOT NULL REFERENCES public.decks(id) ON DELETE CASCADE, deck_id uuid NOT NULL REFERENCES omotomo.decks(id) ON DELETE CASCADE,
sort_order int NOT NULL DEFAULT 0, sort_order int NOT NULL DEFAULT 0,
prompt text NOT NULL, prompt text NOT NULL,
explanation text, explanation text,
@ -31,12 +34,12 @@ CREATE TABLE IF NOT EXISTS public.questions (
correct_answer_indices jsonb NOT NULL DEFAULT '[]'::jsonb correct_answer_indices jsonb NOT NULL DEFAULT '[]'::jsonb
); );
CREATE INDEX IF NOT EXISTS idx_questions_deck_id ON public.questions(deck_id); CREATE INDEX IF NOT EXISTS idx_questions_deck_id ON omotomo.questions(deck_id);
CREATE INDEX IF NOT EXISTS idx_decks_owner_id ON public.decks(owner_id); CREATE INDEX IF NOT EXISTS idx_decks_owner_id ON omotomo.decks(owner_id);
CREATE INDEX IF NOT EXISTS idx_decks_published ON public.decks(published) WHERE published = true; CREATE INDEX IF NOT EXISTS idx_decks_published ON omotomo.decks(published) WHERE published = true;
-- updated_at trigger for decks -- updated_at trigger for decks
CREATE OR REPLACE FUNCTION public.set_decks_updated_at() CREATE OR REPLACE FUNCTION omotomo.set_decks_updated_at()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN
NEW.updated_at = now(); NEW.updated_at = now();
@ -44,74 +47,82 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS decks_updated_at ON public.decks; DROP TRIGGER IF EXISTS decks_updated_at ON omotomo.decks;
CREATE TRIGGER decks_updated_at CREATE TRIGGER decks_updated_at
BEFORE UPDATE ON public.decks BEFORE UPDATE ON omotomo.decks
FOR EACH ROW FOR EACH ROW
EXECUTE PROCEDURE public.set_decks_updated_at(); EXECUTE PROCEDURE omotomo.set_decks_updated_at();
-- RLS -- RLS
ALTER TABLE public.decks ENABLE ROW LEVEL SECURITY; ALTER TABLE omotomo.decks ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.questions ENABLE ROW LEVEL SECURITY; ALTER TABLE omotomo.questions ENABLE ROW LEVEL SECURITY;
-- decks: SELECT if owner or published; mutate only if owner -- decks: SELECT if owner or published; mutate only if owner
CREATE POLICY decks_select ON public.decks DROP POLICY IF EXISTS decks_select ON omotomo.decks;
DROP POLICY IF EXISTS decks_insert ON omotomo.decks;
DROP POLICY IF EXISTS decks_update ON omotomo.decks;
DROP POLICY IF EXISTS decks_delete ON omotomo.decks;
CREATE POLICY decks_select ON omotomo.decks
FOR SELECT FOR SELECT
USING (owner_id = auth.uid() OR published = true); USING (owner_id = auth.uid() OR published = true);
CREATE POLICY decks_insert ON public.decks CREATE POLICY decks_insert ON omotomo.decks
FOR INSERT FOR INSERT
WITH CHECK (owner_id = auth.uid()); WITH CHECK (owner_id = auth.uid());
CREATE POLICY decks_update ON public.decks CREATE POLICY decks_update ON omotomo.decks
FOR UPDATE FOR UPDATE
USING (owner_id = auth.uid()) USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid()); WITH CHECK (owner_id = auth.uid());
CREATE POLICY decks_delete ON public.decks CREATE POLICY decks_delete ON omotomo.decks
FOR DELETE FOR DELETE
USING (owner_id = auth.uid()); USING (owner_id = auth.uid());
-- questions: SELECT if deck is visible; mutate only if deck is owned -- questions: SELECT if deck is visible; mutate only if deck is owned
CREATE POLICY questions_select ON public.questions DROP POLICY IF EXISTS questions_select ON omotomo.questions;
DROP POLICY IF EXISTS questions_insert ON omotomo.questions;
DROP POLICY IF EXISTS questions_update ON omotomo.questions;
DROP POLICY IF EXISTS questions_delete ON omotomo.questions;
CREATE POLICY questions_select ON omotomo.questions
FOR SELECT FOR SELECT
USING ( USING (
EXISTS ( EXISTS (
SELECT 1 FROM public.decks d SELECT 1 FROM omotomo.decks d
WHERE d.id = questions.deck_id WHERE d.id = questions.deck_id
AND (d.owner_id = auth.uid() OR d.published = true) AND (d.owner_id = auth.uid() OR d.published = true)
) )
); );
CREATE POLICY questions_insert ON public.questions CREATE POLICY questions_insert ON omotomo.questions
FOR INSERT FOR INSERT
WITH CHECK ( WITH CHECK (
EXISTS ( EXISTS (
SELECT 1 FROM public.decks d SELECT 1 FROM omotomo.decks d
WHERE d.id = questions.deck_id AND d.owner_id = auth.uid() WHERE d.id = questions.deck_id AND d.owner_id = auth.uid()
) )
); );
CREATE POLICY questions_update ON public.questions CREATE POLICY questions_update ON omotomo.questions
FOR UPDATE FOR UPDATE
USING ( USING (
EXISTS ( EXISTS (
SELECT 1 FROM public.decks d SELECT 1 FROM omotomo.decks d
WHERE d.id = questions.deck_id AND d.owner_id = auth.uid() WHERE d.id = questions.deck_id AND d.owner_id = auth.uid()
) )
) )
WITH CHECK ( WITH CHECK (
EXISTS ( EXISTS (
SELECT 1 FROM public.decks d SELECT 1 FROM omotomo.decks d
WHERE d.id = questions.deck_id AND d.owner_id = auth.uid() WHERE d.id = questions.deck_id AND d.owner_id = auth.uid()
) )
); );
CREATE POLICY questions_delete ON public.questions CREATE POLICY questions_delete ON omotomo.questions
FOR DELETE FOR DELETE
USING ( USING (
EXISTS ( EXISTS (
SELECT 1 FROM public.decks d SELECT 1 FROM omotomo.decks d
WHERE d.id = questions.deck_id AND d.owner_id = auth.uid() WHERE d.id = questions.deck_id AND d.owner_id = auth.uid()
) )
); );

@ -0,0 +1,98 @@
-- Profiles: public display info for users (creator names on community decks)
CREATE TABLE IF NOT EXISTS omotomo.profiles (
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
display_name text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE omotomo.profiles ENABLE ROW LEVEL SECURITY;
-- Anyone can read profiles (to show creator names)
DROP POLICY IF EXISTS profiles_select ON omotomo.profiles;
DROP POLICY IF EXISTS profiles_insert ON omotomo.profiles;
DROP POLICY IF EXISTS profiles_update ON omotomo.profiles;
DROP POLICY IF EXISTS profiles_delete ON omotomo.profiles;
CREATE POLICY profiles_select ON omotomo.profiles
FOR SELECT USING (true);
-- Users can insert/update/delete only their own profile
CREATE POLICY profiles_insert ON omotomo.profiles
FOR INSERT WITH CHECK (auth.uid() = id);
CREATE POLICY profiles_update ON omotomo.profiles
FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id);
CREATE POLICY profiles_delete ON omotomo.profiles
FOR DELETE USING (auth.uid() = id);
-- Create profile when a new user signs up (Supabase Auth)
CREATE OR REPLACE FUNCTION omotomo.handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = omotomo, public
AS $$
BEGIN
INSERT INTO omotomo.profiles (id, display_name)
VALUES (
NEW.id,
COALESCE(
NEW.raw_user_meta_data->>'full_name',
NEW.raw_user_meta_data->>'name',
split_part(NEW.email, '@', 1),
'User'
)
)
ON CONFLICT (id) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION omotomo.handle_new_user();
-- Backfill existing users: run in Supabase Dashboard SQL Editor (Service role) if you have users already:
-- INSERT INTO omotomo.profiles (id, display_name)
-- SELECT id, COALESCE(raw_user_meta_data->>'full_name', raw_user_meta_data->>'name', split_part(email, '@', 1), 'User')
-- FROM auth.users ON CONFLICT (id) DO NOTHING;
-- Deck ratings: one rating (15 stars) per user per deck
CREATE TABLE IF NOT EXISTS omotomo.deck_ratings (
deck_id uuid NOT NULL REFERENCES omotomo.decks(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
rating smallint NOT NULL CHECK (rating >= 1 AND rating <= 5),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (deck_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_deck_ratings_deck_id ON omotomo.deck_ratings(deck_id);
ALTER TABLE omotomo.deck_ratings ENABLE ROW LEVEL SECURITY;
-- Anyone can read ratings (for showing averages on community)
DROP POLICY IF EXISTS deck_ratings_select ON omotomo.deck_ratings;
DROP POLICY IF EXISTS deck_ratings_insert ON omotomo.deck_ratings;
DROP POLICY IF EXISTS deck_ratings_update ON omotomo.deck_ratings;
DROP POLICY IF EXISTS deck_ratings_delete ON omotomo.deck_ratings;
CREATE POLICY deck_ratings_select ON omotomo.deck_ratings
FOR SELECT USING (true);
-- Authenticated users can insert/update only their own rating, and not for decks they own
CREATE POLICY deck_ratings_insert ON omotomo.deck_ratings
FOR INSERT WITH CHECK (
auth.uid() = user_id
AND (SELECT d.owner_id FROM omotomo.decks d WHERE d.id = deck_id) IS DISTINCT FROM auth.uid()
);
CREATE POLICY deck_ratings_update ON omotomo.deck_ratings
FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (
auth.uid() = user_id
AND (SELECT d.owner_id FROM omotomo.decks d WHERE d.id = deck_id) IS DISTINCT FROM auth.uid()
);
CREATE POLICY deck_ratings_delete ON omotomo.deck_ratings
FOR DELETE USING (auth.uid() = user_id);

@ -0,0 +1,56 @@
-- Add avatar_url to profiles (schema omotomo)
ALTER TABLE omotomo.profiles
ADD COLUMN IF NOT EXISTS avatar_url text;
-- Storage bucket for avatars (public read; uploads scoped to user folder).
-- If the INSERT fails (e.g. different storage schema), create the bucket in Supabase Dashboard:
-- Storage → New bucket → name "avatars", Public bucket ON, File size limit 2MB, Allowed MIME types: image/jpeg, image/png, image/gif, image/webp
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'avatars',
'avatars',
true,
2097152,
ARRAY['image/jpeg', 'image/png', 'image/gif', 'image/webp']
)
ON CONFLICT (id) DO NOTHING;
-- RLS: allow authenticated users to upload only to their own folder (avatars/<user_id>/...)
-- Use auth.jwt()->>'sub' to match the folder name (JWT sub is the user id string).
CREATE POLICY "Users can upload own avatar"
ON storage.objects
FOR INSERT
TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
);
CREATE POLICY "Users can update own avatar"
ON storage.objects
FOR UPDATE
TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
)
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
);
CREATE POLICY "Users can delete own avatar"
ON storage.objects
FOR DELETE
TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
);
-- Public read for avatars (bucket is public)
CREATE POLICY "Avatar images are publicly readable"
ON storage.objects
FOR SELECT
TO public
USING (bucket_id = 'avatars');

@ -0,0 +1,3 @@
-- Mark decks that were added from the community (no Publish in UI)
ALTER TABLE omotomo.decks
ADD COLUMN IF NOT EXISTS copied_from_deck_id uuid NULL;

@ -0,0 +1,29 @@
-- Add optional comment to deck ratings (schema omotomo).
-- If deck_ratings doesn't exist (earlier migration not run), create the full table including comment.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'omotomo' AND tablename = 'deck_ratings') THEN
CREATE TABLE omotomo.deck_ratings (
deck_id uuid NOT NULL REFERENCES omotomo.decks(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
rating smallint NOT NULL CHECK (rating >= 1 AND rating <= 5),
created_at timestamptz NOT NULL DEFAULT now(),
comment text,
PRIMARY KEY (deck_id, user_id)
);
CREATE INDEX idx_deck_ratings_deck_id ON omotomo.deck_ratings(deck_id);
ALTER TABLE omotomo.deck_ratings ENABLE ROW LEVEL SECURITY;
CREATE POLICY deck_ratings_select ON omotomo.deck_ratings FOR SELECT USING (true);
CREATE POLICY deck_ratings_insert ON omotomo.deck_ratings FOR INSERT WITH CHECK (
auth.uid() = user_id
AND (SELECT d.owner_id FROM omotomo.decks d WHERE d.id = deck_id) IS DISTINCT FROM auth.uid()
);
CREATE POLICY deck_ratings_update ON omotomo.deck_ratings FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (
auth.uid() = user_id
AND (SELECT d.owner_id FROM omotomo.decks d WHERE d.id = deck_id) IS DISTINCT FROM auth.uid()
);
CREATE POLICY deck_ratings_delete ON omotomo.deck_ratings FOR DELETE USING (auth.uid() = user_id);
ELSE
ALTER TABLE omotomo.deck_ratings ADD COLUMN IF NOT EXISTS comment text;
END IF;
END $$;

@ -0,0 +1,10 @@
-- Run this in Supabase SQL Editor if you get "column profiles.avatar_url does not exist"
-- Use the schema where your profiles table lives (omotomo or public).
-- If your app uses schema omotomo (db: { schema: 'omotomo' }):
ALTER TABLE omotomo.profiles
ADD COLUMN IF NOT EXISTS avatar_url text;
-- If your profiles table is in public schema instead, run this instead:
-- ALTER TABLE public.profiles
-- ADD COLUMN IF NOT EXISTS avatar_url text;

@ -0,0 +1,5 @@
-- Run this in Supabase SQL Editor if you get "column decks.copied_from_deck_id does not exist"
-- Uses schema omotomo (match your app's db.schema).
ALTER TABLE omotomo.decks
ADD COLUMN IF NOT EXISTS copied_from_deck_id uuid NULL;

@ -0,0 +1,37 @@
-- Run this in Supabase SQL Editor if you get "Bucket not found" when saving profile with avatar.
-- If the INSERT fails (e.g. "relation storage.buckets does not exist" or column errors),
-- create the bucket in Dashboard: Storage → New bucket → name "avatars", Public ON.
-- 1) Create the bucket
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'avatars',
'avatars',
true,
2097152,
ARRAY['image/jpeg', 'image/png', 'image/gif', 'image/webp']
)
ON CONFLICT (id) DO NOTHING;
-- 2) RLS policies so users can upload to their own folder and everyone can read
DROP POLICY IF EXISTS "Users can upload own avatar" ON storage.objects;
DROP POLICY IF EXISTS "Users can update own avatar" ON storage.objects;
DROP POLICY IF EXISTS "Users can delete own avatar" ON storage.objects;
DROP POLICY IF EXISTS "Avatar images are publicly readable" ON storage.objects;
CREATE POLICY "Users can upload own avatar"
ON storage.objects FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'avatars' AND (storage.foldername(name))[1] = (auth.jwt()->>'sub'));
CREATE POLICY "Users can update own avatar"
ON storage.objects FOR UPDATE TO authenticated
USING (bucket_id = 'avatars' AND (storage.foldername(name))[1] = (auth.jwt()->>'sub'))
WITH CHECK (bucket_id = 'avatars' AND (storage.foldername(name))[1] = (auth.jwt()->>'sub'));
CREATE POLICY "Users can delete own avatar"
ON storage.objects FOR DELETE TO authenticated
USING (bucket_id = 'avatars' AND (storage.foldername(name))[1] = (auth.jwt()->>'sub'));
CREATE POLICY "Avatar images are publicly readable"
ON storage.objects FOR SELECT TO public
USING (bucket_id = 'avatars');

@ -0,0 +1,37 @@
-- Run this in Supabase SQL Editor if you get 403 "new row violates row-level security policy"
-- when uploading an avatar. Fixes INSERT/UPDATE/DELETE policies to use JWT sub for folder match.
DROP POLICY IF EXISTS "Users can upload own avatar" ON storage.objects;
DROP POLICY IF EXISTS "Users can update own avatar" ON storage.objects;
DROP POLICY IF EXISTS "Users can delete own avatar" ON storage.objects;
DROP POLICY IF EXISTS "Avatar images are publicly readable" ON storage.objects;
-- First folder in path must equal the authenticated user's id (from JWT sub)
CREATE POLICY "Users can upload own avatar"
ON storage.objects FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
);
CREATE POLICY "Users can update own avatar"
ON storage.objects FOR UPDATE TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
)
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
);
CREATE POLICY "Users can delete own avatar"
ON storage.objects FOR DELETE TO authenticated
USING (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
);
CREATE POLICY "Avatar images are publicly readable"
ON storage.objects FOR SELECT TO public
USING (bucket_id = 'avatars');

@ -0,0 +1,26 @@
-- Run this in Supabase SQL Editor if you get "permission denied for table profiles"
-- 1) Grants the Supabase API roles access to the omotomo schema and profiles table.
-- 2) Ensures RLS policies exist on omotomo.profiles (skip policy creation if you already have them).
-- Allow anon and authenticated roles to use the omotomo schema
GRANT USAGE ON SCHEMA omotomo TO anon, authenticated;
-- Allow reading and writing profiles (RLS policies control which rows)
GRANT SELECT, INSERT, UPDATE, DELETE ON omotomo.profiles TO anon, authenticated;
-- RLS: ensure the table is protected and policies allow the right access
ALTER TABLE omotomo.profiles ENABLE ROW LEVEL SECURITY;
-- Drop existing policies if you need to recreate (optional; remove the DROP lines if policies already work)
DROP POLICY IF EXISTS profiles_select ON omotomo.profiles;
DROP POLICY IF EXISTS profiles_insert ON omotomo.profiles;
DROP POLICY IF EXISTS profiles_update ON omotomo.profiles;
DROP POLICY IF EXISTS profiles_delete ON omotomo.profiles;
-- Anyone can read profiles (e.g. show creator names on community decks)
CREATE POLICY profiles_select ON omotomo.profiles FOR SELECT USING (true);
-- Users can insert/update/delete only their own profile (id = auth.uid())
CREATE POLICY profiles_insert ON omotomo.profiles FOR INSERT WITH CHECK (auth.uid() = id);
CREATE POLICY profiles_update ON omotomo.profiles FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id);
CREATE POLICY profiles_delete ON omotomo.profiles FOR DELETE USING (auth.uid() = id);
Loading…
Cancel
Save

Powered by TurnKey Linux.