Added forum.

This commit is contained in:
retoor 2025-07-12 02:41:57 +02:00
parent 94c5ce4989
commit 081d66695c
10 changed files with 1945 additions and 22 deletions

View File

@ -76,6 +76,7 @@ from snek.view.upload import UploadView
from snek.view.user import UserView from snek.view.user import UserView
from snek.view.web import WebView from snek.view.web import WebView
from snek.webdav import WebdavApplication from snek.webdav import WebdavApplication
from snek.forum import setup_forum
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34" SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
from snek.system.template import whitelist_attributes from snek.system.template import whitelist_attributes
@ -152,7 +153,7 @@ class Application(BaseApplication):
self.ssh_host = "0.0.0.0" self.ssh_host = "0.0.0.0"
self.ssh_port = 2242 self.ssh_port = 2242
self.setup_router() self.forum = None
self.ssh_server = None self.ssh_server = None
self.sync_service = None self.sync_service = None
self.executor = None self.executor = None
@ -161,6 +162,8 @@ class Application(BaseApplication):
self.mappers = get_mappers(app=self) self.mappers = get_mappers(app=self)
self.broadcast_service = None self.broadcast_service = None
self.user_availability_service_task = None self.user_availability_service_task = None
self.setup_router()
base_path = pathlib.Path(__file__).parent base_path = pathlib.Path(__file__).parent
self.ip2location = IP2Location.IP2Location( self.ip2location = IP2Location.IP2Location(
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN") base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
@ -333,9 +336,10 @@ class Application(BaseApplication):
) )
self.webdav = WebdavApplication(self) self.webdav = WebdavApplication(self)
self.git = GitApplication(self) self.git = GitApplication(self)
self.add_subapp("/webdav", self.webdav) self.add_subapp("/webdav", self.webdav)
self.add_subapp("/git", self.git) self.add_subapp("/git", self.git)
setup_forum(self)
# self.router.add_get("/{file_path:.*}", self.static_handler) # self.router.add_get("/{file_path:.*}", self.static_handler)
async def handle_test(self, request): async def handle_test(self, request):

115
src/snek/forum.py Normal file
View File

@ -0,0 +1,115 @@
# forum_app.py
import aiohttp.web
from snek.view.forum import ForumIndexView, ForumView, ForumWebSocketView
class ForumApplication(aiohttp.web.Application):
def __init__(self, parent, *args, **kwargs):
super().__init__(*args, **kwargs)
self.parent = parent
self.render_template = self.parent.render_template
# Set up routes
self.setup_routes()
# Set up notification listeners
self.setup_notifications()
@property
def db(self):
return self.parent.db
@property
def services(self):
return self.parent.services
def setup_routes(self):
"""Set up all forum routes"""
# API routes
self.router.add_view("/index.html", ForumIndexView)
self.router.add_route("GET", "/api/forums", ForumView.get_forums)
self.router.add_route("GET", "/api/forums/{slug}", ForumView.get_forum)
self.router.add_route("POST", "/api/forums/{slug}/threads", ForumView.create_thread)
self.router.add_route("GET", "/api/threads/{thread_slug}", ForumView.get_thread)
self.router.add_route("POST", "/api/threads/{thread_uid}/posts", ForumView.create_post)
self.router.add_route("PUT", "/api/posts/{post_uid}", ForumView.edit_post)
self.router.add_route("DELETE", "/api/posts/{post_uid}", ForumView.delete_post)
self.router.add_route("POST", "/api/posts/{post_uid}/like", ForumView.toggle_like)
self.router.add_route("POST", "/api/threads/{thread_uid}/pin", ForumView.toggle_pin)
self.router.add_route("POST", "/api/threads/{thread_uid}/lock", ForumView.toggle_lock)
# WebSocket route
self.router.add_view("/ws", ForumWebSocketView)
# Static HTML route
self.router.add_route("GET", "/{path:.*}", self.serve_forum_html)
def setup_notifications(self):
"""Set up notification listeners for WebSocket broadcasting"""
# Forum notifications
self.services.forum.add_notification_listener("forum_created", self.on_forum_event)
# Thread notifications
self.services.thread.add_notification_listener("thread_created", self.on_thread_event)
# Post notifications
self.services.post.add_notification_listener("post_created", self.on_post_event)
self.services.post.add_notification_listener("post_edited", self.on_post_event)
self.services.post.add_notification_listener("post_deleted", self.on_post_event)
# Like notifications
self.services.post_like.add_notification_listener("post_liked", self.on_like_event)
self.services.post_like.add_notification_listener("post_unliked", self.on_like_event)
async def on_forum_event(self, event_type, data):
"""Handle forum events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_thread_event(self, event_type, data):
"""Handle thread events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_post_event(self, event_type, data):
"""Handle post events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def on_like_event(self, event_type, data):
"""Handle like events"""
await ForumWebSocketView.broadcast_update(self, event_type, data)
async def serve_forum_html(self, request):
"""Serve the forum HTML with the web component"""
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forum</title>
<script type="module" src="/forum/static/snek-forum.js"></script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
}
</style>
</head>
<body>
<snek-forum></snek-forum>
</body>
</html>"""
return await self.parent.render_template("forum.html", request)
#return aiohttp.web.Response(text=html, content_type="text/html")
# Integration with main app
def setup_forum(app):
"""Set up forum sub-application"""
forum_app = ForumApplication(app)
app.add_subapp("/forum", forum_app)
app.forum_app = forum_app
# Register models and services if needed
# This would typically be done in your main app initialization
return forum_app

