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) last_thread_uid = ModelField(name="last_thread_uid", required=False, kind=str)
async def get_threads(self, limit=50, offset=0): 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"], forum_uid=self["uid"],
deleted_at=None, deleted_at=None,
_limit=limit, _limit=limit,
_offset=offset, _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): async def increment_thread_count(self):
self["thread_count"] += 1 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) last_post_by_uid = ModelField(name="last_post_by_uid", required=False, kind=str)
async def get_posts(self, limit=50, offset=0): 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"], thread_uid=self["uid"],
deleted_at=None, deleted_at=None,
_limit=limit, _limit=limit,
_offset=offset, _offset=offset,
_order_by="created_at ASC" order_by="created_at"
) ):
yield post
async def increment_view_count(self): async def increment_view_count(self):
self["view_count"] += 1 self["view_count"] += 1

View File

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

View File

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

View File

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

View File

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