Added forum.
This commit is contained in:
parent
94c5ce4989
commit
081d66695c
@ -76,6 +76,7 @@ from snek.view.upload import UploadView
|
||||
from snek.view.user import UserView
|
||||
from snek.view.web import WebView
|
||||
from snek.webdav import WebdavApplication
|
||||
from snek.forum import setup_forum
|
||||
|
||||
SESSION_KEY = b"c79a0c5fda4b424189c427d28c9f7c34"
|
||||
from snek.system.template import whitelist_attributes
|
||||
@ -152,7 +153,7 @@ class Application(BaseApplication):
|
||||
self.ssh_host = "0.0.0.0"
|
||||
self.ssh_port = 2242
|
||||
|
||||
self.setup_router()
|
||||
self.forum = None
|
||||
self.ssh_server = None
|
||||
self.sync_service = None
|
||||
self.executor = None
|
||||
@ -161,6 +162,8 @@ class Application(BaseApplication):
|
||||
self.mappers = get_mappers(app=self)
|
||||
self.broadcast_service = None
|
||||
self.user_availability_service_task = None
|
||||
|
||||
self.setup_router()
|
||||
base_path = pathlib.Path(__file__).parent
|
||||
self.ip2location = IP2Location.IP2Location(
|
||||
base_path.joinpath("IP2LOCATION-LITE-DB11.BIN")
|
||||
@ -333,9 +336,10 @@ class Application(BaseApplication):
|
||||
)
|
||||
self.webdav = WebdavApplication(self)
|
||||
self.git = GitApplication(self)
|
||||
|
||||
self.add_subapp("/webdav", self.webdav)
|
||||
self.add_subapp("/git", self.git)
|
||||
|
||||
setup_forum(self)
|
||||
# self.router.add_get("/{file_path:.*}", self.static_handler)
|
||||
|
||||
async def handle_test(self, request):
|
||||
|
115
src/snek/forum.py
Normal file
115
src/snek/forum.py
Normal 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
|
@ -12,6 +12,7 @@ from snek.mapper.push import PushMapper
|
||||
from snek.mapper.repository import RepositoryMapper
|
||||
from snek.mapper.user import UserMapper
|
||||
from snek.mapper.user_property import UserPropertyMapper
|
||||
from snek.mapper.forum import ForumMapper, ThreadMapper, PostMapper, PostLikeMapper
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@ -31,9 +32,14 @@ def get_mappers(app=None):
|
||||
"channel_attachment": ChannelAttachmentMapper(app=app),
|
||||
"container": ContainerMapper(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):
|
||||
return get_mappers(app=app)[name]
|
||||
|
||||
|
23
src/snek/mapper/forum.py
Normal file
23
src/snek/mapper/forum.py
Normal 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
|
@ -14,6 +14,7 @@ from snek.model.push_registration import PushRegistrationModel
|
||||
from snek.model.repository import RepositoryModel
|
||||
from snek.model.user import UserModel
|
||||
from snek.model.user_property import UserPropertyModel
|
||||
from snek.model.forum import ForumModel, ThreadModel, PostModel, PostLikeModel
|
||||
from snek.system.object import Object
|
||||
|
||||
|
||||
@ -33,9 +34,15 @@ def get_models():
|
||||
"channel_attachment": ChannelAttachmentModel,
|
||||
"container": Container,
|
||||
"push_registration": PushRegistrationModel,
|
||||
"forum": ForumModel,
|
||||
"thread": ThreadModel,
|
||||
"post": PostModel,
|
||||
"post_like": PostLikeModel,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_model(name):
|
||||
return get_models()[name]
|
||||
|
||||
|
||||
|
96
src/snek/model/forum.py
Normal file
96
src/snek/model/forum.py
Normal 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)
|
@ -17,32 +17,49 @@ from snek.service.user import UserService
|
||||
from snek.service.user_property import UserPropertyService
|
||||
from snek.service.util import UtilService
|
||||
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
|
||||
def get_services(app):
|
||||
return Object(
|
||||
result = Object(
|
||||
**{
|
||||
"user": UserService(app=app),
|
||||
"channel_member": ChannelMemberService(app=app),
|
||||
"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),
|
||||
name: service_cls(app=app)
|
||||
for name, service_cls in _service_registry.items()
|
||||
}
|
||||
)
|
||||
|
||||
result.register = register_service
|
||||
return result
|
||||
|
||||
def get_service(name, app=None):
|
||||
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
293
src/snek/service/forum.py
Normal 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
|
829
src/snek/templates/forum.html
Normal file
829
src/snek/templates/forum.html
Normal 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')">×</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
533
src/snek/view/forum.py
Normal 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
|
Loading…
Reference in New Issue
Block a user