feat: scaffold initial DevPlace project with FastAPI, SQLite auth, and SSR routing

Add complete project skeleton including .gitignore, Makefile, AGENTS.md, config, database init with indexes, main app with router mounting for auth/feed/posts/comments/projects/profile/messages/notifications/votes, Pydantic models for signup/login/post/comment/project/message/profile, and auth router with signup/login page rendering and form handling.
This commit is contained in:
2026-05-10 07:08:12 +00:00
commit 15a3b990fe
54 changed files with 5906 additions and 0 deletions
View File
+13
View File
@@ -0,0 +1,13 @@
from pathlib import Path
from dotenv import load_dotenv
from os import environ
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent.parent
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
PORT = 10500
+42
View File
@@ -0,0 +1,42 @@
import dataset
import logging
from devplacepy.config import DATABASE_URL
logger = logging.getLogger(__name__)
db = dataset.connect(DATABASE_URL)
def init_db():
tables = db.tables
if "users" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_users_username ON users (username)")
db.query("CREATE INDEX IF NOT EXISTS idx_users_email ON users (email)")
if "posts" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_posts_user_uid ON posts (user_uid)")
db.query("CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts (created_at)")
db.query("CREATE INDEX IF NOT EXISTS idx_posts_topic ON posts (topic)")
if "comments" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_comments_post_uid ON comments (post_uid)")
db.query("CREATE INDEX IF NOT EXISTS idx_comments_user_uid ON comments (user_uid)")
if "votes" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_votes_target ON votes (target_uid, target_type)")
if "messages" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages (sender_uid)")
db.query("CREATE INDEX IF NOT EXISTS idx_messages_receiver ON messages (receiver_uid)")
if "notifications" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications (user_uid)")
if "sessions" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions (session_token)")
if "projects" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_projects_user ON projects (user_uid)")
if "badges" in tables:
db.query("CREATE INDEX IF NOT EXISTS idx_badges_user ON badges (user_uid)")
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)")
logger.info("Database initialized")
def get_table(name):
return db[name]
+42
View File
@@ -0,0 +1,42 @@
import logging
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.templating import templates
from devplacepy.utils import get_current_user
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
app = FastAPI(title="DevPlace")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(auth.router, prefix="/auth")
app.include_router(feed.router, prefix="/feed")
app.include_router(posts.router, prefix="/posts")
app.include_router(comments.router, prefix="/comments")
app.include_router(projects.router, prefix="/projects")
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.on_event("startup")
async def startup():
init_db()
logger.info("DevPlace started on port 10500")
@app.get("/")
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})
+49
View File
@@ -0,0 +1,49 @@
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import date
class SignupRequest(BaseModel):
username: str = Field(min_length=3, max_length=32, pattern=r"^[a-zA-Z0-9_-]+$")
email: str = Field(min_length=5, max_length=255)
password: str = Field(min_length=6, max_length=128)
confirm_password: str = Field(min_length=6, max_length=128)
class LoginRequest(BaseModel):
email: str = Field(min_length=5, max_length=255)
password: str = Field(min_length=6, max_length=128)
remember_me: bool = False
class PostCreate(BaseModel):
content: str = Field(min_length=1, max_length=2000)
title: Optional[str] = Field(default=None, max_length=500)
topic: str = Field(pattern=r"^(devlog|showcase|question|rant|fun|random)$")
project_uid: Optional[str] = None
class CommentCreate(BaseModel):
content: str = Field(min_length=1, max_length=1000)
parent_uid: Optional[str] = None
class ProjectCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(min_length=1, max_length=5000)
release_date: Optional[date] = None
demo_date: Optional[date] = None
project_type: str = Field(pattern=r"^(game|game_asset|software|mobile_app|website)$")
platforms: str = Field(default="", max_length=500)
class MessageCreate(BaseModel):
content: str = Field(min_length=1, max_length=2000)
receiver_uid: str
class ProfileUpdate(BaseModel):
bio: Optional[str] = Field(default=None, max_length=500)
location: Optional[str] = Field(default=None, max_length=200)
git_link: Optional[str] = Field(default=None, max_length=500)
website: Optional[str] = Field(default=None, max_length=500)
View File
+127
View File
@@ -0,0 +1,127 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Request
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import hash_password, verify_password, create_session, generate_uid, get_current_user
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/signup", response_class=HTMLResponse)
async def signup_page(request: Request):
user = get_current_user(request)
if user:
return RedirectResponse(url="/feed", status_code=302)
return templates.TemplateResponse("signup.html", {"request": request})
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
user = get_current_user(request)
if user:
return RedirectResponse(url="/feed", status_code=302)
return templates.TemplateResponse("login.html", {"request": request})
@router.post("/signup")
async def signup(request: Request):
form = await request.form()
username = form.get("username", "").strip()
email = form.get("email", "").strip().lower()
password = form.get("password", "")
confirm_password = form.get("confirm_password", "")
errors = []
if len(username) < 3 or len(username) > 32:
errors.append("Username must be between 3 and 32 characters")
if not username.isascii() or not all(c.isalnum() or c in ("-", "_") for c in username):
errors.append("Username can only contain letters, numbers, hyphens, and underscores")
if not email or "@" not in email or len(email) > 255:
errors.append("Valid email is required")
if len(password) < 6:
errors.append("Password must be at least 6 characters")
if password != confirm_password:
errors.append("Passwords do not match")
users = get_table("users")
if users.find_one(username=username):
errors.append("Username already taken")
if users.find_one(email=email):
errors.append("Email already registered")
if errors:
return templates.TemplateResponse(
"signup.html",
{"request": request, "errors": errors, "username": username, "email": email},
)
uid = generate_uid()
users.insert({
"uid": uid,
"username": username,
"email": email,
"password_hash": hash_password(password),
"bio": "",
"location": "",
"git_link": "",
"website": "",
"avatar": "",
"role": "Member",
"level": 1,
"xp": 0,
"stars": 0,
"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)
logger.info(f"User {username} signed up")
return response
@router.post("/login")
async def login(request: Request):
form = await request.form()
email = form.get("email", "").strip().lower()
password = form.get("password", "")
remember_me = form.get("remember_me") == "on"
errors = []
if not email or not password:
errors.append("Email and password are required")
users = get_table("users")
user = users.find_one(email=email) if not errors else None
if not user or not verify_password(password, user["password_hash"]):
errors.append("Invalid email or password")
if errors:
return templates.TemplateResponse(
"login.html",
{"request": request, "errors": errors, "email": email},
)
token = create_session(user["uid"])
max_age = 86400 * 30 if remember_me else 86400 * 7
response = RedirectResponse(url="/feed", status_code=302)
response.set_cookie(key="session", value=token, max_age=max_age, httponly=True)
logger.info(f"User {user['username']} logged in")
return response
@router.get("/logout")
async def logout(request: Request):
token = request.cookies.get("session")
if token:
sessions = get_table("sessions")
session = sessions.find_one(session_token=token)
if session:
sessions.delete(id=session["id"])
response = RedirectResponse(url="/", status_code=302)
response.delete_cookie("session")
return response
+60
View File
@@ -0,0 +1,60 @@
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("/create")
async def create_comment(request: Request):
user = require_user(request)
form = await request.form()
content = form.get("content", "").strip()
post_uid = form.get("post_uid", "")
parent_uid = form.get("parent_uid", "")
if not content or not post_uid:
return RedirectResponse(url="/feed", status_code=302)
if len(content) > 1000:
return RedirectResponse(url=f"/posts/{post_uid}", status_code=302)
comments = get_table("comments")
comments.insert({
"uid": generate_uid(),
"post_uid": post_uid,
"user_uid": user["uid"],
"content": content,
"parent_uid": parent_uid or None,
"created_at": datetime.utcnow().isoformat(),
})
posts = get_table("posts")
post = posts.find_one(uid=post_uid)
if post and post["user_uid"] != user["uid"]:
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": post["user_uid"],
"type": "comment",
"message": f"{user['username']} commented on your post",
"related_uid": post_uid,
"read": False,
"created_at": datetime.utcnow().isoformat(),
})
logger.info(f"Comment by {user['username']} on post {post_uid}")
return RedirectResponse(url=f"/posts/{post_uid}", status_code=302)
@router.post("/delete/{comment_uid}")
async def delete_comment(request: Request, comment_uid: str):
user = require_user(request)
comments = get_table("comments")
comment = comments.find_one(uid=comment_uid)
if comment and comment["user_uid"] == user["uid"]:
comments.delete(id=comment["id"])
return RedirectResponse(url=f"/posts/{comment['post_uid']}", status_code=302)
+76
View File
@@ -0,0 +1,76 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago
logger = logging.getLogger(__name__)
router = APIRouter()
def get_feed_posts(user, tab: str = "all", topic: str = None):
posts_table = get_table("posts")
users_table = get_table("users")
query = "1=1"
params = {}
if topic:
query += " AND topic = :topic"
params["topic"] = topic
if tab == "following":
follows = get_table("follows")
following = [f["following_uid"] for f in follows.find(follower_uid=user["uid"])]
if following:
placeholders = ",".join(f":uid_{i}" for i in range(len(following)))
query += f" AND user_uid IN ({placeholders})"
for i, uid in enumerate(following):
params[f"uid_{i}"] = uid
else:
return []
order = "created_at DESC"
if tab == "trending":
order = "stars DESC, created_at DESC"
posts = list(posts_table.find(**params, _limit=50))
posts.sort(key=lambda p: p.get("created_at", ""), reverse=True)
if tab == "trending":
posts.sort(key=lambda p: int(p.get("stars", 0)), reverse=True)
result = []
for post in posts:
author = users_table.find_one(uid=post["user_uid"])
comment_count = len(list(get_table("comments").find(post_uid=post["uid"])))
result.append({
"post": post,
"author": author,
"time_ago": time_ago(post["created_at"]),
"comment_count": comment_count,
})
return result
@router.get("", response_class=HTMLResponse)
async def feed_page(request: Request, tab: str = "all", topic: str = None):
user = require_user(request)
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()))
total_projects = len(list(get_table("projects").all()))
top_authors = list(users_table.find(order_by=["-stars"], _limit=5))
return templates.TemplateResponse("feed.html", {
"request": request,
"user": user,
"posts": posts,
"current_tab": tab,
"current_topic": topic,
"total_members": total_members,
"posts_today": posts_today,
"total_projects": total_projects,
"top_authors": top_authors,
})
+128
View File
@@ -0,0 +1,128 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago
logger = logging.getLogger(__name__)
router = APIRouter()
def get_conversations(user_uid: str):
messages_table = get_table("messages")
users_table = get_table("users")
all_messages = list(messages_table.find(
"sender_uid = :uid OR receiver_uid = :uid",
{"uid": user_uid},
))
conversation_map = {}
for msg in all_messages:
other_uid = msg["receiver_uid"] if msg["sender_uid"] == user_uid else msg["sender_uid"]
if other_uid not in conversation_map or msg["created_at"] > conversation_map[other_uid]["last_message_at"]:
other_user = users_table.find_one(uid=other_uid)
conversation_map[other_uid] = {
"other_user": other_user,
"last_message": msg["content"],
"last_message_at": msg["created_at"],
"unread": msg["receiver_uid"] == user_uid and not msg["read"],
}
conversations = sorted(
conversation_map.values(),
key=lambda c: c["last_message_at"],
reverse=True,
)
return conversations
def get_conversation_messages(user_uid: str, other_uid: str):
messages_table = get_table("messages")
msgs = list(messages_table.find(
"(sender_uid = :me AND receiver_uid = :other) OR (sender_uid = :other AND receiver_uid = :me)",
{"me": user_uid, "other": other_uid},
))
msgs.sort(key=lambda m: m["created_at"])
for msg in msgs:
if msg["receiver_uid"] == user_uid and not msg["read"]:
messages_table.update({"id": msg["id"], "read": True}, ["id"])
users_table = get_table("users")
other_user = users_table.find_one(uid=other_uid)
result = []
for m in msgs:
sender = users_table.find_one(uid=m["sender_uid"])
result.append({
"message": m,
"sender": sender,
"is_mine": m["sender_uid"] == user_uid,
"time_ago": time_ago(m["created_at"]),
})
return result, other_user
@router.get("", response_class=HTMLResponse)
async def messages_page(request: Request, with_uid: str = None, search: str = ""):
user = require_user(request)
conversations = get_conversations(user["uid"])
if search:
users_table = get_table("users")
found = users_table.find_one(username=search)
if found:
with_uid = found["uid"]
current_conversation = None
messages = []
other_user = None
if with_uid:
messages, other_user = get_conversation_messages(user["uid"], with_uid)
current_conversation = with_uid
return templates.TemplateResponse("messages.html", {
"request": request,
"user": user,
"conversations": conversations,
"messages": messages,
"other_user": other_user,
"current_conversation": current_conversation,
"search": search,
})
@router.post("/send")
async def send_message(request: Request):
user = require_user(request)
form = await request.form()
content = form.get("content", "").strip()
receiver_uid = form.get("receiver_uid", "")
if content and receiver_uid:
messages_table = get_table("messages")
messages_table.insert({
"uid": generate_uid(),
"sender_uid": user["uid"],
"receiver_uid": receiver_uid,
"content": content,
"read": False,
"created_at": datetime.utcnow().isoformat(),
})
notifications = get_table("notifications")
notifications.insert({
"uid": generate_uid(),
"user_uid": receiver_uid,
"type": "message",
"message": f"{user['username']} sent you a message",
"related_uid": user["uid"],
"read": False,
"created_at": datetime.utcnow().isoformat(),
})
logger.info(f"Message from {user['username']} to {receiver_uid}")
return RedirectResponse(url=f"/messages?with_uid={receiver_uid}", status_code=302)
+53
View File
@@ -0,0 +1,53 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("", response_class=HTMLResponse)
async def notifications_page(request: Request):
user = require_user(request)
notifications_table = get_table("notifications")
raw_notifications = list(
notifications_table.find(user_uid=user["uid"], order_by=["-created_at"])
)
users_table = get_table("users")
notifications = []
for n in raw_notifications:
actor = users_table.find_one(uid=n.get("related_uid")) if n.get("related_uid") else None
notifications.append({
"notification": n,
"actor": actor,
"time_ago": time_ago(n["created_at"]),
})
return templates.TemplateResponse("notifications.html", {
"request": request,
"user": user,
"notifications": notifications,
})
@router.post("/mark-read/{notification_uid}")
async def mark_read(request: Request, notification_uid: str):
user = require_user(request)
notifications_table = get_table("notifications")
n = notifications_table.find_one(uid=notification_uid)
if n and n["user_uid"] == user["uid"]:
notifications_table.update({"id": n["id"], "read": True}, ["id"])
return RedirectResponse(url="/notifications", status_code=302)
@router.post("/mark-all-read")
async def mark_all_read(request: Request):
user = require_user(request)
notifications_table = get_table("notifications")
for n in notifications_table.find(user_uid=user["uid"], read=False):
notifications_table.update({"id": n["id"], "read": True}, ["id"])
return RedirectResponse(url="/notifications", status_code=302)
+84
View File
@@ -0,0 +1,84 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Request, HTTPException, status
from fastapi.responses import RedirectResponse, HTMLResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user, time_ago
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/create")
async def create_post(request: Request):
user = require_user(request)
form = await request.form()
content = form.get("content", "").strip()
title = form.get("title", "").strip()
topic = form.get("topic", "random")
project_uid = form.get("project_uid", "")
errors = []
if not content:
errors.append("Content is required")
if len(content) > 2000:
errors.append("Content too long (max 2000 characters)")
if topic not in ("devlog", "showcase", "question", "rant", "fun", "random"):
topic = "random"
if errors:
return RedirectResponse(url="/feed", status_code=302)
posts = get_table("posts")
uid = generate_uid()
posts.insert({
"uid": uid,
"user_uid": user["uid"],
"title": title or None,
"content": content,
"topic": topic,
"project_uid": project_uid or None,
"image": None,
"stars": 0,
"created_at": datetime.utcnow().isoformat(),
})
logger.info(f"Post {uid} created by {user['username']}")
return RedirectResponse(url=f"/posts/{uid}", status_code=302)
@router.get("/{post_uid}", response_class=HTMLResponse)
async def view_post(request: Request, post_uid: str):
user = get_current_user(request)
posts = get_table("posts")
post = posts.find_one(uid=post_uid)
if not post:
raise HTTPException(status_code=404, detail="Post not found")
users_table = get_table("users")
author = users_table.find_one(uid=post["user_uid"])
comments_table = get_table("comments")
raw_comments = list(comments_table.find(post_uid=post_uid, order_by=["created_at"]))
comments = []
for c in raw_comments:
commenter = users_table.find_one(uid=c["user_uid"])
comments.append({
"comment": c,
"author": commenter,
"time_ago": time_ago(c["created_at"]),
"votes": {
"up": len(list(get_table("votes").find(target_uid=c["uid"], value=1))),
"down": len(list(get_table("votes").find(target_uid=c["uid"], value=-1))),
},
})
return templates.TemplateResponse("post.html", {
"request": request,
"user": user,
"post": post,
"author": author,
"comments": comments,
"time_ago": time_ago(post["created_at"]),
})
+66
View File
@@ -0,0 +1,66 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import get_current_user, require_user, time_ago
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/{username}", response_class=HTMLResponse)
async def profile_page(request: Request, username: str, tab: str = "posts"):
current_user = get_current_user(request)
users = get_table("users")
profile_user = users.find_one(username=username)
if not profile_user:
return RedirectResponse(url="/feed", status_code=302)
posts = []
if tab == "posts":
posts_table = get_table("posts")
raw_posts = list(posts_table.find(user_uid=profile_user["uid"]))
for p in raw_posts:
comments_count = len(list(get_table("comments").find(post_uid=p["uid"])))
posts.append({
"post": p,
"time_ago": time_ago(p["created_at"]),
"comment_count": comments_count,
})
posts.sort(key=lambda x: x["post"]["created_at"], reverse=True)
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
return templates.TemplateResponse("profile.html", {
"request": request,
"user": current_user,
"profile_user": profile_user,
"posts": posts,
"badges": badges,
"projects": projects,
"current_tab": tab,
})
@router.post("/update")
async def update_profile(request: Request):
user = require_user(request)
form = await request.form()
bio = form.get("bio", "").strip()
location = form.get("location", "").strip()
git_link = form.get("git_link", "").strip()
website = form.get("website", "").strip()
users = get_table("users")
users.update({
"uid": user["uid"],
"bio": bio,
"location": location,
"git_link": git_link,
"website": website,
}, ["uid"])
logger.info(f"Profile updated for {user['username']}")
return RedirectResponse(url=f"/profile/{user['username']}", status_code=302)
+100
View File
@@ -0,0 +1,100 @@
import logging
from datetime import datetime
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from devplacepy.database import get_table
from devplacepy.templating import templates
from devplacepy.utils import generate_uid, get_current_user, require_user
logger = logging.getLogger(__name__)
router = APIRouter()
def get_projects_list(tab: str = "recent", search: str = "", user_uid: str = None):
projects = get_table("projects")
users = get_table("users")
all_projects = list(projects.all())
if user_uid:
all_projects = [p for p in all_projects if p["user_uid"] == user_uid]
if search:
search_lower = search.lower()
all_projects = [
p for p in all_projects
if search_lower in p.get("title", "").lower()
or search_lower in p.get("description", "").lower()
]
for p in all_projects:
author = users.find_one(uid=p["user_uid"])
p["author_name"] = author["username"] if author else "Unknown"
if tab == "released":
all_projects = [p for p in all_projects if p.get("status") == "Released"]
elif tab == "popular":
all_projects.sort(key=lambda p: int(p.get("stars", 0)), reverse=True)
elif tab == "new":
all_projects.sort(key=lambda p: p.get("created_at", ""), reverse=True)
else:
all_projects.sort(key=lambda p: p.get("created_at", ""), reverse=True)
return all_projects
@router.get("", response_class=HTMLResponse)
async def projects_page(
request: Request,
tab: str = "recent",
search: str = "",
user_uid: str = None,
):
user = get_current_user(request)
projects = get_projects_list(tab, search, user_uid)
users = get_table("users")
total_members = len(list(users.all()))
return templates.TemplateResponse("projects.html", {
"request": request,
"user": user,
"projects": projects,
"current_tab": tab,
"search": search,
"total_count": len(projects),
"total_members": total_members,
})
@router.post("/create")
async def create_project(request: Request):
user = require_user(request)
form = await request.form()
title = form.get("title", "").strip()
description = form.get("description", "").strip()
release_date = form.get("release_date", "")
demo_date = form.get("demo_date", "")
project_type = form.get("project_type", "software")
platforms = form.get("platforms", "").strip()
status = form.get("status", "In Development")
if not title or not description:
return RedirectResponse(url="/projects", status_code=302)
projects = get_table("projects")
uid = generate_uid()
projects.insert({
"uid": uid,
"user_uid": user["uid"],
"title": title,
"description": description,
"release_date": release_date or None,
"demo_date": demo_date or None,
"project_type": project_type,
"platforms": platforms,
"status": status,
"stars": 0,
"created_at": datetime.utcnow().isoformat(),
})
logger.info(f"Project {uid} created by {user['username']}")
return RedirectResponse(url="/projects", status_code=302)
+46
View File
@@ -0,0 +1,46 @@
import logging
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("/{target_type}/{target_uid}")
async def vote(request: Request, target_type: str, target_uid: str):
user = require_user(request)
form = await request.form()
value = int(form.get("value", "1"))
if value not in (1, -1):
return RedirectResponse(url="/feed", status_code=302)
votes = get_table("votes")
existing = votes.find_one(user_uid=user["uid"], target_uid=target_uid, target_type=target_type)
if existing:
if int(existing["value"]) == value:
votes.delete(id=existing["id"])
else:
votes.update({"id": existing["id"], "value": value}, ["id"])
else:
votes.insert({
"uid": generate_uid(),
"user_uid": user["uid"],
"target_uid": target_uid,
"target_type": target_type,
"value": value,
"created_at": __import__("datetime").datetime.utcnow().isoformat(),
})
up = len(list(votes.find(target_uid=target_uid, value=1)))
down = len(list(votes.find(target_uid=target_uid, value=-1)))
if target_type == "post":
posts = get_table("posts")
posts.update({"uid": target_uid, "stars": up - down}, ["uid"])
referer = request.headers.get("Referer", "/feed")
return RedirectResponse(url=referer, status_code=302)
+140
View File
@@ -0,0 +1,140 @@
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f0a1a 0%, #1a0a2e 50%, #0f0a1a 100%);
padding: 1rem;
}
.auth-card {
width: 100%;
max-width: 420px;
background: var(--bg-modal);
border: 1px dashed var(--border-light);
border-radius: var(--radius-xl);
padding: 2.5rem;
box-shadow: var(--shadow-lg);
}
.auth-card h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.25rem;
text-align: center;
}
.auth-card .subtitle {
color: var(--text-secondary);
font-size: 0.875rem;
text-align: center;
margin-bottom: 1.5rem;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.auth-field label {
display: block;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.375rem;
}
.auth-field .input-wrap {
position: relative;
}
.auth-field .input-wrap input {
padding-right: 2.5rem;
}
.auth-toggle-pw {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.25rem;
color: var(--text-muted);
font-size: 1rem;
line-height: 1;
}
.auth-toggle-pw:hover {
color: var(--text-secondary);
}
.auth-options {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8125rem;
}
.auth-options label {
display: flex;
align-items: center;
gap: 0.375rem;
color: var(--text-secondary);
cursor: pointer;
}
.auth-options input[type="checkbox"] {
width: auto;
accent-color: var(--accent);
}
.auth-options a {
font-size: 0.8125rem;
}
.auth-submit {
padding: 0.75rem;
font-size: 1rem;
font-weight: 700;
background: var(--accent);
color: #fff;
border-radius: var(--radius);
transition: all 0.2s;
}
.auth-submit:hover {
background: var(--accent-hover);
}
.auth-footer {
text-align: center;
margin-top: 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.auth-footer a {
font-weight: 600;
}
.auth-back {
display: block;
text-align: center;
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.auth-error {
background: rgba(229, 57, 53, 0.1);
border: 1px solid var(--danger);
border-radius: var(--radius);
padding: 0.75rem;
font-size: 0.8125rem;
color: var(--danger);
margin-bottom: 0.5rem;
}
.auth-error li {
list-style: none;
}
+205
View File
@@ -0,0 +1,205 @@
@import url("variables.css");
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.5;
}
a {
color: var(--accent);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: var(--accent-hover);
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input, textarea, select {
font-family: inherit;
font-size: 0.875rem;
color: var(--text-primary);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.625rem 0.875rem;
outline: none;
transition: border-color 0.2s;
width: 100%;
}
input:focus, textarea:focus, select:focus {
border-color: var(--accent);
}
textarea {
resize: vertical;
min-height: 80px;
}
img {
max-width: 100%;
display: block;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border-light);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 600;
transition: all 0.2s;
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-hover);
color: #fff;
}
.btn-secondary {
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--bg-card-hover);
border-color: var(--border-light);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover {
color: var(--text-primary);
background: var(--bg-card);
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.btn-icon {
padding: 0.5rem;
border-radius: var(--radius);
display: inline-flex;
align-items: center;
justify-content: center;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-devlog { background: #7c4dff; color: #fff; }
.badge-showcase { background: #00bfa5; color: #fff; }
.badge-question { background: #448aff; color: #fff; }
.badge-rant { background: #ff5252; color: #fff; }
.badge-fun { background: #ffab00; color: #000; }
.badge-random { background: var(--border-light); color: var(--text-secondary); }
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent);
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1rem;
color: #fff;
flex-shrink: 0;
overflow: hidden;
}
.avatar-sm { width: 32px; height: 32px; font-size: 0.8125rem; }
.avatar-lg { width: 80px; height: 80px; font-size: 2rem; }
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
}
.page {
max-width: var(--max-content);
margin: 0 auto;
padding: 1rem;
padding-top: calc(var(--nav-height) + 1rem);
}
.fade-in {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.page {
padding: 0.5rem;
padding-top: calc(var(--nav-height) + 0.5rem);
}
}
+287
View File
@@ -0,0 +1,287 @@
.feed-layout {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr 280px;
gap: 1.5rem;
align-items: start;
}
.feed-sidebar {
position: sticky;
top: calc(var(--nav-height) + 1rem);
}
.feed-sidebar h3 {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.feed-categories {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.feed-category {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
font-size: 0.875rem;
color: var(--text-secondary);
transition: all 0.2s;
}
.feed-category:hover {
background: var(--bg-card);
color: var(--text-primary);
}
.feed-category.active {
background: var(--accent-light);
color: var(--accent);
font-weight: 600;
}
.feed-category .dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.feed-nav {
display: flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 1rem;
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 0.25rem;
border: 1px solid var(--border);
}
.feed-nav-btn {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
}
.feed-nav-btn:hover {
color: var(--text-primary);
}
.feed-nav-btn.active {
background: var(--accent);
color: #fff;
}
.feed-nav-actions {
margin-left: auto;
display: flex;
gap: 0.25rem;
}
.feed-posts {
display: flex;
flex-direction: column;
gap: 1rem;
}
.post-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
transition: border-color 0.2s;
}
.post-card:hover {
border-color: var(--border-light);
}
.post-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.post-author {
font-weight: 600;
font-size: 0.875rem;
}
.post-author-role {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 400;
}
.post-time {
font-size: 0.75rem;
color: var(--text-muted);
margin-left: auto;
}
.post-topic {
margin-bottom: 0.75rem;
}
.post-title {
font-size: 1.125rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.post-content {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 0.75rem;
word-break: break-word;
}
.post-actions {
display: flex;
align-items: center;
gap: 0.25rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.post-action-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: var(--radius);
font-size: 0.8125rem;
color: var(--text-muted);
transition: all 0.2s;
}
.post-action-btn:hover {
background: var(--bg-card-hover);
color: var(--text-secondary);
}
.post-action-btn.voted {
color: var(--accent);
}
.feed-right {
position: sticky;
top: calc(var(--nav-height) + 1rem);
display: flex;
flex-direction: column;
gap: 1rem;
}
.daily-topic-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
}
.daily-topic-label {
font-size: 0.6875rem;
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.5rem;
}
.daily-topic-card h4 {
font-size: 0.875rem;
margin-bottom: 0.375rem;
}
.daily-topic-card p {
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.daily-topic-card .topic-links {
display: flex;
gap: 0.75rem;
}
.daily-topic-card .topic-links a {
font-size: 0.75rem;
font-weight: 600;
}
.community-stats {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
}
.community-stats h3 {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.stat-row {
display: flex;
justify-content: space-between;
padding: 0.375rem 0;
font-size: 0.8125rem;
}
.stat-row .label {
color: var(--text-secondary);
}
.stat-row .value {
font-weight: 600;
}
.feed-fab {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-lg);
transition: all 0.2s;
z-index: 100;
}
.feed-fab:hover {
background: var(--accent-hover);
transform: scale(1.05);
}
@media (max-width: 1024px) {
.feed-layout {
grid-template-columns: 1fr;
}
.feed-sidebar, .feed-right {
display: none;
}
}
+194
View File
@@ -0,0 +1,194 @@
.landing {
min-height: 100vh;
background: linear-gradient(135deg, #0f0a1a 0%, #1a0a2e 50%, #0f0a1a 100%);
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.landing::before {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
.landing-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 2rem;
position: relative;
z-index: 1;
}
.landing-logo {
font-size: 1.5rem;
font-weight: 800;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.landing-logo span {
color: var(--accent);
}
.landing-nav-links {
display: flex;
align-items: center;
gap: 1.5rem;
}
.landing-nav-links a {
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.landing-nav-links a:hover {
color: var(--text-primary);
}
.landing-hero {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 4rem 2rem;
position: relative;
z-index: 1;
}
.landing-hero h1 {
font-size: 3.5rem;
font-weight: 800;
line-height: 1.1;
margin-bottom: 1rem;
letter-spacing: -0.03em;
}
.landing-hero h1 span {
color: var(--accent);
}
.landing-hero p {
font-size: 1.125rem;
color: var(--text-secondary);
max-width: 600px;
margin-bottom: 2rem;
line-height: 1.6;
}
.landing-cta {
padding: 1rem 2.5rem;
font-size: 1.125rem;
border-radius: var(--radius-lg);
background: var(--accent);
color: #fff;
font-weight: 700;
transition: all 0.2s;
}
.landing-cta:hover {
background: var(--accent-hover);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.3);
}
.landing-features {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
max-width: 800px;
margin: 2rem auto 0;
position: relative;
z-index: 1;
}
.landing-feature {
text-align: center;
padding: 1rem;
}
.landing-feature-icon {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.landing-feature h3 {
font-size: 0.8125rem;
color: var(--text-primary);
font-weight: 600;
}
.landing-daily-topic {
max-width: 800px;
margin: 3rem auto 0;
padding: 1.5rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
text-align: left;
position: relative;
z-index: 1;
}
.landing-daily-topic-label {
font-size: 0.75rem;
font-weight: 700;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.5rem;
}
.landing-daily-topic h3 {
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
.landing-daily-topic p {
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.landing-daily-topic-links {
display: flex;
gap: 1rem;
}
.landing-daily-topic-links a {
font-size: 0.8125rem;
font-weight: 600;
}
.landing-auth-link {
font-size: 0.875rem;
color: var(--text-secondary);
margin-top: 1.5rem;
position: relative;
z-index: 1;
}
.landing-auth-link a {
color: var(--accent);
font-weight: 600;
}
@media (max-width: 768px) {
.landing-hero h1 {
font-size: 2rem;
}
.landing-features {
grid-template-columns: repeat(2, 1fr);
}
.landing-nav {
padding: 0.75rem 1rem;
}
}
+186
View File
@@ -0,0 +1,186 @@
.messages-layout {
display: grid;
grid-template-columns: 320px 1fr;
gap: 0;
height: calc(100vh - var(--nav-height) - 2rem);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.messages-list {
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.messages-list-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.messages-list-header input {
font-size: 0.8125rem;
}
.messages-conversations {
flex: 1;
overflow-y: auto;
}
.conversation-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
transition: background 0.2s;
cursor: pointer;
}
.conversation-item:hover {
background: var(--bg-card-hover);
}
.conversation-item.active {
background: var(--accent-light);
}
.conversation-info {
flex: 1;
min-width: 0;
}
.conversation-name {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.125rem;
}
.conversation-preview {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-time {
font-size: 0.6875rem;
color: var(--text-muted);
flex-shrink: 0;
}
.messages-main {
display: flex;
flex-direction: column;
}
.messages-main-header {
padding: 1rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.75rem;
}
.messages-main-header h3 {
font-size: 1rem;
font-weight: 600;
}
.messages-thread {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.message-bubble {
max-width: 70%;
padding: 0.625rem 0.875rem;
border-radius: var(--radius-lg);
font-size: 0.875rem;
line-height: 1.5;
position: relative;
}
.message-bubble.mine {
align-self: flex-end;
background: var(--accent);
color: #fff;
border-bottom-right-radius: 4px;
}
.message-bubble.theirs {
align-self: flex-start;
background: var(--bg-card-hover);
color: var(--text-primary);
border-bottom-left-radius: 4px;
}
.message-time {
font-size: 0.6875rem;
color: var(--text-muted);
margin-top: 0.25rem;
display: block;
}
.message-bubble.mine .message-time {
color: rgba(255, 255, 255, 0.7);
}
.messages-input-area {
padding: 1rem;
border-top: 1px solid var(--border);
display: flex;
gap: 0.75rem;
}
.messages-input-area input {
flex: 1;
}
.messages-send-btn {
padding: 0.5rem 1rem;
background: var(--accent);
color: #fff;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
}
.messages-send-btn:hover {
background: var(--accent-hover);
}
.messages-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
gap: 0.5rem;
}
.messages-empty p {
font-size: 0.875rem;
}
@media (max-width: 768px) {
.messages-layout {
grid-template-columns: 1fr;
height: auto;
min-height: calc(100vh - var(--nav-height) - 2rem);
}
.messages-list {
display: none;
}
.messages-list.show {
display: flex;
}
}
+84
View File
@@ -0,0 +1,84 @@
.notifications-page {
max-width: 640px;
margin: 0 auto;
}
.notifications-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.notifications-header h2 {
font-size: 1.5rem;
font-weight: 700;
}
.notifications-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.notification-card {
display: flex;
align-items: center;
gap: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
transition: border-color 0.2s;
}
.notification-card.unread {
border-color: var(--accent);
background: var(--accent-light);
}
.notification-card:hover {
border-color: var(--border-light);
}
.notification-body {
flex: 1;
min-width: 0;
}
.notification-text {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.4;
}
.notification-text strong {
color: var(--text-primary);
}
.notification-time {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
.notification-dismiss {
padding: 0.25rem;
color: var(--text-muted);
font-size: 0.875rem;
transition: color 0.2s;
}
.notification-dismiss:hover {
color: var(--text-primary);
}
.notifications-empty {
text-align: center;
padding: 3rem 1rem;
color: var(--text-muted);
}
.notifications-empty p {
font-size: 0.9375rem;
}
+196
View File
@@ -0,0 +1,196 @@
.post-page {
max-width: 720px;
margin: 0 auto;
}
.post-detail {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.post-detail-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.post-detail-author {
font-weight: 600;
font-size: 0.9375rem;
}
.post-detail-role {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 400;
}
.post-detail-time {
font-size: 0.8125rem;
color: var(--text-muted);
margin-left: auto;
}
.post-detail-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.75rem;
}
.post-detail-content {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.7;
margin-bottom: 1rem;
}
.post-detail-actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.comments-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
.comments-section h3 {
font-size: 1rem;
font-weight: 700;
margin-bottom: 1rem;
}
.comment {
display: flex;
gap: 0.75rem;
padding: 1rem 0;
border-bottom: 1px solid var(--border);
}
.comment:last-child {
border-bottom: none;
}
.comment-votes {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
min-width: 32px;
}
.comment-vote-btn {
padding: 0.25rem;
color: var(--text-muted);
font-size: 0.8125rem;
transition: color 0.2s;
}
.comment-vote-btn:hover {
color: var(--accent);
}
.comment-vote-count {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-secondary);
}
.comment-body {
flex: 1;
min-width: 0;
}
.comment-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.comment-author {
font-size: 0.8125rem;
font-weight: 600;
}
.comment-time {
font-size: 0.75rem;
color: var(--text-muted);
}
.comment-text {
font-size: 0.875rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 0.5rem;
}
.comment-actions {
display: flex;
gap: 0.5rem;
}
.comment-action-btn {
font-size: 0.75rem;
color: var(--text-muted);
padding: 0.25rem 0.5rem;
border-radius: var(--radius);
transition: all 0.2s;
}
.comment-action-btn:hover {
color: var(--text-secondary);
background: var(--bg-card-hover);
}
.comment-form {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.comment-form textarea {
flex: 1;
min-height: 44px;
max-height: 120px;
}
.comment-form-actions {
display: flex;
align-items: flex-end;
gap: 0.25rem;
}
.comment-form-actions button {
padding: 0.5rem;
color: var(--text-muted);
transition: color 0.2s;
}
.comment-form-actions button:hover {
color: var(--text-primary);
}
.comment-form-submit {
padding: 0.5rem 1rem;
background: var(--accent);
color: #fff;
border-radius: var(--radius);
font-weight: 600;
font-size: 0.8125rem;
}
.comment-form-submit:hover {
background: var(--accent-hover);
}
+195
View File
@@ -0,0 +1,195 @@
.profile-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1.5rem;
align-items: start;
}
.profile-sidebar {
position: sticky;
top: calc(var(--nav-height) + 1rem);
}
.profile-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
text-align: center;
}
.profile-avatar-wrap {
margin-bottom: 1rem;
}
.profile-avatar-wrap .avatar {
margin: 0 auto;
}
.profile-name {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.profile-role {
font-size: 0.8125rem;
color: var(--text-muted);
font-weight: 500;
margin-bottom: 1rem;
}
.profile-stats {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-bottom: 1rem;
}
.profile-stat {
text-align: center;
}
.profile-stat-value {
font-size: 1.25rem;
font-weight: 700;
display: block;
}
.profile-stat-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.profile-level-bar {
margin-bottom: 1rem;
}
.profile-level-bar .level-label {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.25rem;
display: flex;
justify-content: space-between;
}
.profile-level-bar .bar {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.profile-level-bar .bar-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.3s;
}
.profile-badges {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
justify-content: center;
margin-bottom: 1rem;
}
.profile-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
background: var(--accent-light);
color: var(--accent);
}
.profile-info {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem;
margin-top: 1rem;
}
.profile-info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
font-size: 0.8125rem;
}
.profile-info-row:not(:last-child) {
border-bottom: 1px solid var(--border);
}
.profile-info-label {
color: var(--text-muted);
}
.profile-info-value {
color: var(--text-secondary);
}
.profile-info-edit {
color: var(--text-muted);
padding: 0.25rem;
font-size: 0.8125rem;
}
.profile-info-edit:hover {
color: var(--accent);
}
.profile-content {
min-width: 0;
}
.profile-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 0.25rem;
border: 1px solid var(--border);
}
.profile-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
}
.profile-tab:hover {
color: var(--text-primary);
}
.profile-tab.active {
background: var(--accent);
color: #fff;
}
.profile-posts {
display: flex;
flex-direction: column;
gap: 1rem;
}
@media (max-width: 768px) {
.profile-layout {
grid-template-columns: 1fr;
}
.profile-sidebar {
position: static;
}
}
+185
View File
@@ -0,0 +1,185 @@
.projects-layout {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
gap: 1.5rem;
align-items: start;
}
.projects-sidebar {
position: sticky;
top: calc(var(--nav-height) + 1rem);
}
.projects-sidebar h3 {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.projects-search {
margin-bottom: 1rem;
}
.projects-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 0.25rem;
border: 1px solid var(--border);
flex-wrap: wrap;
}
.projects-tab {
padding: 0.5rem 1rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
transition: all 0.2s;
}
.projects-tab:hover {
color: var(--text-primary);
}
.projects-tab.active {
background: var(--accent);
color: #fff;
}
.projects-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.projects-header h2 {
font-size: 1.5rem;
font-weight: 700;
}
.projects-header .subtitle {
font-size: 0.875rem;
color: var(--text-secondary);
}
.projects-count {
font-size: 0.8125rem;
color: var(--text-muted);
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.project-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
transition: border-color 0.2s;
}
.project-card:hover {
border-color: var(--border-light);
}
.project-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.project-card-title {
font-size: 1.125rem;
font-weight: 700;
}
.project-card-star {
padding: 0.25rem;
color: var(--text-muted);
font-size: 1rem;
}
.project-card-star:hover {
color: var(--warning);
}
.project-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.project-card-meta span {
display: flex;
align-items: center;
gap: 0.25rem;
}
.project-card-desc {
font-size: 0.8125rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 0.75rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.project-card-platforms {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.platform-tag {
display: inline-flex;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
background: var(--border);
color: var(--text-muted);
}
.project-status {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
}
.project-status.released {
color: var(--success);
}
.project-status.dev {
color: var(--warning);
}
@media (max-width: 768px) {
.projects-layout {
grid-template-columns: 1fr;
}
.projects-sidebar {
display: none;
}
.projects-grid {
grid-template-columns: 1fr;
}
}
+38
View File
@@ -0,0 +1,38 @@
:root {
--bg-primary: #0f0a1a;
--bg-secondary: #1a1028;
--bg-card: #221436;
--bg-card-hover: #2a1a42;
--bg-input: #1a1028;
--bg-modal: #1a1028;
--accent: #ff6b35;
--accent-hover: #e55a2b;
--accent-light: rgba(255, 107, 53, 0.1);
--text-primary: #f0e8f8;
--text-secondary: #b8a8d0;
--text-muted: #7a6a90;
--border: #2e2040;
--border-light: #3a2a50;
--success: #4caf50;
--warning: #ff9800;
--danger: #e53935;
--info: #42a5f5;
--radius: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "SF Mono", Monaco, "Cascadia Code", monospace;
--nav-height: 56px;
--sidebar-width: 240px;
--max-content: 1200px;
}
+216
View File
@@ -0,0 +1,216 @@
class Application {
constructor() {
this.initPasswordToggles();
this.initModals();
this.initPostForm();
this.initCommentForms();
this.initVoteButtons();
this.initMessageSearch();
this.initNotificationDismiss();
this.initProfileEdit();
this.initProjectForm();
this.loadCSS("/static/css/variables.css");
this.loadCSS("/static/css/base.css");
}
loadCSS(href) {
if (document.querySelector(`link[href="${href}"]`)) {
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = href;
document.head.appendChild(link);
}
initPasswordToggles() {
document.querySelectorAll(".auth-toggle-pw").forEach((btn) => {
btn.addEventListener("click", () => {
const input = btn.parentElement.querySelector("input");
if (!input) {
return;
}
const type = input.type === "password" ? "text" : "password";
input.type = type;
btn.textContent = type === "password" ? "\u{1F441}" : "\u{1F441}";
});
});
}
initModals() {
document.querySelectorAll("[data-modal]").forEach((trigger) => {
trigger.addEventListener("click", () => {
const modalId = trigger.dataset.modal;
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add("visible");
}
});
});
document.querySelectorAll(".modal-overlay").forEach((modal) => {
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.classList.remove("visible");
}
});
const closeBtn = modal.querySelector(".modal-close");
if (closeBtn) {
closeBtn.addEventListener("click", () => {
modal.classList.remove("visible");
});
}
});
}
initPostForm() {
const form = document.getElementById("create-post-form");
if (!form) {
return;
}
const content = form.querySelector("#post-content");
const title = form.querySelector("#post-title");
const contentCount = form.querySelector("#post-content-count");
const titleCount = form.querySelector("#post-title-count");
if (content && contentCount) {
content.addEventListener("input", () => {
contentCount.textContent = `${content.value.length}/2000`;
});
}
if (title && titleCount) {
title.addEventListener("input", () => {
titleCount.textContent = `${title.value.length}/500`;
});
}
}
initCommentForms() {
document.querySelectorAll(".comment-form").forEach((form) => {
const textarea = form.querySelector("textarea");
if (!textarea) {
return;
}
textarea.addEventListener("input", () => {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
});
});
}
initVoteButtons() {
document.querySelectorAll(".post-action-btn[data-vote]").forEach((btn) => {
btn.addEventListener("click", async () => {
const targetUid = btn.dataset.target;
const targetType = btn.dataset.type || "post";
const value = btn.dataset.vote;
const form = document.createElement("form");
form.method = "POST";
form.action = `/votes/${targetType}/${targetUid}`;
const input = document.createElement("input");
input.type = "hidden";
input.name = "value";
input.value = value;
form.appendChild(input);
document.body.appendChild(form);
form.submit();
});
});
}
initMessageSearch() {
const searchInput = document.getElementById("message-search");
if (!searchInput) {
return;
}
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const query = searchInput.value.trim();
if (query) {
window.location.href = `/messages?search=${encodeURIComponent(query)}`;
}
}
});
}
initNotificationDismiss() {
document.querySelectorAll(".notification-dismiss").forEach((btn) => {
btn.addEventListener("click", async () => {
const uid = btn.dataset.uid;
if (!uid) {
return;
}
const form = document.createElement("form");
form.method = "POST";
form.action = `/notifications/mark-read/${uid}`;
document.body.appendChild(form);
form.submit();
});
});
}
initProfileEdit() {
document.querySelectorAll("[data-edit-field]").forEach((btn) => {
btn.addEventListener("click", () => {
const field = btn.dataset.editField;
const display = document.getElementById(`display-${field}`);
const input = document.getElementById(`input-${field}`);
if (!display || !input) {
return;
}
display.classList.toggle("hidden");
input.classList.toggle("hidden");
if (!input.classList.contains("hidden")) {
input.focus();
}
});
});
}
initProjectForm() {
const platformsInput = document.getElementById("platforms-input");
if (!platformsInput) {
return;
}
const hiddenInput = document.getElementById("platforms");
const tagsContainer = document.getElementById("platforms-tags");
platformsInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
const val = platformsInput.value.trim();
if (!val) {
return;
}
const tag = document.createElement("span");
tag.className = "platform-tag";
tag.textContent = val;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "x";
remove.style.marginLeft = "4px";
remove.style.fontSize = "0.75rem";
remove.style.padding = "0";
remove.addEventListener("click", () => {
tag.remove();
updatePlatforms();
});
tag.appendChild(remove);
tagsContainer.appendChild(tag);
platformsInput.value = "";
updatePlatforms();
}
});
function updatePlatforms() {
const tags = tagsContainer.querySelectorAll(".platform-tag");
const values = Array.from(tags).map((t) =>
t.textContent.replace("x", "").trim()
);
hiddenInput.value = values.join(",");
}
}
}
const app = new Application();
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DevPlace - The Developer Social Network</title>
<link rel="stylesheet" href="/static/css/variables.css">
<link rel="stylesheet" href="/static/css/base.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>&#x1f4bb;</text></svg>">
{% block extra_head %}{% endblock %}
</head>
<body>
{% if user %}
<nav class="topnav">
<div class="topnav-inner">
<a href="/feed" class="topnav-logo">Dev<span>Place</span></a>
<div class="topnav-links">
<a href="/feed" class="topnav-link {% if request.url.path == '/feed' %}active{% endif %}">Home</a>
<a href="/projects" class="topnav-link {% if 'projects' in request.url.path %}active{% endif %}">Projects</a>
<a href="/messages" class="topnav-link {% if 'messages' in request.url.path %}active{% endif %}">Messages</a>
</div>
<div class="topnav-right">
<a href="/notifications" class="topnav-icon">
<span class="nav-bell">&#x1F514;</span>
{% set unread_count = get_unread_count(user["uid"]) %}
{% if unread_count > 0 %}
<span class="nav-badge">{{ unread_count }}</span>
{% endif %}
</a>
<a href="/profile/{{ user['username'] }}" class="topnav-user">
<div class="avatar avatar-sm">{{ user['username'][:1].upper() }}</div>
<div class="topnav-user-info">
<span class="topnav-user-name">{{ user['username'] }}</span>
<span class="topnav-user-role">{{ user.get('role', 'Member') }}</span>
</div>
</a>
</div>
</div>
</nav>
{% endif %}
<main class="page">
{% block content %}{% endblock %}
</main>
<script type="module" src="/static/js/Application.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
+318
View File
@@ -0,0 +1,318 @@
{% 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">
<aside class="feed-sidebar">
<h3>Topics</h3>
<div class="feed-categories">
<a href="/feed" class="feed-category {% if not current_topic %}active{% endif %}">
<span class="dot" style="background: var(--text-muted);"></span>
All
</a>
<a href="/feed?topic=devlog" class="feed-category {% if current_topic == 'devlog' %}active{% endif %}">
<span class="dot" style="background: #7c4dff;"></span>
Devlog
</a>
<a href="/feed?topic=showcase" class="feed-category {% if current_topic == 'showcase' %}active{% endif %}">
<span class="dot" style="background: #00bfa5;"></span>
Showcase
</a>
<a href="/feed?topic=question" class="feed-category {% if current_topic == 'question' %}active{% endif %}">
<span class="dot" style="background: #448aff;"></span>
Question
</a>
<a href="/feed?topic=rant" class="feed-category {% if current_topic == 'rant' %}active{% endif %}">
<span class="dot" style="background: #ff5252;"></span>
Rant
</a>
<a href="/feed?topic=fun" class="feed-category {% if current_topic == 'fun' %}active{% endif %}">
<span class="dot" style="background: #ffab00;"></span>
Fun
</a>
</div>
</aside>
<div class="feed-main">
<div class="feed-nav">
<a href="/feed?tab=all" class="feed-nav-btn {% if current_tab == 'all' or not current_tab %}active{% endif %}">All</a>
<a href="/feed?tab=trending" class="feed-nav-btn {% if current_tab == 'trending' %}active{% endif %}">Trending</a>
<a href="/feed?tab=recent" class="feed-nav-btn {% if current_tab == 'recent' %}active{% endif %}">Recent</a>
<a href="/feed?tab=following" class="feed-nav-btn {% if current_tab == 'following' %}active{% endif %}">Following</a>
<div class="feed-nav-actions">
<button class="btn-ghost btn-icon" title="Refresh">&#x21BB;</button>
<button class="btn-ghost btn-icon" title="Notifications">&#x1F514;</button>
</div>
</div>
<div class="feed-posts">
{% for item in posts %}
<article class="post-card fade-in">
<div class="post-header">
<div class="avatar avatar-sm">{{ item.author['username'][:1].upper() if item.author else '?' }}</div>
<div>
<div class="post-author">{{ item.author['username'] if item.author else 'Unknown' }}
{% if item.author and item.author.get('role') %}
<span class="post-author-role">&middot; {{ item.author['role'] }}</span>
{% endif %}
</div>
</div>
<span class="post-time">{{ item.time_ago }}</span>
</div>
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<h3 class="post-title">{{ item.post['title'] }}</h3>
{% endif %}
<p class="post-content">{{ item.post['content'][:300] }}{% if item.post['content']|length > 300 %}...{% endif %}</p>
<div class="post-actions">
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">
+{{ item.post.get('stars', 0) }}
</button>
</form>
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn">
&#x1F4AC; {{ item.comment_count }}
</a>
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn" style="margin-left: auto;">
&#x2197;&#xFE0E; Open
</a>
<button class="post-action-btn">&#x1F517; Share</button>
</div>
</article>
{% else %}
<div class="card" style="text-align: center; padding: 3rem 1rem;">
<p style="color: var(--text-muted); font-size: 0.9375rem;">No posts yet. Be the first!</p>
</div>
{% endfor %}
</div>
</div>
<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>
<div class="topic-links">
<a href="#">Read More</a>
<a href="#">Source</a>
</div>
</div>
<div class="community-stats">
<h3>Community</h3>
<div class="stat-row">
<span class="label">Total Members</span>
<span class="value">{{ total_members }}</span>
</div>
<div class="stat-row">
<span class="label">Posts Today</span>
<span class="value">{{ posts_today }}</span>
</div>
<div class="stat-row">
<span class="label">Total Projects</span>
<span class="value">{{ total_projects }}</span>
</div>
<div style="margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border);">
<h3 style="font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: var(--text-muted); margin-bottom: 0.5rem;">Top Authors</h3>
{% for author in top_authors %}
<div class="stat-row">
<span class="label">
<span class="avatar avatar-sm" style="display: inline-flex; width: 20px; height: 20px; font-size: 0.625rem; vertical-align: middle; margin-right: 0.375rem;">{{ author['username'][:1].upper() }}</span>
{{ author['username'] }}
</span>
<span class="value">{{ author.get('stars', 0) }}</span>
</div>
{% endfor %}
</div>
</div>
</aside>
</div>
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post">+</a>
<div class="modal-overlay" id="create-post-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 2000; align-items: center; justify-content: center;">
<div class="card" style="width: 100%; max-width: 560px; margin: 1rem; max-height: 90vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="font-size: 1.125rem; font-weight: 700;">Create New Post</h3>
<button type="button" class="modal-close btn-ghost btn-icon" style="font-size: 1.25rem;">&times;</button>
</div>
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
<div style="display: flex; gap: 0.375rem; flex-wrap: wrap; margin-bottom: 1rem;">
{% for t in ['devlog', 'showcase', 'question', 'rant', 'fun', 'random'] %}
<label style="display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; cursor: pointer; background: var(--bg-card-hover); transition: all 0.2s;">
<input type="radio" name="topic" value="{{ t }}" {% if t == 'random' %}checked{% endif %} style="width: auto; accent-color: var(--accent);">
{{ t|capitalize }}
</label>
{% endfor %}
</div>
<div class="auth-field" style="margin-bottom: 0.75rem;">
<label for="post-content">What are you sharing?</label>
<textarea id="post-content" name="content" required maxlength="2000" placeholder="What's on your mind?" style="min-height: 120px;"></textarea>
<small style="color: var(--text-muted); font-size: 0.75rem;"><span id="post-content-count">0/2000</span></small>
</div>
<div class="auth-field" style="margin-bottom: 0.75rem;">
<label for="post-title">Title (optional)</label>
<input type="text" id="post-title" name="title" maxlength="500" placeholder="Post title">
<small style="color: var(--text-muted); font-size: 0.75rem;"><span id="post-title-count">0/500</span></small>
</div>
<div class="auth-field" style="margin-bottom: 1rem;">
<label for="project_uid">Link to Project (Optional)</label>
<select id="project_uid" name="project_uid">
<option value="">No project</option>
{% for p in get_user_projects(user['uid']) %}
<option value="{{ p['uid'] }}">{{ p['title'] }}</option>
{% endfor %}
</select>
</div>
<div class="auth-field" style="margin-bottom: 1rem;">
<label>Add an image (optional)</label>
<div style="border: 1px dashed var(--border); border-radius: var(--radius); padding: 2rem; text-align: center; color: var(--text-muted); font-size: 0.8125rem;">
Drop image here or click to browse
</div>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Post</button>
</div>
</form>
</div>
</div>
<script>
document.querySelector("[data-modal]").addEventListener("click", function() {
document.getElementById("create-post-modal").style.display = "flex";
});
document.querySelectorAll(".modal-close").forEach(function(btn) {
btn.addEventListener("click", function() {
var modal = this.closest(".modal-overlay");
if (modal) modal.style.display = "none";
});
});
document.getElementById("create-post-modal").addEventListener("click", function(e) {
if (e.target === this) this.style.display = "none";
});
</script>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/landing.css">
{% endblock %}
{% block content %}
<div class="landing">
<nav class="landing-nav">
<div class="landing-logo">Dev<span>Place</span></div>
<div class="landing-nav-links">
<a href="/">Home</a>
<a href="/projects">Projects</a>
<a href="/auth/login">Login</a>
<a href="/auth/signup" class="btn btn-primary btn-sm">Sign Up</a>
</div>
</nav>
<section class="landing-hero">
<h1>Devplace.net &mdash; The Developer <span>Social Network</span></h1>
<p>Track industry shifts. Discover bold releases. Share what you're building in an open, uncensored environment.</p>
<a href="/auth/signup" class="landing-cta">Join DevPlace Free</a>
<div class="landing-features">
<div class="landing-feature">
<div class="landing-feature-icon">&#x1F511;</div>
<h3>100% Free Forever</h3>
</div>
<div class="landing-feature">
<div class="landing-feature-icon">&#x1F6AB;</div>
<h3>Zero Ads</h3>
</div>
<div class="landing-feature">
<div class="landing-feature-icon">&#x1F4AD;</div>
<h3>No Censorship</h3>
</div>
<div class="landing-feature">
<div class="landing-feature-icon">&#x1F4B3;</div>
<h3>No Payments</h3>
</div>
</div>
<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>
<div class="landing-daily-topic-links">
<a href="#">Read More</a>
<a href="#">Source</a>
</div>
</div>
<div class="landing-auth-link">
Already have an account? <a href="/auth/login">Log In</a>
</div>
</section>
</div>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% 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="/" class="auth-back">&larr; Back</a>
<h2>Welcome Back</h2>
<p class="subtitle">Sign in to continue to DevPlace</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/login">
<div class="auth-field">
<label for="email">Email address</label>
<input type="email" id="email" name="email" value="{{ email or '' }}" required maxlength="255" placeholder="you@example.com">
</div>
<div class="auth-field">
<label for="password">Password</label>
<div class="input-wrap">
<input type="password" id="password" name="password" required minlength="6" maxlength="128" placeholder="Your password">
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">&#x1F441;</button>
</div>
</div>
<div class="auth-options">
<label>
<input type="checkbox" name="remember_me"> Remember me
</label>
<a href="#">Forgot your password?</a>
</div>
<button type="submit" class="auth-submit">Sign in</button>
</form>
<div class="auth-footer">
Don't have an account? <a href="/auth/signup">Create new account</a>
</div>
</div>
</div>
{% endblock %}
+98
View File
@@ -0,0 +1,98 @@
{% extends "base.html" %}
{% 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>
{% endblock %}
{% block content %}
<div class="messages-layout">
<div class="messages-list">
<div class="messages-list-header">
<input type="text" id="message-search" placeholder="Search conversations..." value="{{ search }}">
</div>
<div class="messages-conversations">
{% for conv in conversations %}
<a href="/messages?with_uid={{ conv.other_user['uid'] }}" class="conversation-item {% if current_conversation == conv.other_user['uid'] %}active{% endif %}">
<div class="avatar avatar-sm">{{ conv.other_user['username'][:1].upper() }}</div>
<div class="conversation-info">
<div class="conversation-name">{{ conv.other_user['username'] }}</div>
<div class="conversation-preview">{{ conv.last_message[:60] }}</div>
</div>
<span class="conversation-time">{{ conv.last_message_at[:10] }}</span>
</a>
{% else %}
<div style="padding: 2rem 1rem; text-align: center; color: var(--text-muted); font-size: 0.875rem;">
No conversations yet
</div>
{% endfor %}
</div>
</div>
<div class="messages-main">
{% if other_user %}
<div class="messages-main-header">
<div class="avatar avatar-sm">{{ other_user['username'][:1].upper() }}</div>
<h3>{{ other_user['username'] }}</h3>
</div>
<div class="messages-thread">
{% for msg in messages %}
<div class="message-bubble {% if msg.is_mine %}mine{% else %}theirs{% endif %}">
{{ msg.message['content'] }}
<span class="message-time">{{ msg.time_ago }}</span>
</div>
{% endfor %}
</div>
<form class="messages-input-area" method="POST" action="/messages/send">
<input type="hidden" name="receiver_uid" value="{{ other_user['uid'] }}">
<input type="text" name="content" placeholder="Type a message..." required maxlength="2000" autocomplete="off">
<button type="submit" class="messages-send-btn">&#x27A4;</button>
</form>
{% else %}
<div class="messages-empty">
<p>Select a conversation to start chatting</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% 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">
<div class="notifications-header">
<h2>Notifications</h2>
<form method="POST" action="/notifications/mark-all-read" style="display:inline;">
<button type="submit" class="btn btn-ghost btn-sm">Mark all read</button>
</form>
</div>
<div class="notifications-list">
{% for item in notifications %}
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}">
<div class="avatar avatar-sm">{{ item.actor['username'][:1].upper() if item.actor else '?' }}</div>
<div class="notification-body">
<div class="notification-text">{{ item.notification['message'] }}</div>
<div class="notification-time">{{ item.time_ago }}</div>
</div>
<form method="POST" action="/notifications/mark-read/{{ item.notification['uid'] }}" style="display:inline;">
<button type="submit" class="notification-dismiss" data-uid="{{ item.notification['uid'] }}">&times;</button>
</form>
</div>
{% else %}
<div class="notifications-empty">
<p>No notifications yet</p>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
+149
View File
@@ -0,0 +1,149 @@
{% 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">
<a href="/feed" style="display: inline-flex; align-items: center; gap: 0.375rem; color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;">&larr; Back to Feed</a>
<article class="post-detail">
<div class="post-detail-header">
<div class="avatar">{{ author['username'][:1].upper() if author else '?' }}</div>
<div>
<div class="post-detail-author">{{ author['username'] if author else 'Unknown' }}
{% if author and author.get('role') %}
<span class="post-detail-role">&middot; {{ author['role'] }}</span>
{% endif %}
</div>
</div>
<span class="post-detail-time">{{ time_ago }}</span>
</div>
<div style="margin-bottom: 0.75rem;">
<span class="badge badge-{{ post['topic'] }}">{{ post['topic'] }}</span>
</div>
{% if post.get('title') %}
<h1 class="post-detail-title">{{ post['title'] }}</h1>
{% endif %}
<div class="post-detail-content">{{ post['content'] }}</div>
<div class="post-detail-actions">
<form method="POST" action="/votes/post/{{ post['uid'] }}" style="display:inline;">
<input type="hidden" name="value" value="1">
<button type="submit" class="post-action-btn" style="display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.625rem; border-radius: var(--radius); font-size: 0.8125rem; color: var(--text-muted); background: none; cursor: pointer;">
+{{ post.get('stars', 0) }}
</button>
</form>
<button class="post-action-btn" style="display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.625rem; border-radius: var(--radius); font-size: 0.8125rem; color: var(--text-muted); background: none;">&#x1F517; Share</button>
</div>
</article>
<section class="comments-section">
<h3>Comments</h3>
{% for item in comments %}
<div class="comment">
<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">+</button>
</form>
<span class="comment-vote-count">{{ 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">-</button>
</form>
</div>
<div class="comment-body">
<div class="comment-header">
<div class="avatar avatar-sm" style="width: 24px; height: 24px; font-size: 0.625rem;">{{ item.author['username'][:1].upper() if item.author else '?' }}</div>
<span class="comment-author">{{ item.author['username'] if item.author else 'Unknown' }}</span>
<span class="comment-time">{{ item.time_ago }}</span>
</div>
<div class="comment-text">{{ item.comment['content'] }}</div>
<div class="comment-actions">
<button class="comment-action-btn">Reply</button>
{% if user and item.comment['user_uid'] == user['uid'] %}
<form method="POST" action="/comments/delete/{{ item.comment['uid'] }}" style="display:inline;">
<button type="submit" class="comment-action-btn">Delete</button>
</form>
{% endif %}
</div>
</div>
</div>
{% else %}
<p style="color: var(--text-muted); font-size: 0.875rem; text-align: center; padding: 2rem 0;">No comments yet. Start the discussion.</p>
{% endfor %}
{% if user %}
<form class="comment-form" method="POST" action="/comments/create">
<input type="hidden" name="post_uid" value="{{ post['uid'] }}">
<div class="avatar avatar-sm">{{ user['username'][:1].upper() }}</div>
<textarea name="content" placeholder="Your opinion goes here..." required maxlength="1000"></textarea>
<div class="comment-form-actions">
<button type="button" title="Add emoji">&#x1F600;</button>
<button type="button" title="Add image">&#x1F5BC;</button>
<button type="submit" class="comment-form-submit">Post</button>
</div>
</form>
{% endif %}
</section>
</div>
{% endblock %}
+228
View File
@@ -0,0 +1,228 @@
{% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/profile.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 %}
{% block content %}
<div class="profile-layout">
<aside class="profile-sidebar">
<div class="profile-card">
<div class="profile-avatar-wrap">
<div class="avatar avatar-lg">{{ profile_user['username'][:1].upper() }}</div>
</div>
<div class="profile-name">{{ profile_user['username'] }}</div>
{% if profile_user.get('role') %}
<div class="profile-role">{{ profile_user['role'] }}</div>
{% endif %}
<div class="profile-stats">
<div class="profile-stat">
<span class="profile-stat-value">{{ profile_user.get('posts_count', 0) }}</span>
<span class="profile-stat-label">Posts</span>
</div>
<div class="profile-stat">
<span class="profile-stat-value">{{ profile_user.get('level', 1) }}</span>
<span class="profile-stat-label">Level</span>
</div>
<div class="profile-stat">
<span class="profile-stat-value">{{ profile_user.get('stars', 0) }}</span>
<span class="profile-stat-label">Stars</span>
</div>
</div>
<div class="profile-level-bar">
<div class="level-label">
<span>Progress to next level</span>
<span>{{ profile_user.get('xp', 0) % 100 }}%</span>
</div>
<div class="bar">
<div class="bar-fill" style="width: {{ profile_user.get('xp', 0) % 100 }}%"></div>
</div>
</div>
<div class="profile-badges">
{% for badge in badges %}
<span class="profile-badge">{{ badge['badge_name'] }}</span>
{% else %}
<span class="profile-badge">Member</span>
{% endfor %}
</div>
</div>
{% if user and user['uid'] == profile_user['uid'] %}
<div class="profile-info">
<form method="POST" action="/profile/update">
<div class="profile-info-row">
<span class="profile-info-label">Bio</span>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span class="profile-info-value bio-display" id="display-bio">{{ profile_user.get('bio', '') or 'Not set' }}</span>
<button type="button" class="profile-info-edit" data-edit-field="bio">&#x270E;</button>
</div>
</div>
<div class="auth-field hidden" id="input-bio" style="margin-bottom: 0.5rem;">
<textarea name="bio" maxlength="500" rows="2" style="font-size: 0.8125rem;">{{ profile_user.get('bio', '') }}</textarea>
</div>
<div class="profile-info-row">
<span class="profile-info-label">Location</span>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span class="profile-info-value" id="display-location">{{ profile_user.get('location', '') or 'Not set' }}</span>
<button type="button" class="profile-info-edit" data-edit-field="location">&#x270E;</button>
</div>
</div>
<div class="auth-field hidden" id="input-location" style="margin-bottom: 0.5rem;">
<input type="text" name="location" maxlength="200" value="{{ profile_user.get('location', '') }}" style="font-size: 0.8125rem;">
</div>
<div class="profile-info-row">
<span class="profile-info-label">Git Link</span>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span class="profile-info-value" id="display-git_link">{{ profile_user.get('git_link', '') or 'Not set' }}</span>
<button type="button" class="profile-info-edit" data-edit-field="git_link">&#x270E;</button>
</div>
</div>
<div class="auth-field hidden" id="input-git_link" style="margin-bottom: 0.5rem;">
<input type="url" name="git_link" maxlength="500" value="{{ profile_user.get('git_link', '') }}" style="font-size: 0.8125rem;">
</div>
<div class="profile-info-row">
<span class="profile-info-label">Website</span>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span class="profile-info-value" id="display-website">{{ profile_user.get('website', '') or 'Not set' }}</span>
<button type="button" class="profile-info-edit" data-edit-field="website">&#x270E;</button>
</div>
</div>
<div class="auth-field hidden" id="input-website" style="margin-bottom: 0.5rem;">
<input type="url" name="website" maxlength="500" value="{{ profile_user.get('website', '') }}" style="font-size: 0.8125rem;">
</div>
<button type="submit" class="btn btn-primary btn-sm" style="width: 100%; margin-top: 0.5rem;">Save Changes</button>
</form>
</div>
{% else %}
<div class="profile-info">
<div class="profile-info-row">
<span class="profile-info-label">Bio</span>
<span class="profile-info-value">{{ profile_user.get('bio', '') or 'Not set' }}</span>
</div>
<div class="profile-info-row">
<span class="profile-info-label">Location</span>
<span class="profile-info-value">{{ profile_user.get('location', '') or 'Not set' }}</span>
</div>
<div class="profile-info-row">
<span class="profile-info-label">Git Link</span>
<span class="profile-info-value">{{ profile_user.get('git_link', '') or 'Not set' }}</span>
</div>
<div class="profile-info-row">
<span class="profile-info-label">Website</span>
<span class="profile-info-value">{{ profile_user.get('website', '') or 'Not set' }}</span>
</div>
</div>
{% endif %}
</aside>
<div class="profile-content">
<a href="/feed" style="display: inline-flex; align-items: center; gap: 0.375rem; color: var(--text-secondary); font-size: 0.875rem; margin-bottom: 1rem;">&larr; Back</a>
<div class="profile-tabs">
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}">Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}">Projects</a>
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}">Activity</a>
<button class="btn-ghost btn-icon" style="margin-left: auto; font-size: 0.8125rem;">&#x25B3;</button>
</div>
<div class="profile-posts">
{% if current_tab == 'posts' %}
{% for item in posts %}
<article class="post-card fade-in">
<div class="post-header">
<div class="avatar avatar-sm">{{ profile_user['username'][:1].upper() }}</div>
<div>
<div class="post-author">{{ profile_user['username'] }}
{% if profile_user.get('role') %}
<span class="post-author-role">&middot; {{ profile_user['role'] }}</span>
{% endif %}
</div>
</div>
<span class="post-time">{{ item.time_ago }}</span>
</div>
<div class="post-topic">
<span class="badge badge-{{ item.post['topic'] }}">{{ item.post['topic'] }}</span>
</div>
{% if item.post.get('title') %}
<h3 class="post-title">{{ item.post['title'] }}</h3>
{% endif %}
<p class="post-content">{{ item.post['content'][:200] }}{% if item.post['content']|length > 200 %}...{% endif %}</p>
<div class="post-actions">
<span class="post-action-btn" style="cursor: default;">+{{ item.post.get('stars', 0) }}</span>
<span class="post-action-btn" style="cursor: default;">&#x1F4AC; {{ item.comment_count }}</span>
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn" style="margin-left: auto;">&#x2197;&#xFE0E; Open</a>
</div>
</article>
{% else %}
<div class="card" style="text-align: center; padding: 3rem 1rem;">
<p style="color: var(--text-muted);">No posts yet.</p>
</div>
{% endfor %}
{% elif current_tab == 'projects' %}
{% for p in projects %}
<div class="card">
<h3 style="font-size: 1rem; font-weight: 700; margin-bottom: 0.25rem;">{{ p['title'] }}</h3>
<p style="font-size: 0.8125rem; color: var(--text-secondary);">{{ p.get('description', '')[:200] }}</p>
<div style="margin-top: 0.5rem; display: flex; gap: 0.375rem;">
<span class="badge" style="background: var(--border); color: var(--text-muted);">{{ p.get('project_type', 'software') }}</span>
<span class="badge" style="background: var(--border); color: var(--text-muted);">{{ p.get('status', 'In Development') }}</span>
</div>
</div>
{% else %}
<div class="card" style="text-align: center; padding: 3rem 1rem;">
<p style="color: var(--text-muted);">No projects yet.</p>
</div>
{% endfor %}
{% else %}
<div class="card" style="text-align: center; padding: 3rem 1rem;">
<p style="color: var(--text-muted);">Activity coming soon.</p>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+275
View File
@@ -0,0 +1,275 @@
{% extends "base.html" %}
{% 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>
{% endblock %}
{% block content %}
<div class="projects-layout">
<aside class="projects-sidebar">
<h3>Filter</h3>
<div class="projects-search">
<form method="GET" action="/projects">
<input type="text" name="search" placeholder="Search projects..." value="{{ search }}">
</form>
</div>
<div style="margin-bottom: 1rem;">
<h3 style="margin-bottom: 0.5rem;">Type</h3>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<a href="/projects?tab={{ current_tab }}" class="feed-category" style="display: flex; align-items: center; gap: 0.625rem; padding: 0.5rem 0.75rem; border-radius: var(--radius); font-size: 0.875rem; color: var(--text-secondary); transition: all 0.2s;">All</a>
<a href="/projects?tab={{ current_tab }}&type=game" class="feed-category" style="display: flex; align-items: center; gap: 0.625rem; padding: 0.5rem 0.75rem; border-radius: var(--radius); font-size: 0.875rem; color: var(--text-secondary); transition: all 0.2s;">Game</a>
<a href="/projects?tab={{ current_tab }}&type=software" class="feed-category" style="display: flex; align-items: center; gap: 0.625rem; padding: 0.5rem 0.75rem; border-radius: var(--radius); font-size: 0.875rem; color: var(--text-secondary); transition: all 0.2s;">Software</a>
<a href="/projects?tab={{ current_tab }}&type=mobile_app" class="feed-category" style="display: flex; align-items: center; gap: 0.625rem; padding: 0.5rem 0.75rem; border-radius: var(--radius); font-size: 0.875rem; color: var(--text-secondary); transition: all 0.2s;">Mobile App</a>
<a href="/projects?tab={{ current_tab }}&type=website" class="feed-category" style="display: flex; align-items: center; gap: 0.625rem; padding: 0.5rem 0.75rem; border-radius: var(--radius); font-size: 0.875rem; color: var(--text-secondary); transition: all 0.2s;">Website</a>
</div>
</div>
{% if user %}
<div style="margin-bottom: 1rem;">
<h3 style="margin-bottom: 0.5rem;">My Projects</h3>
<a href="/projects?user_uid={{ user['uid'] }}" class="btn btn-secondary btn-sm" style="width: 100%; justify-content: center;">View My Projects</a>
</div>
{% endif %}
</aside>
<div class="projects-main">
<div class="projects-header">
<div>
<h2>Projects</h2>
<span class="subtitle">Discover amazing projects from the DevPlace community</span>
</div>
<div class="projects-count">Showing {{ total_count }} of {{ total_count }} projects</div>
</div>
<div class="projects-tabs">
<a href="/projects?tab=recent" class="projects-tab {% if current_tab == 'recent' or not current_tab %}active{% endif %}">Recently Released</a>
<a href="/projects?tab=popular" class="projects-tab {% if current_tab == 'popular' %}active{% endif %}">Most Popular</a>
<a href="/projects?tab=new" class="projects-tab {% if current_tab == 'new' %}active{% endif %}">New This Week</a>
{% if user %}
<a href="/projects?user_uid={{ user['uid'] }}" class="projects-tab {% if request.query_params.get('user_uid') %}active{% endif %}">My Projects</a>
{% endif %}
<button class="btn-ghost btn-icon" style="margin-left: auto;">&#x2699;</button>
</div>
<div class="projects-grid">
{% for project in projects %}
<div class="project-card fade-in">
<div class="project-card-header">
<h3 class="project-card-title">{{ project['title'] }}</h3>
<button class="project-card-star">&#x2606;</button>
</div>
<div class="project-card-meta">
<span>By {{ project.get('author_name', 'Unknown') }}</span>
{% if project.get('release_date') %}
<span>&#x1F4C5; {{ project['release_date'] }}</span>
{% endif %}
<span class="project-status {% if project.get('status') == 'Released' %}released{% else %}dev{% endif %}">
&#x25CF; {{ project.get('status', 'In Development') }}
</span>
</div>
<div class="project-card-desc">{{ project.get('description', '')[:200] }}</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span class="badge" style="background: var(--border); color: var(--text-muted); text-transform: capitalize;">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
{% if project.get('platforms') %}
<div class="project-card-platforms">
{% for p in project['platforms'].split(',') %}
<span class="platform-tag">{{ p.strip() }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% else %}
<div class="card" style="text-align: center; padding: 3rem 1rem; grid-column: 1 / -1;">
<p style="color: var(--text-muted); font-size: 0.9375rem;">No projects found. Create one!</p>
</div>
{% endfor %}
</div>
</div>
</div>
<button class="feed-fab" id="create-project-btn" title="Add Project" style="position: fixed; bottom: 2rem; right: 2rem; width: 56px; height: 56px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 1.5rem; display: flex; align-items: center; justify-content: center; box-shadow: var(--shadow-lg); border: none; cursor: pointer; z-index: 100;">+</button>
<div class="modal-overlay" id="create-project-modal" style="position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 2000; align-items: center; justify-content: center;">
<div class="card" style="width: 100%; max-width: 560px; margin: 1rem; max-height: 90vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="font-size: 1.125rem; font-weight: 700;">Create Project</h3>
<button type="button" class="modal-close btn-ghost btn-icon" style="font-size: 1.25rem;">&times;</button>
</div>
<form method="POST" action="/projects/create">
<div class="auth-field" style="margin-bottom: 0.75rem;">
<label for="title">Title</label>
<input type="text" id="title" name="title" required maxlength="200" placeholder="Project name">
</div>
<div class="auth-field" style="margin-bottom: 0.75rem;">
<label for="description">Description</label>
<textarea id="description" name="description" required maxlength="5000" placeholder="Describe your project" style="min-height: 100px;"></textarea>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem;">
<div class="auth-field">
<label for="release_date">Release Date</label>
<input type="date" id="release_date" name="release_date">
</div>
<div class="auth-field">
<label for="demo_date">Demo Date</label>
<input type="date" id="demo_date" name="demo_date">
</div>
</div>
<div class="auth-field" style="margin-bottom: 0.75rem;">
<label>Type</label>
<div style="display: flex; gap: 0.375rem; flex-wrap: wrap;">
{% for t in [('game', 'Game'), ('game_asset', 'Game Asset'), ('software', 'Software'), ('mobile_app', 'Mobile App'), ('website', 'Website')] %}
<label style="display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; cursor: pointer; background: var(--bg-card-hover);">
<input type="radio" name="project_type" value="{{ t[0] }}" {% if t[0] == 'software' %}checked{% endif %} style="width: auto; accent-color: var(--accent);">
{{ t[1] }}
</label>
{% endfor %}
</div>
</div>
<div class="auth-field" style="margin-bottom: 0.75rem;">
<label>Status</label>
<div style="display: flex; gap: 0.375rem;">
<label style="display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; cursor: pointer; background: var(--bg-card-hover);">
<input type="radio" name="status" value="In Development" checked style="width: auto; accent-color: var(--accent);"> In Development
</label>
<label style="display: flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.75rem; border-radius: 999px; font-size: 0.75rem; font-weight: 600; cursor: pointer; background: var(--bg-card-hover);">
<input type="radio" name="status" value="Released" style="width: auto; accent-color: var(--accent);"> Released
</label>
</div>
</div>
<div class="auth-field" style="margin-bottom: 1rem;">
<label>Platforms</label>
<div id="platforms-tags" style="display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 0.5rem;"></div>
<input type="text" id="platforms-input" placeholder="Add platform (press Enter)" style="font-size: 0.8125rem;">
<input type="hidden" id="platforms" name="platforms" value="">
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.5rem;">
{% for p in ['PC', 'Windows', 'Mac', 'Linux', 'iOS', 'Android', 'Web', 'Console'] %}
<button type="button" class="platform-preset" data-platform="{{ p }}" style="padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.6875rem; font-weight: 600; background: var(--border); color: var(--text-muted); cursor: pointer; border: none;">{{ p }}</button>
{% endfor %}
</div>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: flex-end;">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Create Project</button>
</div>
</form>
</div>
</div>
<script>
document.getElementById("create-project-btn").addEventListener("click", function() {
document.getElementById("create-project-modal").classList.add("visible");
});
document.querySelectorAll("#create-project-modal .modal-close").forEach(function(btn) {
btn.addEventListener("click", function() {
document.getElementById("create-project-modal").classList.remove("visible");
});
});
document.getElementById("create-project-modal").addEventListener("click", function(e) {
if (e.target === this) this.classList.remove("visible");
});
(function() {
var platformsInput = document.getElementById("platforms-input");
var hiddenInput = document.getElementById("platforms");
var tagsContainer = document.getElementById("platforms-tags");
function addPlatform(val) {
val = val.trim();
if (!val) return;
var tag = document.createElement("span");
tag.className = "platform-tag";
tag.textContent = val;
var remove = document.createElement("button");
remove.type = "button";
remove.textContent = "x";
remove.style.marginLeft = "4px";
remove.style.fontSize = "0.75rem";
remove.style.padding = "0";
remove.style.background = "none";
remove.style.border = "none";
remove.style.color = "inherit";
remove.style.cursor = "pointer";
remove.addEventListener("click", function() {
tag.remove();
updatePlatforms();
});
tag.appendChild(remove);
tagsContainer.appendChild(tag);
platformsInput.value = "";
updatePlatforms();
}
function updatePlatforms() {
var tags = tagsContainer.querySelectorAll(".platform-tag");
var values = [];
tags.forEach(function(t) {
values.push(t.textContent.replace("x", "").trim());
});
hiddenInput.value = values.join(",");
}
platformsInput.addEventListener("keydown", function(e) {
if (e.key === "Enter") {
e.preventDefault();
addPlatform(platformsInput.value);
}
});
document.querySelectorAll(".platform-preset").forEach(function(btn) {
btn.addEventListener("click", function() {
addPlatform(this.dataset.platform);
});
});
})();
</script>
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% 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="/" class="auth-back">&larr; Back</a>
<h2>Join the Community</h2>
<p class="subtitle">Create your DevPlace account and start connecting with developers</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/signup">
<div class="auth-field">
<label for="username">Username</label>
<input type="text" id="username" name="username" value="{{ username or '' }}" required maxlength="32" placeholder="cooldev42">
<small style="color: var(--text-muted); font-size: 0.75rem;">Letters, numbers, hyphens, and underscores only</small>
</div>
<div class="auth-field">
<label for="email">Email address</label>
<input type="email" id="email" name="email" value="{{ email or '' }}" required maxlength="255" placeholder="you@example.com">
</div>
<div class="auth-field">
<label for="password">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">&#x1F441;</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">&#x1F441;</button>
</div>
</div>
<button type="submit" class="auth-submit">Create account</button>
</form>
<div class="auth-footer">
Already have an account? <a href="/auth/login">Sign in instead</a>
</div>
</div>
</div>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
from fastapi.templating import Jinja2Templates
from devplacepy.config import TEMPLATES_DIR
from devplacepy.database import get_table
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
def jinja_unread_count(user_uid: str) -> int:
notifs = get_table("notifications")
return len(list(notifs.find(user_uid=user_uid, read=False)))
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_user_projects"] = jinja_user_projects
+87
View File
@@ -0,0 +1,87 @@
import secrets
import logging
from datetime import datetime, timedelta
from passlib.hash import pbkdf2_sha256
from fastapi import Request, HTTPException, status
from devplacepy.database import get_table
from devplacepy.config import SECRET_KEY, SESSION_MAX_AGE
logger = logging.getLogger(__name__)
def hash_password(password: str) -> str:
return pbkdf2_sha256.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return pbkdf2_sha256.verify(password, hashed)
def create_session(user_uid: str) -> str:
token = secrets.token_hex(32)
expires_at = datetime.utcnow() + timedelta(seconds=SESSION_MAX_AGE)
sessions = get_table("sessions")
sessions.insert({
"session_token": token,
"user_uid": user_uid,
"created_at": datetime.utcnow().isoformat(),
"expires_at": expires_at.isoformat(),
})
return token
def get_current_user(request: Request):
token = request.cookies.get("session")
if not token:
return None
sessions = get_table("sessions")
session = sessions.find_one(session_token=token)
if not session:
return None
expires = datetime.fromisoformat(session["expires_at"])
if expires < datetime.utcnow():
sessions.delete(id=session["id"])
return None
users = get_table("users")
user = users.find_one(uid=session["user_uid"])
return user
def require_user(request: Request):
user = get_current_user(request)
if not user:
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/"})
return user
def slugify(text: str) -> str:
import re
text = text.lower().strip()
text = re.sub(r"[^a-z0-9-]", "-", text)
text = re.sub(r"-+", "-", text)
return text.strip("-")
def generate_uid() -> str:
import uuid
return str(uuid.uuid4())
def time_ago(dt_str: str) -> str:
dt = datetime.fromisoformat(dt_str)
now = datetime.utcnow()
diff = now - dt
days = diff.days
if days > 365:
return f"{days // 365}y ago"
if days > 30:
return f"{days // 30}mo ago"
if days > 0:
return f"{days}d ago"
hours = diff.seconds // 3600
if hours > 0:
return f"{hours}h ago"
minutes = diff.seconds // 60
if minutes > 0:
return f"{minutes}m ago"
return "just now"