forked from retoor/devplacepy
Bsic version.
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