Compare commits

..
5 Commits
Author SHA1 Message Date
retoor ac9d509287 Update. 2025-08-03 23:38:39 +02:00
retoor cefb8fab3c Update. 2025-08-03 23:37:50 +02:00
retoor d2583440e8 Update. 2025-08-03 20:43:26 +02:00
retoor bd9e9bc294 Changed port number. 2025-08-03 20:37:53 +02:00
retoor 582b6d8173 Initial commit. 2025-08-03 20:36:36 +02:00
21 changed files with 1997 additions and 9460 deletions
+1 -5
View File
@@ -1,6 +1,2 @@
.env
*.old
*.json
__pycache__
*.txt
*.db
uploads
-199
View File
@@ -1,199 +0,0 @@
# Rant Community - Developer Handover Documentation
**Version:** 1.0
**Date:** August 5, 2025
## 1. Introduction
Welcome to the Rant Community application. This document provides a comprehensive overview of the project's architecture, features, and implementation details. It is intended to facilitate a smooth handover to the new development team.
The Rant Community is a full-stack web application that allows users to post short text-based "rants," comment on them, vote, and interact with other users. It features a complete user authentication system, profile management, and a real-time notification system.
The application is built with a Python FastAPI backend, a dynamic frontend using both Jinja2 templates for server-side rendering (SSR) and a vanilla JavaScript component-based architecture for a single-page application (SPA) experience. Data persistence is handled by an SQLite database, accessed asynchronously.
## 2. Project Structure
The project is organized into the following key files and directories:
```
/
|-- main.py # FastAPI application, API endpoints, SSR routes
|-- ads.py # Asynchronous Database Set (SQLite wrapper)
|-- classic.html # SPA-style frontend with JavaScript components
|-- locustfile.py # Performance testing script for the API
|-- rant_community.db # SQLite database file
|-- devrant_log.json # Development log
|-- uploads/ # Directory for user-uploaded images
|-- static/ # Static assets (CSS, JS, images)
| |-- index.html # Duplicate of classic.html
|-- templates/ # Jinja2 templates for SSR
| |-- base.html
| |-- feed.html
| |-- login.html
| |-- notifications.html
| |-- profile.html
| |-- rant_detail.html
| |-- search.html
| |-- components/
| |-- modals.html
| |-- navigation.html
| |-- rant_card.html
```
## 3. Backend (FastAPI)
The backend is a FastAPI application (`main.py`) responsible for serving the API and the server-side rendered pages.
### 3.1. API Endpoints
The application exposes a RESTful API for all core functionalities. All API endpoints are prefixed with `/api`.
**Key Endpoints:**
**Users & Authentication:**
- `POST /api/users`: Register a new user.
- `POST /api/users/auth-token`: Log in a user and receive an auth token.
- `GET /api/users/{user_id}`: Fetch a user's profile.
- `POST /api/users/me/edit-profile`: Edit the current user's profile.
**Rants:**
- `GET /api/rant/rants`: Get a feed of rants.
- `POST /api/rant/rants`: Create a new rant.
- `GET /api/rant/rants/{rant_id}`: Get details of a specific rant.
- `POST /api/rant/rants/{rant_id}`: Update a rant.
- `DELETE /api/rant/rants/{rant_id}`: Delete a rant.
**Comments, Votes, and Favorites:**
- `POST /api/rant/rants/{rant_id}/comments`: Add a comment to a rant.
- `POST /api/rant/rants/{rant_id}/vote`: Vote on a rant.
- `POST /api/rant/rants/{rant_id}/favorite`: Favorite a rant.
- `POST /api/rant/rants/{rant_id}/unfavorite`: Unfavorite a rant.
- `POST /api/comments/{comment_id}/vote`: Vote on a comment.
**Notifications & Search:**
- `GET /api/users/me/notif-feed`: Get notifications for the current user.
- `GET /api/rant/search`: Search for rants.
### 3.2. Authentication
Authentication is token-based. When a user logs in, a unique token is generated and stored in the `auth_tokens` table. This token is then sent to the client and must be included in the headers or body of subsequent requests to authenticated endpoints.
The `authenticate_user` function in `main.py` validates the provided token (`token_id`, `token_key`, `user_id`).
### 3.3. Database (ads.py)
The `ads.py` file contains the `AsyncDataSet` class, a powerful asynchronous wrapper for the SQLite database. It simplifies database operations by providing an intuitive, high-level API.
**Key Features of AsyncDataSet:**
- **Asynchronous Operations:** All database calls are non-blocking, using `aiosqlite`.
- **Automatic Schema Migration:** The class automatically adds missing tables and columns on the fly, preventing errors and simplifying development.
- **CRUD and More:** Provides methods for insert, update, delete, get, find, upsert, count, and exists.
- **Raw SQL Execution:** Allows for executing complex queries with `execute_raw` and `query_raw`.
- **Transactions:** Supports atomic operations using an `async with` transaction context.
- **KV Store:** Includes a simple key-value store functionality (`kv_set`, `kv_get`).
### 3.4. Database Schema
The database schema is defined in the `init_db` function in `main.py`. It consists of the following tables:
- `users`: Stores user information, credentials, and profile details.
- `auth_tokens`: Manages user authentication tokens.
- `rants`: Contains all the rants posted by users.
- `comments`: Stores comments on rants.
- `votes`: Tracks user votes on rants and comments.
- `favorites`: Manages users' favorited rants.
- `notifications`: Stores notifications for users.
## 4. Frontend
The application employs a hybrid frontend strategy, combining server-side rendering (SSR) with a client-side single-page application (SPA) architecture.
### 4.1. Server-Side Rendering (SSR)
The main pages of the application (feed, rant details, profiles) are rendered on the server using Jinja2 templates. This approach ensures fast initial page loads and good SEO.
The `templates/` directory contains all the Jinja2 templates. `base.html` serves as the main layout file. Reusable UI components are defined in `templates/components/`.
### 4.2. Single-Page Application (classic.html)
The `classic.html` file provides an alternative, fully client-side rendered version of the application. It is a self-contained SPA built with vanilla JavaScript, demonstrating a component-based architecture.
**Key Components of the SPA:**
- **EventBus:** A global event bus for communication between different components. This decouples components and allows for a more modular architecture.
- **AuthManager:** A singleton class that handles user authentication on the client side, including storing the auth token in `localStorage`.
- **Custom Elements (Web Components):** The UI is built using custom elements for different parts of the application, such as:
- `rant-navigation`
- `rant-card`
- `feed-view`
- `rant-detail-view`
- `comments-section`
- `profile-view`
- `rant-modal`
- **RantApp:** The main application component that manages routing and view rendering.
## 5. Getting Started
### 5.1. Prerequisites
- Python 3.7+
- pip
### 5.2. Installation
- Clone the repository.
- Install the required Python packages:
```
pip install fastapi "uvicorn[standard]" aiosqlite python-multipart
```
### 5.3. Running the Application
Start the FastAPI server:
```
uvicorn main:app --host 0.0.0.0 --port 8111 --reload
```
The application will be available at `http://127.0.0.1:8111`.
### 5.4. Performance Testing
The project includes a `locustfile.py` for performance testing the API using Locust.
- Install Locust:
```
pip install locust
```
- Run the tests:
```
locust -f locustfile.py
```
- Open `http://localhost:8089` in your browser to start the test.
## 6. Key Features and Implementation
### 6.1. Rant and Comment System
Users can create, edit, and delete their own rants and comments. Content is sanitized using an `escapeHtml` function to prevent XSS attacks. Rants can have tags, which are stored as a JSON string in the database.
### 6.2. Voting System
Users can upvote (+1) or downvote (-1) rants and comments. The votes table tracks each vote to prevent users from voting multiple times on the same item. The score of rants, comments, and users is updated in real-time.
### 6.3. User Profiles
Each user has a public profile page displaying their rants, comments, and favorited posts. Users can edit their profile information, including their bio, skills, and social links.
### 6.4. Notifications
Users receive notifications for comments on their rants and for mentions. The navigation bar displays a real-time count of unread notifications. The `/api/users/me/notif-feed` endpoint marks notifications as read when they are fetched.
### 6.5. Search
The application provides a simple search functionality that looks for matching terms in rant text and tags.
## 7. Future Improvements
- **WebSocket Integration:** Implement WebSockets for real-time updates (e.g., new comments, live notifications) without needing to reload the page.
- **Database Migration Tool:** Integrate a proper database migration tool like Alembic to manage schema changes more robustly.
- **Frontend Framework:** For more complex features, consider migrating the frontend to a modern JavaScript framework like React, Vue, or Svelte.
- **Containerization:** Dockerize the application for easier deployment and environment consistency.
- **Testing:** Expand the test suite with more comprehensive unit and integration tests for both the backend and frontend.
-611
View File
@@ -1,611 +0,0 @@
import re
import json
from uuid import uuid4
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, AsyncGenerator, Union, Tuple, Set
from pathlib import Path
import aiosqlite
import unittest
from types import SimpleNamespace
import asyncio
class AsyncDataSet:
_KV_TABLE = "__kv_store"
_DEFAULT_COLUMNS = {
"uid": "TEXT PRIMARY KEY",
"created_at": "TEXT",
"updated_at": "TEXT",
"deleted_at": "TEXT",
}
def __init__(self, file: str):
self._file = file
self._table_columns_cache: Dict[str, Set[str]] = {}
@staticmethod
def _utc_iso() -> str:
return (
datetime.now(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
@staticmethod
def _py_to_sqlite_type(value: Any) -> str:
if value is None:
return "TEXT"
if isinstance(value, bool):
return "INTEGER"
if isinstance(value, int):
return "INTEGER"
if isinstance(value, float):
return "REAL"
if isinstance(value, (bytes, bytearray, memoryview)):
return "BLOB"
return "TEXT"
async def _get_table_columns(self, table: str) -> Set[str]:
"""Get actual columns that exist in the table."""
if table in self._table_columns_cache:
return self._table_columns_cache[table]
columns = set()
try:
async with aiosqlite.connect(self._file) as db:
async with db.execute(f"PRAGMA table_info({table})") as cursor:
async for row in cursor:
columns.add(row[1]) # Column name is at index 1
self._table_columns_cache[table] = columns
except:
pass
return columns
async def _invalidate_column_cache(self, table: str):
"""Invalidate column cache for a table."""
if table in self._table_columns_cache:
del self._table_columns_cache[table]
async def _ensure_column(self, table: str, name: str, value: Any) -> None:
col_type = self._py_to_sqlite_type(value)
try:
async with aiosqlite.connect(self._file) as db:
await db.execute(f"ALTER TABLE {table} ADD COLUMN `{name}` {col_type}")
await db.commit()
await self._invalidate_column_cache(table)
except aiosqlite.OperationalError as e:
if "duplicate column name" in str(e).lower():
pass # Column already exists
else:
raise
async def _ensure_table(self, table: str, col_sources: Dict[str, Any]) -> None:
# Always include default columns
cols = self._DEFAULT_COLUMNS.copy()
# Add columns from col_sources
for key, val in col_sources.items():
if key not in cols:
cols[key] = self._py_to_sqlite_type(val)
columns_sql = ", ".join(f"`{k}` {t}" for k, t in cols.items())
async with aiosqlite.connect(self._file) as db:
await db.execute(f"CREATE TABLE IF NOT EXISTS {table} ({columns_sql})")
await db.commit()
await self._invalidate_column_cache(table)
async def _table_exists(self, table: str) -> bool:
"""Check if a table exists."""
async with aiosqlite.connect(self._file) as db:
async with db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)
) as cursor:
return await cursor.fetchone() is not None
_RE_NO_COLUMN = re.compile(r"(?:no such column:|has no column named) (\w+)")
_RE_NO_TABLE = re.compile(r"no such table: (\w+)")
@classmethod
def _missing_column_from_error(
cls, err: aiosqlite.OperationalError
) -> Optional[str]:
m = cls._RE_NO_COLUMN.search(str(err))
return m.group(1) if m else None
@classmethod
def _missing_table_from_error(
cls, err: aiosqlite.OperationalError
) -> Optional[str]:
m = cls._RE_NO_TABLE.search(str(err))
return m.group(1) if m else None
async def _safe_execute(
self,
table: str,
sql: str,
params: Iterable[Any],
col_sources: Dict[str, Any],
max_retries: int = 10
) -> aiosqlite.Cursor:
retries = 0
while retries < max_retries:
try:
async with aiosqlite.connect(self._file) as db:
cursor = await db.execute(sql, params)
await db.commit()
return cursor
except aiosqlite.OperationalError as err:
retries += 1
err_str = str(err).lower()
# Handle missing column
col = self._missing_column_from_error(err)
if col:
if col in col_sources:
await self._ensure_column(table, col, col_sources[col])
else:
# Column not in sources, ensure it with NULL/TEXT type
await self._ensure_column(table, col, None)
continue
# Handle missing table
tbl = self._missing_table_from_error(err)
if tbl:
await self._ensure_table(tbl, col_sources)
continue
# Handle other column-related errors
if "has no column named" in err_str:
# Extract column name differently
match = re.search(r"table \w+ has no column named (\w+)", err_str)
if match:
col_name = match.group(1)
if col_name in col_sources:
await self._ensure_column(table, col_name, col_sources[col_name])
else:
await self._ensure_column(table, col_name, None)
continue
raise
raise Exception(f"Max retries ({max_retries}) exceeded")
async def _filter_existing_columns(self, table: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""Filter data to only include columns that exist in the table."""
if not await self._table_exists(table):
return data
existing_columns = await self._get_table_columns(table)
if not existing_columns:
return data
return {k: v for k, v in data.items() if k in existing_columns}
async def _safe_query(
self,
table: str,
sql: str,
params: Iterable[Any],
col_sources: Dict[str, Any],
) -> AsyncGenerator[Dict[str, Any], None]:
# Check if table exists first
if not await self._table_exists(table):
return
max_retries = 10
retries = 0
while retries < max_retries:
try:
async with aiosqlite.connect(self._file) as db:
db.row_factory = aiosqlite.Row
async with db.execute(sql, params) as cursor:
async for row in cursor:
yield dict(row)
return
except aiosqlite.OperationalError as err:
retries += 1
err_str = str(err).lower()
# Handle missing table
tbl = self._missing_table_from_error(err)
if tbl:
# For queries, if table doesn't exist, just return empty
return
# Handle missing column in WHERE clause or SELECT
if "no such column" in err_str:
# For queries with missing columns, return empty
return
raise
@staticmethod
def _build_where(where: Optional[Dict[str, Any]]) -> tuple[str, List[Any]]:
if not where:
return "", []
clauses, vals = zip(*[(f"`{k}` = ?", v) for k, v in where.items()])
return " WHERE " + " AND ".join(clauses), list(vals)
async def insert(self, table: str, args: Dict[str, Any], return_id: bool = False) -> Union[str, int]:
"""Insert a record. If return_id=True, returns auto-incremented ID instead of UUID."""
uid = str(uuid4())
now = self._utc_iso()
record = {
"uid": uid,
"created_at": now,
"updated_at": now,
"deleted_at": None,
**args,
}
# Ensure table exists with all needed columns
await self._ensure_table(table, record)
# Handle auto-increment ID if requested
if return_id and 'id' not in args:
# Ensure id column exists
async with aiosqlite.connect(self._file) as db:
# Add id column if it doesn't exist
try:
await db.execute(f"ALTER TABLE {table} ADD COLUMN id INTEGER PRIMARY KEY AUTOINCREMENT")
await db.commit()
except aiosqlite.OperationalError as e:
if "duplicate column name" not in str(e).lower():
# Try without autoincrement constraint
try:
await db.execute(f"ALTER TABLE {table} ADD COLUMN id INTEGER")
await db.commit()
except:
pass
await self._invalidate_column_cache(table)
# Insert and get lastrowid
cols = "`" + "`, `".join(record.keys()) + "`"
qs = ", ".join(["?"] * len(record))
sql = f"INSERT INTO {table} ({cols}) VALUES ({qs})"
cursor = await self._safe_execute(table, sql, list(record.values()), record)
return cursor.lastrowid
cols = "`" + "`, `".join(record) + "`"
qs = ", ".join(["?"] * len(record))
sql = f"INSERT INTO {table} ({cols}) VALUES ({qs})"
await self._safe_execute(table, sql, list(record.values()), record)
return uid
async def update(
self,
table: str,
args: Dict[str, Any],
where: Optional[Dict[str, Any]] = None,
) -> int:
if not args:
return 0
# Check if table exists
if not await self._table_exists(table):
return 0
args["updated_at"] = self._utc_iso()
# Ensure all columns exist
all_cols = {**args, **(where or {})}
await self._ensure_table(table, all_cols)
for col, val in all_cols.items():
await self._ensure_column(table, col, val)
set_clause = ", ".join(f"`{k}` = ?" for k in args)
where_clause, where_params = self._build_where(where)
sql = f"UPDATE {table} SET {set_clause}{where_clause}"
params = list(args.values()) + where_params
cur = await self._safe_execute(table, sql, params, all_cols)
return cur.rowcount
async def delete(self, table: str, where: Optional[Dict[str, Any]] = None) -> int:
# Check if table exists
if not await self._table_exists(table):
return 0
where_clause, where_params = self._build_where(where)
sql = f"DELETE FROM {table}{where_clause}"
cur = await self._safe_execute(table, sql, where_params, where or {})
return cur.rowcount
async def upsert(
self,
table: str,
args: Dict[str, Any],
where: Optional[Dict[str, Any]] = None,
) -> str | None:
if not args:
raise ValueError("Nothing to update. Empty dict given.")
args['updated_at'] = self._utc_iso()
affected = await self.update(table, args, where)
if affected:
rec = await self.get(table, where)
return rec.get("uid") if rec else None
merged = {**(where or {}), **args}
return await self.insert(table, merged)
async def get(
self, table: str, where: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
where_clause, where_params = self._build_where(where)
sql = f"SELECT * FROM {table}{where_clause} LIMIT 1"
async for row in self._safe_query(table, sql, where_params, where or {}):
return row
return None
async def find(
self,
table: str,
where: Optional[Dict[str, Any]] = None,
*,
limit: int = 0,
offset: int = 0,
order_by: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Find records with optional ordering."""
where_clause, where_params = self._build_where(where)
order_clause = f" ORDER BY {order_by}" if order_by else ""
extra = (f" LIMIT {limit}" if limit else "") + (
f" OFFSET {offset}" if offset else ""
)
sql = f"SELECT * FROM {table}{where_clause}{order_clause}{extra}"
return [
row async for row in self._safe_query(table, sql, where_params, where or {})
]
async def count(self, table: str, where: Optional[Dict[str, Any]] = None) -> int:
# Check if table exists
if not await self._table_exists(table):
return 0
where_clause, where_params = self._build_where(where)
sql = f"SELECT COUNT(*) FROM {table}{where_clause}"
gen = self._safe_query(table, sql, where_params, where or {})
async for row in gen:
return next(iter(row.values()), 0)
return 0
async def exists(self, table: str, where: Dict[str, Any]) -> bool:
return (await self.count(table, where)) > 0
async def kv_set(
self,
key: str,
value: Any,
*,
table: str | None = None,
) -> None:
tbl = table or self._KV_TABLE
json_val = json.dumps(value, default=str)
await self.upsert(tbl, {"value": json_val}, {"key": key})
async def kv_get(
self,
key: str,
*,
default: Any = None,
table: str | None = None,
) -> Any:
tbl = table or self._KV_TABLE
row = await self.get(tbl, {"key": key})
if not row:
return default
try:
return json.loads(row["value"])
except Exception:
return default
async def execute_raw(self, sql: str, params: Optional[Tuple] = None) -> Any:
"""Execute raw SQL for complex queries like JOINs."""
async with aiosqlite.connect(self._file) as db:
cursor = await db.execute(sql, params or ())
await db.commit()
return cursor
async def query_raw(self, sql: str, params: Optional[Tuple] = None) -> List[Dict[str, Any]]:
"""Execute raw SQL query and return results as list of dicts."""
try:
async with aiosqlite.connect(self._file) as db:
db.row_factory = aiosqlite.Row
async with db.execute(sql, params or ()) as cursor:
return [dict(row) async for row in cursor]
except aiosqlite.OperationalError:
# Return empty list if query fails
return []
async def query_one(self, sql: str, params: Optional[Tuple] = None) -> Optional[Dict[str, Any]]:
"""Execute raw SQL query and return single result."""
results = await self.query_raw(sql + " LIMIT 1", params)
return results[0] if results else None
async def create_table(self, table: str, schema: Dict[str, str], constraints: Optional[List[str]] = None):
"""Create table with custom schema and constraints. Always includes default columns."""
# Merge default columns with custom schema
full_schema = self._DEFAULT_COLUMNS.copy()
full_schema.update(schema)
columns = [f"`{col}` {dtype}" for col, dtype in full_schema.items()]
if constraints:
columns.extend(constraints)
columns_sql = ", ".join(columns)
async with aiosqlite.connect(self._file) as db:
await db.execute(f"CREATE TABLE IF NOT EXISTS {table} ({columns_sql})")
await db.commit()
await self._invalidate_column_cache(table)
async def insert_unique(self, table: str, args: Dict[str, Any], unique_fields: List[str]) -> Union[str, None]:
"""Insert with unique constraint handling. Returns uid on success, None if duplicate."""
try:
return await self.insert(table, args)
except aiosqlite.IntegrityError as e:
if "UNIQUE" in str(e):
return None
raise
async def transaction(self):
"""Context manager for transactions."""
return TransactionContext(self._file)
async def aggregate(self, table: str, function: str, column: str = "*", where: Optional[Dict[str, Any]] = None) -> Any:
"""Perform aggregate functions like SUM, AVG, MAX, MIN."""
# Check if table exists
if not await self._table_exists(table):
return None
where_clause, where_params = self._build_where(where)
sql = f"SELECT {function}({column}) as result FROM {table}{where_clause}"
result = await self.query_one(sql, tuple(where_params))
return result['result'] if result else None
class TransactionContext:
"""Context manager for database transactions."""
def __init__(self, db_file: str):
self.db_file = db_file
self.conn = None
async def __aenter__(self):
self.conn = await aiosqlite.connect(self.db_file)
self.conn.row_factory = aiosqlite.Row
await self.conn.execute("BEGIN")
return self.conn
async def __aexit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
await self.conn.commit()
else:
await self.conn.rollback()
await self.conn.close()
# Test cases remain the same but with additional tests for new functionality
class TestAsyncDataSet(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.db_path = Path("temp_test.db")
if self.db_path.exists():
self.db_path.unlink()
self.connector = AsyncDataSet(str(self.db_path))
async def asyncTearDown(self):
if self.db_path.exists():
self.db_path.unlink()
async def test_insert_and_get(self):
await self.connector.insert("people", {"name": "John Doe", "age": 30})
rec = await self.connector.get("people", {"name": "John Doe"})
self.assertIsNotNone(rec)
self.assertEqual(rec["name"], "John Doe")
async def test_get_nonexistent(self):
result = await self.connector.get("people", {"name": "Jane Doe"})
self.assertIsNone(result)
async def test_update(self):
await self.connector.insert("people", {"name": "John Doe", "age": 30})
await self.connector.update("people", {"age": 31}, {"name": "John Doe"})
rec = await self.connector.get("people", {"name": "John Doe"})
self.assertEqual(rec["age"], 31)
async def test_order_by(self):
await self.connector.insert("people", {"name": "Alice", "age": 25})
await self.connector.insert("people", {"name": "Bob", "age": 30})
await self.connector.insert("people", {"name": "Charlie", "age": 20})
results = await self.connector.find("people", order_by="age ASC")
self.assertEqual(results[0]["name"], "Charlie")
self.assertEqual(results[-1]["name"], "Bob")
async def test_raw_query(self):
await self.connector.insert("people", {"name": "John", "age": 30})
await self.connector.insert("people", {"name": "Jane", "age": 25})
results = await self.connector.query_raw(
"SELECT * FROM people WHERE age > ?", (26,)
)
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["name"], "John")
async def test_aggregate(self):
await self.connector.insert("people", {"name": "John", "age": 30})
await self.connector.insert("people", {"name": "Jane", "age": 25})
await self.connector.insert("people", {"name": "Bob", "age": 35})
avg_age = await self.connector.aggregate("people", "AVG", "age")
self.assertEqual(avg_age, 30)
max_age = await self.connector.aggregate("people", "MAX", "age")
self.assertEqual(max_age, 35)
async def test_insert_with_auto_id(self):
# Test auto-increment ID functionality
id1 = await self.connector.insert("posts", {"title": "First"}, return_id=True)
id2 = await self.connector.insert("posts", {"title": "Second"}, return_id=True)
self.assertEqual(id2, id1 + 1)
async def test_transaction(self):
async with self.connector.transaction() as conn:
await conn.execute("INSERT INTO people (uid, name, age, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
("test-uid", "John", 30, "2024-01-01", "2024-01-01"))
# Transaction will be committed
rec = await self.connector.get("people", {"name": "John"})
self.assertIsNotNone(rec)
async def test_create_custom_table(self):
schema = {
"id": "INTEGER PRIMARY KEY AUTOINCREMENT",
"username": "TEXT NOT NULL",
"email": "TEXT NOT NULL",
"score": "INTEGER DEFAULT 0"
}
constraints = ["UNIQUE(username)", "UNIQUE(email)"]
await self.connector.create_table("users", schema, constraints)
# Test that table was created with constraints
result = await self.connector.insert_unique(
"users",
{"username": "john", "email": "john@example.com"},
["username", "email"]
)
self.assertIsNotNone(result)
# Test duplicate insert
result = await self.connector.insert_unique(
"users",
{"username": "john", "email": "different@example.com"},
["username", "email"]
)
self.assertIsNone(result)
async def test_missing_table_operations(self):
# Test operations on non-existent tables
self.assertEqual(await self.connector.count("nonexistent"), 0)
self.assertEqual(await self.connector.find("nonexistent"), [])
self.assertIsNone(await self.connector.get("nonexistent"))
self.assertFalse(await self.connector.exists("nonexistent", {"id": 1}))
self.assertEqual(await self.connector.delete("nonexistent"), 0)
self.assertEqual(await self.connector.update("nonexistent", {"name": "test"}), 0)
async def test_auto_column_creation(self):
# Insert with new columns that don't exist yet
await self.connector.insert("dynamic", {"col1": "value1", "col2": 42, "col3": 3.14})
# Add more columns in next insert
await self.connector.insert("dynamic", {"col1": "value2", "col4": True, "col5": None})
# All records should be retrievable
records = await self.connector.find("dynamic")
self.assertEqual(len(records), 2)
if __name__ == "__main__":
unittest.main()
-2252
View File
File diff suppressed because it is too large Load Diff
-1976
View File
File diff suppressed because it is too large Load Diff
-256
View File
@@ -1,256 +0,0 @@
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}")
+1294
View File
File diff suppressed because it is too large Load Diff
+688 -1244
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
<nav>
<div class="nav-container">
<a href="/" class="logo">Rant</a>
<div class="nav-links">
<a href="/">Home</a>
<a href="/chat">Chat</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>
-794
View File
@@ -1,794 +0,0 @@
<!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>
-1616
View File
File diff suppressed because it is too large Load Diff
-133
View File
@@ -1,133 +0,0 @@
<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>
-24
View File
@@ -1,24 +0,0 @@
<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 %}
<a href="/chat">Chat</a>
<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>
-35
View File
@@ -1,35 +0,0 @@
{% 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
View File
@@ -1,16 +0,0 @@
{% 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 %}
-20
View File
@@ -1,20 +0,0 @@
{% extends 'base.html' %}
{% from 'components/rant_card.html' import render_rant_card %}
{% block navigation %}
{% include '_header.html' %}
{% endblock %}
{% 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
View File
@@ -1,26 +0,0 @@
{% 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 %}
-15
View File
@@ -1,15 +0,0 @@
{% 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
View File
@@ -1,74 +0,0 @@
{% 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
View File
@@ -1,103 +0,0 @@
{% 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
View File
@@ -1,23 +0,0 @@
{% 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 %}