This commit is contained in:
retoor 2025-07-12 20:58:19 +02:00
parent bdee3e844f
commit 8321761358
5 changed files with 388 additions and 281 deletions

View File

@ -17,13 +17,15 @@ class ForumModel(BaseModel):
last_thread_uid = ModelField(name="last_thread_uid", required=False, kind=str)
async def get_threads(self, limit=50, offset=0):
return await self.app.services.thread.find(
async for thread in self.app.services.thread.find(
forum_uid=self["uid"],
deleted_at=None,
_limit=limit,
_offset=offset,
_order_by="is_pinned DESC, last_post_at DESC"
)
order_by="-last_post_at"
#order_by="is_pinned DESC, last_post_at DESC"
):
yield thread
async def increment_thread_count(self):
self["thread_count"] += 1
@ -49,13 +51,14 @@ class ThreadModel(BaseModel):
last_post_by_uid = ModelField(name="last_post_by_uid", required=False, kind=str)
async def get_posts(self, limit=50, offset=0):
return await self.app.services.post.find(
async for post in self.app.services.post.find(
thread_uid=self["uid"],
deleted_at=None,
_limit=limit,
_offset=offset,
_order_by="created_at ASC"
)
order_by="created_at"
):
yield post
async def increment_view_count(self):
self["view_count"] += 1

View File

@ -4,14 +4,15 @@ import re
import uuid
from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, List
from snek.system.model import now
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
class BaseForumService(BaseService):
"""
Base mix-in that gives a service `add_notification_listener`,
an internal `_dispatch_event` helper, and a public `notify` method.
"""
def get_timestamp(self):
return now()
def generate_uid(self):
return str(uuid.uuid4())
@ -93,7 +94,7 @@ class ForumService(BaseForumService):
return slug
async def get_active_forums(self):
async for forum in self.find(is_active=True, _order_by="position ASC, created_at ASC"):
async for forum in self.find(is_active=True, order_by="position"):
yield forum
async def update_last_post(self, forum_uid, thread_uid):
@ -108,6 +109,8 @@ class ForumService(BaseForumService):
class ThreadService(BaseForumService):
mapper_name = "thread"
async def create_thread(self, forum_uid, title, content, created_by_uid):
# Generate slug
slug = self.services.forum.generate_slug(title)

View File

@ -255,6 +255,9 @@ class BaseModel:
def mapper(self):
return self._mapper
def save(self):
return self.mapper.save(self)
@mapper.setter
def mapper(self, value):
self._mapper = value

View File