View File

@ -12,6 +12,7 @@ from snek.mapper.push import PushMapper
from snek.mapper.repository import RepositoryMapper from snek.mapper.repository import RepositoryMapper
from snek.mapper.user import UserMapper from snek.mapper.user import UserMapper
from snek.mapper.user_property import UserPropertyMapper from snek.mapper.user_property import UserPropertyMapper
from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
from snek.system.object import Object from snek.system.object import Object
@ -31,9 +32,14 @@ def get_mappers(app=None):
"channel_attachment": ChannelAttachmentMapper(app=app), "channel_attachment": ChannelAttachmentMapper(app=app),
"container": ContainerMapper(app=app), "container": ContainerMapper(app=app),
"push": PushMapper(app=app), "push": PushMapper(app=app),
"forum": ForumMapper(app=app),
"thread": ThreadMapper(app=app),
"post": PostMapper(app=app),
"post_like": PostLikeMapper(app=app),
} }
) )
def get_mapper(name, app=None): def get_mapper(name, app=None):
return get_mappers(app=app)[name] return get_mappers(app=app)[name]

23
src/snek/mapper/forum.py Normal file
View File

@ -0,0 +1,23 @@
# mapper/forum.py
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.system.mapper import BaseMapper
class ForumMapper(BaseMapper):
table_name = "forum"
model_class = ForumModel
class ThreadMapper(BaseMapper):
table_name = "thread"
model_class = ThreadModel
class PostMapper(BaseMapper):
table_name = "post"
model_class = PostModel
class PostLikeMapper(BaseMapper):
table_name = "post_like"
model_class = PostLikeModel

View File

@ -14,6 +14,7 @@ from snek.model.push_registration import PushRegistrationModel
from snek.model.repository import RepositoryModel from snek.model.repository import RepositoryModel
from snek.model.user import UserModel from snek.model.user import UserModel
from snek.model.user_property import UserPropertyModel from snek.model.user_property import UserPropertyModel
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
from snek.system.object import Object from snek.system.object import Object
@ -33,9 +34,15 @@ def get_models():
"channel_attachment": ChannelAttachmentModel, "channel_attachment": ChannelAttachmentModel,
"container": Container, "container": Container,
"push_registration": PushRegistrationModel, "push_registration": PushRegistrationModel,
"forum": ForumModel,
"thread": ThreadModel,
"post": PostModel,
"post_like": PostLikeModel,
} }
) )
def get_model(name): def get_model(name):
return get_models()[name] return get_models()[name]

96
src/snek/model/forum.py Normal file
View File

@ -0,0 +1,96 @@
# models/forum.py
from snek.system.model import BaseModel, ModelField
class ForumModel(BaseModel):
"""Forum categories"""
name = ModelField(name="name", required=True, kind=str, min_length=3, max_length=100)
description = ModelField(name="description", required=False, kind=str, max_length=500)
slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$", unique=True)
icon = ModelField(name="icon", required=False, kind=str)
position = ModelField(name="position", required=True, kind=int, value=0)
is_active = ModelField(name="is_active", required=True, kind=bool, value=True)
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
thread_count = ModelField(name="thread_count", required=True, kind=int, value=0)
post_count = ModelField(name="post_count", required=True, kind=int, value=0)
last_post_at = ModelField(name="last_post_at", 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):
return await 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"
)
async def increment_thread_count(self):
self["thread_count"] += 1
await self.save()
async def increment_post_count(self):
self["post_count"] += 1
await self.save()
# models/thread.py
class ThreadModel(BaseModel):
"""Forum threads"""
forum_uid = ModelField(name="forum_uid", required=True, kind=str)
title = ModelField(name="title", required=True, kind=str, min_length=5, max_length=200)
slug = ModelField(name="slug", required=True, kind=str, regex=r"^[a-z0-9-]+$")
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
is_pinned = ModelField(name="is_pinned", required=True, kind=bool, value=False)
is_locked = ModelField(name="is_locked", required=True, kind=bool, value=False)
view_count = ModelField(name="view_count", required=True, kind=int, value=0)
post_count = ModelField(name="post_count", required=True, kind=int, value=0)
last_post_at = ModelField(name="last_post_at", 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):
return await self.app.services.post.find(
thread_uid=self["uid"],
deleted_at=None,
_limit=limit,
_offset=offset,
_order_by="created_at ASC"
)
async def increment_view_count(self):
self["view_count"] += 1
await self.save()
async def increment_post_count(self):
self["post_count"] += 1
self["last_post_at"] = self.app.services.get_timestamp()
await self.save()
# models/post.py
class PostModel(BaseModel):
"""Forum posts"""
thread_uid = ModelField(name="thread_uid", required=True, kind=str)
forum_uid = ModelField(name="forum_uid", required=True, kind=str)
content = ModelField(name="content", required=True, kind=str, min_length=1, max_length=10000)
created_by_uid = ModelField(name="created_by_uid", required=True, kind=str)
edited_at = ModelField(name="edited_at", required=False, kind=str)
edited_by_uid = ModelField(name="edited_by_uid", required=False, kind=str)
is_first_post = ModelField(name="is_first_post", required=True, kind=bool, value=False)
like_count = ModelField(name="like_count", required=True, kind=int, value=0)
async def get_author(self):
return await self.app.services.user.get(uid=self["created_by_uid"])
async def is_liked_by(self, user_uid):
return await self.app.services.post_like.exists(
post_uid=self["uid"],
user_uid=user_uid
)
# models/post_like.py
class PostLikeModel(BaseModel):
"""Post likes"""
post_uid = ModelField(name="post_uid", required=True, kind=str)
user_uid = ModelField(name="user_uid", required=True, kind=str)

