beeta/frontend/src/routes/forums/+page.svelte

1031 lines
31 KiB
Svelte
Raw Normal View History

2026-01-05 22:54:27 -05:00
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { auth, isAuthenticated, isTexter, isAdmin, isModerator } from '$lib/stores/auth';
import { siteSettings } from '$lib/stores/siteSettings';
import ProfilePreview from '$lib/components/chat/ProfilePreview.svelte';
let forums = [];
let loading = true;
let error = '';
// Create forum modal
let showCreateModal = false;
let newForum = { name: '', description: '', titleColor: '#ffffff' };
let creating = false;
let createError = '';
// Banner upload in create modal
let bannerFile = null;
let bannerPreview = null;
let bannerInput;
// Banner position editor in create modal
let bannerPosition = 50;
let bannerPositionX = 50;
let bannerZoom = 100;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let dragStartPosX = 0;
let dragStartPosY = 0;
let bannerEditorRef;
// Profile preview state
let activeProfilePreview = null;
// Delete state
let deleteConfirmForum = null;
let deleting = false;
onMount(async () => {
// Wait for auth to initialize
const unsubscribe = auth.subscribe(async (state) => {
if (!state.loading) {
if (!state.user) {
goto('/login');
return;
}
await loadForums();
unsubscribe();
}
});
});
async function loadForums() {
loading = true;
error = '';
try {
const response = await fetch('/api/forums', {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
forums = data.forums || [];
} else if (response.status === 401) {
goto('/login');
} else {
const data = await response.json();
error = data.error || 'Failed to load forums';
}
} catch (e) {
error = 'Network error';
}
loading = false;
}
function handleBannerSelect(event) {
const file = event.target.files[0];
if (!file) return;
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
createError = 'Please upload a JPG, PNG, GIF, or WebP image';
return;
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
createError = 'Banner image must be under 5MB';
return;
}
bannerFile = file;
// Create preview URL
bannerPreview = URL.createObjectURL(file);
createError = '';
}
function removeBannerPreview() {
if (bannerPreview) {
URL.revokeObjectURL(bannerPreview);
}
bannerFile = null;
bannerPreview = null;
bannerPosition = 50;
bannerPositionX = 50;
bannerZoom = 100;
if (bannerInput) bannerInput.value = '';
}
function startDrag(e) {
if (!bannerPreview) return;
isDragging = true;
const { clientX, clientY } = e.touches ? e.touches[0] : e;
dragStartX = clientX;
dragStartY = clientY;
dragStartPosX = bannerPositionX;
dragStartPosY = bannerPosition;
e.preventDefault();
}
function onDrag(e) {
if (!isDragging || !bannerEditorRef) return;
const { clientX, clientY } = e.touches ? e.touches[0] : e;
const rect = bannerEditorRef.getBoundingClientRect();
const deltaX = clientX - dragStartX;
const deltaY = clientY - dragStartY;
const scaleFactor = 100 / bannerZoom;
const newPosX = dragStartPosX - (deltaX / rect.width) * 100 * scaleFactor;
const newPosY = dragStartPosY - (deltaY / rect.height) * 100 * scaleFactor;
bannerPositionX = Math.max(0, Math.min(100, newPosX));
bannerPosition = Math.max(0, Math.min(100, newPosY));
e.preventDefault();
}
function endDrag() {
isDragging = false;
}
async function createForum() {
if (!newForum.name.trim()) {
createError = 'Forum name is required';
return;
}
creating = true;
createError = '';
try {
// Step 1: Create the forum
const response = await fetch('/api/forums', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: newForum.name.trim(),
description: newForum.description.trim(),
titleColor: newForum.titleColor
})
});
const data = await response.json();
if (response.ok && data.success) {
const forumId = data.forum.id;
// Step 2: Upload banner if selected
if (bannerFile) {
const formData = new FormData();
formData.append('banner', bannerFile);
const bannerResp = await fetch(`/api/forums/${forumId}/banner`, {
method: 'POST',
credentials: 'include',
body: formData
});
// Step 3: Save banner position if upload succeeded
if (bannerResp.ok) {
await fetch(`/api/forums/${forumId}/banner/position`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
bannerPosition,
bannerPositionX,
bannerZoom
})
});
}
}
// Reset and close
showCreateModal = false;
newForum = { name: '', description: '', titleColor: '#ffffff' };
removeBannerPreview();
await loadForums();
} else {
createError = data.error || 'Failed to create forum';
}
} catch (e) {
createError = 'Network error';
}
creating = false;
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function formatRelativeTime(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return formatDate(dateString);
}
function handleUsernameClick(event, forum) {
event.preventDefault();
event.stopPropagation();
const rect = event.target.getBoundingClientRect();
activeProfilePreview = {
username: forum.username,
userId: forum.userId,
isGuest: false,
position: { x: rect.left, y: rect.bottom + 5 }
};
}
function handleProfileClose() {
activeProfilePreview = null;
}
function canDeleteForum(forum) {
const user = $auth.user;
if (!user) return false;
return $isAdmin || $isModerator || forum.userId === user.id;
}
function confirmDelete(event, forum) {
event.preventDefault();
event.stopPropagation();
deleteConfirmForum = forum;
}
async function deleteForum() {
if (!deleteConfirmForum) return;
deleting = true;
try {
const response = await fetch(`/api/forums/${deleteConfirmForum.id}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
deleteConfirmForum = null;
await loadForums();
} else {
const data = await response.json();
error = data.error || 'Failed to delete forum';
}
} catch (e) {
error = 'Network error';
}
deleting = false;
}
</script>
<svelte:head>
<title>{$siteSettings.site_title} - Forums</title>
</svelte:head>
<style>
.realm-nav {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
overflow-x: auto;
scrollbar-width: none;
}
.realm-nav::-webkit-scrollbar {
display: none;
}
.nav-link {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--gray);
text-decoration: none;
font-size: 0.85rem;
white-space: nowrap;
transition: all 0.2s;
}
.nav-link:hover {
border-color: var(--primary);
color: var(--white);
background: rgba(86, 29, 94, 0.1);
}
.nav-link.active {
border-color: var(--primary);
color: var(--primary);
background: rgba(86, 29, 94, 0.15);
}
.nav-link svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.forums-container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.forums-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.forums-header h1 {
font-size: 1.75rem;
color: var(--white);
margin: 0;
}
.forums-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.forum-card {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.25rem;
transition: border-color 0.2s;
}
.forum-card:hover {
border-color: var(--primary);
}
.forum-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.forum-title-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
flex: 1;
}
.forum-link {
text-decoration: none;
color: inherit;
flex: 1;
}
.btn-delete {
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem;
cursor: pointer;
color: var(--gray);
transition: all 0.2s;
flex-shrink: 0;
}
.btn-delete:hover {
background: var(--error);
border-color: var(--error);
color: white;
}
.btn-delete svg {
width: 16px;
height: 16px;
display: block;
}
.forum-name {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.forum-description {
color: var(--gray);
font-size: 0.9rem;
margin-bottom: 1rem;
line-height: 1.5;
}
.forum-meta {
display: flex;
gap: 1.5rem;
color: var(--gray);
font-size: 0.85rem;
flex-wrap: wrap;
}
.forum-meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
.forum-card-banner {
display: block;
position: relative;
height: 50px;
aspect-ratio: 900 / 200;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
background: #111;
}
.forum-card-banner img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.forum-last-post {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.15rem;
flex-shrink: 0;
text-align: right;
}
.last-thread-link {
color: var(--text);
font-size: 0.8rem;
text-decoration: none;
white-space: nowrap;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.last-thread-link:hover {
color: var(--primary);
text-decoration: underline;
}
.last-post-time {
color: var(--gray);
font-size: 0.75rem;
white-space: nowrap;
}
.forum-created {
margin-left: auto;
}
.forum-owner {
cursor: pointer;
transition: filter 0.15s ease;
}
.forum-owner:hover {
filter: invert(1);
}
.loading, .error, .empty {
text-align: center;
padding: 3rem;
color: var(--gray);
}
.error {
color: var(--error);
}
.form-hint {
font-size: 0.85rem;
color: var(--gray);
margin-top: 0.5rem;
}
.banner-upload-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
border: 2px dashed var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
color: var(--gray);
}
.banner-upload-area:hover {
border-color: var(--primary);
background: rgba(255, 255, 255, 0.02);
}
.banner-upload-area svg {
width: 40px;
height: 40px;
margin-bottom: 0.5rem;
opacity: 0.5;
}
.banner-preview {
position: relative;
width: 100%;
height: 120px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
.banner-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.banner-remove {
position: absolute;
top: 0.5rem;
right: 0.5rem;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.banner-remove:hover {
background: var(--error);
}
.banner-remove svg {
width: 16px;
height: 16px;
color: white;
}
/* Banner editor styles */
.banner-editor {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
border-radius: 8px;
cursor: grab;
background: #111;
border: 1px solid var(--border);
}
.banner-editor:active {
cursor: grabbing;
}
.banner-editor-img {
width: 100%;
height: 100%;
object-fit: cover;
pointer-events: none;
user-select: none;
}
.banner-editor-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.banner-editor:hover .banner-editor-overlay {
opacity: 1;
}
.banner-editor-hint {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.85rem;
}
.position-controls {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.75rem;
}
.zoom-control {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.zoom-control label {
color: var(--gray);
font-size: 0.8rem;
}
.zoom-control input[type="range"] {
width: 100%;
accent-color: var(--primary);
}
.reset-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--gray);
padding: 0.4rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
white-space: nowrap;
}
.reset-btn:hover {
border-color: var(--primary);
color: var(--white);
}
/* Color picker styles */
.color-picker-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.color-input {
width: 44px;
height: 36px;
padding: 2px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-dark);
cursor: pointer;
}
.color-input::-webkit-color-swatch-wrapper {
padding: 2px;
}
.color-input::-webkit-color-swatch {
border-radius: 2px;
border: none;
}
.color-text-input {
width: 90px;
font-family: monospace;
text-transform: uppercase;
}
.color-preview-text {
font-size: 1.1rem;
font-weight: 600;
}
</style>
<!-- Realm Navigation Bar -->
<nav class="realm-nav">
<a href="/video" class="nav-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="4" width="20" height="16" rx="2"/>
<path d="M10 9l5 3-5 3V9z" fill="currentColor"/>
</svg>
Videos
</a>
<a href="/audio" class="nav-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/>
</svg>
Audio
</a>
<a href="/ebooks" class="nav-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
</svg>
eBooks
</a>
</nav>
<div class="forums-container">
<div class="forums-header">
<h1>Forums</h1>
{#if $isTexter || $isAdmin}
<button class="btn" on:click={() => showCreateModal = true}>
Create Forum
</button>
{/if}
</div>
{#if loading}
<div class="loading">Loading forums...</div>
{:else if error}
<div class="error">{error}</div>
{:else if forums.length === 0}
<div class="empty">
No forums yet.
{#if $isTexter || $isAdmin}
<br>Create the first one!
{/if}
</div>
{:else}
<div class="forums-list">
{#each forums as forum}
<div class="forum-card">
<div class="forum-header">
<div class="forum-title-row">
<a href="/forums/{forum.slug}" class="forum-link">
<div class="forum-name" style="color: {forum.titleColor || '#ffffff'};">{forum.name}</div>
{#if forum.description}
<div class="forum-description">{forum.description}</div>
{/if}
</a>
{#if canDeleteForum(forum)}
<button
class="btn-delete"
on:click={(e) => confirmDelete(e, forum)}
title="Delete forum"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
</button>
{/if}
</div>
{#if forum.lastPostAt}
<div class="forum-last-post" title="Last activity">
{#if forum.lastThreadTitle}
<a href="/forums/{forum.slug}/thread/{forum.lastThreadId}" class="last-thread-link">
{forum.lastThreadTitle.length > 30 ? forum.lastThreadTitle.slice(0, 30) + '...' : forum.lastThreadTitle}
</a>
{/if}
<span class="last-post-time">{formatRelativeTime(forum.lastPostAt)}</span>
</div>
{/if}
{#if forum.bannerUrl}
<a href="/forums/{forum.slug}" class="forum-card-banner">
<img
src={forum.bannerUrl}
alt="{forum.name} banner"
style="object-position: {forum.bannerPositionX ?? 50}% {forum.bannerPosition ?? 50}%; transform: scale({(forum.bannerZoom ?? 100) / 100}); transform-origin: {forum.bannerPositionX ?? 50}% {forum.bannerPosition ?? 50}%;"
/>
</a>
{/if}
</div>
<div class="forum-meta">
<span class="forum-meta-item">
<span
class="forum-owner"
style="color: {forum.userColor || 'var(--primary)'};"
on:click={(e) => handleUsernameClick(e, forum)}
>
@{forum.username}
</span>
</span>
<span class="forum-meta-item">
{forum.threadCount || 0} threads
</span>
<span class="forum-meta-item">
{forum.postCount || 0} posts
</span>
<span class="forum-meta-item forum-created">
Created {formatDate(forum.createdAt)}
</span>
</div>
</div>
{/each}
</div>
{/if}
</div>
{#if showCreateModal}
<div class="modal" on:click={() => showCreateModal = false}>
<div class="modal-content" style="max-width: 800px;" on:click|stopPropagation>
<div class="modal-header">
<h2>Create Forum</h2>
<button class="modal-close" on:click={() => showCreateModal = false}>&times;</button>
</div>
{#if createError}
<div class="error" style="margin-bottom: 1rem;">{createError}</div>
{/if}
<form on:submit|preventDefault={createForum}>
<div class="form-group">
<label for="forum-name">Forum Name</label>
<input
id="forum-name"
type="text"
bind:value={newForum.name}
placeholder="Enter forum name"
maxlength="100"
required
/>
<p class="form-hint">Up to 100 characters</p>
</div>
<div class="form-group">
<label for="forum-description">Description (optional)</label>
<textarea
id="forum-description"
bind:value={newForum.description}
placeholder="Describe your forum"
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="forum-title-color">Title Color</label>
<div class="color-picker-row">
<input
id="forum-title-color"
type="color"
bind:value={newForum.titleColor}
class="color-input"
/>
<input
type="text"
bind:value={newForum.titleColor}
placeholder="#ffffff"
maxlength="7"
class="color-text-input"
/>
<span class="color-preview-text" style="color: {newForum.titleColor};">
{newForum.name || 'Forum Title'}
</span>
</div>
</div>
<div class="form-group">
<label>Banner Image (optional)</label>
<input
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
bind:this={bannerInput}
on:change={handleBannerSelect}
style="display: none;"
/>
{#if bannerPreview}
<div
class="banner-editor"
bind:this={bannerEditorRef}
on:mousedown={startDrag}
on:mousemove={onDrag}
on:mouseup={endDrag}
on:mouseleave={endDrag}
on:touchstart={startDrag}
on:touchmove={onDrag}
on:touchend={endDrag}
role="slider"
aria-label="Drag to position banner"
tabindex="0"
>
<img
src={bannerPreview}
alt="Banner preview"
class="banner-editor-img"
style="object-position: {bannerPositionX}% {bannerPosition}%; transform: scale({bannerZoom / 100}); transform-origin: {bannerPositionX}% {bannerPosition}%;"
/>
<div class="banner-editor-overlay">
<span class="banner-editor-hint">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 9l-3 3l3 3M9 5l3-3l3 3M15 19l3 3l-3 3M19 9l3 3l-3 3M2 12h20M12 2v20"/>
</svg>
Drag to position
</span>
</div>
<button type="button" class="banner-remove" on:click={removeBannerPreview}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<div class="position-controls">
<div class="zoom-control">
<label for="create-banner-zoom">Zoom: {bannerZoom}%</label>
<input
id="create-banner-zoom"
type="range"
min="100"
max="200"
step="5"
bind:value={bannerZoom}
/>
</div>
<button
type="button"
class="reset-btn"
on:click={() => {
bannerZoom = 100;
bannerPosition = 50;
bannerPositionX = 50;
}}
>
Reset Position
</button>
</div>
{:else}
<div class="banner-upload-area" on:click={() => bannerInput.click()}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
<span>Click to select a banner image</span>
</div>
{/if}
<p class="form-hint">JPG, PNG, GIF, or WebP. Max 5MB. Drag to position.</p>
</div>
<button type="submit" class="btn btn-block" disabled={creating}>
{creating ? 'Creating...' : 'Create Forum'}
</button>
</form>
</div>
</div>
{/if}
{#if activeProfilePreview}
<ProfilePreview
username={activeProfilePreview.username}
userId={activeProfilePreview.userId}
isGuest={activeProfilePreview.isGuest}
position={activeProfilePreview.position}
on:close={handleProfileClose}
/>
{/if}
{#if deleteConfirmForum}
<div class="modal" on:click={() => deleteConfirmForum = null}>
<div class="modal-content" on:click|stopPropagation>
<div class="modal-header">
<h2>Delete Forum</h2>
<button class="modal-close" on:click={() => deleteConfirmForum = null}>&times;</button>
</div>
<p style="color: var(--gray); margin-bottom: 1.5rem;">
Are you sure you want to delete <strong style="color: var(--white);">{deleteConfirmForum.name}</strong>?
<br><br>
This will permanently delete all threads and posts in this forum. This action cannot be undone.
</p>
<div style="display: flex; gap: 1rem;">
<button
class="btn"
style="flex: 1; background: var(--bg-dark);"
on:click={() => deleteConfirmForum = null}
disabled={deleting}
>
Cancel
</button>
<button
class="btn"
style="flex: 1; background: var(--error);"
on:click={deleteForum}
disabled={deleting}
>
{deleting ? 'Deleting...' : 'Delete Forum'}
</button>
</div>
</div>
</div>
{/if}