beeta/frontend/src/routes/forums/[slug]/+page.svelte

1412 lines
42 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 { page } from '$app/stores';
import { auth, isAuthenticated, isAdmin, isModerator } from '$lib/stores/auth';
import { siteSettings } from '$lib/stores/siteSettings';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { stickersMap, ensureLoaded } from '$lib/stores/stickers';
import ProfilePreview from '$lib/components/chat/ProfilePreview.svelte';
let forum = null;
let threads = [];
let loading = true;
let error = '';
let isBanned = false;
// Create thread modal
let showCreateModal = false;
let newThread = { title: '', content: '' };
let creating = false;
let createError = '';
// Banner upload
let bannerInput;
let uploadingBanner = false;
let bannerError = '';
let showBannerMenu = false;
// Banner position editor
let showPositionEditor = false;
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;
let savingPosition = false;
// Title color editor
let showTitleColorEditor = false;
let titleColor = '#ffffff';
let savingTitleColor = false;
// Description editor
let showDescriptionEditor = false;
let editedDescription = '';
let savingDescription = false;
// Profile preview state
let activeProfilePreview = null;
// Check if current user is forum owner
$: isOwner = forum && $auth.user && forum.userId === $auth.user.id;
$: canModerate = isOwner || $isAdmin || $isModerator;
marked.setOptions({
breaks: true,
gfm: true
});
onMount(async () => {
await ensureLoaded();
const unsubscribe = auth.subscribe(async (state) => {
if (!state.loading) {
if (!state.user) {
goto('/login');
return;
}
await loadForum();
unsubscribe();
}
});
});
async function loadForum() {
loading = true;
error = '';
const forumId = $page.params.slug;
try {
// Load forum details
const forumResponse = await fetch(`/api/forums/${forumId}`, {
credentials: 'include'
});
if (forumResponse.ok) {
const forumData = await forumResponse.json();
forum = forumData.forum;
// Initialize banner position values
bannerPosition = forum.bannerPosition ?? 50;
bannerPositionX = forum.bannerPositionX ?? 50;
bannerZoom = forum.bannerZoom ?? 100;
// Initialize title color
titleColor = forum.titleColor ?? '#ffffff';
} else if (forumResponse.status === 401) {
goto('/login');
return;
} else if (forumResponse.status === 404) {
error = 'Forum not found';
loading = false;
return;
}
// Load threads
const threadsResponse = await fetch(`/api/forums/${forumId}/threads`, {
credentials: 'include'
});
if (threadsResponse.ok) {
const threadsData = await threadsResponse.json();
threads = threadsData.threads || [];
} else if (threadsResponse.status === 403) {
const data = await threadsResponse.json();
if (data.error && data.error.includes('banned')) {
isBanned = true;
error = 'You are banned from this forum';
}
}
} catch (e) {
error = 'Network error';
}
loading = false;
}
function parsePreview(content, stickerMap) {
if (!content) return '';
// Truncate to preview length
let preview = content.slice(0, 200);
if (content.length > 200) preview += '...';
// Replace stickers
preview = preview.replace(/:(\w+):/g, (match, name) => {
const key = name.toLowerCase();
if (stickerMap && stickerMap[key]) {
return `<img src="${stickerMap[key]}" alt="${name}" class="sticker-img-small" />`;
}
return match;
});
let html = marked.parse(preview);
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'img', 'br', 'strong', 'em', 'code', 'del', 'span'],
ALLOWED_ATTR: ['src', 'alt', 'class'],
FORBID_TAGS: ['a', 'button', 'script']
});
}
// Reactive parsed previews - re-run when stickers load
$: parsedThreadPreviews = threads.reduce((acc, thread) => {
acc[thread.id] = parsePreview(thread.content, $stickersMap);
return acc;
}, {});
async function createThread() {
if (!newThread.title.trim()) {
createError = 'Thread title is required';
return;
}
if (!newThread.content.trim()) {
createError = 'Thread content is required';
return;
}
creating = true;
createError = '';
try {
const response = await fetch(`/api/forums/${$page.params.slug}/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
title: newThread.title.trim(),
content: newThread.content.trim()
})
});
const data = await response.json();
if (response.ok && data.success) {
showCreateModal = false;
newThread = { title: '', content: '' };
// Navigate to the new thread
goto(`/forums/${$page.params.slug}/thread/${data.thread.id}`);
} else {
createError = data.error || 'Failed to create thread';
}
} catch (e) {
createError = 'Network error';
}
creating = false;
}
async function deleteThread(threadId) {
if (!confirm('Are you sure you want to delete this thread?')) return;
try {
const response = await fetch(`/api/forums/${$page.params.slug}/threads/${threadId}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
threads = threads.filter(t => t.id !== threadId);
}
} catch (e) {
console.error('Failed to delete thread:', e);
}
}
function triggerBannerUpload() {
bannerInput.click();
}
async function handleBannerUpload(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)) {
bannerError = 'Please upload a JPG, PNG, GIF, or WebP image';
return;
}
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
bannerError = 'Image must be under 5MB';
return;
}
uploadingBanner = true;
bannerError = '';
try {
const formData = new FormData();
formData.append('banner', file);
const response = await fetch(`/api/forums/${forum.id}/banner`, {
2026-01-05 22:54:27 -05:00
method: 'POST',
credentials: 'include',
body: formData
});
const data = await response.json();
if (response.ok && data.success) {
forum.bannerUrl = data.bannerUrl;
forum = forum; // Trigger reactivity
} else {
bannerError = data.error || 'Failed to upload banner';
}
} catch (e) {
bannerError = 'Network error';
}
uploadingBanner = false;
// Reset input so same file can be selected again
event.target.value = '';
}
async function deleteBanner() {
if (!confirm('Remove the forum banner?')) return;
uploadingBanner = true;
bannerError = '';
try {
const response = await fetch(`/api/forums/${forum.id}/banner`, {
2026-01-05 22:54:27 -05:00
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
forum.bannerUrl = '';
forum = forum; // Trigger reactivity
} else {
const data = await response.json();
bannerError = data.error || 'Failed to remove banner';
}
} catch (e) {
bannerError = 'Network error';
}
uploadingBanner = false;
}
function openPositionEditor() {
showBannerMenu = false;
// Reset to current saved values
bannerPosition = forum.bannerPosition ?? 50;
bannerPositionX = forum.bannerPositionX ?? 50;
bannerZoom = forum.bannerZoom ?? 100;
showPositionEditor = true;
}
function closePositionEditor() {
showPositionEditor = false;
// Reset to saved values
bannerPosition = forum.bannerPosition ?? 50;
bannerPositionX = forum.bannerPositionX ?? 50;
bannerZoom = forum.bannerZoom ?? 100;
}
function startDrag(e) {
if (!forum.bannerUrl) 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();
// Calculate movement as percentage of container size
const deltaX = clientX - dragStartX;
const deltaY = clientY - dragStartY;
// Scale factor adjusted by zoom
const scaleFactor = 100 / bannerZoom;
const newPosX = dragStartPosX - (deltaX / rect.width) * 100 * scaleFactor;
const newPosY = dragStartPosY - (deltaY / rect.height) * 100 * scaleFactor;
// Clamp values
bannerPositionX = Math.max(0, Math.min(100, newPosX));
bannerPosition = Math.max(0, Math.min(100, newPosY));
e.preventDefault();
}
function endDrag() {
isDragging = false;
}
async function saveBannerPosition() {
savingPosition = true;
bannerError = '';
try {
const response = await fetch(`/api/forums/${forum.id}/banner/position`, {
2026-01-05 22:54:27 -05:00
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
bannerPosition,
bannerPositionX,
bannerZoom
})
});
if (response.ok) {
// Update forum object with new values
forum.bannerPosition = bannerPosition;
forum.bannerPositionX = bannerPositionX;
forum.bannerZoom = bannerZoom;
forum = forum; // Trigger reactivity
showPositionEditor = false;
} else {
const data = await response.json();
bannerError = data.error || 'Failed to save position';
}
} catch (e) {
bannerError = 'Network error';
}
savingPosition = false;
}
function openTitleColorEditor() {
showBannerMenu = false;
titleColor = forum.titleColor ?? '#ffffff';
showTitleColorEditor = true;
}
function closeTitleColorEditor() {
showTitleColorEditor = false;
titleColor = forum.titleColor ?? '#ffffff';
}
async function saveTitleColor() {
savingTitleColor = true;
bannerError = '';
try {
const response = await fetch(`/api/forums/${forum.id}/title-color`, {
2026-01-05 22:54:27 -05:00
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ titleColor })
});
if (response.ok) {
forum.titleColor = titleColor;
forum = forum; // Trigger reactivity
showTitleColorEditor = false;
} else {
const data = await response.json();
bannerError = data.error || 'Failed to save title color';
}
} catch (e) {
bannerError = 'Network error';
}
savingTitleColor = false;
}
function openDescriptionEditor() {
showBannerMenu = false;
editedDescription = forum.description ?? '';
showDescriptionEditor = true;
}
function closeDescriptionEditor() {
showDescriptionEditor = false;
editedDescription = '';
}
async function saveDescription() {
savingDescription = true;
bannerError = '';
try {
const response = await fetch(`/api/forums/${forum.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ description: editedDescription })
});
if (response.ok) {
forum.description = editedDescription;
forum = forum; // Trigger reactivity
showDescriptionEditor = false;
} else {
const data = await response.json();
bannerError = data.error || 'Failed to save description';
}
} catch (e) {
bannerError = 'Network error';
}
savingDescription = false;
}
async function togglePin(thread) {
try {
const response = await fetch(`/api/forums/${$page.params.slug}/threads/${thread.id}/pin`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
thread.isPinned = data.isPinned;
// Re-sort threads (pinned first)
threads = [...threads].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return new Date(b.lastPostAt) - new Date(a.lastPostAt);
});
}
} catch (e) {
console.error('Failed to toggle pin:', e);
}
}
async function toggleLock(thread) {
try {
const response = await fetch(`/api/forums/${$page.params.slug}/threads/${thread.id}/lock`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
thread.isLocked = data.isLocked;
threads = threads;
}
} catch (e) {
console.error('Failed to toggle lock:', e);
}
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function timeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return formatDate(dateString);
}
function handleUsernameClick(event, username, userId) {
event.preventDefault();
event.stopPropagation();
const rect = event.target.getBoundingClientRect();
activeProfilePreview = {
username,
userId,
isGuest: false,
position: { x: rect.left, y: rect.bottom + 5 }
};
}
function handleProfileClose() {
activeProfilePreview = null;
}
</script>
<svelte:head>
<title>{forum ? `${$siteSettings.site_title} - ${forum.name}` : $siteSettings.site_title}</title>
</svelte:head>
<svelte:window on:click={() => showBannerMenu = false} />
<style>
.forum-container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.breadcrumb {
margin-bottom: 1.5rem;
}
.breadcrumb a {
color: var(--gray);
text-decoration: none;
}
.breadcrumb a:hover {
color: var(--primary);
}
.breadcrumb span {
color: var(--gray);
}
.forum-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
gap: 1rem;
}
.forum-info h1 {
font-size: 1.75rem;
margin: 0 0 0.5rem 0;
}
.forum-description {
color: var(--gray);
font-size: 0.95rem;
}
.forum-owner {
font-size: 0.85rem;
margin-top: 0.5rem;
}
.forum-owner-name {
cursor: pointer;
transition: filter 0.15s ease;
}
.forum-owner-name:hover {
filter: invert(1);
}
.threads-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.thread-card {
background: #111;
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.25rem;
transition: border-color 0.2s;
}
.thread-card:hover {
border-color: var(--primary);
}
.thread-card.pinned {
border-color: #f59e0b;
background: rgba(245, 158, 11, 0.05);
}
.thread-card.locked {
opacity: 0.7;
}
.thread-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.thread-badge {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-weight: 500;
}
.badge-pinned {
background: #f59e0b;
color: #000;
}
.badge-locked {
background: var(--gray);
color: #000;
}
.thread-title {
font-size: 1.1rem;
font-weight: 600;
color: var(--white);
text-decoration: none;
display: block;
flex: 1;
}
.thread-title:hover {
color: var(--primary);
}
.thread-preview {
color: var(--gray);
font-size: 0.9rem;
margin-bottom: 0.75rem;
line-height: 1.5;
max-height: 3em;
overflow: hidden;
}
.thread-preview :global(p) {
margin: 0;
}
.thread-preview :global(.sticker-img-small) {
height: 1.2em;
vertical-align: middle;
}
.thread-meta {
display: flex;
gap: 1rem;
color: var(--gray);
font-size: 0.8rem;
align-items: center;
flex-wrap: wrap;
}
.thread-author {
cursor: pointer;
transition: filter 0.15s ease;
}
.thread-author:hover {
filter: invert(1);
}
.thread-actions {
margin-left: auto;
display: flex;
gap: 0.5rem;
}
.action-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--gray);
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
}
.action-btn:hover {
border-color: var(--primary);
color: var(--white);
}
.action-btn.danger:hover {
border-color: var(--error);
color: var(--error);
}
.loading, .error, .empty {
text-align: center;
padding: 3rem;
color: var(--gray);
}
.error {
color: var(--error);
}
.banned-notice {
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--error);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
color: var(--error);
text-align: center;
}
.form-hint {
font-size: 0.85rem;
color: var(--gray);
margin-top: 0.5rem;
}
/* Banner styles */
.forum-banner {
position: relative;
width: 100%;
height: 200px;
margin-bottom: 1.5rem;
border-radius: 8px;
overflow: hidden;
background: #111;
border: 1px solid var(--border);
}
.forum-banner img {
width: 100%;
height: 100%;
object-fit: cover;
}
.banner-actions {
position: absolute;
bottom: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
}
.banner-btn {
background: rgba(0, 0, 0, 0.7);
border: 1px solid var(--border);
color: var(--white);
padding: 0.5rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.banner-btn:hover {
background: rgba(0, 0, 0, 0.9);
border-color: var(--primary);
}
.banner-btn.danger:hover {
border-color: var(--error);
color: var(--error);
}
.banner-error {
color: var(--error);
font-size: 0.85rem;
margin-bottom: 1rem;
text-align: center;
}
.forum-title-row {
display: flex;
align-items: center;
gap: 0.75rem;
position: relative;
}
.btn-banner-menu {
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.4rem;
cursor: pointer;
color: var(--gray);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-banner-menu:hover {
border-color: var(--primary);
color: var(--primary);
}
.btn-banner-menu:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-banner-menu svg {
width: 18px;
height: 18px;
}
.banner-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.5rem;
background: #1a1a1a;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.25rem;
z-index: 100;
min-width: 150px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.banner-menu button {
display: block;
width: 100%;
background: transparent;
border: none;
color: var(--white);
padding: 0.5rem 0.75rem;
text-align: left;
cursor: pointer;
border-radius: 4px;
font-size: 0.9rem;
}
.banner-menu button:hover {
background: rgba(255, 255, 255, 0.1);
}
.banner-menu button.danger {
color: var(--error);
}
.banner-menu button.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.menu-divider {
height: 1px;
background: var(--border);
margin: 0.25rem 0;
}
/* Position editor styles */
.position-editor-modal {
max-width: 600px;
}
.banner-editor {
position: relative;
width: 100%;
height: 200px;
overflow: hidden;
border-radius: 8px;
cursor: grab;
background: #111;
border: 1px solid var(--border);
margin-bottom: 1rem;
}
.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;
}
.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.9rem;
}
.position-controls {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.zoom-control {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.zoom-control label {
color: var(--gray);
font-size: 0.9rem;
}
.zoom-control input[type="range"] {
width: 100%;
accent-color: var(--primary);
}
.position-display {
display: flex;
gap: 1rem;
color: var(--gray);
font-size: 0.85rem;
}
.reset-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--gray);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
align-self: flex-start;
}
.reset-btn:hover {
border-color: var(--primary);
color: var(--white);
}
.position-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.btn-secondary {
background: var(--bg-dark);
}
/* Color picker styles */
.color-picker-row {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.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.25rem;
font-weight: 600;
}
</style>
<div class="forum-container">
<div class="breadcrumb">
<a href="/forums">Forums</a>
<span> / </span>
<span>{forum?.name || 'Loading...'}</span>
</div>
{#if loading}
<div class="loading">Loading...</div>
{:else if error && !forum}
<div class="error">{error}</div>
{:else if forum}
<!-- Hidden file input for banner upload -->
<input
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
bind:this={bannerInput}
on:change={handleBannerUpload}
style="display: none;"
/>
{#if bannerError}
<div class="banner-error">{bannerError}</div>
{/if}
<!-- Banner display -->
{#if forum.bannerUrl}
<div class="forum-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}%;"
/>
</div>
{/if}
<div class="forum-header">
<div class="forum-info">
<div class="forum-title-row">
<h1 style="color: {forum.titleColor || '#ffffff'};">{forum.name}</h1>
{#if canModerate}
<button
class="btn-banner-menu"
on:click|stopPropagation={() => showBannerMenu = !showBannerMenu}
disabled={uploadingBanner}
title={forum.bannerUrl ? "Edit banner" : "Add banner"}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</button>
{#if showBannerMenu}
<div class="banner-menu" on:click|stopPropagation>
<button on:click={() => { showBannerMenu = false; triggerBannerUpload(); }}>
{forum.bannerUrl ? 'Change Banner' : 'Add Banner'}
</button>
{#if forum.bannerUrl}
<button on:click={openPositionEditor}>
Adjust Position
</button>
<button class="danger" on:click={() => { showBannerMenu = false; deleteBanner(); }}>
Remove Banner
</button>
{/if}
<div class="menu-divider"></div>
<button on:click={openDescriptionEditor}>
Edit Description
</button>
<button on:click={openTitleColorEditor}>
Set Title Color
</button>
</div>
{/if}
{/if}
</div>
{#if forum.description}
<div class="forum-description">{forum.description}</div>
{/if}
<div class="forum-owner">
by <span
class="forum-owner-name"
style="color: {forum.userColor || 'var(--primary)'};"
on:click={(e) => handleUsernameClick(e, forum.username, forum.userId)}
>@{forum.username}</span>
</div>
</div>
{#if !isBanned}
<button class="btn" on:click={() => showCreateModal = true}>
New Thread
</button>
{/if}
</div>
{#if isBanned}
<div class="banned-notice">
You are banned from this forum and cannot create threads or post replies.
</div>
{/if}
{#if threads.length === 0}
<div class="empty">
No threads yet.
{#if !isBanned}
Start the first discussion!
{/if}
</div>
{:else}
<div class="threads-list">
{#each threads as thread}
<div class="thread-card" class:pinned={thread.isPinned} class:locked={thread.isLocked}>
<div class="thread-header">
{#if thread.isPinned}
<span class="thread-badge badge-pinned">PINNED</span>
{/if}
{#if thread.isLocked}
<span class="thread-badge badge-locked">LOCKED</span>
{/if}
<a href="/forums/{forum.slug}/thread/{thread.id}" class="thread-title">
{thread.title}
</a>
</div>
<div class="thread-preview">
{@html parsedThreadPreviews[thread.id] || ''}
</div>
<div class="thread-meta">
<span>
by <span
class="thread-author"
style="color: {thread.userColor || 'var(--primary)'};"
on:click={(e) => handleUsernameClick(e, thread.username, thread.userId)}
>@{thread.username}</span>
</span>
<span>{thread.postCount || 0} replies</span>
<span>Last activity {timeAgo(thread.lastPostAt)}</span>
{#if canModerate || ($auth.user && thread.userId === $auth.user.id)}
<div class="thread-actions">
{#if canModerate}
<button class="action-btn" on:click|stopPropagation={() => togglePin(thread)}>
{thread.isPinned ? 'Unpin' : 'Pin'}
</button>
<button class="action-btn" on:click|stopPropagation={() => toggleLock(thread)}>
{thread.isLocked ? 'Unlock' : 'Lock'}
</button>
{/if}
<button class="action-btn danger" on:click|stopPropagation={() => deleteThread(thread.id)}>
Delete
</button>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
{#if showCreateModal}
<div class="modal" on:click={() => showCreateModal = false}>
<div class="modal-content" style="max-width: 600px;" on:click|stopPropagation>
<div class="modal-header">
<h2>Create Thread</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={createThread}>
<div class="form-group">
<label for="thread-title">Title</label>
<input
id="thread-title"
type="text"
bind:value={newThread.title}
placeholder="Enter thread title"
maxlength="255"
required
/>
</div>
<div class="form-group">
<label for="thread-content">Content</label>
<textarea
id="thread-content"
bind:value={newThread.content}
placeholder="Write your post... (Markdown and :stickers: supported)"
rows="8"
required
></textarea>
<p class="form-hint">
Supports Markdown formatting and :sticker: syntax
</p>
</div>
<button type="submit" class="btn btn-block" disabled={creating}>
{creating ? 'Creating...' : 'Create Thread'}
</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 showPositionEditor && forum?.bannerUrl}
<div class="modal" on:click={closePositionEditor}>
<div class="modal-content position-editor-modal" on:click|stopPropagation>
<div class="modal-header">
<h2>Adjust Banner Position</h2>
<button class="modal-close" on:click={closePositionEditor}>&times;</button>
</div>
<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"
aria-valuenow={bannerPosition}
tabindex="0"
>
<img
src={forum.bannerUrl}
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>
</div>
<div class="position-controls">
<div class="zoom-control">
<label for="banner-zoom">Zoom: {bannerZoom}%</label>
<input
id="banner-zoom"
type="range"
min="100"
max="200"
step="5"
bind:value={bannerZoom}
/>
</div>
<div class="position-display">
<span>X: {Math.round(bannerPositionX)}%</span>
<span>Y: {Math.round(bannerPosition)}%</span>
</div>
<button
type="button"
class="reset-btn"
on:click={() => {
bannerZoom = 100;
bannerPosition = 50;
bannerPositionX = 50;
}}
>
Reset to Default
</button>
</div>
<div class="position-actions">
<button class="btn btn-secondary" on:click={closePositionEditor} disabled={savingPosition}>
Cancel
</button>
<button class="btn" on:click={saveBannerPosition} disabled={savingPosition}>
{savingPosition ? 'Saving...' : 'Save Position'}
</button>
</div>
</div>
</div>
{/if}
{#if showTitleColorEditor}
<div class="modal" on:click={closeTitleColorEditor}>
<div class="modal-content" style="max-width: 400px;" on:click|stopPropagation>
<div class="modal-header">
<h2>Set Title Color</h2>
<button class="modal-close" on:click={closeTitleColorEditor}>&times;</button>
</div>
<div class="form-group">
<label for="title-color-input">Forum Title Color</label>
<div class="color-picker-row">
<input
id="title-color-input"
type="color"
bind:value={titleColor}
class="color-input"
/>
<input
type="text"
bind:value={titleColor}
placeholder="#ffffff"
maxlength="7"
class="color-text-input"
/>
</div>
<span class="color-preview-text" style="color: {titleColor};">
{forum?.name || 'Forum Title'}
</span>
</div>
<div class="position-actions">
<button class="btn btn-secondary" on:click={closeTitleColorEditor} disabled={savingTitleColor}>
Cancel
</button>
<button class="btn" on:click={saveTitleColor} disabled={savingTitleColor}>
{savingTitleColor ? 'Saving...' : 'Save Color'}
</button>
</div>
</div>
</div>
{/if}
{#if showDescriptionEditor}
<div class="modal" on:click={closeDescriptionEditor}>
<div class="modal-content" style="max-width: 500px;" on:click|stopPropagation>
<div class="modal-header">
<h2>Edit Description</h2>
<button class="modal-close" on:click={closeDescriptionEditor}>&times;</button>
</div>
<div class="form-group">
<label for="description-input">Forum Description</label>
<textarea
id="description-input"
bind:value={editedDescription}
placeholder="Enter a description for this forum..."
rows="4"
></textarea>
</div>
<div class="position-actions">
<button class="btn btn-secondary" on:click={closeDescriptionEditor} disabled={savingDescription}>
Cancel
</button>
<button class="btn" on:click={saveDescription} disabled={savingDescription}>
{savingDescription ? 'Saving...' : 'Save Description'}
</button>
</div>
</div>
</div>
{/if}