View File

@ -17,32 +17,49 @@ from snek.service.user import UserService
from snek.service.user_property import UserPropertyService from snek.service.user_property import UserPropertyService
from snek.service.util import UtilService from snek.service.util import UtilService
from snek.system.object import Object from snek.system.object import Object
from snek.service.statistics import StatisticsService from snek.service.statistics import StatisticsService
from snek.service.forum import ForumService, ThreadService, PostService, PostLikeService
_service_registry = {}
def register_service(name, service_cls):
_service_registry[name] = service_cls
register = register_service
@functools.cache @functools.cache
def get_services(app): def get_services(app):
return Object( result = Object(
**{ **{
"user": UserService(app=app), name: service_cls(app=app)
"channel_member": ChannelMemberService(app=app), for name, service_cls in _service_registry.items()
"channel": ChannelService(app=app),
"channel_message": ChannelMessageService(app=app),
"chat": ChatService(app=app),
"socket": SocketService(app=app),
"notification": NotificationService(app=app),
"util": UtilService(app=app),
"drive": DriveService(app=app),
"drive_item": DriveItemService(app=app),
"user_property": UserPropertyService(app=app),
"repository": RepositoryService(app=app),
"db": DBService(app=app),
"channel_attachment": ChannelAttachmentService(app=app),
"container": ContainerService(app=app),
"push": PushService(app=app),
"statistics": StatisticsService(app=app),
} }
) )
result.register = register_service
return result
def get_service(name, app=None): def get_service(name, app=None):
return get_services(app=app)[name] return get_services(app=app)[name]
# Registering all services
register_service("user", UserService)
register_service("channel_member", ChannelMemberService)
register_service("channel", ChannelService)
register_service("channel_message", ChannelMessageService)
register_service("chat", ChatService)
register_service("socket", SocketService)
register_service("notification", NotificationService)
register_service("util", UtilService)
register_service("drive", DriveService)
register_service("drive_item", DriveItemService)
register_service("user_property", UserPropertyService)
register_service("repository", RepositoryService)
register_service("db", DBService)
register_service("channel_attachment", ChannelAttachmentService)
register_service("container", ContainerService)
register_service("push", PushService)
register_service("statistics", StatisticsService)
register_service("forum", ForumService)
register_service("thread", ThreadService)
register_service("post", PostService)
register_service("post_like", PostLikeService)

293
src/snek/service/forum.py Normal file
View File

