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:
@@ -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("/")
|
||||
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
|
||||
|
||||
REACTION_EMOJI = ["\U0001F44D", "❤️", "\U0001F680", "\U0001F389", "\U0001F602", "\U0001F440", "\U0001F525", "\U0001F92F"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.auth-submit:hover {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -32,7 +32,6 @@
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.mention-dropdown-item:hover,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(",");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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}";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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">🔖</span> <span class="bookmark-label">{% if _bookmarked %}Saved{% else %}Save{% endif %}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
@@ -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">💬</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">🗑️</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 %}
|
||||
|
||||
@@ -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 %}>✓</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 %} · Log in to vote{% endif %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -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">
|
||||
💬 {{ 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">
|
||||
↗︎ Open
|
||||
</a>
|
||||
{% if _show_share %}
|
||||
<button type="button" class="post-action-btn" data-share="{{ content_url(item.post, 'posts') }}">🔗 Share</button>
|
||||
{% endif %}
|
||||
{% set _type = "post" %}{% set _uid = item.post['uid'] %}{% set _bookmarked = item.bookmarked %}{% include "_bookmark_button.html" %}
|
||||
</div>
|
||||
|
||||
{% if item.recent_comments %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">🙂</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 %}
|
||||
@@ -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">💾</span>Save Settings</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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">⬇️</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">🔕</span>
|
||||
<span class="nav-bell">🔕</span>
|
||||
</button>
|
||||
<a href="/notifications" class="topnav-icon">
|
||||
<span class="nav-bell">🔔</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">☰</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">🔔</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">👥</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>
|
||||
<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">👤</span> Profile</a>
|
||||
<a href="/auth/logout" class="topnav-mobile-link"><span class="icon">🚪</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">🔑</span> Login</a>
|
||||
<a href="/auth/signup" class="topnav-mobile-link"><span class="icon">✨</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>
|
||||
|
||||
@@ -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">🐛</span> Report Bug</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" data-modal="create-bug-modal"><span class="icon">🐛</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>
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
<a href="/feed?tab=recent" class="feed-nav-btn {% if current_tab == 'recent' %}active{% endif %}"><span class="icon">🕐</span> Recent</a>
|
||||
<a href="/feed?tab=following" class="feed-nav-btn {% if current_tab == 'following' %}active{% endif %}"><span class="icon">👥</span> Following</a>
|
||||
<div class="feed-nav-actions">
|
||||
<button class="btn-ghost btn-icon" title="Refresh" onclick="window.location.reload()">↻</button>
|
||||
<button class="btn-ghost btn-icon" title="Notifications" onclick="window.location.href='/notifications'">🔔</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">📊</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>
|
||||
|
||||
@@ -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">✏️</span>Edit</button>
|
||||
<button type="button" class="gist-star-btn" data-modal="edit-gist-modal"><span class="icon">✏️</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">🗑️</span>Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% set _type = "gist" %}{% set _uid = gist['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">←</button>
|
||||
<button type="button" class="messages-back-btn" id="messages-back-btn" aria-label="Back to conversations">←</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>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
Read on {{ article['source_name'] }} ↗
|
||||
</a>
|
||||
<button type="button" class="news-detail-back" data-share="/news/{{ article['slug'] or article['uid'] }}">🔗 Share</button>
|
||||
{% set _type = "news" %}{% set _uid = article['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
|
||||
<a href="/news" class="news-detail-back">← Back to News</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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">🔔</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">✅</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">
|
||||
|
||||
@@ -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'] }}">🔗 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">✏️</span>Edit</button>
|
||||
<button type="button" class="post-action-btn" data-modal="edit-post-modal"><span class="icon">✏️</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">🗑️</span>Delete</button>
|
||||
</form>
|
||||
|
||||
@@ -147,6 +147,22 @@
|
||||
<div class="profile-content">
|
||||
<a href="/feed" class="back-link">← Back</a>
|
||||
|
||||
<div class="profile-heatmap-card">
|
||||
<div class="heatmap-header">
|
||||
<h3 class="heatmap-title">Contributions</h3>
|
||||
<span class="streak-info"><span class="icon">🔥</span> {{ streak.current }} day streak · 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">📝</span> Posts</a>
|
||||
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}"><span class="icon">🚀</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">☆ {{ 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' %}📝{% else %}💬{% endif %}
|
||||
|
||||
@@ -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">🗑️</span> Delete</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user