parent
9eede674b6
commit
32c786fa24
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
@ -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>
|
||||||
@ -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>
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
-- Add avatar_url to profiles (schema omotomo)
|
||||||
|
ALTER TABLE omotomo.profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS avatar_url text;
|
||||||
|
|
||||||
|
-- Storage bucket for avatars (public read; uploads scoped to user folder).
|
||||||
|
-- If the INSERT fails (e.g. different storage schema), create the bucket in Supabase Dashboard:
|
||||||
|
-- Storage → New bucket → name "avatars", Public bucket ON, File size limit 2MB, Allowed MIME types: image/jpeg, image/png, image/gif, image/webp
|
||||||
|
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||||
|
VALUES (
|
||||||
|
'avatars',
|
||||||
|
'avatars',
|
||||||
|
true,
|
||||||
|
2097152,
|
||||||
|
ARRAY['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- RLS: allow authenticated users to upload only to their own folder (avatars/<user_id>/...)
|
||||||
|
-- Use auth.jwt()->>'sub' to match the folder name (JWT sub is the user id string).
|
||||||
|
CREATE POLICY "Users can upload own avatar"
|
||||||
|
ON storage.objects
|
||||||
|
FOR INSERT
|
||||||
|
TO authenticated
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update own avatar"
|
||||||
|
ON storage.objects
|
||||||
|
FOR UPDATE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can delete own avatar"
|
||||||
|
ON storage.objects
|
||||||
|
FOR DELETE
|
||||||
|
TO authenticated
|
||||||
|
USING (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Public read for avatars (bucket is public)
|
||||||
|
CREATE POLICY "Avatar images are publicly readable"
|
||||||
|
ON storage.objects
|
||||||
|
FOR SELECT
|
||||||
|
TO public
|
||||||
|
USING (bucket_id = 'avatars');
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
-- Mark decks that were added from the community (no Publish in UI)
|
||||||
|
ALTER TABLE omotomo.decks
|
||||||
|
ADD COLUMN IF NOT EXISTS copied_from_deck_id uuid NULL;
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
-- Add optional comment to deck ratings (schema omotomo).
|
||||||
|
-- If deck_ratings doesn't exist (earlier migration not run), create the full table including comment.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_tables WHERE schemaname = 'omotomo' AND tablename = 'deck_ratings') THEN
|
||||||
|
CREATE TABLE omotomo.deck_ratings (
|
||||||
|
deck_id uuid NOT NULL REFERENCES omotomo.decks(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
rating smallint NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
comment text,
|
||||||
|
PRIMARY KEY (deck_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_deck_ratings_deck_id ON omotomo.deck_ratings(deck_id);
|
||||||
|
ALTER TABLE omotomo.deck_ratings ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY deck_ratings_select ON omotomo.deck_ratings FOR SELECT USING (true);
|
||||||
|
CREATE POLICY deck_ratings_insert ON omotomo.deck_ratings FOR INSERT WITH CHECK (
|
||||||
|
auth.uid() = user_id
|
||||||
|
AND (SELECT d.owner_id FROM omotomo.decks d WHERE d.id = deck_id) IS DISTINCT FROM auth.uid()
|
||||||
|
);
|
||||||
|
CREATE POLICY deck_ratings_update ON omotomo.deck_ratings FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (
|
||||||
|
auth.uid() = user_id
|
||||||
|
AND (SELECT d.owner_id FROM omotomo.decks d WHERE d.id = deck_id) IS DISTINCT FROM auth.uid()
|
||||||
|
);
|
||||||
|
CREATE POLICY deck_ratings_delete ON omotomo.deck_ratings FOR DELETE USING (auth.uid() = user_id);
|
||||||
|
ELSE
|
||||||
|
ALTER TABLE omotomo.deck_ratings ADD COLUMN IF NOT EXISTS comment text;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
-- Run this in Supabase SQL Editor if you get "column profiles.avatar_url does not exist"
|
||||||
|
-- Use the schema where your profiles table lives (omotomo or public).
|
||||||
|
|
||||||
|
-- If your app uses schema omotomo (db: { schema: 'omotomo' }):
|
||||||
|
ALTER TABLE omotomo.profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS avatar_url text;
|
||||||
|
|
||||||
|
-- If your profiles table is in public schema instead, run this instead:
|
||||||
|
-- ALTER TABLE public.profiles
|
||||||
|
-- ADD COLUMN IF NOT EXISTS avatar_url text;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- Run this in Supabase SQL Editor if you get "column decks.copied_from_deck_id does not exist"
|
||||||
|
-- Uses schema omotomo (match your app's db.schema).
|
||||||
|
|
||||||
|
ALTER TABLE omotomo.decks
|
||||||
|
ADD COLUMN IF NOT EXISTS copied_from_deck_id uuid NULL;
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
-- Run this in Supabase SQL Editor if you get "Bucket not found" when saving profile with avatar.
|
||||||
|
-- If the INSERT fails (e.g. "relation storage.buckets does not exist" or column errors),
|
||||||
|
-- create the bucket in Dashboard: Storage → New bucket → name "avatars", Public ON.
|
||||||
|
|
||||||
|
-- 1) Create the bucket
|
||||||
|
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
||||||
|
VALUES (
|
||||||
|
'avatars',
|
||||||
|
'avatars',
|
||||||
|
true,
|
||||||
|
2097152,
|
||||||
|
ARRAY['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||||
|
)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- 2) RLS policies so users can upload to their own folder and everyone can read
|
||||||
|
DROP POLICY IF EXISTS "Users can upload own avatar" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Users can update own avatar" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Users can delete own avatar" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Avatar images are publicly readable" ON storage.objects;
|
||||||
|
|
||||||
|
CREATE POLICY "Users can upload own avatar"
|
||||||
|
ON storage.objects FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (bucket_id = 'avatars' AND (storage.foldername(name))[1] = (auth.jwt()->>'sub'));
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update own avatar"
|
||||||
|
ON storage.objects FOR UPDATE TO authenticated
|
||||||
|
USING (bucket_id = 'avatars' AND (storage.foldername(name))[1] = (auth.jwt()->>'sub'))
|
||||||
|
WITH CHECK (bucket_id = 'avatars' AND (storage.foldername(name))[1] = (auth.jwt()->>'sub'));
|
||||||
|
|
||||||
|
CREATE POLICY "Users can delete own avatar"
|
||||||
|
ON storage.objects FOR DELETE TO authenticated
|
||||||
|
USING (bucket_id = 'avatars' AND (storage.foldername(name))[1] = (auth.jwt()->>'sub'));
|
||||||
|
|
||||||
|
CREATE POLICY "Avatar images are publicly readable"
|
||||||
|
ON storage.objects FOR SELECT TO public
|
||||||
|
USING (bucket_id = 'avatars');
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
-- Run this in Supabase SQL Editor if you get 403 "new row violates row-level security policy"
|
||||||
|
-- when uploading an avatar. Fixes INSERT/UPDATE/DELETE policies to use JWT sub for folder match.
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "Users can upload own avatar" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Users can update own avatar" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Users can delete own avatar" ON storage.objects;
|
||||||
|
DROP POLICY IF EXISTS "Avatar images are publicly readable" ON storage.objects;
|
||||||
|
|
||||||
|
-- First folder in path must equal the authenticated user's id (from JWT sub)
|
||||||
|
CREATE POLICY "Users can upload own avatar"
|
||||||
|
ON storage.objects FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can update own avatar"
|
||||||
|
ON storage.objects FOR UPDATE TO authenticated
|
||||||
|
USING (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Users can delete own avatar"
|
||||||
|
ON storage.objects FOR DELETE TO authenticated
|
||||||
|
USING (
|
||||||
|
bucket_id = 'avatars'
|
||||||
|
AND (storage.foldername(name))[1] = (auth.jwt()->>'sub')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY "Avatar images are publicly readable"
|
||||||
|
ON storage.objects FOR SELECT TO public
|
||||||
|
USING (bucket_id = 'avatars');
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
-- Run this in Supabase SQL Editor if you get "permission denied for table profiles"
|
||||||
|
-- 1) Grants the Supabase API roles access to the omotomo schema and profiles table.
|
||||||
|
-- 2) Ensures RLS policies exist on omotomo.profiles (skip policy creation if you already have them).
|
||||||
|
|
||||||
|
-- Allow anon and authenticated roles to use the omotomo schema
|
||||||
|
GRANT USAGE ON SCHEMA omotomo TO anon, authenticated;
|
||||||
|
|
||||||
|
-- Allow reading and writing profiles (RLS policies control which rows)
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON omotomo.profiles TO anon, authenticated;
|
||||||
|
|
||||||
|
-- RLS: ensure the table is protected and policies allow the right access
|
||||||
|
ALTER TABLE omotomo.profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Drop existing policies if you need to recreate (optional; remove the DROP lines if policies already work)
|
||||||
|
DROP POLICY IF EXISTS profiles_select ON omotomo.profiles;
|
||||||
|
DROP POLICY IF EXISTS profiles_insert ON omotomo.profiles;
|
||||||
|
DROP POLICY IF EXISTS profiles_update ON omotomo.profiles;
|
||||||
|
DROP POLICY IF EXISTS profiles_delete ON omotomo.profiles;
|
||||||
|
|
||||||
|
-- Anyone can read profiles (e.g. show creator names on community decks)
|
||||||
|
CREATE POLICY profiles_select ON omotomo.profiles FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Users can insert/update/delete only their own profile (id = auth.uid())
|
||||||
|
CREATE POLICY profiles_insert ON omotomo.profiles FOR INSERT WITH CHECK (auth.uid() = id);
|
||||||
|
CREATE POLICY profiles_update ON omotomo.profiles FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id);
|
||||||
|
CREATE POLICY profiles_delete ON omotomo.profiles FOR DELETE USING (auth.uid() = id);
|
||||||
Loading…
Reference in new issue