@ -0,0 +1,293 @@
# services/forum.py
from snek.system.service import BaseService
import re
from collections import defaultdict
from typing import Any, Awaitable, Callable, Dict, List
EventListener = Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
class BaseForumService(BaseService):
"""
Base mix-in that gives a service `add_notification_listener`
and an internal `_dispatch_event` helper.
"""
def __init__(self,*args, **kwargs) -> None:
# Map event name -> list of listener callables
self._listeners: Dict[str, List[EventListener]] = defaultdict(list)
super().__init__(*args, **kwargs)
def add_notification_listener(
self, event_name: str, listener: EventListener
) -> None:
"""
Register a callback to be fired when `event_name` happens.
Parameters
----------
event_name : str
The name of the domain event, e.g. "post_created".
listener : Callable[[str, Any], Awaitable[None]] | Callable[[str, Any], None]
Your handler; can be async or sync.
"""
if not callable(listener):
raise TypeError("listener must be callable")
self._listeners[event_name].append(listener)
# -----------------------------------------------------------------
# The piece below is optional but makes the service fully usable.
# Call `_dispatch_event` whenever the service actually performs an
# action that should notify listeners.
# -----------------------------------------------------------------
async def _dispatch_event(self, event_name: str, data: Any) -> None:
"""Invoke every listener for the given event."""
for listener in self._listeners.get(event_name, []):
if hasattr(listener, "__await__"): # async function or coro
await listener(event_name, data)
else: # plain sync function
listener(event_name, data)
class ForumService(BaseForumService):
mapper_name = "forum"
async def create_forum(self, name, description, created_by_uid, slug=None, icon=None):
if not slug:
slug = self.generate_slug(name)
# Check if slug exists
existing = await self.find_one(slug=slug)
if existing:
slug = f"{slug}-{self.generate_uid()[:8]}"
model = await self.new()
model["name"] = name
model["description"] = description
model["slug"] = slug
model["created_by_uid"] = created_by_uid
if icon:
model["icon"] = icon
if await self.save(model):
await self.notify("forum_created", model)
return model
raise Exception(f"Failed to create forum: {model.errors}")
def generate_slug(self, text):
# Convert to lowercase and replace spaces with hyphens
slug = text.lower().strip()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[-\s]+', '-', slug)
return slug
async def get_active_forums(self):
async for forum in self.find(is_active=True, _order_by="position ASC, created_at ASC"):
yield forum
async def update_last_post(self, forum_uid, thread_uid):
forum = await self.get(uid=forum_uid)
if forum:
forum["last_post_at"] = self.get_timestamp()
forum["last_thread_uid"] = thread_uid
await self.save(forum)
# services/thread.py
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)
# Check if slug exists in this forum
existing = await self.find_one(forum_uid=forum_uid, slug=slug)
if existing:
slug = f"{slug}-{self.generate_uid()[:8]}"
# Create thread
thread = await self.new()
thread["forum_uid"] = forum_uid
thread["title"] = title
thread["slug"] = slug
thread["created_by_uid"] = created_by_uid
thread["last_post_at"] = self.get_timestamp()
thread["last_post_by_uid"] = created_by_uid
if await self.save(thread):
# Create first post
post = await self.services.post.create_post(
thread_uid=thread["uid"],
forum_uid=forum_uid,
content=content,
created_by_uid=created_by_uid,
is_first_post=True
)
# Update forum counters
forum = await self.services.forum.get(uid=forum_uid)
await forum.increment_thread_count()
await self.services.forum.update_last_post(forum_uid, thread["uid"])
await self.notify("thread_created", {
"thread": thread,
"forum_uid": forum_uid
})
return thread, post
raise Exception(f"Failed to create thread: {thread.errors}")
async def toggle_pin(self, thread_uid, user_uid):
thread = await self.get(uid=thread_uid)
if not thread:
return None
# Check if user is admin
user = await self.services.user.get(uid=user_uid)
if not user.get("is_admin"):
return None
thread["is_pinned"] = not thread["is_pinned"]
await self.save(thread)
return thread
async def toggle_lock(self, thread_uid, user_uid):
thread = await self.get(uid=thread_uid)
if not thread:
return None
# Check if user is admin or thread creator
user = await self.services.user.get(uid=user_uid)
if not user.get("is_admin") and thread["created_by_uid"] != user_uid:
return None
thread["is_locked"] = not thread["is_locked"]
await self.save(thread)
return thread
# services/post.py
class PostService(BaseForumService):
mapper_name = "post"
async def create_post(self, thread_uid, forum_uid, content, created_by_uid, is_first_post=False):
# Check if thread is locked
thread = await self.services.thread.get(uid=thread_uid)
if thread["is_locked"] and not is_first_post:
raise Exception("Thread is locked")
post = await self.new()
post["thread_uid"] = thread_uid
post["forum_uid"] = forum_uid
post["content"] = content
post["created_by_uid"] = created_by_uid
post["is_first_post"] = is_first_post
if await self.save(post):
# Update thread counters
if not is_first_post:
thread["post_count"] += 1
thread["last_post_at"] = self.get_timestamp()
thread["last_post_by_uid"] = created_by_uid
await self.services.thread.save(thread)
# Update forum counters
forum = await self.services.forum.get(uid=forum_uid)
await forum.increment_post_count()
await self.services.forum.update_last_post(forum_uid, thread_uid)
await self.notify("post_created", {
"post": post,
"thread_uid": thread_uid,
"forum_uid": forum_uid
})
return post
raise Exception(f"Failed to create post: {post.errors}")
async def edit_post(self, post_uid, content, user_uid):
post = await self.get(uid=post_uid)
if not post:
return None
# Check permissions
user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
return None
post["content"] = content
post["edited_at"] = self.get_timestamp()
post["edited_by_uid"] = user_uid
if await self.save(post):
await self.notify("post_edited", post)
return post
return None
async def delete_post(self, post_uid, user_uid):
post = await self.get(uid=post_uid)
if not post:
return False
# Check permissions
user = await self.services.user.get(uid=user_uid)
if post["created_by_uid"] != user_uid and not user.get("is_admin"):
return False
# Don't allow deleting first post
if post["is_first_post"]:
return False
post["deleted_at"] = self.get_timestamp()
if await self.save(post):
await self.notify("post_deleted", post)
return True
return False
# services/post_like.py
class PostLikeService(BaseForumService):
mapper_name = "post_like"
async def toggle_like(self, post_uid, user_uid):
# Check if already liked
existing = await self.find_one(post_uid=post_uid, user_uid=user_uid)
if existing:
# Unlike
await self.delete(uid=existing["uid"])
# Update post like count
post = await self.services.post.get(uid=post_uid)
post["like_count"] = max(0, post["like_count"] - 1)
await self.services.post.save(post)
await self.notify("post_unliked", {
"post_uid": post_uid,
"user_uid": user_uid
})
return False
else:
# Like
like = await self.new()
like["post_uid"] = post_uid
like["user_uid"] = user_uid
if await self.save(like):
# Update post like count
post = await self.services.post.get(uid=post_uid)
post["like_count"] += 1
await self.services.post.save(post)
await self.notify("post_liked", {
"post_uid": post_uid,
"user_uid": user_uid
})
return True
return None

View File

