feat: add user_id index to profiles table for faster lookups

The profiles table previously lacked an index on the user_id column, causing full table scans during user lookups. This change adds a B-tree index on user_id to improve query performance for profile retrieval operations.
This commit is contained in:
2026-06-06 14:31:42 +00:00
parent fd2ab34adc
commit add1b7c56b
79 changed files with 2431 additions and 185 deletions
+3 -1
View File
@@ -9,7 +9,9 @@ STATIC_DIR = BASE_DIR / "devplacepy" / "static"
TEMPLATES_DIR = BASE_DIR / "devplacepy" / "templates"
DATABASE_URL = environ.get("DEVPLACE_DATABASE_URL", f"sqlite:///{BASE_DIR / 'devplace.db'}")
SECRET_KEY = environ.get("SECRET_KEY", "devplace-secret-key-change-in-production")
SESSION_MAX_AGE = 86400 * 7
SECONDS_PER_DAY = 86400
SESSION_MAX_AGE = SECONDS_PER_DAY * 7
SESSION_MAX_AGE_REMEMBER = SECONDS_PER_DAY * 30
PORT = 10500
SITE_URL = environ.get("DEVPLACE_SITE_URL", "").rstrip("/")
+2
View File
@@ -1 +1,3 @@
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
REACTION_EMOJI = ["\U0001F44D", "❤️", "\U0001F680", "\U0001F389", "\U0001F602", "\U0001F440", "\U0001F525", "\U0001F92F"]
+18
View File
@@ -9,9 +9,16 @@ from devplacepy.database import (
get_users_by_uids,
get_vote_counts,
get_user_votes,
get_reactions_by_targets,
get_user_bookmarks,
get_poll_for_post,
delete_engagement,
load_comments,
db,
)
BOOKMARKABLE_TYPES = {"post", "gist", "project", "news"}
REACTABLE_TYPES = {"post", "comment", "gist", "project"}
from devplacepy.attachments import delete_attachments_for, delete_inline_image, get_attachments, link_attachments
from devplacepy.utils import time_ago, generate_uid, make_combined_slug, award_rewards, create_mention_notifications
@@ -71,6 +78,9 @@ def detail_context(request, user: dict | None, detail: dict, key: str, seo_ctx:
"time_ago": detail["time_ago"],
"comments": detail["comments"],
"attachments": detail["attachments"],
"reactions": detail.get("reactions", {"counts": {}, "mine": []}),
"bookmarked": detail.get("bookmarked", False),
"poll": detail.get("poll"),
}
if extra:
context.update(extra)
@@ -104,6 +114,9 @@ def delete_content_item(table_name: str, target_type: str, user: dict, slug: str
votes.delete(target_uid=item["uid"])
if comment_uids:
votes.delete(votes.table.columns.target_uid.in_(comment_uids), target_type="comment")
delete_engagement(target_type, [item["uid"]])
if comment_uids:
delete_engagement("comment", comment_uids)
if inline_image_field:
delete_inline_image(item.get(inline_image_field))
table.delete(id=item["id"])
@@ -117,6 +130,8 @@ def load_detail(table_name: str, target_type: str, slug: str, user: dict | None)
return None
author = get_users_by_uids([item["user_uid"]]).get(item["user_uid"])
ups, downs = get_vote_counts([item["uid"]])
reactions = get_reactions_by_targets(target_type, [item["uid"]], user).get(item["uid"], {"counts": {}, "mine": []}) if target_type in REACTABLE_TYPES else {"counts": {}, "mine": []}
bookmarked = bool(user) and target_type in BOOKMARKABLE_TYPES and item["uid"] in get_user_bookmarks(user["uid"], target_type, [item["uid"]])
return {
"item": item,
"author": author,
@@ -126,6 +141,9 @@ def load_detail(table_name: str, target_type: str, slug: str, user: dict | None)
"comments": load_comments(target_type, item["uid"], user),
"attachments": get_attachments(target_type, item["uid"]),
"time_ago": time_ago(item["created_at"]),
"reactions": reactions,
"bookmarked": bookmarked,
"poll": get_poll_for_post(item["uid"], user) if target_type == "post" else None,
}
+217 -1
View File
@@ -1,7 +1,7 @@
import dataset
import logging
from collections import defaultdict
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from devplacepy.cache import TTLCache
from devplacepy.config import DATABASE_URL
@@ -68,6 +68,14 @@ def init_db():
_index(db, "gists", "idx_gists_language", ["language"])
_index(db, "attachments", "idx_attachments_resource", ["resource_type", "resource_uid"])
_index(db, "attachments", "idx_attachments_target", ["target_type", "target_uid"])
_index(db, "reactions", "idx_reactions_target", ["target_type", "target_uid"])
_index(db, "reactions", "idx_reactions_user_target", ["user_uid", "target_type", "target_uid"])
_index(db, "bookmarks", "idx_bookmarks_user", ["user_uid"])
_index(db, "bookmarks", "idx_bookmarks_target", ["target_type", "target_uid"])
_index(db, "polls", "idx_polls_post", ["post_uid"])
_index(db, "poll_options", "idx_poll_options_poll", ["poll_uid"])
_index(db, "poll_votes", "idx_poll_votes_poll", ["poll_uid"])
_index(db, "poll_votes", "idx_poll_votes_user", ["poll_uid", "user_uid"])
if "site_settings" in tables:
defaults = {"site_name": "DevPlace", "site_description": "The Developer Social Network", "site_tagline": "Track industry shifts. Discover bold releases. Share what you are building in an open, uncensored environment."}
@@ -140,6 +148,21 @@ def init_db():
if not existing:
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
operational_defaults = {
"rate_limit_per_minute": "60",
"rate_limit_window_seconds": "60",
"news_service_interval": "3600",
"session_max_age_days": "7",
"session_remember_days": "30",
"registration_open": "1",
"maintenance_mode": "0",
"maintenance_message": "DevPlace is undergoing scheduled maintenance. Please check back shortly.",
}
for key, value in operational_defaults.items():
existing = db["site_settings"].find_one(key=key)
if not existing:
db["site_settings"].insert({"uid": f"default_{key}", "key": key, "value": value})
_backfill_gamification()
logger.info("Database initialized")
@@ -256,12 +279,99 @@ def get_user_votes(user_uid, target_uids):
return {r["target_uid"]: r["value"] for r in rows}
def get_reactions_by_targets(target_type, target_uids, user=None):
empty = {"counts": {}, "mine": []}
if not target_uids or "reactions" not in db.tables:
return {}
placeholders, params = _in_clause(target_uids)
params["tt"] = target_type
rows = db.query(f"SELECT target_uid, emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders}) GROUP BY target_uid, emoji", **params)
counts = defaultdict(dict)
for row in rows:
counts[row["target_uid"]][row["emoji"]] = row["c"]
mine = defaultdict(list)
if user:
my_placeholders, my_params = _in_clause(target_uids, prefix="m")
my_params["tt"] = target_type
my_params["u"] = user["uid"]
for row in db.query(f"SELECT target_uid, emoji FROM reactions WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({my_placeholders})", **my_params):
mine[row["target_uid"]].append(row["emoji"])
result = {}
for uid in target_uids:
result[uid] = {"counts": dict(counts.get(uid, {})), "mine": list(mine.get(uid, []))}
return result
def get_user_bookmarks(user_uid, target_type, target_uids):
if not user_uid or not target_uids or "bookmarks" not in db.tables:
return set()
placeholders, params = _in_clause(target_uids)
params["u"] = user_uid
params["tt"] = target_type
rows = db.query(f"SELECT target_uid FROM bookmarks WHERE user_uid=:u AND target_type=:tt AND target_uid IN ({placeholders})", **params)
return {row["target_uid"] for row in rows}
def get_polls_by_post_uids(post_uids, user=None):
if not post_uids or "polls" not in db.tables:
return {}
placeholders, params = _in_clause(post_uids)
polls = list(db.query(f"SELECT * FROM polls WHERE post_uid IN ({placeholders})", **params))
if not polls:
return {}
poll_uids = [poll["uid"] for poll in polls]
option_placeholders, option_params = _in_clause(poll_uids, prefix="o")
options = list(db.query(f"SELECT * FROM poll_options WHERE poll_uid IN ({option_placeholders}) ORDER BY position", **option_params))
counts = defaultdict(dict)
totals = defaultdict(int)
if "poll_votes" in db.tables:
vote_placeholders, vote_params = _in_clause(poll_uids, prefix="v")
for row in db.query(f"SELECT poll_uid, option_uid, COUNT(*) as c FROM poll_votes WHERE poll_uid IN ({vote_placeholders}) GROUP BY poll_uid, option_uid", **vote_params):
counts[row["poll_uid"]][row["option_uid"]] = row["c"]
totals[row["poll_uid"]] += row["c"]
my_choice = {}
if user and "poll_votes" in db.tables:
my_placeholders, my_params = _in_clause(poll_uids, prefix="m")
my_params["u"] = user["uid"]
for row in db.query(f"SELECT poll_uid, option_uid FROM poll_votes WHERE user_uid=:u AND poll_uid IN ({my_placeholders})", **my_params):
my_choice[row["poll_uid"]] = row["option_uid"]
options_by_poll = defaultdict(list)
for option in options:
options_by_poll[option["poll_uid"]].append(option)
result = {}
for poll in polls:
poll_uid = poll["uid"]
total = totals.get(poll_uid, 0)
rendered = []
for option in options_by_poll.get(poll_uid, []):
count = counts.get(poll_uid, {}).get(option["uid"], 0)
rendered.append({
"uid": option["uid"],
"label": option["label"],
"count": count,
"pct": round(count * 100 / total) if total else 0,
})
result[poll["post_uid"]] = {
"uid": poll_uid,
"question": poll["question"],
"options": rendered,
"total": total,
"my_choice": my_choice.get(poll_uid),
}
return result
def get_poll_for_post(post_uid, user=None):
return get_polls_by_post_uids([post_uid], user).get(post_uid)
def _build_comment_items(raw, user=None):
uids = [c["user_uid"] for c in raw]
cids = [c["uid"] for c in raw]
users = get_users_by_uids(uids)
ups, downs = get_vote_counts(cids)
my_votes = get_user_votes(user["uid"], cids) if user else {}
reactions = get_reactions_by_targets("comment", cids, user)
from devplacepy.utils import time_ago
from devplacepy.attachments import get_attachments_batch as _gab
atts_map = _gab("comment", cids) if "attachments" in db.tables else {}
@@ -275,6 +385,7 @@ def _build_comment_items(raw, user=None):
"my_vote": my_votes.get(c["uid"], 0),
"children": [],
"attachments": atts_map.get(c["uid"], []),
"reactions": reactions.get(c["uid"], {"counts": {}, "mine": []}),
}
return items
@@ -434,6 +545,89 @@ def get_site_stats() -> dict:
return stats
_activity_cache = TTLCache(ttl=120)
_ACTIVITY_TABLES = ("posts", "comments", "gists", "projects")
def get_activity_calendar(user_uid: str) -> dict:
cached = _activity_cache.get(user_uid)
if cached is not None:
return cached
sources = [table for table in _ACTIVITY_TABLES if table in db.tables]
calendar: dict[str, int] = {}
if sources:
cutoff = (datetime.now(timezone.utc) - timedelta(days=364)).date().isoformat()
union = " UNION ALL ".join(f"SELECT created_at FROM {table} WHERE user_uid = :u" for table in sources)
rows = db.query(
f"SELECT date(created_at) AS day, COUNT(*) AS c FROM ({union}) WHERE date(created_at) >= :cutoff GROUP BY day",
u=user_uid, cutoff=cutoff,
)
for row in rows:
if row["day"]:
calendar[row["day"]] = row["c"]
_activity_cache.set(user_uid, calendar)
return calendar
def _activity_level(count: int) -> int:
if count <= 0:
return 0
if count == 1:
return 1
if count <= 3:
return 2
if count <= 6:
return 3
return 4
def get_activity_heatmap(user_uid: str) -> list:
calendar = get_activity_calendar(user_uid)
today = datetime.now(timezone.utc).date()
start = today - timedelta(days=363)
start = start - timedelta(days=start.weekday())
weeks = []
week = []
day = start
while day <= today:
iso = day.isoformat()
count = calendar.get(iso, 0)
week.append({"date": iso, "count": count, "level": _activity_level(count)})
if len(week) == 7:
weeks.append(week)
week = []
day = day + timedelta(days=1)
if week:
weeks.append(week)
return weeks
def get_streaks(user_uid: str) -> dict:
calendar = get_activity_calendar(user_uid)
if not calendar:
return {"current": 0, "longest": 0}
dates = sorted(datetime.fromisoformat(day).date() for day in calendar)
date_set = set(dates)
longest = 1
run = 1
for index in range(1, len(dates)):
if (dates[index] - dates[index - 1]).days == 1:
run += 1
else:
run = 1
longest = max(longest, run)
today = datetime.now(timezone.utc).date()
cursor = today
if today not in date_set and (today - timedelta(days=1)) in date_set:
cursor = today - timedelta(days=1)
current = 0
while cursor in date_set:
current += 1
cursor = cursor - timedelta(days=1)
return {"current": current, "longest": longest}
_gist_languages_cache = TTLCache(ttl=60)
@@ -563,6 +757,28 @@ def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> No
_authors_cache.clear()
def delete_engagement(target_type: str, target_uids: list) -> None:
uids = [uid for uid in (target_uids or []) if uid]
if not uids:
return
if "reactions" in db.tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
db.query(f"DELETE FROM reactions WHERE target_type=:tt AND target_uid IN ({placeholders})", **params)
if "bookmarks" in db.tables:
placeholders, params = _in_clause(uids)
params["tt"] = target_type
db.query(f"DELETE FROM bookmarks WHERE target_type=:tt AND target_uid IN ({placeholders})", **params)
if target_type == "post" and "polls" in db.tables:
for uid in uids:
for poll in db["polls"].find(post_uid=uid):
if "poll_votes" in db.tables:
db["poll_votes"].delete(poll_uid=poll["uid"])
if "poll_options" in db.tables:
db["poll_options"].delete(poll_uid=poll["uid"])
db["polls"].delete(post_uid=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:
+26 -4
View File
@@ -9,11 +9,11 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.exceptions import RequestValidationError
from devplacepy.config import STATIC_DIR, PORT, SERVICE_LOCK_FILE
from devplacepy.database import init_db, get_table, db, refresh_snapshot, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids
from devplacepy.database import init_db, get_table, db, refresh_snapshot, get_users_by_uids, get_comment_counts_by_post_uids, get_vote_counts, get_news_images_by_uids, get_setting, get_int_setting
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
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, push, leaderboard
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, push, leaderboard, reactions, bookmarks, polls
from devplacepy.services.manager import service_manager
from devplacepy.services.news import NewsService
@@ -119,6 +119,9 @@ app.include_router(profile.router, prefix="/profile")
app.include_router(messages.router, prefix="/messages")
app.include_router(notifications.router, prefix="/notifications")
app.include_router(votes.router, prefix="/votes")
app.include_router(reactions.router, prefix="/reactions")
app.include_router(bookmarks.router, prefix="/bookmarks")
app.include_router(polls.router, prefix="/polls")
app.include_router(avatar.router, prefix="/avatar")
app.include_router(follow.router, prefix="/follow")
app.include_router(leaderboard.router, prefix="/leaderboard")
@@ -150,16 +153,35 @@ 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"):
limit = max(1, get_int_setting("rate_limit_per_minute", RATE_LIMIT))
window = max(1, get_int_setting("rate_limit_window_seconds", RATE_WINDOW))
ip = request.headers.get("X-Real-IP") or (request.client.host if request.client else "unknown")
now = time.time()
window_start = now - RATE_WINDOW
window_start = now - window
_rate_limit_store[ip] = [t for t in _rate_limit_store[ip] if t > window_start]
if len(_rate_limit_store[ip]) >= RATE_LIMIT:
if len(_rate_limit_store[ip]) >= limit:
return HTMLResponse("Rate limit exceeded. Try again later.", status_code=429)
_rate_limit_store[ip].append(now)
return await call_next(request)
_MAINTENANCE_ALLOWED_PREFIXES = ("/static", "/avatar", "/auth", "/admin")
@app.middleware("http")
async def maintenance_middleware(request: Request, call_next):
if get_setting("maintenance_mode", "0") != "1":
return await call_next(request)
if request.url.path.startswith(_MAINTENANCE_ALLOWED_PREFIXES):
return await call_next(request)
user = get_current_user(request)
if user and user.get("role") == "Admin":
return await call_next(request)
message = get_setting("maintenance_message", "DevPlace is undergoing scheduled maintenance. Please check back shortly.")
seo_ctx = base_seo_context(request, title="Maintenance - DevPlace", description=message, robots="noindex")
return templates.TemplateResponse(request, "error.html", {**seo_ctx, "request": request, "error_code": 503, "error_message": message}, status_code=503)
@app.on_event("startup")
async def startup():
init_db()
+26 -1
View File
@@ -1,6 +1,6 @@
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.constants import TOPICS
from devplacepy.constants import TOPICS, REACTION_EMOJI
class SignupForm(BaseModel):
@@ -65,6 +65,8 @@ class PostForm(BaseModel):
topic: str = "random"
project_uid: str = ""
attachment_uids: list[str] = []
poll_question: str = Field(default="", max_length=200)
poll_options: list[str] = []
@field_validator("topic")
@classmethod
@@ -154,6 +156,21 @@ class VoteForm(BaseModel):
return value
class ReactionForm(BaseModel):
emoji: str = Field(min_length=1, max_length=16)
@field_validator("emoji")
@classmethod
def valid_emoji(cls, value):
if value not in REACTION_EMOJI:
raise ValueError("Invalid reaction")
return value
class PollVoteForm(BaseModel):
option_uid: str = Field(min_length=1)
class AdminRoleForm(BaseModel):
role: Literal["member", "admin"]
@@ -174,3 +191,11 @@ class AdminSettingsForm(BaseModel):
max_upload_size_mb: str = Field(default="", max_length=10)
allowed_file_types: str = Field(default="", max_length=1000)
max_attachments_per_resource: str = Field(default="", max_length=10)
rate_limit_per_minute: str = Field(default="", max_length=10)
rate_limit_window_seconds: str = Field(default="", max_length=10)
news_service_interval: str = Field(default="", max_length=10)
session_max_age_days: str = Field(default="", max_length=10)
session_remember_days: str = Field(default="", max_length=10)
registration_open: str = Field(default="", max_length=1)
maintenance_mode: str = Field(default="", max_length=1)
maintenance_message: str = Field(default="", max_length=300)
+2 -1
View File
@@ -20,6 +20,7 @@ from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from devplacepy.config import (
SECONDS_PER_DAY,
VAPID_PRIVATE_KEY_FILE,
VAPID_PRIVATE_KEY_PKCS8_FILE,
VAPID_PUBLIC_KEY_FILE,
@@ -31,7 +32,7 @@ from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
JWT_LIFETIME_SECONDS = 60 * 60
PUSH_TTL_SECONDS = "86400"
PUSH_TTL_SECONDS = str(SECONDS_PER_DAY)
DEAD_SUBSCRIPTION_STATUSES = (404, 410)
ACCEPTED_STATUSES = (200, 201)
+17 -6
View File
@@ -5,11 +5,12 @@ from typing import Annotated
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table
from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.templating import templates
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user, award_badge, clear_session_cache, safe_next
from devplacepy.seo import base_seo_context
from devplacepy.models import SignupForm, LoginForm, ForgotPasswordForm, ResetPasswordForm
from devplacepy.config import SECONDS_PER_DAY
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -26,7 +27,8 @@ async def signup_page(request: Request):
description="Create your DevPlace account and start connecting with developers.",
robots="noindex,nofollow",
)
return templates.TemplateResponse(request, "signup.html", {**seo_ctx, "request": request})
registration_closed = get_setting("registration_open", "1") != "1"
return templates.TemplateResponse(request, "signup.html", {**seo_ctx, "request": request, "registration_closed": registration_closed})
@router.get("/login", response_class=HTMLResponse)
@@ -49,6 +51,13 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
email = data.email.strip().lower()
password = data.password
if get_setting("registration_open", "1") != "1":
seo_ctx = base_seo_context(request, title="Join DevPlace", robots="noindex,nofollow")
return templates.TemplateResponse(
request, "signup.html",
{**seo_ctx, "request": request, "registration_closed": True},
)
errors = []
users = get_table("users")
if users.find_one(username=username):
@@ -84,9 +93,10 @@ async def signup(request: Request, data: Annotated[SignupForm, Form()]):
award_badge(uid, "Member")
token = create_session(uid)
max_age = max(1, get_int_setting("session_max_age_days", 7)) * SECONDS_PER_DAY
token = create_session(uid, max_age)
response = RedirectResponse(url="/feed", status_code=302)
response.set_cookie(key="session", value=token, max_age=86400 * 7, httponly=True, samesite="lax")
response.set_cookie(key="session", value=token, max_age=max_age, httponly=True, samesite="lax")
logger.info(f"User {username} signed up")
return response
@@ -111,8 +121,9 @@ async def login(request: Request, data: Annotated[LoginForm, Form()]):
{**seo_ctx, "request": request, "errors": errors, "email": email, "next_url": safe_next(data.next, "")},
)
token = create_session(user["uid"])
max_age = 86400 * 30 if remember_me else 86400 * 7
days = get_int_setting("session_remember_days", 30) if remember_me else get_int_setting("session_max_age_days", 7)
max_age = max(1, days) * SECONDS_PER_DAY
token = create_session(user["uid"], max_age)
response = RedirectResponse(url=safe_next(data.next), status_code=302)
response.set_cookie(key="session", value=token, max_age=max_age, httponly=True, samesite="lax")
logger.info(f"User {user['username']} logged in")
+3 -2
View File
@@ -4,12 +4,13 @@ from fastapi import APIRouter, Request
from fastapi.responses import Response
from devplacepy.avatar import generate_avatar_svg
from devplacepy.cache import TTLCache
from devplacepy.config import SECONDS_PER_DAY
logger = logging.getLogger(__name__)
router = APIRouter()
_cache = TTLCache(ttl=86400, max_size=4096)
_CACHE_CONTROL = "public, max-age=86400, immutable"
_cache = TTLCache(ttl=SECONDS_PER_DAY, max_size=4096)
_CACHE_CONTROL = f"public, max-age={SECONDS_PER_DAY}, immutable"
@router.get("/{style}/{seed}")
+87
View File
@@ -0,0 +1,87 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse
from devplacepy.database import get_table, db, paginate, resolve_object_url
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, require_user, time_ago
from devplacepy.seo import base_seo_context
logger = logging.getLogger(__name__)
router = APIRouter()
BOOKMARKABLE: set[str] = {"post", "gist", "project", "news"}
TABLE_BY_TYPE: dict[str, str] = {"post": "posts", "gist": "gists", "project": "projects", "news": "news"}
LABEL_BY_TYPE: dict[str, str] = {"post": "Post", "gist": "Gist", "project": "Project", "news": "Article"}
@router.get("/saved", response_class=HTMLResponse)
async def saved_page(request: Request, before: str = None):
user = require_user(request)
bookmarks = get_table("bookmarks")
rows, next_cursor = paginate(bookmarks, before=before, user_uid=user["uid"])
uids_by_type: dict[str, list] = {}
for row in rows:
uids_by_type.setdefault(row["target_type"], []).append(row["target_uid"])
resolved: dict[tuple, dict] = {}
for target_type, uids in uids_by_type.items():
table_name = TABLE_BY_TYPE.get(target_type)
if not table_name or table_name not in db.tables:
continue
table = get_table(table_name)
for obj in table.find(table.table.columns.uid.in_(uids)):
resolved[(target_type, obj["uid"])] = obj
items = []
for row in rows:
obj = resolved.get((row["target_type"], row["target_uid"]))
if not obj:
continue
title = obj.get("title") or (obj.get("content", "") or "")[:80] or "Untitled"
items.append({
"target_type": row["target_type"],
"type_label": LABEL_BY_TYPE.get(row["target_type"], row["target_type"].title()),
"title": title,
"url": resolve_object_url(row["target_type"], row["target_uid"]),
"time_ago": time_ago(row["created_at"]),
})
seo_ctx = base_seo_context(request, title="Saved", description="Your saved content on DevPlace.", robots="noindex,nofollow")
return templates.TemplateResponse(request, "saved.html", {
**seo_ctx,
"request": request,
"user": user,
"items": items,
"next_cursor": next_cursor,
})
@router.post("/{target_type}/{target_uid}")
async def toggle_bookmark(request: Request, target_type: str, target_uid: str):
user = require_user(request)
if target_type not in BOOKMARKABLE:
return JSONResponse({"error": "Invalid target"}, status_code=400)
bookmarks = get_table("bookmarks")
existing = bookmarks.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
if existing:
bookmarks.delete(id=existing["id"])
saved = False
else:
bookmarks.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"target_uid": target_uid,
"target_type": target_type,
"created_at": datetime.now(timezone.utc).isoformat(),
})
saved = True
if request.headers.get("x-requested-with") == "fetch":
return JSONResponse({"saved": saved})
return RedirectResponse(url=request.headers.get("Referer", "/feed"), status_code=302)
+2 -1
View File
@@ -3,7 +3,7 @@ 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, resolve_object_url
from devplacepy.database import get_table, resolve_object_url, delete_engagement
from devplacepy.attachments import link_attachments, delete_target_attachments
from devplacepy.content import is_owner
from devplacepy.utils import generate_uid, require_user, create_mention_notifications, create_notification, award_rewards, XP_COMMENT
@@ -73,6 +73,7 @@ async def delete_comment(request: Request, comment_uid: str):
target_uid = comment.get("target_uid") or comment.get("post_uid", "")
delete_target_attachments("comment", comment_uid)
get_table("votes").delete(target_uid=comment_uid, target_type="comment")
delete_engagement("comment", [comment_uid])
comments.delete(id=comment["id"])
logger.info(f"Comment {comment_uid} deleted by {user['username']}")
redirect_url = resolve_object_url(target_type, target_uid)
+7 -1
View File
@@ -1,7 +1,7 @@
import logging
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_recent_comments_by_post_uids, get_site_stats, get_top_authors, paginate
from devplacepy.database import get_table, get_daily_topic, get_users_by_uids, get_comment_counts_by_post_uids, get_recent_comments_by_post_uids, get_site_stats, get_top_authors, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, paginate
from devplacepy.attachments import get_attachments_batch
from devplacepy.content import enrich_items
from devplacepy.templating import templates
@@ -49,10 +49,16 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None, befor
post_uids_list = [item["post"]["uid"] for item in posts]
attachments_map = get_attachments_batch("post", post_uids_list)
recent_comments = get_recent_comments_by_post_uids(post_uids_list, 3, user)
reactions_map = get_reactions_by_targets("post", post_uids_list, user)
bookmark_set = get_user_bookmarks(user["uid"], "post", post_uids_list) if user else set()
polls_map = get_polls_by_post_uids(post_uids_list, user)
for item in posts:
uid = item["post"]["uid"]
item["attachments"] = attachments_map.get(uid, [])
item["recent_comments"] = recent_comments.get(uid, [])
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
seo_ctx = list_page_seo(
request,
+3 -1
View File
@@ -6,7 +6,7 @@ from devplacepy.models import MessageForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db
from devplacepy.attachments import get_attachments_batch
from devplacepy.templating import templates, clear_unread_cache
from devplacepy.templating import templates, clear_unread_cache, clear_messages_cache
from devplacepy.utils import generate_uid, require_user, time_ago, create_mention_notifications
from devplacepy.seo import base_seo_context
@@ -71,6 +71,7 @@ def get_conversation_messages(user_uid: str, other_uid: str):
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)
clear_messages_cache(user_uid)
from devplacepy.database import get_users_by_uids
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
@@ -182,6 +183,7 @@ async def send_message(request: Request, data: Annotated[MessageForm, Form()]):
"created_at": datetime.now(timezone.utc).isoformat(),
})
clear_unread_cache(receiver_uid)
clear_messages_cache(receiver_uid)
create_mention_notifications(content, user["uid"], f"/messages?with_uid={receiver_uid}")
+3 -1
View File
@@ -2,7 +2,7 @@ import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids, paginate
from devplacepy.database import get_table, db, load_comments, resolve_by_slug, get_news_images_by_uids, get_user_bookmarks, paginate
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, time_ago, not_found
from devplacepy.content import canonical_redirect
@@ -77,6 +77,7 @@ async def news_detail_page(request: Request, news_slug: str):
canonical_slug = article.get("slug", "") or article["uid"]
comments = load_comments("news", article["uid"], user)
bookmarked = bool(user) and article["uid"] in get_user_bookmarks(user["uid"], "news", [article["uid"]])
base = site_url(request)
page_url = f"{base}/news/{canonical_slug}"
@@ -100,4 +101,5 @@ async def news_detail_page(request: Request, news_slug: str):
"grade": article.get("grade", 0),
"time_ago": time_ago(article["synced_at"]),
"comments": comments,
"bookmarked": bookmarked,
})
+19 -3
View File
@@ -1,10 +1,15 @@
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db
from devplacepy.templating import templates, clear_unread_cache
from devplacepy.utils import require_user, time_ago
from devplacepy.templating import (
templates,
clear_unread_cache,
jinja_unread_count,
jinja_unread_messages,
)
from devplacepy.utils import require_user, get_current_user, time_ago
from devplacepy.seo import base_seo_context
logger = logging.getLogger(__name__)
@@ -98,6 +103,17 @@ async def notifications_page(request: Request, before: str = None):
})
@router.get("/counts")
async def unread_counts(request: Request):
user = get_current_user(request)
if not user:
return JSONResponse({"notifications": 0, "messages": 0})
return JSONResponse({
"notifications": jinja_unread_count(user["uid"]),
"messages": jinja_unread_messages(user["uid"]),
})
@router.get("/open/{notification_uid}")
async def open_notification(request: Request, notification_uid: str):
user = require_user(request)
+44
View File
@@ -0,0 +1,44 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, get_poll_for_post
from devplacepy.utils import generate_uid, require_user
from devplacepy.models import PollVoteForm
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/{poll_uid}/vote")
async def vote_poll(request: Request, poll_uid: str, data: Annotated[PollVoteForm, Form()]):
user = require_user(request)
poll = get_table("polls").find_one(uid=poll_uid)
if not poll:
return JSONResponse({"error": "Poll not found"}, status_code=404)
option = get_table("poll_options").find_one(uid=data.option_uid, poll_uid=poll_uid)
if not option:
return JSONResponse({"error": "Invalid option"}, status_code=400)
poll_votes = get_table("poll_votes")
existing = poll_votes.find_one(poll_uid=poll_uid, user_uid=user["uid"])
if existing:
if existing["option_uid"] == data.option_uid:
poll_votes.delete(id=existing["id"])
else:
poll_votes.update({"id": existing["id"], "option_uid": data.option_uid}, ["id"])
else:
poll_votes.insert({
"uid": generate_uid(),
"poll_uid": poll_uid,
"option_uid": data.option_uid,
"user_uid": user["uid"],
"created_at": datetime.now(timezone.utc).isoformat(),
})
result = get_poll_for_post(poll["post_uid"], user)
if request.headers.get("x-requested-with") == "fetch":
return JSONResponse(result)
return RedirectResponse(url=request.headers.get("Referer", "/feed"), status_code=302)
+26 -2
View File
@@ -1,11 +1,12 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.constants import TOPICS
from devplacepy.database import db
from devplacepy.database import db, get_table
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago, not_found, XP_POST
from devplacepy.utils import get_current_user, require_user, time_ago, not_found, generate_uid, XP_POST
from devplacepy.content import load_detail, edit_content_item, delete_content_item, create_content_item, detail_context, canonical_redirect, first_image_url
from devplacepy.seo import base_seo_context, site_url, website_schema, discussion_forum_posting, truncate
from devplacepy.attachments import save_inline_image
@@ -39,9 +40,32 @@ async def create_post(request: Request, data: Annotated[PostForm, Form()]):
"project_uid": project_uid or None,
"image": image_filename,
}, slug_text, XP_POST, "First Post", content, data.attachment_uids)
create_poll(uid, user, data.poll_question, data.poll_options)
return RedirectResponse(url=f"/posts/{post_slug}", status_code=302)
def create_poll(post_uid: str, user: dict, question: str, options: list[str]) -> None:
question = question.strip()
labels = [option.strip() for option in options if option.strip()][:6]
if not question or len(labels) < 2:
return
poll_uid = generate_uid()
get_table("polls").insert({
"uid": poll_uid,
"post_uid": post_uid,
"user_uid": user["uid"],
"question": question,
"created_at": datetime.now(timezone.utc).isoformat(),
})
get_table("poll_options").insert_many([{
"uid": generate_uid(),
"poll_uid": poll_uid,
"label": label,
"position": index,
} for index, label in enumerate(labels)])
@router.get("/{post_slug}", response_class=HTMLResponse)
async def view_post(request: Request, post_slug: str):
user = get_current_user(request)
+16 -2
View File
@@ -3,7 +3,7 @@ from typing import Annotated
from fastapi import APIRouter, Request, Form
from devplacepy.models import ProfileForm
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids
from devplacepy.database import get_table, db, get_user_stars, get_user_rank, get_comment_counts_by_post_uids, get_reactions_by_targets, get_user_bookmarks, get_polls_by_post_uids, get_activity_heatmap, get_streaks
from devplacepy.content import enrich_items
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, require_user_api, time_ago, clear_user_cache, not_found
@@ -44,9 +44,18 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
if tab == "posts":
posts_table = get_table("posts")
raw_posts = list(posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"]))
counts = get_comment_counts_by_post_uids([p["uid"] for p in raw_posts]) if raw_posts else {}
post_uids = [p["uid"] for p in raw_posts]
counts = get_comment_counts_by_post_uids(post_uids) if raw_posts else {}
authors = {profile_user["uid"]: profile_user}
posts = enrich_items(raw_posts, "post", authors, {"comment_count": counts}, user=current_user)
reactions_map = get_reactions_by_targets("post", post_uids, current_user)
bookmark_set = get_user_bookmarks(current_user["uid"], "post", post_uids) if current_user else set()
polls_map = get_polls_by_post_uids(post_uids, current_user)
for item in posts:
uid = item["post"]["uid"]
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
@@ -68,6 +77,9 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
activities.append({"type": "comment", "content": c["content"][:80], "time_ago": time_ago(c["created_at"]), "created_at": c["created_at"], "uid": target_uid, "target_type": target_type})
activities.sort(key=lambda a: a["created_at"], reverse=True)
heatmap = get_activity_heatmap(profile_user["uid"])
streak = get_streaks(profile_user["uid"])
is_following = False
if current_user:
follows = get_table("follows")
@@ -108,6 +120,8 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
"is_following": is_following,
"activities": activities,
"rank": rank,
"heatmap": heatmap,
"streak": streak,
})
+46
View File
@@ -0,0 +1,46 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import RedirectResponse, JSONResponse
from devplacepy.database import get_table, db
from devplacepy.utils import generate_uid, require_user
from devplacepy.models import ReactionForm
logger = logging.getLogger(__name__)
router = APIRouter()
REACTABLE: set[str] = {"post", "comment", "gist", "project"}
@router.post("/{target_type}/{target_uid}")
async def react(request: Request, target_type: str, target_uid: str, data: Annotated[ReactionForm, Form()]):
user = require_user(request)
if target_type not in REACTABLE:
return JSONResponse({"error": "Invalid target"}, status_code=400)
emoji = data.emoji
reactions = get_table("reactions")
existing = reactions.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type, emoji=emoji)
if existing:
reactions.delete(id=existing["id"])
else:
reactions.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"target_uid": target_uid,
"target_type": target_type,
"emoji": emoji,
"created_at": datetime.now(timezone.utc).isoformat(),
})
counts = {row["emoji"]: row["c"] for row in db.query(
"SELECT emoji, COUNT(*) as c FROM reactions WHERE target_type=:tt AND target_uid=:tu GROUP BY emoji",
tt=target_type, tu=target_uid,
)}
mine = [row["emoji"] for row in reactions.find(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)]
if request.headers.get("x-requested-with") == "fetch":
return JSONResponse({"counts": counts, "mine": mine})
return RedirectResponse(url=request.headers.get("Referer", "/feed"), status_code=302)
+2 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime, timezone
import httpx
from devplacepy.database import get_table, get_setting
from devplacepy.database import get_table, get_setting, get_int_setting
from devplacepy.services.base import BaseService
from devplacepy.utils import generate_uid, make_combined_slug, strip_html
@@ -68,6 +68,7 @@ class NewsService(BaseService):
super().__init__(name="news", interval_seconds=3600)
async def run_once(self) -> None:
self.interval_seconds = max(60, get_int_setting("news_service_interval", 3600))
api_url = get_setting("news_api_url", NEWS_API_URL_DEFAULT)
ai_url = get_setting("news_ai_url", AI_URL_DEFAULT)
ai_model = get_setting("news_ai_model", AI_MODEL_DEFAULT)
-5
View File
@@ -116,7 +116,6 @@
color: var(--text-secondary);
border: none;
cursor: pointer;
transition: all 0.2s;
}
.admin-btn:hover {
@@ -239,7 +238,6 @@
background: var(--bg-card-hover);
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
padding: 0;
display: inline-flex;
align-items: center;
@@ -258,7 +256,6 @@
height: 16px;
border-radius: 50%;
background: var(--text-primary);
transition: transform 0.2s;
}
.admin-toggle-switch.active .admin-toggle-knob {
@@ -297,7 +294,6 @@
color: var(--text-secondary);
border: 1px solid var(--border);
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
user-select: none;
}
@@ -338,7 +334,6 @@
font-weight: 600;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.15s;
}
.pagination-page:hover {
+3 -7
View File
@@ -7,7 +7,6 @@
font-size: 1.125rem;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
background: none;
border: 1px solid transparent;
line-height: 1;
@@ -99,7 +98,6 @@
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.attachment-preview:hover .attachment-remove {
@@ -118,7 +116,6 @@
.attachment-progress-bar {
height: 100%;
background: var(--accent);
transition: width 0.3s;
}
.attachment-gallery {
@@ -136,7 +133,6 @@
border: 1px solid var(--border);
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
background: var(--bg-card-hover);
}
@@ -261,9 +257,9 @@
@media (hover: none) and (pointer: coarse) {
.attachment-preview .attachment-remove {
width: 28px;
height: 28px;
font-size: 0.875rem;
width: 32px;
height: 32px;
font-size: 1rem;
opacity: 1;
}
}
-1
View File
@@ -99,7 +99,6 @@
background: var(--accent);
color: #fff;
border-radius: var(--radius);
transition: all 0.2s;
}
.auth-submit:hover {
+9 -23
View File
@@ -72,7 +72,6 @@
font-weight: 600;
cursor: pointer;
background: var(--bg-card-hover);
transition: all 0.2s;
}
.topic-radio {
width: auto;
@@ -151,7 +150,6 @@
padding: 0.75rem;
width: 100%;
border-radius: var(--radius);
transition: background 0.2s;
}
.related-link:hover {
background: var(--bg-card);
@@ -345,7 +343,6 @@ body {
a {
color: var(--accent);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
@@ -369,7 +366,6 @@ input, textarea, select {
border-radius: var(--radius);
padding: 0.625rem 0.875rem;
outline: none;
transition: border-color 0.2s;
width: 100%;
}
@@ -413,7 +409,6 @@ img {
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 600;
transition: all 0.2s;
white-space: nowrap;
}
@@ -537,12 +532,11 @@ img {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s;
}
.topnav-link:hover { color: var(--text-primary); background: var(--bg-card); }
.topnav-link.active { color: var(--accent); background: var(--accent-light); }
.topnav-right { margin-left: auto; display: flex; align-items: center; gap: 1rem; flex-shrink: 0; }
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; }
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; background: none; border: none; cursor: pointer; font-family: inherit; line-height: 1; }
.topnav-icon:hover { color: var(--text-primary); }
.nav-badge {
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px;
@@ -550,7 +544,12 @@ img {
font-size: 0.6875rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.topnav-user { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem; border-radius: var(--radius); transition: background 0.2s; }
.nav-badge-inline {
position: static; margin-left: 0.375rem;
display: inline-flex; vertical-align: middle;
}
.nav-badge[hidden] { display: none; }
.topnav-user { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem; border-radius: var(--radius); }
.topnav-user:hover { background: var(--bg-card); }
.topnav-user-info { display: flex; flex-direction: column; line-height: 1.2; }
.topnav-user-name { font-size: 0.8125rem; font-weight: 600; color: var(--text-primary); }
@@ -608,7 +607,6 @@ img {
z-index: 999;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
}
.topnav-mobile-panel.open {
@@ -632,7 +630,6 @@ img {
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.15s;
}
.topnav-mobile-link:hover {
@@ -691,7 +688,7 @@ img {
.user-avatar-link { display: inline-flex; flex-shrink: 0; line-height: 0; }
.user-avatar-link:hover { opacity: 0.85; }
.user-link { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--text-primary); transition: color 0.2s; }
.user-link { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--text-primary); }
.user-link:hover { color: var(--accent); text-decoration: none; }
.user-link-name { font-weight: 600; font-size: 0.875rem; }
.user-link-role { font-size: 0.75rem; color: var(--text-muted); font-weight: 400; }
@@ -748,7 +745,6 @@ img {
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: color 0.2s;
}
.comment-form-actions button:hover {
@@ -803,15 +799,6 @@ button.comment-form-submit:hover {
border-radius: var(--radius-lg);
}
.fade-in {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.icon {
font-size: 1rem;
width: 20px;
@@ -843,7 +830,6 @@ button.comment-form-submit:hover {
padding: 0.5rem;
color: var(--text-muted);
font-size: 1.125rem;
transition: color 0.2s;
background: none;
border: none;
cursor: pointer;
@@ -880,7 +866,7 @@ button.comment-form-submit:hover {
@media (max-width: 360px) {
.page {
padding: 0.25rem;
padding: 0.5rem;
}
.card {
+318
View File
@@ -0,0 +1,318 @@
.reaction-bar {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
.reaction-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.reaction-chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 0.15rem 0.5rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.85rem;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.reaction-chip:hover {
border-color: var(--border-light);
background: var(--bg-card-hover);
}
.reaction-chip.reacted {
border-color: var(--accent);
background: var(--accent-light);
color: var(--text-primary);
}
.reaction-chip[disabled] {
cursor: default;
}
.reaction-chip[hidden] {
display: none;
}
.reaction-count {
font-variant-numeric: tabular-nums;
min-width: 0.75rem;
text-align: center;
}
.reaction-add {
position: relative;
}
.reaction-add-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 0.15rem 0.6rem;
border: 1px dashed var(--border-light);
border-radius: 999px;
background: transparent;
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.4;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.reaction-add-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
.reaction-add-icon {
font-size: 1rem;
line-height: 1;
}
.reaction-add-label {
font-weight: 500;
}
.reaction-palette {
position: absolute;
bottom: calc(100% + var(--space-xs));
left: 0;
z-index: 20;
display: flex;
gap: var(--space-xs);
padding: var(--space-sm);
background: var(--bg-modal);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.reaction-palette[hidden] {
display: none;
}
.reaction-palette-btn {
background: transparent;
border: none;
font-size: 1.15rem;
line-height: 1;
cursor: pointer;
padding: 0.15rem;
border-radius: var(--radius);
}
.reaction-palette-btn:hover {
background: var(--bg-card-hover);
}
.bookmark-btn.bookmarked {
color: var(--accent);
}
.poll {
margin: var(--space-md) 0;
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
}
.poll-question {
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-md);
}
.poll-options {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.poll-option {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text-primary);
font-size: 0.9rem;
text-align: left;
cursor: pointer;
overflow: hidden;
}
.poll-option[disabled] {
cursor: default;
}
.poll-option:not([disabled]):hover {
border-color: var(--border-light);
}
.poll-option.chosen {
border-color: var(--accent);
}
.poll-option.chosen .poll-option-label {
font-weight: 600;
}
.poll-option-bar {
position: absolute;
inset: 0;
width: 0;
background: rgba(255, 107, 53, 0.18);
transition: width 0.3s ease;
z-index: 0;
}
.poll-option.chosen .poll-option-bar {
background: rgba(255, 107, 53, 0.32);
}
.poll-option-label {
position: relative;
z-index: 1;
}
.poll-option-meta {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
white-space: nowrap;
}
.poll-option-check {
color: var(--accent);
font-weight: 700;
}
.poll-option-pct {
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.poll-total {
margin-top: var(--space-sm);
font-size: 0.8rem;
color: var(--text-muted);
}
.poll-builder {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: var(--space-lg);
}
.poll-builder[hidden] {
display: none;
}
.poll-builder-options {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.poll-builder-row {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.poll-builder-row input {
flex: 1;
}
.poll-builder-remove {
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: var(--radius);
background: transparent;
color: var(--text-muted);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.poll-builder-remove:hover {
color: var(--danger);
border-color: var(--danger);
}
.poll-builder-error {
margin: 0;
color: var(--danger);
font-size: 0.8rem;
}
.saved-page {
max-width: 760px;
margin: 0 auto;
padding: var(--space-lg);
}
.saved-title {
margin-bottom: var(--space-lg);
}
.saved-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.saved-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text-primary);
text-decoration: none;
}
.saved-item:hover {
background: var(--bg-card-hover);
}
.saved-item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.saved-item-time {
color: var(--text-muted);
font-size: 0.8rem;
}
+1 -7
View File
@@ -25,7 +25,6 @@
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
}
.feed-nav-btn:hover {
@@ -49,7 +48,6 @@
padding: 0.375rem 0.5rem;
border-radius: var(--radius);
font-size: 0.875rem;
transition: all 0.2s;
}
.feed-nav-actions button:hover {
@@ -68,7 +66,6 @@
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
transition: all 0.2s;
}
.post-card:hover {
@@ -130,7 +127,6 @@
margin-bottom: 0.5rem;
color: var(--text-primary);
line-height: 1.3;
transition: color 0.15s ease;
}
.post-content {
@@ -144,7 +140,6 @@
-webkit-box-orient: vertical;
overflow: hidden;
cursor: pointer;
transition: color 0.15s ease;
}
.post-content:hover {
@@ -154,6 +149,7 @@
.post-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
@@ -169,7 +165,6 @@
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-muted);
transition: all 0.2s;
background: none;
border: none;
cursor: pointer;
@@ -328,7 +323,6 @@
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(229, 57, 53, 0.4);
transition: all 0.2s;
z-index: 100;
border: none;
cursor: pointer;
-4
View File
@@ -33,7 +33,6 @@
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
transition: all 0.2s;
cursor: pointer;
}
@@ -97,7 +96,6 @@
gap: 0.25rem;
font-size: 0.75rem;
color: var(--text-muted);
transition: color 0.2s;
}
.gist-card-star:hover {
@@ -206,7 +204,6 @@
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
transition: all 0.2s;
}
.gist-code-header .gist-copy-btn:hover {
@@ -249,7 +246,6 @@
background: none;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.gist-star-btn:hover {
-5
View File
@@ -90,7 +90,6 @@
background: var(--accent);
color: #fff;
font-weight: 700;
transition: all 0.2s;
}
.landing-cta:hover {
@@ -164,7 +163,6 @@
font-size: 0.875rem;
font-weight: 600;
color: var(--accent);
transition: color 0.2s;
}
.landing-section-link:hover {
@@ -185,7 +183,6 @@
display: flex;
flex-direction: column;
gap: 0.625rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.landing-post-card:hover {
@@ -226,7 +223,6 @@
.landing-post-title a {
color: var(--text-primary);
transition: color 0.2s;
}
.landing-post-title a:hover {
@@ -264,7 +260,6 @@
font-weight: 600;
color: var(--accent);
margin-left: auto;
transition: color 0.2s;
}
.landing-post-open:hover {
-1
View File
@@ -32,7 +32,6 @@
cursor: pointer;
text-align: left;
font-family: inherit;
transition: background 0.1s;
}
.mention-dropdown-item:hover,
+2 -4
View File
@@ -1,6 +1,6 @@
.messages-layout {
display: grid;
grid-template-columns: 320px 1fr;
grid-template-columns: minmax(0, 320px) 1fr;
gap: 0;
height: calc(100vh - var(--nav-height) - 2rem);
background: var(--bg-card);
@@ -13,6 +13,7 @@
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-width: 0;
}
.messages-list-header {
@@ -47,7 +48,6 @@
padding: 0.625rem 1rem;
font-size: 0.875rem;
color: var(--text-primary);
transition: background 0.15s;
text-decoration: none;
}
@@ -65,7 +65,6 @@
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
transition: background 0.2s;
cursor: pointer;
}
@@ -207,7 +206,6 @@
align-items: center;
justify-content: center;
font-size: 1rem;
transition: background 0.15s;
}
.messages-send-btn:hover {
-4
View File
@@ -31,7 +31,6 @@
border: 1px solid var(--border);
border-radius: var(--radius-xl);
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
}
@@ -112,7 +111,6 @@
.news-card-title a {
color: var(--text-primary);
transition: color 0.2s;
}
.news-card-title a:hover {
@@ -136,7 +134,6 @@
font-weight: 600;
color: var(--accent);
align-self: flex-start;
transition: color 0.2s;
}
.news-read-link:hover {
@@ -247,7 +244,6 @@
background: var(--accent);
color: #fff;
border-radius: var(--radius);
transition: all 0.2s;
}
.news-detail-read-link:hover {
+7 -4
View File
@@ -1,13 +1,11 @@
.notifications-page {
max-width: 640px;
margin: 0 auto;
padding-top: var(--nav-height);
}
.notifications-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
@@ -18,6 +16,13 @@
font-weight: 700;
}
.notifications-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.notifications-list {
display: flex;
flex-direction: column;
@@ -32,7 +37,6 @@
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
transition: all 0.2s;
cursor: pointer;
}
@@ -70,7 +74,6 @@
padding: 0.25rem;
color: var(--text-muted);
font-size: 0.875rem;
transition: color 0.2s;
}
.notification-dismiss:hover {
+3 -18
View File
@@ -54,6 +54,7 @@
.post-detail-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
@@ -98,7 +99,6 @@
padding: 0.25rem;
color: var(--text-muted);
font-size: 0.8125rem;
transition: color 0.2s;
}
.comment-vote-btn.vote-up:hover {
@@ -130,22 +130,6 @@
min-width: 0;
}
.comment-highlight {
animation: comment-highlight-fade 2s ease-out;
border-radius: var(--radius);
}
@keyframes comment-highlight-fade {
from {
background: var(--accent-light);
box-shadow: 0 0 0 4px var(--accent-light);
}
to {
background: transparent;
box-shadow: 0 0 0 4px transparent;
}
}
.comment-header {
display: flex;
align-items: center;
@@ -172,6 +156,8 @@
.comment-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -180,7 +166,6 @@
color: var(--text-muted);
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
transition: all 0.2s;
}
.comment-action-btn:hover {
+63 -1
View File
@@ -95,7 +95,6 @@ a.profile-stat-value:hover {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.3s;
}
.profile-badges {
@@ -236,3 +235,66 @@ a.profile-stat-value:hover {
gap: 0.5rem;
}
}
.profile-heatmap-card {
padding: var(--space-lg);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-card);
margin-bottom: var(--space-lg);
}
.heatmap-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.heatmap-title {
font-size: 0.95rem;
margin: 0;
}
.streak-info {
font-size: 0.8rem;
color: var(--text-secondary);
}
.heatmap-grid {
display: flex;
gap: 3px;
overflow-x: auto;
padding-bottom: var(--space-xs);
}
.heatmap-week {
display: flex;
flex-direction: column;
gap: 3px;
}
.heatmap-cell {
width: 11px;
height: 11px;
border-radius: 2px;
background: var(--border);
}
.heatmap-cell.level-1 {
background: rgba(255, 107, 53, 0.35);
}
.heatmap-cell.level-2 {
background: rgba(255, 107, 53, 0.55);
}
.heatmap-cell.level-3 {
background: rgba(255, 107, 53, 0.75);
}
.heatmap-cell.level-4 {
background: var(--accent);
}
-2
View File
@@ -58,7 +58,6 @@
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
transition: border-color 0.2s;
cursor: pointer;
}
@@ -265,7 +264,6 @@
background: none;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.project-star-btn:hover {
background: var(--bg-card-hover);
-1
View File
@@ -38,7 +38,6 @@
border-radius: var(--radius);
font-size: 0.875rem;
color: var(--text-secondary);
transition: all 0.2s;
}
.sidebar-link:hover {
+8
View File
@@ -10,6 +10,10 @@ import { ContentEnhancer } from "./ContentEnhancer.js";
import { DomUtils } from "./DomUtils.js";
import { PushManager } from "./PushManager.js";
import { PwaInstaller } from "./PwaInstaller.js";
import { CounterManager } from "./CounterManager.js";
import { ReactionBar } from "./ReactionBar.js";
import { BookmarkManager } from "./BookmarkManager.js";
import { PollManager } from "./PollManager.js";
class Application {
constructor() {
@@ -25,6 +29,10 @@ class Application {
this.dom = new DomUtils();
this.push = new PushManager();
this.pwa = new PwaInstaller();
this.counters = new CounterManager();
this.reactions = new ReactionBar();
this.bookmarks = new BookmarkManager();
this.polls = new PollManager();
}
}
+13 -1
View File
@@ -122,11 +122,15 @@ export class AttachmentUploader {
preview.dataset.uid = result.uid;
if (result.thumbnail_url) {
const img = preview.querySelector("img");
if (img) img.src = result.thumbnail_url;
if (img) {
this.revokePreview(preview);
img.src = result.thumbnail_url;
}
}
}
} catch (err) {
this.showError(err.message);
this.revokePreview(preview);
preview.remove();
} finally {
this.button.classList.remove("uploading");
@@ -158,6 +162,7 @@ export class AttachmentUploader {
this.attachments.splice(idx, 1);
this.updateUids();
}
this.revokePreview(div);
div.remove();
});
div.appendChild(remove);
@@ -168,6 +173,13 @@ export class AttachmentUploader {
return div;
}
revokePreview(preview) {
const img = preview.querySelector("img");
if (img && img.src.startsWith("blob:")) {
URL.revokeObjectURL(img.src);
}
}
updateUids() {
const uids = this.attachments.map((a) => a.uid);
this.uploadedUidsInput.value = uids.join(",");
+27
View File
@@ -0,0 +1,27 @@
import { Http } from "./Http.js";
export class BookmarkManager {
constructor() {
document.addEventListener("click", (event) => this.onClick(event));
}
async onClick(event) {
const button = event.target.closest(".bookmark-btn");
if (!button) {
return;
}
event.preventDefault();
const type = button.dataset.bookmarkType;
const uid = button.dataset.bookmarkUid;
try {
const result = await Http.sendForm(`/bookmarks/${type}/${uid}`, {});
button.classList.toggle("bookmarked", result.saved);
const label = button.querySelector(".bookmark-label");
if (label) {
label.textContent = result.saved ? "Saved" : "Save";
}
} catch (error) {
console.error("bookmark failed", error);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import { Http } from "./Http.js";
export class CounterManager {
constructor() {
if (!document.querySelector("[data-counter]")) {
return;
}
this.interval = 30000;
this.poll();
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
this.poll();
}
});
setInterval(() => {
if (document.visibilityState === "visible") {
this.poll();
}
}, this.interval);
}
async poll() {
let counts;
try {
counts = await Http.getJson("/notifications/counts");
} catch (error) {
console.error("counter poll failed", error);
return;
}
this.apply("notifications", counts.notifications);
this.apply("messages", counts.messages);
}
apply(key, count) {
document.querySelectorAll(`[data-counter="${key}"]`).forEach((target) => {
const badge = target.querySelector("[data-counter-badge]");
if (!badge) {
return;
}
badge.textContent = count;
badge.hidden = count === 0;
});
}
}
+15
View File
@@ -6,6 +6,8 @@ export class DomUtils {
this.initShareButtons();
this.initTogglers();
this.initStopPropagation();
this.initReload();
this.initCardNav();
}
static onDataAttr(attr, event, handler) {
@@ -67,4 +69,17 @@ export class DomUtils {
initStopPropagation() {
DomUtils.onDataAttr("stop-propagation", "click", (el, e) => e.stopPropagation());
}
initReload() {
DomUtils.onDataAttr("reload", "click", () => window.location.reload());
}
initCardNav() {
DomUtils.onDataAttr("card-href", "click", (el, e) => {
if (e.target.closest("a, button")) {
return;
}
window.location.href = el.dataset.cardHref;
});
}
}
+3
View File
@@ -6,6 +6,9 @@ export class Http {
static async getJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`request failed with status ${response.status}`);
}
return response.json();
}
+1 -1
View File
@@ -14,7 +14,7 @@ export class ModalManager {
}
const type = input.type === "password" ? "text" : "password";
input.type = type;
btn.textContent = type === "password" ? "\u{1F441}" : "\u{1F441}";
btn.textContent = type === "password" ? "\u{1F441}" : "\u{1F648}";
});
});
}
+1 -5
View File
@@ -13,11 +13,7 @@ export class NotificationManager {
if (!target) {
return;
}
requestAnimationFrame(() => {
target.scrollIntoView({ behavior: "smooth", block: "center" });
target.classList.add("comment-highlight");
setTimeout(() => target.classList.remove("comment-highlight"), 2000);
});
target.scrollIntoView({ block: "center" });
});
}
}
+182
View File
@@ -0,0 +1,182 @@
import { Http } from "./Http.js";
export class PollManager {
constructor() {
document.addEventListener("click", (event) => this.onClick(event));
document.addEventListener("submit", (event) => this.onSubmit(event), true);
}
onClick(event) {
const option = event.target.closest(".poll-option");
if (option && !option.disabled) {
event.preventDefault();
this.vote(option);
return;
}
const toggle = event.target.closest("[data-poll-toggle]");
if (toggle) {
event.preventDefault();
this.toggleBuilder(toggle);
return;
}
const addOption = event.target.closest("[data-poll-add-option]");
if (addOption) {
event.preventDefault();
this.addOption(addOption);
return;
}
const removeOption = event.target.closest("[data-poll-remove-option]");
if (removeOption) {
event.preventDefault();
this.removeOption(removeOption);
}
}
async vote(option) {
const poll = option.closest(".poll");
if (!poll) {
return;
}
const pollUid = poll.dataset.pollUid;
const optionUid = option.dataset.optionUid;
try {
const result = await Http.sendForm(`/polls/${pollUid}/vote`, { option_uid: optionUid });
this.render(poll, result);
} catch (error) {
console.error("poll vote failed", error);
}
}
render(poll, result) {
const byUid = {};
(result.options || []).forEach((option) => {
byUid[option.uid] = option;
});
poll.querySelectorAll(".poll-option").forEach((button) => {
const data = byUid[button.dataset.optionUid];
if (!data) {
return;
}
const chosen = result.my_choice === data.uid;
button.classList.toggle("chosen", chosen);
button.setAttribute("aria-pressed", chosen ? "true" : "false");
const bar = button.querySelector(".poll-option-bar");
if (bar) {
bar.style.width = `${data.pct}%`;
}
const pct = button.querySelector(".poll-option-pct");
if (pct) {
pct.textContent = `${data.pct}%`;
}
const check = button.querySelector(".poll-option-check");
if (check) {
check.hidden = !chosen;
}
});
const total = poll.querySelector(".poll-total");
if (total) {
total.textContent = `${result.total} vote${result.total === 1 ? "" : "s"}`;
}
}
toggleBuilder(toggle) {
const form = toggle.closest("form");
const builder = form ? form.querySelector("[data-poll-builder]") : null;
if (!builder) {
return;
}
const activating = builder.hidden;
builder.hidden = !activating;
this.setPollEnabled(builder, activating);
const label = toggle.querySelector("[data-poll-toggle-label]");
if (label) {
label.textContent = activating ? "Remove poll" : "Add poll";
}
}
setPollEnabled(builder, enabled) {
builder.querySelectorAll("input[name='poll_question'], input[name='poll_options']").forEach((input) => {
input.disabled = !enabled;
if (!enabled) {
input.value = "";
}
});
const error = builder.querySelector("[data-poll-error]");
if (error) {
error.hidden = true;
error.textContent = "";
}
}
addOption(button) {
const form = button.closest("form");
const container = form ? form.querySelector("[data-poll-options]") : null;
if (!container) {
return;
}
const rows = container.querySelectorAll(".poll-builder-row");
if (rows.length >= 6) {
return;
}
const row = document.createElement("div");
row.className = "poll-builder-row";
const input = document.createElement("input");
input.type = "text";
input.name = "poll_options";
input.maxLength = 100;
input.placeholder = `Option ${rows.length + 1}`;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "poll-builder-remove";
remove.dataset.pollRemoveOption = "";
remove.setAttribute("aria-label", "Remove option");
remove.textContent = "×";
row.appendChild(input);
row.appendChild(remove);
container.appendChild(row);
}
removeOption(button) {
const container = button.closest("[data-poll-options]");
if (!container) {
return;
}
if (container.querySelectorAll(".poll-builder-row").length <= 2) {
return;
}
const row = button.closest(".poll-builder-row");
if (row) {
row.remove();
}
}
onSubmit(event) {
const form = event.target;
if (!form || typeof form.querySelector !== "function") {
return;
}
const builder = form.querySelector("[data-poll-builder]");
if (!builder || builder.hidden) {
return;
}
const question = builder.querySelector("input[name='poll_question']");
const options = [...builder.querySelectorAll("input[name='poll_options']")]
.map((input) => input.value.trim())
.filter(Boolean);
let problem = "";
if (!question || !question.value.trim()) {
problem = "Add a poll question, or remove the poll.";
} else if (options.length < 2) {
problem = "Add at least two poll options, or remove the poll.";
}
if (problem) {
event.preventDefault();
event.stopImmediatePropagation();
const error = builder.querySelector("[data-poll-error]");
if (error) {
error.textContent = problem;
error.hidden = false;
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import { Http } from "./Http.js";
export class ReactionBar {
constructor() {
document.addEventListener("click", (event) => this.onClick(event));
}
async onClick(event) {
const addButton = event.target.closest(".reaction-add-btn");
if (addButton) {
event.preventDefault();
this.togglePalette(addButton);
return;
}
const trigger = event.target.closest("[data-reaction-emoji]");
if (trigger && !trigger.disabled) {
event.preventDefault();
await this.react(trigger);
return;
}
this.closeAllPalettes();
}
togglePalette(addButton) {
const palette = addButton.parentElement.querySelector(".reaction-palette");
const isOpen = palette.hidden === false;
this.closeAllPalettes();
palette.hidden = isOpen;
}
closeAllPalettes() {
document.querySelectorAll(".reaction-palette").forEach((palette) => {
palette.hidden = true;
});
}
async react(trigger) {
const bar = trigger.closest(".reaction-bar");
if (!bar) {
return;
}
const type = bar.dataset.reactionType;
const uid = bar.dataset.reactionUid;
const emoji = trigger.dataset.reactionEmoji;
try {
const result = await Http.sendForm(`/reactions/${type}/${uid}`, { emoji });
this.render(bar, result);
} catch (error) {
console.error("reaction failed", error);
}
this.closeAllPalettes();
}
render(bar, result) {
const counts = result.counts || {};
const mine = new Set(result.mine || []);
bar.querySelectorAll(".reaction-list .reaction-chip").forEach((chip) => {
const emoji = chip.dataset.reactionEmoji;
const count = counts[emoji] || 0;
chip.querySelector(".reaction-count").textContent = count;
chip.hidden = count === 0 && !mine.has(emoji);
chip.classList.toggle("reacted", mine.has(emoji));
});
}
}
@@ -0,0 +1,5 @@
{% if user %}
<button type="button" class="post-action-btn bookmark-btn{% if _bookmarked %} bookmarked{% endif %}" data-bookmark-type="{{ _type }}" data-bookmark-uid="{{ _uid }}" title="Save" aria-label="Save">
<span class="bookmark-icon">&#x1F516;</span> <span class="bookmark-label">{% if _bookmarked %}Saved{% else %}Save{% endif %}</span>
</button>
{% endif %}
+5 -4
View File
@@ -3,12 +3,12 @@
<div class="comment-votes">
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="1">
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}">+</button>
<button type="submit" class="comment-vote-btn vote-up{% if item.my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote">+</button>
</form>
<span class="comment-vote-count" data-vote-count="{{ item.comment['uid'] }}">{{ item.votes.up - item.votes.down }}</span>
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
<input type="hidden" name="value" value="-1">
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}">-</button>
<button type="submit" class="comment-vote-btn vote-down{% if item.my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote">-</button>
</form>
</div>
@@ -26,12 +26,13 @@
{% include "_attachment_display.html" %}
{% endif %}
<div class="comment-actions">
<button class="comment-action-btn" data-action="reply"><span class="icon">&#x1F4AC;</span> Reply</button>
<button type="button" class="comment-action-btn" data-action="reply"><span class="icon">💬</span> Reply</button>
{% if user and item.comment['user_uid'] == user['uid'] %}
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" class="inline-form">
<button type="submit" class="comment-action-btn"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button>
<button type="submit" class="comment-action-btn" data-confirm="Delete this comment?"><span class="icon">🗑️</span> Delete</button>
</form>
{% endif %}
{% set _type = "comment" %}{% set _uid = item.comment['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
</div>
{% if item.children %}
+18
View File
@@ -0,0 +1,18 @@
{% if _poll %}
<div class="poll" data-poll-uid="{{ _poll.uid }}">
<div class="poll-question">{{ _poll.question }}</div>
<div class="poll-options" role="group" aria-label="Poll options">
{% for opt in _poll.options %}
<button type="button" class="poll-option{% if _poll.my_choice == opt.uid %} chosen{% endif %}" data-option-uid="{{ opt.uid }}" aria-pressed="{% if _poll.my_choice == opt.uid %}true{% else %}false{% endif %}"{% if not user %} disabled{% endif %}>
<span class="poll-option-bar" style="width: {{ opt.pct }}%;"></span>
<span class="poll-option-label">{{ opt.label }}</span>
<span class="poll-option-meta">
<span class="poll-option-check"{% if _poll.my_choice != opt.uid %} hidden{% endif %}>&#x2713;</span>
<span class="poll-option-pct">{{ opt.pct }}%</span>
</span>
</button>
{% endfor %}
</div>
<div class="poll-total">{{ _poll.total }} vote{% if _poll.total != 1 %}s{% endif %}{% if not user %} &middot; Log in to vote{% endif %}</div>
</div>
{% endif %}
+8 -2
View File
@@ -1,5 +1,5 @@
{% from "_macros.html" import content_url %}
<article class="post-card fade-in">
<article class="post-card">
{% include "_post_header.html" %}
<div class="post-topic">
@@ -12,23 +12,29 @@
</a>
{% endif %}
<div class="post-content rendered-content" data-render onclick="if (!event.target.closest('a')) window.location.href='{{ content_url(item.post, 'posts') }}'">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
<div class="post-content rendered-content" data-render data-card-href="{{ content_url(item.post, 'posts') }}">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</div>
{% if item.attachments %}
{% include "_attachment_display.html" %}
{% endif %}
{% if item.poll %}
{% set _poll = item.poll %}{% include "_poll.html" %}
{% endif %}
<div class="post-actions">
{% set _uid = item.post['uid'] %}{% set _my_vote = item.my_vote %}{% set _count = item.post.get('stars', 0) %}{% include "_post_votes.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _reactions = item.reactions %}{% include "_reaction_bar.html" %}
<a href="{{ content_url(item.post, 'posts') }}" class="post-action-btn share">
&#x2197;&#xFE0E; Open
</a>
{% if _show_share %}
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">&#x1F517; Share</button>
{% endif %}
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _bookmarked = item.bookmarked %}{% include "_bookmark_button.html" %}
</div>
{% if item.recent_comments %}
+2 -2
View File
@@ -1,11 +1,11 @@
<div class="post-votes">
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}">+</button>
<button type="submit" class="post-action-btn vote-up{% if _my_vote == 1 %} voted{% endif %}" aria-label="Upvote" title="Upvote">+</button>
</form>
<span class="post-vote-count" data-vote-count="{{ _uid }}">{{ _count }}</span>
<form method="POST" action="/votes/post/{{ _uid }}" class="inline-form">
<input type="hidden" name="value" value="-1">
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}">−</button>
<button type="submit" class="post-action-btn vote-down{% if _my_vote == -1 %} voted{% endif %}" aria-label="Downvote" title="Downvote">−</button>
</form>
</div>
+24
View File
@@ -0,0 +1,24 @@
{% set _counts = (_reactions or {}).get('counts', {}) %}
{% set _mine = (_reactions or {}).get('mine', []) %}
{% if user or _counts %}
<div class="reaction-bar" data-reaction-type="{{ _type }}" data-reaction-uid="{{ _uid }}">
<div class="reaction-list">
{% for emoji in REACTION_EMOJI %}
<button type="button" class="reaction-chip{% if emoji in _mine %} reacted{% endif %}" data-reaction-emoji="{{ emoji }}"{% if not _counts.get(emoji) and emoji not in _mine %} hidden{% endif %}{% if not user %} disabled{% endif %}>
<span class="reaction-emoji">{{ emoji }}</span>
<span class="reaction-count">{{ _counts.get(emoji, 0) }}</span>
</button>
{% endfor %}
</div>
{% if user %}
<div class="reaction-add">
<button type="button" class="reaction-add-btn" aria-label="Add reaction" title="Add reaction"><span class="reaction-add-icon">&#x1F642;</span><span class="reaction-add-label">React</span></button>
<div class="reaction-palette" hidden>
{% for emoji in REACTION_EMOJI %}
<button type="button" class="reaction-palette-btn" data-reaction-emoji="{{ emoji }}">{{ emoji }}</button>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{% endif %}
+61 -3
View File
@@ -28,7 +28,7 @@
<div class="admin-field">
<label for="news_grade_threshold">Grade Threshold (1-10)</label>
<input type="number" id="news_grade_threshold" name="news_grade_threshold" value="{{ settings.get('news_grade_threshold', '7') }}" min="1" max="10" style="width: 100px;">
<input type="number" id="news_grade_threshold" name="news_grade_threshold" value="{{ settings.get('news_grade_threshold', '7') }}" min="1" max="10" style="max-width: 100px;">
<small class="hint-text">Articles with grade >= threshold are auto-published. Below this they go to draft.</small>
</div>
@@ -59,7 +59,7 @@
<div class="admin-field">
<label for="max_upload_size_mb">Max Upload Size (MB)</label>
<input type="number" id="max_upload_size_mb" name="max_upload_size_mb" value="{{ settings.get('max_upload_size_mb', '10') }}" min="1" max="500" style="width: 120px;">
<input type="number" id="max_upload_size_mb" name="max_upload_size_mb" value="{{ settings.get('max_upload_size_mb', '10') }}" min="1" max="500" style="max-width: 120px;">
<small class="hint-text">Maximum file size per upload in megabytes.</small>
</div>
@@ -71,10 +71,68 @@
<div class="admin-field">
<label for="max_attachments_per_resource">Max Attachments Per Resource</label>
<input type="number" id="max_attachments_per_resource" name="max_attachments_per_resource" value="{{ settings.get('max_attachments_per_resource', '10') }}" min="1" max="50" style="width: 120px;">
<input type="number" id="max_attachments_per_resource" name="max_attachments_per_resource" value="{{ settings.get('max_attachments_per_resource', '10') }}" min="1" max="50" style="max-width: 120px;">
<small class="hint-text">Maximum number of files that can be attached to a single post, comment, etc.</small>
</div>
<hr style="border: none; border-top: 1px solid var(--border); margin: 1.5rem 0;">
<h3 style="font-size: 0.875rem; font-weight: 700; margin-bottom: 0.75rem; color: var(--text-secondary);">Operational</h3>
<div class="admin-field">
<label for="registration_open">Registration</label>
<select id="registration_open" name="registration_open" style="max-width: 160px;">
<option value="1" {% if settings.get('registration_open', '1') == '1' %}selected{% endif %}>Open</option>
<option value="0" {% if settings.get('registration_open', '1') != '1' %}selected{% endif %}>Closed</option>
</select>
<small class="hint-text">When closed, new account sign-ups are rejected.</small>
</div>
<div class="admin-field">
<label for="maintenance_mode">Maintenance Mode</label>
<select id="maintenance_mode" name="maintenance_mode" style="max-width: 160px;">
<option value="0" {% if settings.get('maintenance_mode', '0') != '1' %}selected{% endif %}>Disabled</option>
<option value="1" {% if settings.get('maintenance_mode', '0') == '1' %}selected{% endif %}>Enabled</option>
</select>
<small class="hint-text">When enabled, non-admin visitors see the maintenance page. Admins retain full access.</small>
</div>
<div class="admin-field">
<label for="maintenance_message">Maintenance Message</label>
<input type="text" id="maintenance_message" name="maintenance_message" value="{{ settings.get('maintenance_message', 'DevPlace is undergoing scheduled maintenance. Please check back shortly.') }}" maxlength="300">
<small class="hint-text">Shown to visitors while maintenance mode is enabled.</small>
</div>
<div class="admin-field">
<label for="rate_limit_per_minute">Rate Limit (requests)</label>
<input type="number" id="rate_limit_per_minute" name="rate_limit_per_minute" value="{{ settings.get('rate_limit_per_minute', '60') }}" min="1" style="max-width: 120px;">
<small class="hint-text">Maximum mutating requests allowed per IP within the window below.</small>
</div>
<div class="admin-field">
<label for="rate_limit_window_seconds">Rate Limit Window (seconds)</label>
<input type="number" id="rate_limit_window_seconds" name="rate_limit_window_seconds" value="{{ settings.get('rate_limit_window_seconds', '60') }}" min="1" style="max-width: 120px;">
<small class="hint-text">Rolling window over which the request limit is counted.</small>
</div>
<div class="admin-field">
<label for="news_service_interval">News Service Interval (seconds)</label>
<input type="number" id="news_service_interval" name="news_service_interval" value="{{ settings.get('news_service_interval', '3600') }}" min="60" style="max-width: 120px;">
<small class="hint-text">Delay between news fetch cycles. Minimum 60 seconds.</small>
</div>
<div class="admin-field">
<label for="session_max_age_days">Session Length (days)</label>
<input type="number" id="session_max_age_days" name="session_max_age_days" value="{{ settings.get('session_max_age_days', '7') }}" min="1" style="max-width: 120px;">
<small class="hint-text">Cookie lifetime for a standard sign-in.</small>
</div>
<div class="admin-field">
<label for="session_remember_days">Remember-Me Length (days)</label>
<input type="number" id="session_remember_days" name="session_remember_days" value="{{ settings.get('session_remember_days', '30') }}" min="1" style="max-width: 120px;">
<small class="hint-text">Cookie lifetime when "remember me" is checked at login.</small>
</div>
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4BE;</span>Save Settings</button>
</form>
{% endblock %}
+20 -19
View File
@@ -38,6 +38,7 @@
<link rel="stylesheet" href="/static/css/markdown.css">
<link rel="stylesheet" href="/static/css/mention.css">
<link rel="stylesheet" href="/static/css/attachments.css">
<link rel="stylesheet" href="/static/css/engagement.css">
{% block extra_head %}{% endblock %}
{% if page_schema %}
@@ -58,7 +59,7 @@
<a href="/projects" class="topnav-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}
<a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a>
<a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages"><span class="icon">✉️</span> Messages{% set msg_unread = get_unread_messages(user["uid"]) %}<span class="nav-badge nav-badge-inline" data-counter-badge {% if msg_unread == 0 %}hidden{% endif %}>{{ msg_unread }}</span></a>
{% if user.get('role') == 'Admin' %}
<a href="/admin" class="topnav-link {% if 'admin' in request.url.path %}active{% endif %}"><span class="icon">⚙️</span> Admin</a>
{% endif %}
@@ -67,17 +68,15 @@
<div class="topnav-right">
{% if user %}
<button type="button" class="topnav-icon" data-pwa-install hidden title="Install DevPlace app" aria-label="Install DevPlace app">
<span class="nav-bell">&#x2B07;&#xFE0F;</span>
<span class="nav-bell">⬇️</span>
</button>
<button type="button" class="topnav-icon" data-push-enable hidden title="Enable push notifications" aria-label="Enable push notifications">
<span class="nav-bell">&#x1F515;</span>
<span class="nav-bell">🔕</span>
</button>
<a href="/notifications" class="topnav-icon">
<span class="nav-bell">&#x1F514;</span>
<a href="/notifications" class="topnav-icon" data-counter="notifications" title="Notifications" aria-label="Notifications">
<span class="nav-bell">🔔</span>
{% set unread_count = get_unread_count(user["uid"]) %}
{% if unread_count > 0 %}
<span class="nav-badge">{{ unread_count }}</span>
{% endif %}
<span class="nav-badge" data-counter-badge {% if unread_count == 0 %}hidden{% endif %}>{{ unread_count }}</span>
</a>
<div class="topnav-user-dropdown">
<a href="/profile/{{ user['username'] }}" class="topnav-user">
@@ -88,6 +87,7 @@
</div>
</a>
<div class="dropdown-menu">
<a href="/bookmarks/saved" class="dropdown-item"><span class="icon">🔖</span> Saved</a>
<a href="/auth/logout" class="dropdown-item"><span class="icon">🚪</span> Logout</a>
</div>
</div>
@@ -95,7 +95,7 @@
<a href="/auth/login" class="topnav-link"><span class="icon">🔑</span> Login</a>
<a href="/auth/signup" class="btn btn-primary btn-sm"><span class="icon">✨</span>Sign Up</a>
{% endif %}
<button class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu">&#x2630;</button>
<button type="button" class="topnav-hamburger" id="hamburger-btn" aria-label="Toggle menu">☰</button>
</div>
</div>
</nav>
@@ -110,15 +110,15 @@
<a href="/projects" class="topnav-mobile-link {% if 'projects' in request.url.path %}active{% endif %}"><span class="icon">🚀</span> Projects</a>
<a href="/leaderboard" class="topnav-mobile-link {% if 'leaderboard' in request.url.path %}active{% endif %}"><span class="icon">🏆</span> Leaderboard</a>
{% if user %}
<a href="/messages" class="topnav-mobile-link {% if 'messages' in request.url.path %}active{% endif %}"><span class="icon">✉️</span> Messages</a>
<a href="/notifications" class="topnav-mobile-link {% if 'notifications' in request.url.path %}active{% endif %}"><span class="icon">&#x1F514;</span> Notifications</a>
<a href="/messages" class="topnav-mobile-link {% if 'messages' in request.url.path %}active{% endif %}" data-counter="messages"><span class="icon">✉️</span> Messages<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_messages(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_messages(user["uid"]) }}</span></a>
<a href="/notifications" class="topnav-mobile-link {% if 'notifications' in request.url.path %}active{% endif %}" data-counter="notifications"><span class="icon">🔔</span> Notifications<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_count(user["uid"]) == 0 %}hidden{% endif %}>{{ get_unread_count(user["uid"]) }}</span></a>
{% if user.get('role') == 'Admin' %}
<div class="topnav-mobile-divider"></div>
<div class="topnav-mobile-section-label">Admin</div>
<a href="/admin/users" class="topnav-mobile-link {% if 'admin/users' in request.url.path %}active{% endif %}"><span class="icon">&#x1F465;</span> Users</a>
<a href="/admin/news" class="topnav-mobile-link {% if 'admin/news' in request.url.path %}active{% endif %}"><span class="icon">&#x1F4F0;</span> News</a>
<a href="/admin/services" class="topnav-mobile-link {% if 'admin/services' in request.url.path %}active{% endif %}"><span class="icon">&#x2699;&#xFE0F;</span> Services</a>
<a href="/admin/settings" class="topnav-mobile-link {% if 'admin/settings' in request.url.path %}active{% endif %}"><span class="icon">&#x1F527;</span> Settings</a>
<a href="/admin/users" class="topnav-mobile-link {% if 'admin/users' in request.url.path %}active{% endif %}"><span class="icon">👥</span> Users</a>
<a href="/admin/news" class="topnav-mobile-link {% if 'admin/news' in request.url.path %}active{% endif %}"><span class="icon">📰</span> News</a>
<a href="/admin/services" class="topnav-mobile-link {% if 'admin/services' in request.url.path %}active{% endif %}"><span class="icon">⚙️</span> Services</a>
<a href="/admin/settings" class="topnav-mobile-link {% if 'admin/settings' in request.url.path %}active{% endif %}"><span class="icon">🔧</span> Settings</a>
{% endif %}
<div class="topnav-mobile-divider"></div>
<div class="topnav-mobile-user">
@@ -128,12 +128,13 @@
<span class="topnav-mobile-user-role">{{ user.get('role', 'Member') }}</span>
</div>
</div>
<a href="/profile/{{ user['username'] }}" class="topnav-mobile-link"><span class="icon">&#x1F464;</span> Profile</a>
<a href="/auth/logout" class="topnav-mobile-link"><span class="icon">&#x1F6AA;</span> Logout</a>
<a href="/profile/{{ user['username'] }}" class="topnav-mobile-link"><span class="icon">👤</span> Profile</a>
<a href="/bookmarks/saved" class="topnav-mobile-link {% if 'bookmarks' in request.url.path %}active{% endif %}"><span class="icon">🔖</span> Saved</a>
<a href="/auth/logout" class="topnav-mobile-link"><span class="icon">🚪</span> Logout</a>
{% else %}
<div class="topnav-mobile-divider"></div>
<a href="/auth/login" class="topnav-mobile-link"><span class="icon">&#x1F511;</span> Login</a>
<a href="/auth/signup" class="topnav-mobile-link"><span class="icon">&#x2728;</span> Sign Up</a>
<a href="/auth/login" class="topnav-mobile-link"><span class="icon">🔑</span> Login</a>
<a href="/auth/signup" class="topnav-mobile-link"><span class="icon">✨</span> Sign Up</a>
{% endif %}
</nav>
</div>
+2 -2
View File
@@ -10,13 +10,13 @@
<div class="bugs-header">
<h1>Bug Reports</h1>
{% if user %}
<button class="btn btn-primary btn-sm" data-modal="create-bug-modal"><span class="icon">&#x1F41B;</span> Report Bug</button>
<button type="button" class="btn btn-primary btn-sm" data-modal="create-bug-modal"><span class="icon">&#x1F41B;</span> Report Bug</button>
{% endif %}
</div>
<div class="bugs-list">
{% for item in bugs %}
<div class="bug-card fade-in">
<div class="bug-card">
<div class="bug-card-header">
{% set _user = item.author %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<span class="bug-title">{{ item.bug['title'] }}</span>
+15 -2
View File
@@ -49,8 +49,8 @@
<a href="/feed?tab=recent" class="feed-nav-btn {% if current_tab == 'recent' %}active{% endif %}"><span class="icon">&#x1F550;</span> Recent</a>
<a href="/feed?tab=following" class="feed-nav-btn {% if current_tab == 'following' %}active{% endif %}"><span class="icon">&#x1F465;</span> Following</a>
<div class="feed-nav-actions">
<button class="btn-ghost btn-icon" title="Refresh" onclick="window.location.reload()">&#x21BB;</button>
<button class="btn-ghost btn-icon" title="Notifications" onclick="window.location.href='/notifications'">&#x1F514;</button>
<button type="button" class="btn-ghost btn-icon" title="Refresh" aria-label="Refresh" data-reload>↻</button>
<a href="/notifications" class="btn-ghost btn-icon" title="Notifications" aria-label="Notifications">🔔</a>
</div>
</div>
@@ -149,6 +149,19 @@
<input type="file" id="post-image" name="image" accept="image/*">
</div>
<div class="auth-field auth-field-gap">
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">&#x1F4CA;</span> <span data-poll-toggle-label>Add poll</span></button>
</div>
<div class="poll-builder" data-poll-builder hidden>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" class="poll-builder-question" disabled>
<div class="poll-builder-options" data-poll-options>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" disabled></div>
</div>
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
<p class="poll-builder-error" data-poll-error hidden></p>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Post</button>
+3 -1
View File
@@ -48,12 +48,14 @@
{% if user %}
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "gist-star-btn" %}{% include "_star_vote.html" %}
{% endif %}
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
{% if is_owner %}
<button class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
<button type="button" class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
<form method="POST" action="/gists/delete/{{ gist['slug'] or gist['uid'] }}" style="display:inline;">
<button type="submit" class="gist-star-btn" data-confirm="Delete this gist?"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button>
</form>
{% endif %}
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
</div>
</article>
+2 -2
View File
@@ -50,7 +50,7 @@
<div class="gists-grid">
{% for item in gists %}
<div class="gist-card fade-in card-link-host">
<div class="gist-card card-link-host">
{% set _href = "/gists/" ~ (item.gist['slug'] or item.gist['uid']) %}
{% set _label = item.gist['title'] %}
{% include "_card_link.html" %}
@@ -85,7 +85,7 @@
</div>
{% if user %}
<button class="feed-fab" id="create-gist-btn" title="Create Gist" data-modal="create-gist-modal">+</button>
<button type="button" class="feed-fab" id="create-gist-btn" title="Create Gist" data-modal="create-gist-modal">+</button>
{% call modal('create-gist-modal', 'Create Gist', wide=true) %}
<form method="POST" action="/gists/create">
+1 -1
View File
@@ -30,7 +30,7 @@
<div class="messages-main">
{% if other_user %}
<div class="messages-main-header">
<button class="messages-back-btn" id="messages-back-btn" aria-label="Back to conversations">&#x2190;</button>
<button type="button" class="messages-back-btn" id="messages-back-btn" aria-label="Back to conversations">&#x2190;</button>
<a href="/profile/{{ other_user['username'] }}">
<img src="{{ avatar_url('multiavatar', other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}" loading="lazy">
</a>
+1
View File
@@ -40,6 +40,7 @@
Read on {{ article['source_name'] }} &#x2197;
</a>
<button type="button" class="news-detail-back" data-share="/news/{{ article['slug'] or article['uid'] }}">&#x1F517; Share</button>
{% set _type = "news" %}{% set _uid = article['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
<a href="/news" class="news-detail-back">&larr; Back to News</a>
</div>
</div>
+6 -4
View File
@@ -6,10 +6,12 @@
<div class="notifications-page">
<div class="notifications-header">
<h2>Notifications</h2>
<button type="button" class="btn btn-ghost btn-sm" data-push-enable hidden><span class="icon">&#x1F514;</span>Enable push</button>
<form method="POST" action="/notifications/mark-all-read" style="display:inline;">
<button type="submit" class="btn btn-ghost btn-sm"><span class="icon">&#x2705;</span>Clear</button>
</form>
<div class="notifications-actions">
<button type="button" class="btn btn-ghost btn-sm" data-push-enable hidden><span class="icon">🔔</span>Enable push</button>
<form method="POST" action="/notifications/mark-all-read">
<button type="submit" class="btn btn-ghost btn-sm"><span class="icon">✅</span>Clear</button>
</form>
</div>
</div>
<div class="notifications-list">
+7 -1
View File
@@ -36,11 +36,17 @@
{% include "_attachment_display.html" %}
{% endif %}
{% if poll %}
{% set _poll = poll %}{% include "_poll.html" %}
{% endif %}
<div class="post-detail-actions">
{% set _uid = post['uid'] %}{% set _my_vote = my_vote %}{% set _count = post.get('stars', 0) %}{% include "_post_votes.html" %}
{% set _type = "post" %}{% set _uid = post['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
<button type="button" class="post-action-btn" data-share="/posts/{{ post['slug'] or post['uid'] }}">&#x1F517; Share</button>
{% set _type = "post" %}{% set _uid = post['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
{% if user and post['user_uid'] == user['uid'] %}
<button class="post-action-btn" data-modal="edit-post-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
<button type="button" class="post-action-btn" data-modal="edit-post-modal"><span class="icon">&#x270F;&#xFE0F;</span>Edit</button>
<form method="POST" action="/posts/delete/{{ post['slug'] or post['uid'] }}" class="inline-form">
<button type="submit" class="post-action-btn" data-confirm="Delete this post?"><span class="icon">&#x1F5D1;&#xFE0F;</span>Delete</button>
</form>
+19 -3
View File
@@ -147,6 +147,22 @@
<div class="profile-content">
<a href="/feed" class="back-link">&larr; Back</a>
<div class="profile-heatmap-card">
<div class="heatmap-header">
<h3 class="heatmap-title">Contributions</h3>
<span class="streak-info"><span class="icon">&#x1F525;</span> {{ streak.current }} day streak &middot; Longest {{ streak.longest }}</span>
</div>
<div class="heatmap-grid">
{% for week in heatmap %}
<div class="heatmap-week">
{% for day in week %}
<span class="heatmap-cell level-{{ day.level }}" title="{{ day.count }} on {{ day.date }}"></span>
{% endfor %}
</div>
{% endfor %}
</div>
</div>
<div class="profile-tabs">
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}"><span class="icon">&#x1F4DD;</span> Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">&#x1F680;</span> Projects</a>
@@ -164,7 +180,7 @@
{% endfor %}
{% elif current_tab == 'projects' %}
{% for p in projects %}
<a href="/projects/{{ p['slug'] or p['uid'] }}" class="project-card fade-in">
<a href="/projects/{{ p['slug'] or p['uid'] }}" class="project-card">
<div class="project-card-header">
<h3 class="project-card-title">{{ p['title'] }}</h3>
</div>
@@ -189,7 +205,7 @@
{% elif current_tab == 'gists' %}
{% for entry in gists %}
{% set g = entry.gist %}
<a href="/gists/{{ g['slug'] or g['uid'] }}" class="gist-card fade-in">
<a href="/gists/{{ g['slug'] or g['uid'] }}" class="gist-card">
<div class="gist-card-header">
<h3 class="gist-card-title">{{ g['title'] }}</h3>
<span class="gist-card-star">&#x2606; {{ g.get('stars', 0) }}</span>
@@ -209,7 +225,7 @@
{% endfor %}
{% else %}
{% for act in activities %}
<div class="post-card fade-in activity-card">
<div class="post-card activity-card">
<div class="activity-row">
<span class="activity-icon">
{% if act['type'] == 'post' %}&#x1F4DD;{% else %}&#x1F4AC;{% endif %}
+2
View File
@@ -57,11 +57,13 @@
{% if user %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
{% endif %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
{% if is_owner %}
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}" style="display:inline;">
<button type="submit" class="project-star-btn" data-confirm="Delete this project?"><span class="icon">&#x1F5D1;&#xFE0F;</span> Delete</button>
</form>
{% endif %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
</div>
</article>
+2 -2
View File
@@ -54,7 +54,7 @@
<div class="projects-grid">
{% for project in projects %}
<div class="project-card fade-in card-link-host">
<div class="project-card card-link-host">
{% set _href = "/projects/" ~ (project['slug'] or project['uid']) %}
{% set _label = project['title'] %}
{% include "_card_link.html" %}
@@ -96,7 +96,7 @@
</div>
{% if user %}
<button class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal">+</button>
<button type="button" class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal">+</button>
{% call modal('create-project-modal', 'Create Project') %}
<form method="POST" action="/projects/create">
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/post.css">
{% endblock %}
{% block content %}
<div class="saved-page">
<h1 class="saved-title">Saved</h1>
<div class="saved-list">
{% for item in items %}
<a href="{{ item.url }}" class="saved-item">
<span class="saved-type badge">{{ item.type_label }}</span>
<span class="saved-item-title">{{ item.title }}</span>
<span class="saved-item-time">{{ item.time_ago }}</span>
</a>
{% else %}
<div class="empty-state">Nothing saved yet. Use the Save button on posts, gists, projects and news.</div>
{% endfor %}
</div>
{% if next_cursor %}
<div class="load-more-wrap">
<a href="/bookmarks/saved?before={{ next_cursor }}" class="btn btn-secondary">Load more</a>
</div>
{% endif %}
</div>
{% endblock %}
+14
View File
@@ -19,6 +19,19 @@
</div>
{% endif %}
{% if registration_closed %}
<div class="auth-error">
<ul>
<li>Registration is currently closed.</li>
</ul>
</div>
<div class="auth-footer">
Already have an account? <a href="/auth/login">Sign in instead</a>
</div>
</div>
</div>
{% else %}
<form class="auth-form" method="POST" action="/auth/signup">
<div class="auth-field">
<label for="username">Username</label>
@@ -55,4 +68,5 @@
</div>
</div>
</div>
{% endif %}
{% endblock %}
+19 -2
View File
@@ -1,19 +1,24 @@
from fastapi.templating import Jinja2Templates
from devplacepy.cache import TTLCache
from devplacepy.config import TEMPLATES_DIR
from devplacepy.constants import TOPICS
from devplacepy.constants import TOPICS, REACTION_EMOJI
from devplacepy.database import get_table
from devplacepy.avatar import avatar_url
from devplacepy.utils import format_date as _format_date
from devplacepy.utils import badge_info
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
_unread_cache = TTLCache(ttl=60)
_unread_cache = TTLCache(ttl=10)
_messages_cache = TTLCache(ttl=10)
def clear_unread_cache(user_uid: str) -> None:
_unread_cache.pop(user_uid)
def clear_messages_cache(user_uid: str) -> None:
_messages_cache.pop(user_uid)
def jinja_unread_count(user_uid: str) -> int:
cached = _unread_cache.get(user_uid)
if cached is not None:
@@ -23,16 +28,28 @@ def jinja_unread_count(user_uid: str) -> int:
_unread_cache.set(user_uid, count)
return count
def jinja_unread_messages(user_uid: str) -> int:
cached = _messages_cache.get(user_uid)
if cached is not None:
return cached
messages = get_table("messages")
count = messages.count(receiver_uid=user_uid, read=False)
_messages_cache.set(user_uid, count)
return count
def jinja_user_projects(user_uid: str) -> list:
projects = get_table("projects")
return list(projects.find(user_uid=user_uid))
templates.env.globals["get_unread_count"] = jinja_unread_count
templates.env.globals["get_unread_messages"] = jinja_unread_messages
templates.env.globals["get_user_projects"] = jinja_user_projects
templates.env.globals["avatar_url"] = avatar_url
templates.env.globals["format_date"] = _format_date
templates.env.globals["badge_info"] = badge_info
templates.env.globals["TOPICS"] = TOPICS
templates.env.globals["REACTION_EMOJI"] = REACTION_EMOJI
def jinja_max_upload_size_mb():
from devplacepy.database import get_int_setting
return get_int_setting("max_upload_size_mb", 10)
+7 -2
View File
@@ -21,9 +21,9 @@ def verify_password(password: str, hashed: str) -> bool:
return pbkdf2_sha256.verify(password, hashed)
def create_session(user_uid: str) -> str:
def create_session(user_uid: str, max_age_seconds: int = SESSION_MAX_AGE) -> str:
token = secrets.token_hex(32)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=SESSION_MAX_AGE)
expires_at = datetime.now(timezone.utc) + timedelta(seconds=max_age_seconds)
sessions = get_table("sessions")
sessions.insert({
"session_token": token,
@@ -242,6 +242,7 @@ BADGE_CATALOG = {
"Rising Star": {"icon": "☆", "description": "Earned 25 stars"},
"Star Author": {"icon": "★", "description": "Earned 100 stars"},
"Popular": {"icon": "◎", "description": "Reached 10 followers"},
"On Fire": {"icon": "🔥", "description": "Maintained a 7-day activity streak"},
"Level 5": {"icon": "❖", "description": "Reached level 5"},
"Level 10": {"icon": "❖", "description": "Reached level 10"},
}
@@ -296,6 +297,10 @@ def check_milestone_badges(user_uid: str) -> list:
awarded.append("Rising Star")
if "Popular" not in held and get_table("follows").count(following_uid=user_uid) >= 10 and award_badge(user_uid, "Popular"):
awarded.append("Popular")
if "On Fire" not in held:
from devplacepy.database import get_streaks
if get_streaks(user_uid)["current"] >= 7 and award_badge(user_uid, "On Fire"):
awarded.append("On Fire")
for badge_name in awarded:
notify_badge(user_uid, badge_name)
return awarded