1031 lines
31 KiB
Svelte
1031 lines
31 KiB
Svelte
|
|
<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}>×</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}>×</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}
|