forked from retoor/devplacepy
Pre locust
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import dataset
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from devplacepy.config import DATABASE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -35,8 +36,27 @@ def init_db():
|
||||
if "follows" in tables:
|
||||
db.query("CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows (follower_uid)")
|
||||
db.query("CREATE INDEX IF NOT EXISTS idx_follows_following ON follows (following_uid)")
|
||||
if "daily_topics" in tables:
|
||||
existing = db["daily_topics"].find_one()
|
||||
if not existing:
|
||||
db["daily_topics"].insert({
|
||||
"uid": "default",
|
||||
"title": "Anthropic details how it improved Claude's safety...",
|
||||
"summary": "New techniques in AI safety research show promising results for alignment of large language models with human values.",
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
if "password_resets" in tables:
|
||||
db.query("CREATE INDEX IF NOT EXISTS idx_password_resets_token ON password_resets (token)")
|
||||
logger.info("Database initialized")
|
||||
|
||||
|
||||
def get_table(name):
|
||||
return db[name]
|
||||
|
||||
|
||||
def get_daily_topic():
|
||||
topics = db["daily_topics"]
|
||||
topic = topics.find_one()
|
||||
if topic:
|
||||
return topic
|
||||
return {"title": "Anthropic details how it improved Claude's safety...", "summary": "New techniques in AI safety research show promising results."}
|
||||
|
||||
+5
-3
@@ -3,10 +3,10 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from devplacepy.config import STATIC_DIR
|
||||
from devplacepy.database import init_db
|
||||
from devplacepy.database import init_db, get_daily_topic
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user
|
||||
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar
|
||||
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar, follow
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -27,6 +27,7 @@ app.include_router(messages.router, prefix="/messages")
|
||||
app.include_router(notifications.router, prefix="/notifications")
|
||||
app.include_router(votes.router, prefix="/votes")
|
||||
app.include_router(avatar.router, prefix="/avatar")
|
||||
app.include_router(follow.router, prefix="/follow")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
@@ -40,4 +41,5 @@ async def landing(request: Request):
|
||||
user = get_current_user(request)
|
||||
if user:
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
return templates.TemplateResponse("landing.html", {"request": request})
|
||||
daily_topic = get_daily_topic()
|
||||
return templates.TemplateResponse("landing.html", {"request": request, "daily_topic": daily_topic})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from devplacepy.database import get_table
|
||||
@@ -79,6 +80,14 @@ async def signup(request: Request):
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
badges = get_table("badges")
|
||||
badges.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": uid,
|
||||
"badge_name": "Member",
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
token = create_session(uid)
|
||||
response = RedirectResponse(url="/feed", status_code=302)
|
||||
response.set_cookie(key="session", value=token, max_age=86400 * 7, httponly=True)
|
||||
@@ -117,6 +126,90 @@ async def login(request: Request):
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/forgot-password", response_class=HTMLResponse)
|
||||
async def forgot_password_page(request: Request):
|
||||
return templates.TemplateResponse("forgot_password.html", {"request": request})
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_password(request: Request):
|
||||
form = await request.form()
|
||||
email = form.get("email", "").strip().lower()
|
||||
errors = []
|
||||
if not email or "@" not in email:
|
||||
errors.append("Valid email is required")
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse("forgot_password.html", {
|
||||
"request": request, "errors": errors,
|
||||
})
|
||||
|
||||
users = get_table("users")
|
||||
user = users.find_one(email=email)
|
||||
if user:
|
||||
token = secrets.token_hex(32)
|
||||
resets = get_table("password_resets")
|
||||
resets.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user["uid"],
|
||||
"token": hash_password(token),
|
||||
"expires_at": (datetime.utcnow() + timedelta(hours=1)).isoformat(),
|
||||
"used": False,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
logger.info(f"Password reset requested for {email}: /auth/reset-password/{token}")
|
||||
|
||||
return templates.TemplateResponse("forgot_password.html", {
|
||||
"request": request, "sent": True,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/reset-password/{token}", response_class=HTMLResponse)
|
||||
async def reset_password_page(request: Request, token: str):
|
||||
return templates.TemplateResponse("reset_password.html", {
|
||||
"request": request, "token": token,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/reset-password/{token}")
|
||||
async def reset_password(request: Request, token: str):
|
||||
form = await request.form()
|
||||
password = form.get("password", "")
|
||||
confirm = form.get("confirm_password", "")
|
||||
|
||||
errors = []
|
||||
if len(password) < 6:
|
||||
errors.append("Password must be at least 6 characters")
|
||||
if password != confirm:
|
||||
errors.append("Passwords do not match")
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse("reset_password.html", {
|
||||
"request": request, "token": token, "errors": errors,
|
||||
})
|
||||
|
||||
resets = get_table("password_resets")
|
||||
users = get_table("users")
|
||||
all_resets = list(resets.find(used=False))
|
||||
matched = None
|
||||
for r in all_resets:
|
||||
expires = datetime.fromisoformat(r["expires_at"])
|
||||
if expires > datetime.utcnow() and verify_password(token, r["token"]):
|
||||
matched = r
|
||||
break
|
||||
|
||||
if not matched:
|
||||
errors.append("Invalid or expired reset token")
|
||||
return templates.TemplateResponse("reset_password.html", {
|
||||
"request": request, "token": token, "errors": errors,
|
||||
})
|
||||
|
||||
users.update({"uid": matched["user_uid"], "password_hash": hash_password(password)}, ["uid"])
|
||||
resets.update({"id": matched["id"], "used": True}, ["id"])
|
||||
logger.info(f"Password reset completed for user {matched['user_uid']}")
|
||||
return RedirectResponse(url="/auth/login", status_code=302)
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
token = request.cookies.get("session")
|
||||
|
||||
@@ -32,6 +32,16 @@ async def create_comment(request: Request):
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
badges = get_table("badges")
|
||||
existing = badges.find_one(user_uid=user["uid"], badge_name="First Comment")
|
||||
if not existing:
|
||||
badges.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user["uid"],
|
||||
"badge_name": "First Comment",
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
posts = get_table("posts")
|
||||
post = posts.find_one(uid=post_uid)
|
||||
if post and post["user_uid"] != user["uid"]:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.database import get_table, get_daily_topic
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.utils import get_current_user, require_user, time_ago
|
||||
|
||||
@@ -59,9 +60,12 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None):
|
||||
posts = get_feed_posts(user, tab, topic)
|
||||
users_table = get_table("users")
|
||||
total_members = len(list(users_table.all()))
|
||||
posts_today = len(list(get_table("posts").find()))
|
||||
posts_table = get_table("posts")
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
||||
posts_today = len(list(posts_table.find(created_at={">=": today_start})))
|
||||
total_projects = len(list(get_table("projects").all()))
|
||||
top_authors = list(users_table.find(order_by=["-stars"], _limit=5))
|
||||
daily_topic = get_daily_topic()
|
||||
|
||||
return templates.TemplateResponse("feed.html", {
|
||||
"request": request,
|
||||
@@ -73,4 +77,5 @@ async def feed_page(request: Request, tab: str = "all", topic: str = None):
|
||||
"posts_today": posts_today,
|
||||
"total_projects": total_projects,
|
||||
"top_authors": top_authors,
|
||||
"daily_topic": daily_topic,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.utils import generate_uid, require_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/{username}")
|
||||
async def follow_user(request: Request, username: str):
|
||||
user = require_user(request)
|
||||
users = get_table("users")
|
||||
target = users.find_one(username=username)
|
||||
if not target or target["uid"] == user["uid"]:
|
||||
return RedirectResponse(url=f"/profile/{username}", status_code=302)
|
||||
|
||||
follows = get_table("follows")
|
||||
existing = follows.find_one(follower_uid=user["uid"], following_uid=target["uid"])
|
||||
if existing:
|
||||
return RedirectResponse(url=f"/profile/{username}", status_code=302)
|
||||
|
||||
follows.insert({
|
||||
"uid": generate_uid(),
|
||||
"follower_uid": user["uid"],
|
||||
"following_uid": target["uid"],
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
notifications = get_table("notifications")
|
||||
notifications.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": target["uid"],
|
||||
"type": "follow",
|
||||
"message": f"{user['username']} started following you",
|
||||
"related_uid": user["uid"],
|
||||
"read": False,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
logger.info(f"{user['username']} followed {username}")
|
||||
return RedirectResponse(url=f"/profile/{username}", status_code=302)
|
||||
|
||||
|
||||
@router.post("/unfollow/{username}")
|
||||
async def unfollow_user(request: Request, username: str):
|
||||
user = require_user(request)
|
||||
users = get_table("users")
|
||||
target = users.find_one(username=username)
|
||||
if not target:
|
||||
return RedirectResponse(url=f"/profile/{username}", status_code=302)
|
||||
|
||||
follows = get_table("follows")
|
||||
existing = follows.find_one(follower_uid=user["uid"], following_uid=target["uid"])
|
||||
if existing:
|
||||
follows.delete(id=existing["id"])
|
||||
logger.info(f"{user['username']} unfollowed {username}")
|
||||
|
||||
return RedirectResponse(url=f"/profile/{username}", status_code=302)
|
||||
@@ -1,9 +1,13 @@
|
||||
import os
|
||||
import logging
|
||||
import aiofiles
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request, HTTPException, status
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Request, HTTPException, status, UploadFile, Form
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.config import STATIC_DIR
|
||||
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,6 +34,22 @@ async def create_post(request: Request):
|
||||
if errors:
|
||||
return RedirectResponse(url="/feed", status_code=302)
|
||||
|
||||
image_filename = None
|
||||
image_file = form.get("image")
|
||||
if image_file and hasattr(image_file, "filename") and image_file.filename:
|
||||
try:
|
||||
upload_dir = STATIC_DIR / "uploads"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = Path(image_file.filename).suffix or ".png"
|
||||
image_filename = f"{generate_uid()}{ext}"
|
||||
file_path = upload_dir / image_filename
|
||||
content_bytes = await image_file.read()
|
||||
async with aiofiles.open(str(file_path), "wb") as f:
|
||||
await f.write(content_bytes)
|
||||
logger.info(f"Image saved: {image_filename}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Image upload failed: {e}")
|
||||
|
||||
posts = get_table("posts")
|
||||
uid = generate_uid()
|
||||
posts.insert({
|
||||
@@ -39,11 +59,21 @@ async def create_post(request: Request):
|
||||
"content": content,
|
||||
"topic": topic,
|
||||
"project_uid": project_uid or None,
|
||||
"image": None,
|
||||
"image": image_filename,
|
||||
"stars": 0,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
badges = get_table("badges")
|
||||
existing = badges.find_one(user_uid=user["uid"], badge_name="First Post")
|
||||
if not existing:
|
||||
badges.insert({
|
||||
"uid": generate_uid(),
|
||||
"user_uid": user["uid"],
|
||||
"badge_name": "First Post",
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
logger.info(f"Post {uid} created by {user['username']}")
|
||||
return RedirectResponse(url=f"/posts/{uid}", status_code=302)
|
||||
|
||||
|
||||
@@ -32,6 +32,22 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
|
||||
|
||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
|
||||
posts_count = len(list(get_table("posts").find(user_uid=profile_user["uid"])))
|
||||
|
||||
activities = []
|
||||
if tab == "activity":
|
||||
posts_table = get_table("posts")
|
||||
for p in posts_table.find(user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10):
|
||||
activities.append({"type": "post", "content": p.get("title") or p["content"][:80], "time_ago": time_ago(p["created_at"]), "uid": p["uid"]})
|
||||
comments_table = get_table("comments")
|
||||
for c in comments_table.find(user_uid=profile_user["uid"], order_by=["-created_at"], _limit=10):
|
||||
activities.append({"type": "comment", "content": c["content"][:80], "time_ago": time_ago(c["created_at"]), "uid": c["post_uid"]})
|
||||
activities.sort(key=lambda a: a["time_ago"], reverse=True)
|
||||
|
||||
is_following = False
|
||||
if current_user:
|
||||
follows = get_table("follows")
|
||||
is_following = bool(follows.find_one(follower_uid=current_user["uid"], following_uid=profile_user["uid"]))
|
||||
|
||||
return templates.TemplateResponse("profile.html", {
|
||||
"request": request,
|
||||
@@ -41,6 +57,9 @@ async def profile_page(request: Request, username: str, tab: str = "posts"):
|
||||
"badges": badges,
|
||||
"projects": projects,
|
||||
"current_tab": tab,
|
||||
"posts_count": posts_count,
|
||||
"is_following": is_following,
|
||||
"activities": activities,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None):
|
||||
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None, project_type: str = None):
|
||||
projects = get_table("projects")
|
||||
users = get_table("users")
|
||||
all_projects = list(projects.all())
|
||||
@@ -18,6 +18,9 @@ def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = Non
|
||||
if user_uid:
|
||||
all_projects = [p for p in all_projects if p["user_uid"] == user_uid]
|
||||
|
||||
if project_type:
|
||||
all_projects = [p for p in all_projects if p.get("project_type") == project_type]
|
||||
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
all_projects = [
|
||||
@@ -48,9 +51,10 @@ async def projects_page(
|
||||
tab: str = "recent",
|
||||
search: str = "",
|
||||
user_uid: str = None,
|
||||
project_type: str = None,
|
||||
):
|
||||
user = get_current_user(request)
|
||||
projects = get_projects_list(tab, search, user_uid)
|
||||
projects = get_projects_list(tab, search, user_uid, project_type)
|
||||
users = get_table("users")
|
||||
total_members = len(list(users.all()))
|
||||
|
||||
|
||||
@@ -41,6 +41,9 @@ async def vote(request: Request, target_type: str, target_uid: str):
|
||||
if target_type == "post":
|
||||
posts = get_table("posts")
|
||||
posts.update({"uid": target_uid, "stars": up - down}, ["uid"])
|
||||
elif target_type == "project":
|
||||
projects = get_table("projects")
|
||||
projects.update({"uid": target_uid, "stars": up - down}, ["uid"])
|
||||
|
||||
referer = request.headers.get("Referer", "/feed")
|
||||
return RedirectResponse(url=referer, status_code=302)
|
||||
|
||||
@@ -175,6 +175,60 @@ img {
|
||||
.avatar-lg { width: 80px; height: 80px; font-size: 2rem; }
|
||||
.avatar-img { border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||
|
||||
.topnav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--nav-height);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.topnav-inner {
|
||||
max-width: var(--max-content);
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.topnav-logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.topnav-logo span { color: var(--accent); }
|
||||
.topnav-links { display: flex; gap: 0.25rem; }
|
||||
.topnav-link {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
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; }
|
||||
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
|
||||
.topnav-icon:hover { color: var(--text-primary); }
|
||||
.nav-badge {
|
||||
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px;
|
||||
padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff;
|
||||
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; }
|
||||
.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); }
|
||||
.topnav-user-role { font-size: 0.6875rem; color: var(--text-muted); }
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -1,115 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<style>
|
||||
.topnav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--nav-height);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.topnav-inner {
|
||||
max-width: var(--max-content);
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.topnav-logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.topnav-logo span {
|
||||
color: var(--accent);
|
||||
}
|
||||
.topnav-links {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.topnav-link {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
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;
|
||||
}
|
||||
.topnav-icon {
|
||||
position: relative;
|
||||
padding: 0.375rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.25rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.topnav-icon:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
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;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.topnav-user-role {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="feed-layout">
|
||||
@@ -204,8 +95,8 @@
|
||||
<aside class="feed-right">
|
||||
<div class="daily-topic-card">
|
||||
<div class="daily-topic-label">Daily Topic</div>
|
||||
<h4>Anthropic details how it improved Claude's safety...</h4>
|
||||
<p>New techniques in AI safety research show promising results.</p>
|
||||
<h4>{{ daily_topic.get('title', '') }}</h4>
|
||||
<p>{{ daily_topic.get('summary', '') }}</p>
|
||||
<div class="topic-links">
|
||||
<a href="#">Read More</a>
|
||||
<a href="#">Source</a>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/auth.css">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<a href="/auth/login" class="auth-back">← Back to Login</a>
|
||||
<h2>Reset Password</h2>
|
||||
<p class="subtitle">Enter your email and we will send you a reset link</p>
|
||||
|
||||
{% if sent %}
|
||||
<div class="auth-success" style="background: rgba(76, 175, 80, 0.1); border: 1px solid var(--success); border-radius: var(--radius); padding: 0.75rem; font-size: 0.8125rem; color: var(--success); margin-bottom: 0.5rem;">
|
||||
If an account with that email exists, a reset link has been sent. Check your email or server logs.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if errors %}
|
||||
<div class="auth-error">
|
||||
<ul>
|
||||
{% for error in errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="auth-form" method="POST" action="/auth/forgot-password">
|
||||
<div class="auth-field">
|
||||
<label for="email">Email address</label>
|
||||
<input type="email" id="email" name="email" required maxlength="255" placeholder="you@example.com">
|
||||
</div>
|
||||
<button type="submit" class="auth-submit">Send Reset Link</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
Remember your password? <a href="/auth/login">Sign in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -40,8 +40,8 @@
|
||||
|
||||
<div class="landing-daily-topic">
|
||||
<div class="landing-daily-topic-label">Daily Topic</div>
|
||||
<h3>Anthropic details how it improved Claude's safety...</h3>
|
||||
<p>New techniques in AI safety research show promising results for alignment of large language models with human values.</p>
|
||||
<h3>{{ daily_topic.get('title', '') }}</h3>
|
||||
<p>{{ daily_topic.get('summary', '') }}</p>
|
||||
<div class="landing-daily-topic-links">
|
||||
<a href="#">Read More</a>
|
||||
<a href="#">Source</a>
|
||||
|
||||
@@ -2,42 +2,6 @@
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/messages.css">
|
||||
<style>
|
||||
.topnav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--nav-height);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.topnav-inner {
|
||||
max-width: var(--max-content);
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.topnav-logo { font-size: 1.25rem; font-weight: 800; color: var(--text-primary); letter-spacing: -0.02em; }
|
||||
.topnav-logo span { color: var(--accent); }
|
||||
.topnav-links { display: flex; gap: 0.25rem; }
|
||||
.topnav-link { padding: 0.5rem 0.75rem; border-radius: var(--radius); 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; }
|
||||
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
|
||||
.topnav-icon:hover { color: var(--text-primary); }
|
||||
.nav-badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; 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; }
|
||||
.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); }
|
||||
.topnav-user-role { font-size: 0.6875rem; color: var(--text-muted); }
|
||||
.page { padding-top: 0; }
|
||||
.page > .messages-layout { margin-top: calc(var(--nav-height) + 1rem); }
|
||||
</style>
|
||||
|
||||
@@ -1,44 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/notifications.css">
|
||||
<style>
|
||||
.topnav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--nav-height);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.topnav-inner {
|
||||
max-width: var(--max-content);
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.topnav-logo { font-size: 1.25rem; font-weight: 800; color: var(--text-primary); letter-spacing: -0.02em; }
|
||||
.topnav-logo span { color: var(--accent); }
|
||||
.topnav-links { display: flex; gap: 0.25rem; }
|
||||
.topnav-link { padding: 0.5rem 0.75rem; border-radius: var(--radius); 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; }
|
||||
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
|
||||
.topnav-icon:hover { color: var(--text-primary); }
|
||||
.nav-badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; 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; }
|
||||
.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); }
|
||||
.topnav-user-role { font-size: 0.6875rem; color: var(--text-muted); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="notifications-page">
|
||||
|
||||
@@ -1,60 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
<style>
|
||||
.topnav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--nav-height);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.topnav-inner {
|
||||
max-width: var(--max-content);
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.topnav-logo {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.topnav-logo span { color: var(--accent); }
|
||||
.topnav-links { display: flex; gap: 0.25rem; }
|
||||
.topnav-link {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
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; }
|
||||
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
|
||||
.topnav-icon:hover { color: var(--text-primary); }
|
||||
.nav-badge {
|
||||
position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px;
|
||||
border-radius: 8px; background: var(--accent); color: #fff; 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; }
|
||||
.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); }
|
||||
.topnav-user-role { font-size: 0.6875rem; color: var(--text-muted); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="post-page">
|
||||
|
||||
@@ -3,42 +3,6 @@
|
||||
<link rel="stylesheet" href="/static/css/profile.css">
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
<style>
|
||||
.topnav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--nav-height);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.topnav-inner {
|
||||
max-width: var(--max-content);
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.topnav-logo { font-size: 1.25rem; font-weight: 800; color: var(--text-primary); letter-spacing: -0.02em; }
|
||||
.topnav-logo span { color: var(--accent); }
|
||||
.topnav-links { display: flex; gap: 0.25rem; }
|
||||
.topnav-link { padding: 0.5rem 0.75rem; border-radius: var(--radius); 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; }
|
||||
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
|
||||
.topnav-icon:hover { color: var(--text-primary); }
|
||||
.nav-badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; 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; }
|
||||
.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); }
|
||||
.topnav-user-role { font-size: 0.6875rem; color: var(--text-muted); }
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -56,7 +20,7 @@
|
||||
|
||||
<div class="profile-stats">
|
||||
<div class="profile-stat">
|
||||
<span class="profile-stat-value">{{ profile_user.get('posts_count', 0) }}</span>
|
||||
<span class="profile-stat-value">{{ posts_count }}</span>
|
||||
<span class="profile-stat-label">Posts</span>
|
||||
</div>
|
||||
<div class="profile-stat">
|
||||
@@ -88,7 +52,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user and user['uid'] == profile_user['uid'] %}
|
||||
{% if user and user['uid'] != profile_user['uid'] %}
|
||||
<div style="margin-top: 1rem;">
|
||||
{% if is_following %}
|
||||
<form method="POST" action="/follow/unfollow/{{ profile_user['username'] }}">
|
||||
<button type="submit" class="btn btn-secondary btn-sm" style="width: 100%;">Unfollow</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="/follow/{{ profile_user['username'] }}">
|
||||
<button type="submit" class="btn btn-primary btn-sm" style="width: 100%;">Follow</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user and user['uid'] == profile_user['uid'] %}
|
||||
<div class="profile-info">
|
||||
<form method="POST" action="/profile/update">
|
||||
<div class="profile-info-row">
|
||||
@@ -242,7 +220,22 @@
|
||||
<div class="empty-state">No projects yet.</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">Activity coming soon.</div>
|
||||
{% for act in activities %}
|
||||
<div class="post-card fade-in" style="padding: 0.75rem 1rem;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem;">
|
||||
<span style="color: var(--text-muted);">
|
||||
{% if act['type'] == 'post' %}📝{% else %}💬{% endif %}
|
||||
</span>
|
||||
<span style="color: var(--text-secondary);">
|
||||
{% if act['type'] == 'post' %}Posted{% else %}Commented{% endif %}
|
||||
</span>
|
||||
<span style="color: var(--text-muted);">{{ act['time_ago'] }}</span>
|
||||
</div>
|
||||
<p style="font-size: 0.8125rem; color: var(--text-muted); margin-top: 0.25rem;">{{ act['content'] }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">No activity yet.</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,42 +2,6 @@
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
<style>
|
||||
.topnav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--nav-height);
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.topnav-inner {
|
||||
max-width: var(--max-content);
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
.topnav-logo { font-size: 1.25rem; font-weight: 800; color: var(--text-primary); letter-spacing: -0.02em; }
|
||||
.topnav-logo span { color: var(--accent); }
|
||||
.topnav-links { display: flex; gap: 0.25rem; }
|
||||
.topnav-link { padding: 0.5rem 0.75rem; border-radius: var(--radius); 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; }
|
||||
.topnav-icon { position: relative; padding: 0.375rem; color: var(--text-secondary); font-size: 1.25rem; transition: color 0.2s; }
|
||||
.topnav-icon:hover { color: var(--text-primary); }
|
||||
.nav-badge { position: absolute; top: 0; right: 0; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: var(--accent); color: #fff; 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; }
|
||||
.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); }
|
||||
.topnav-user-role { font-size: 0.6875rem; color: var(--text-muted); }
|
||||
.modal-overlay { display: none; }
|
||||
.modal-overlay.visible { display: flex; }
|
||||
</style>
|
||||
@@ -95,7 +59,10 @@
|
||||
<div class="project-card fade-in">
|
||||
<div class="project-card-header">
|
||||
<h3 class="project-card-title">{{ project['title'] }}</h3>
|
||||
<button class="project-card-star">☆</button>
|
||||
<form method="POST" action="/votes/project/{{ project['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="project-card-star">☆</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="project-card-meta">
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/auth.css">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<a href="/auth/login" class="auth-back">← Back to Login</a>
|
||||
<h2>Set New Password</h2>
|
||||
<p class="subtitle">Choose a new password for your account</p>
|
||||
|
||||
{% if errors %}
|
||||
<div class="auth-error">
|
||||
<ul>
|
||||
{% for error in errors %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="auth-form" method="POST" action="/auth/reset-password/{{ token }}">
|
||||
<div class="auth-field">
|
||||
<label for="password">New Password</label>
|
||||
<div class="input-wrap">
|
||||
<input type="password" id="password" name="password" required maxlength="128" placeholder="At least 6 characters">
|
||||
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-field">
|
||||
<label for="confirm_password">Confirm Password</label>
|
||||
<div class="input-wrap">
|
||||
<input type="password" id="confirm_password" name="confirm_password" required maxlength="128" placeholder="Repeat your password">
|
||||
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="auth-submit">Reset Password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user