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:
@@ -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
|
||||
@@ -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]
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"]),
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>💻</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">🔔</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>
|
||||
@@ -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">↻</button>
|
||||
<button class="btn-ghost btn-icon" title="Notifications">🔔</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">· {{ 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">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn" style="margin-left: auto;">
|
||||
↗︎ Open
|
||||
</a>
|
||||
<button class="post-action-btn">🔗 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;">×</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 %}
|
||||
@@ -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 — 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">🔑</div>
|
||||
<h3>100% Free Forever</h3>
|
||||
</div>
|
||||
<div class="landing-feature">
|
||||
<div class="landing-feature-icon">🚫</div>
|
||||
<h3>Zero Ads</h3>
|
||||
</div>
|
||||
<div class="landing-feature">
|
||||
<div class="landing-feature-icon">💭</div>
|
||||
<h3>No Censorship</h3>
|
||||
</div>
|
||||
<div class="landing-feature">
|
||||
<div class="landing-feature-icon">💳</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 %}
|
||||
@@ -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">← 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">👁</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 %}
|
||||
@@ -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">➤</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="messages-empty">
|
||||
<p>Select a conversation to start chatting</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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'] }}">×</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="notifications-empty">
|
||||
<p>No notifications yet</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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;">← 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">· {{ 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;">🔗 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">😀</button>
|
||||
<button type="button" title="Add image">🖼</button>
|
||||
<button type="submit" class="comment-form-submit">Post</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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">✎</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">✎</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">✎</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">✎</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;">← 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;">△</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">· {{ 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;">💬 {{ item.comment_count }}</span>
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn" style="margin-left: auto;">↗︎ 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 %}
|
||||
@@ -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;">⚙</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">☆</button>
|
||||
</div>
|
||||
|
||||
<div class="project-card-meta">
|
||||
<span>By {{ project.get('author_name', 'Unknown') }}</span>
|
||||
{% if project.get('release_date') %}
|
||||
<span>📅 {{ project['release_date'] }}</span>
|
||||
{% endif %}
|
||||
<span class="project-status {% if project.get('status') == 'Released' %}released{% else %}dev{% endif %}">
|
||||
● {{ 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;">×</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 %}
|
||||
@@ -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">← 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">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-field">
|
||||
<label for="confirm_password">Confirm Password</label>
|
||||
<div class="input-wrap">
|
||||
<input type="password" id="confirm_password" name="confirm_password" required maxlength="128" placeholder="Repeat your password">
|
||||
<button type="button" class="auth-toggle-pw" aria-label="Toggle password visibility">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="auth-submit">Create account</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
Already have an account? <a href="/auth/login">Sign in instead</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user