@ -1,13 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forum Component</title>
</head>
<body>
{% extends "app.html" %}
{% block title %}Forum{% endblock %}
{% block header_text %}<nav class="breadcrumb" id="breadcrumb">
<!-- Breadcrumb will be rendered here -->
</nav>{% endblock %}
{% block main %}
<style>
snek-forum, .container {
height: 100%;
width: 90%;
background: transparent !important;
}
</style>
<script>
// snek-forum.js - Forum Web Component
// snek-forum.js - Forum Web Component (Tech-centric Dark Theme)
class SnekForum extends HTMLElement {
constructor() {
super();
@ -46,7 +53,6 @@ class SnekForum extends HTMLElement {
this.ws.onclose = () => {
console.log('WebSocket disconnected');
// Reconnect after 3 seconds
setTimeout(() => this.connectWebSocket(), 3000);
};
}
@ -115,25 +121,29 @@ class SnekForum extends HTMLElement {
const data = await this.fetchAPI('/forum/api/forums');
this.currentView = 'forums';
this.renderForums(data.forums);
this.updateBreadcrumb();
} catch (error) {
console.error('Error loading forums:', error);
}
}
async loadForum(slug) {
async loadForum(slug, page = 1) {
try {
this.currentPage = page;
const data = await this.fetchAPI(`/forum/api/forums/${slug}?page=${this.currentPage}`);
this.currentView = 'forum';
this.currentForum = data.forum;
this.subscribe('forum', data.forum.uid);
this.renderForum(data);
this.updateBreadcrumb();
} catch (error) {
console.error('Error loading forum:', error);
}
}
async loadThread(slug) {
async loadThread(slug, page = 1) {
try {
this.currentPage = page;
const data = await this.fetchAPI(`/forum/api/threads/${slug}?page=${this.currentPage}`);
this.currentView = 'thread';
this.currentThread = data.thread;
@ -142,6 +152,7 @@ class SnekForum extends HTMLElement {
}
this.subscribe('thread', data.thread.uid);
this.renderThread(data);
this.updateBreadcrumb();
} catch (error) {
console.error('Error loading thread:', error);
}
@ -176,13 +187,13 @@ class SnekForum extends HTMLElement {
const data = await this.fetchAPI(`/forum/api/posts/${postUid}/like`, {
method: 'POST'
});
// Update UI
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${postUid}"]`);
if (postEl) {
const likeBtn = postEl.querySelector('.like-button');
const likeCount = postEl.querySelector('.like-count');
likeBtn.classList.toggle('liked', data.is_liked);
likeCount.textContent = data.like_count;
likeBtn.querySelector('span').textContent = data.is_liked ? '❤️' : '🤍';
}
} catch (error) {
console.error('Error toggling like:', error);
@ -190,413 +201,487 @@ class SnekForum extends HTMLElement {
}
render() {
const accent = "#00FFFF";
const accent_alt = "#FF6200";
const isCyan = true; // switch between cyan and orange here
// Generate subtle star background (canvas dots)
const bgSvg = encodeURIComponent(`
<svg width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
<g>
${Array.from({length: 40}).map(() => {
const x = Math.random() * 100;
const y = Math.random() * 100;
const r = Math.random() * 1.4 + 0.2;
return `<circle cx="${x}vw" cy="${y}vh" r="${r}" fill="white" opacity="0.2" />`;
}).join('')}
</g>
</svg>
`);
this.shadowRoot.innerHTML = `
<style>
@import url('https://fonts.googleapis.com/css?family=Montserrat:400,700&display=swap');
:host {
display: block;
min-height: 100vh;
background: #f5f5f5;
font-family: 'Montserrat', 'Poppins', 'Roboto', Arial, sans-serif;
background: transparent !important;
color: #fff;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
width: 100%;
height: 100%;
background: transparent !important;
}
.header {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
margin-bottom: 24px;
text-align: center;
}
.breadcrumb {
display: flex;
gap: 10px;
align-items: center;
font-size: 14px;
font-size: 14pt;
justify-content: center;
color: ${accent};
letter-spacing: 0.1em;
}
.breadcrumb a {
color: #666;
color: #fff;
text-decoration: none;
font-weight: 600;
cursor: pointer;
}
.breadcrumb a:hover {
text-decoration: underline;
color: ${accent_alt};
}
.breadcrumb span {
color: #A9A9A9;
font-weight: 700;
}
.content {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
min-height: 120px;
background: transparent !important;
}
/* Forums List */
.forums-list {
padding: 0;
margin: 0;
}
.forum-item {
display: flex;
padding: 20px;
border-bottom: 1px solid #eee;
align-items: center;
padding: 24px 18px;
cursor: pointer;
transition: background 0.2s;
transition: background 0.12s;
background: transparent !important;
}
.forum-item:hover {
background: #f9f9f9;
background: #23282D;
}
.forum-item:last-child {
border-bottom: none;
}
.forum-item:last-child { border-bottom: none; }
.forum-icon {
width: 48px;
height: 48px;
margin-right: 15px;
width: 56px;
height: 56px;
margin-right: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #eee;
border-radius: 8px;
font-size: 24px;
background: #202B2E;
border-radius: 10px;
font-size: 30px;
color: ${accent};
border: 1.5px solid ${accent};
box-shadow: 0 1px 4px 0 #00FFFF22;
}
.forum-info {
flex: 1;
}
.forum-info { flex: 1; }
.forum-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 5px;
font-size: 22pt;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 3px;
color: #fff;
letter-spacing: 0.05em;
}
.forum-description {
font-size: 14px;
color: #666;
font-size: 16pt;
color: #A9A9A9;
margin-bottom: 5px;
font-weight: 400;
}
.forum-stats {
font-size: 12px;
color: #999;
font-size: 12pt;
color: #00FFFF;
opacity: 0.7;
font-weight: 500;
}
/* Threads List */
.thread-item {
display: flex;
padding: 15px 20px;
border-bottom: 1px solid #eee;
padding: 18px 18px;
cursor: pointer;
transition: background 0.2s;
transition: background 0.12s;
background: transparent !important;
}
.thread-item:hover {
background: #f9f9f9;
background: #20262B;
}
.thread-item.pinned {
background: #fff9e6;
border-left: 5px solid ${accent};
}
.thread-info {
flex: 1;
}
.thread-info { flex: 1; }
.thread-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 5px;
font-size: 18pt;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
text-transform: uppercase;
color: #fff;
}
.thread-meta {
font-size: 13px;
color: #666;
font-size: 12pt;
color: #A9A9A9;
margin-top: 3px;
}
.thread-stats {
text-align: right;
font-size: 13px;
color: #666;
font-size: 11pt;
color: #A9A9A9;
min-width: 120px;
}
.badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
background: #eee;
color: #666;
font-size: 10pt;
padding: 2px 10px;
border-radius: 4px;
background: #232323;
color: ${accent};
font-weight: 700;
text-transform: uppercase;
margin-left: 4px;
}
.badge.pinned {
background: #ffd700;
color: #333;
background: ${accent};
color: #181E22;
}
.badge.locked {
background: #666;
color: white;
background: #fff;
color: #181E22;
border: 1px solid ${accent_alt};
}
/* Posts */
.posts-list { margin: 0; }
.post {
display: flex;
padding: 20px;
border-bottom: 1px solid #eee;
padding: 24px 18px;
background: transparent !important;
}
.post-author {
width: 150px;
margin-right: 20px;
width: 120px;
margin-right: 30px;
text-align: center;
}
.author-avatar {
width: 60px;
height: 60px;
width: 62px;
height: 62px;
border-radius: 50%;
background: #eee;
background: #232323;
margin: 0 auto 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-size: 27pt;
font-weight: bold;
border: 2px solid ${accent};
box-shadow: 0 2px 12px 0 #00FFFF11;
}
.author-name {
font-weight: 500;
margin-bottom: 5px;
font-weight: 700;
font-size: 13pt;
color: #fff;
letter-spacing: 0.03em;
}
.post-content {
flex: 1;
}
.post-content { flex: 1; }
.post-header {
font-size: 13px;
color: #666;
font-size: 12pt;
color: #A9A9A9;
margin-bottom: 10px;
}
.post-body {
line-height: 1.6;
line-height: 1.7;
font-size: 15pt;
color: #fff;
white-space: pre-wrap;
}
.post-footer {
display: flex;
gap: 15px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #f0f0f0;
gap: 24px;
margin-top: 18px;
padding-top: 14px;
}
.post-action {
font-size: 13px;
color: #666;
font-size: 13pt;
color: #A9A9A9;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
gap: 7px;
font-weight: 600;
letter-spacing: 0.03em;
transition: color 0.12s;
}
.post-action:hover {
color: #333;
.post-action:hover, .post-action.liked {
color: ${accent};
}
.like-button.liked {
color: #e74c3c;
.like-button.liked span:first-child {
color: ${accent_alt};
text-shadow: 0 2px 8px #FF620066;
}
/* Forms */
.form-group {
margin-bottom: 15px;
margin-bottom: 22px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
margin-bottom: 7px;
font-weight: 700;
text-transform: uppercase;
color: ${accent};
font-size: 15pt;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
padding: 13px 16px;
font-size: 16pt;
font-family: inherit;
color: #fff;
background: #232323;
outline: none;
transition: border-color 0.16s;
}
.form-group input:focus,
.form-group textarea:focus {
border-color: ${accent_alt};
}
.form-group textarea {
min-height: 150px;
min-height: 130px;
resize: vertical;
}
.button {
padding: 8px 16px;
padding: 12px 32px;
border: none;
border-radius: 4px;
font-size: 14px;
border-radius: 10px;
font-size: 16pt;
font-weight: 700;
cursor: pointer;
background: #007bff;
color: white;
background: ${accent};
color: #fff;
box-shadow: 0 2px 10px #00FFFF33;
text-transform: uppercase;
letter-spacing: 0.07em;
transition: background 0.12s, color 0.12s;
}
.button:hover {
background: #0056b3;
background: ${accent_alt};
color: #fff;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.new-thread-button {
float: right;
margin-bottom: 20px;
display: block;
margin: 0 auto 24px auto;
width: 250px;
}
.reply-form {
padding: 20px;
background: #f9f9f9;
border-top: 2px solid #eee;
width: 33%;
padding: 32px 18px 18px 18px;
margin-top: 0;
}
.reply-form h3 {
text-align: center;
color: ${accent};
font-size: 22pt;
margin-bottom: 18px;
font-weight: 700;
text-transform: uppercase;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1000;
}
.modal.show {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 8px;
padding: 30px;
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
.modal.show {
display: flex;
}
.modal-content {
background: #181E22;
border-radius: 14px;
padding: 36px 32px;
max-width: 520px;
width: 96vw;
max-height: 90vh;
box-shadow: 0 2px 32px 0 #000C;
color: #fff;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-title {
font-size: 20px;
font-weight: 600;
font-size: 24pt;
font-weight: 700;
text-transform: uppercase;
color: ${accent};
}
.modal-close {
font-size: 24px;
font-size: 32px;
cursor: pointer;
background: none;
border: none;
color: #999;
color: #A9A9A9;
transition: color 0.13s;
}
.modal-close:hover {
color: #333;
color: ${accent_alt};
}
.pagination {
display: flex;
justify-content: center;
gap: 10px;
padding: 20px;
gap: 15px;
padding: 24px 0 10px 0;
}
.page-button {
padding: 5px 10px;
border: 1px solid #ddd;
background: white;
padding: 7px 18px;
border: 1.5px solid ${accent};
background: #232323;
cursor: pointer;
border-radius: 4px;
border-radius: 8px;
color: #fff;
font-size: 14pt;
font-weight: 700;
text-transform: uppercase;
transition: background 0.13s, color 0.13s;
}
.page-button:hover {
background: #f0f0f0;
background: ${accent};
color: #181E22;
}
.page-button.active {
background: #007bff;
color: white;
border-color: #007bff;
background: ${accent};
color: #181E22;
border-color: ${accent_alt};
}
/* Feature card style for content sections */
.feature-card {
background: #1A1A1A;
border-radius: 10px;
box-shadow: 0 1px 8px #0006;
border: 1px solid #232323;
padding: 28px 22px;
margin-bottom: 20px;
color: #fff;
}
.feature-card .title {
color: ${accent_alt};
font-size: 20pt;
font-weight: 700;
margin-bottom: 9px;
text-transform: uppercase;
}
/* Minimal snake icon for branding */
.snake-icon {
display: inline-block;
width: 42px;
height: 42px;
margin-bottom: -10px;
vertical-align: middle;
}
.snake-icon path {
stroke: ${accent};
stroke-width: 3;
fill: none;
}
</style>
<div class="container">
<div class="header">
<nav class="breadcrumb">
<a href="#" @click="loadForums">Forums</a>
<span id="breadcrumb-extra"></span>
</nav>
</div>
<div class="content" id="main-content">
<!-- Content will be rendered here -->
</div>
</div>
<!-- New Thread Modal -->
<div class="modal" id="new-thread-modal">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">New Thread</h2>
<button class="modal-close" onclick="this.closest('.modal').classList.remove('show')">&times;</button>
<h2 class="modal-title">NEW THREAD</h2>
<button class="modal-close" type="button">&times;</button>
</div>
<form id="new-thread-form">
<div class="form-group">
<label>Title</label>
<input type="text" name="title" required minlength="5" maxlength="200">
<input type="text" name="title" required minlength="5" maxlength="200" placeholder="Thread Title">
</div>
<div class="form-group">
<label>Content</label>
<textarea name="content" required minlength="1"></textarea>
<textarea name="content" required minlength="1" placeholder="Write your post..."></textarea>
</div>
<button type="submit" class="button">Create Thread</button>
<button type="submit" class="button">CREATE THREAD</button>
</form>
</div>
</div>
`;
// Breadcrumb render
this.updateBreadcrumb();
// Add event listeners
this.shadowRoot.addEventListener('click', (e) => {
if (e.target.matches('[data-forum-slug]')) {
this.loadForum(e.target.dataset.forumSlug);
} else if (e.target.matches('[data-thread-slug]')) {
this.loadThread(e.target.dataset.threadSlug);
} else if (e.target.matches('.like-button')) {
// Breadcrumb navigation
if (e.target.closest('.breadcrumb a')) {
e.preventDefault();
const node = e.target.closest('.breadcrumb a');
const action = node.dataset.action;
if (action === "forums") {
this.loadForums();
} else if (action === "forum") {
this.loadForum(node.dataset.slug);
}
}
// Forum item
else if (e.target.closest('[data-forum-slug]')) {
this.loadForum(e.target.closest('[data-forum-slug]').dataset.forumSlug);
}
// Thread item
else if (e.target.closest('[data-thread-slug]')) {
this.loadThread(e.target.closest('[data-thread-slug]').dataset.threadSlug);
}
// Like button
else if (e.target.closest('.like-button')) {
this.toggleLike(e.target.closest('[data-post-uid]').dataset.postUid);
} else if (e.target.matches('.new-thread-button')) {
}
// New thread button
else if (e.target.closest('.new-thread-button')) {
this.shadowRoot.getElementById('new-thread-modal').classList.add('show');
}
// Modal close
else if (e.target.closest('.modal-close')) {
e.target.closest('.modal').classList.remove('show');
}
// Pagination buttons
else if (e.target.matches('.page-button[data-page]')) {
const page = parseInt(e.target.dataset.page, 10);
if (this.currentView === "forum") {
this.loadForum(this.currentForum.slug, page);
} else if (this.currentView === "thread") {
this.loadThread(this.currentThread.slug, page);
}
}
});
// Form submissions
@ -609,6 +694,23 @@ class SnekForum extends HTMLElement {
});
}
updateBreadcrumb() {
return;
const crumb = [];
crumb.push(`<a style="display: none;" href="#" data-action="forums">FORUMS</a>`);
if (this.currentView === "forum" && this.currentForum) {
crumb.push(`<span></span>`);
crumb.push(`<span>${this.currentForum.name.toUpperCase()}</span>`);
}
if (this.currentView === "thread" && this.currentThread && this.currentForum) {
crumb.push(`<span></span>`);
crumb.push(`<a href="#" data-action="forum" data-slug="${this.currentForum.slug}">${this.currentForum.name.toUpperCase()}</a>`);
crumb.push(`<span></span>`);
crumb.push(`<span>${this.currentThread.title.toUpperCase()}</span>`);
}
this.shadowRoot.getElementById('breadcrumb').innerHTML = crumb.join(' ');
}
renderForums(forums) {
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
@ -631,34 +733,31 @@ class SnekForum extends HTMLElement {
renderForum(data) {
const { forum, threads } = data;
const breadcrumb = this.shadowRoot.getElementById('breadcrumb-extra');
breadcrumb.innerHTML = `<span></span> <span>${forum.name}</span>`;
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div style="padding: 20px;">
<button class="button new-thread-button">New Thread</button>
<div style="padding: 18px;">
<button class="button new-thread-button">NEW THREAD</button>
<div style="clear: both;"></div>
<div class="threads-list">
${threads.map(thread => `
<div class="thread-item ${thread.is_pinned ? 'pinned' : ''}" data-thread-slug="${thread.slug}">
<div class="thread-info">
<div class="thread-title">
${thread.title}
${thread.is_pinned ? '<span class="badge pinned">Pinned</span>' : ''}
${thread.is_locked ? '<span class="badge locked">Locked</span>' : ''}
${thread.is_pinned ? '<span class="badge pinned">PINNED</span>' : ''}
${thread.is_locked ? '<span class="badge locked">LOCKED</span>' : ''}
</div>
<div class="thread-meta">
Started by ${thread.author.nick} · ${this.formatDate(thread.created_at)}
Started by <span style="color:#00FFFF;font-weight:700">${thread.author.nick}</span>
· ${this.formatDate(thread.created_at)}
</div>
</div>
<div class="thread-stats">
<div>${thread.post_count} replies</div>
<div>${thread.view_count} views</div>
${thread.last_post_author ? `
<div style="margin-top: 5px; font-size: 12px;">
Last: ${thread.last_post_author.nick}<br>
<div style="margin-top: 7px; font-size: 12pt;">
Last: <span style="color:#FF6200;">${thread.last_post_author.nick}</span><br>
${this.formatDate(thread.last_post_at)}
</div>
` : ''}
@ -666,12 +765,11 @@ class SnekForum extends HTMLElement {
</div>
`).join('')}
</div>
${data.hasMore ? `
<div class="pagination">
<button class="page-button" onclick="this.getRootNode().host.loadForum('${forum.slug}', ${data.page - 1})" ${data.page === 1 ? 'disabled' : ''}>Previous</button>
<button class="page-button" data-page="${data.page - 1}" ${data.page === 1 ? 'disabled' : ''}>Previous</button>
<span class="page-button active">${data.page}</span>
<button class="page-button" onclick="this.getRootNode().host.loadForum('${forum.slug}', ${data.page + 1})">Next</button>
<button class="page-button" data-page="${data.page + 1}">Next</button>
</div>
` : ''}
</div>
@ -680,14 +778,6 @@ class SnekForum extends HTMLElement {
renderThread(data) {
const { thread, forum, posts } = data;
const breadcrumb = this.shadowRoot.getElementById('breadcrumb-extra');
breadcrumb.innerHTML = `
<span></span>
<a href="#" onclick="this.getRootNode().host.loadForum('${forum.slug}')">${forum.name}</a>
<span></span>
<span>${thread.title}</span>
`;
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div class="posts-list">
@ -715,33 +805,29 @@ class SnekForum extends HTMLElement {
</div>
`).join('')}
</div>
${!thread.is_locked ? `
<div class="reply-form">
<h3>Reply to Thread</h3>
<h3>Reply</h3>
<form id="reply-form">
<div class="form-group">
<textarea name="content" placeholder="Write your reply..." required></textarea>
</div>
<button type="submit" class="button">Post Reply</button>
<button type="submit" class="button">POST REPLY</button>
</form>
</div>
` : `
<div class="reply-form">
<p style="text-align: center; color: #666;">This thread is locked.</p>
<p style="text-align: center; color: #A9A9A9; font-size: 18pt;">This thread is locked.</p>
</div>
`}
${data.hasMore ? `
<div class="pagination">
<button class="page-button" onclick="this.getRootNode().host.loadThread('${thread.slug}', ${data.page - 1})" ${data.page === 1 ? 'disabled' : ''}>Previous</button>
<button class="page-button" data-page="${data.page - 1}" ${data.page === 1 ? 'disabled' : ''}>Previous</button>
<span class="page-button active">${data.page}</span>
<button class="page-button" onclick="this.getRootNode().host.loadThread('${thread.slug}', ${data.page + 1})">Next</button>
<button class="page-button" data-page="${data.page + 1}">Next</button>
</div>
` : ''}
`;
// Add reply form listener
const replyForm = this.shadowRoot.getElementById('reply-form');
if (replyForm) {
replyForm.addEventListener('submit', (e) => {
@ -756,7 +842,6 @@ class SnekForum extends HTMLElement {
addNewPost(post) {
const postsList = this.shadowRoot.querySelector('.posts-list');
if (!postsList) return;
const postHtml = `
<div class="post" data-post-uid="${post.uid}">
<div class="post-author">
@ -805,12 +890,10 @@ class SnekForum extends HTMLElement {
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)} minutes ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)} hours ago`;
if (diff < 604800000) return `${Math.floor(diff / 86400000)} days ago`;
return date.toLocaleDateString();
}
@ -820,10 +903,8 @@ class SnekForum extends HTMLElement {
return div.innerHTML;
}
}
customElements.define('snek-forum', SnekForum);
</script>
<snek-forum></snek-forum>
</body>
</html>
{% endblock %}

View File

@ -101,9 +101,10 @@ class ForumView(BaseView):
async def get_forum(self):
request = self
self = request.app
setattr(self, "request", request)
"""GET /forum/api/forums/:slug - Get forum by slug"""
slug = self.request.match_info["slug"]
forum = await self.services.forum.find_one(slug=slug, is_active=True)
forum = await self.services.forum.get(slug=slug, is_active=True)
if not forum:
return web.json_response({"error": "Forum not found"}, status=404)
@ -163,12 +164,14 @@ class ForumView(BaseView):
async def create_thread(self):
request = self
self = request.app
setattr(self, "request", request)
"""POST /forum/api/forums/:slug/threads - Create new thread"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
slug = self.request.match_info["slug"]
forum = await self.services.forum.find_one(slug=slug, is_active=True)
forum = await self.services.forum.get(slug=slug, is_active=True)
if not forum:
return web.json_response({"error": "Forum not found"}, status=404)
@ -201,9 +204,11 @@ class ForumView(BaseView):
async def get_thread(self):
request = self
self = request.app
setattr(self, "request", request)
"""GET /forum/api/threads/:thread_slug - Get thread with posts"""
thread_slug = self.request.match_info["thread_slug"]
thread = await self.services.thread.find_one(slug=thread_slug)
thread = await self.services.thread.get(slug=thread_slug)
if not thread:
return web.json_response({"error": "Thread not found"}, status=404)
@ -277,6 +282,8 @@ class ForumView(BaseView):
async def create_post(self):
request = self
self = request.app
setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/posts - Create new post"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
@ -324,6 +331,8 @@ class ForumView(BaseView):
async def edit_post(self):
request = self
self = request.app
setattr(self, "request", request)
"""PUT /forum/api/posts/:post_uid - Edit post"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
@ -355,6 +364,8 @@ class ForumView(BaseView):
async def delete_post(self):
request = self
self = request.app
setattr(self, "request", request)
"""DELETE /forum/api/posts/:post_uid - Delete post"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
@ -374,6 +385,8 @@ class ForumView(BaseView):
async def toggle_like(self):
request = self
self = request.app
setattr(self, "request", request)
"""POST /forum/api/posts/:post_uid/like - Toggle post like"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
@ -399,6 +412,8 @@ class ForumView(BaseView):
async def toggle_pin(self):
request = self
self = request.app
setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/pin - Toggle thread pin"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
@ -418,6 +433,8 @@ class ForumView(BaseView):
async def toggle_lock(self):
request = self
self = request.app
setattr(self, "request", request)
"""POST /forum/api/threads/:thread_uid/lock - Toggle thread lock"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)