Deck ratings (comment + popup), remove Published badge from My decks
This commit is contained in:
@@ -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 self‑hosted 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.
|
||||||
+1
-1
@@ -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>
|
||||||
|
|||||||
BIN
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>
|
||||||
+250
-10
@@ -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>
|
||||||
+298
-13
@@ -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)
|
||||||
);
|
);
|
||||||
return decks.map((d, i) => ({ ...d, question_count: questionCounts[i] }));
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
+26
-3
@@ -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) {
|
||||||
|
|||||||
+371
-16
@@ -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.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-creator {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted, #a0a0a0);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.creator-link {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--accent, #3b82f6);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.creator-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-rating {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted, #a0a0a0);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-rating.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-rating.clickable:hover {
|
||||||
|
color: #eab308;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-rating .stars {
|
||||||
|
color: #eab308;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-rating .rating-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted, #888);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-card-actions {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.use-hint {
|
.deck-card-actions .btn {
|
||||||
margin: 0;
|
padding: 0.35rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-muted, #a0a0a0);
|
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>
|
||||||
</header>
|
</div>
|
||||||
|
</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;
|
||||||
|
|||||||
+241
-29
@@ -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}
|
</div>
|
||||||
<span class="badge published">Published</span>
|
{#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}
|
{/if}
|
||||||
</div>
|
</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 {
|
||||||
display: flex;
|
margin: 0;
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
background: var(--card-bg, #1a1a1a);
|
|
||||||
border: 1px solid var(--border, #333);
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck-card-main {
|
.deck-card-inner {
|
||||||
flex: 1;
|
display: flex;
|
||||||
min-width: 0;
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 140px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--card-bg, #1a1a1a);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-card-inner:hover {
|
||||||
|
border-color: var(--border-hover, #444);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck-card-inner:focus {
|
||||||
|
outline: 2px solid var(--accent, #3b82f6);
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.deck-title {
|
.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 (1–5 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);
|
||||||
Reference in New Issue
Block a user