Update.
This commit is contained in:
parent
bdee3e844f
commit
8321761358
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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')">×</button>
|
||||
<h2 class="modal-title">NEW THREAD</h2>
|
||||
<button class="modal-close" type="button">×</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 %}
|
||||
|
||||
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user