@ -0,0 +1,829 @@
<!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>
<script>
// snek-forum.js - Forum Web Component
class SnekForum extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.ws = null;
this.currentView = 'forums';
this.currentForum = null;
this.currentThread = null;
this.currentPage = 1;
}
connectedCallback() {
this.render();
this.connectWebSocket();
this.loadForums();
}
disconnectedCallback() {
if (this.ws) {
this.ws.close();
}
}
connectWebSocket() {
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/forum/ws`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
};
this.ws.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleWebSocketMessage(data);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
// Reconnect after 3 seconds
setTimeout(() => this.connectWebSocket(), 3000);
};
}
handleWebSocketMessage(data) {
switch (data.type) {
case 'post_created':
if (this.currentView === 'thread' && this.currentThread?.uid === data.data.thread_uid) {
this.addNewPost(data.data.post);
}
break;
case 'post_edited':
this.updatePost(data.data.post);
break;
case 'post_deleted':
this.removePost(data.data.post.uid);
break;
case 'post_liked':
case 'post_unliked':
this.updatePostLikes(data.data.post_uid);
break;
case 'thread_created':
if (this.currentView === 'forum' && this.currentForum?.uid === data.data.forum_uid) {
this.loadForum(this.currentForum.slug);
}
break;
}
}
subscribe(type, id) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
action: 'subscribe',
type: type,
id: id
}));
}
}
unsubscribe(type, id) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
action: 'unsubscribe',
type: type,
id: id
}));
}
}
async fetchAPI(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
async loadForums() {
try {
const data = await this.fetchAPI('/forum/api/forums');
this.currentView = 'forums';
this.renderForums(data.forums);
} catch (error) {
console.error('Error loading forums:', error);
}
}
async loadForum(slug) {
try {
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);
} catch (error) {
console.error('Error loading forum:', error);
}
}
async loadThread(slug) {
try {
const data = await this.fetchAPI(`/forum/api/threads/${slug}?page=${this.currentPage}`);
this.currentView = 'thread';
this.currentThread = data.thread;
if (this.currentForum) {
this.unsubscribe('forum', this.currentForum.uid);
}
this.subscribe('thread', data.thread.uid);
this.renderThread(data);
} catch (error) {
console.error('Error loading thread:', error);
}
}
async createThread(forumSlug, title, content) {
try {
const data = await this.fetchAPI(`/forum/api/forums/${forumSlug}/threads`, {
method: 'POST',
body: JSON.stringify({ title, content })
});
this.loadThread(data.thread.slug);
} catch (error) {
console.error('Error creating thread:', error);
}
}
async createPost(threadUid, content) {
try {
await this.fetchAPI(`/forum/api/threads/${threadUid}/posts`, {
method: 'POST',
body: JSON.stringify({ content })
});
// Post will be added via WebSocket
} catch (error) {
console.error('Error creating post:', error);
}
}
async toggleLike(postUid) {
try {
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;
}
} catch (error) {
console.error('Error toggling like:', error);
}
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
min-height: 100vh;
background: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.breadcrumb {
display: flex;
gap: 10px;
align-items: center;
font-size: 14px;
}
.breadcrumb a {
color: #666;
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.content {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* Forums List */
.forums-list {
padding: 0;
}
.forum-item {
display: flex;
padding: 20px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.forum-item:hover {
background: #f9f9f9;
}
.forum-item:last-child {
border-bottom: none;
}
.forum-icon {
width: 48px;
height: 48px;
margin-right: 15px;
display: flex;
align-items: center;
justify-content: center;
background: #eee;
border-radius: 8px;
font-size: 24px;
}
.forum-info {
flex: 1;
}
.forum-name {
font-size: 18px;
font-weight: 600;
margin-bottom: 5px;
}
.forum-description {
font-size: 14px;
color: #666;
margin-bottom: 5px;
}
.forum-stats {
font-size: 12px;
color: #999;
}
/* Threads List */
.thread-item {
display: flex;
padding: 15px 20px;
border-bottom: 1px solid #eee;
cursor: pointer;
transition: background 0.2s;
}
.thread-item:hover {
background: #f9f9f9;
}
.thread-item.pinned {
background: #fff9e6;
}
.thread-info {
flex: 1;
}
.thread-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 5px;
display: flex;
align-items: center;
gap: 8px;
}
.thread-meta {
font-size: 13px;
color: #666;
}
.thread-stats {
text-align: right;
font-size: 13px;
color: #666;
}
.badge {
font-size: 11px;
padding: 2px 6px;
border-radius: 3px;
background: #eee;
color: #666;
}
.badge.pinned {
background: #ffd700;
color: #333;
}
.badge.locked {
background: #666;
color: white;
}
/* Posts */
.post {
display: flex;
padding: 20px;
border-bottom: 1px solid #eee;
}
.post-author {
width: 150px;
margin-right: 20px;
text-align: center;
}
.author-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: #eee;
margin: 0 auto 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: bold;
}
.author-name {
font-weight: 500;
margin-bottom: 5px;
}
.post-content {
flex: 1;
}
.post-header {
font-size: 13px;
color: #666;
margin-bottom: 10px;
}
.post-body {
line-height: 1.6;
white-space: pre-wrap;
}
.post-footer {
display: flex;
gap: 15px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #f0f0f0;
}
.post-action {
font-size: 13px;
color: #666;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
}
.post-action:hover {
color: #333;
}
.like-button.liked {
color: #e74c3c;
}
/* Forms */
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
.form-group textarea {
min-height: 150px;
resize: vertical;
}
.button {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
background: #007bff;
color: white;
}
.button:hover {
background: #0056b3;
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.new-thread-button {
float: right;
margin-bottom: 20px;
}
.reply-form {
padding: 20px;
background: #f9f9f9;
border-top: 2px solid #eee;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
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-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.modal-title {
font-size: 20px;
font-weight: 600;
}
.modal-close {
font-size: 24px;
cursor: pointer;
background: none;
border: none;
color: #999;
}
.modal-close:hover {
color: #333;
}
.pagination {
display: flex;
justify-content: center;
gap: 10px;
padding: 20px;
}
.page-button {
padding: 5px 10px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
border-radius: 4px;
}
.page-button:hover {
background: #f0f0f0;
}
.page-button.active {
background: #007bff;
color: white;
border-color: #007bff;
}
</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>
</div>
<form id="new-thread-form">
<div class="form-group">
<label>Title</label>
<input type="text" name="title" required minlength="5" maxlength="200">
</div>
<div class="form-group">
<label>Content</label>
<textarea name="content" required minlength="1"></textarea>
</div>
<button type="submit" class="button">Create Thread</button>
</form>
</div>
</div>
`;
// 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')) {
this.toggleLike(e.target.closest('[data-post-uid]').dataset.postUid);
} else if (e.target.matches('.new-thread-button')) {
this.shadowRoot.getElementById('new-thread-modal').classList.add('show');
}
});
// Form submissions
this.shadowRoot.getElementById('new-thread-form').addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
this.createThread(this.currentForum.slug, formData.get('title'), formData.get('content'));
e.target.reset();
this.shadowRoot.getElementById('new-thread-modal').classList.remove('show');
});
}
renderForums(forums) {
const content = this.shadowRoot.getElementById('main-content');
content.innerHTML = `
<div class="forums-list">
${forums.map(forum => `
<div class="forum-item" data-forum-slug="${forum.slug}">
<div class="forum-icon">${forum.icon || '📁'}</div>
<div class="forum-info">
<div class="forum-name">${forum.name}</div>
${forum.description ? `<div class="forum-description">${forum.description}</div>` : ''}
<div class="forum-stats">
${forum.thread_count} threads · ${forum.post_count} posts
</div>
</div>
</div>
`).join('')}
</div>
`;
}
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="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>' : ''}
</div>
<div class="thread-meta">
Started by ${thread.author.nick} · ${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>
${this.formatDate(thread.last_post_at)}
</div>
` : ''}
</div>
</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>
<span class="page-button active">${data.page}</span>
<button class="page-button" onclick="this.getRootNode().host.loadForum('${forum.slug}', ${data.page + 1})">Next</button>
</div>
` : ''}
</div>
`;
}
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">
${posts.map(post => `
<div class="post" data-post-uid="${post.uid}">
<div class="post-author">
<div class="author-avatar" style="color: ${post.author.color}">
${post.author.nick.charAt(0).toUpperCase()}
</div>
<div class="author-name">${post.author.nick}</div>
</div>
<div class="post-content">
<div class="post-header">
Posted ${this.formatDate(post.created_at)}
${post.edited_at ? `· Edited ${this.formatDate(post.edited_at)}` : ''}
</div>
<div class="post-body">${this.escapeHtml(post.content)}</div>
<div class="post-footer">
<span class="post-action like-button ${post.is_liked ? 'liked' : ''}">
<span>${post.is_liked ? '❤️' : '🤍'}</span>
<span class="like-count">${post.like_count}</span>
</span>
</div>
</div>
</div>
`).join('')}
</div>
${!thread.is_locked ? `
<div class="reply-form">
<h3>Reply to Thread</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>
</form>
</div>
` : `
<div class="reply-form">
<p style="text-align: center; color: #666;">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>
<span class="page-button active">${data.page}</span>
<button class="page-button" onclick="this.getRootNode().host.loadThread('${thread.slug}', ${data.page + 1})">Next</button>
</div>
` : ''}
`;
// Add reply form listener
const replyForm = this.shadowRoot.getElementById('reply-form');
if (replyForm) {
replyForm.addEventListener('submit', (e) => {
e.preventDefault();
const formData = new FormData(e.target);
this.createPost(thread.uid, formData.get('content'));
e.target.reset();
});
}
}
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">
<div class="author-avatar" style="color: ${post.author.color}">
${post.author.nick.charAt(0).toUpperCase()}
</div>
<div class="author-name">${post.author.nick}</div>
</div>
<div class="post-content">
<div class="post-header">
Posted ${this.formatDate(post.created_at)}
</div>
<div class="post-body">${this.escapeHtml(post.content)}</div>
<div class="post-footer">
<span class="post-action like-button">
<span>🤍</span>
<span class="like-count">0</span>
</span>
</div>
</div>
</div>
`;
postsList.insertAdjacentHTML('beforeend', postHtml);
}
updatePost(post) {
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${post.uid}"]`);
if (postEl) {
const bodyEl = postEl.querySelector('.post-body');
const headerEl = postEl.querySelector('.post-header');
if (bodyEl) bodyEl.textContent = post.content;
if (headerEl && post.edited_at) {
headerEl.innerHTML += ` · Edited ${this.formatDate(post.edited_at)}`;
}
}
}
removePost(postUid) {
const postEl = this.shadowRoot.querySelector(`[data-post-uid="${postUid}"]`);
if (postEl) {
postEl.remove();
}
}
formatDate(dateStr) {
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();
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
customElements.define('snek-forum', SnekForum);
</script>
<snek-forum></snek-forum>
</body>
</html>

533
src/snek/view/forum.py Normal file
View File

@ -0,0 +1,533 @@
# views/forum.py
from snek.system.view import BaseView
from aiohttp import web
import json
from aiohttp import web
from snek.system.view import BaseView
class ForumIndexView(BaseView):
login_required = True
async def get(self):
if self.login_required and not self.session.get("logged_in"):
return web.HTTPFound("/")
channel = await self.services.channel.get(
uid=self.request.match_info.get("channel")
)
if not channel:
user = await self.services.user.get(
uid=self.request.match_info.get("channel")
)
if user:
channel = await self.services.channel.get_dm(
self.session.get("uid"), user["uid"]
)
if channel:
return web.HTTPFound("/channel/{}.html".format(channel["uid"]))
if not channel:
return web.HTTPNotFound()
channel_member = await self.app.services.channel_member.get(
user_uid=self.session.get("uid"), channel_uid=channel["uid"]
)
if not channel_member:
if not channel["is_private"]:
channel_member = await self.app.services.channel_member.create(
channel_uid=channel["uid"],
user_uid=self.session.get("uid"),
is_moderator=False,
is_read_only=False,
is_muted=False,
is_banned=False,
)
return web.HTTPNotFound()
channel_member["new_count"] = 0
await self.app.services.channel_member.save(channel_member)
user = await self.services.user.get(uid=self.session.get("uid"))
messages = [
await self.app.services.channel_message.to_extended_dict(message)
for message in await self.app.services.channel_message.offset(
channel["uid"]
)
]
for message in messages:
await self.app.services.notification.mark_as_read(
self.session.get("uid"), message["uid"]
)
name = await channel_member.get_name()
return await self.render_template(
"forum.html",
{"name": name, "channel": channel, "user": user, "messages": messages},
)
class ForumView(BaseView):
"""REST API endpoints for forum"""
login_required = True
async def get_forums(self):
request = self
self = request.app
"""GET /forum/api/forums - Get all active forums"""
forums = []
async for forum in self.services.forum.get_active_forums():
forums.append({
"uid": forum["uid"],
"name": forum["name"],
"description": forum["description"],
"slug": forum["slug"],
"icon": forum["icon"],
"thread_count": forum["thread_count"],
"post_count": forum["post_count"],
"last_post_at": forum["last_post_at"],
"last_thread_uid": forum["last_thread_uid"]
})
return web.json_response({"forums": forums})
async def get_forum(self):
request = self
self = request.app
"""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)
if not forum:
return web.json_response({"error": "Forum not found"}, status=404)
# Get threads
threads = []
page = int(self.request.query.get("page", 1))
limit = 50
offset = (page - 1) * limit
async for thread in forum.get_threads(limit=limit, offset=offset):
# Get author info
author = await self.services.user.get(uid=thread["created_by_uid"])
last_post_author = None
if thread["last_post_by_uid"]:
last_post_author = await self.services.user.get(uid=thread["last_post_by_uid"])
threads.append({
"uid": thread["uid"],
"title": thread["title"],
"slug": thread["slug"],
"is_pinned": thread["is_pinned"],
"is_locked": thread["is_locked"],
"view_count": thread["view_count"],
"post_count": thread["post_count"],
"created_at": thread["created_at"],
"last_post_at": thread["last_post_at"],
"author": {
"uid": author["uid"],
"username": author["username"],
"nick": author["nick"],
"color": author["color"]
},
"last_post_author": {
"uid": last_post_author["uid"],
"username": last_post_author["username"],
"nick": last_post_author["nick"],
"color": last_post_author["color"]
} if last_post_author else None
})
return web.json_response({
"forum": {
"uid": forum["uid"],
"name": forum["name"],
"description": forum["description"],
"slug": forum["slug"],
"icon": forum["icon"],
"thread_count": forum["thread_count"],
"post_count": forum["post_count"]
},
"threads": threads,
"page": page,
"hasMore": len(threads) == limit
})
async def create_thread(self):
request = self
self = request.app
"""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)
if not forum:
return web.json_response({"error": "Forum not found"}, status=404)
data = await self.request.json()
title = data.get("title", "").strip()
content = data.get("content", "").strip()
if not title or not content:
return web.json_response({"error": "Title and content required"}, status=400)
try:
thread, post = await self.services.thread.create_thread(
forum_uid=forum["uid"],
title=title,
content=content,
created_by_uid=self.request.session["uid"]
)
return web.json_response({
"thread": {
"uid": thread["uid"],
"slug": thread["slug"],
"forum_slug": forum["slug"]
}
})
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
async def get_thread(self):
request = self
self = request.app
"""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)
if not thread:
return web.json_response({"error": "Thread not found"}, status=404)
# Increment view count
await thread.increment_view_count()
# Get forum
forum = await self.services.forum.get(uid=thread["forum_uid"])
# Get posts
posts = []
page = int(self.request.query.get("page", 1))
limit = 50
offset = (page - 1) * limit
current_user_uid = self.request.session.get("uid")
async for post in thread.get_posts(limit=limit, offset=offset):
author = await post.get_author()
is_liked = False
if current_user_uid:
is_liked = await post.is_liked_by(current_user_uid)
posts.append({
"uid": post["uid"],
"content": post["content"],
"created_at": post["created_at"],
"edited_at": post["edited_at"],
"is_first_post": post["is_first_post"],
"like_count": post["like_count"],
"is_liked": is_liked,
"author": {
"uid": author["uid"],
"username": author["username"],
"nick": author["nick"],
"color": author["color"]
}
})
# Get thread author
thread_author = await self.services.user.get(uid=thread["created_by_uid"])
return web.json_response({
"thread": {
"uid": thread["uid"],
"title": thread["title"],
"slug": thread["slug"],
"is_pinned": thread["is_pinned"],
"is_locked": thread["is_locked"],
"view_count": thread["view_count"],
"post_count": thread["post_count"],
"created_at": thread["created_at"],
"author": {
"uid": thread_author["uid"],
"username": thread_author["username"],
"nick": thread_author["nick"],
"color": thread_author["color"]
}
},
"forum": {
"uid": forum["uid"],
"name": forum["name"],
"slug": forum["slug"]
},
"posts": posts,
"page": page,
"hasMore": len(posts) == limit
})
async def create_post(self):
request = self
self = request.app
"""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)
thread_uid = self.request.match_info["thread_uid"]
thread = await self.services.thread.get(uid=thread_uid)
if not thread:
return web.json_response({"error": "Thread not found"}, status=404)
data = await self.request.json()
content = data.get("content", "").strip()
if not content:
return web.json_response({"error": "Content required"}, status=400)
try:
post = await self.services.post.create_post(
thread_uid=thread["uid"],
forum_uid=thread["forum_uid"],
content=content,
created_by_uid=self.request.session["uid"]
)
author = await post.get_author()
return web.json_response({
"post": {
"uid": post["uid"],
"content": post["content"],
"created_at": post["created_at"],
"like_count": post["like_count"],
"is_liked": False,
"author": {
"uid": author["uid"],
"username": author["username"],
"nick": author["nick"],
"color": author["color"]
}
}
})
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
async def edit_post(self):
request = self
self = request.app
"""PUT /forum/api/posts/:post_uid - Edit post"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
post_uid = self.request.match_info["post_uid"]
data = await self.request.json()
content = data.get("content", "").strip()
if not content:
return web.json_response({"error": "Content required"}, status=400)
post = await self.services.post.edit_post(
post_uid=post_uid,
content=content,
user_uid=self.request.session["uid"]
)
if not post:
return web.json_response({"error": "Cannot edit post"}, status=403)
return web.json_response({
"post": {
"uid": post["uid"],
"content": post["content"],
"edited_at": post["edited_at"]
}
})
async def delete_post(self):
request = self
self = request.app
"""DELETE /forum/api/posts/:post_uid - Delete post"""
if not self.request.session.get("logged_in"):
return web.json_response({"error": "Unauthorized"}, status=401)
post_uid = self.request.match_info["post_uid"]
success = await self.services.post.delete_post(
post_uid=post_uid,
user_uid=self.request.session["uid"]
)
if not success:
return web.json_response({"error": "Cannot delete post"}, status=403)
return web.json_response({"success": True})
async def toggle_like(self):
request = self
self = request.app
"""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)
post_uid = self.request.match_info["post_uid"]
is_liked = await self.services.post_like.toggle_like(
post_uid=post_uid,
user_uid=self.request.session["uid"]
)
if is_liked is None:
return web.json_response({"error": "Failed to toggle like"}, status=400)
# Get updated post
post = await self.services.post.get(uid=post_uid)
return web.json_response({
"is_liked": is_liked,
"like_count": post["like_count"]
})
async def toggle_pin(self):
request = self
self = request.app
"""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)
thread_uid = self.request.match_info["thread_uid"]
thread = await self.services.thread.toggle_pin(
thread_uid=thread_uid,
user_uid=self.request.session["uid"]
)
if not thread:
return web.json_response({"error": "Cannot toggle pin"}, status=403)
return web.json_response({"is_pinned": thread["is_pinned"]})
async def toggle_lock(self):
request = self
self = request.app
"""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)
thread_uid = self.request.match_info["thread_uid"]
thread = await self.services.thread.toggle_lock(
thread_uid=thread_uid,
user_uid=self.request.session["uid"]
)
if not thread:
return web.json_response({"error": "Cannot toggle lock"}, status=403)
return web.json_response({"is_locked": thread["is_locked"]})
# views/forum_websocket.py
class ForumWebSocketView(BaseView):
"""WebSocket view for real-time forum updates"""
async def websocket_handler(self):
ws = web.WebSocketResponse()
await ws.prepare(self.request)
# Store WebSocket connection
ws_id = self.services.generate_uid()
if not hasattr(self.app, 'forum_websockets'):
self.app.forum_websockets = {}
user_uid = self.request.session.get("uid")
self.app.forum_websockets[ws_id] = {
"ws": ws,
"user_uid": user_uid,
"subscriptions": set()
}
try:
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
data = json.loads(msg.data)
await self.handle_ws_message(ws_id, data)
elif msg.type == web.WSMsgType.ERROR:
break
finally:
# Clean up
if ws_id in self.app.forum_websockets:
del self.app.forum_websockets[ws_id]
return ws
async def handle_ws_message(self, ws_id, data):
"""Handle incoming WebSocket messages"""
action = data.get("action")
if action == "subscribe":
# Subscribe to forum/thread updates
target_type = data.get("type") # "forum" or "thread"
target_id = data.get("id")
if target_type and target_id:
subscription = f"{target_type}:{target_id}"
self.app.forum_websockets[ws_id]["subscriptions"].add(subscription)
# Send confirmation
ws = self.app.forum_websockets[ws_id]["ws"]
await ws.send_str(json.dumps({
"type": "subscribed",
"subscription": subscription
}))
elif action == "unsubscribe":
target_type = data.get("type")
target_id = data.get("id")
if target_type and target_id:
subscription = f"{target_type}:{target_id}"
self.app.forum_websockets[ws_id]["subscriptions"].discard(subscription)
# Send confirmation
ws = self.app.forum_websockets[ws_id]["ws"]
await ws.send_str(json.dumps({
"type": "unsubscribed",
"subscription": subscription
}))
@staticmethod
async def broadcast_update(app, event_type, data):
"""Broadcast updates to subscribed WebSocket clients"""
if not hasattr(app, 'forum_websockets'):
return
# Determine subscription targets based on event
targets = set()
if event_type in ["thread_created", "post_created", "post_edited", "post_deleted"]:
if "forum_uid" in data:
targets.add(f"forum:{data['forum_uid']}")
if event_type in ["post_created", "post_edited", "post_deleted", "post_liked", "post_unliked"]:
if "thread_uid" in data:
targets.add(f"thread:{data['thread_uid']}")
# Send to subscribed clients
for ws_id, ws_data in app.forum_websockets.items():
if ws_data["subscriptions"] & targets:
try:
await ws_data["ws"].send_str(json.dumps({
"type": event_type,
"data": data
}))
except Exception:
pass