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]) {
|
2026-01-06 15:22:41 -05:00
|
|
|
return `<img src="${stickerMap[key]}" alt="${name}" class="sticker-img-small" onerror="this.onerror=null;this.src='/dlive2.gif';" />`;
|
2026-01-05 22:54:27 -05:00
|
|
|
}
|
|
|
|
|
return match;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let html = marked.parse(preview);
|
|
|
|
|
|
|
|
|
|
return DOMPurify.sanitize(html, {
|
|
|
|
|
ALLOWED_TAGS: ['p', 'img', 'br', 'strong', 'em', 'code', 'del', 'span'],
|
2026-01-06 15:22:41 -05:00
|
|
|
ALLOWED_ATTR: ['src', 'alt', 'class', 'onerror'],
|
2026-01-05 22:54:27 -05:00
|
|
|
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);
|
|
|
|
|
|
2026-01-06 04:45:39 -05:00
|
|
|
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 {
|
2026-01-06 04:45:39 -05:00
|
|
|
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 {
|
2026-01-06 04:45:39 -05:00
|
|
|
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 {
|
2026-01-06 04:45:39 -05:00
|
|
|
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}>×</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}>×</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}>×</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}>×</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}
|