Update, backend rendering.

This commit is contained in:
retoor 2025-08-05 03:07:46 +02:00
parent 95f0dd7915
commit d1394506ab
13 changed files with 3983 additions and 32 deletions

1976
classic.html Normal file

File diff suppressed because it is too large Load Diff

256
locustfile.py Normal file
View File

@ -0,0 +1,256 @@
from locust import HttpUser, task, between
import random
import uuid
class RantCommunityUser(HttpUser):
wait_time = between(1, 5)
host = "http://127.0.0.1:8111"
def on_start(self):
self.token_id = random.randint(1, 1000)
self.token_key = str(uuid.uuid4())
self.user_id = random.randint(1, 1000)
self.username = f"user_{self.user_id}"
self.password = "testpassword123"
@task(2)
def home_page(self):
self.client.get(f"/?sort=recent")
@task(2)
def rant_detail(self):
rant_id = random.randint(1, 100)
self.client.get(f"/rant/{rant_id}")
@task(2)
def profile_page(self):
user_id = random.randint(1, 100)
tab = random.choice(["rants", "comments", "favorites"])
self.client.get(f"/profile/{user_id}?tab={tab}")
@task(1)
def search_page(self):
term = random.choice(["test", "rant", "community"])
self.client.get(f"/search?term={term}")
@task(1)
def notifications(self):
self.client.get("/notifications")
@task(1)
def classic_page(self):
self.client.get("/classic")
@task(1)
def login(self):
self.client.post("/login", data={
"username": self.username,
"password": self.password
})
@task(1)
def logout(self):
self.client.get("/logout")
@task(1)
def register_user(self):
self.client.post("/api/users", data={
"email": f"{self.username}@example.com",
"username": self.username,
"password": self.password,
"type": 1,
"app": 3
})
@task(1)
def auth_token(self):
self.client.post("/api/users/auth-token", data={
"username": self.username,
"password": self.password,
"app": 3
})
@task(3)
def get_rants(self):
self.client.get(f"/api/rant/rants?sort=recent&limit=20&skip=0&app=3&token_id={self.token_id}&token_key={self.token_key}&user_id={self.user_id}")
@task(2)
def create_rant(self):
self.client.post("/api/rant/rants", data={
"rant": "This is a test rant",
"tags": "test,example",
"type": 1,
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(2)
def get_rant(self):
rant_id = random.randint(1, 100)
self.client.get(f"/api/rant/rants/{rant_id}?app=3&token_id={self.token_id}&token_key={self.token_key}&user_id={self.user_id}")
@task(1)
def update_rant(self):
rant_id = random.randint(1, 100)
self.client.post(f"/api/rant/rants/{rant_id}", data={
"rant": "Updated test rant",
"tags": "test,updated",
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(1)
def delete_rant(self):
rant_id = random.randint(1, 100)
self.client.delete(f"/api/rant/rants/{rant_id}?app=3&token_id={self.token_id}&token_key={self.token_key}&user_id={self.user_id}")
@task(2)
def vote_rant(self):
rant_id = random.randint(1, 100)
self.client.post(f"/api/rant/rants/{rant_id}/vote", data={
"vote": random.choice([1, -1]),
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(1)
def favorite_rant(self):
rant_id = random.randint(1, 100)
self.client.post(f"/api/rant/rants/{rant_id}/favorite", data={
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(1)
def unfavorite_rant(self):
rant_id = random.randint(1, 100)
self.client.post(f"/api/rant/rants/{rant_id}/unfavorite", data={
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(2)
def create_comment(self):
rant_id = random.randint(1, 100)
self.client.post(f"/api/rant/rants/{rant_id}/comments", data={
"comment": "Test comment",
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(1)
def get_comment(self):
comment_id = random.randint(1, 100)
self.client.get(f"/api/comments/{comment_id}?app=3&token_id={self.token_id}&token_key={self.token_key}&user_id={self.user_id}")
@task(1)
def update_comment(self):
comment_id = random.randint(1, 100)
self.client.post(f"/api/comments/{comment_id}", data={
"comment": "Updated test comment",
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(1)
def delete_comment(self):
comment_id = random.randint(1, 100)
self.client.delete(f"/api/comments/{comment_id}?app=3&token_id={self.token_id}&token_key={self.token_key}&user_id={self.user_id}")
@task(1)
def vote_comment(self):
comment_id = random.randint(1, 100)
self.client.post(f"/api/comments/{comment_id}/vote", data={
"vote": random.choice([1, -1]),
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(1)
def get_profile(self):
user_id = random.randint(1, 100)
self.client.get(f"/api/users/{user_id}?app=3&token_id={self.token_id}&token_key={self.token_key}&auth_user_id={self.user_id}")
@task(1)
def get_user_id(self):
self.client.get(f"/api/get-user-id?username={self.username}&app=3")
@task(1)
def search(self):
term = random.choice(["test", "rant", "community"])
self.client.get(f"/api/rant/search?term={term}&app=3&token_id={self.token_id}&token_key={self.token_key}&user_id={self.user_id}")
@task(1)
def get_notifications(self):
self.client.get(f"/api/users/me/notif-feed?ext_prof=1&app=3&token_id={self.token_id}&token_key={self.token_key}&user_id={self.user_id}")
@task(1)
def clear_notifications(self):
self.client.delete(f"/api/users/me/notif-feed", data={
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(1)
def edit_profile(self):
self.client.post("/api/users/me/edit-profile", data={
"profile_about": "Test about",
"profile_skills": "Python,Testing",
"profile_location": "Test City",
"profile_website": "http://example.com",
"profile_github": "http://github.com/test",
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(1)
def forgot_password(self):
self.client.post("/api/users/forgot-password", data={
"username": self.username,
"app": 3
})
@task(1)
def resend_confirmation(self):
self.client.post("/api/users/me/resend-confirm", data={
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(1)
def mark_news_read(self):
news_id = str(uuid.uuid4())
self.client.post("/api/users/me/mark-news-read", data={
"news_id": news_id,
"app": 3,
"token_id": self.token_id,
"token_key": self.token_key,
"user_id": self.user_id
})
@task(1)
def get_upload(self):
filename = f"test_{random.randint(1, 100)}.jpg"
self.client.get(f"/uploads/{filename}")

541
main.py
View File

@ -1,6 +1,8 @@
from fastapi import FastAPI, HTTPException, Depends, Form, File, UploadFile
from fastapi import FastAPI, HTTPException, Depends, Form, File, UploadFile, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, EmailStr
from typing import Optional, List, Dict, Any, Literal
from datetime import datetime, timedelta
@ -28,6 +30,10 @@ DB_PATH = "rant_community.db"
UPLOAD_DIR = Path("uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
# Template setup
templates = Jinja2Templates(directory="templates")
# Initialize AsyncDataSet
db = AsyncDataSet(DB_PATH)
@ -115,6 +121,10 @@ async def init_db():
@app.on_event("startup")
async def startup_event():
await init_db()
# Create necessary directories
Path("templates").mkdir(exist_ok=True)
Path("templates/components").mkdir(exist_ok=True)
Path("static").mkdir(exist_ok=True)
# Pydantic models
class UserRegister(BaseModel):
@ -145,9 +155,59 @@ def hash_password(password: str) -> str:
def generate_token() -> str:
return secrets.token_urlsafe(32)
async def DELETE_get_current_user(token_id: Optional[int] = Form(None),
token_key: Optional[str] = Form(None),
user_id: Optional[int] = Form(None)):
def format_time(timestamp):
date = datetime.fromtimestamp(timestamp)
now = datetime.now()
diff = (now - date).total_seconds()
if diff < 60: return 'just now'
if diff < 3600: return f'{int(diff / 60)}m ago'
if diff < 86400: return f'{int(diff / 3600)}h ago'
if diff < 604800: return f'{int(diff / 86400)}d ago'
return date.strftime('%Y-%m-%d')
templates.env.globals['format_time'] = format_time
# Add template context processors
def escape_html(text):
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;').replace("'", '&#39;')
templates.env.globals['escape_html'] = escape_html
# Authentication helpers
async def get_current_user_from_cookie(request: Request):
"""Get current user from session cookie"""
auth_token = request.cookies.get("auth_token")
if not auth_token:
return None
try:
token_data = json.loads(auth_token)
token = await db.get("auth_tokens", {
"id": token_data['id'],
"token_key": token_data['key'],
"user_id": token_data['user_id']
})
if not token or token['expire_time'] <= int(datetime.now().timestamp()):
return None
user = await db.get("users", {"id": token_data['user_id']})
if user:
# Add token info to user object for client-side API calls
user['token_id'] = token_data['id']
user['token_key'] = token_data['key']
return user
except:
return None
async def authenticate_user(token_id: Optional[int] = None,
token_key: Optional[str] = None,
user_id: Optional[int] = None):
"""Generic authentication function that works with any parameter source"""
if not all([token_id, token_key, user_id]):
return None
@ -232,7 +292,446 @@ async def format_comment(comment_row, user_row, current_user_id=None):
}
}
# Endpoints
# SSR Routes
@app.get("/", response_class=HTMLResponse)
async def home(request: Request, sort: str = "recent"):
current_user = await get_current_user_from_cookie(request)
current_user_id = current_user['id'] if current_user else None
# Get rants
order_by = "r.created_time DESC" if sort == "recent" else "r.score DESC"
rows = await db.query_raw(
f"""SELECT r.*, u.id as user_id, u.username, u.score as user_score,
u.avatar_b, u.avatar_i
FROM rants r
JOIN users u ON r.user_id = u.id
ORDER BY {order_by}
LIMIT 50 OFFSET 0""",
()
)
rants = []
for row in rows:
rant_data = {
'id': row['id'],
'text': row['text'],
'score': row['score'],
'created_time': row['created_time'],
'attached_image': row['attached_image'],
'tags': row['tags'],
'edited': row['edited'],
'type': row['type']
}
user_data = {
'id': row['user_id'],
'username': row['username'],
'score': row['user_score'],
'avatar_b': row['avatar_b'],
'avatar_i': row['avatar_i']
}
rants.append(await format_rant(rant_data, user_data, current_user_id))
# Get notification count
notif_count = 0
if current_user:
notif_count = await db.count("notifications", {"user_id": current_user['id'], "read": 0})
return templates.TemplateResponse("feed.html", {
"request": request,
"current_user": current_user,
"notif_count": notif_count,
"rants": rants,
"current_sort": sort,
"escape_html": escape_html,
"format_time": format_time
})
@app.get("/rant/{rant_id}", response_class=HTMLResponse)
async def rant_detail(request: Request, rant_id: int):
current_user = await get_current_user_from_cookie(request)
current_user_id = current_user['id'] if current_user else None
# Get rant with user info
rant_row = await db.query_one(
"""SELECT r.*, u.id as user_id, u.username, u.score as user_score,
u.avatar_b, u.avatar_i
FROM rants r
JOIN users u ON r.user_id = u.id
WHERE r.id = ?""",
(rant_id,)
)
if not rant_row:
raise HTTPException(status_code=404, detail="Rant not found")
rant_data = {
'id': rant_row['id'],
'text': rant_row['text'],
'score': rant_row['score'],
'created_time': rant_row['created_time'],
'attached_image': rant_row['attached_image'],
'tags': rant_row['tags'],
'edited': rant_row['edited'],
'type': rant_row['type']
}
user_data = {
'id': rant_row['user_id'],
'username': rant_row['username'],
'score': rant_row['user_score'],
'avatar_b': rant_row['avatar_b'],
'avatar_i': rant_row['avatar_i']
}
rant = await format_rant(rant_data, user_data, current_user_id)
# Get comments
comment_rows = await db.query_raw(
"""SELECT c.*, u.id as user_id, u.username, u.score as user_score,
u.avatar_b, u.avatar_i
FROM comments c
JOIN users u ON c.user_id = u.id
WHERE c.rant_id = ?
ORDER BY c.created_time ASC""",
(rant_id,)
)
comments = []
for row in comment_rows:
comment_data = {
'id': row['id'],
'rant_id': row['rant_id'],
'body': row['body'],
'score': row['score'],
'created_time': row['created_time']
}
user_data = {
'id': row['user_id'],
'username': row['username'],
'score': row['user_score'],
'avatar_b': row['avatar_b'],
'avatar_i': row['avatar_i']
}
comments.append(await format_comment(comment_data, user_data, current_user_id))
# Check if subscribed (favorited)
subscribed = 0
if current_user_id:
if await db.exists("favorites", {"user_id": current_user_id, "rant_id": rant_id}):
subscribed = 1
# Get notification count
notif_count = 0
if current_user:
notif_count = await db.count("notifications", {"user_id": current_user['id'], "read": 0})
return templates.TemplateResponse("rant_detail.html", {
"request": request,
"current_user": current_user,
"notif_count": notif_count,
"rant": rant,
"comments": comments,
"subscribed": subscribed,
"escape_html": escape_html,
"format_time": format_time
})
@app.get("/profile/{user_id}", response_class=HTMLResponse)
async def profile(request: Request, user_id: int, tab: str = "rants"):
current_user = await get_current_user_from_cookie(request)
current_user_id = current_user['id'] if current_user else None
# Get user
user = await db.get("users", {"id": user_id})
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get user's rants
rant_rows = await db.query_raw(
"""SELECT r.*, u.id as user_id, u.username, u.score as user_score,
u.avatar_b, u.avatar_i
FROM rants r
JOIN users u ON r.user_id = u.id
WHERE r.user_id = ?
ORDER BY r.created_time DESC
LIMIT 50""",
(user_id,)
)
rants = []
for row in rant_rows:
rant_data = {
'id': row['id'],
'text': row['text'],
'score': row['score'],
'created_time': row['created_time'],
'attached_image': row['attached_image'],
'tags': row['tags'],
'edited': row['edited'],
'type': row['type']
}
user_data = {
'id': row['user_id'],
'username': row['username'],
'score': row['user_score'],
'avatar_b': row['avatar_b'],
'avatar_i': row['avatar_i']
}
rants.append(await format_rant(rant_data, user_data, current_user_id))
# Get user's comments
comment_rows = await db.query_raw(
"""SELECT c.*, u.id as user_id, u.username, u.score as user_score,
u.avatar_b, u.avatar_i
FROM comments c
JOIN users u ON c.user_id = u.id
WHERE c.user_id = ?
ORDER BY c.created_time DESC
LIMIT 50""",
(user_id,)
)
comments = []
for row in comment_rows:
comment_data = {
'id': row['id'],
'rant_id': row['rant_id'],
'body': row['body'],
'score': row['score'],
'created_time': row['created_time']
}
user_data = {
'id': row['user_id'],
'username': row['username'],
'score': row['user_score'],
'avatar_b': row['avatar_b'],
'avatar_i': row['avatar_i']
}
comments.append(await format_comment(comment_data, user_data, current_user_id))
# Get favorited rants
favorite_rows = await db.query_raw(
"""SELECT r.*, u.id as user_id, u.username, u.score as user_score,
u.avatar_b, u.avatar_i
FROM rants r
JOIN users u ON r.user_id = u.id
JOIN favorites f ON f.rant_id = r.id
WHERE f.user_id = ?
ORDER BY f.id DESC
LIMIT 50""",
(user_id,)
)
favorites = []
for row in favorite_rows:
rant_data = {
'id': row['id'],
'text': row['text'],
'score': row['score'],
'created_time': row['created_time'],
'attached_image': row['attached_image'],
'tags': row['tags'],
'edited': row['edited'],
'type': row['type']
}
user_data = {
'id': row['user_id'],
'username': row['username'],
'score': row['user_score'],
'avatar_b': row['avatar_b'],
'avatar_i': row['avatar_i']
}
favorites.append(await format_rant(rant_data, user_data, current_user_id))
# Get notification count
notif_count = 0
if current_user:
notif_count = await db.count("notifications", {"user_id": current_user['id'], "read": 0})
profile_data = {
"username": user['username'],
"score": user['score'],
"about": user['about'],
"location": user['location'],
"created_time": user['created_time'],
"skills": user['skills'],
"github": user['github'],
"website": user['website'],
"avatar": {
"b": user['avatar_b'],
"i": user['avatar_i'] or ""
}
}
return templates.TemplateResponse("profile.html", {
"request": request,
"current_user": current_user,
"notif_count": notif_count,
"profile": profile_data,
"profile_user_id": user_id,
"rants": rants,
"comments": comments,
"favorites": favorites,
"active_tab": tab,
"escape_html": escape_html,
"format_time": format_time
})
@app.get("/search", response_class=HTMLResponse)
async def search_page(request: Request, term: str = None):
current_user = await get_current_user_from_cookie(request)
current_user_id = current_user['id'] if current_user else None
results = []
if term:
# Search rants
rows = await db.query_raw(
"""SELECT r.*, u.id as user_id, u.username, u.score as user_score,
u.avatar_b, u.avatar_i
FROM rants r
JOIN users u ON r.user_id = u.id
WHERE r.text LIKE ? OR r.tags LIKE ?
ORDER BY r.score DESC
LIMIT 50""",
(f'%{term}%', f'%{term}%')
)
for row in rows:
rant_data = {
'id': row['id'],
'text': row['text'],
'score': row['score'],
'created_time': row['created_time'],
'attached_image': row['attached_image'],
'tags': row['tags'],
'edited': row['edited'],
'type': row['type']
}
user_data = {
'id': row['user_id'],
'username': row['username'],
'score': row['user_score'],
'avatar_b': row['avatar_b'],
'avatar_i': row['avatar_i']
}
results.append(await format_rant(rant_data, user_data, current_user_id))
# Get notification count
notif_count = 0
if current_user:
notif_count = await db.count("notifications", {"user_id": current_user['id'], "read": 0})
return templates.TemplateResponse("search.html", {
"request": request,
"current_user": current_user,
"notif_count": notif_count,
"search_term": term,
"results": results,
"escape_html": escape_html,
"format_time": format_time
})
@app.get("/notifications", response_class=HTMLResponse)
async def notifications(request: Request):
current_user = await get_current_user_from_cookie(request)
if not current_user:
return RedirectResponse("/", status_code=302)
# Get notifications
rows = await db.query_raw(
"""SELECT n.*, u.username
FROM notifications n
LEFT JOIN users u ON n.from_user_id = u.id
WHERE n.user_id = ?
ORDER BY n.created_time DESC
LIMIT 50""",
(current_user['id'],)
)
items = []
for row in rows:
item = {
"type": row['type'],
"rant_id": row['rant_id'],
"comment_id": row['comment_id'],
"created_time": row['created_time'],
"read": row['read'],
"uid": row['from_user_id'],
"username": row['username'] or ""
}
items.append(item)
# Mark notifications as read
if rows:
await db.update("notifications", {"read": 1}, {"user_id": current_user['id']})
return templates.TemplateResponse("notifications.html", {
"request": request,
"current_user": current_user,
"notif_count": 0, # We just marked them as read
"items": items,
"format_time": format_time
})
@app.get("/classic", response_class=HTMLResponse)
async def classic():
return FileResponse("classic.html")
# Authentication endpoints with cookie support
@app.post("/login")
async def login_form(
request: Request,
response: Response,
username: str = Form(...),
password: str = Form(...)
):
# Find user by username or email
user = await db.query_one(
"SELECT * FROM users WHERE username = ? OR email = ?",
(username, username)
)
if not user or user['password_hash'] != hash_password(password):
return templates.TemplateResponse("login.html", {
"request": request,
"error": "Invalid login credentials entered. Please try again."
})
# Create auth token
token_key = generate_token()
expire_time = int((datetime.now() + timedelta(days=30)).timestamp())
token_id = await db.insert("auth_tokens", {
"user_id": user['id'],
"token_key": token_key,
"expire_time": expire_time
}, return_id=True)
# Set cookie
auth_token = {
"id": token_id,
"key": token_key,
"expire_time": expire_time,
"user_id": user['id']
}
response = RedirectResponse("/", status_code=302)
response.set_cookie("auth_token", json.dumps(auth_token), max_age=30*24*60*60)
return response
@app.get("/logout")
async def logout(response: Response):
response = RedirectResponse("/", status_code=302)
response.delete_cookie("auth_token")
return response
# REST API Endpoints (keeping all existing endpoints)
@app.post("/api/users")
async def register_user(
email: str = Form(...),
@ -1208,25 +1707,6 @@ async def get_notifications(
}
}
async def authenticate_user(token_id: Optional[int] = None,
token_key: Optional[str] = None,
user_id: Optional[int] = None):
"""Generic authentication function that works with any parameter source"""
if not all([token_id, token_key, user_id]):
return None
token = await db.get("auth_tokens", {
"id": token_id,
"token_key": token_key,
"user_id": user_id
})
if not token or token['expire_time'] <= int(datetime.now().timestamp()):
return None
return user_id
@app.delete("/api/users/me/notif-feed")
async def clear_notifications(
app: int = Form(3),
@ -1313,14 +1793,11 @@ async def get_upload(filename: str):
return FileResponse(file_path)
raise HTTPException(status_code=404, detail="File not found")
# Serve static files (for frontend)
from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static"), name="static")
# Create static directory if it doesn't exist
Path("static").mkdir(exist_ok=True)
# Root endpoint serves the main HTML
@app.get("/")
async def root():
return FileResponse("static/index.html")
# Serve static files (for frontend)
app.mount("/static", StaticFiles(directory="static"), name="static")
if __name__ == "__main__":
import uvicorn

794
templates/base.html Normal file
View File

@ -0,0 +1,794 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Rant Community{% endblock %}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #d55161;
--primary-dark: #c44154;
--secondary: #7bc8a4;
--background: #0a0a0a;
--surface: #1a1a1a;
--surface-light: #2a2a2a;
--text: #e0e0e0;
--text-dim: #a0a0a0;
--success: #4caf50;
--error: #f44336;
--warning: #ff9800;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--background);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
/* Navigation */
nav {
background: var(--surface);
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 2px 10px rgba(0,0,0,0.5);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
color: var(--primary);
text-decoration: none;
}
.nav-links {
display: flex;
gap: 2rem;
align-items: center;
}
.nav-links a {
color: var(--text);
text-decoration: none;
transition: color 0.3s;
}
.nav-links a:hover {
color: var(--primary);
}
.btn {
background: var(--primary);
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.3s;
text-decoration: none;
display: inline-block;
}
.btn:hover {
background: var(--primary-dark);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--surface-light);
}
.btn-secondary:hover {
background: #3a3a3a;
}
/* Container */
.container {
max-width: 800px;
margin: 2rem auto;
padding: 0 2rem;
}
/* Rant Card */
.rant-card {
background: var(--surface);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
transition: transform 0.2s;
cursor: pointer;
}
.rant-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.rant-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 1rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: white;
}
.user-info {
flex: 1;
}
.username {
font-weight: bold;
color: var(--primary);
}
.score {
color: var(--text-dim);
font-size: 0.9rem;
}
.rant-content {
margin-bottom: 1rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.rant-image {
max-width: 100%;
border-radius: 4px;
margin: 1rem 0;
}
.rant-footer {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-dim);
font-size: 0.9rem;
}
.rant-actions {
display: flex;
gap: 1rem;
}
.action-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
display: flex;
align-items: center;
gap: 0.3rem;
transition: color 0.3s;
padding: 0.3rem 0.6rem;
border-radius: 4px;
}
.action-btn:hover {
color: var(--primary);
background: rgba(213, 81, 97, 0.1);
}
.action-btn.voted {
color: var(--primary);
}
.action-btn.downvoted {
color: var(--error);
}
/* Tags */
.tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0.5rem 0;
}
.tag {
background: var(--surface-light);
padding: 0.2rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
color: var(--secondary);
}
/* Forms */
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-dim);
}
input, textarea, select {
width: 100%;
padding: 0.75rem;
background: var(--surface-light);
border: 1px solid transparent;
border-radius: 4px;
color: var(--text);
font-size: 1rem;
transition: border-color 0.3s;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--primary);
}
textarea {
resize: vertical;
min-height: 120px;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
z-index: 2000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: var(--surface);
padding: 2rem;
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.close-btn {
background: none;
border: none;
color: var(--text-dim);
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.3s;
}
.close-btn:hover {
background: var(--surface-light);
color: var(--text);
}
/* Loading */
.loading {
text-align: center;
padding: 2rem;
color: var(--text-dim);
}
.spinner {
border: 3px solid var(--surface-light);
border-top: 3px solid var(--primary);
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Comments */
.comments-section {
background: var(--surface);
border-radius: 8px;
padding: 1.5rem;
margin-top: 2rem;
}
.comment {
padding: 1rem 0;
border-bottom: 1px solid var(--surface-light);
}
.comment:last-child {
border-bottom: none;
}
.comment-form {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--surface-light);
}
/* Sort Options */
.sort-options {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
justify-content: center;
}
.sort-btn {
background: var(--surface);
border: 1px solid var(--surface-light);
padding: 0.5rem 1.5rem;
border-radius: 20px;
color: var(--text-dim);
cursor: pointer;
transition: all 0.3s;
}
.sort-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.sort-btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* Profile */
.profile-header {
background: var(--surface);
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
text-align: center;
}
.profile-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
margin: 0 auto 1rem;
font-size: 2rem;
}
.profile-stats {
display: flex;
justify-content: center;
gap: 3rem;
margin-top: 1.5rem;
}
.stat {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--primary);
}
.stat-label {
color: var(--text-dim);
font-size: 0.9rem;
}
/* Tabs */
.tabs {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--surface-light);
}
.tab {
padding: 1rem 2rem;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
position: relative;
transition: color 0.3s;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--primary);
}
.tab.active::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: var(--primary);
}
/* Alert */
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
display: none;
}
.alert.active {
display: block;
}
.alert.success {
background: rgba(76, 175, 80, 0.1);
border: 1px solid var(--success);
color: var(--success);
}
.alert.error {
background: rgba(244, 67, 54, 0.1);
border: 1px solid var(--error);
color: var(--error);
}
/* Search */
.search-box {
background: var(--surface);
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.search-form {
display: flex;
gap: 1rem;
}
.search-form input {
flex: 1;
}
/* Floating Action Button */
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 60px;
height: 60px;
border-radius: 50%;
background: var(--primary);
color: white;
border: none;
font-size: 1.5rem;
cursor: pointer;
box-shadow: 0 4px 20px rgba(213, 81, 97, 0.4);
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
}
.fab:hover {
transform: scale(1.1);
box-shadow: 0 6px 30px rgba(213, 81, 97, 0.6);
}
/* Responsive */
@media (max-width: 768px) {
.nav-links {
gap: 1rem;
}
.nav-links span {
display: none;
}
.profile-stats {
gap: 1.5rem;
}
.tabs {
gap: 0.5rem;
}
.tab {
padding: 1rem;
}
}
</style>
</head>
<body>
<!-- Navigation Component -->
{% include 'components/navigation.html' %}
<!-- Main Content Container -->
<div id="content" class="container">
{% block content %}{% endblock %}
</div>
<!-- Floating Action Button -->
{% if current_user %}
<button class="fab" onclick="showModal('create-rant')">+</button>
{% endif %}
<!-- Modals -->
{% include 'components/modals.html' %}
<script>
// Auth token management
const authToken = {% if current_user %}{ id: {{ current_user.id }}, token_id: {{ current_user.token_id }}, token_key: "{{ current_user.token_key }}" }{% else %}null{% endif %};
const APP_ID = 3;
// API call helper
async function apiCall(endpoint, options = {}) {
let url = `/api${endpoint}`;
// Add auth to FormData or URLSearchParams if logged in
if (authToken && options.body) {
if (options.body instanceof FormData) {
options.body.append('app', APP_ID);
options.body.append('token_id', authToken.token_id);
options.body.append('token_key', authToken.token_key);
options.body.append('user_id', authToken.id);
} else if (options.body instanceof URLSearchParams) {
options.body.append('app', APP_ID);
options.body.append('token_id', authToken.token_id);
options.body.append('token_key', authToken.token_key);
options.body.append('user_id', authToken.id);
}
}
// Add auth to query params for GET requests
if (authToken && (options.method === 'GET' || !options.method)) {
const separator = endpoint.includes('?') ? '&' : '?';
url += `${separator}app=${APP_ID}&token_id=${authToken.token_id}&token_key=${authToken.token_key}&user_id=${authToken.id}`;
}
try {
const response = await fetch(url, options);
const data = await response.json();
return data;
} catch (error) {
console.error('API Error:', error);
return { success: false, error: error.message };
}
}
// Modal functions
function showModal(type) {
const modal = document.getElementById('modal');
modal.classList.add('active');
switch(type) {
case 'login':
document.getElementById('loginModal').style.display = 'block';
document.getElementById('registerModal').style.display = 'none';
document.getElementById('createRantModal').style.display = 'none';
document.getElementById('editProfileModal').style.display = 'none';
break;
case 'register':
document.getElementById('loginModal').style.display = 'none';
document.getElementById('registerModal').style.display = 'block';
document.getElementById('createRantModal').style.display = 'none';
document.getElementById('editProfileModal').style.display = 'none';
break;
case 'create-rant':
document.getElementById('loginModal').style.display = 'none';
document.getElementById('registerModal').style.display = 'none';
document.getElementById('createRantModal').style.display = 'block';
document.getElementById('editProfileModal').style.display = 'none';
break;
case 'edit-profile':
document.getElementById('loginModal').style.display = 'none';
document.getElementById('registerModal').style.display = 'none';
document.getElementById('createRantModal').style.display = 'none';
document.getElementById('editProfileModal').style.display = 'block';
break;
}
}
function closeModal() {
const modal = document.getElementById('modal');
modal.classList.remove('active');
}
// Vote functions
async function voteRant(rantId, vote) {
if (!authToken) {
showModal('login');
return;
}
const formData = new FormData();
formData.append('vote', vote);
if (vote === -1) {
formData.append('reason', 0);
}
const data = await apiCall(`/rant/rants/${rantId}/vote`, {
method: 'POST',
body: formData
});
if (data.success) {
location.reload();
}
}
async function voteComment(commentId, vote) {
if (!authToken) {
showModal('login');
return;
}
const formData = new FormData();
formData.append('vote', vote);
const data = await apiCall(`/comments/${commentId}/vote`, {
method: 'POST',
body: formData
});
if (data.success) {
location.reload();
}
}
async function toggleFavorite(rantId, subscribed) {
if (!authToken) {
showModal('login');
return;
}
const endpoint = subscribed ? 'unfavorite' : 'favorite';
const formData = new FormData();
const data = await apiCall(`/rant/rants/${rantId}/${endpoint}`, {
method: 'POST',
body: formData
});
if (data.success) {
location.reload();
}
}
async function deleteRant(rantId) {
if (!confirm('Are you sure you want to delete this rant?')) return;
const params = new URLSearchParams();
params.append('app', APP_ID);
params.append('token_id', authToken.token_id);
params.append('token_key', authToken.token_key);
params.append('user_id', authToken.id);
const data = await apiCall(`/rant/rants/${rantId}?${params}`, {
method: 'DELETE'
});
if (data.success) {
window.location.href = '/';
}
}
async function deleteComment(commentId) {
if (!confirm('Are you sure you want to delete this comment?')) return;
const params = new URLSearchParams();
params.append('app', APP_ID);
params.append('token_id', authToken.token_id);
params.append('token_key', authToken.token_key);
params.append('user_id', authToken.id);
const data = await apiCall(`/comments/${commentId}?${params}`, {
method: 'DELETE'
});
if (data.success) {
location.reload();
}
}
// Form submissions
async function submitCreateRant(event) {
event.preventDefault();
const formData = new FormData(event.target);
const data = await apiCall('/rant/rants', {
method: 'POST',
body: formData
});
if (data.success) {
window.location.href = `/rant/${data.rant_id}`;
} else {
document.getElementById('rantError').textContent = data.error;
document.getElementById('rantError').classList.add('active');
}
}
async function submitEditProfile(event) {
event.preventDefault();
const formData = new FormData(event.target);
// Add profile_ prefix to all fields
const profileData = new FormData();
for (let [key, value] of formData.entries()) {
profileData.append(`profile_${key}`, value);
}
const data = await apiCall('/users/me/edit-profile', {
method: 'POST',
body: profileData
});
if (data.success) {
location.reload();
}
}
// Modal close on background click
document.getElementById('modal')?.addEventListener('click', (e) => {
if (e.target.id === 'modal') {
closeModal();
}
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@ -0,0 +1,133 @@
<div id="modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modalTitle">Modal Title</h2>
<button class="close-btn" onclick="closeModal()">&times;</button>
</div>
<div id="modalBody">
<!-- Login Modal -->
<div id="loginModal" style="display: none;">
<h2>Login</h2>
<form action="/login" method="POST">
<div class="alert error" id="loginError"></div>
<div class="form-group">
<label>Username or Email</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn" style="width: 100%;">Login</button>
<p style="text-align: center; margin-top: 1rem;">
Don't have an account? <a href="#" onclick="showModal('register'); return false;">Sign up</a>
</p>
</form>
</div>
<!-- Register Modal -->
<div id="registerModal" style="display: none;">
<h2>Sign Up</h2>
<form onsubmit="submitRegister(event); return false;">
<div class="alert error" id="registerError"></div>
<div class="form-group">
<label>Email</label>
<input type="email" name="email" required>
</div>
<div class="form-group">
<label>Username</label>
<input type="text" name="username" required minlength="4" maxlength="15">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn" style="width: 100%;">Sign Up</button>
<p style="text-align: center; margin-top: 1rem;">
Already have an account? <a href="#" onclick="showModal('login'); return false;">Login</a>
</p>
</form>
</div>
<!-- Create Rant Modal -->
<div id="createRantModal" style="display: none;">
<h2>Create Rant</h2>
<form onsubmit="submitCreateRant(event); return false;">
<div class="alert error" id="rantError"></div>
<div class="form-group">
<label>What's on your mind?</label>
<textarea name="rant" placeholder="Share your thoughts..." required></textarea>
</div>
<div class="form-group">
<label>Tags (comma separated)</label>
<input type="text" name="tags" placeholder="rant, javascript, devops">
</div>
<div class="form-group">
<label>Type</label>
<select name="type">
<option value="1">Rant</option>
<option value="2">Collab</option>
<option value="3">Question</option>
<option value="4">devRant</option>
<option value="5">Random</option>
</select>
</div>
<button type="submit" class="btn" style="width: 100%;">Post Rant</button>
</form>
</div>
<!-- Edit Profile Modal -->
<div id="editProfileModal" style="display: none;">
<h2>Edit Profile</h2>
<form onsubmit="submitEditProfile(event); return false;">
<div class="alert success" id="profileSuccess"></div>
<div class="form-group">
<label>About</label>
<textarea name="about" placeholder="Tell us about yourself..."></textarea>
</div>
<div class="form-group">
<label>Skills</label>
<input type="text" name="skills" placeholder="JavaScript, Python, DevOps">
</div>
<div class="form-group">
<label>Location</label>
<input type="text" name="location" placeholder="San Francisco, CA">
</div>
<div class="form-group">
<label>Website</label>
<input type="url" name="website" placeholder="https://example.com">
</div>
<div class="form-group">
<label>GitHub Username</label>
<input type="text" name="github" placeholder="username">
</div>
<button type="submit" class="btn" style="width: 100%;">Update Profile</button>
</form>
</div>
</div>
</div>
</div>
<script>
async function submitRegister(event) {
event.preventDefault();
const formData = new FormData(event.target);
formData.append('app', 3);
formData.append('type', 1);
const response = await fetch('/api/users', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
closeModal();
showModal('login');
alert('Registration successful! Please login.');
} else {
document.getElementById('registerError').textContent = data.error;
document.getElementById('registerError').classList.add('active');
}
}
</script>

View File

@ -0,0 +1,23 @@
<nav>
<div class="nav-container">
<a href="/" class="logo">Rant</a>
<div class="nav-links">
<a href="/">Feed</a>
<a href="/search">Search</a>
{% if current_user %}
<span>
<a href="/profile/{{ current_user.id }}">Profile</a>
<a href="/notifications">Notifications {% if notif_count > 0 %}<span style="color: var(--error);">({{ notif_count }})</span>{% endif %}</a>
</span>
<span>
<a href="/logout" class="btn btn-secondary">Logout</a>
</span>
{% else %}
<span>
<button class="btn btn-secondary" onclick="showModal('login')">Login</button>
<button class="btn" onclick="showModal('register')">Sign Up</button>
</span>
{% endif %}
</div>
</div>
</nav>

View File

@ -0,0 +1,35 @@
{% macro render_rant_card(rant, clickable=True) %}
<div class="rant-card" {% if clickable %}onclick="if (!event.target.closest('button') && !event.target.closest('.username')) { window.location.href='/rant/{{ rant.id }}'; }" style="cursor: pointer;"{% endif %}>
<div class="rant-header">
<div class="avatar" style="background: #{{ rant.user_avatar.b }}">
{{ rant.user_username[0]|upper }}
</div>
<div class="user-info">
<div class="username" onclick="event.stopPropagation(); window.location.href='/profile/{{ rant.user_id }}';" style="cursor: pointer;">{{ rant.user_username }}</div>
<div class="score">{{ rant.user_score }} points</div>
</div>
</div>
<div class="rant-content">{{ escape_html(rant.text) }}</div>
{% if rant.attached_image %}
<img src="{{ rant.attached_image }}" alt="Rant image" class="rant-image">
{% endif %}
{% if rant.tags %}
<div class="tags">
{% for tag in rant.tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<div class="rant-footer">
<div class="rant-actions">
<button class="action-btn {% if rant.vote_state == 1 %}voted{% elif rant.vote_state == -1 %}downvoted{% endif %}" onclick="event.stopPropagation(); voteRant({{ rant.id }}, {{ 0 if rant.vote_state == 1 else 1 }})">
++ {{ rant.score }}
</button>
<button class="action-btn" onclick="event.stopPropagation(); window.location.href='/rant/{{ rant.id }}';">
💬 {{ rant.num_comments }}
</button>
</div>
<div>{{ format_time(rant.created_time) }}</div>
</div>
</div>
{% endmacro %}

16
templates/feed.html Normal file
View File

@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% from 'components/rant_card.html' import render_rant_card %}
{% block content %}
<div class="sort-options">
<a href="/?sort=recent" class="sort-btn {% if current_sort == 'recent' %}active{% endif %}">Recent</a>
<a href="/?sort=top" class="sort-btn {% if current_sort == 'top' %}active{% endif %}">Top</a>
<a href="/?sort=algo" class="sort-btn {% if current_sort == 'algo' %}active{% endif %}">Algorithm</a>
</div>
<div>
{% for rant in rants %}
{{ render_rant_card(rant) }}
{% endfor %}
</div>
{% endblock %}

26
templates/login.html Normal file
View File

@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block content %}
<div style="max-width: 400px; margin: 0 auto;">
<div class="rant-card">
<h2>Login</h2>
<form action="/login" method="POST">
{% if error %}
<div class="alert error active">{{ error }}</div>
{% endif %}
<div class="form-group">
<label>Username or Email</label>
<input type="text" name="username" required>
</div>
<div class="form-group">
<label>Password</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn" style="width: 100%;">Login</button>
<p style="text-align: center; margin-top: 1rem;">
Don't have an account? <a href="#" onclick="showModal('register'); return false;">Sign up</a>
</p>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block content %}
<h2>Notifications</h2>
{% if items %}
{% for notif in items %}
<div class="rant-card" onclick="window.location.href='/rant/{{ notif.rant_id }}';" style="cursor: pointer;">
<p><strong>{{ notif.username }}</strong> {% if notif.type == 'comment' %}commented on your rant{% else %}mentioned you{% endif %}</p>
<p style="color: var(--text-dim); font-size: 0.9rem;">{{ format_time(notif.created_time) }}</p>
</div>
{% endfor %}
{% else %}
<p style="text-align: center; color: var(--text-dim); margin-top: 2rem;">No notifications</p>
{% endif %}
{% endblock %}

74
templates/profile.html Normal file
View File

@ -0,0 +1,74 @@
{% extends 'base.html' %}
{% from 'components/rant_card.html' import render_rant_card %}
{% block content %}
<a href="/" class="btn btn-secondary">← Back to Feed</a>
<div class="profile-header">
<div class="profile-avatar avatar" style="background: #{{ profile.avatar.b }}">
{{ profile.username[0]|upper }}
</div>
<h1>{{ profile.username }}</h1>
{% if profile.about %}
<p style="margin-top: 1rem;">{{ escape_html(profile.about) }}</p>
{% endif %}
{% if profile.skills %}
<p><strong>Skills:</strong> {{ escape_html(profile.skills) }}</p>
{% endif %}
{% if profile.location %}
<p><strong>Location:</strong> {{ escape_html(profile.location) }}</p>
{% endif %}
{% if profile.github %}
<p><strong>GitHub:</strong> <a href="https://github.com/{{ profile.github }}" target="_blank">{{ profile.github }}</a></p>
{% endif %}
{% if profile.website %}
<p><strong>Website:</strong> <a href="{{ profile.website }}" target="_blank">{{ profile.website }}</a></p>
{% endif %}
<div class="profile-stats">
<div class="stat">
<div class="stat-value">{{ profile.score }}</div>
<div class="stat-label">Score</div>
</div>
<div class="stat">
<div class="stat-value">{{ rants|length }}</div>
<div class="stat-label">Rants</div>
</div>
<div class="stat">
<div class="stat-value">{{ comments|length }}</div>
<div class="stat-label">Comments</div>
</div>
</div>
{% if current_user and current_user.id == profile_user_id %}
<button class="btn" onclick="showModal('edit-profile')" style="margin-top: 1rem;">Edit Profile</button>
{% endif %}
</div>
<div class="tabs">
<a href="/profile/{{ profile_user_id }}?tab=rants" class="tab {% if active_tab == 'rants' %}active{% endif %}">Rants</a>
<a href="/profile/{{ profile_user_id }}?tab=comments" class="tab {% if active_tab == 'comments' %}active{% endif %}">Comments</a>
<a href="/profile/{{ profile_user_id }}?tab=favorites" class="tab {% if active_tab == 'favorites' %}active{% endif %}">Favorites</a>
</div>
<div id="profileContent">
{% if active_tab == 'rants' %}
{% for rant in rants %}
{{ render_rant_card(rant) }}
{% endfor %}
{% elif active_tab == 'comments' %}
{% for comment in comments %}
<div class="rant-card" onclick="window.location.href='/rant/{{ comment.rant_id }}';" style="cursor: pointer;">
<div class="rant-content">{{ escape_html(comment.body) }}</div>
<div class="rant-footer">
<div class="rant-actions">
<button class="action-btn">++ {{ comment.score }}</button>
</div>
<div>{{ format_time(comment.created_time) }}</div>
</div>
</div>
{% endfor %}
{% elif active_tab == 'favorites' %}
{% for rant in favorites %}
{{ render_rant_card(rant) }}
{% endfor %}
{% endif %}
</div>
{% endblock %}

103
templates/rant_detail.html Normal file
View File

@ -0,0 +1,103 @@
{% extends 'base.html' %}
{% block content %}
<a href="/" class="btn btn-secondary">← Back to Feed</a>
<div class="rant-card" style="margin-top: 1rem; cursor: default;">
<div class="rant-header">
<div class="avatar" style="background: #{{ rant.user_avatar.b }}">
{{ rant.user_username[0]|upper }}
</div>
<div class="user-info">
<div class="username" onclick="window.location.href='/profile/{{ rant.user_id }}';" style="cursor: pointer;">{{ rant.user_username }}</div>
<div class="score">{{ rant.user_score }} points</div>
</div>
{% if current_user and current_user.id == rant.user_id %}
<button class="btn btn-secondary" onclick="showModal('edit-rant')">Edit</button>
<button class="btn btn-secondary" onclick="deleteRant({{ rant.id }})" style="margin-left: 0.5rem;">Delete</button>
{% endif %}
</div>
<div class="rant-content">{{ escape_html(rant.text) }}</div>
{% if rant.attached_image %}
<img src="{{ rant.attached_image }}" alt="Rant image" class="rant-image">
{% endif %}
{% if rant.tags %}
<div class="tags">
{% for tag in rant.tags %}
<span class="tag">{{ tag }}</span>
{% endfor %}
</div>
{% endif %}
<div class="rant-footer">
<div class="rant-actions">
<button class="action-btn {% if rant.vote_state == 1 %}voted{% endif %}" onclick="voteRant({{ rant.id }}, {{ 0 if rant.vote_state == 1 else 1 }})">
++ {{ rant.score }}
</button>
<button class="action-btn {% if rant.vote_state == -1 %}downvoted{% endif %}" onclick="voteRant({{ rant.id }}, {{ 0 if rant.vote_state == -1 else -1 }})">
--
</button>
<button class="action-btn {% if subscribed %}voted{% endif %}" onclick="toggleFavorite({{ rant.id }}, {{ subscribed }})">
{% if subscribed %}★{% else %}☆{% endif %} Favorite
</button>
</div>
<div>{{ format_time(rant.created_time) }}</div>
</div>
</div>
<div class="comments-section">
<h3>Comments ({{ comments|length }})</h3>
<div id="commentsList">
{% for comment in comments %}
<div class="comment">
<div class="rant-header">
<div class="avatar" style="background: #{{ comment.user_avatar.b }}">
{{ comment.user_username[0]|upper }}
</div>
<div class="user-info">
<div class="username" onclick="window.location.href='/profile/{{ comment.user_id }}';" style="cursor: pointer;">{{ comment.user_username }}</div>
<div class="score">{{ comment.user_score }} points</div>
</div>
{% if current_user and current_user.id == comment.user_id %}
<button class="btn btn-secondary" onclick="deleteComment({{ comment.id }})">Delete</button>
{% endif %}
</div>
<div class="rant-content">{{ escape_html(comment.body) }}</div>
<div class="rant-footer">
<div class="rant-actions">
<button class="action-btn {% if comment.vote_state == 1 %}voted{% endif %}" onclick="voteComment({{ comment.id }}, {{ 0 if comment.vote_state == 1 else 1 }})">
++ {{ comment.score }}
</button>
</div>
<div>{{ format_time(comment.created_time) }}</div>
</div>
</div>
{% endfor %}
</div>
{% if current_user %}
<form class="comment-form" onsubmit="submitComment(event); return false;">
<h4>Add a comment</h4>
<div class="form-group">
<textarea name="comment" placeholder="Write your comment..." required></textarea>
</div>
<button type="submit" class="btn">Post Comment</button>
</form>
{% else %}
<p style="text-align: center; margin-top: 2rem;">Login to comment</p>
{% endif %}
</div>
<script>
async function submitComment(event) {
event.preventDefault();
const formData = new FormData(event.target);
const data = await apiCall(`/rant/rants/{{ rant.id }}/comments`, {
method: 'POST',
body: formData
});
if (data.success) {
location.reload();
}
}
</script>
{% endblock %}

23
templates/search.html Normal file
View File

@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% from 'components/rant_card.html' import render_rant_card %}
{% block content %}
<div class="search-box">
<form class="search-form" action="/search" method="GET">
<input type="text" name="term" placeholder="Search rants..." value="{{ search_term or '' }}" required>
<button type="submit" class="btn">Search</button>
</form>
</div>
<div id="searchResults">
{% if search_term %}
{% if results %}
{% for rant in results %}
{{ render_rant_card(rant) }}
{% endfor %}
{% else %}
<p style="text-align: center; color: var(--text-dim);">No results found</p>
{% endif %}
{% endif %}
</div>
{% endblock %}