chore: update css, html, py files
This commit is contained in:
parent
2b3cc49d65
commit
a99e81aae0
@ -33,6 +33,14 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Version 1.34.0 - 2026-01-31
|
||||
|
||||
update css, html, py files
|
||||
|
||||
**Changes:** 44 files, 3802 lines
|
||||
**Languages:** CSS (712 lines), HTML (1300 lines), Python (1790 lines)
|
||||
|
||||
## Version 1.33.0 - 2026-01-24
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "Snek"
|
||||
version = "1.33.0"
|
||||
version = "1.34.0"
|
||||
readme = "README.md"
|
||||
#license = { file = "LICENSE", content-type="text/markdown" }
|
||||
description = "Snek Chat Application by Molodetz"
|
||||
|
||||
@ -79,6 +79,29 @@ from snek.view.settings.profile_pages import (
|
||||
)
|
||||
from snek.view.profile_page import ProfilePageView
|
||||
from snek.view.stats import StatsView
|
||||
from snek.view.admin import (
|
||||
AdminIndexView,
|
||||
AdminUsersIndexView,
|
||||
AdminUserEditView,
|
||||
AdminUserBanView,
|
||||
AdminChannelsIndexView,
|
||||
AdminChannelMembersView,
|
||||
AdminChannelEditView,
|
||||
AdminChannelClearView,
|
||||
AdminChannelDeleteView,
|
||||
AdminMessagesIndexView,
|
||||
AdminForumsIndexView,
|
||||
AdminThreadsView,
|
||||
AdminPostsView,
|
||||
AdminFilesIndexView,
|
||||
AdminDriveItemsView,
|
||||
AdminRepositoriesView,
|
||||
AdminNotificationsIndexView,
|
||||
AdminNotificationCreateView,
|
||||
AdminSystemIndexView,
|
||||
AdminKVView,
|
||||
AdminPushView,
|
||||
)
|
||||
from snek.view.status import StatusView
|
||||
from snek.view.terminal import TerminalSocketView, TerminalView
|
||||
from snek.view.upload import UploadView
|
||||
@ -370,6 +393,27 @@ class Application(BaseApplication):
|
||||
self.router.add_view(
|
||||
"/settings/containers/container/{uid}/delete.html", ContainersDeleteView
|
||||
)
|
||||
self.router.add_view("/admin/index.html", AdminIndexView)
|
||||
self.router.add_view("/admin/users/index.html", AdminUsersIndexView)
|
||||
self.router.add_view("/admin/users/{user_uid}/edit.html", AdminUserEditView)
|
||||
self.router.add_view("/admin/users/{user_uid}/ban.html", AdminUserBanView)
|
||||
self.router.add_view("/admin/channels/index.html", AdminChannelsIndexView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/members.html", AdminChannelMembersView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/edit.html", AdminChannelEditView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/clear.html", AdminChannelClearView)
|
||||
self.router.add_view("/admin/channels/{channel_uid}/delete.html", AdminChannelDeleteView)
|
||||
self.router.add_view("/admin/messages/index.html", AdminMessagesIndexView)
|
||||
self.router.add_view("/admin/forums/index.html", AdminForumsIndexView)
|
||||
self.router.add_view("/admin/forums/{forum_uid}/threads.html", AdminThreadsView)
|
||||
self.router.add_view("/admin/forums/threads/{thread_uid}/posts.html", AdminPostsView)
|
||||
self.router.add_view("/admin/files/index.html", AdminFilesIndexView)
|
||||
self.router.add_view("/admin/files/{drive_uid}/items.html", AdminDriveItemsView)
|
||||
self.router.add_view("/admin/files/repositories.html", AdminRepositoriesView)
|
||||
self.router.add_view("/admin/notifications/index.html", AdminNotificationsIndexView)
|
||||
self.router.add_view("/admin/notifications/create.html", AdminNotificationCreateView)
|
||||
self.router.add_view("/admin/system/index.html", AdminSystemIndexView)
|
||||
self.router.add_view("/admin/system/kv.html", AdminKVView)
|
||||
self.router.add_view("/admin/system/push.html", AdminPushView)
|
||||
self.webdav = WebdavApplication(self)
|
||||
self.git = GitApplication(self)
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import functools
|
||||
|
||||
from snek.service.admin import AdminService
|
||||
from snek.service.channel import ChannelService
|
||||
from snek.service.channel_attachment import ChannelAttachmentService
|
||||
from snek.service.channel_member import ChannelMemberService
|
||||
@ -68,4 +69,5 @@ register_service("post", PostService)
|
||||
register_service("post_like", PostLikeService)
|
||||
register_service("profile_page", ProfilePageService)
|
||||
register_service("mention", MentionService)
|
||||
register_service("admin", AdminService)
|
||||
|
||||
|
||||
577
src/snek/service/admin.py
Normal file
577
src/snek/service/admin.py
Normal file
@ -0,0 +1,577 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from snek.system.service import BaseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminService(BaseService):
|
||||
|
||||
mapper_name = None
|
||||
|
||||
async def _require_admin(self, user_uid: str) -> bool:
|
||||
user = await self.services.user.get(uid=user_uid)
|
||||
if not user or not user["is_admin"]:
|
||||
raise PermissionError("Admin access required")
|
||||
return True
|
||||
|
||||
async def get_dashboard_stats(self) -> dict[str, Any]:
|
||||
logger.info("Fetching dashboard statistics")
|
||||
user_count = await self.services.user.count(deleted_at=None)
|
||||
channel_count = await self.services.channel.count(deleted_at=None)
|
||||
message_count = await self.services.channel_message.count(deleted_at=None)
|
||||
forum_count = await self.services.forum.count(deleted_at=None)
|
||||
thread_count = await self.services.thread.count(deleted_at=None)
|
||||
post_count = await self.services.post.count(deleted_at=None)
|
||||
connected_users = await self.services.socket.get_connected_users()
|
||||
online_count = len(connected_users)
|
||||
drive_count = await self.services.drive.count(deleted_at=None)
|
||||
repository_count = await self.services.repository.count(deleted_at=None)
|
||||
notification_count = await self.services.notification.count(deleted_at=None)
|
||||
logger.debug(f"Dashboard stats: users={user_count}, channels={channel_count}, messages={message_count}")
|
||||
return {
|
||||
"users": user_count,
|
||||
"channels": channel_count,
|
||||
"messages": message_count,
|
||||
"forums": forum_count,
|
||||
"threads": thread_count,
|
||||
"posts": post_count,
|
||||
"online": online_count,
|
||||
"drives": drive_count,
|
||||
"repositories": repository_count,
|
||||
"notifications": notification_count,
|
||||
}
|
||||
|
||||
async def get_recent_activity(self, limit: int = 20) -> list[dict]:
|
||||
logger.info(f"Fetching recent activity, limit={limit}")
|
||||
messages = []
|
||||
async for message in self.services.channel_message.find(_limit=limit, _order_by="-created_at", deleted_at=None):
|
||||
user = await self.services.user.get(uid=message["user_uid"])
|
||||
channel = await self.services.channel.get(uid=message["channel_uid"])
|
||||
messages.append({
|
||||
"uid": message["uid"],
|
||||
"content": message["message"][:100] if message["message"] else "",
|
||||
"created_at": str(message["created_at"]) if message["created_at"] else None,
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"user_uid": message["user_uid"],
|
||||
"channel_name": channel["name"] if channel else "Unknown",
|
||||
"channel_uid": message["channel_uid"],
|
||||
})
|
||||
logger.debug(f"Fetched {len(messages)} recent messages")
|
||||
return messages
|
||||
|
||||
async def list_users(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
|
||||
logger.info(f"Listing users: page={page}, per_page={per_page}, search={search}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
users = []
|
||||
async for user in self.services.user.find(**filters):
|
||||
if search and search.lower() not in (user["username"] or "").lower() and search.lower() not in (user["nick"] or "").lower():
|
||||
continue
|
||||
users.append({
|
||||
"uid": user["uid"],
|
||||
"username": user["username"],
|
||||
"nick": user["nick"],
|
||||
"is_admin": user["is_admin"] or False,
|
||||
"is_banned": user["is_banned"] or False,
|
||||
"created_at": str(user["created_at"]) if user["created_at"] else None,
|
||||
"last_online": str(user["last_online"]) if user["last_online"] else None,
|
||||
"city": user["city"],
|
||||
"country_short": user["country_short"],
|
||||
})
|
||||
total = await self.services.user.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(users)} users, total={total}")
|
||||
return {"users": users, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def update_user(self, admin_uid: str, target_uid: str, updates: dict) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} updating user {target_uid}")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
allowed_fields = ["nick", "is_admin", "is_banned", "color"]
|
||||
for field in allowed_fields:
|
||||
if field in updates:
|
||||
if field == "is_admin" and target_uid == admin_uid:
|
||||
raise ValueError("Cannot modify own admin status")
|
||||
user[field] = updates[field]
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"User {target_uid} updated by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def ban_user(self, admin_uid: str, target_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} banning user {target_uid}")
|
||||
if admin_uid == target_uid:
|
||||
raise ValueError("Cannot ban yourself")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
user["is_banned"] = True
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"User {target_uid} banned by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def unban_user(self, admin_uid: str, target_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} unbanning user {target_uid}")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
user["is_banned"] = False
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"User {target_uid} unbanned by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def reset_password(self, admin_uid: str, target_uid: str, new_password: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} resetting password for user {target_uid}")
|
||||
if len(new_password) != 6:
|
||||
raise ValueError("Password must be exactly 6 characters")
|
||||
user = await self.services.user.get(uid=target_uid)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
user["password"] = new_password
|
||||
await self.services.user.save(user)
|
||||
logger.info(f"Password reset for user {target_uid} by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_channels(self, page: int = 1, per_page: int = 20, search: str = None) -> dict:
|
||||
logger.info(f"Listing channels: page={page}, per_page={per_page}, search={search}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
channels = []
|
||||
async for channel in self.services.channel.find(**filters):
|
||||
if search and search.lower() not in (channel["name"] or "").lower():
|
||||
continue
|
||||
member_count = await self.services.channel_member.count(channel_uid=channel["uid"], deleted_at=None)
|
||||
message_count = await self.services.channel_message.count(channel_uid=channel["uid"], deleted_at=None)
|
||||
channels.append({
|
||||
"uid": channel["uid"],
|
||||
"name": channel["name"],
|
||||
"description": channel["description"] or "",
|
||||
"tag": channel["tag"],
|
||||
"is_private": channel["is_private"] or False,
|
||||
"created_at": str(channel["created_at"]) if channel["created_at"] else None,
|
||||
"member_count": member_count,
|
||||
"message_count": message_count,
|
||||
})
|
||||
total = await self.services.channel.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(channels)} channels, total={total}")
|
||||
return {"channels": channels, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def get_channel_members(self, channel_uid: str) -> list[dict]:
|
||||
logger.info(f"Getting members for channel {channel_uid}")
|
||||
members = []
|
||||
async for member in self.services.channel_member.find(channel_uid=channel_uid, deleted_at=None):
|
||||
user = await self.services.user.get(uid=member["user_uid"])
|
||||
members.append({
|
||||
"uid": member["uid"],
|
||||
"user_uid": member["user_uid"],
|
||||
"username": user["username"] if user else "Unknown",
|
||||
"nick": user["nick"] if user else "Unknown",
|
||||
"is_owner": member["is_owner"] or False,
|
||||
"is_banned": member["is_banned"] or False,
|
||||
"joined_at": str(member["created_at"]) if member["created_at"] else None,
|
||||
})
|
||||
logger.debug(f"Found {len(members)} members for channel {channel_uid}")
|
||||
return members
|
||||
|
||||
async def update_channel(self, admin_uid: str, channel_uid: str, updates: dict) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} updating channel {channel_uid}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
raise ValueError("Channel not found")
|
||||
allowed_fields = ["name", "description", "is_private"]
|
||||
for field in allowed_fields:
|
||||
if field in updates:
|
||||
channel[field] = updates[field]
|
||||
await self.services.channel.save(channel)
|
||||
logger.info(f"Channel {channel_uid} updated by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def delete_channel(self, admin_uid: str, channel_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting channel {channel_uid}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
raise ValueError("Channel not found")
|
||||
if channel["name"] == "public":
|
||||
raise ValueError("Cannot delete the public channel")
|
||||
channel["deleted_at"] = datetime.now()
|
||||
await self.services.channel.save(channel)
|
||||
logger.info(f"Channel {channel_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def clear_channel_history(self, admin_uid: str, channel_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} clearing history for channel {channel_uid}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
raise ValueError("Channel not found")
|
||||
deleted_count = 0
|
||||
async for message in self.services.channel_message.find(channel_uid=channel_uid, deleted_at=None):
|
||||
message["deleted_at"] = datetime.now()
|
||||
await self.services.channel_message.save(message)
|
||||
deleted_count += 1
|
||||
logger.info(f"Cleared {deleted_count} messages from channel {channel_uid}")
|
||||
return {"success": True, "deleted_count": deleted_count}
|
||||
|
||||
async def search_messages(self, query: str = None, channel_uid: str = None, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Searching messages: query={query}, channel={channel_uid}, user={user_uid}, page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
|
||||
if channel_uid:
|
||||
filters["channel_uid"] = channel_uid
|
||||
if user_uid:
|
||||
filters["user_uid"] = user_uid
|
||||
messages = []
|
||||
async for message in self.services.channel_message.find(**filters):
|
||||
if query and query.lower() not in (message["message"] or "").lower():
|
||||
continue
|
||||
user = await self.services.user.get(uid=message["user_uid"])
|
||||
channel = await self.services.channel.get(uid=message["channel_uid"])
|
||||
messages.append({
|
||||
"uid": message["uid"],
|
||||
"content": message["message"][:200] if message["message"] else "",
|
||||
"created_at": str(message["created_at"]) if message["created_at"] else None,
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"user_uid": message["user_uid"],
|
||||
"channel_name": channel["name"] if channel else "Unknown",
|
||||
"channel_uid": message["channel_uid"],
|
||||
})
|
||||
total = await self.services.channel_message.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(messages)} messages")
|
||||
return {"messages": messages, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_message(self, admin_uid: str, message_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting message {message_uid}")
|
||||
message = await self.services.channel_message.get(uid=message_uid)
|
||||
if not message:
|
||||
raise ValueError("Message not found")
|
||||
message["deleted_at"] = datetime.now()
|
||||
await self.services.channel_message.save(message)
|
||||
logger.info(f"Message {message_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_forums(self, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing forums: page={page}, per_page={per_page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
forums = []
|
||||
async for forum in self.services.forum.find(**filters):
|
||||
thread_count = await self.services.thread.count(forum_uid=forum["uid"], deleted_at=None)
|
||||
forums.append({
|
||||
"uid": forum["uid"],
|
||||
"name": forum["name"],
|
||||
"description": forum["description"] or "",
|
||||
"is_active": forum["is_active"] if forum["is_active"] is not None else True,
|
||||
"created_at": str(forum["created_at"]) if forum["created_at"] else None,
|
||||
"thread_count": thread_count,
|
||||
})
|
||||
total = await self.services.forum.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(forums)} forums, total={total}")
|
||||
return {"forums": forums, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def update_forum(self, admin_uid: str, forum_uid: str, updates: dict) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} updating forum {forum_uid}")
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if not forum:
|
||||
raise ValueError("Forum not found")
|
||||
allowed_fields = ["name", "description", "is_active"]
|
||||
for field in allowed_fields:
|
||||
if field in updates:
|
||||
forum[field] = updates[field]
|
||||
await self.services.forum.save(forum)
|
||||
logger.info(f"Forum {forum_uid} updated by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def delete_forum(self, admin_uid: str, forum_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting forum {forum_uid}")
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if not forum:
|
||||
raise ValueError("Forum not found")
|
||||
forum["deleted_at"] = datetime.now()
|
||||
await self.services.forum.save(forum)
|
||||
logger.info(f"Forum {forum_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_threads(self, forum_uid: str, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing threads for forum {forum_uid}: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"forum_uid": forum_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
threads = []
|
||||
async for thread in self.services.thread.find(**filters):
|
||||
user = await self.services.user.get(uid=thread["created_by_uid"])
|
||||
post_count = await self.services.post.count(thread_uid=thread["uid"], deleted_at=None)
|
||||
threads.append({
|
||||
"uid": thread["uid"],
|
||||
"title": thread["title"],
|
||||
"is_pinned": thread["is_pinned"] or False,
|
||||
"is_locked": thread["is_locked"] or False,
|
||||
"created_at": str(thread["created_at"]) if thread["created_at"] else None,
|
||||
"created_by_nick": user["nick"] if user else "Unknown",
|
||||
"created_by_uid": thread["created_by_uid"],
|
||||
"post_count": post_count,
|
||||
})
|
||||
total = await self.services.thread.count(forum_uid=forum_uid, deleted_at=None)
|
||||
logger.debug(f"Found {len(threads)} threads, total={total}")
|
||||
return {"threads": threads, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def toggle_thread_pin(self, admin_uid: str, thread_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} toggling pin for thread {thread_uid}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
raise ValueError("Thread not found")
|
||||
thread["is_pinned"] = not (thread["is_pinned"] or False)
|
||||
await self.services.thread.save(thread)
|
||||
logger.info(f"Thread {thread_uid} pin toggled to {thread['is_pinned']}")
|
||||
return {"success": True, "is_pinned": thread["is_pinned"]}
|
||||
|
||||
async def toggle_thread_lock(self, admin_uid: str, thread_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} toggling lock for thread {thread_uid}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
raise ValueError("Thread not found")
|
||||
thread["is_locked"] = not (thread["is_locked"] or False)
|
||||
await self.services.thread.save(thread)
|
||||
logger.info(f"Thread {thread_uid} lock toggled to {thread['is_locked']}")
|
||||
return {"success": True, "is_locked": thread["is_locked"]}
|
||||
|
||||
async def delete_thread(self, admin_uid: str, thread_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting thread {thread_uid}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
raise ValueError("Thread not found")
|
||||
thread["deleted_at"] = datetime.now()
|
||||
await self.services.thread.save(thread)
|
||||
logger.info(f"Thread {thread_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_posts(self, thread_uid: str, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing posts for thread {thread_uid}: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"thread_uid": thread_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
posts = []
|
||||
async for post in self.services.post.find(**filters):
|
||||
user = await self.services.user.get(uid=post["created_by_uid"])
|
||||
posts.append({
|
||||
"uid": post["uid"],
|
||||
"content": post["content"][:200] if post["content"] else "",
|
||||
"created_at": str(post["created_at"]) if post["created_at"] else None,
|
||||
"created_by_nick": user["nick"] if user else "Unknown",
|
||||
"created_by_uid": post["created_by_uid"],
|
||||
})
|
||||
total = await self.services.post.count(thread_uid=thread_uid, deleted_at=None)
|
||||
logger.debug(f"Found {len(posts)} posts, total={total}")
|
||||
return {"posts": posts, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_post(self, admin_uid: str, post_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting post {post_uid}")
|
||||
post = await self.services.post.get(uid=post_uid)
|
||||
if not post:
|
||||
raise ValueError("Post not found")
|
||||
post["deleted_at"] = datetime.now()
|
||||
await self.services.post.save(post)
|
||||
logger.info(f"Post {post_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_drives(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing drives: user_uid={user_uid}, page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
if user_uid:
|
||||
filters["user_uid"] = user_uid
|
||||
drives = []
|
||||
async for drive in self.services.drive.find(**filters):
|
||||
user = await self.services.user.get(uid=drive["user_uid"])
|
||||
item_count = await self.services.drive_item.count(drive_uid=drive["uid"], deleted_at=None)
|
||||
drives.append({
|
||||
"uid": drive["uid"],
|
||||
"name": drive["name"] or "Drive",
|
||||
"user_uid": drive["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"created_at": str(drive["created_at"]) if drive["created_at"] else None,
|
||||
"item_count": item_count,
|
||||
})
|
||||
total = await self.services.drive.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(drives)} drives, total={total}")
|
||||
return {"drives": drives, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def list_drive_items(self, drive_uid: str, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing items for drive {drive_uid}: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"drive_uid": drive_uid, "deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
items = []
|
||||
async for item in self.services.drive_item.find(**filters):
|
||||
items.append({
|
||||
"uid": item["uid"],
|
||||
"name": item["name"] or "",
|
||||
"path": item["path"] or "",
|
||||
"size": item["size"] or 0,
|
||||
"mime_type": item["mime_type"] or "",
|
||||
"created_at": str(item["created_at"]) if item["created_at"] else None,
|
||||
})
|
||||
total = await self.services.drive_item.count(drive_uid=drive_uid, deleted_at=None)
|
||||
logger.debug(f"Found {len(items)} items, total={total}")
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_drive_item(self, admin_uid: str, item_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting drive item {item_uid}")
|
||||
item = await self.services.drive_item.get(uid=item_uid)
|
||||
if not item:
|
||||
raise ValueError("Drive item not found")
|
||||
item["deleted_at"] = datetime.now()
|
||||
await self.services.drive_item.save(item)
|
||||
logger.info(f"Drive item {item_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_repositories(self, user_uid: str = None, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing repositories: user_uid={user_uid}, page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
if user_uid:
|
||||
filters["user_uid"] = user_uid
|
||||
repositories = []
|
||||
async for repo in self.services.repository.find(**filters):
|
||||
user = await self.services.user.get(uid=repo["user_uid"])
|
||||
repositories.append({
|
||||
"uid": repo["uid"],
|
||||
"name": repo["name"],
|
||||
"user_uid": repo["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"is_private": repo["is_private"] or False,
|
||||
"created_at": str(repo["created_at"]) if repo["created_at"] else None,
|
||||
})
|
||||
total = await self.services.repository.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(repositories)} repositories, total={total}")
|
||||
return {"repositories": repositories, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_repository(self, admin_uid: str, repo_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting repository {repo_uid}")
|
||||
repo = await self.services.repository.get(uid=repo_uid)
|
||||
if not repo:
|
||||
raise ValueError("Repository not found")
|
||||
repo["deleted_at"] = datetime.now()
|
||||
await self.services.repository.save(repo)
|
||||
logger.info(f"Repository {repo_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_notifications(self, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing notifications: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset, "_order_by": "-created_at"}
|
||||
notifications = []
|
||||
async for notif in self.services.notification.find(**filters):
|
||||
user = await self.services.user.get(uid=notif["user_uid"])
|
||||
notifications.append({
|
||||
"uid": notif["uid"],
|
||||
"message": (notif["message"] or "")[:100],
|
||||
"type": notif["type"] or "",
|
||||
"user_uid": notif["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"is_read": notif["is_read"] or False,
|
||||
"created_at": str(notif["created_at"]) if notif["created_at"] else None,
|
||||
})
|
||||
total = await self.services.notification.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(notifications)} notifications, total={total}")
|
||||
return {"notifications": notifications, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def mass_notify(self, admin_uid: str, message: str, user_uids: list[str] = None) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} sending mass notification")
|
||||
if not message or len(message) > 500:
|
||||
raise ValueError("Message must be between 1 and 500 characters")
|
||||
sent_count = 0
|
||||
if user_uids:
|
||||
target_users = user_uids
|
||||
else:
|
||||
target_users = []
|
||||
async for user in self.services.user.find(deleted_at=None, is_banned=False):
|
||||
target_users.append(user["uid"])
|
||||
for user_uid in target_users:
|
||||
await self.services.notification.create(
|
||||
user_uid=user_uid,
|
||||
message=message,
|
||||
notification_type="admin",
|
||||
)
|
||||
sent_count += 1
|
||||
logger.info(f"Mass notification sent to {sent_count} users by admin {admin_uid}")
|
||||
return {"success": True, "sent_count": sent_count}
|
||||
|
||||
async def get_kv_entries(self) -> list[dict]:
|
||||
logger.info("Fetching KV store entries")
|
||||
entries = []
|
||||
for row in self.app.db.query("SELECT key, value FROM kv ORDER BY key"):
|
||||
entries.append({"key": row["key"], "value": row["value"]})
|
||||
logger.debug(f"Found {len(entries)} KV entries")
|
||||
return entries
|
||||
|
||||
async def set_kv_entry(self, admin_uid: str, key: str, value: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} setting KV entry: key={key}")
|
||||
if not key or len(key) > 255:
|
||||
raise ValueError("Key must be between 1 and 255 characters")
|
||||
if len(value) > 10000:
|
||||
raise ValueError("Value must be less than 10000 characters")
|
||||
self.app.db["kv"].upsert({"key": key, "value": value}, ["key"])
|
||||
logger.info(f"KV entry set: key={key}")
|
||||
return {"success": True}
|
||||
|
||||
async def delete_kv_entry(self, admin_uid: str, key: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting KV entry: key={key}")
|
||||
self.app.db["kv"].delete(key=key)
|
||||
logger.info(f"KV entry deleted: key={key}")
|
||||
return {"success": True}
|
||||
|
||||
async def list_push_registrations(self, page: int = 1, per_page: int = 20) -> dict:
|
||||
logger.info(f"Listing push registrations: page={page}")
|
||||
offset = (page - 1) * per_page
|
||||
filters = {"deleted_at": None, "_limit": per_page, "_offset": offset}
|
||||
registrations = []
|
||||
async for reg in self.services.push.find(**filters):
|
||||
user = await self.services.user.get(uid=reg["user_uid"])
|
||||
registrations.append({
|
||||
"uid": reg["uid"],
|
||||
"user_uid": reg["user_uid"],
|
||||
"user_nick": user["nick"] if user else "Unknown",
|
||||
"endpoint": (reg["endpoint"] or "")[:50] + "..." if reg["endpoint"] else "",
|
||||
"created_at": str(reg["created_at"]) if reg["created_at"] else None,
|
||||
})
|
||||
total = await self.services.push.count(deleted_at=None)
|
||||
logger.debug(f"Found {len(registrations)} push registrations, total={total}")
|
||||
return {"registrations": registrations, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
async def delete_push_registration(self, admin_uid: str, registration_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} deleting push registration {registration_uid}")
|
||||
reg = await self.services.push.get(uid=registration_uid)
|
||||
if not reg:
|
||||
raise ValueError("Push registration not found")
|
||||
reg["deleted_at"] = datetime.now()
|
||||
await self.services.push.save(reg)
|
||||
logger.info(f"Push registration {registration_uid} deleted by admin {admin_uid}")
|
||||
return {"success": True}
|
||||
|
||||
async def run_maintenance(self, admin_uid: str) -> dict:
|
||||
await self._require_admin(admin_uid)
|
||||
logger.info(f"Admin {admin_uid} running maintenance")
|
||||
self.app.db.query("VACUUM")
|
||||
self.app.db.query("ANALYZE")
|
||||
logger.info("Maintenance completed: VACUUM and ANALYZE")
|
||||
return {"success": True, "message": "Database maintenance completed"}
|
||||
696
src/snek/static/admin.css
Normal file
696
src/snek/static/admin.css
Normal file
@ -0,0 +1,696 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
html:has(body.admin-page) {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body.admin-page {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
body.admin-page main {
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
:root {
|
||||
--admin-bg: #000000;
|
||||
--admin-bg-secondary: #0f0f0f;
|
||||
--admin-bg-tertiary: #1a1a1a;
|
||||
--admin-accent: #f05a28;
|
||||
--admin-accent-hover: #e04924;
|
||||
--admin-text: #e6e6e6;
|
||||
--admin-text-muted: #888;
|
||||
--admin-text-faded: #aaa;
|
||||
--admin-border: #333;
|
||||
--admin-border-light: #444;
|
||||
--admin-danger: #8b0000;
|
||||
--admin-danger-hover: #a00000;
|
||||
--admin-warning: #c9a227;
|
||||
--admin-success: #2e7d32;
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.admin-sidebar h2 {
|
||||
color: var(--admin-accent);
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-sidebar hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--admin-border);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.admin-stats h2 {
|
||||
margin-bottom: 20px;
|
||||
color: var(--admin-accent);
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.admin-stats h3 {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.time-cell {
|
||||
font-size: 0.85em;
|
||||
color: var(--admin-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.stat-card.stat-highlight {
|
||||
border-color: var(--admin-accent);
|
||||
background: linear-gradient(135deg, var(--admin-bg-secondary) 0%, rgba(240, 90, 40, 0.1) 100%);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.75em;
|
||||
color: var(--admin-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 2.5em;
|
||||
font-weight: bold;
|
||||
color: var(--admin-accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: var(--admin-success);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
display: block;
|
||||
font-size: 0.8em;
|
||||
color: var(--admin-text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
display: block;
|
||||
font-size: 1.1em;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.admin-activity {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.admin-activity h2 {
|
||||
margin-bottom: 15px;
|
||||
color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: var(--admin-bg-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-table thead {
|
||||
background: var(--admin-bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
text-align: left;
|
||||
padding: 12px 15px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: var(--admin-bg-tertiary);
|
||||
}
|
||||
|
||||
.admin-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.message-preview,
|
||||
.description-preview,
|
||||
.value-preview,
|
||||
.path-preview,
|
||||
.endpoint-preview {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-banned {
|
||||
background: rgba(139, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.row-inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.row-pinned {
|
||||
background: rgba(240, 90, 40, 0.1);
|
||||
}
|
||||
|
||||
.row-locked {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.row-read {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--admin-border);
|
||||
background: var(--admin-bg-tertiary);
|
||||
color: var(--admin-text);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #2a2a2a;
|
||||
border-color: var(--admin-border-light);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--admin-accent);
|
||||
border-color: var(--admin-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--admin-accent-hover);
|
||||
border-color: var(--admin-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--admin-bg-tertiary);
|
||||
border-color: var(--admin-border);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--admin-danger);
|
||||
border-color: var(--admin-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--admin-danger-hover);
|
||||
border-color: var(--admin-danger-hover);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: var(--admin-warning);
|
||||
border-color: var(--admin-warning);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #d4ab2a;
|
||||
border-color: #d4ab2a;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-search {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-search form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg-secondary);
|
||||
color: var(--admin-text);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.search-select {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg-secondary);
|
||||
color: var(--admin-text);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.search-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.admin-pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
padding: 15px;
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.pagination-pages {
|
||||
color: var(--admin-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid var(--admin-border);
|
||||
}
|
||||
|
||||
.admin-header h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.admin-header p {
|
||||
color: var(--admin-text-muted);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
max-width: 600px;
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-form h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="color"],
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
background: var(--admin-bg);
|
||||
color: var(--admin-text);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.form-group.checkbox label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.form-group.checkbox input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-actions-inline {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.radio-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.radio-group input[type="radio"] {
|
||||
accent-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.user-checkboxes {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
background: var(--admin-bg);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: block;
|
||||
padding: 5px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
margin-right: 10px;
|
||||
accent-color: var(--admin-accent);
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
border-color: var(--admin-danger);
|
||||
background: rgba(139, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.danger-zone h2 {
|
||||
color: var(--admin-danger);
|
||||
}
|
||||
|
||||
.danger-zone p {
|
||||
color: var(--admin-text-muted);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-info {
|
||||
max-width: 600px;
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.admin-info h3 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.admin-info dl {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-info dt {
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.admin-info dd {
|
||||
color: var(--admin-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-list {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-list h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-actions-section {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.admin-actions-section h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-actions-section p {
|
||||
color: var(--admin-text-muted);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-links {
|
||||
background: var(--admin-bg-secondary);
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.admin-links h2 {
|
||||
color: var(--admin-accent);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.admin-links ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.admin-links li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-links a {
|
||||
color: var(--admin-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: rgba(139, 0, 0, 0.2);
|
||||
border: 1px solid var(--admin-danger);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: rgba(46, 125, 50, 0.2);
|
||||
border: 1px solid var(--admin-success);
|
||||
color: #81c784;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--admin-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-input,
|
||||
.search-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,22 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html:has(body.profile-page) {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body.profile-page {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
body.profile-page main {
|
||||
overflow: visible;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
18
src/snek/templates/admin/base.html
Normal file
18
src/snek/templates/admin/base.html
Normal file
@ -0,0 +1,18 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block body_class %}admin-page{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% include "admin/sidebar.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="/admin.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Admin Panel</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
{% endblock main %}
|
||||
26
src/snek/templates/admin/channels/clear.html
Normal file
26
src/snek/templates/admin/channels/clear.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Clear Channel History</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form danger-zone">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<p>This action will permanently delete all messages in this channel. This cannot be undone.</p>
|
||||
|
||||
<form method="post" action="/admin/channels/{{ channel.uid }}/clear.html">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Clear All Messages</button>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
26
src/snek/templates/admin/channels/delete.html
Normal file
26
src/snek/templates/admin/channels/delete.html
Normal file
@ -0,0 +1,26 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Delete Channel</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form danger-zone">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<p>This action will permanently delete this channel and all its messages. This cannot be undone.</p>
|
||||
|
||||
<form method="post" action="/admin/channels/{{ channel.uid }}/delete.html">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Delete Channel</button>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
53
src/snek/templates/admin/channels/edit.html
Normal file
53
src/snek/templates/admin/channels/edit.html
Normal file
@ -0,0 +1,53 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Edit Channel</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/channels/{{ channel.uid }}/edit.html">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" id="name" name="name" value="{{ channel.name }}" maxlength="50" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description" rows="3" maxlength="500">{{ channel.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="is_private" {% if channel.is_private %}checked{% endif %}>
|
||||
Private Channel
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-info">
|
||||
<h3>Channel Information</h3>
|
||||
<dl>
|
||||
<dt>UID</dt>
|
||||
<dd>{{ channel.uid }}</dd>
|
||||
<dt>Type</dt>
|
||||
<dd>{{ channel.tag or 'channel' }}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ channel.created_at }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
70
src/snek/templates/admin/channels/index.html
Normal file
70
src/snek/templates/admin/channels/index.html
Normal file
@ -0,0 +1,70 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Channels</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/channels/index.html">
|
||||
<input type="text" name="search" placeholder="Search channels..." value="{{ search }}" class="search-input">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
{% if search %}
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Private</th>
|
||||
<th>Members</th>
|
||||
<th>Messages</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for channel in channels %}
|
||||
<tr>
|
||||
<td>{{ channel.name }}</td>
|
||||
<td>{{ channel.tag or 'channel' }}</td>
|
||||
<td>{% if channel.is_private %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ channel.member_count }}</td>
|
||||
<td>{{ channel.message_count }}</td>
|
||||
<td>{{ channel.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/channels/{{ channel.uid }}/members.html" class="btn btn-small">Members</a>
|
||||
<a href="/admin/channels/{{ channel.uid }}/edit.html" class="btn btn-small">Edit</a>
|
||||
<a href="/admin/channels/{{ channel.uid }}/clear.html" class="btn btn-small btn-warning">Clear</a>
|
||||
<a href="/admin/channels/{{ channel.uid }}/delete.html" class="btn btn-small btn-danger">Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7">No channels found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} channels</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/channels/index.html?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/channels/index.html?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
48
src/snek/templates/admin/channels/members.html
Normal file
48
src/snek/templates/admin/channels/members.html
Normal file
@ -0,0 +1,48 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Channel Members</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ channel.name }}</h2>
|
||||
<p>{{ channel.description }}</p>
|
||||
<a href="/admin/channels/index.html" class="btn btn-secondary">Back to Channels</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Nick</th>
|
||||
<th>Owner</th>
|
||||
<th>Banned</th>
|
||||
<th>Joined</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for member in members %}
|
||||
<tr class="{% if member.is_banned %}row-banned{% endif %}">
|
||||
<td>{{ member.username }}</td>
|
||||
<td>{{ member.nick }}</td>
|
||||
<td>{% if member.is_owner %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{% if member.is_banned %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ member.joined_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/users/{{ member.user_uid }}/edit.html" class="btn btn-small">Edit User</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">No members found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
67
src/snek/templates/admin/files/index.html
Normal file
67
src/snek/templates/admin/files/index.html
Normal file
@ -0,0 +1,67 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Drives</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/files/index.html">
|
||||
<select name="user_uid" class="search-select">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Filter</button>
|
||||
{% if user_uid %}
|
||||
<a href="/admin/files/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Items</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for drive in drives %}
|
||||
<tr>
|
||||
<td>{{ drive.name }}</td>
|
||||
<td><a href="/admin/users/{{ drive.user_uid }}/edit.html">{{ drive.user_nick }}</a></td>
|
||||
<td>{{ drive.item_count }}</td>
|
||||
<td>{{ drive.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/files/{{ drive.uid }}/items.html" class="btn btn-small">View Items</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No drives found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/files/index.html?page={{ page - 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/files/index.html?page={{ page + 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
63
src/snek/templates/admin/files/items.html
Normal file
63
src/snek/templates/admin/files/items.html
Normal file
@ -0,0 +1,63 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Drive Items</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ drive.name or 'Drive' }}</h2>
|
||||
<p>Owner: {{ user.nick if user else 'Unknown' }}</p>
|
||||
<a href="/admin/files/index.html" class="btn btn-secondary">Back to Drives</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Path</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>{{ item.name }}</td>
|
||||
<td class="path-preview">{{ item.path }}</td>
|
||||
<td>{{ item.size }}</td>
|
||||
<td>{{ item.mime_type }}</td>
|
||||
<td>{{ item.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/files/{{ drive.uid }}/items.html" style="display:inline;">
|
||||
<input type="hidden" name="item_uid" value="{{ item.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this item?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">No items found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/files/{{ drive.uid }}/items.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/files/{{ drive.uid }}/items.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
70
src/snek/templates/admin/files/repositories.html
Normal file
70
src/snek/templates/admin/files/repositories.html
Normal file
@ -0,0 +1,70 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Repositories</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/files/repositories.html">
|
||||
<select name="user_uid" class="search-select">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Filter</button>
|
||||
{% if user_uid %}
|
||||
<a href="/admin/files/repositories.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Private</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for repo in repositories %}
|
||||
<tr>
|
||||
<td>{{ repo.name }}</td>
|
||||
<td><a href="/admin/users/{{ repo.user_uid }}/edit.html">{{ repo.user_nick }}</a></td>
|
||||
<td>{% if repo.is_private %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ repo.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/files/repositories.html?{% if user_uid %}user_uid={{ user_uid }}{% endif %}" style="display:inline;">
|
||||
<input type="hidden" name="repo_uid" value="{{ repo.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this repository?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No repositories found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/files/repositories.html?page={{ page - 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/files/repositories.html?page={{ page + 1 }}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
64
src/snek/templates/admin/forums/index.html
Normal file
64
src/snek/templates/admin/forums/index.html
Normal file
@ -0,0 +1,64 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Forums</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Description</th>
|
||||
<th>Active</th>
|
||||
<th>Threads</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for forum in forums %}
|
||||
<tr class="{% if not forum.is_active %}row-inactive{% endif %}">
|
||||
<td>{{ forum.name }}</td>
|
||||
<td class="description-preview">{{ forum.description }}</td>
|
||||
<td>{% if forum.is_active %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ forum.thread_count }}</td>
|
||||
<td>{{ forum.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html" class="btn btn-small">Threads</a>
|
||||
<form method="post" action="/admin/forums/index.html" style="display:inline;">
|
||||
<input type="hidden" name="forum_uid" value="{{ forum.uid }}">
|
||||
<input type="hidden" name="action" value="toggle_active">
|
||||
<button type="submit" class="btn btn-small">{% if forum.is_active %}Deactivate{% else %}Activate{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/forums/index.html" style="display:inline;">
|
||||
<input type="hidden" name="forum_uid" value="{{ forum.uid }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this forum?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6">No forums found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/forums/index.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/forums/index.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
60
src/snek/templates/admin/forums/posts.html
Normal file
60
src/snek/templates/admin/forums/posts.html
Normal file
@ -0,0 +1,60 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Posts</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ thread.title }}</h2>
|
||||
<p>Forum: {{ forum.name }}</p>
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html" class="btn btn-secondary">Back to Threads</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Author</th>
|
||||
<th>Content</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in posts %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ post.created_by_uid }}/edit.html">{{ post.created_by_nick }}</a></td>
|
||||
<td class="message-preview">{{ post.content }}</td>
|
||||
<td>{{ post.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/forums/threads/{{ thread.uid }}/posts.html" style="display:inline;">
|
||||
<input type="hidden" name="post_uid" value="{{ post.uid }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this post?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No posts found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
77
src/snek/templates/admin/forums/threads.html
Normal file
77
src/snek/templates/admin/forums/threads.html
Normal file
@ -0,0 +1,77 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Threads</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<h2>{{ forum.name }}</h2>
|
||||
<p>{{ forum.description }}</p>
|
||||
<a href="/admin/forums/index.html" class="btn btn-secondary">Back to Forums</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Author</th>
|
||||
<th>Pinned</th>
|
||||
<th>Locked</th>
|
||||
<th>Posts</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for thread in threads %}
|
||||
<tr class="{% if thread.is_pinned %}row-pinned{% endif %} {% if thread.is_locked %}row-locked{% endif %}">
|
||||
<td>{{ thread.title }}</td>
|
||||
<td><a href="/admin/users/{{ thread.created_by_uid }}/edit.html">{{ thread.created_by_nick }}</a></td>
|
||||
<td>{% if thread.is_pinned %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{% if thread.is_locked %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ thread.post_count }}</td>
|
||||
<td>{{ thread.created_at }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/forums/threads/{{ thread.uid }}/posts.html" class="btn btn-small">Posts</a>
|
||||
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
|
||||
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
|
||||
<input type="hidden" name="action" value="toggle_pin">
|
||||
<button type="submit" class="btn btn-small">{% if thread.is_pinned %}Unpin{% else %}Pin{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
|
||||
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
|
||||
<input type="hidden" name="action" value="toggle_lock">
|
||||
<button type="submit" class="btn btn-small">{% if thread.is_locked %}Unlock{% else %}Lock{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/forums/{{ forum.uid }}/threads.html" style="display:inline;">
|
||||
<input type="hidden" name="thread_uid" value="{{ thread.uid }}">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this thread?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7">No threads found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/forums/{{ forum.uid }}/threads.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
88
src/snek/templates/admin/index.html
Normal file
88
src/snek/templates/admin/index.html
Normal file
@ -0,0 +1,88 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Dashboard</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-stats">
|
||||
<h2>System Overview</h2>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card stat-highlight">
|
||||
<span class="stat-label">Total Users</span>
|
||||
<span class="stat-value">{{ stats.users }}</span>
|
||||
<span class="stat-sub">{{ stats.online }} currently online</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Channels</span>
|
||||
<span class="stat-value">{{ stats.channels }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Messages</span>
|
||||
<span class="stat-value">{{ stats.messages }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Notifications</span>
|
||||
<span class="stat-value">{{ stats.notifications }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Forums</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Forums</span>
|
||||
<span class="stat-value">{{ stats.forums }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Threads</span>
|
||||
<span class="stat-value">{{ stats.threads }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Posts</span>
|
||||
<span class="stat-value">{{ stats.posts }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Storage</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">User Drives</span>
|
||||
<span class="stat-value">{{ stats.drives }}</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">Git Repositories</span>
|
||||
<span class="stat-value">{{ stats.repositories }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-activity">
|
||||
<h2>Recent Messages</h2>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Channel</th>
|
||||
<th>Message</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in activity %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ item.user_uid }}/edit.html">{{ item.user_nick }}</a></td>
|
||||
<td><a href="/admin/channels/{{ item.channel_uid }}/edit.html">{{ item.channel_name }}</a></td>
|
||||
<td class="message-preview">{{ item.content }}</td>
|
||||
<td class="time-cell">{{ item.created_at }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No recent activity</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
80
src/snek/templates/admin/messages/index.html
Normal file
80
src/snek/templates/admin/messages/index.html
Normal file
@ -0,0 +1,80 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Messages</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/messages/index.html">
|
||||
<div class="search-row">
|
||||
<input type="text" name="query" placeholder="Search messages..." value="{{ query }}" class="search-input">
|
||||
<select name="channel_uid" class="search-select">
|
||||
<option value="">All Channels</option>
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.uid }}" {% if channel.uid == channel_uid %}selected{% endif %}>{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="user_uid" class="search-select">
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.uid }}" {% if user.uid == user_uid %}selected{% endif %}>{{ user.nick }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
{% if query or channel_uid or user_uid %}
|
||||
<a href="/admin/messages/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Channel</th>
|
||||
<th>Message</th>
|
||||
<th>Time</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for message in messages %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ message.user_uid }}/edit.html">{{ message.user_nick }}</a></td>
|
||||
<td><a href="/admin/channels/{{ message.channel_uid }}/edit.html">{{ message.channel_name }}</a></td>
|
||||
<td class="message-preview">{{ message.content }}</td>
|
||||
<td>{{ message.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/messages/index.html?{{ request.query_string }}" style="display:inline;">
|
||||
<input type="hidden" name="message_uid" value="{{ message.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this message?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No messages found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} messages</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/messages/index.html?page={{ page - 1 }}{% if query %}&query={{ query }}{% endif %}{% if channel_uid %}&channel_uid={{ channel_uid }}{% endif %}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/messages/index.html?page={{ page + 1 }}{% if query %}&query={{ query }}{% endif %}{% if channel_uid %}&channel_uid={{ channel_uid }}{% endif %}{% if user_uid %}&user_uid={{ user_uid }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
65
src/snek/templates/admin/notifications/create.html
Normal file
65
src/snek/templates/admin/notifications/create.html
Normal file
@ -0,0 +1,65 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Create Notification</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form">
|
||||
<h2>Mass Notification</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/notifications/create.html">
|
||||
<div class="form-group">
|
||||
<label for="message">Message</label>
|
||||
<textarea id="message" name="message" rows="4" maxlength="500" required placeholder="Enter notification message...">{{ message or '' }}</textarea>
|
||||
<small>Maximum 500 characters</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Recipients</label>
|
||||
<div class="radio-group">
|
||||
<label>
|
||||
<input type="radio" name="target" value="all" checked>
|
||||
All Users
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="target" value="selected">
|
||||
Selected Users
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="user-select-group" style="display:none;">
|
||||
<label>Select Users</label>
|
||||
<div class="user-checkboxes">
|
||||
{% for user in users %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="user_uids" value="{{ user.uid }}">
|
||||
{{ user.nick }} ({{ user.username }})
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Send Notification</button>
|
||||
<a href="/admin/notifications/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('input[name="target"]').forEach(function(radio) {
|
||||
radio.addEventListener('change', function() {
|
||||
var userGroup = document.getElementById('user-select-group');
|
||||
userGroup.style.display = this.value === 'selected' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock main %}
|
||||
55
src/snek/templates/admin/notifications/index.html
Normal file
55
src/snek/templates/admin/notifications/index.html
Normal file
@ -0,0 +1,55 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Notifications</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-header">
|
||||
<a href="/admin/notifications/create.html" class="btn btn-primary">Create Mass Notification</a>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Type</th>
|
||||
<th>Message</th>
|
||||
<th>Read</th>
|
||||
<th>Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for notif in notifications %}
|
||||
<tr class="{% if notif.is_read %}row-read{% endif %}">
|
||||
<td><a href="/admin/users/{{ notif.user_uid }}/edit.html">{{ notif.user_nick }}</a></td>
|
||||
<td>{{ notif.type }}</td>
|
||||
<td class="message-preview">{{ notif.message }}</td>
|
||||
<td>{% if notif.is_read %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ notif.created_at }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5">No notifications found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }}</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/notifications/index.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/notifications/index.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
20
src/snek/templates/admin/sidebar.html
Normal file
20
src/snek/templates/admin/sidebar.html
Normal file
@ -0,0 +1,20 @@
|
||||
<aside class="sidebar admin-sidebar" id="channelSidebar">
|
||||
<h2>Admin</h2>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/admin/index.html">Dashboard</a></li>
|
||||
<li><a class="no-select" href="/admin/users/index.html">Users</a></li>
|
||||
<li><a class="no-select" href="/admin/channels/index.html">Channels</a></li>
|
||||
<li><a class="no-select" href="/admin/messages/index.html">Messages</a></li>
|
||||
<li><a class="no-select" href="/admin/forums/index.html">Forums</a></li>
|
||||
<li><a class="no-select" href="/admin/files/index.html">Files</a></li>
|
||||
<li><a class="no-select" href="/admin/files/repositories.html">Repositories</a></li>
|
||||
<li><a class="no-select" href="/admin/notifications/index.html">Notifications</a></li>
|
||||
<li><a class="no-select" href="/admin/system/index.html">System</a></li>
|
||||
<li><a class="no-select" href="/admin/system/kv.html">KV Store</a></li>
|
||||
<li><a class="no-select" href="/admin/system/push.html">Push</a></li>
|
||||
</ul>
|
||||
<hr>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/settings/index.html">Back to Settings</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
52
src/snek/templates/admin/system/index.html
Normal file
52
src/snek/templates/admin/system/index.html
Normal file
@ -0,0 +1,52 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>System</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
{% if message %}
|
||||
<div class="alert alert-success">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="admin-stats">
|
||||
<h2>System Information</h2>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Uptime</span>
|
||||
<span class="info-value">{{ uptime }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Total Users</span>
|
||||
<span class="info-value">{{ stats.users }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Online Users</span>
|
||||
<span class="info-value">{{ stats.online }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Total Messages</span>
|
||||
<span class="info-value">{{ stats.messages }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-actions-section">
|
||||
<h2>Maintenance</h2>
|
||||
<form method="post" action="/admin/system/index.html">
|
||||
<input type="hidden" name="action" value="maintenance">
|
||||
<p>Run database maintenance (VACUUM and ANALYZE) to optimize performance.</p>
|
||||
<button type="submit" class="btn btn-primary">Run Maintenance</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-links">
|
||||
<h2>Quick Links</h2>
|
||||
<ul>
|
||||
<li><a href="/admin/system/kv.html">KV Store Editor</a></li>
|
||||
<li><a href="/admin/system/push.html">Push Registrations</a></li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
65
src/snek/templates/admin/system/kv.html
Normal file
65
src/snek/templates/admin/system/kv.html
Normal file
@ -0,0 +1,65 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>KV Store</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="admin-form">
|
||||
<h2>Add / Update Entry</h2>
|
||||
<form method="post" action="/admin/system/kv.html">
|
||||
<input type="hidden" name="action" value="set">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="key">Key</label>
|
||||
<input type="text" id="key" name="key" maxlength="255" required placeholder="key_name">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="value">Value</label>
|
||||
<input type="text" id="value" name="value" maxlength="10000" required placeholder="value">
|
||||
</div>
|
||||
<div class="form-group form-actions-inline">
|
||||
<button type="submit" class="btn btn-primary">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<h2>Current Entries</h2>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td><code>{{ entry.key }}</code></td>
|
||||
<td class="value-preview">{{ entry.value }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/system/kv.html" style="display:inline;">
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="key" value="{{ entry.key }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this entry?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3">No entries found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
53
src/snek/templates/admin/system/push.html
Normal file
53
src/snek/templates/admin/system/push.html
Normal file
@ -0,0 +1,53 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Push Registrations</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Endpoint</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reg in registrations %}
|
||||
<tr>
|
||||
<td><a href="/admin/users/{{ reg.user_uid }}/edit.html">{{ reg.user_nick }}</a></td>
|
||||
<td class="endpoint-preview">{{ reg.endpoint }}</td>
|
||||
<td>{{ reg.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/system/push.html" style="display:inline;">
|
||||
<input type="hidden" name="registration_uid" value="{{ reg.uid }}">
|
||||
<button type="submit" class="btn btn-small btn-danger" onclick="return confirm('Delete this registration?')">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">No push registrations found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
{% if total > per_page %}
|
||||
<section class="admin-pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/system/push.html?page={{ page - 1 }}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/system/push.html?page={{ page + 1 }}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock main %}
|
||||
37
src/snek/templates/admin/users/ban.html
Normal file
37
src/snek/templates/admin/users/ban.html
Normal file
@ -0,0 +1,37 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>{% if target_user.is_banned %}Unban{% else %}Ban{% endif %} User</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form danger-zone">
|
||||
<h2>{{ target_user.username }} ({{ target_user.nick }})</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if target_user.is_banned %}
|
||||
<p>This user is currently banned. Unbanning will restore their access to the system.</p>
|
||||
<form method="post" action="/admin/users/{{ target_user.uid }}/ban.html">
|
||||
<input type="hidden" name="action" value="unban">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Unban User</button>
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p>Banning this user will prevent them from logging in and accessing the system.</p>
|
||||
<form method="post" action="/admin/users/{{ target_user.uid }}/ban.html">
|
||||
<input type="hidden" name="action" value="ban">
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-danger">Ban User</button>
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
64
src/snek/templates/admin/users/edit.html
Normal file
64
src/snek/templates/admin/users/edit.html
Normal file
@ -0,0 +1,64 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Edit User</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-form">
|
||||
<h2>{{ target_user.username }}</h2>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/users/{{ target_user.uid }}/edit.html">
|
||||
<div class="form-group">
|
||||
<label for="nick">Nickname</label>
|
||||
<input type="text" id="nick" name="nick" value="{{ target_user.nick }}" maxlength="20" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="color">Color</label>
|
||||
<input type="color" id="color" name="color" value="{{ target_user.color or '#ffffff' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="is_admin" {% if target_user.is_admin %}checked{% endif %}>
|
||||
Administrator
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox">
|
||||
<label>
|
||||
<input type="checkbox" name="is_banned" {% if target_user.is_banned %}checked{% endif %}>
|
||||
Banned
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-info">
|
||||
<h3>User Information</h3>
|
||||
<dl>
|
||||
<dt>UID</dt>
|
||||
<dd>{{ target_user.uid }}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ target_user.created_at }}</dd>
|
||||
<dt>Last Online</dt>
|
||||
<dd>{{ target_user.last_online or 'Never' }}</dd>
|
||||
<dt>Location</dt>
|
||||
<dd>{{ target_user.city }}{% if target_user.country_long %}, {{ target_user.country_long }}{% endif %}</dd>
|
||||
<dt>IP</dt>
|
||||
<dd>{{ target_user.ip or 'Unknown' }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
68
src/snek/templates/admin/users/index.html
Normal file
68
src/snek/templates/admin/users/index.html
Normal file
@ -0,0 +1,68 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block logo %}
|
||||
<h1>Users</h1>
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="admin-content">
|
||||
<section class="admin-search">
|
||||
<form method="get" action="/admin/users/index.html">
|
||||
<input type="text" name="search" placeholder="Search users..." value="{{ search }}" class="search-input">
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
{% if search %}
|
||||
<a href="/admin/users/index.html" class="btn btn-secondary">Clear</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="admin-list">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Nick</th>
|
||||
<th>Admin</th>
|
||||
<th>Banned</th>
|
||||
<th>Location</th>
|
||||
<th>Last Online</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr class="{% if user.is_banned %}row-banned{% endif %}">
|
||||
<td>{{ user.username }}</td>
|
||||
<td>{{ user.nick }}</td>
|
||||
<td>{% if user.is_admin %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{% if user.is_banned %}Yes{% else %}No{% endif %}</td>
|
||||
<td>{{ user.city }}{% if user.country_short %}, {{ user.country_short }}{% endif %}</td>
|
||||
<td>{{ user.last_online or 'Never' }}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/users/{{ user.uid }}/edit.html" class="btn btn-small">Edit</a>
|
||||
<a href="/admin/users/{{ user.uid }}/ban.html" class="btn btn-small btn-danger">{% if user.is_banned %}Unban{% else %}Ban{% endif %}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7">No users found</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="admin-pagination">
|
||||
<span class="pagination-info">Showing {{ ((page - 1) * per_page) + 1 }}-{% if page * per_page < total %}{{ page * per_page }}{% else %}{{ total }}{% endif %} of {{ total }} users</span>
|
||||
<div class="pagination-controls">
|
||||
{% if page > 1 %}
|
||||
<a href="/admin/users/index.html?page={{ page - 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Previous</a>
|
||||
{% endif %}
|
||||
<span class="pagination-pages">Page {{ page }} of {{ ((total - 1) // per_page) + 1 }}</span>
|
||||
{% if page * per_page < total %}
|
||||
<a href="/admin/users/index.html?page={{ page + 1 }}{% if search %}&search={{ search }}{% endif %}" class="btn btn-secondary">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock main %}
|
||||
@ -40,8 +40,9 @@
|
||||
<link rel="stylesheet" href="/mention-nav.css">
|
||||
<link rel="icon" type="image/png" href="/image/snek_logo_32x32.png" sizes="32x32">
|
||||
<link rel="icon" type="image/png" href="/image/snek_logo_64x64.png" sizes="64x64">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
<header>
|
||||
<div class="logo no-select">{% block header_text %}{% endblock %}</div>
|
||||
<channel-menu></channel-menu>
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
{% extends "app.html" %}
|
||||
|
||||
{% block body_class %}profile-page{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<aside class="sidebar" id="channelSidebar">
|
||||
<h2>Navigation</h2>
|
||||
|
||||
@ -5,4 +5,10 @@
|
||||
<li><a class="no-select" href="/settings/profile_pages/index.html">Profile Pages</a></li>
|
||||
<li><a class="no-select" href="/settings/repositories/index.html">Repositories</a></li>
|
||||
</ul>
|
||||
{% if user and user.is_admin %}
|
||||
<h2>Administration</h2>
|
||||
<ul>
|
||||
<li><a class="no-select" href="/admin/index.html">Admin Panel</a></li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</aside>
|
||||
|
||||
34
src/snek/view/admin/__init__.py
Normal file
34
src/snek/view/admin/__init__.py
Normal file
@ -0,0 +1,34 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.view.admin.index import AdminIndexView
|
||||
from snek.view.admin.users import AdminUsersIndexView, AdminUserEditView, AdminUserBanView
|
||||
from snek.view.admin.channels import AdminChannelsIndexView, AdminChannelMembersView, AdminChannelEditView, AdminChannelClearView, AdminChannelDeleteView
|
||||
from snek.view.admin.messages import AdminMessagesIndexView
|
||||
from snek.view.admin.forums import AdminForumsIndexView, AdminThreadsView, AdminPostsView
|
||||
from snek.view.admin.files import AdminFilesIndexView, AdminDriveItemsView, AdminRepositoriesView
|
||||
from snek.view.admin.notifications import AdminNotificationsIndexView, AdminNotificationCreateView
|
||||
from snek.view.admin.system import AdminSystemIndexView, AdminKVView, AdminPushView
|
||||
|
||||
__all__ = [
|
||||
"AdminIndexView",
|
||||
"AdminUsersIndexView",
|
||||
"AdminUserEditView",
|
||||
"AdminUserBanView",
|
||||
"AdminChannelsIndexView",
|
||||
"AdminChannelMembersView",
|
||||
"AdminChannelEditView",
|
||||
"AdminChannelClearView",
|
||||
"AdminChannelDeleteView",
|
||||
"AdminMessagesIndexView",
|
||||
"AdminForumsIndexView",
|
||||
"AdminThreadsView",
|
||||
"AdminPostsView",
|
||||
"AdminFilesIndexView",
|
||||
"AdminDriveItemsView",
|
||||
"AdminRepositoriesView",
|
||||
"AdminNotificationsIndexView",
|
||||
"AdminNotificationCreateView",
|
||||
"AdminSystemIndexView",
|
||||
"AdminKVView",
|
||||
"AdminPushView",
|
||||
]
|
||||
24
src/snek/view/admin/base.py
Normal file
24
src/snek/view/admin/base.py
Normal file
@ -0,0 +1,24 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.system.view import BaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminBaseView(BaseView):
|
||||
|
||||
login_required = True
|
||||
|
||||
async def _iter(self):
|
||||
if not self.session.get("logged_in") or not self.session.get("uid"):
|
||||
logger.warning("Unauthorized admin access attempt: not logged in")
|
||||
return web.HTTPFound("/login.html")
|
||||
user = await self.services.user.get(uid=self.session.get("uid"))
|
||||
if not user or not user["is_admin"]:
|
||||
logger.warning(f"Forbidden admin access attempt by user {self.session.get('uid')}")
|
||||
return web.HTTPForbidden(text="Admin access required")
|
||||
return await super()._iter()
|
||||
148
src/snek/view/admin/channels.py
Normal file
148
src/snek/view/admin/channels.py
Normal file
@ -0,0 +1,148 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminChannelsIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin channels list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
search = self.request.query.get("search", "")
|
||||
result = await self.services.admin.list_channels(page=page, search=search if search else None)
|
||||
return await self.render_template(
|
||||
"admin/channels/index.html",
|
||||
{
|
||||
"channels": result["channels"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"search": search,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelMembersView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel members accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
members = await self.services.admin.get_channel_members(channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/members.html",
|
||||
{
|
||||
"channel": channel,
|
||||
"members": members,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelEditView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel edit accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
return await self.render_template(
|
||||
"admin/channels/edit.html",
|
||||
{"channel": channel}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel edit POST for {channel_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
data = await self.request.post()
|
||||
updates = {}
|
||||
if "name" in data:
|
||||
updates["name"] = data["name"]
|
||||
if "description" in data:
|
||||
updates["description"] = data["description"]
|
||||
if "is_private" in data:
|
||||
updates["is_private"] = data["is_private"] == "on"
|
||||
await self.services.admin.update_channel(
|
||||
admin_uid=self.session.get("uid"),
|
||||
channel_uid=channel_uid,
|
||||
updates=updates
|
||||
)
|
||||
return web.HTTPFound("/admin/channels/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Channel update error: {ex}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/edit.html",
|
||||
{"channel": channel, "error": str(ex)}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelClearView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel clear accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
return await self.render_template(
|
||||
"admin/channels/clear.html",
|
||||
{"channel": channel}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel clear POST for {channel_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
result = await self.services.admin.clear_channel_history(
|
||||
admin_uid=self.session.get("uid"),
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return web.HTTPFound(f"/admin/channels/index.html?cleared={result['deleted_count']}")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Channel clear error: {ex}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/clear.html",
|
||||
{"channel": channel, "error": str(ex)}
|
||||
)
|
||||
|
||||
|
||||
class AdminChannelDeleteView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel delete accessed for {channel_uid} by {self.session.get('uid')}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
if not channel:
|
||||
return web.HTTPNotFound(text="Channel not found")
|
||||
return await self.render_template(
|
||||
"admin/channels/delete.html",
|
||||
{"channel": channel}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
channel_uid = self.request.match_info.get("channel_uid")
|
||||
logger.info(f"Admin channel delete POST for {channel_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
await self.services.admin.delete_channel(
|
||||
admin_uid=self.session.get("uid"),
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return web.HTTPFound("/admin/channels/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Channel delete error: {ex}")
|
||||
channel = await self.services.channel.get(uid=channel_uid)
|
||||
return await self.render_template(
|
||||
"admin/channels/delete.html",
|
||||
{"channel": channel, "error": str(ex)}
|
||||
)
|
||||
110
src/snek/view/admin/files.py
Normal file
110
src/snek/view/admin/files.py
Normal file
@ -0,0 +1,110 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminFilesIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin files list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
user_uid = self.request.query.get("user_uid", "")
|
||||
result = await self.services.admin.list_drives(
|
||||
user_uid=user_uid if user_uid else None,
|
||||
page=page
|
||||
)
|
||||
users_result = await self.services.admin.list_users(per_page=100)
|
||||
return await self.render_template(
|
||||
"admin/files/index.html",
|
||||
{
|
||||
"drives": result["drives"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"user_uid": user_uid,
|
||||
"users": users_result["users"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminDriveItemsView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
drive_uid = self.request.match_info.get("drive_uid")
|
||||
logger.info(f"Admin drive items accessed for {drive_uid} by {self.session.get('uid')}")
|
||||
drive = await self.services.drive.get(uid=drive_uid)
|
||||
if not drive:
|
||||
return web.HTTPNotFound(text="Drive not found")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_drive_items(drive_uid=drive_uid, page=page)
|
||||
user = await self.services.user.get(uid=drive["user_uid"])
|
||||
return await self.render_template(
|
||||
"admin/files/items.html",
|
||||
{
|
||||
"drive": drive,
|
||||
"user": user,
|
||||
"items": result["items"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
drive_uid = self.request.match_info.get("drive_uid")
|
||||
logger.info(f"Admin drive item delete POST for {drive_uid} by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
item_uid = data.get("item_uid")
|
||||
if item_uid:
|
||||
try:
|
||||
await self.services.admin.delete_drive_item(
|
||||
admin_uid=self.session.get("uid"),
|
||||
item_uid=item_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Drive item delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
|
||||
|
||||
class AdminRepositoriesView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin repositories list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
user_uid = self.request.query.get("user_uid", "")
|
||||
result = await self.services.admin.list_repositories(
|
||||
user_uid=user_uid if user_uid else None,
|
||||
page=page
|
||||
)
|
||||
users_result = await self.services.admin.list_users(per_page=100)
|
||||
return await self.render_template(
|
||||
"admin/files/repositories.html",
|
||||
{
|
||||
"repositories": result["repositories"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"user_uid": user_uid,
|
||||
"users": users_result["users"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin repository delete POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
repo_uid = data.get("repo_uid")
|
||||
if repo_uid:
|
||||
try:
|
||||
await self.services.admin.delete_repository(
|
||||
admin_uid=self.session.get("uid"),
|
||||
repo_uid=repo_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Repository delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
139
src/snek/view/admin/forums.py
Normal file
139
src/snek/view/admin/forums.py
Normal file
@ -0,0 +1,139 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminForumsIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin forums list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_forums(page=page)
|
||||
return await self.render_template(
|
||||
"admin/forums/index.html",
|
||||
{
|
||||
"forums": result["forums"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin forum action POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
forum_uid = data.get("forum_uid")
|
||||
action = data.get("action")
|
||||
if forum_uid and action:
|
||||
try:
|
||||
if action == "delete":
|
||||
await self.services.admin.delete_forum(
|
||||
admin_uid=self.session.get("uid"),
|
||||
forum_uid=forum_uid
|
||||
)
|
||||
elif action == "toggle_active":
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if forum:
|
||||
await self.services.admin.update_forum(
|
||||
admin_uid=self.session.get("uid"),
|
||||
forum_uid=forum_uid,
|
||||
updates={"is_active": not forum.get("is_active", True)}
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Forum action error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
|
||||
|
||||
class AdminThreadsView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
forum_uid = self.request.match_info.get("forum_uid")
|
||||
logger.info(f"Admin threads list accessed for forum {forum_uid} by {self.session.get('uid')}")
|
||||
forum = await self.services.forum.get(uid=forum_uid)
|
||||
if not forum:
|
||||
return web.HTTPNotFound(text="Forum not found")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_threads(forum_uid=forum_uid, page=page)
|
||||
return await self.render_template(
|
||||
"admin/forums/threads.html",
|
||||
{
|
||||
"forum": forum,
|
||||
"threads": result["threads"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
forum_uid = self.request.match_info.get("forum_uid")
|
||||
logger.info(f"Admin thread action POST for forum {forum_uid} by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
thread_uid = data.get("thread_uid")
|
||||
action = data.get("action")
|
||||
if thread_uid and action:
|
||||
try:
|
||||
if action == "delete":
|
||||
await self.services.admin.delete_thread(
|
||||
admin_uid=self.session.get("uid"),
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
elif action == "toggle_pin":
|
||||
await self.services.admin.toggle_thread_pin(
|
||||
admin_uid=self.session.get("uid"),
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
elif action == "toggle_lock":
|
||||
await self.services.admin.toggle_thread_lock(
|
||||
admin_uid=self.session.get("uid"),
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Thread action error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
|
||||
|
||||
class AdminPostsView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
thread_uid = self.request.match_info.get("thread_uid")
|
||||
logger.info(f"Admin posts list accessed for thread {thread_uid} by {self.session.get('uid')}")
|
||||
thread = await self.services.thread.get(uid=thread_uid)
|
||||
if not thread:
|
||||
return web.HTTPNotFound(text="Thread not found")
|
||||
forum = await self.services.forum.get(uid=thread["forum_uid"])
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_posts(thread_uid=thread_uid, page=page)
|
||||
return await self.render_template(
|
||||
"admin/forums/posts.html",
|
||||
{
|
||||
"forum": forum,
|
||||
"thread": thread,
|
||||
"posts": result["posts"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
thread_uid = self.request.match_info.get("thread_uid")
|
||||
logger.info(f"Admin post action POST for thread {thread_uid} by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
post_uid = data.get("post_uid")
|
||||
action = data.get("action")
|
||||
if post_uid and action == "delete":
|
||||
try:
|
||||
await self.services.admin.delete_post(
|
||||
admin_uid=self.session.get("uid"),
|
||||
post_uid=post_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Post delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
22
src/snek/view/admin/index.py
Normal file
22
src/snek/view/admin/index.py
Normal file
@ -0,0 +1,22 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin dashboard accessed by user {self.session.get('uid')}")
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
activity = await self.services.admin.get_recent_activity(limit=10)
|
||||
return await self.render_template(
|
||||
"admin/index.html",
|
||||
{
|
||||
"stats": stats,
|
||||
"activity": activity,
|
||||
}
|
||||
)
|
||||
55
src/snek/view/admin/messages.py
Normal file
55
src/snek/view/admin/messages.py
Normal file
@ -0,0 +1,55 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminMessagesIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin messages list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
query = self.request.query.get("query", "")
|
||||
channel_uid = self.request.query.get("channel_uid", "")
|
||||
user_uid = self.request.query.get("user_uid", "")
|
||||
result = await self.services.admin.search_messages(
|
||||
query=query if query else None,
|
||||
channel_uid=channel_uid if channel_uid else None,
|
||||
user_uid=user_uid if user_uid else None,
|
||||
page=page
|
||||
)
|
||||
channels_result = await self.services.admin.list_channels(per_page=100)
|
||||
users_result = await self.services.admin.list_users(per_page=100)
|
||||
return await self.render_template(
|
||||
"admin/messages/index.html",
|
||||
{
|
||||
"messages": result["messages"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"query": query,
|
||||
"channel_uid": channel_uid,
|
||||
"user_uid": user_uid,
|
||||
"channels": channels_result["channels"],
|
||||
"users": users_result["users"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin message delete POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
message_uid = data.get("message_uid")
|
||||
if message_uid:
|
||||
try:
|
||||
await self.services.admin.delete_message(
|
||||
admin_uid=self.session.get("uid"),
|
||||
message_uid=message_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Message delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
58
src/snek/view/admin/notifications.py
Normal file
58
src/snek/view/admin/notifications.py
Normal file
@ -0,0 +1,58 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminNotificationsIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin notifications list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_notifications(page=page)
|
||||
return await self.render_template(
|
||||
"admin/notifications/index.html",
|
||||
{
|
||||
"notifications": result["notifications"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminNotificationCreateView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin notification create accessed by user {self.session.get('uid')}")
|
||||
users_result = await self.services.admin.list_users(per_page=200)
|
||||
return await self.render_template(
|
||||
"admin/notifications/create.html",
|
||||
{"users": users_result["users"]}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin notification create POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
message = data.get("message", "").strip()
|
||||
target = data.get("target", "all")
|
||||
user_uids = data.getall("user_uids", []) if target == "selected" else None
|
||||
try:
|
||||
result = await self.services.admin.mass_notify(
|
||||
admin_uid=self.session.get("uid"),
|
||||
message=message,
|
||||
user_uids=user_uids
|
||||
)
|
||||
return web.HTTPFound(f"/admin/notifications/index.html?sent={result['sent_count']}")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Mass notify error: {ex}")
|
||||
users_result = await self.services.admin.list_users(per_page=200)
|
||||
return await self.render_template(
|
||||
"admin/notifications/create.html",
|
||||
{"users": users_result["users"], "error": str(ex), "message": message}
|
||||
)
|
||||
117
src/snek/view/admin/system.py
Normal file
117
src/snek/view/admin/system.py
Normal file
@ -0,0 +1,117 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminSystemIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin system index accessed by user {self.session.get('uid')}")
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
return await self.render_template(
|
||||
"admin/system/index.html",
|
||||
{
|
||||
"stats": stats,
|
||||
"uptime": self.app.uptime,
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin system action POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
action = data.get("action")
|
||||
message = None
|
||||
if action == "maintenance":
|
||||
try:
|
||||
result = await self.services.admin.run_maintenance(
|
||||
admin_uid=self.session.get("uid")
|
||||
)
|
||||
message = result.get("message", "Maintenance completed")
|
||||
except Exception as ex:
|
||||
logger.warning(f"Maintenance error: {ex}")
|
||||
message = f"Error: {ex}"
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
return await self.render_template(
|
||||
"admin/system/index.html",
|
||||
{
|
||||
"stats": stats,
|
||||
"uptime": self.app.uptime,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminKVView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin KV store accessed by user {self.session.get('uid')}")
|
||||
entries = await self.services.admin.get_kv_entries()
|
||||
return await self.render_template(
|
||||
"admin/system/kv.html",
|
||||
{"entries": entries}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin KV store POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
action = data.get("action")
|
||||
try:
|
||||
if action == "set":
|
||||
key = data.get("key", "").strip()
|
||||
value = data.get("value", "").strip()
|
||||
await self.services.admin.set_kv_entry(
|
||||
admin_uid=self.session.get("uid"),
|
||||
key=key,
|
||||
value=value
|
||||
)
|
||||
elif action == "delete":
|
||||
key = data.get("key", "").strip()
|
||||
await self.services.admin.delete_kv_entry(
|
||||
admin_uid=self.session.get("uid"),
|
||||
key=key
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"KV store error: {ex}")
|
||||
entries = await self.services.admin.get_kv_entries()
|
||||
return await self.render_template(
|
||||
"admin/system/kv.html",
|
||||
{"entries": entries, "error": str(ex)}
|
||||
)
|
||||
return web.HTTPFound("/admin/system/kv.html")
|
||||
|
||||
|
||||
class AdminPushView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin push registrations accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
result = await self.services.admin.list_push_registrations(page=page)
|
||||
return await self.render_template(
|
||||
"admin/system/push.html",
|
||||
{
|
||||
"registrations": result["registrations"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
logger.info(f"Admin push registration delete POST by {self.session.get('uid')}")
|
||||
data = await self.request.post()
|
||||
registration_uid = data.get("registration_uid")
|
||||
if registration_uid:
|
||||
try:
|
||||
await self.services.admin.delete_push_registration(
|
||||
admin_uid=self.session.get("uid"),
|
||||
registration_uid=registration_uid
|
||||
)
|
||||
except ValueError as ex:
|
||||
logger.warning(f"Push registration delete error: {ex}")
|
||||
return web.HTTPFound(self.request.path_qs)
|
||||
109
src/snek/view/admin/users.py
Normal file
109
src/snek/view/admin/users.py
Normal file
@ -0,0 +1,109 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from snek.view.admin.base import AdminBaseView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminUsersIndexView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
logger.info(f"Admin users list accessed by user {self.session.get('uid')}")
|
||||
page = int(self.request.query.get("page", 1))
|
||||
search = self.request.query.get("search", "")
|
||||
result = await self.services.admin.list_users(page=page, search=search if search else None)
|
||||
return await self.render_template(
|
||||
"admin/users/index.html",
|
||||
{
|
||||
"users": result["users"],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"per_page": result["per_page"],
|
||||
"search": search,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AdminUserEditView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
logger.info(f"Admin user edit accessed for {user_uid} by {self.session.get('uid')}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
if not target_user:
|
||||
return web.HTTPNotFound(text="User not found")
|
||||
return await self.render_template(
|
||||
"admin/users/edit.html",
|
||||
{"target_user": target_user}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
logger.info(f"Admin user edit POST for {user_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
data = await self.request.post()
|
||||
updates = {}
|
||||
if "nick" in data:
|
||||
updates["nick"] = data["nick"]
|
||||
if "is_admin" in data:
|
||||
updates["is_admin"] = data["is_admin"] == "on"
|
||||
if "is_banned" in data:
|
||||
updates["is_banned"] = data["is_banned"] == "on"
|
||||
if "color" in data:
|
||||
updates["color"] = data["color"]
|
||||
await self.services.admin.update_user(
|
||||
admin_uid=self.session.get("uid"),
|
||||
target_uid=user_uid,
|
||||
updates=updates
|
||||
)
|
||||
return web.HTTPFound("/admin/users/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"User update error: {ex}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
return await self.render_template(
|
||||
"admin/users/edit.html",
|
||||
{"target_user": target_user, "error": str(ex)}
|
||||
)
|
||||
|
||||
|
||||
class AdminUserBanView(AdminBaseView):
|
||||
|
||||
async def get(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
logger.info(f"Admin user ban accessed for {user_uid} by {self.session.get('uid')}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
if not target_user:
|
||||
return web.HTTPNotFound(text="User not found")
|
||||
return await self.render_template(
|
||||
"admin/users/ban.html",
|
||||
{"target_user": target_user}
|
||||
)
|
||||
|
||||
async def post(self):
|
||||
user_uid = self.request.match_info.get("user_uid")
|
||||
data = await self.request.post()
|
||||
action = data.get("action", "ban")
|
||||
logger.info(f"Admin user {action} POST for {user_uid} by {self.session.get('uid')}")
|
||||
try:
|
||||
if action == "unban":
|
||||
await self.services.admin.unban_user(
|
||||
admin_uid=self.session.get("uid"),
|
||||
target_uid=user_uid
|
||||
)
|
||||
else:
|
||||
await self.services.admin.ban_user(
|
||||
admin_uid=self.session.get("uid"),
|
||||
target_uid=user_uid
|
||||
)
|
||||
return web.HTTPFound("/admin/users/index.html")
|
||||
except ValueError as ex:
|
||||
logger.warning(f"User ban error: {ex}")
|
||||
target_user = await self.services.user.get(uid=user_uid)
|
||||
return await self.render_template(
|
||||
"admin/users/ban.html",
|
||||
{"target_user": target_user, "error": str(ex)}
|
||||
)
|
||||
@ -12,6 +12,7 @@ from snek.system.view import BaseView
|
||||
from snek.view.rpc.base import BaseAction
|
||||
from snek.view.rpc.utils import safe_get, safe_str
|
||||
from snek.view.rpc.actions import (
|
||||
AdminActions,
|
||||
AuthActions,
|
||||
UserActions,
|
||||
ChannelActions,
|
||||
@ -27,6 +28,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class RPCApi(
|
||||
BaseAction,
|
||||
AdminActions,
|
||||
AuthActions,
|
||||
UserActions,
|
||||
ChannelActions,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from snek.view.rpc.actions.admin import AdminActions
|
||||
from snek.view.rpc.actions.auth import AuthActions
|
||||
from snek.view.rpc.actions.user import UserActions
|
||||
from snek.view.rpc.actions.channel import ChannelActions
|
||||
@ -10,6 +11,7 @@ from snek.view.rpc.actions.database import DatabaseActions
|
||||
from snek.view.rpc.actions.misc import MiscActions
|
||||
|
||||
__all__ = [
|
||||
"AdminActions",
|
||||
"AuthActions",
|
||||
"UserActions",
|
||||
"ChannelActions",
|
||||
|
||||
347
src/snek/view/rpc/actions/admin.py
Normal file
347
src/snek/view/rpc/actions/admin.py
Normal file
@ -0,0 +1,347 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
|
||||
from snek.view.rpc.utils import safe_str
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminActions:
|
||||
|
||||
async def _require_admin(self):
|
||||
self._require_login()
|
||||
self._require_services()
|
||||
user = await self.services.user.get(uid=self.user_uid)
|
||||
if not user or not user["is_admin"]:
|
||||
raise PermissionError("Admin access required")
|
||||
return True
|
||||
|
||||
async def admin_get_stats(self):
|
||||
try:
|
||||
await self._require_admin()
|
||||
stats = await self.services.admin.get_dashboard_stats()
|
||||
return {"success": True, "data": stats}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_stats failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get stats"}
|
||||
|
||||
async def admin_get_recent_activity(self, limit=20):
|
||||
try:
|
||||
await self._require_admin()
|
||||
activity = await self.services.admin.get_recent_activity(limit=limit)
|
||||
return {"success": True, "data": activity}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_recent_activity failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get activity"}
|
||||
|
||||
async def admin_list_users(self, page=1, per_page=20, search=None):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_users(page=page, per_page=per_page, search=search)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_users failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list users"}
|
||||
|
||||
async def admin_update_user(self, target_uid, updates):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.update_user(
|
||||
admin_uid=self.user_uid,
|
||||
target_uid=target_uid,
|
||||
updates=updates
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_update_user failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to update user"}
|
||||
|
||||
async def admin_ban_user(self, target_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.ban_user(
|
||||
admin_uid=self.user_uid,
|
||||
target_uid=target_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_ban_user failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to ban user"}
|
||||
|
||||
async def admin_unban_user(self, target_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.unban_user(
|
||||
admin_uid=self.user_uid,
|
||||
target_uid=target_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_unban_user failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to unban user"}
|
||||
|
||||
async def admin_list_channels(self, page=1, per_page=20, search=None):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_channels(page=page, per_page=per_page, search=search)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_channels failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list channels"}
|
||||
|
||||
async def admin_get_channel_members(self, channel_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.get_channel_members(channel_uid)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_channel_members failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get channel members"}
|
||||
|
||||
async def admin_update_channel(self, channel_uid, updates):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.update_channel(
|
||||
admin_uid=self.user_uid,
|
||||
channel_uid=channel_uid,
|
||||
updates=updates
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_update_channel failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to update channel"}
|
||||
|
||||
async def admin_delete_channel(self, channel_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_channel(
|
||||
admin_uid=self.user_uid,
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_channel failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete channel"}
|
||||
|
||||
async def admin_clear_channel(self, channel_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.clear_channel_history(
|
||||
admin_uid=self.user_uid,
|
||||
channel_uid=channel_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_clear_channel failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to clear channel"}
|
||||
|
||||
async def admin_search_messages(self, query=None, channel_uid=None, user_uid=None, page=1):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.search_messages(
|
||||
query=query,
|
||||
channel_uid=channel_uid,
|
||||
user_uid=user_uid,
|
||||
page=page
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_search_messages failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to search messages"}
|
||||
|
||||
async def admin_delete_message(self, message_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_message(
|
||||
admin_uid=self.user_uid,
|
||||
message_uid=message_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_message failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete message"}
|
||||
|
||||
async def admin_list_forums(self, page=1):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_forums(page=page)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_forums failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list forums"}
|
||||
|
||||
async def admin_delete_forum(self, forum_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_forum(
|
||||
admin_uid=self.user_uid,
|
||||
forum_uid=forum_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_forum failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete forum"}
|
||||
|
||||
async def admin_list_threads(self, forum_uid, page=1):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.list_threads(forum_uid=forum_uid, page=page)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_list_threads failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to list threads"}
|
||||
|
||||
async def admin_toggle_thread_pin(self, thread_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.toggle_thread_pin(
|
||||
admin_uid=self.user_uid,
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_toggle_thread_pin failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to toggle pin"}
|
||||
|
||||
async def admin_toggle_thread_lock(self, thread_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.toggle_thread_lock(
|
||||
admin_uid=self.user_uid,
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_toggle_thread_lock failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to toggle lock"}
|
||||
|
||||
async def admin_delete_thread(self, thread_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_thread(
|
||||
admin_uid=self.user_uid,
|
||||
thread_uid=thread_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_thread failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete thread"}
|
||||
|
||||
async def admin_delete_post(self, post_uid):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_post(
|
||||
admin_uid=self.user_uid,
|
||||
post_uid=post_uid
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_post failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete post"}
|
||||
|
||||
async def admin_mass_notify(self, message, user_uids=None):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.mass_notify(
|
||||
admin_uid=self.user_uid,
|
||||
message=message,
|
||||
user_uids=user_uids
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_mass_notify failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to send notifications"}
|
||||
|
||||
async def admin_get_kv_entries(self):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.get_kv_entries()
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_get_kv_entries failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to get KV entries"}
|
||||
|
||||
async def admin_set_kv_entry(self, key, value):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.set_kv_entry(
|
||||
admin_uid=self.user_uid,
|
||||
key=key,
|
||||
value=value
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_set_kv_entry failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to set KV entry"}
|
||||
|
||||
async def admin_delete_kv_entry(self, key):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.delete_kv_entry(
|
||||
admin_uid=self.user_uid,
|
||||
key=key
|
||||
)
|
||||
return {"success": True, "data": result}
|
||||
except (PermissionError, ValueError) as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_delete_kv_entry failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to delete KV entry"}
|
||||
|
||||
async def admin_run_maintenance(self):
|
||||
try:
|
||||
await self._require_admin()
|
||||
result = await self.services.admin.run_maintenance(admin_uid=self.user_uid)
|
||||
return {"success": True, "data": result}
|
||||
except PermissionError as ex:
|
||||
return {"success": False, "error": safe_str(ex)}
|
||||
except Exception as ex:
|
||||
logger.warning(f"admin_run_maintenance failed: {safe_str(ex)}")
|
||||
return {"success": False, "error": "Failed to run maintenance"}
|
||||
Loading…
Reference in New Issue
Block a user