Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49853e079b | ||
|
|
2afb2038e5 |
@@ -60,6 +60,7 @@ def cmd_news_sanitize(args):
|
|||||||
def cmd_attachments_prune(args):
|
def cmd_attachments_prune(args):
|
||||||
from devplacepy.database import db
|
from devplacepy.database import db
|
||||||
from devplacepy.config import STATIC_DIR
|
from devplacepy.config import STATIC_DIR
|
||||||
|
import os
|
||||||
deleted_records = 0
|
deleted_records = 0
|
||||||
deleted_files = 0
|
deleted_files = 0
|
||||||
freed_bytes = 0
|
freed_bytes = 0
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from devplacepy.database import (
|
|
||||||
get_table,
|
|
||||||
resolve_by_slug,
|
|
||||||
get_users_by_uids,
|
|
||||||
get_vote_counts,
|
|
||||||
load_comments,
|
|
||||||
db,
|
|
||||||
)
|
|
||||||
from devplacepy.attachments import delete_target_attachments, delete_inline_image, get_attachments
|
|
||||||
from devplacepy.utils import time_ago
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def edit_content_item(table_name: str, user: dict, slug: str, update_fields: dict, redirect_fail: str) -> RedirectResponse:
|
|
||||||
table = get_table(table_name)
|
|
||||||
item = resolve_by_slug(table, slug)
|
|
||||||
if not item or item["user_uid"] != user["uid"]:
|
|
||||||
return RedirectResponse(url=redirect_fail, status_code=302)
|
|
||||||
table.update({"uid": item["uid"], **update_fields}, ["uid"])
|
|
||||||
logger.info(f"{table_name} {item['uid']} edited by {user['username']}")
|
|
||||||
return RedirectResponse(url=f"/{table_name}/{item['slug'] or item['uid']}", status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
def delete_content_item(table_name: str, target_type: str, user: dict, slug: str, redirect_url: str, inline_image_field: str | None = None) -> RedirectResponse:
|
|
||||||
table = get_table(table_name)
|
|
||||||
item = resolve_by_slug(table, slug)
|
|
||||||
if item and item["user_uid"] == user["uid"]:
|
|
||||||
delete_target_attachments(target_type, item["uid"])
|
|
||||||
if "comments" in db.tables:
|
|
||||||
comments = get_table("comments")
|
|
||||||
for comment in comments.find(target_uid=item["uid"]):
|
|
||||||
delete_target_attachments("comment", comment["uid"])
|
|
||||||
comments.delete(target_uid=item["uid"])
|
|
||||||
if "votes" in db.tables:
|
|
||||||
get_table("votes").delete(target_uid=item["uid"])
|
|
||||||
if inline_image_field:
|
|
||||||
delete_inline_image(item.get(inline_image_field))
|
|
||||||
table.delete(id=item["id"])
|
|
||||||
logger.info(f"{table_name} {item['uid']} deleted by {user['username']}")
|
|
||||||
return RedirectResponse(url=redirect_url, status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
def load_detail(table_name: str, target_type: str, slug: str, user: dict | None) -> dict | None:
|
|
||||||
item = resolve_by_slug(get_table(table_name), slug)
|
|
||||||
if not item:
|
|
||||||
return None
|
|
||||||
author = get_users_by_uids([item["user_uid"]]).get(item["user_uid"])
|
|
||||||
ups, downs = get_vote_counts([item["uid"]])
|
|
||||||
return {
|
|
||||||
"item": item,
|
|
||||||
"author": author,
|
|
||||||
"is_owner": bool(user and user["uid"] == item["user_uid"]),
|
|
||||||
"star_count": ups.get(item["uid"], 0) - downs.get(item["uid"], 0),
|
|
||||||
"comments": load_comments(target_type, item["uid"]),
|
|
||||||
"attachments": get_attachments(target_type, item["uid"]),
|
|
||||||
"time_ago": time_ago(item["created_at"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def enrich_items(items: list, key: str, authors: dict, extra_maps: dict[str, Any] | None = None, ts_field: str = "created_at") -> list:
|
|
||||||
extra_maps = extra_maps or {}
|
|
||||||
enriched = []
|
|
||||||
for item in items:
|
|
||||||
entry = {
|
|
||||||
key: item,
|
|
||||||
"author": authors.get(item["user_uid"]),
|
|
||||||
"time_ago": time_ago(item[ts_field]),
|
|
||||||
}
|
|
||||||
for name, source in extra_maps.items():
|
|
||||||
entry[name] = source(item) if callable(source) else source.get(item["uid"])
|
|
||||||
enriched.append(entry)
|
|
||||||
return enriched
|
|
||||||
@@ -326,31 +326,6 @@ def resolve_by_slug(table, slug):
|
|||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
VOTABLE_TARGETS: dict[str, str] = {
|
|
||||||
"post": "posts",
|
|
||||||
"project": "projects",
|
|
||||||
"gist": "gists",
|
|
||||||
"comment": "comments",
|
|
||||||
}
|
|
||||||
|
|
||||||
STAR_TARGETS: set[str] = {"post", "project", "gist"}
|
|
||||||
|
|
||||||
|
|
||||||
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
|
|
||||||
table_name = VOTABLE_TARGETS.get(target_type)
|
|
||||||
if not table_name or target_type not in STAR_TARGETS:
|
|
||||||
return
|
|
||||||
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
|
|
||||||
|
|
||||||
|
|
||||||
def get_target_owner_uid(target_type: str, target_uid: str) -> str | None:
|
|
||||||
table_name = VOTABLE_TARGETS.get(target_type)
|
|
||||||
if not table_name:
|
|
||||||
return None
|
|
||||||
row = get_table(table_name).find_one(uid=target_uid)
|
|
||||||
return row["user_uid"] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def build_pagination(page, total, per_page=25):
|
def build_pagination(page, total, per_page=25):
|
||||||
total_pages = max(1, __import__("math").ceil(total / per_page))
|
total_pages = max(1, __import__("math").ceil(total / per_page))
|
||||||
page = max(1, min(page, total_pages))
|
page = max(1, min(page, total_pages))
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ from devplacepy.config import STATIC_DIR, PORT
|
|||||||
from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids
|
from devplacepy.database import init_db, get_table, db, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import get_current_user, time_ago
|
from devplacepy.utils import get_current_user, time_ago
|
||||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
|
||||||
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads
|
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow, admin, seo, bugs, news, gists, services as services_router, uploads
|
||||||
from devplacepy.services.manager import service_manager
|
from devplacepy.services.manager import service_manager
|
||||||
from devplacepy.services.news import NewsService
|
from devplacepy.services.news import NewsService
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Request, Form
|
from fastapi import APIRouter, Request, Form
|
||||||
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
|
from devplacepy.models import AdminRoleForm, AdminPasswordForm, AdminSettingsForm
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from devplacepy.database import get_table, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
|
from devplacepy.database import get_table, db, build_pagination, get_post_counts_by_user_uids, get_news_images_by_uids, clear_settings_cache
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago, clear_user_cache
|
from devplacepy.utils import require_admin, hash_password, generate_uid, time_ago, clear_user_cache
|
||||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fastapi import APIRouter, Request, Form
|
|||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
from devplacepy.database import get_table
|
from devplacepy.database import get_table
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge
|
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user
|
||||||
from devplacepy.seo import base_seo_context
|
from devplacepy.seo import base_seo_context
|
||||||
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
|
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
|
||||||
|
|
||||||
@@ -82,7 +82,13 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
|
|||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
award_badge(uid, "Member")
|
badges = get_table("badges")
|
||||||
|
badges.insert({
|
||||||
|
"uid": generate_uid(),
|
||||||
|
"user_uid": uid,
|
||||||
|
"badge_name": "Member",
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
token = create_session(uid)
|
token = create_session(uid)
|
||||||
response = RedirectResponse(url="/feed", status_code=302)
|
response = RedirectResponse(url="/feed", status_code=302)
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from fastapi import APIRouter, Request, Form
|
|||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from devplacepy.database import get_table, resolve_by_slug
|
from devplacepy.database import get_table, resolve_by_slug
|
||||||
from devplacepy.attachments import link_attachments, delete_target_attachments
|
from devplacepy.attachments import link_attachments, delete_target_attachments
|
||||||
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_badge
|
from devplacepy.templating import clear_unread_cache
|
||||||
|
from devplacepy.utils import generate_uid, require_user, create_mention_notifications
|
||||||
from devplacepy.models import CommentForm
|
from devplacepy.models import CommentForm
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -59,20 +60,49 @@ async def create_comment(request: Request, data: Annotated[CommentForm, Form()])
|
|||||||
if data.attachment_uids:
|
if data.attachment_uids:
|
||||||
link_attachments(data.attachment_uids, "comment", comment_uid)
|
link_attachments(data.attachment_uids, "comment", comment_uid)
|
||||||
|
|
||||||
award_badge(user["uid"], "First Comment")
|
badges = get_table("badges")
|
||||||
|
existing = badges.find_one(user_uid=user["uid"], badge_name="First Comment")
|
||||||
|
if not existing:
|
||||||
|
badges.insert({
|
||||||
|
"uid": generate_uid(),
|
||||||
|
"user_uid": user["uid"],
|
||||||
|
"badge_name": "First Comment",
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
if target_type == "post":
|
if target_type == "post":
|
||||||
if parent_uid:
|
if parent_uid:
|
||||||
parent = get_table("comments").find_one(uid=parent_uid)
|
comments_table = get_table("comments")
|
||||||
|
parent = comments_table.find_one(uid=parent_uid)
|
||||||
if parent and parent["user_uid"] != user["uid"]:
|
if parent and parent["user_uid"] != user["uid"]:
|
||||||
create_notification(parent["user_uid"], "reply", f"{user['username']} replied to your comment", user["uid"])
|
notifications = get_table("notifications")
|
||||||
|
notifications.insert({
|
||||||
|
"uid": generate_uid(),
|
||||||
|
"user_uid": parent["user_uid"],
|
||||||
|
"type": "reply",
|
||||||
|
"message": f"{user['username']} replied to your comment",
|
||||||
|
"related_uid": user["uid"],
|
||||||
|
"read": False,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
clear_unread_cache(parent["user_uid"])
|
||||||
else:
|
else:
|
||||||
posts = get_table("posts")
|
posts = get_table("posts")
|
||||||
post = posts.find_one(uid=target_uid)
|
post = posts.find_one(uid=target_uid)
|
||||||
if not post:
|
if not post:
|
||||||
post = posts.find_one(slug=target_uid)
|
post = posts.find_one(slug=target_uid)
|
||||||
if post and post["user_uid"] != user["uid"]:
|
if post and post["user_uid"] != user["uid"]:
|
||||||
create_notification(post["user_uid"], "comment", f"{user['username']} commented on your post", user["uid"])
|
notifications = get_table("notifications")
|
||||||
|
notifications.insert({
|
||||||
|
"uid": generate_uid(),
|
||||||
|
"user_uid": post["user_uid"],
|
||||||
|
"type": "comment",
|
||||||
|
"message": f"{user['username']} commented on your post",
|
||||||
|
"related_uid": user["uid"],
|
||||||
|
"read": False,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
clear_unread_cache(post["user_uid"])
|
||||||
|
|
||||||
create_mention_notifications(content, user["uid"], redirect_url)
|
create_mention_notifications(content, user["uid"], redirect_url)
|
||||||
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
|
logger.info(f"Comment by {user['username']} on {target_type} {target_uid}")
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ from fastapi import APIRouter, Request
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats
|
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_site_stats
|
||||||
from devplacepy.attachments import get_attachments_batch
|
from devplacepy.attachments import get_attachments_batch
|
||||||
from devplacepy.content import enrich_items
|
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import get_current_user
|
from devplacepy.utils import get_current_user, time_ago
|
||||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -49,10 +48,19 @@ def get_feed_posts(user, tab: str = "all", topic: str = None, before: str = None
|
|||||||
if not posts:
|
if not posts:
|
||||||
return [], next_cursor
|
return [], next_cursor
|
||||||
|
|
||||||
authors = get_users_by_uids([p["user_uid"] for p in posts])
|
uids = [p["user_uid"] for p in posts]
|
||||||
counts = get_comment_counts_by_post_uids([p["uid"] for p in posts])
|
post_uids = [p["uid"] for p in posts]
|
||||||
|
authors = get_users_by_uids(uids)
|
||||||
|
counts = get_comment_counts_by_post_uids(post_uids)
|
||||||
|
|
||||||
result = enrich_items(posts, "post", authors, {"comment_count": counts})
|
result = []
|
||||||
|
for post in posts:
|
||||||
|
result.append({
|
||||||
|
"post": post,
|
||||||
|
"author": authors.get(post["user_uid"]),
|
||||||
|
"time_ago": time_ago(post["created_at"]),
|
||||||
|
"comment_count": counts.get(post["uid"], 0),
|
||||||
|
})
|
||||||
return result, next_cursor
|
return result, next_cursor
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from devplacepy.database import get_table
|
from devplacepy.database import get_table
|
||||||
from devplacepy.utils import generate_uid, require_user, create_notification
|
from devplacepy.templating import clear_unread_cache
|
||||||
|
from devplacepy.utils import generate_uid, require_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -29,7 +30,17 @@ async def follow_user(request: Request, username: str):
|
|||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
create_notification(target["uid"], "follow", f"{user['username']} started following you", user["uid"])
|
notifications = get_table("notifications")
|
||||||
|
notifications.insert({
|
||||||
|
"uid": generate_uid(),
|
||||||
|
"user_uid": target["uid"],
|
||||||
|
"type": "follow",
|
||||||
|
"message": f"{user['username']} started following you",
|
||||||
|
"related_uid": user["uid"],
|
||||||
|
"read": False,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
clear_unread_cache(target["uid"])
|
||||||
|
|
||||||
logger.info(f"{user['username']} followed {username}")
|
logger.info(f"{user['username']} followed {username}")
|
||||||
return RedirectResponse(url=f"/profile/{username}", status_code=302)
|
return RedirectResponse(url=f"/profile/{username}", status_code=302)
|
||||||
|
|||||||
+74
-22
@@ -4,11 +4,11 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import APIRouter, Request, HTTPException, Form
|
from fastapi import APIRouter, Request, HTTPException, Form
|
||||||
from devplacepy.models import GistForm, GistEditForm
|
from devplacepy.models import GistForm, GistEditForm
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from devplacepy.database import get_table, get_users_by_uids
|
from devplacepy.database import get_table, load_comments, get_vote_counts, resolve_by_slug, db
|
||||||
from devplacepy.content import load_detail, edit_content_item, delete_content_item, enrich_items
|
from devplacepy.attachments import get_attachments, delete_target_attachments
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
|
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
|
||||||
from devplacepy.seo import base_seo_context, site_url, website_schema, software_source_code_schema
|
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_source_code_schema
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -37,8 +37,19 @@ def get_gists_list(user_uid=None, language=None):
|
|||||||
if not all_gists:
|
if not all_gists:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
users_map = get_users_by_uids([g["user_uid"] for g in all_gists])
|
from devplacepy.database import get_users_by_uids
|
||||||
return enrich_items(all_gists, "gist", users_map)
|
uids = [g["user_uid"] for g in all_gists]
|
||||||
|
users_map = get_users_by_uids(uids)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for g in all_gists:
|
||||||
|
author = users_map.get(g["user_uid"])
|
||||||
|
result.append({
|
||||||
|
"gist": g,
|
||||||
|
"author": author,
|
||||||
|
"time_ago": time_ago(g["created_at"]),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
@router.get("", response_class=HTMLResponse)
|
||||||
@@ -71,10 +82,23 @@ async def gists_page(request: Request, language: str = None, user_uid: str = Non
|
|||||||
@router.get("/{gist_slug}", response_class=HTMLResponse)
|
@router.get("/{gist_slug}", response_class=HTMLResponse)
|
||||||
async def gist_detail(request: Request, gist_slug: str):
|
async def gist_detail(request: Request, gist_slug: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
detail = load_detail("gists", "gist", gist_slug, user)
|
gists = get_table("gists")
|
||||||
if not detail:
|
gist = resolve_by_slug(gists, gist_slug)
|
||||||
|
if not gist:
|
||||||
raise HTTPException(status_code=404, detail="Gist not found")
|
raise HTTPException(status_code=404, detail="Gist not found")
|
||||||
gist = detail["item"]
|
|
||||||
|
from devplacepy.database import get_users_by_uids
|
||||||
|
users_map = get_users_by_uids([gist["user_uid"]])
|
||||||
|
author = users_map.get(gist["user_uid"])
|
||||||
|
|
||||||
|
is_owner = user and user["uid"] == gist["user_uid"]
|
||||||
|
|
||||||
|
ups, downs = get_vote_counts([gist["uid"]])
|
||||||
|
star_count = ups.get(gist["uid"], 0) - downs.get(gist["uid"], 0)
|
||||||
|
|
||||||
|
comments = load_comments("gist", gist["uid"])
|
||||||
|
|
||||||
|
gist_attachments = get_attachments("gist", gist["uid"])
|
||||||
|
|
||||||
base = site_url(request)
|
base = site_url(request)
|
||||||
seo_ctx = base_seo_context(
|
seo_ctx = base_seo_context(
|
||||||
@@ -94,13 +118,13 @@ async def gist_detail(request: Request, gist_slug: str):
|
|||||||
"request": request,
|
"request": request,
|
||||||
"user": user,
|
"user": user,
|
||||||
"gist": gist,
|
"gist": gist,
|
||||||
"author": detail["author"],
|
"author": author,
|
||||||
"is_owner": detail["is_owner"],
|
"is_owner": is_owner,
|
||||||
"star_count": detail["star_count"],
|
"star_count": star_count,
|
||||||
"time_ago": detail["time_ago"],
|
"time_ago": time_ago(gist["created_at"]),
|
||||||
"comments": detail["comments"],
|
"comments": comments,
|
||||||
"languages": LANGUAGES,
|
"languages": LANGUAGES,
|
||||||
"attachments": detail["attachments"],
|
"attachments": gist_attachments,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -143,18 +167,46 @@ async def create_gist(request: Request, data: Annotated[GistForm, Form()]):
|
|||||||
@router.post("/edit/{gist_slug}")
|
@router.post("/edit/{gist_slug}")
|
||||||
async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]):
|
async def edit_gist(request: Request, gist_slug: str, data: Annotated[GistEditForm, Form()]):
|
||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
|
gists = get_table("gists")
|
||||||
|
gist = resolve_by_slug(gists, gist_slug)
|
||||||
|
if not gist or gist["user_uid"] != user["uid"]:
|
||||||
|
return RedirectResponse(url="/gists", status_code=302)
|
||||||
|
|
||||||
|
title = data.title.strip()
|
||||||
|
description = data.description.strip()
|
||||||
|
source_code = data.source_code.strip()
|
||||||
language = data.language
|
language = data.language
|
||||||
if language not in {l[0] for l in LANGUAGES}:
|
|
||||||
|
valid_languages = {l[0] for l in LANGUAGES}
|
||||||
|
if language not in valid_languages:
|
||||||
language = "plaintext"
|
language = "plaintext"
|
||||||
return edit_content_item("gists", user, gist_slug, {
|
|
||||||
"title": data.title.strip(),
|
gists.update({
|
||||||
"description": data.description.strip() or None,
|
"uid": gist["uid"],
|
||||||
"source_code": data.source_code.strip(),
|
"title": title,
|
||||||
|
"description": description or None,
|
||||||
|
"source_code": source_code,
|
||||||
"language": language,
|
"language": language,
|
||||||
}, "/gists")
|
}, ["uid"])
|
||||||
|
|
||||||
|
logger.info(f"Gist {gist['uid']} edited by {user['username']}")
|
||||||
|
return RedirectResponse(url=f"/gists/{gist['slug'] or gist['uid']}", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/delete/{gist_slug}")
|
@router.post("/delete/{gist_slug}")
|
||||||
async def delete_gist(request: Request, gist_slug: str):
|
async def delete_gist(request: Request, gist_slug: str):
|
||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
return delete_content_item("gists", "gist", user, gist_slug, "/gists")
|
gists = get_table("gists")
|
||||||
|
gist = resolve_by_slug(gists, gist_slug)
|
||||||
|
if gist and gist["user_uid"] == user["uid"]:
|
||||||
|
from devplacepy.attachments import delete_target_attachments
|
||||||
|
delete_target_attachments("gist", gist["uid"])
|
||||||
|
if "comments" in db.tables:
|
||||||
|
for c in get_table("comments").find(target_type="gist", target_uid=gist["uid"]):
|
||||||
|
delete_target_attachments("comment", c["uid"])
|
||||||
|
get_table("comments").delete(target_type="gist", target_uid=gist["uid"])
|
||||||
|
if "votes" in db.tables:
|
||||||
|
get_table("votes").delete(target_type="gist", target_uid=gist["uid"])
|
||||||
|
gists.delete(id=gist["id"])
|
||||||
|
logger.info(f"Gist {gist['uid']} deleted by {user['username']}")
|
||||||
|
return RedirectResponse(url="/gists", status_code=302)
|
||||||
|
|||||||
@@ -68,9 +68,8 @@ def get_conversation_messages(user_uid: str, other_uid: str):
|
|||||||
msgs.append(m)
|
msgs.append(m)
|
||||||
msgs.sort(key=lambda m: m["created_at"])
|
msgs.sort(key=lambda m: m["created_at"])
|
||||||
|
|
||||||
if "messages" in db.tables:
|
with db:
|
||||||
with db:
|
db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
|
||||||
db.query("UPDATE messages SET read = 1 WHERE receiver_uid = :me AND sender_uid = :other AND read = 0", me=user_uid, other=other_uid)
|
|
||||||
|
|
||||||
from devplacepy.database import get_users_by_uids
|
from devplacepy.database import get_users_by_uids
|
||||||
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
|
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
|
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import get_current_user, time_ago
|
from devplacepy.utils import get_current_user, time_ago
|
||||||
from devplacepy.seo import base_seo_context, website_schema, site_url, news_article_schema
|
from devplacepy.seo import base_seo_context, website_schema, site_url, discussion_forum_posting, combine, news_article_schema
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import APIRouter, Request, HTTPException, Form
|
from fastapi import APIRouter, Request, HTTPException, Form
|
||||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||||
from devplacepy.constants import TOPICS
|
from devplacepy.constants import TOPICS
|
||||||
from devplacepy.database import get_table, load_comments, db, resolve_by_slug
|
from devplacepy.database import get_table, get_comment_counts_by_post_uids, load_comments, db, resolve_by_slug
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications, award_badge
|
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
|
||||||
from devplacepy.content import edit_content_item, delete_content_item
|
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, discussion_forum_posting, combine, truncate
|
||||||
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
|
from devplacepy.attachments import get_attachments, link_attachments, delete_target_attachments, save_inline_image, delete_inline_image
|
||||||
from devplacepy.attachments import get_attachments, link_attachments, save_inline_image
|
|
||||||
from devplacepy.models import PostForm, PostEditForm
|
from devplacepy.models import PostForm, PostEditForm
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -49,7 +48,15 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
|
|||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
award_badge(user["uid"], "First Post")
|
badges = get_table("badges")
|
||||||
|
existing = badges.find_one(user_uid=user["uid"], badge_name="First Post")
|
||||||
|
if not existing:
|
||||||
|
badges.insert({
|
||||||
|
"uid": generate_uid(),
|
||||||
|
"user_uid": user["uid"],
|
||||||
|
"badge_name": "First Post",
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
if data.attachment_uids:
|
if data.attachment_uids:
|
||||||
link_attachments(data.attachment_uids, "post", uid)
|
link_attachments(data.attachment_uids, "post", uid)
|
||||||
@@ -129,14 +136,32 @@ async def view_post(request: Request, post_slug: str):
|
|||||||
@router.post("/edit/{post_slug}")
|
@router.post("/edit/{post_slug}")
|
||||||
async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]):
|
async def edit_post(request: Request, post_slug: str, data: Annotated[PostEditForm, Form()]):
|
||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
return edit_content_item("posts", user, post_slug, {
|
posts = get_table("posts")
|
||||||
|
post = resolve_by_slug(posts, post_slug)
|
||||||
|
if not post or post["user_uid"] != user["uid"]:
|
||||||
|
return RedirectResponse(url="/feed", status_code=302)
|
||||||
|
|
||||||
|
posts.update({
|
||||||
|
"uid": post["uid"],
|
||||||
"content": data.content.strip(),
|
"content": data.content.strip(),
|
||||||
"title": data.title.strip() or None,
|
"title": data.title.strip() or None,
|
||||||
"topic": data.topic,
|
"topic": data.topic,
|
||||||
}, "/feed")
|
}, ["uid"])
|
||||||
|
|
||||||
|
logger.info(f"Post {post['uid']} edited by {user['username']}")
|
||||||
|
return RedirectResponse(url=f"/posts/{post['slug'] or post['uid']}", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/delete/{post_slug}")
|
@router.post("/delete/{post_slug}")
|
||||||
async def delete_post(request: Request, post_slug: str):
|
async def delete_post(request: Request, post_slug: str):
|
||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
return delete_content_item("posts", "post", user, post_slug, "/feed", inline_image_field="image")
|
posts = get_table("posts")
|
||||||
|
post = resolve_by_slug(posts, post_slug)
|
||||||
|
if post and post["user_uid"] == user["uid"]:
|
||||||
|
delete_target_attachments("post", post["uid"])
|
||||||
|
get_table("comments").delete(post_uid=post["uid"])
|
||||||
|
get_table("votes").delete(target_uid=post["uid"])
|
||||||
|
delete_inline_image(post.get("image"))
|
||||||
|
posts.delete(id=post["id"])
|
||||||
|
logger.info(f"Post {post['uid']} deleted by {user['username']}")
|
||||||
|
return RedirectResponse(url="/feed", status_code=302)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from devplacepy.models import ProfileForm
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||||
from devplacepy.database import get_table, db
|
from devplacepy.database import get_table, db
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache
|
from devplacepy.utils import get_current_user, require_user, time_ago, clear_user_cache
|
||||||
from devplacepy.seo import base_seo_context, site_url, website_schema, profile_page_schema
|
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, profile_page_schema, combine
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -14,7 +14,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.get("/search")
|
@router.get("/search")
|
||||||
async def search_users(request: Request, q: str = ""):
|
async def search_users(request: Request, q: str = ""):
|
||||||
require_user_api(request)
|
require_user(request)
|
||||||
if not q or len(q) < 1:
|
if not q or len(q) < 1:
|
||||||
return JSONResponse({"results": []})
|
return JSONResponse({"results": []})
|
||||||
if "users" in db.tables:
|
if "users" in db.tables:
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ from sqlalchemy import or_
|
|||||||
from fastapi import APIRouter, Request, HTTPException, Form
|
from fastapi import APIRouter, Request, HTTPException, Form
|
||||||
from devplacepy.models import ProjectForm
|
from devplacepy.models import ProjectForm
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from devplacepy.database import get_table, get_users_by_uids, get_site_stats
|
from devplacepy.database import get_table, get_vote_counts, load_comments, resolve_by_slug, get_users_by_uids, get_site_stats
|
||||||
from devplacepy.content import load_detail, delete_content_item
|
from devplacepy.attachments import link_attachments, get_attachments, delete_target_attachments
|
||||||
from devplacepy.attachments import link_attachments
|
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import generate_uid, get_current_user, require_user, make_combined_slug, create_mention_notifications
|
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago, make_combined_slug, create_mention_notifications
|
||||||
from devplacepy.seo import base_seo_context, site_url, website_schema, software_application_schema
|
from devplacepy.seo import base_seo_context, site_url, website_schema, breadcrumb_schema, combine, software_application_schema
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -83,10 +82,22 @@ async def projects_page(
|
|||||||
@router.get("/{project_slug}", response_class=HTMLResponse)
|
@router.get("/{project_slug}", response_class=HTMLResponse)
|
||||||
async def project_detail(request: Request, project_slug: str):
|
async def project_detail(request: Request, project_slug: str):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
detail = load_detail("projects", "project", project_slug, user)
|
projects = get_table("projects")
|
||||||
if not detail:
|
project = resolve_by_slug(projects, project_slug)
|
||||||
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
project = detail["item"]
|
|
||||||
|
users_map = get_users_by_uids([project["user_uid"]])
|
||||||
|
author = users_map.get(project["user_uid"])
|
||||||
|
|
||||||
|
is_owner = user and user["uid"] == project["user_uid"]
|
||||||
|
|
||||||
|
ups, downs = get_vote_counts([project["uid"]])
|
||||||
|
star_count = ups.get(project["uid"], 0) - downs.get(project["uid"], 0)
|
||||||
|
|
||||||
|
comments = load_comments("project", project["uid"])
|
||||||
|
|
||||||
|
project_attachments = get_attachments("project", project["uid"])
|
||||||
|
|
||||||
base = site_url(request)
|
base = site_url(request)
|
||||||
seo_ctx = base_seo_context(
|
seo_ctx = base_seo_context(
|
||||||
@@ -105,19 +116,25 @@ async def project_detail(request: Request, project_slug: str):
|
|||||||
"request": request,
|
"request": request,
|
||||||
"user": user,
|
"user": user,
|
||||||
"project": project,
|
"project": project,
|
||||||
"author": detail["author"],
|
"author": author,
|
||||||
"is_owner": detail["is_owner"],
|
"is_owner": is_owner,
|
||||||
"star_count": detail["star_count"],
|
"star_count": star_count,
|
||||||
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
|
"platforms": project.get("platforms", "").split(",") if project.get("platforms") else [],
|
||||||
"comments": detail["comments"],
|
"comments": comments,
|
||||||
"attachments": detail["attachments"],
|
"attachments": project_attachments,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/delete/{project_slug}")
|
@router.post("/delete/{project_slug}")
|
||||||
async def delete_project(request: Request, project_slug: str):
|
async def delete_project(request: Request, project_slug: str):
|
||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
return delete_content_item("projects", "project", user, project_slug, "/projects")
|
projects = get_table("projects")
|
||||||
|
project = resolve_by_slug(projects, project_slug)
|
||||||
|
if project and project["user_uid"] == user["uid"]:
|
||||||
|
delete_target_attachments("project", project["uid"])
|
||||||
|
projects.delete(id=project["id"])
|
||||||
|
logger.info(f"Project {project['uid']} deleted by {user['username']}")
|
||||||
|
return RedirectResponse(url="/projects", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/create")
|
@router.post("/create")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from pathlib import Path
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from devplacepy.database import get_table, get_setting, get_int_setting
|
from devplacepy.database import get_table, get_setting, get_int_setting
|
||||||
from devplacepy.utils import require_user_api
|
from devplacepy.utils import require_user
|
||||||
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
|
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -12,7 +12,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_file(request: Request):
|
async def upload_file(request: Request):
|
||||||
user = require_user_api(request)
|
user = require_user(request)
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
file = form.get("file")
|
file = form.get("file")
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ async def upload_file(request: Request):
|
|||||||
|
|
||||||
@router.delete("/delete/{attachment_uid}")
|
@router.delete("/delete/{attachment_uid}")
|
||||||
async def delete_attachment_route(request: Request, attachment_uid: str):
|
async def delete_attachment_route(request: Request, attachment_uid: str):
|
||||||
user = require_user_api(request)
|
user = require_user(request)
|
||||||
att = get_table("attachments").find_one(uid=attachment_uid)
|
att = get_table("attachments").find_one(uid=attachment_uid)
|
||||||
if not att:
|
if not att:
|
||||||
return JSONResponse({"error": "Attachment not found"}, status_code=404)
|
return JSONResponse({"error": "Attachment not found"}, status_code=404)
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ from typing import Annotated
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from fastapi import APIRouter, Request, Form
|
from fastapi import APIRouter, Request, Form
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from devplacepy.database import get_table, update_target_stars, get_target_owner_uid
|
from devplacepy.database import get_table
|
||||||
from devplacepy.utils import generate_uid, require_user, create_notification
|
from devplacepy.templating import clear_unread_cache
|
||||||
|
from devplacepy.utils import generate_uid, require_user
|
||||||
from devplacepy.models import VoteForm
|
from devplacepy.models import VoteForm
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
NOTIFY_ON_VOTE: set[str] = {"post", "comment", "gist", "project"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{target_type}/{target_uid}")
|
@router.post("/{target_type}/{target_uid}")
|
||||||
async def vote(request: Request, target_type: str, target_uid: str, data: Annotated[VoteForm, Form()]):
|
async def vote(request: Request, target_type: str, target_uid: str, data: Annotated[VoteForm, Form()]):
|
||||||
@@ -40,12 +39,46 @@ async def vote(request: Request, target_type: str, target_uid: str, data: Annota
|
|||||||
down_count = votes.count(target_uid=target_uid, value=-1)
|
down_count = votes.count(target_uid=target_uid, value=-1)
|
||||||
net = up_count - down_count
|
net = up_count - down_count
|
||||||
|
|
||||||
update_target_stars(target_type, target_uid, net)
|
if target_type == "post":
|
||||||
|
posts = get_table("posts")
|
||||||
|
posts.update({"uid": target_uid, "stars": net}, ["uid"])
|
||||||
|
elif target_type == "project":
|
||||||
|
projects = get_table("projects")
|
||||||
|
projects.update({"uid": target_uid, "stars": net}, ["uid"])
|
||||||
|
elif target_type == "gist":
|
||||||
|
gists = get_table("gists")
|
||||||
|
gists.update({"uid": target_uid, "stars": net}, ["uid"])
|
||||||
|
|
||||||
if value == 1 and target_type in NOTIFY_ON_VOTE:
|
if value == 1:
|
||||||
owner_uid = get_target_owner_uid(target_type, target_uid)
|
target_owner_uid = None
|
||||||
if owner_uid and owner_uid != user["uid"]:
|
if target_type == "post":
|
||||||
create_notification(owner_uid, "vote", f"{user['username']} ++'d your {target_type}", user["uid"])
|
target_owner = posts.find_one(uid=target_uid)
|
||||||
|
if target_owner:
|
||||||
|
target_owner_uid = target_owner["user_uid"]
|
||||||
|
elif target_type == "comment":
|
||||||
|
comments = get_table("comments")
|
||||||
|
target_comment = comments.find_one(uid=target_uid)
|
||||||
|
if target_comment:
|
||||||
|
target_owner_uid = target_comment["user_uid"]
|
||||||
|
elif target_type == "gist":
|
||||||
|
gists = get_table("gists")
|
||||||
|
target_gist = gists.find_one(uid=target_uid)
|
||||||
|
if target_gist:
|
||||||
|
target_owner_uid = target_gist["user_uid"]
|
||||||
|
|
||||||
|
if target_owner_uid and target_owner_uid != user["uid"]:
|
||||||
|
label = {"post": "post", "comment": "comment", "gist": "gist"}.get(target_type, "gist")
|
||||||
|
notifications = get_table("notifications")
|
||||||
|
notifications.insert({
|
||||||
|
"uid": generate_uid(),
|
||||||
|
"user_uid": target_owner_uid,
|
||||||
|
"type": "vote",
|
||||||
|
"message": f"{user['username']} ++'d your {label}",
|
||||||
|
"related_uid": user["uid"],
|
||||||
|
"read": False,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
clear_unread_cache(target_owner_uid)
|
||||||
|
|
||||||
referer = request.headers.get("Referer", "/feed")
|
referer = request.headers.get("Referer", "/feed")
|
||||||
return RedirectResponse(url=referer, status_code=302)
|
return RedirectResponse(url=referer, status_code=302)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from xml.etree.ElementTree import Element, tostring
|
from xml.etree.ElementTree import Element, tostring
|
||||||
from xml.dom import minidom
|
from xml.dom import minidom
|
||||||
from devplacepy.config import SITE_URL
|
from devplacepy.config import SITE_URL
|
||||||
|
|||||||
@@ -126,12 +126,12 @@
|
|||||||
|
|
||||||
.admin-btn-sm {
|
.admin-btn-sm {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
padding: 0.25rem 0.375rem;
|
padding: 0.2rem 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-select {
|
.admin-select {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.25rem 0.375rem;
|
padding: 0.2rem 0.4rem;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
.admin-input-sm {
|
.admin-input-sm {
|
||||||
width: 110px;
|
width: 110px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.25rem 0.375rem;
|
padding: 0.2rem 0.4rem;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg-input);
|
background: var(--bg-input);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|||||||
@@ -408,12 +408,12 @@ img {
|
|||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--white);
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
color: var(--white);
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@@ -461,13 +461,13 @@ img {
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-devlog { background: var(--topic-devlog); color: var(--white); }
|
.badge-devlog { background: var(--topic-devlog); color: #fff; }
|
||||||
.badge-showcase { background: var(--topic-showcase); color: var(--white); }
|
.badge-showcase { background: var(--topic-showcase); color: #fff; }
|
||||||
.badge-question { background: var(--topic-question); color: var(--white); }
|
.badge-question { background: var(--topic-question); color: #fff; }
|
||||||
.badge-rant { background: var(--topic-rant); color: var(--white); }
|
.badge-rant { background: var(--topic-rant); color: #fff; }
|
||||||
.badge-fun { background: var(--topic-fun); color: #000; }
|
.badge-fun { background: var(--topic-fun); color: #000; }
|
||||||
.badge-random { background: var(--border-light); color: var(--text-secondary); }
|
.badge-random { background: var(--border-light); color: var(--text-secondary); }
|
||||||
.badge-signals { background: var(--topic-signals); color: var(--white); }
|
.badge-signals { background: var(--topic-signals); color: #fff; }
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
@@ -479,7 +479,7 @@ img {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--white);
|
color: #fff;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -535,7 +535,7 @@ img {
|
|||||||
.topnav-icon:hover { color: var(--text-primary); }
|
.topnav-icon:hover { color: var(--text-primary); }
|
||||||
.nav-badge {
|
.nav-badge {
|
||||||
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px;
|
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px;
|
||||||
padding: 0 4px; border-radius: 8px; background: var(--accent); color: var(--white);
|
padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff;
|
||||||
font-size: 0.6875rem; font-weight: 700;
|
font-size: 0.6875rem; font-weight: 700;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -723,6 +723,16 @@ img {
|
|||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-author-link {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-author-link:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
|
|
||||||
.post-card:hover {
|
.post-card:hover {
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-light);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-header {
|
.post-header {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
.gist-card:hover {
|
.gist-card:hover {
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-light);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gist-card-header {
|
.gist-card-header {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.news-card-body {
|
.news-card-body {
|
||||||
padding: 1rem;
|
padding: 1.125rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.news-card-title {
|
.news-card-title {
|
||||||
font-size: 1.125rem;
|
font-size: 1.0625rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 680px) {
|
||||||
.news-grid {
|
.news-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
|
|
||||||
.service-card {
|
.service-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: var(--radius);
|
border-radius: 8px;
|
||||||
padding: var(--space-lg);
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-header {
|
.service-header {
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
.service-name {
|
.service-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1.125rem;
|
font-size: 1.1rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,20 +28,6 @@
|
|||||||
|
|
||||||
--shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
--shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
||||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
|
|
||||||
--white: #fff;
|
|
||||||
--overlay-dark: rgba(0, 0, 0, 0.7);
|
|
||||||
--overlay-light: rgba(255, 255, 255, 0.05);
|
|
||||||
|
|
||||||
--space-xs: 0.25rem;
|
|
||||||
--space-sm: 0.375rem;
|
|
||||||
--space-base: 0.5rem;
|
|
||||||
--space-md: 0.75rem;
|
|
||||||
--space-lg: 1rem;
|
|
||||||
--space-xl: 1.25rem;
|
|
||||||
--space-2xl: 1.5rem;
|
|
||||||
|
|
||||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
--font-mono: "SF Mono", Monaco, "Cascadia Code", monospace;
|
--font-mono: "SF Mono", Monaco, "Cascadia Code", monospace;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Toast } from "./Toast.js";
|
|
||||||
|
|
||||||
export class AttachmentUploader {
|
export class AttachmentUploader {
|
||||||
constructor(form) {
|
constructor(form) {
|
||||||
this.form = form;
|
this.form = form;
|
||||||
@@ -174,7 +172,8 @@ export class AttachmentUploader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showError(msg) {
|
showError(msg) {
|
||||||
Toast.flash(this.errorEl, msg, 5000, "");
|
this.errorEl.textContent = msg;
|
||||||
|
setTimeout(() => { if (this.errorEl.textContent === msg) this.errorEl.textContent = ""; }, 5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
export class Avatar {
|
|
||||||
static imgHtml(username, size = 24) {
|
|
||||||
const url = `/avatar/multiavatar/${encodeURIComponent(username)}?size=${size}`;
|
|
||||||
return `<img src="${url}" class="avatar-img" style="width:${size}px;height:${size}px;border-radius:50%" alt="" loading="lazy">`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Avatar = Avatar;
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Toast } from "./Toast.js";
|
|
||||||
|
|
||||||
export class DomUtils {
|
export class DomUtils {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initClipboardCopy();
|
this.initClipboardCopy();
|
||||||
@@ -16,7 +14,9 @@ export class DomUtils {
|
|||||||
if (!source) return;
|
if (!source) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(source.textContent);
|
await navigator.clipboard.writeText(source.textContent);
|
||||||
Toast.flash(btn, "Copied!", 2000);
|
const original = btn.textContent;
|
||||||
|
btn.textContent = "Copied!";
|
||||||
|
setTimeout(() => { btn.textContent = original; }, 2000);
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail
|
// silently fail
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,9 @@ export class DomUtils {
|
|||||||
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
|
const url = new URL(btn.dataset.share || window.location.href, window.location.href).href;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
Toast.flash(btn, "Copied!", 1000);
|
const original = btn.textContent;
|
||||||
|
btn.textContent = "Copied!";
|
||||||
|
setTimeout(() => { btn.textContent = original; }, 1000);
|
||||||
} catch {
|
} catch {
|
||||||
// silently fail
|
// silently fail
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { TextInput } from "./TextInput.js";
|
|
||||||
|
|
||||||
export class EmojiPicker {
|
export class EmojiPicker {
|
||||||
constructor(textarea) {
|
constructor(textarea) {
|
||||||
this.textarea = textarea;
|
this.textarea = textarea;
|
||||||
@@ -37,7 +35,15 @@ export class EmojiPicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
insert(unicode) {
|
insert(unicode) {
|
||||||
TextInput.insertAtCursor(this.textarea, unicode);
|
const ta = this.textarea;
|
||||||
|
const start = ta.selectionStart;
|
||||||
|
const end = ta.selectionEnd;
|
||||||
|
const text = ta.value;
|
||||||
|
ta.value = text.substring(0, start) + unicode + text.substring(end);
|
||||||
|
const newPos = start + unicode.length;
|
||||||
|
ta.setSelectionRange(newPos, newPos);
|
||||||
|
ta.focus();
|
||||||
|
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
export class Http {
|
|
||||||
static async getJson(url) {
|
|
||||||
const response = await fetch(url);
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
static postForm(action, data = {}) {
|
|
||||||
const form = document.createElement("form");
|
|
||||||
form.method = "POST";
|
|
||||||
form.action = action;
|
|
||||||
for (const [name, value] of Object.entries(data)) {
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "hidden";
|
|
||||||
input.name = name;
|
|
||||||
input.value = value;
|
|
||||||
form.appendChild(input);
|
|
||||||
}
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Http = Http;
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import { Http } from "./Http.js";
|
|
||||||
import { Avatar } from "./Avatar.js";
|
|
||||||
import { TextInput } from "./TextInput.js";
|
|
||||||
|
|
||||||
export class MentionInput {
|
export class MentionInput {
|
||||||
constructor(element) {
|
constructor(element) {
|
||||||
this.input = element;
|
this.input = element;
|
||||||
@@ -54,7 +50,8 @@ export class MentionInput {
|
|||||||
|
|
||||||
async fetch(query) {
|
async fetch(query) {
|
||||||
try {
|
try {
|
||||||
const data = await Http.getJson("/profile/search?q=" + encodeURIComponent(query));
|
const resp = await fetch("/profile/search?q=" + encodeURIComponent(query));
|
||||||
|
const data = await resp.json();
|
||||||
const results = data.results || [];
|
const results = data.results || [];
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
this.dropdown.style.display = "none";
|
this.dropdown.style.display = "none";
|
||||||
@@ -74,7 +71,7 @@ export class MentionInput {
|
|||||||
item.type = "button";
|
item.type = "button";
|
||||||
item.className = "mention-dropdown-item";
|
item.className = "mention-dropdown-item";
|
||||||
item.dataset.username = r.username;
|
item.dataset.username = r.username;
|
||||||
item.innerHTML = Avatar.imgHtml(r.username) + "<span>@" + r.username + "</span>";
|
item.innerHTML = '<img src="/avatar/multiavatar/' + encodeURIComponent(r.username) + '?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>@' + r.username + '</span>';
|
||||||
item.addEventListener("mousedown", (e) => {
|
item.addEventListener("mousedown", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.insert(r.username);
|
this.insert(r.username);
|
||||||
@@ -124,7 +121,11 @@ export class MentionInput {
|
|||||||
let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1);
|
let after = val.substring(this.lastMatch.index + this.lastMatch.query.length + 1);
|
||||||
before = before.replace(/@+$/, "");
|
before = before.replace(/@+$/, "");
|
||||||
after = after.replace(/^@+/, "");
|
after = after.replace(/^@+/, "");
|
||||||
TextInput.applyValue(this.input, before + "@" + username + " " + after, before.length + username.length + 2);
|
this.input.value = before + "@" + username + " " + after;
|
||||||
|
const newPos = before.length + username.length + 2;
|
||||||
|
this.input.setSelectionRange(newPos, newPos);
|
||||||
|
this.input.focus();
|
||||||
|
this.input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
this.dropdown.style.display = "none";
|
this.dropdown.style.display = "none";
|
||||||
this.lastMatch = null;
|
this.lastMatch = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import { Http } from "./Http.js";
|
|
||||||
import { Avatar } from "./Avatar.js";
|
|
||||||
|
|
||||||
export class MessageSearch {
|
export class MessageSearch {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initMessageSearch();
|
this.initMessageSearch();
|
||||||
@@ -29,7 +26,8 @@ export class MessageSearch {
|
|||||||
}
|
}
|
||||||
debounceTimer = setTimeout(async () => {
|
debounceTimer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await Http.getJson(`/messages/search?q=${encodeURIComponent(q)}`);
|
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
|
||||||
|
const data = await resp.json();
|
||||||
const results = data.results || [];
|
const results = data.results || [];
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
dropdown.style.display = "none";
|
dropdown.style.display = "none";
|
||||||
@@ -40,7 +38,7 @@ export class MessageSearch {
|
|||||||
const item = document.createElement("a");
|
const item = document.createElement("a");
|
||||||
item.className = "search-dropdown-item";
|
item.className = "search-dropdown-item";
|
||||||
item.href = `/messages?with_uid=${r.uid}`;
|
item.href = `/messages?with_uid=${r.uid}`;
|
||||||
item.innerHTML = `${Avatar.imgHtml(r.username)}<span>${r.username}</span>`;
|
item.innerHTML = `<img src="/avatar/multiavatar/${encodeURIComponent(r.username)}?size=24" class="avatar-img" style="width:24px;height:24px;border-radius:50%" alt="" loading="lazy"><span>${r.username}</span>`;
|
||||||
dropdown.appendChild(item);
|
dropdown.appendChild(item);
|
||||||
}
|
}
|
||||||
dropdown.style.display = "block";
|
dropdown.style.display = "block";
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Http } from "./Http.js";
|
|
||||||
|
|
||||||
class ServiceMonitor {
|
class ServiceMonitor {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pollInterval = 5000;
|
this.pollInterval = 5000;
|
||||||
@@ -31,7 +29,8 @@ class ServiceMonitor {
|
|||||||
|
|
||||||
async pollServices() {
|
async pollServices() {
|
||||||
try {
|
try {
|
||||||
const data = await Http.getJson("/admin/services/data");
|
const resp = await fetch("/admin/services/data");
|
||||||
|
const data = await resp.json();
|
||||||
const container = document.getElementById("services-list");
|
const container = document.getElementById("services-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
for (const svc of data.services) {
|
for (const svc of data.services) {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
export class TextInput {
|
|
||||||
static applyValue(element, value, caretPos) {
|
|
||||||
element.value = value;
|
|
||||||
element.setSelectionRange(caretPos, caretPos);
|
|
||||||
element.focus();
|
|
||||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
static insertAtCursor(element, text) {
|
|
||||||
const start = element.selectionStart;
|
|
||||||
const end = element.selectionEnd;
|
|
||||||
const value = element.value.substring(0, start) + text + element.value.substring(end);
|
|
||||||
TextInput.applyValue(element, value, start + text.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.TextInput = TextInput;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export class Toast {
|
|
||||||
static flash(element, message, ms = 2000, revertTo = null) {
|
|
||||||
const original = revertTo === null ? element.textContent : revertTo;
|
|
||||||
element.textContent = message;
|
|
||||||
setTimeout(() => {
|
|
||||||
if (element.textContent === message) {
|
|
||||||
element.textContent = original;
|
|
||||||
}
|
|
||||||
}, ms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.Toast = Toast;
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Http } from "./Http.js";
|
|
||||||
|
|
||||||
export class VoteManager {
|
export class VoteManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initVoteButtons();
|
this.initVoteButtons();
|
||||||
@@ -8,22 +6,37 @@ export class VoteManager {
|
|||||||
|
|
||||||
initVoteButtons() {
|
initVoteButtons() {
|
||||||
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
|
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", async () => {
|
||||||
const targetUid = btn.dataset.target;
|
const targetUid = btn.dataset.target;
|
||||||
const targetType = btn.dataset.type || "post";
|
const targetType = btn.dataset.type || "post";
|
||||||
Http.postForm(`/votes/${targetType}/${targetUid}`, { value: btn.dataset.vote });
|
const value = btn.dataset.vote;
|
||||||
|
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.method = "POST";
|
||||||
|
form.action = `/votes/${targetType}/${targetUid}`;
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = "value";
|
||||||
|
input.value = value;
|
||||||
|
form.appendChild(input);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
initNotificationDismiss() {
|
initNotificationDismiss() {
|
||||||
document.querySelectorAll(".notification-dismiss").forEach((btn) => {
|
document.querySelectorAll(".notification-dismiss").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", async () => {
|
||||||
const uid = btn.dataset.uid;
|
const uid = btn.dataset.uid;
|
||||||
if (!uid) {
|
if (!uid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Http.postForm(`/notifications/mark-read/${uid}`);
|
const form = document.createElement("form");
|
||||||
|
form.method = "POST";
|
||||||
|
form.action = `/notifications/mark-read/${uid}`;
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ from devplacepy.constants import TOPICS
|
|||||||
from devplacepy.database import get_table
|
from devplacepy.database import get_table
|
||||||
from devplacepy.avatar import avatar_url
|
from devplacepy.avatar import avatar_url
|
||||||
from devplacepy.utils import format_date as _format_date
|
from devplacepy.utils import format_date as _format_date
|
||||||
|
from devplacepy.seo import (
|
||||||
|
site_url, combine,
|
||||||
|
website_schema, breadcrumb_schema,
|
||||||
|
discussion_forum_posting, profile_page_schema,
|
||||||
|
software_application_schema, truncate,
|
||||||
|
)
|
||||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
|
||||||
_unread_cache = TTLCache(ttl=60)
|
_unread_cache = TTLCache(ttl=60)
|
||||||
|
|||||||
+14
-37
@@ -7,7 +7,7 @@ from passlib.hash import pbkdf2_sha256
|
|||||||
from fastapi import Request, HTTPException, status
|
from fastapi import Request, HTTPException, status
|
||||||
from devplacepy.cache import TTLCache
|
from devplacepy.cache import TTLCache
|
||||||
from devplacepy.database import get_table
|
from devplacepy.database import get_table
|
||||||
from devplacepy.config import SESSION_MAX_AGE
|
from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -86,13 +86,6 @@ def require_admin(request: Request):
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
def require_user_api(request: Request):
|
|
||||||
user = get_current_user(request)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def strip_html(text: str) -> str:
|
def strip_html(text: str) -> str:
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
@@ -147,39 +140,13 @@ def extract_mentions(content: str) -> list[str]:
|
|||||||
return re.findall(r"(?:^|[\s(])@([a-zA-Z0-9_-]+)", content)
|
return re.findall(r"(?:^|[\s(])@([a-zA-Z0-9_-]+)", content)
|
||||||
|
|
||||||
|
|
||||||
def create_notification(user_uid: str, notification_type: str, message: str, related_uid: str, target_url: str | None = None) -> None:
|
|
||||||
from devplacepy.templating import clear_unread_cache
|
|
||||||
get_table("notifications").insert({
|
|
||||||
"uid": generate_uid(),
|
|
||||||
"user_uid": user_uid,
|
|
||||||
"type": notification_type,
|
|
||||||
"message": message,
|
|
||||||
"related_uid": related_uid,
|
|
||||||
"target_url": target_url,
|
|
||||||
"read": False,
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
})
|
|
||||||
clear_unread_cache(user_uid)
|
|
||||||
|
|
||||||
|
|
||||||
def award_badge(user_uid: str, badge_name: str) -> bool:
|
|
||||||
badges = get_table("badges")
|
|
||||||
if badges.find_one(user_uid=user_uid, badge_name=badge_name):
|
|
||||||
return False
|
|
||||||
badges.insert({
|
|
||||||
"uid": generate_uid(),
|
|
||||||
"user_uid": user_uid,
|
|
||||||
"badge_name": badge_name,
|
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
})
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def create_mention_notifications(content: str, actor_uid: str, target_url: str) -> None:
|
def create_mention_notifications(content: str, actor_uid: str, target_url: str) -> None:
|
||||||
usernames = extract_mentions(content)
|
usernames = extract_mentions(content)
|
||||||
if not usernames:
|
if not usernames:
|
||||||
return
|
return
|
||||||
|
from devplacepy.templating import clear_unread_cache
|
||||||
users = get_table("users")
|
users = get_table("users")
|
||||||
|
notifs = get_table("notifications")
|
||||||
seen = set()
|
seen = set()
|
||||||
for username in usernames:
|
for username in usernames:
|
||||||
if username in seen:
|
if username in seen:
|
||||||
@@ -187,7 +154,17 @@ def create_mention_notifications(content: str, actor_uid: str, target_url: str)
|
|||||||
seen.add(username)
|
seen.add(username)
|
||||||
mentioned = users.find_one(username=username)
|
mentioned = users.find_one(username=username)
|
||||||
if mentioned and mentioned["uid"] != actor_uid:
|
if mentioned and mentioned["uid"] != actor_uid:
|
||||||
create_notification(mentioned["uid"], "mention", f"@{username} mentioned you", actor_uid, target_url)
|
notifs.insert({
|
||||||
|
"uid": generate_uid(),
|
||||||
|
"user_uid": mentioned["uid"],
|
||||||
|
"type": "mention",
|
||||||
|
"message": f"@{username} mentioned you",
|
||||||
|
"related_uid": actor_uid,
|
||||||
|
"target_url": target_url,
|
||||||
|
"read": False,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
})
|
||||||
|
clear_unread_cache(mentioned["uid"])
|
||||||
|
|
||||||
|
|
||||||
def format_date(dt_str: str, include_time: bool = False) -> str:
|
def format_date(dt_str: str, include_time: bool = False) -> str:
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ def test_uploaded_file_served_as_attachment(app_server):
|
|||||||
|
|
||||||
def test_upload_requires_login(app_server):
|
def test_upload_requires_login(app_server):
|
||||||
r = requests.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}, allow_redirects=False)
|
r = requests.post(f"{BASE_URL}/uploads/upload", files={"file": ("x.png", _png_bytes(), "image/png")}, allow_redirects=False)
|
||||||
assert r.status_code == 401
|
assert r.status_code in (302, 303)
|
||||||
|
|
||||||
|
|
||||||
def test_delete_own_allowed_other_user_forbidden(app_server):
|
def test_delete_own_allowed_other_user_forbidden(app_server):
|
||||||
|
|||||||
Reference in New Issue
Block a user