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