Update
DevPlace CI / test (push) Failing after 3m44s

This commit is contained in:
2026-05-23 01:50:31 +02:00
parent e7f139437e
commit 22e066a202
35 changed files with 947 additions and 765 deletions
+208 -72
View File
@@ -3,31 +3,93 @@ from datetime import datetime, timezone
from pathlib import Path
from PIL import Image
from io import BytesIO
from devplacepy.database import get_table
from devplacepy.database import get_table, db
from devplacepy.config import STATIC_DIR
from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
ATTACHMENTS_DIR = STATIC_DIR / "uploads" / "attachments"
UPLOADS_DIR = STATIC_DIR / "uploads"
ATTACHMENTS_DIR = UPLOADS_DIR / "attachments"
THUMBNAIL_SIZE = (200, 200)
THUMBNAIL_QUALITY = 80
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"}
THUMBNAIL_EXTENSIONS = IMAGE_EXTENSIONS - {".gif"}
POST_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
ALLOWED_UPLOAD_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".tiff": "image/tiff",
".pdf": "application/pdf",
".zip": "application/zip",
".mp4": "video/mp4",
".mp3": "audio/mpeg",
".txt": "text/plain",
".py": "text/x-python",
".js": "text/javascript",
".css": "text/css",
".md": "text/markdown",
}
FILE_ICONS = {
".pdf": "\U0001F4C4",
".zip": "\U0001F4E6",
".gz": "\U0001F4E6",
".tar": "\U0001F4E6",
".rar": "\U0001F4E6",
".7z": "\U0001F4E6",
".mp4": "\U0001F3AC",
".mp3": "\U0001F3B5",
".py": "\U0001F4BB",
".js": "\U0001F4BB",
".ts": "\U0001F4BB",
".html": "\U0001F4BB",
".css": "\U0001F4BB",
".json": "\U0001F4BB",
".md": "\U0001F4BB",
".csv": "\U0001F4CA",
".xls": "\U0001F4CA",
".xlsx": "\U0001F4CA",
".doc": "\U0001F4DD",
".docx": "\U0001F4DD",
".txt": "\U0001F4C4",
".exe": "\u2699",
".bin": "\u2699",
}
DEFAULT_FILE_ICON = "\U0001F4CE"
def _get_setting(key, default):
row = get_table("site_settings").find_one(key=key)
return row["value"] if row else default
def _get_max_upload_bytes():
return int(_get_setting("max_upload_size_mb", "10")) * 1024 * 1024
def _directory_for(uid):
return f"{uid[:2]}/{uid[2:4]}"
def _detect_mime(file_bytes, original_filename):
ext = Path(original_filename).suffix.lower()
m = {".jpg":"image/jpeg",".jpeg":"image/jpeg",".png":"image/png",".gif":"image/gif",".webp":"image/webp",".svg":"image/svg+xml",".bmp":"image/bmp",".tiff":"image/tiff",".pdf":"application/pdf",".zip":"application/zip",".mp4":"video/mp4",".mp3":"audio/mpeg",".txt":"text/plain",".py":"text/x-python",".js":"text/javascript",".html":"text/html",".css":"text/css",".md":"text/markdown"}
return m.get(ext, "application/octet-stream")
return ALLOWED_UPLOAD_TYPES.get(ext, "application/octet-stream")
def _image_dimensions(file_bytes):
try:
img = Image.open(BytesIO(file_bytes))
return img.width, img.height
except Exception as e:
logger.warning(f"Could not read image dimensions: {e}")
return None, None
def _generate_thumbnail(file_bytes, thumb_path):
try:
@@ -45,95 +107,169 @@ def _generate_thumbnail(file_bytes, thumb_path):
logger.warning(f"Thumbnail generation failed: {e}")
return None
def save_inline_image(file_bytes, original_filename):
if len(file_bytes) > _get_max_upload_bytes():
logger.warning(f"Inline image too large: {original_filename}")
return None
ext = Path(original_filename).suffix.lower()
if ext not in POST_IMAGE_EXTENSIONS:
logger.warning(f"Unsupported inline image type: {ext}")
return None
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
filename = f"{generate_uid()}{ext}"
(UPLOADS_DIR / filename).write_bytes(file_bytes)
logger.info(f"Inline image saved: {filename}")
return filename
def delete_inline_image(filename):
if not filename:
return
try:
(UPLOADS_DIR / filename).unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to delete inline image {filename}: {e}")
def store_attachment(file_bytes, original_filename, user_uid):
if len(file_bytes) > _get_max_upload_bytes():
return None
ext = Path(original_filename).suffix.lower()
if ext not in ALLOWED_UPLOAD_TYPES:
return None
uid = generate_uid()
ext = Path(original_filename).suffix.lower() or ".bin"
stored_name = f"{uid}{ext}"
directory = _directory_for(uid)
file_dir = ATTACHMENTS_DIR / directory
file_dir.mkdir(parents=True, exist_ok=True)
(file_dir / stored_name).write_bytes(file_bytes)
mime = _detect_mime(file_bytes, original_filename)
is_img = mime.startswith("image/")
img_w, img_h = None, None
thumb = None
if is_img:
try:
img = Image.open(BytesIO(file_bytes))
img_w, img_h = img.width, img.height
if ext not in (".gif",):
thumb_name = f"{uid}_thumb.jpg"
r = _generate_thumbnail(file_bytes, file_dir / thumb_name)
if r: thumb = r
except: pass
is_image = mime.startswith("image/")
image_width, image_height = None, None
thumbnail = None
if is_image:
image_width, image_height = _image_dimensions(file_bytes)
if ext not in (".gif",):
thumbnail = _generate_thumbnail(file_bytes, file_dir / f"{uid}_thumb.jpg")
get_table("attachments").insert({
"uid": uid, "target_type": "", "target_uid": "", "user_uid": user_uid,
"original_filename": original_filename, "stored_name": stored_name,
"directory": directory, "file_size": len(file_bytes), "mime_type": mime,
"image_width": img_w, "image_height": img_h,
"has_thumbnail": 1 if thumb else 0,
"uid": uid,
"target_type": "",
"target_uid": "",
"user_uid": user_uid,
"original_filename": original_filename,
"stored_name": stored_name,
"directory": directory,
"file_size": len(file_bytes),
"mime_type": mime,
"image_width": image_width,
"image_height": image_height,
"has_thumbnail": 1 if thumbnail else 0,
"created_at": datetime.now(timezone.utc).isoformat(),
})
return {"uid": uid, "original_filename": original_filename, "file_size": len(file_bytes), "mime_type": mime, "url": f"/static/uploads/attachments/{directory}/{stored_name}", "thumbnail_url": f"/static/uploads/attachments/{directory}/{thumb}" if thumb else None, "has_thumbnail": thumb is not None, "is_image": is_img}
return {
"uid": uid,
"original_filename": original_filename,
"file_size": len(file_bytes),
"mime_type": mime,
"url": f"/static/uploads/attachments/{directory}/{stored_name}",
"thumbnail_url": f"/static/uploads/attachments/{directory}/{thumbnail}" if thumbnail else None,
"has_thumbnail": thumbnail is not None,
"is_image": is_image,
}
def link_attachments(uids, target_type, target_uid):
if not uids: return
atts = get_table("attachments")
for auid in uids:
auid = auid.strip()
if not auid: continue
e = atts.find_one(uid=auid)
if e: atts.update({"id": e["id"], "uid": auid, "target_type": target_type, "target_uid": target_uid}, ["id"])
if not uids:
return
attachments = get_table("attachments")
for uid in uids:
uid = uid.strip()
if not uid:
continue
existing = attachments.find_one(uid=uid)
if existing:
attachments.update({"id": existing["id"], "uid": uid, "target_type": target_type, "target_uid": target_uid}, ["id"])
def delete_attachment(uid):
atts = get_table("attachments")
att = atts.find_one(uid=uid)
if not att: return
sn, d = att.get("stored_name",""), att.get("directory","")
if sn and d:
fp = ATTACHMENTS_DIR / d / sn
try: fp.unlink(missing_ok=True)
except: pass
for tp in (ATTACHMENTS_DIR / d).glob(f"{Path(sn).stem}_thumb.*"):
try: tp.unlink(missing_ok=True)
except: pass
atts.delete(id=att["id"])
attachments = get_table("attachments")
attachment = attachments.find_one(uid=uid)
if not attachment:
return
stored_name = attachment.get("stored_name", "")
directory = attachment.get("directory", "")
if stored_name and directory:
file_path = ATTACHMENTS_DIR / directory / stored_name
try:
file_path.unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to delete attachment file {file_path}: {e}")
for thumb_path in (ATTACHMENTS_DIR / directory).glob(f"{Path(stored_name).stem}_thumb.*"):
try:
thumb_path.unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to delete thumbnail {thumb_path}: {e}")
attachments.delete(id=attachment["id"])
def delete_target_attachments(tt, tu):
for a in get_table("attachments").find(target_type=tt, target_uid=tu):
delete_attachment(a["uid"])
def get_attachments(tt, tu):
try: rows = list(get_table("attachments").find(target_type=tt, target_uid=tu, order_by=["created_at"]))
except: return []
return [_r2a(r) for r in rows]
def delete_target_attachments(target_type, target_uid):
for attachment in get_table("attachments").find(target_type=target_type, target_uid=target_uid):
delete_attachment(attachment["uid"])
def get_attachments_batch(tt, uids):
if not uids: return {}
from devplacepy.database import db
if "attachments" not in db.tables: return {u:[] for u in uids}
ph = ",".join(f":p{i}" for i in range(len(uids)))
try:
rows = db.query(f"SELECT * FROM attachments WHERE target_type=:tt AND target_uid IN ({ph}) ORDER BY created_at", tt=tt, **{f"p{i}":u for i,u in enumerate(uids)})
except: return {u:[] for u in uids}
r = {u:[] for u in uids}
def get_attachments(target_type, target_uid):
if "attachments" not in db.tables:
return []
rows = list(get_table("attachments").find(target_type=target_type, target_uid=target_uid, order_by=["created_at"]))
return [_row_to_attachment(r) for r in rows]
def get_attachments_batch(target_type, uids):
if not uids:
return {}
if "attachments" not in db.tables:
return {uid: [] for uid in uids}
placeholders = ",".join(f":p{i}" for i in range(len(uids)))
params = {f"p{i}": uid for i, uid in enumerate(uids)}
rows = db.query(
f"SELECT * FROM attachments WHERE target_type=:tt AND target_uid IN ({placeholders}) ORDER BY created_at",
tt=target_type, **params,
)
result = {uid: [] for uid in uids}
for row in rows:
if row["target_uid"] in r: r[row["target_uid"]].append(_r2a(row))
return r
if row["target_uid"] in result:
result[row["target_uid"]].append(_row_to_attachment(row))
return result
def _r2a(r):
sn, d = r.get("stored_name",""), r.get("directory","")
ts = f"{Path(sn).stem}_thumb.jpg" if r.get("has_thumbnail") else None
return {"uid":r["uid"],"original_filename":r.get("original_filename",""),"file_size":r.get("file_size",0),"mime_type":r.get("mime_type",""),"url":f"/static/uploads/attachments/{d}/{sn}","thumbnail_url":f"/static/uploads/attachments/{d}/{ts}" if ts else None,"has_thumbnail":bool(r.get("has_thumbnail")),"is_image":r.get("mime_type","").startswith("image/")}
def format_file_size(b):
if b < 1024: return f"{b} B"
if b < 1048576: return f"{b/1024:.1f} KB"
return f"{b/1048576:.1f} MB"
def _row_to_attachment(row):
stored_name = row.get("stored_name", "")
directory = row.get("directory", "")
thumb_name = f"{Path(stored_name).stem}_thumb.jpg" if row.get("has_thumbnail") else None
return {
"uid": row["uid"],
"original_filename": row.get("original_filename", ""),
"file_size": row.get("file_size", 0),
"mime_type": row.get("mime_type", ""),
"url": f"/static/uploads/attachments/{directory}/{stored_name}",
"thumbnail_url": f"/static/uploads/attachments/{directory}/{thumb_name}" if thumb_name else None,
"has_thumbnail": bool(row.get("has_thumbnail")),
"is_image": row.get("mime_type", "").startswith("image/"),
}
def file_icon_emoji(fn):
ext = Path(fn).suffix.lower()
m = {".pdf":"\U0001F4C4",".zip":"\U0001F4E6",".gz":"\U0001F4E6",".tar":"\U0001F4E6",".rar":"\U0001F4E6",".7z":"\U0001F4E6",".mp4":"\U0001F3AC",".mp3":"\U0001F3B5",".py":"\U0001F4BB",".js":"\U0001F4BB",".ts":"\U0001F4BB",".html":"\U0001F4BB",".css":"\U0001F4BB",".json":"\U0001F4BB",".md":"\U0001F4BB",".csv":"\U0001F4CA",".xls":"\U0001F4CA",".xlsx":"\U0001F4CA",".doc":"\U0001F4DD",".docx":"\U0001F4DD",".txt":"\U0001F4C4",".exe":"\u2699",".bin":"\u2699"}
return m.get(ext, "\U0001F4CE")
def format_file_size(size):
if size < 1024:
return f"{size} B"
if size < 1048576:
return f"{size / 1024:.1f} KB"
return f"{size / 1048576:.1f} MB"
def file_icon_emoji(filename):
ext = Path(filename).suffix.lower()
return FILE_ICONS.get(ext, DEFAULT_FILE_ICON)
+19
View File
@@ -1,6 +1,7 @@
import argparse
import sys
from devplacepy.database import get_table
from devplacepy.utils import strip_html
def cmd_role_get(args):
@@ -40,6 +41,22 @@ def cmd_news_clear(args):
print("News data cleared")
def cmd_news_sanitize(args):
from devplacepy.database import db
if "news" not in db.tables:
print("News table does not exist")
return
news_table = db["news"]
updated = 0
for row in news_table.all():
desc = (strip_html(row.get("description", "") or ""))[:5000]
content = (strip_html(row.get("content", "") or ""))[:10000]
if desc != row.get("description", "") or content != row.get("content", ""):
news_table.update({"id": row["id"], "description": desc, "content": content}, ["id"])
updated += 1
print(f"Sanitized {updated} news article(s)")
def cmd_attachments_prune(args):
from devplacepy.database import db
from devplacepy.config import STATIC_DIR
@@ -101,6 +118,8 @@ def main():
news_sub = news.add_subparsers(title="action", dest="action")
news_clear = news_sub.add_parser("clear", help="Delete all news from local database")
news_clear.set_defaults(func=cmd_news_clear)
news_sanitize = news_sub.add_parser("sanitize", help="Strip HTML from all existing news descriptions and content")
news_sanitize.set_defaults(func=cmd_news_sanitize)
attachments = sub.add_parser("attachments", help="Attachment management")
att_sub = attachments.add_subparsers(title="action", dest="action")
-9
View File
@@ -266,15 +266,6 @@ def resolve_by_slug(table, slug):
return entry
def pagination_params(page, per_page=25):
total = None
total_pages = None
return {
"page": page,
"per_page": per_page,
}
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))
+12 -4
View File
@@ -25,20 +25,28 @@ _rate_limit_store = defaultdict(list)
RATE_LIMIT = 60
RATE_WINDOW = 60
class UploadStaticFiles(StaticFiles):
async def get_response(self, path, scope):
response = await super().get_response(path, scope)
response.headers["Content-Disposition"] = "attachment"
return response
app = FastAPI(title="DevPlace")
app.mount("/static/uploads", UploadStaticFiles(directory=str(STATIC_DIR / "uploads"), check_dir=False), name="uploads")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.exception_handler(404)
async def not_found(request: Request, exc):
seo_ctx = base_seo_context(request, title="Not Found — DevPlace", description="The page you requested does not exist.", robots="noindex")
seo_ctx = base_seo_context(request, title="Not Found - DevPlace", description="The page you requested does not exist.", robots="noindex")
return templates.TemplateResponse(request, "error.html", {**seo_ctx, "request": request, "error_code": 404, "error_message": "Page not found"}, status_code=404)
@app.exception_handler(500)
async def server_error(request: Request, exc):
logger.exception("500 error on %s %s", request.method, request.url.path)
seo_ctx = base_seo_context(request, title="Server Error — DevPlace", description="Something went wrong.", robots="noindex")
seo_ctx = base_seo_context(request, title="Server Error - DevPlace", description="Something went wrong.", robots="noindex")
return templates.TemplateResponse(request, "error.html", {**seo_ctx, "request": request, "error_code": 500, "error_message": "Internal server error"}, status_code=500)
app.include_router(auth.router, prefix="/auth")
@@ -73,7 +81,7 @@ async def add_security_headers(request: Request, call_next):
@app.middleware("http")
async def rate_limit_middleware(request: Request, call_next):
if request.method in ("POST", "PUT", "DELETE", "PATCH"):
ip = request.client.host if request.client else "unknown"
ip = request.headers.get("X-Real-IP") or (request.client.host if request.client else "unknown")
now = time.time()
window_start = now - RATE_WINDOW
_rate_limit_store[ip] = [t for t in _rate_limit_store[ip] if t > window_start]
@@ -152,7 +160,7 @@ async def landing(request: Request):
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="DevPlace — The Developer Social Network",
title="DevPlace - The Developer Social Network",
description="Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.",
breadcrumbs=[],
schemas=[website_schema(base)],
+3 -3
View File
@@ -31,7 +31,7 @@ async def admin_users(request: Request, page: int = 1):
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Users — Admin",
title="Users - Admin",
description="Manage DevPlace users.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
@@ -100,7 +100,7 @@ async def admin_settings(request: Request):
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Settings — Admin",
title="Settings - Admin",
description="Manage DevPlace site settings.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
@@ -144,7 +144,7 @@ async def admin_news(request: Request, page: int = 1):
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="News — Admin",
title="News - Admin",
description="Manage DevPlace news articles.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
+5 -30
View File
@@ -1,16 +1,13 @@
import logging
import aiofiles
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Request, HTTPException
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.templating import templates
from devplacepy.config import STATIC_DIR
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
from devplacepy.attachments import get_attachments, link_attachments, delete_target_attachments, save_inline_image, delete_inline_image
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -41,26 +38,9 @@ async def create_post(request: Request):
image_filename = None
image_file = form.get("image")
if image_file and hasattr(image_file, "filename") and image_file.filename:
try:
content_bytes = await image_file.read()
if len(content_bytes) > 5 * 1024 * 1024:
logger.warning(f"Image too large: {image_file.filename}")
else:
ext = Path(image_file.filename).suffix.lower()
allowed = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
if ext not in allowed:
logger.warning(f"Unsupported image type: {ext}")
else:
upload_dir = STATIC_DIR / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
image_filename = f"{generate_uid()}{ext}"
file_path = upload_dir / image_filename
async with aiofiles.open(str(file_path), "wb") as f:
await f.write(content_bytes)
logger.info(f"Image saved: {image_filename}")
content += f"\n\n![](/static/uploads/{image_filename})"
except Exception as e:
logger.warning(f"Image upload failed: {e}")
image_filename = save_inline_image(await image_file.read(), image_file.filename)
if image_filename:
content += f"\n\n![](/static/uploads/{image_filename})"
posts = get_table("posts")
uid = generate_uid()
@@ -211,12 +191,7 @@ async def delete_post(request: Request, post_slug: str):
delete_target_attachments("post", post["uid"])
get_table("comments").delete(post_uid=post["uid"])
get_table("votes").delete(target_uid=post["uid"])
image = post.get("image")
if image:
try:
(STATIC_DIR / "uploads" / image).unlink(missing_ok=True)
except Exception as e:
logger.warning(f"Failed to delete image {image}: {e}")
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)
+1 -1
View File
@@ -17,7 +17,7 @@ async def services_page(request: Request):
base = site_url(request)
seo_ctx = base_seo_context(
request,
title="Services — Admin",
title="Services - Admin",
description="Monitor background services on DevPlace.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
+8 -6
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from devplacepy.database import get_table, get_setting
from devplacepy.utils import require_user
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment
from devplacepy.attachments import store_attachment, delete_attachment as _delete_attachment, ALLOWED_UPLOAD_TYPES
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -19,14 +19,16 @@ async def upload_file(request: Request):
if not file or not hasattr(file, "filename") or not file.filename:
return JSONResponse({"error": "No file provided"}, status_code=400)
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_UPLOAD_TYPES:
return JSONResponse({"error": f"File type '{ext}' not allowed"}, status_code=415)
allowed_types_raw = get_setting("allowed_file_types", "").strip()
allowed_extensions = set()
if allowed_types_raw:
for ext in allowed_types_raw.split(","):
ext = ext.strip().lower()
allowed_extensions.add(ext if ext.startswith(".") else f".{ext}")
ext = Path(file.filename).suffix.lower()
for allowed_ext in allowed_types_raw.split(","):
allowed_ext = allowed_ext.strip().lower()
allowed_extensions.add(allowed_ext if allowed_ext.startswith(".") else f".{allowed_ext}")
if allowed_extensions and ext not in allowed_extensions:
return JSONResponse({"error": f"File type '{ext}' not allowed"}, status_code=415)
+1 -1
View File
@@ -134,7 +134,7 @@ DEFAULT_OG_IMAGE = "/static/og-default.svg"
def base_seo_context(request, title="", description="", robots="index,follow", og_type="website", og_image=None, breadcrumbs=None, schemas=None):
base = site_url(request)
page_title = f"{title} — {SITE_NAME}" if title else SITE_NAME
page_title = f"{title} - {SITE_NAME}" if title else SITE_NAME
canonical = str(request.url).split("?")[0]
og_img = og_image or f"{base}{DEFAULT_OG_IMAGE}"
return {
+7 -7
View File
@@ -7,7 +7,7 @@ import httpx
from devplacepy.database import get_table
from devplacepy.services.base import BaseService
from devplacepy.utils import generate_uid, make_combined_slug
from devplacepy.utils import generate_uid, make_combined_slug, strip_html
logger = logging.getLogger(__name__)
@@ -147,10 +147,10 @@ class NewsService(BaseService):
"status": is_published,
"title": article.get("title", ""),
"slug": existing_slug or make_combined_slug(article.get("title", "") or "news", existing["uid"]),
"description": (article.get("description", "") or "")[:5000],
"description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"source_name": article.get("feed_name", ""),
"content": (article.get("content", "") or "")[:10000],
"content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
@@ -166,13 +166,13 @@ class NewsService(BaseService):
"slug": article_slug,
"external_id": external_id,
"title": article.get("title", ""),
"description": (article.get("description", "") or "")[:5000],
"description": strip_html(article.get("description", "") or "")[:5000],
"url": article.get("link", ""),
"image_url": "",
"source_name": article.get("feed_name", ""),
"grade": grade_val,
"status": is_published,
"content": (article.get("content", "") or "")[:10000],
"content": strip_html(article.get("content", "") or "")[:10000],
"author": article.get("author", ""),
"article_published": article.get("published", ""),
"synced_at": now,
@@ -212,8 +212,8 @@ class NewsService(BaseService):
async def _grade_article(self, article: dict, ai_url: str, ai_model: str) -> int | None:
title = (article.get("title", "") or "")[:500]
description = (article.get("description", "") or "")[:1000]
content = (article.get("content", "") or "")[:1500]
description = strip_html(article.get("description", "") or "")[:1000]
content = strip_html(article.get("content", "") or "")[:1500]
prompt = (
"Rate this article's relevance to software developers on a scale of 1-10. "
+3 -14
View File
@@ -24,7 +24,7 @@
.gists-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-template-columns: 1fr;
gap: 1rem;
}
@@ -34,12 +34,7 @@
border-radius: var(--radius-lg);
padding: 1.25rem;
transition: all 0.2s;
}
a.gist-card {
display: block;
text-decoration: none;
color: inherit;
cursor: pointer;
}
.gist-card:hover {
@@ -62,9 +57,6 @@ a.gist-card {
line-height: 1.3;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gist-card-meta {
@@ -282,13 +274,10 @@ a.gist-card {
color: var(--text-muted);
}
@media (max-width: 768px) {
@media (max-width: 1024px) {
.gists-layout {
grid-template-columns: 1fr;
}
.gists-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
+1
View File
@@ -68,6 +68,7 @@
border-radius: var(--radius-lg);
padding: 1.25rem;
transition: border-color 0.2s;
cursor: pointer;
}
.project-card:hover {
+19 -532
View File
@@ -1,538 +1,25 @@
import { ModalManager } from "./ModalManager.js";
import { FormManager } from "./FormManager.js";
import { VoteManager } from "./VoteManager.js";
import { MessageSearch } from "./MessageSearch.js";
import { ProfileEditor } from "./ProfileEditor.js";
import { MobileNav } from "./MobileNav.js";
import { CommentManager } from "./CommentManager.js";
import { ContentEnhancer } from "./ContentEnhancer.js";
import { DomUtils } from "./DomUtils.js";
class Application {
constructor() {
this.initPasswordToggles();
this.initModals();
this.initPostForm();
this.initCommentForms();
this.initVoteButtons();
this.initMessageSearch();
this.initNotificationDismiss();
this.initProfileEdit();
this.initMobileNav();
this.initMessagesResponsive();
this.initFormDisable();
this.initContentRenderer();
this.initCommentReply();
this.initEmojiPickers();
this.initMentionInputs();
this.initConfirmations();
this.initImageFallbacks();
this.initClipboardCopy();
this.initAutoSubmitSelects();
this.initTogglers();
this.initStopPropagation();
this.initMessageThread();
this.initPlatformTags();
this.initAttachmentManagers();
this.modals = new ModalManager();
this.forms = new FormManager();
this.votes = new VoteManager();
this.messageSearch = new MessageSearch();
this.profile = new ProfileEditor();
this.mobileNav = new MobileNav();
this.comments = new CommentManager();
this.content = new ContentEnhancer();
this.dom = new DomUtils();
}
initMessagesResponsive() {
const list = document.querySelector(".messages-list");
const main = document.querySelector(".messages-main");
const backBtn = document.getElementById("messages-back-btn");
if (!list || !main) return;
const isMobile = () => window.innerWidth <= 768;
if (isMobile()) {
if (window.location.search.includes("with_uid=")) {
list.classList.add("hide");
main.classList.remove("hide");
} else {
list.classList.remove("hide");
main.classList.add("hide");
}
}
if (backBtn) {
backBtn.addEventListener("click", () => {
if (!isMobile()) return;
list.classList.remove("hide");
main.classList.add("hide");
window.history.replaceState(null, "", "/messages");
});
}
list.querySelectorAll(".conversation-item").forEach((item) => {
item.addEventListener("click", (e) => {
if (!isMobile()) return;
list.classList.add("hide");
main.classList.remove("hide");
});
});
const mq = window.matchMedia("(max-width: 768px)");
mq.addEventListener("change", () => {
if (!isMobile()) {
list.classList.remove("hide");
main.classList.remove("hide");
}
});
}
initMobileNav() {
const btn = document.getElementById("hamburger-btn");
const panel = document.getElementById("mobile-panel");
const overlay = document.getElementById("mobile-overlay");
if (!btn || !panel || !overlay) return;
const close = () => {
panel.classList.remove("open");
overlay.style.display = "none";
btn.innerHTML = "\u2630";
};
const open = () => {
panel.classList.add("open");
overlay.style.display = "block";
btn.innerHTML = "\u2715";
};
btn.addEventListener("click", () => {
if (panel.classList.contains("open")) {
close();
} else {
open();
}
});
overlay.addEventListener("click", close);
panel.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", close);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && panel.classList.contains("open")) {
close();
}
});
}
loadCSS(href) {
if (document.querySelector(`link[href="${href}"]`)) {
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
}
initPasswordToggles() {
document.querySelectorAll(".auth-toggle-pw").forEach((btn) => {
btn.addEventListener("click", () => {
const input = btn.parentElement.querySelector("input");
if (!input) {
return;
}
const type = input.type === "password" ? "text" : "password";
input.type = type;
btn.textContent = type === "password" ? "\u{1F441}" : "\u{1F441}";
});
});
}
initModals() {
document.querySelectorAll("[data-modal]").forEach((trigger) => {
trigger.addEventListener("click", (e) => {
e.preventDefault();
const modalId = trigger.dataset.modal;
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add("visible");
}
});
});
document.querySelectorAll(".modal-overlay").forEach((modal) => {
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.classList.remove("visible");
}
});
modal.querySelectorAll(".modal-close").forEach((closeBtn) => {
closeBtn.addEventListener("click", () => {
modal.classList.remove("visible");
});
});
});
}
initPostForm() {
const form = document.getElementById("create-post-form");
if (!form) {
return;
}
const content = form.querySelector("#post-content");
const title = form.querySelector("#post-title");
const contentCount = form.querySelector("#post-content-count");
const titleCount = form.querySelector("#post-title-count");
if (content && contentCount) {
content.addEventListener("input", () => {
contentCount.textContent = `${content.value.length}/2000`;
});
}
if (title && titleCount) {
title.addEventListener("input", () => {
titleCount.textContent = `${title.value.length}/500`;
});
}
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
btn.disabled = true;
btn.textContent = "Posting...";
}
});
}
initFormDisable() {
document.querySelectorAll("form").forEach((form) => {
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
btn.disabled = true;
}
});
});
}
initCommentForms() {
document.querySelectorAll(".comment-form").forEach((form) => {
const textarea = form.querySelector("textarea");
if (!textarea) {
return;
}
textarea.addEventListener("input", () => {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
});
});
}
initVoteButtons() {
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
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();
});
});
}
initMessageSearch() {
const searchInput = document.getElementById("message-search");
if (!searchInput) {
return;
}
const wrap = searchInput.parentElement;
const dropdown = document.createElement("div");
dropdown.className = "search-dropdown";
wrap.appendChild(dropdown);
let debounceTimer = null;
searchInput.addEventListener("input", () => {
clearTimeout(debounceTimer);
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
dropdown.style.display = "none";
return;
}
debounceTimer = setTimeout(async () => {
try {
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
const data = await resp.json();
const results = data.results || [];
if (results.length === 0) {
dropdown.style.display = "none";
return;
}
dropdown.innerHTML = "";
for (const r of results) {
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>`;
dropdown.appendChild(item);
}
dropdown.style.display = "block";
} catch (e) {
// silently fail — no suggestions
}
}, 200);
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const first = dropdown.querySelector(".search-dropdown-item");
if (first) {
window.location.href = first.href;
}
}
});
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
dropdown.style.display = "none";
}
});
}
initNotificationDismiss() {
document.querySelectorAll(".notification-dismiss").forEach((btn) => {
btn.addEventListener("click", async () => {
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();
});
});
}
initProfileEdit() {
document.querySelectorAll("[data-edit-field]").forEach((btn) => {
btn.addEventListener("click", () => {
const field = btn.dataset.editField;
const display = document.getElementById(`display-${field}`);
const input = document.getElementById(`input-${field}`);
if (!display || !input) {
return;
}
display.classList.toggle("hidden");
input.classList.toggle("hidden");
if (!input.classList.contains("hidden")) {
input.focus();
}
});
});
}
initContentRenderer() {
document.querySelectorAll("[data-render]").forEach((el) => {
window.contentRenderer.applyTo(el);
});
window.contentRenderer.highlightAll();
}
initCommentReply() {
document.querySelectorAll("[data-action='reply']").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
const comment = btn.closest(".comment");
const commentForm = document.querySelector(".comment-form");
if (!comment || !commentForm) return;
const textarea = commentForm.querySelector("textarea");
if (!textarea) return;
let parentInput = commentForm.querySelector('input[name="parent_uid"]');
if (!parentInput) {
parentInput = document.createElement("input");
parentInput.type = "hidden";
parentInput.name = "parent_uid";
commentForm.appendChild(parentInput);
}
const commentBody = comment.querySelector(".comment-body");
if (commentBody && commentBody.dataset.commentUid) {
parentInput.value = commentBody.dataset.commentUid;
}
textarea.focus();
textarea.scrollIntoView({ behavior: "smooth" });
});
});
}
initMentionInputs() {
if (!window.MentionInput) return;
document.querySelectorAll("[data-mention]").forEach((el) => {
if (el.dataset.mentionInitialized) return;
el.dataset.mentionInitialized = "true";
new window.MentionInput(el);
});
}
initConfirmations() {
document.querySelectorAll("[data-confirm]").forEach((btn) => {
btn.addEventListener("click", (e) => {
if (!confirm(btn.dataset.confirm)) {
e.preventDefault();
}
});
});
}
initImageFallbacks() {
document.querySelectorAll(".image-fallback").forEach((img) => {
img.addEventListener("error", () => {
const container = img.closest(".news-card-image, .news-detail-image") || img.parentElement;
if (container) container.style.display = "none";
});
});
}
initClipboardCopy() {
document.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const source = document.getElementById(btn.dataset.copy);
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 2000);
} catch {
// silently fail
}
});
});
}
initAutoSubmitSelects() {
document.querySelectorAll("[data-auto-submit]").forEach((select) => {
select.addEventListener("change", () => {
const form = select.closest("form");
if (form) form.submit();
});
});
}
initTogglers() {
document.querySelectorAll("[data-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const target = document.getElementById(btn.dataset.toggle);
if (target) target.classList.toggle("hidden");
});
});
}
initStopPropagation() {
document.querySelectorAll("[data-stop-propagation]").forEach((el) => {
el.addEventListener("click", (e) => e.stopPropagation());
});
}
initMessageThread() {
const thread = document.querySelector(".messages-thread");
if (thread) thread.scrollTop = thread.scrollHeight;
}
initPlatformTags() {
const platformsInput = document.getElementById("platforms-input");
const hiddenInput = document.getElementById("platforms");
const tagsContainer = document.getElementById("platforms-tags");
if (!platformsInput || !hiddenInput || !tagsContainer) return;
const addPlatform = (val) => {
val = val.trim();
if (!val) return;
const tag = document.createElement("span");
tag.className = "platform-tag";
tag.textContent = val;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "x";
remove.style.marginLeft = "4px";
remove.style.fontSize = "0.75rem";
remove.style.padding = "0";
remove.style.background = "none";
remove.style.border = "none";
remove.style.color = "inherit";
remove.style.cursor = "pointer";
remove.addEventListener("click", () => {
tag.remove();
updatePlatforms();
});
tag.appendChild(remove);
tagsContainer.appendChild(tag);
platformsInput.value = "";
updatePlatforms();
};
const updatePlatforms = () => {
const values = [];
tagsContainer.querySelectorAll(".platform-tag").forEach((t) => {
values.push(t.textContent.replace("x", "").trim());
});
hiddenInput.value = values.join(",");
};
platformsInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
addPlatform(platformsInput.value);
}
});
document.querySelectorAll(".platform-preset").forEach((btn) => {
btn.addEventListener("click", () => {
addPlatform(btn.dataset.platform);
});
});
}
initAttachmentManagers() {
if (!window.AttachmentUploader) return;
document.querySelectorAll(".attachment-upload-container").forEach((container) => {
if (container.dataset.attachmentInitialized) return;
container.dataset.attachmentInitialized = "true";
const form = container.closest("form");
if (form) {
form.dataset.maxSize = container.dataset.maxSize;
form.dataset.maxFiles = container.dataset.maxFiles;
form.dataset.allowedTypes = container.dataset.allowedTypes || "";
new window.AttachmentUploader(form);
}
});
}
initEmojiPickers() {
document.querySelectorAll(".comment-form textarea, .emoji-picker-target").forEach((textarea) => {
if (textarea.dataset.emojiInitialized) return;
textarea.dataset.emojiInitialized = "true";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "emoji-toggle-btn";
btn.title = "Add emoji";
btn.innerHTML = "\u{1F600}";
const picker = new window.EmojiPicker(textarea);
btn.addEventListener("click", () => picker.toggle());
const parent = textarea.parentElement;
if (parent) {
const actions = parent.querySelector(".comment-form-actions");
if (actions) {
actions.style.position = "relative";
actions.appendChild(picker.wrapper);
actions.insertBefore(btn, actions.firstChild);
}
}
});
}
}
const app = new Application();
+1 -1
View File
@@ -1,4 +1,4 @@
class AttachmentUploader {
export class AttachmentUploader {
constructor(form) {
this.form = form;
this.attachments = [];
+31
View File
@@ -0,0 +1,31 @@
export class CommentManager {
constructor() {
this.initCommentReply();
}
initCommentReply() {
document.querySelectorAll("[data-action='reply']").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
const comment = btn.closest(".comment");
const commentForm = document.querySelector(".comment-form");
if (!comment || !commentForm) return;
const textarea = commentForm.querySelector("textarea");
if (!textarea) return;
let parentInput = commentForm.querySelector('input[name="parent_uid"]');
if (!parentInput) {
parentInput = document.createElement("input");
parentInput.type = "hidden";
parentInput.name = "parent_uid";
commentForm.appendChild(parentInput);
}
const commentBody = comment.querySelector(".comment-body");
if (commentBody && commentBody.dataset.commentUid) {
parentInput.value = commentBody.dataset.commentUid;
}
textarea.focus();
textarea.scrollIntoView({ behavior: "smooth" });
});
});
}
}
+78
View File
@@ -0,0 +1,78 @@
import { contentRenderer } from "./ContentRenderer.js";
import { MentionInput } from "./MentionInput.js";
import { EmojiPicker } from "./EmojiPicker.js";
import { AttachmentUploader } from "./AttachmentUploader.js";
export class ContentEnhancer {
constructor() {
this.initContentRenderer();
this.initEmojiPickers();
this.initMentionInputs();
this.initImageFallbacks();
this.initAttachmentManagers();
}
initContentRenderer() {
document.querySelectorAll("[data-render]").forEach((el) => {
contentRenderer.applyTo(el);
});
contentRenderer.highlightAll();
}
initEmojiPickers() {
document.querySelectorAll(".comment-form textarea, .emoji-picker-target").forEach((textarea) => {
if (textarea.dataset.emojiInitialized) return;
textarea.dataset.emojiInitialized = "true";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "emoji-toggle-btn";
btn.title = "Add emoji";
btn.innerHTML = "\u{1F600}";
const picker = new EmojiPicker(textarea);
btn.addEventListener("click", () => picker.toggle());
const parent = textarea.parentElement;
if (parent) {
const actions = parent.querySelector(".comment-form-actions");
if (actions) {
actions.style.position = "relative";
actions.appendChild(picker.wrapper);
actions.insertBefore(btn, actions.firstChild);
}
}
});
}
initMentionInputs() {
document.querySelectorAll("[data-mention]").forEach((el) => {
if (el.dataset.mentionInitialized) return;
el.dataset.mentionInitialized = "true";
new MentionInput(el);
});
}
initImageFallbacks() {
document.querySelectorAll(".image-fallback").forEach((img) => {
img.addEventListener("error", () => {
const container = img.closest(".news-card-image, .news-detail-image") || img.parentElement;
if (container) container.style.display = "none";
});
});
}
initAttachmentManagers() {
document.querySelectorAll(".attachment-upload-container").forEach((container) => {
if (container.dataset.attachmentInitialized) return;
container.dataset.attachmentInitialized = "true";
const form = container.closest("form");
if (form) {
form.dataset.maxSize = container.dataset.maxSize;
form.dataset.maxFiles = container.dataset.maxFiles;
form.dataset.allowedTypes = container.dataset.allowedTypes || "";
new AttachmentUploader(form);
}
});
}
}
+3 -2
View File
@@ -1,4 +1,4 @@
class ContentRenderer {
export class ContentRenderer {
constructor() {
this.emojiMap = this.buildEmojiMap();
this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i;
@@ -195,4 +195,5 @@ class ContentRenderer {
}
}
window.contentRenderer = new ContentRenderer();
export const contentRenderer = new ContentRenderer();
window.contentRenderer = contentRenderer;
+50
View File
@@ -0,0 +1,50 @@
export class DomUtils {
constructor() {
this.initClipboardCopy();
this.initTogglers();
this.initStopPropagation();
this.initCardLinks();
}
initClipboardCopy() {
document.querySelectorAll("[data-copy]").forEach((btn) => {
btn.addEventListener("click", async () => {
const source = document.getElementById(btn.dataset.copy);
if (!source) return;
try {
await navigator.clipboard.writeText(source.textContent);
const original = btn.textContent;
btn.textContent = "Copied!";
setTimeout(() => { btn.textContent = original; }, 2000);
} catch {
// silently fail
}
});
});
}
initTogglers() {
document.querySelectorAll("[data-toggle]").forEach((btn) => {
btn.addEventListener("click", () => {
const target = document.getElementById(btn.dataset.toggle);
if (target) target.classList.toggle("hidden");
});
});
}
initStopPropagation() {
document.querySelectorAll("[data-stop-propagation]").forEach((el) => {
el.addEventListener("click", (e) => e.stopPropagation());
});
}
initCardLinks() {
document.querySelectorAll("[data-href]").forEach((el) => {
el.addEventListener("click", (e) => {
if (e.target.closest("[data-stop-propagation]")) return;
const href = el.dataset.href;
if (href) window.location.href = href;
});
});
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
class EmojiPicker {
export class EmojiPicker {
constructor(textarea) {
this.textarea = textarea;
this.picker = null;
+71
View File
@@ -0,0 +1,71 @@
export class FormManager {
constructor() {
this.initPostForm();
this.initCommentForms();
this.initFormDisable();
this.initAutoSubmitSelects();
}
initPostForm() {
const form = document.getElementById("create-post-form");
if (!form) {
return;
}
const content = form.querySelector("#post-content");
const title = form.querySelector("#post-title");
const contentCount = form.querySelector("#post-content-count");
const titleCount = form.querySelector("#post-title-count");
if (content && contentCount) {
content.addEventListener("input", () => {
contentCount.textContent = `${content.value.length}/2000`;
});
}
if (title && titleCount) {
title.addEventListener("input", () => {
titleCount.textContent = `${title.value.length}/500`;
});
}
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
btn.disabled = true;
btn.textContent = "Posting...";
}
});
}
initCommentForms() {
document.querySelectorAll(".comment-form").forEach((form) => {
const textarea = form.querySelector("textarea");
if (!textarea) {
return;
}
textarea.addEventListener("input", () => {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
});
});
}
initFormDisable() {
document.querySelectorAll("form").forEach((form) => {
form.addEventListener("submit", () => {
const btn = form.querySelector("button[type='submit']");
if (btn) {
btn.disabled = true;
}
});
});
}
initAutoSubmitSelects() {
document.querySelectorAll("[data-auto-submit]").forEach((select) => {
select.addEventListener("change", () => {
const form = select.closest("form");
if (form) form.submit();
});
});
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
class MentionInput {
export class MentionInput {
constructor(element) {
this.input = element;
this.dropdown = null;
+66
View File
@@ -0,0 +1,66 @@
export class MessageSearch {
constructor() {
this.initMessageSearch();
}
initMessageSearch() {
const searchInput = document.getElementById("message-search");
if (!searchInput) {
return;
}
const wrap = searchInput.parentElement;
const dropdown = document.createElement("div");
dropdown.className = "search-dropdown";
wrap.appendChild(dropdown);
let debounceTimer = null;
searchInput.addEventListener("input", () => {
clearTimeout(debounceTimer);
const q = searchInput.value.trim();
if (q.length < 1) {
dropdown.innerHTML = "";
dropdown.style.display = "none";
return;
}
debounceTimer = setTimeout(async () => {
try {
const resp = await fetch(`/messages/search?q=${encodeURIComponent(q)}`);
const data = await resp.json();
const results = data.results || [];
if (results.length === 0) {
dropdown.style.display = "none";
return;
}
dropdown.innerHTML = "";
for (const r of results) {
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>`;
dropdown.appendChild(item);
}
dropdown.style.display = "block";
} catch (e) {
// silently fail - no suggestions
}
}, 200);
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const first = dropdown.querySelector(".search-dropdown-item");
if (first) {
window.location.href = first.href;
}
}
});
document.addEventListener("click", (e) => {
if (!wrap.contains(e.target)) {
dropdown.style.display = "none";
}
});
}
}
+95
View File
@@ -0,0 +1,95 @@
export class MobileNav {
constructor() {
this.initMobileNav();
this.initMessagesResponsive();
this.initMessageThread();
}
initMobileNav() {
const btn = document.getElementById("hamburger-btn");
const panel = document.getElementById("mobile-panel");
const overlay = document.getElementById("mobile-overlay");
if (!btn || !panel || !overlay) return;
const close = () => {
panel.classList.remove("open");
overlay.style.display = "none";
btn.innerHTML = "☰";
};
const open = () => {
panel.classList.add("open");
overlay.style.display = "block";
btn.innerHTML = "✕";
};
btn.addEventListener("click", () => {
if (panel.classList.contains("open")) {
close();
} else {
open();
}
});
overlay.addEventListener("click", close);
panel.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", close);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && panel.classList.contains("open")) {
close();
}
});
}
initMessagesResponsive() {
const list = document.querySelector(".messages-list");
const main = document.querySelector(".messages-main");
const backBtn = document.getElementById("messages-back-btn");
if (!list || !main) return;
const isMobile = () => window.innerWidth <= 768;
if (isMobile()) {
if (window.location.search.includes("with_uid=")) {
list.classList.add("hide");
main.classList.remove("hide");
} else {
list.classList.remove("hide");
main.classList.add("hide");
}
}
if (backBtn) {
backBtn.addEventListener("click", () => {
if (!isMobile()) return;
list.classList.remove("hide");
main.classList.add("hide");
window.history.replaceState(null, "", "/messages");
});
}
list.querySelectorAll(".conversation-item").forEach((item) => {
item.addEventListener("click", (e) => {
if (!isMobile()) return;
list.classList.add("hide");
main.classList.remove("hide");
});
});
const mq = window.matchMedia("(max-width: 768px)");
mq.addEventListener("change", () => {
if (!isMobile()) {
list.classList.remove("hide");
main.classList.remove("hide");
}
});
}
initMessageThread() {
const thread = document.querySelector(".messages-thread");
if (thread) thread.scrollTop = thread.scrollHeight;
}
}
+57
View File
@@ -0,0 +1,57 @@
export class ModalManager {
constructor() {
this.initPasswordToggles();
this.initModals();
this.initConfirmations();
}
initPasswordToggles() {
document.querySelectorAll(".auth-toggle-pw").forEach((btn) => {
btn.addEventListener("click", () => {
const input = btn.parentElement.querySelector("input");
if (!input) {
return;
}
const type = input.type === "password" ? "text" : "password";
input.type = type;
btn.textContent = type === "password" ? "\u{1F441}" : "\u{1F441}";
});
});
}
initModals() {
document.querySelectorAll("[data-modal]").forEach((trigger) => {
trigger.addEventListener("click", (e) => {
e.preventDefault();
const modalId = trigger.dataset.modal;
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add("visible");
}
});
});
document.querySelectorAll(".modal-overlay").forEach((modal) => {
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.classList.remove("visible");
}
});
modal.querySelectorAll(".modal-close").forEach((closeBtn) => {
closeBtn.addEventListener("click", () => {
modal.classList.remove("visible");
});
});
});
}
initConfirmations() {
document.querySelectorAll("[data-confirm]").forEach((btn) => {
btn.addEventListener("click", (e) => {
if (!confirm(btn.dataset.confirm)) {
e.preventDefault();
}
});
});
}
}
+78
View File
@@ -0,0 +1,78 @@
export class ProfileEditor {
constructor() {
this.initProfileEdit();
this.initPlatformTags();
}
initProfileEdit() {
document.querySelectorAll("[data-edit-field]").forEach((btn) => {
btn.addEventListener("click", () => {
const field = btn.dataset.editField;
const display = document.getElementById(`display-${field}`);
const input = document.getElementById(`input-${field}`);
if (!display || !input) {
return;
}
display.classList.toggle("hidden");
input.classList.toggle("hidden");
if (!input.classList.contains("hidden")) {
input.focus();
}
});
});
}
initPlatformTags() {
const platformsInput = document.getElementById("platforms-input");
const hiddenInput = document.getElementById("platforms");
const tagsContainer = document.getElementById("platforms-tags");
if (!platformsInput || !hiddenInput || !tagsContainer) return;
const addPlatform = (val) => {
val = val.trim();
if (!val) return;
const tag = document.createElement("span");
tag.className = "platform-tag";
tag.textContent = val;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "x";
remove.style.marginLeft = "4px";
remove.style.fontSize = "0.75rem";
remove.style.padding = "0";
remove.style.background = "none";
remove.style.border = "none";
remove.style.color = "inherit";
remove.style.cursor = "pointer";
remove.addEventListener("click", () => {
tag.remove();
updatePlatforms();
});
tag.appendChild(remove);
tagsContainer.appendChild(tag);
platformsInput.value = "";
updatePlatforms();
};
const updatePlatforms = () => {
const values = [];
tagsContainer.querySelectorAll(".platform-tag").forEach((t) => {
values.push(t.textContent.replace("x", "").trim());
});
hiddenInput.value = values.join(",");
};
platformsInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
addPlatform(platformsInput.value);
}
});
document.querySelectorAll(".platform-preset").forEach((btn) => {
btn.addEventListener("click", () => {
addPlatform(btn.dataset.platform);
});
});
}
}
+43
View File
@@ -0,0 +1,43 @@
export class VoteManager {
constructor() {
this.initVoteButtons();
this.initNotificationDismiss();
}
initVoteButtons() {
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
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();
});
});
}
initNotificationDismiss() {
document.querySelectorAll(".notification-dismiss").forEach((btn) => {
btn.addEventListener("click", async () => {
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();
});
});
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{% extends "admin_base.html" %}
{% block admin_content %}
<div class="admin-toolbar">
<h2>News — Curation</h2>
<h2>News - Curation</h2>
<span class="admin-count">{{ pagination.total }} articles</span>
</div>
+1 -5
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace — The Developer Social Network{% endif %}</title>
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
<meta name="description" content="{% if meta_description %}{{ meta_description }}{% else %}Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.{% endif %}">
<link rel="canonical" href="{{ canonical_url or request.url }}">
<meta name="robots" content="{{ meta_robots or 'index,follow' }}">
@@ -158,10 +158,6 @@
<script defer src="https://cdn.jsdelivr.net/npm/marked/lib/marked.umd.js"></script>
<script defer src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js"></script>
<script type="module" src="https://cdn.jsdelivr.net/npm/emoji-picker-element@^1/index.js"></script>
<script type="module" src="/static/js/ContentRenderer.js"></script>
<script type="module" src="/static/js/AttachmentUploader.js"></script>
<script type="module" src="/static/js/EmojiPicker.js"></script>
<script type="module" src="/static/js/MentionInput.js"></script>
<script type="module" src="/static/js/Application.js"></script>
{% block extra_js %}{% endblock %}
</body>
+2 -2
View File
@@ -47,7 +47,7 @@
<div class="gists-grid">
{% for item in gists %}
<a href="/gists/{{ item.gist['slug'] or item.gist['uid'] }}" class="gist-card fade-in">
<div class="gist-card fade-in" data-href="/gists/{{ item.gist['slug'] or item.gist['uid'] }}">
<div class="gist-card-header">
<h3 class="gist-card-title">{{ item.gist['title'] }}</h3>
<form method="POST" action="/votes/gist/{{ item.gist['uid'] }}" class="inline-form" data-stop-propagation>
@@ -71,7 +71,7 @@
</div>
<span>{{ item.time_ago }}</span>
</div>
</a>
</div>
{% else %}
<div class="empty-state empty-state-full">No gists found. Create one!</div>
{% endfor %}
+2 -2
View File
@@ -53,7 +53,7 @@
<div class="projects-grid">
{% for project in projects %}
<a href="/projects/{{ project['slug'] or project['uid'] }}" class="project-card fade-in">
<div class="project-card fade-in" data-href="/projects/{{ project['slug'] or project['uid'] }}">
<div class="project-card-header">
<h3 class="project-card-title">{{ project['title'] }}</h3>
<form method="POST" action="/votes/project/{{ project['uid'] }}" class="inline-form">
@@ -84,7 +84,7 @@
</div>
{% endif %}
</div>
</a>
</div>
{% else %}
<div class="empty-state empty-state-full">No projects found. Create one!</div>
{% endfor %}
+9 -1
View File
@@ -1,3 +1,4 @@
import html
import re
import secrets
import time
@@ -83,8 +84,15 @@ def require_admin(request: Request):
return user
def strip_html(text: str) -> str:
if not text:
return ""
text = re.sub(r"<[^>]+>", " ", text)
text = html.unescape(text)
return re.sub(r"\s+", " ", text).strip()
def slugify(text: str) -> str:
import re
text = text.lower().strip()
text = re.sub(r"[^a-z0-9-]", "-", text)
text = re.sub(r"-+", "-", text)