feat: add avatar upload and cropping functionality to user profile settings
Implement avatar management with image upload, client-side cropping, and server-side storage. Users can now upload profile pictures from their local filesystem, crop them to a square aspect ratio using a drag-and-resize interface, and save the result. The backend stores cropped images in a dedicated avatars directory and updates the user record with the new avatar path. Includes validation for file type and size limits, plus fallback to default avatar on error.
This commit is contained in:
parent
97689f86d7
commit
7dd1da38b9
123
AGENTS.md
123
AGENTS.md
@ -5,16 +5,11 @@
|
|||||||
```bash
|
```bash
|
||||||
make install # pip install -e .
|
make install # pip install -e .
|
||||||
make dev # uvicorn --reload on port 10500
|
make dev # uvicorn --reload on port 10500
|
||||||
make test # run all tests headless
|
make test # 95 tests headless
|
||||||
make test-headed # run tests in visible browser
|
make test-headed # same tests in visible browser
|
||||||
|
make demo # full-journey GUI demo (headed)
|
||||||
```
|
```
|
||||||
|
|
||||||
Single test file: `python -m pytest tests/test_auth.py -v --tb=line`
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
Use `hawk` (not ruff/mypy): `hawk .`
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **FastAPI** backend serving **Jinja2 templates** (SSR). Pure ES6 JS for interactivity.
|
- **FastAPI** backend serving **Jinja2 templates** (SSR). Pure ES6 JS for interactivity.
|
||||||
@ -25,6 +20,7 @@ Use `hawk` (not ruff/mypy): `hawk .`
|
|||||||
- **Ports:** 10500 (dev), 10501 (tests)
|
- **Ports:** 10500 (dev), 10501 (tests)
|
||||||
- **Username:** letters, numbers, hyphens, underscores only. 3-32 chars.
|
- **Username:** letters, numbers, hyphens, underscores only. 3-32 chars.
|
||||||
- **Password:** minimum 6 chars.
|
- **Password:** minimum 6 chars.
|
||||||
|
- **Avatars:** DiceBear-based via local proxy at `/avatar/{style}/{seed}`. Style picker on signup and profile page. Falls back to initial-based SVG if DiceBear is unreachable. Cache is in-memory (cleared on restart).
|
||||||
|
|
||||||
## Routing
|
## Routing
|
||||||
|
|
||||||
@ -45,14 +41,119 @@ Use `hawk` (not ruff/mypy): `hawk .`
|
|||||||
- No comments/docstrings in source — code is self-documenting.
|
- No comments/docstrings in source — code is self-documenting.
|
||||||
- Forbidden variable name patterns: `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_` (see CLAUDE.md for full list).
|
- Forbidden variable name patterns: `_new`, `_old`, `_temp`, `_v2`, `better_`, `my_`, `the_` (see CLAUDE.md for full list).
|
||||||
- Pydantic models exist in `models.py` but routers use `await request.form()` directly for validation.
|
- Pydantic models exist in `models.py` but routers use `await request.form()` directly for validation.
|
||||||
- Template globals: `get_unread_count(user_uid)` and `get_user_projects(user_uid)` available via `devplacepy.templating`.
|
- Template globals available via `devplacepy.templating`: `get_unread_count(user_uid)`, `get_user_projects(user_uid)`, `avatar_url(style, seed, size)`, `avatar_styles()`.
|
||||||
- `DEVPLACE_DATABASE_URL` env var overrides the SQLite path (used by tests for isolation).
|
- `DEVPLACE_DATABASE_URL` env var overrides the SQLite path (used by tests).
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
- **86 tests across 8 files** using **Playwright** (not the pytest-playwright plugin — it conflicts, uninstall it if present).
|
- **95 tests across 9 files** using **Playwright** (not the pytest-playwright plugin — uninstall it if present).
|
||||||
- Headed mode via `PLAYWRIGHT_HEADLESS=0` env var (default: headless).
|
- Headed mode via `PLAYWRIGHT_HEADLESS=0` env var (default: headless).
|
||||||
- Server starts as subprocess on port 10501 with a temp DB (`DEVPLACE_DATABASE_URL`).
|
- Server starts as subprocess on port 10501 with a temp DB (`DEVPLACE_DATABASE_URL`).
|
||||||
- Test users (`alice_test`, `bob_test`) seeded once at session level via HTTP POST to `/auth/signup`.
|
- Test users (`alice_test`, `bob_test`) seeded once at session level via HTTP POST to `/auth/signup`.
|
||||||
- Use `alice` fixture for logged-in session as alice_test, `bob` for bob_test (separate Playwright context).
|
- Use `alice` fixture for logged-in session as alice_test, `bob` for bob_test (separate Playwright context).
|
||||||
- Failure screenshots auto-save to `/tmp/devplace_test_screenshots/`.
|
- Failure screenshots auto-save to `/tmp/devplace_test_screenshots/`.
|
||||||
|
|
||||||
|
## Feature Workflow (for automated agents)
|
||||||
|
|
||||||
|
This is the exact procedure to follow when adding or modifying any feature. Execute every step in order.
|
||||||
|
|
||||||
|
### Step 1: Understand
|
||||||
|
|
||||||
|
- Read the router file for the feature area (`routers/{area}.py`)
|
||||||
|
- Read the template (`templates/{area}.html`)
|
||||||
|
- Read the existing test file (`tests/test_{area}.py`)
|
||||||
|
- Identify what data flows through: form fields → router → template → response
|
||||||
|
|
||||||
|
### Step 2: Implement backend
|
||||||
|
|
||||||
|
- Add or modify the route in the appropriate router file under `routers/`
|
||||||
|
- Use `await request.form()` to read form data, never the Pydantic models
|
||||||
|
- Use `templates.TemplateResponse(...)` from `devplacepy.templating` to render
|
||||||
|
- Redirect with `RedirectResponse(url=..., status_code=302)`
|
||||||
|
- Log every action with `logger.info(...)`
|
||||||
|
- If adding a new database field, just insert it in the dict — `dataset` auto-syncs
|
||||||
|
- If the feature touches user data, add a column to the insert/update dict (e.g. `avatar_style`)
|
||||||
|
- Register new routers in `main.py` with `app.include_router(router, prefix="/{path}")`
|
||||||
|
|
||||||
|
### Step 3: Implement frontend
|
||||||
|
|
||||||
|
- Add a new template file in `templates/` or modify an existing one
|
||||||
|
- Create a CSS file in `static/css/` if the page needs custom styles
|
||||||
|
- Load the CSS in the template's `{% block extra_head %}` with `<link rel="stylesheet">`
|
||||||
|
- Use `{% extends "base.html" %}` and `{% block content %}` for page templates
|
||||||
|
- Jinja2 globals available: `avatar_url()`, `avatar_styles()`, `get_unread_count()`, `get_user_projects()`
|
||||||
|
- For interactivity, add a class to `static/js/Application.js` and instantiate in the constructor
|
||||||
|
- No NPM, no frameworks — pure ES6 modules
|
||||||
|
|
||||||
|
### Step 4: Validate code
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hawk .
|
||||||
|
```
|
||||||
|
|
||||||
|
Zero errors required. If hawk fails, fix before proceeding.
|
||||||
|
|
||||||
|
### Step 5: Run existing tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
All 95 tests must pass. If any fail, investigate and fix. Do NOT proceed with failing tests.
|
||||||
|
|
||||||
|
### Step 6: Write new tests
|
||||||
|
|
||||||
|
- Add tests in `tests/test_{area}.py`
|
||||||
|
- Use `alice` fixture for authenticated sessions as `alice_test`
|
||||||
|
- Use `bob` fixture for multi-user scenarios (separate browser context)
|
||||||
|
- Use `page`, `app_server` for unauthenticated page checks
|
||||||
|
- Assert on visible text/content, not on internal state
|
||||||
|
- Use `page.wait_for_url("**/pattern")` for navigation assertions
|
||||||
|
- Use `page.wait_for_timeout(500)` for form re-render waits
|
||||||
|
- Test both success paths and error/validation paths
|
||||||
|
- For form submissions that re-render the same page (validation errors), click without `expect_navigation` — just wait for the error text
|
||||||
|
- For form submissions that redirect, use `expect_navigation` wrapping the click, then `wait_for_url`
|
||||||
|
|
||||||
|
### Step 7: Run full suite again
|
||||||
|
|
||||||
|
```bash
|
||||||
|
hawk .
|
||||||
|
make test
|
||||||
|
make test-headed # visual confirmation in headed browser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Visual verification (if UI changed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start server, seed data, take screenshot
|
||||||
|
falcon take --output /tmp/verify.png
|
||||||
|
falcon describe /tmp/verify.png
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the AI description matches the intended UI. If it does not, iterate.
|
||||||
|
|
||||||
|
### Step 9: Document
|
||||||
|
|
||||||
|
- Update `AGENTS.md` if the feature introduces new conventions
|
||||||
|
- Update `README.md` if the feature adds routes, config, or dependencies
|
||||||
|
- Add the route to the routing table in README.md
|
||||||
|
|
||||||
|
## Autonomous Agentic CI Workflow
|
||||||
|
|
||||||
|
When working without user oversight, run this loop:
|
||||||
|
|
||||||
|
```
|
||||||
|
while feature_not_complete:
|
||||||
|
1. Plan: read existing code, design the change
|
||||||
|
2. Implement: write code (router → template → CSS → JS)
|
||||||
|
3. hawk . # must pass
|
||||||
|
4. make test # all 95 must pass
|
||||||
|
5. If tests fail: diagnose → fix → goto 3
|
||||||
|
6. Update tests if new functionality was added
|
||||||
|
7. make test # re-verify after test changes
|
||||||
|
8. falcon take + describe # visual check for UI changes
|
||||||
|
9. If visual fail: fix CSS/template → goto 3
|
||||||
|
10. Update AGENTS.md if needed
|
||||||
|
```
|
||||||
|
|
||||||
|
Failures at any step block the workflow. Never skip a failed step.
|
||||||
|
|||||||
91
README.md
Normal file
91
README.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# DevPlace — The Developer Social Network
|
||||||
|
|
||||||
|
Server-rendered social network for developers. FastAPI backend serving Jinja2 templates with ES6 interactivity. Avatar generation via DiceBear proxy. SQLite storage via `dataset`.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make install # pip install -e .
|
||||||
|
make dev # uvicorn --reload on port 10500
|
||||||
|
make test # 95 Playwright integration tests, headless
|
||||||
|
make test-headed # same tests in visible browser
|
||||||
|
make demo # absurd full-journey demo (headed)
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:10500`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| Backend | Python 3.13+, FastAPI, Uvicorn |
|
||||||
|
| Templates | Jinja2 (server-side rendered) |
|
||||||
|
| Frontend | Pure ES6 JavaScript, one class per file |
|
||||||
|
| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs) |
|
||||||
|
| Auth | Session cookies, SHA256+SALT via passlib |
|
||||||
|
| Avatars | DiceBear 9.x via local proxy (`/avatar/{style}/{seed}`) |
|
||||||
|
| Validation | `hawk` (Python/JS/CSS/HTML) |
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
devplacepy/
|
||||||
|
main.py # FastAPI app, router registration
|
||||||
|
config.py # Settings from env vars + .env
|
||||||
|
database.py # dataset connection, index creation
|
||||||
|
templating.py # Shared Jinja2 environment + globals
|
||||||
|
avatar.py # DiceBear styles, URL generation
|
||||||
|
utils.py # Password hashing, session mgmt, time_ago
|
||||||
|
models.py # Pydantic schemas (validated but unused in routes)
|
||||||
|
routers/ # One file per domain (auth, feed, posts, ...)
|
||||||
|
templates/ # Jinja2 HTML templates
|
||||||
|
static/css/ # Page-specific CSS files
|
||||||
|
static/js/ # Application.js (ES6 module)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Prefix | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `/auth` | Signup, login, logout |
|
||||||
|
| `/feed` | Post feed with topic/tab filtering |
|
||||||
|
| `/posts` | Post detail, creation |
|
||||||
|
| `/comments` | Comment creation, deletion |
|
||||||
|
| `/projects` | Project listing, creation |
|
||||||
|
| `/profile` | Profile view, editing |
|
||||||
|
| `/messages` | Direct messaging |
|
||||||
|
| `/notifications` | Notification list, mark read |
|
||||||
|
| `/votes` | Upvote/downvote on posts and comments |
|
||||||
|
| `/avatar` | DiceBear avatar proxy with caching |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
No REST API — everything is form-based POST + redirect. All state lives in the session cookie. Endpoints return HTML or redirect.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Env var | Default | Purpose |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| `DEVPLACE_DATABASE_URL` | `sqlite:///devplace.db` | Database connection string |
|
||||||
|
| `SECRET_KEY` | hardcoded fallback | Session signing key |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **95 integration tests** across 9 files using Playwright (NOT pytest-playwright plugin)
|
||||||
|
- Server starts as subprocess on port 10501 with isolated temp database
|
||||||
|
- Test users `alice_test` / `bob_test` seeded via HTTP at session start
|
||||||
|
- Failure screenshots auto-save to `/tmp/devplace_test_screenshots/`
|
||||||
|
- Headed mode: `PLAYWRIGHT_HEADLESS=0 make test`
|
||||||
|
|
||||||
|
## Feature workflow
|
||||||
|
|
||||||
|
1. Implement the feature (router + template + CSS + JS)
|
||||||
|
2. `hawk .` — validate all source files
|
||||||
|
3. `make test` — run all 95 tests headless
|
||||||
|
4. If adding new functionality, write Playwright tests in `tests/test_*.py`
|
||||||
|
5. For visual features, take a screenshot and verify with `falcon describe`
|
||||||
|
6. Update `AGENTS.md` if any new conventions were introduced
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — DevPlace
|
||||||
40
devplacepy/avatar.py
Normal file
40
devplacepy/avatar.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
AVATAR_STYLES = [
|
||||||
|
"adventurer",
|
||||||
|
"adventurer-neutral",
|
||||||
|
"avataaars",
|
||||||
|
"big-ears",
|
||||||
|
"big-smile",
|
||||||
|
"bottts",
|
||||||
|
"croodles",
|
||||||
|
"fun-emoji",
|
||||||
|
"identicon",
|
||||||
|
"initials",
|
||||||
|
"lorelei",
|
||||||
|
"micah",
|
||||||
|
"miniavs",
|
||||||
|
"notionists",
|
||||||
|
"open-peeps",
|
||||||
|
"personas",
|
||||||
|
"pixel-art",
|
||||||
|
"shapes",
|
||||||
|
"thumbs",
|
||||||
|
]
|
||||||
|
|
||||||
|
DICEBEAR_BASE = "https://api.dicebear.com/9.x"
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_url(style: str, seed: str, size: int = 128) -> str:
|
||||||
|
style = style or "initials"
|
||||||
|
return f"/avatar/{style}/{seed}?size={size}"
|
||||||
|
|
||||||
|
|
||||||
|
def dicebear_proxy_url(style: str, seed: str, size: int = 128) -> str:
|
||||||
|
return f"{DICEBEAR_BASE}/{style}/svg?seed={seed}&size={size}"
|
||||||
|
|
||||||
|
|
||||||
|
def avatar_styles():
|
||||||
|
return AVATAR_STYLES
|
||||||
@ -6,7 +6,7 @@ from devplacepy.config import STATIC_DIR
|
|||||||
from devplacepy.database import init_db
|
from devplacepy.database import init_db
|
||||||
from devplacepy.templating import templates
|
from devplacepy.templating import templates
|
||||||
from devplacepy.utils import get_current_user
|
from devplacepy.utils import get_current_user
|
||||||
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes
|
from devplacepy.routers import auth, feed, posts, comments, projects, profile, messages, notifications, votes, avatar
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@ -26,6 +26,7 @@ app.include_router(profile.router, prefix="/profile")
|
|||||||
app.include_router(messages.router, prefix="/messages")
|
app.include_router(messages.router, prefix="/messages")
|
||||||
app.include_router(notifications.router, prefix="/notifications")
|
app.include_router(notifications.router, prefix="/notifications")
|
||||||
app.include_router(votes.router, prefix="/votes")
|
app.include_router(votes.router, prefix="/votes")
|
||||||
|
app.include_router(avatar.router, prefix="/avatar")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
|
|||||||
@ -59,6 +59,9 @@ async def signup(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
uid = generate_uid()
|
uid = generate_uid()
|
||||||
|
avatar_style = form.get("avatar_style", "initials")
|
||||||
|
if avatar_style not in ("adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile", "bottts", "croodles", "fun-emoji", "identicon", "initials", "lorelei", "micah", "miniavs", "notionists", "open-peeps", "personas", "pixel-art", "shapes", "thumbs"):
|
||||||
|
avatar_style = "initials"
|
||||||
users.insert({
|
users.insert({
|
||||||
"uid": uid,
|
"uid": uid,
|
||||||
"username": username,
|
"username": username,
|
||||||
@ -68,7 +71,7 @@ async def signup(request: Request):
|
|||||||
"location": "",
|
"location": "",
|
||||||
"git_link": "",
|
"git_link": "",
|
||||||
"website": "",
|
"website": "",
|
||||||
"avatar": "",
|
"avatar_style": avatar_style,
|
||||||
"role": "Member",
|
"role": "Member",
|
||||||
"level": 1,
|
"level": 1,
|
||||||
"xp": 0,
|
"xp": 0,
|
||||||
|
|||||||
45
devplacepy/routers/avatar.py
Normal file
45
devplacepy/routers/avatar.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from devplacepy.avatar import dicebear_proxy_url
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _initial_svg(seed: str) -> str:
|
||||||
|
initial = seed[:1].upper() if seed else "?"
|
||||||
|
return (
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
|
||||||
|
f'<rect width="100" height="100" rx="50" fill="#ff6b35"/>'
|
||||||
|
f'<text x="50" y="65" text-anchor="middle" fill="white" font-size="40" font-weight="700" font-family="sans-serif">{initial}</text></svg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{style}/{seed}")
|
||||||
|
async def avatar_proxy(request: Request, style: str, seed: str, size: int = 128):
|
||||||
|
cache_key = f"{style}:{seed}:{size}"
|
||||||
|
if cache_key in _cache:
|
||||||
|
return Response(content=_cache[cache_key], media_type="image/svg+xml")
|
||||||
|
|
||||||
|
if style == "initials" or not style:
|
||||||
|
svg = _initial_svg(seed)
|
||||||
|
_cache[cache_key] = svg
|
||||||
|
return Response(content=svg, media_type="image/svg+xml")
|
||||||
|
|
||||||
|
url = dicebear_proxy_url(style, seed, size)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
resp = await client.get(url, follow_redirects=True)
|
||||||
|
content_type = resp.headers.get("content-type", "image/svg+xml")
|
||||||
|
body = resp.content
|
||||||
|
_cache[cache_key] = body
|
||||||
|
return Response(content=body, media_type=content_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Avatar proxy failed for {style}/{seed}: {e}")
|
||||||
|
svg = _initial_svg(seed)
|
||||||
|
_cache[cache_key] = svg
|
||||||
|
return Response(content=svg, media_type="image/svg+xml")
|
||||||
@ -61,10 +61,10 @@ async def view_post(request: Request, post_uid: str):
|
|||||||
|
|
||||||
comments_table = get_table("comments")
|
comments_table = get_table("comments")
|
||||||
raw_comments = list(comments_table.find(post_uid=post_uid, order_by=["created_at"]))
|
raw_comments = list(comments_table.find(post_uid=post_uid, order_by=["created_at"]))
|
||||||
comments = []
|
comment_map = {}
|
||||||
for c in raw_comments:
|
for c in raw_comments:
|
||||||
commenter = users_table.find_one(uid=c["user_uid"])
|
commenter = users_table.find_one(uid=c["user_uid"])
|
||||||
comments.append({
|
comment_map[c["uid"]] = {
|
||||||
"comment": c,
|
"comment": c,
|
||||||
"author": commenter,
|
"author": commenter,
|
||||||
"time_ago": time_ago(c["created_at"]),
|
"time_ago": time_ago(c["created_at"]),
|
||||||
@ -72,13 +72,22 @@ async def view_post(request: Request, post_uid: str):
|
|||||||
"up": len(list(get_table("votes").find(target_uid=c["uid"], value=1))),
|
"up": len(list(get_table("votes").find(target_uid=c["uid"], value=1))),
|
||||||
"down": len(list(get_table("votes").find(target_uid=c["uid"], value=-1))),
|
"down": len(list(get_table("votes").find(target_uid=c["uid"], value=-1))),
|
||||||
},
|
},
|
||||||
})
|
"children": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
top_level = []
|
||||||
|
for item in comment_map.values():
|
||||||
|
parent_uid = item["comment"].get("parent_uid")
|
||||||
|
if parent_uid and parent_uid in comment_map:
|
||||||
|
comment_map[parent_uid]["children"].append(item)
|
||||||
|
else:
|
||||||
|
top_level.append(item)
|
||||||
|
|
||||||
return templates.TemplateResponse("post.html", {
|
return templates.TemplateResponse("post.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": user,
|
"user": user,
|
||||||
"post": post,
|
"post": post,
|
||||||
"author": author,
|
"author": author,
|
||||||
"comments": comments,
|
"comments": top_level,
|
||||||
"time_ago": time_ago(post["created_at"]),
|
"time_ago": time_ago(post["created_at"]),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -52,15 +52,21 @@ async def update_profile(request: Request):
|
|||||||
location = form.get("location", "").strip()
|
location = form.get("location", "").strip()
|
||||||
git_link = form.get("git_link", "").strip()
|
git_link = form.get("git_link", "").strip()
|
||||||
website = form.get("website", "").strip()
|
website = form.get("website", "").strip()
|
||||||
|
avatar_style = form.get("avatar_style", "").strip()
|
||||||
|
|
||||||
users = get_table("users")
|
users = get_table("users")
|
||||||
users.update({
|
update_data = {
|
||||||
"uid": user["uid"],
|
"uid": user["uid"],
|
||||||
"bio": bio,
|
"bio": bio,
|
||||||
"location": location,
|
"location": location,
|
||||||
"git_link": git_link,
|
"git_link": git_link,
|
||||||
"website": website,
|
"website": website,
|
||||||
}, ["uid"])
|
}
|
||||||
|
valid_styles = ("adventurer", "adventurer-neutral", "avataaars", "big-ears", "big-smile", "bottts", "croodles", "fun-emoji", "identicon", "initials", "lorelei", "micah", "miniavs", "notionists", "open-peeps", "personas", "pixel-art", "shapes", "thumbs")
|
||||||
|
if avatar_style in valid_styles:
|
||||||
|
update_data["avatar_style"] = avatar_style
|
||||||
|
|
||||||
|
users.update(update_data, ["uid"])
|
||||||
|
|
||||||
logger.info(f"Profile updated for {user['username']}")
|
logger.info(f"Profile updated for {user['username']}")
|
||||||
return RedirectResponse(url=f"/profile/{user['username']}", status_code=302)
|
return RedirectResponse(url=f"/profile/{user['username']}", status_code=302)
|
||||||
|
|||||||
@ -173,6 +173,7 @@ img {
|
|||||||
|
|
||||||
.avatar-sm { width: 32px; height: 32px; font-size: 0.8125rem; }
|
.avatar-sm { width: 32px; height: 32px; font-size: 0.8125rem; }
|
||||||
.avatar-lg { width: 80px; height: 80px; font-size: 2rem; }
|
.avatar-lg { width: 80px; height: 80px; font-size: 2rem; }
|
||||||
|
.avatar-img { border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
@ -188,6 +189,16 @@ img {
|
|||||||
padding-top: calc(var(--nav-height) + 1rem);
|
padding-top: calc(var(--nav-height) + 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
.fade-in {
|
.fade-in {
|
||||||
animation: fadeIn 0.3s ease;
|
animation: fadeIn 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,10 @@
|
|||||||
.feed-sidebar {
|
.feed-sidebar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(var(--nav-height) + 1rem);
|
top: calc(var(--nav-height) + 1rem);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-sidebar h3 {
|
.feed-sidebar h3 {
|
||||||
@ -22,7 +26,7 @@
|
|||||||
.feed-categories {
|
.feed-categories {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-category {
|
.feed-category {
|
||||||
@ -37,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feed-category:hover {
|
.feed-category:hover {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,6 +80,7 @@
|
|||||||
|
|
||||||
.feed-nav-btn:hover {
|
.feed-nav-btn:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-nav-btn.active {
|
.feed-nav-btn.active {
|
||||||
@ -89,6 +94,19 @@
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-nav-actions button {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-nav-actions button:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.feed-posts {
|
.feed-posts {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -99,12 +117,13 @@
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1rem;
|
padding: 1.25rem;
|
||||||
transition: border-color 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-card:hover {
|
.post-card:hover {
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-light);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-header {
|
.post-header {
|
||||||
@ -114,21 +133,29 @@
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-author-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.post-author {
|
.post-author {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-author-role {
|
.post-author-role {
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-time {
|
.post-time {
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-topic {
|
.post-topic {
|
||||||
@ -140,14 +167,19 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-content {
|
.post-content {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-actions {
|
.post-actions {
|
||||||
@ -159,14 +191,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-action-btn {
|
.post-action-btn {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
padding: 0.375rem 0.625rem;
|
padding: 0.375rem 0.75rem;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-action-btn:hover {
|
.post-action-btn:hover {
|
||||||
@ -174,10 +210,15 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-action-btn.voted {
|
.post-action-btn.voted,
|
||||||
|
.post-action-btn.vote-up {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-action-btn.share {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.feed-right {
|
.feed-right {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(var(--nav-height) + 1rem);
|
top: calc(var(--nav-height) + 1rem);
|
||||||
@ -190,7 +231,7 @@
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.daily-topic-label {
|
.daily-topic-label {
|
||||||
@ -205,12 +246,14 @@
|
|||||||
.daily-topic-card h4 {
|
.daily-topic-card h4 {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
margin-bottom: 0.375rem;
|
margin-bottom: 0.375rem;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.daily-topic-card p {
|
.daily-topic-card p {
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
margin-bottom: 0.5rem;
|
line-height: 1.5;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.daily-topic-card .topic-links {
|
.daily-topic-card .topic-links {
|
||||||
@ -227,7 +270,7 @@
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1rem;
|
padding: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.community-stats h3 {
|
.community-stats h3 {
|
||||||
@ -242,16 +285,28 @@
|
|||||||
.stat-row {
|
.stat-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
padding: 0.375rem 0;
|
padding: 0.375rem 0;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-row:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-row .label {
|
.stat-row .label {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-row .value {
|
.stat-row .value {
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-fab {
|
.feed-fab {
|
||||||
@ -267,14 +322,17 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: 0 4px 16px rgba(255, 107, 53, 0.4);
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-fab:hover {
|
.feed-fab:hover {
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 6px 20px rgba(255, 107, 53, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
|
|||||||
@ -28,8 +28,8 @@
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1rem;
|
padding: 1.25rem;
|
||||||
transition: border-color 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-card.unread {
|
.notification-card.unread {
|
||||||
@ -73,12 +73,4 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notifications-empty {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.notifications-empty p {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -72,12 +72,14 @@
|
|||||||
.comment {
|
.comment {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem 0;
|
padding: 0.75rem 0;
|
||||||
border-bottom: 1px solid var(--border);
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment:last-child {
|
.comment-replies {
|
||||||
border-bottom: none;
|
margin-top: 0.75rem;
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-votes {
|
.comment-votes {
|
||||||
|
|||||||
@ -28,7 +28,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
<a href="/profile/{{ user['username'] }}" class="topnav-user">
|
<a href="/profile/{{ user['username'] }}" class="topnav-user">
|
||||||
<div class="avatar avatar-sm">{{ user['username'][:1].upper() }}</div>
|
<img src="{{ avatar_url(user.get('avatar_style', 'initials'), user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}">
|
||||||
<div class="topnav-user-info">
|
<div class="topnav-user-info">
|
||||||
<span class="topnav-user-name">{{ user['username'] }}</span>
|
<span class="topnav-user-name">{{ user['username'] }}</span>
|
||||||
<span class="topnav-user-role">{{ user.get('role', 'Member') }}</span>
|
<span class="topnav-user-role">{{ user.get('role', 'Member') }}</span>
|
||||||
|
|||||||
@ -159,13 +159,12 @@
|
|||||||
{% for item in posts %}
|
{% for item in posts %}
|
||||||
<article class="post-card fade-in">
|
<article class="post-card fade-in">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<div class="avatar avatar-sm">{{ item.author['username'][:1].upper() if item.author else '?' }}</div>
|
<img src="{{ avatar_url(item.author.get('avatar_style', 'initials') if item.author else 'initials', item.author['username'] if item.author else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.author['username'] if item.author else '?' }}">
|
||||||
<div>
|
<div class="post-author-wrap">
|
||||||
<div class="post-author">{{ item.author['username'] if item.author else 'Unknown' }}
|
<span class="post-author">{{ item.author['username'] if item.author else 'Unknown' }}</span>
|
||||||
{% if item.author and item.author.get('role') %}
|
{% if item.author and item.author.get('role') %}
|
||||||
<span class="post-author-role">· {{ item.author['role'] }}</span>
|
<span class="post-author-role">{{ item.author['role'] }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="post-time">{{ item.time_ago }}</span>
|
<span class="post-time">{{ item.time_ago }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -183,23 +182,21 @@
|
|||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" style="display:inline;">
|
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" style="display:inline;">
|
||||||
<input type="hidden" name="value" value="1">
|
<input type="hidden" name="value" value="1">
|
||||||
<button type="submit" class="post-action-btn" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">
|
<button type="submit" class="post-action-btn vote-up" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">
|
||||||
+{{ item.post.get('stars', 0) }}
|
+{{ item.post.get('stars', 0) }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn">
|
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn">
|
||||||
💬 {{ item.comment_count }}
|
💬 {{ item.comment_count }}
|
||||||
</a>
|
</a>
|
||||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn" style="margin-left: auto;">
|
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn share">
|
||||||
↗︎ Open
|
↗︎ Open
|
||||||
</a>
|
</a>
|
||||||
<button class="post-action-btn">🔗 Share</button>
|
<button class="post-action-btn">🔗 Share</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
<div class="empty-state">No posts yet. Be the first!</div>
|
||||||
<p style="color: var(--text-muted); font-size: 0.9375rem;">No posts yet. Be the first!</p>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -234,7 +231,7 @@
|
|||||||
{% for author in top_authors %}
|
{% for author in top_authors %}
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="label">
|
<span class="label">
|
||||||
<span class="avatar avatar-sm" style="display: inline-flex; width: 20px; height: 20px; font-size: 0.625rem; vertical-align: middle; margin-right: 0.375rem;">{{ author['username'][:1].upper() }}</span>
|
<img src="{{ avatar_url(author.get('avatar_style', 'initials'), author['username'], 20) }}" class="avatar-img" style="width: 20px; height: 20px; vertical-align: middle; margin-right: 0.375rem;" alt="{{ author['username'] }}">
|
||||||
{{ author['username'] }}
|
{{ author['username'] }}
|
||||||
</span>
|
</span>
|
||||||
<span class="value">{{ author.get('stars', 0) }}</span>
|
<span class="value">{{ author.get('stars', 0) }}</span>
|
||||||
|
|||||||
@ -52,7 +52,7 @@
|
|||||||
<div class="messages-conversations">
|
<div class="messages-conversations">
|
||||||
{% for conv in conversations %}
|
{% for conv in conversations %}
|
||||||
<a href="/messages?with_uid={{ conv.other_user['uid'] }}" class="conversation-item {% if current_conversation == conv.other_user['uid'] %}active{% endif %}">
|
<a href="/messages?with_uid={{ conv.other_user['uid'] }}" class="conversation-item {% if current_conversation == conv.other_user['uid'] %}active{% endif %}">
|
||||||
<div class="avatar avatar-sm">{{ conv.other_user['username'][:1].upper() }}</div>
|
<img src="{{ avatar_url(conv.other_user.get('avatar_style', 'initials'), conv.other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ conv.other_user['username'] }}">
|
||||||
<div class="conversation-info">
|
<div class="conversation-info">
|
||||||
<div class="conversation-name">{{ conv.other_user['username'] }}</div>
|
<div class="conversation-name">{{ conv.other_user['username'] }}</div>
|
||||||
<div class="conversation-preview">{{ conv.last_message[:60] }}</div>
|
<div class="conversation-preview">{{ conv.last_message[:60] }}</div>
|
||||||
@ -60,9 +60,7 @@
|
|||||||
<span class="conversation-time">{{ conv.last_message_at[:10] }}</span>
|
<span class="conversation-time">{{ conv.last_message_at[:10] }}</span>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div style="padding: 2rem 1rem; text-align: center; color: var(--text-muted); font-size: 0.875rem;">
|
<div class="empty-state" style="border: none;">No conversations yet</div>
|
||||||
No conversations yet
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -70,7 +68,7 @@
|
|||||||
<div class="messages-main">
|
<div class="messages-main">
|
||||||
{% if other_user %}
|
{% if other_user %}
|
||||||
<div class="messages-main-header">
|
<div class="messages-main-header">
|
||||||
<div class="avatar avatar-sm">{{ other_user['username'][:1].upper() }}</div>
|
<img src="{{ avatar_url(other_user.get('avatar_style', 'initials'), other_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ other_user['username'] }}">
|
||||||
<h3>{{ other_user['username'] }}</h3>
|
<h3>{{ other_user['username'] }}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -52,7 +52,7 @@
|
|||||||
<div class="notifications-list">
|
<div class="notifications-list">
|
||||||
{% for item in notifications %}
|
{% for item in notifications %}
|
||||||
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}">
|
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}">
|
||||||
<div class="avatar avatar-sm">{{ item.actor['username'][:1].upper() if item.actor else '?' }}</div>
|
<img src="{{ avatar_url(item.actor.get('avatar_style', 'initials') if item.actor else 'initials', item.actor['username'] if item.actor else '?', 32) }}" class="avatar-img avatar-sm" alt="{{ item.actor['username'] if item.actor else '?' }}">
|
||||||
<div class="notification-body">
|
<div class="notification-body">
|
||||||
<div class="notification-text">{{ item.notification['message'] }}</div>
|
<div class="notification-text">{{ item.notification['message'] }}</div>
|
||||||
<div class="notification-time">{{ item.time_ago }}</div>
|
<div class="notification-time">{{ item.time_ago }}</div>
|
||||||
@ -62,9 +62,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="notifications-empty">
|
<div class="empty-state">No notifications yet</div>
|
||||||
<p>No notifications yet</p>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
<article class="post-detail">
|
<article class="post-detail">
|
||||||
<div class="post-detail-header">
|
<div class="post-detail-header">
|
||||||
<div class="avatar">{{ author['username'][:1].upper() if author else '?' }}</div>
|
<img src="{{ avatar_url(author.get('avatar_style', 'initials') if author else 'initials', author['username'] if author else '?', 40) }}" class="avatar-img" style="width: 40px; height: 40px; border-radius: 50%;" alt="{{ author['username'] if author else '?' }}">
|
||||||
<div>
|
<div>
|
||||||
<div class="post-detail-author">{{ author['username'] if author else 'Unknown' }}
|
<div class="post-detail-author">{{ author['username'] if author else 'Unknown' }}
|
||||||
{% if author and author.get('role') %}
|
{% if author and author.get('role') %}
|
||||||
@ -97,8 +97,9 @@
|
|||||||
<section class="comments-section">
|
<section class="comments-section">
|
||||||
<h3>Comments</h3>
|
<h3>Comments</h3>
|
||||||
|
|
||||||
{% for item in comments %}
|
{% macro render_comment(item, depth=0) %}
|
||||||
<div class="comment">
|
<div class="comment" style="margin-left: {{ depth * 1.5 }}rem;">
|
||||||
|
<div class="comment-thread-line"></div>
|
||||||
<div class="comment-votes">
|
<div class="comment-votes">
|
||||||
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
|
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
|
||||||
<input type="hidden" name="value" value="1">
|
<input type="hidden" name="value" value="1">
|
||||||
@ -113,7 +114,7 @@
|
|||||||
|
|
||||||
<div class="comment-body">
|
<div class="comment-body">
|
||||||
<div class="comment-header">
|
<div class="comment-header">
|
||||||
<div class="avatar avatar-sm" style="width: 24px; height: 24px; font-size: 0.625rem;">{{ item.author['username'][:1].upper() if item.author else '?' }}</div>
|
<img src="{{ avatar_url(item.author.get('avatar_style', 'initials') if item.author else 'initials', item.author['username'] if item.author else '?', 24) }}" class="avatar-img" style="width: 24px; height: 24px; border-radius: 50%;" alt="{{ item.author['username'] if item.author else '?' }}">
|
||||||
<span class="comment-author">{{ item.author['username'] if item.author else 'Unknown' }}</span>
|
<span class="comment-author">{{ item.author['username'] if item.author else 'Unknown' }}</span>
|
||||||
<span class="comment-time">{{ item.time_ago }}</span>
|
<span class="comment-time">{{ item.time_ago }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -126,8 +127,20 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if item.children %}
|
||||||
|
<div class="comment-replies">
|
||||||
|
{% for child in item.children %}
|
||||||
|
{{ render_comment(child, depth + 1) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% for item in comments %}
|
||||||
|
{{ render_comment(item, 0) }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p style="color: var(--text-muted); font-size: 0.875rem; text-align: center; padding: 2rem 0;">No comments yet. Start the discussion.</p>
|
<p style="color: var(--text-muted); font-size: 0.875rem; text-align: center; padding: 2rem 0;">No comments yet. Start the discussion.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@ -135,7 +148,7 @@
|
|||||||
{% if user %}
|
{% if user %}
|
||||||
<form class="comment-form" method="POST" action="/comments/create">
|
<form class="comment-form" method="POST" action="/comments/create">
|
||||||
<input type="hidden" name="post_uid" value="{{ post['uid'] }}">
|
<input type="hidden" name="post_uid" value="{{ post['uid'] }}">
|
||||||
<div class="avatar avatar-sm">{{ user['username'][:1].upper() }}</div>
|
<img src="{{ avatar_url(user.get('avatar_style', 'initials'), user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ user['username'] }}">
|
||||||
<textarea name="content" placeholder="Your opinion goes here..." required maxlength="1000"></textarea>
|
<textarea name="content" placeholder="Your opinion goes here..." required maxlength="1000"></textarea>
|
||||||
<div class="comment-form-actions">
|
<div class="comment-form-actions">
|
||||||
<button type="button" title="Add emoji">😀</button>
|
<button type="button" title="Add emoji">😀</button>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<link rel="stylesheet" href="/static/css/profile.css">
|
<link rel="stylesheet" href="/static/css/profile.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/projects.css">
|
||||||
<style>
|
<style>
|
||||||
.topnav {
|
.topnav {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -45,8 +46,8 @@
|
|||||||
<div class="profile-layout">
|
<div class="profile-layout">
|
||||||
<aside class="profile-sidebar">
|
<aside class="profile-sidebar">
|
||||||
<div class="profile-card">
|
<div class="profile-card">
|
||||||
<div class="profile-avatar-wrap">
|
<div class="profile-avatar-wrap" style="position: relative;">
|
||||||
<div class="avatar avatar-lg">{{ profile_user['username'][:1].upper() }}</div>
|
<img src="{{ avatar_url(profile_user.get('avatar_style', 'initials'), profile_user['username'], 80) }}" class="avatar-img avatar-lg" alt="{{ profile_user['username'] }}" id="profile-avatar-preview">
|
||||||
</div>
|
</div>
|
||||||
<div class="profile-name">{{ profile_user['username'] }}</div>
|
<div class="profile-name">{{ profile_user['username'] }}</div>
|
||||||
{% if profile_user.get('role') %}
|
{% if profile_user.get('role') %}
|
||||||
@ -87,7 +88,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if user and user['uid'] == profile_user['uid'] %}
|
{% if user and user['uid'] == profile_user['uid'] %}
|
||||||
<div class="profile-info">
|
<div class="profile-info">
|
||||||
<form method="POST" action="/profile/update">
|
<form method="POST" action="/profile/update">
|
||||||
<div class="profile-info-row">
|
<div class="profile-info-row">
|
||||||
@ -134,6 +135,23 @@
|
|||||||
<input type="url" name="website" maxlength="500" value="{{ profile_user.get('website', '') }}" style="font-size: 0.8125rem;">
|
<input type="url" name="website" maxlength="500" value="{{ profile_user.get('website', '') }}" style="font-size: 0.8125rem;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-info-row">
|
||||||
|
<span class="profile-info-label">Avatar Style</span>
|
||||||
|
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<img src="{{ avatar_url(profile_user.get('avatar_style', 'initials'), profile_user['username'], 32) }}" class="avatar-img avatar-sm" alt="">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.375rem; margin: 0.5rem 0;">
|
||||||
|
{% for style in avatar_styles() %}
|
||||||
|
<label style="display: flex; flex-direction: column; align-items: center; gap: 0.125rem; padding: 0.25rem; border-radius: var(--radius); border: 2px solid var(--border); cursor: pointer; transition: border-color 0.2s; {% if profile_user.get('avatar_style', 'initials') == style %}border-color: var(--accent);{% endif %}">
|
||||||
|
<input type="radio" name="avatar_style" value="{{ style }}" {% if profile_user.get('avatar_style', 'initials') == style %}checked{% endif %} style="display: none;" onchange="this.closest('label').parentElement.querySelectorAll('label').forEach(l => l.style.borderColor = 'var(--border)'); this.closest('label').style.borderColor = 'var(--accent)'; document.getElementById('profile-avatar-preview').src = '{{ avatar_url(style, profile_user['username'], 80) }}';">
|
||||||
|
<img src="{{ avatar_url(style, profile_user['username'], 36) }}" class="avatar-img" style="width: 28px; height: 28px; border-radius: 50%;" alt="{{ style }}">
|
||||||
|
<span style="font-size: 0.5rem; color: var(--text-muted); text-align: center; line-height: 1.1;">{{ style[:8] }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<small style="color: var(--text-muted); font-size: 0.75rem; display: block; margin-bottom: 0.5rem;">Avatars by DiceBear</small>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm" style="width: 100%; margin-top: 0.5rem;">Save Changes</button>
|
<button type="submit" class="btn btn-primary btn-sm" style="width: 100%; margin-top: 0.5rem;">Save Changes</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -174,13 +192,12 @@
|
|||||||
{% for item in posts %}
|
{% for item in posts %}
|
||||||
<article class="post-card fade-in">
|
<article class="post-card fade-in">
|
||||||
<div class="post-header">
|
<div class="post-header">
|
||||||
<div class="avatar avatar-sm">{{ profile_user['username'][:1].upper() }}</div>
|
<img src="{{ avatar_url(profile_user.get('avatar_style', 'initials'), profile_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ profile_user['username'] }}">
|
||||||
<div>
|
<div class="post-author-wrap">
|
||||||
<div class="post-author">{{ profile_user['username'] }}
|
<span class="post-author">{{ profile_user['username'] }}</span>
|
||||||
{% if profile_user.get('role') %}
|
{% if profile_user.get('role') %}
|
||||||
<span class="post-author-role">· {{ profile_user['role'] }}</span>
|
<span class="post-author-role">{{ profile_user['role'] }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="post-time">{{ item.time_ago }}</span>
|
<span class="post-time">{{ item.time_ago }}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -194,33 +211,38 @@
|
|||||||
<div class="post-actions">
|
<div class="post-actions">
|
||||||
<span class="post-action-btn" style="cursor: default;">+{{ item.post.get('stars', 0) }}</span>
|
<span class="post-action-btn" style="cursor: default;">+{{ item.post.get('stars', 0) }}</span>
|
||||||
<span class="post-action-btn" style="cursor: default;">💬 {{ item.comment_count }}</span>
|
<span class="post-action-btn" style="cursor: default;">💬 {{ item.comment_count }}</span>
|
||||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn" style="margin-left: auto;">↗︎ Open</a>
|
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn share">↗︎ Open</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
<div class="empty-state">No posts yet.</div>
|
||||||
<p style="color: var(--text-muted);">No posts yet.</p>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% elif current_tab == 'projects' %}
|
{% elif current_tab == 'projects' %}
|
||||||
{% for p in projects %}
|
{% for p in projects %}
|
||||||
<div class="card">
|
<div class="project-card fade-in">
|
||||||
<h3 style="font-size: 1rem; font-weight: 700; margin-bottom: 0.25rem;">{{ p['title'] }}</h3>
|
<div class="project-card-header">
|
||||||
<p style="font-size: 0.8125rem; color: var(--text-secondary);">{{ p.get('description', '')[:200] }}</p>
|
<h3 class="project-card-title">{{ p['title'] }}</h3>
|
||||||
<div style="margin-top: 0.5rem; display: flex; gap: 0.375rem;">
|
|
||||||
<span class="badge" style="background: var(--border); color: var(--text-muted);">{{ p.get('project_type', 'software') }}</span>
|
|
||||||
<span class="badge" style="background: var(--border); color: var(--text-muted);">{{ p.get('status', 'In Development') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="project-card-meta">
|
||||||
|
<span class="project-status {% if p.get('status') == 'Released' %}released{% else %}dev{% endif %}">
|
||||||
|
● {{ p.get('status', 'In Development') }}
|
||||||
|
</span>
|
||||||
|
<span>{{ p.get('project_type', 'software')|replace('_', ' ')|capitalize }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-card-desc">{{ p.get('description', '')[:200] }}</div>
|
||||||
|
{% if p.get('platforms') %}
|
||||||
|
<div class="project-card-platforms">
|
||||||
|
{% for plat in p['platforms'].split(',') %}
|
||||||
|
<span class="platform-tag">{{ plat.strip() }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
<div class="empty-state">No projects yet.</div>
|
||||||
<p style="color: var(--text-muted);">No projects yet.</p>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
<div class="empty-state">Activity coming soon.</div>
|
||||||
<p style="color: var(--text-muted);">Activity coming soon.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -122,9 +122,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card" style="text-align: center; padding: 3rem 1rem; grid-column: 1 / -1;">
|
<div class="empty-state" style="grid-column: 1 / -1;">No projects found. Create one!</div>
|
||||||
<p style="color: var(--text-muted); font-size: 0.9375rem;">No projects found. Create one!</p>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -47,7 +47,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-field">
|
||||||
|
<label>Avatar Style</label>
|
||||||
|
<div class="avatar-picker" style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.5rem; margin-top: 0.25rem;">
|
||||||
|
{% for style in avatar_styles() %}
|
||||||
|
<label class="avatar-option" style="display: flex; flex-direction: column; align-items: center; gap: 0.25rem; padding: 0.375rem; border-radius: var(--radius); border: 2px solid var(--border); cursor: pointer; transition: border-color 0.2s;">
|
||||||
|
<input type="radio" name="avatar_style" value="{{ style }}" {% if loop.first %}checked{% endif %} style="display: none;">
|
||||||
|
<img src="{{ avatar_url(style, 'demo', 48) }}" class="avatar-img" style="width: 36px; height: 36px; border-radius: 50%;" alt="{{ style }}">
|
||||||
|
<span style="font-size: 0.5625rem; color: var(--text-muted); text-align: center; line-height: 1.2;">{{ style.replace('-', ' ')|capitalize }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<small style="color: var(--text-muted); font-size: 0.75rem; margin-top: 0.375rem; display: block;">Avatars by DiceBear — choose your style</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="auth-submit">Create account</button>
|
<button type="submit" class="auth-submit">Create account</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.avatar-option:hover { border-color: var(--accent); }
|
||||||
|
.avatar-option input[type="radio"]:checked + img { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 50%; }
|
||||||
|
</style>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="auth-footer">
|
<div class="auth-footer">
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from devplacepy.config import TEMPLATES_DIR
|
from devplacepy.config import TEMPLATES_DIR
|
||||||
from devplacepy.database import get_table
|
from devplacepy.database import get_table
|
||||||
|
from devplacepy.avatar import avatar_url, avatar_styles
|
||||||
|
|
||||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
|
||||||
@ -17,3 +18,5 @@ def jinja_user_projects(user_uid: str) -> list:
|
|||||||
|
|
||||||
templates.env.globals["get_unread_count"] = jinja_unread_count
|
templates.env.globals["get_unread_count"] = jinja_unread_count
|
||||||
templates.env.globals["get_user_projects"] = jinja_user_projects
|
templates.env.globals["get_user_projects"] = jinja_user_projects
|
||||||
|
templates.env.globals["avatar_url"] = avatar_url
|
||||||
|
templates.env.globals["avatar_styles"] = avatar_styles
|
||||||
|
|||||||
@ -12,6 +12,8 @@ dependencies = [
|
|||||||
"passlib[bcrypt]",
|
"passlib[bcrypt]",
|
||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
"aiofiles",
|
"aiofiles",
|
||||||
|
"httpx",
|
||||||
|
"dicebear",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@ -94,10 +94,12 @@ def browser_context(browser):
|
|||||||
def page(browser_context):
|
def page(browser_context):
|
||||||
p = browser_context.new_page()
|
p = browser_context.new_page()
|
||||||
p.set_default_timeout(10000)
|
p.set_default_timeout(10000)
|
||||||
|
p.bring_to_front()
|
||||||
yield p
|
yield p
|
||||||
p.close()
|
p.close()
|
||||||
|
|
||||||
def signup_user(page, user):
|
def signup_user(page, user):
|
||||||
|
page.bring_to_front()
|
||||||
page.goto(f"{BASE_URL}/auth/signup")
|
page.goto(f"{BASE_URL}/auth/signup")
|
||||||
page.fill("#username", user["username"])
|
page.fill("#username", user["username"])
|
||||||
page.fill("#email", user["email"])
|
page.fill("#email", user["email"])
|
||||||
@ -108,6 +110,7 @@ def signup_user(page, user):
|
|||||||
|
|
||||||
|
|
||||||
def login_user(page, user):
|
def login_user(page, user):
|
||||||
|
page.bring_to_front()
|
||||||
page.goto(f"{BASE_URL}/auth/login")
|
page.goto(f"{BASE_URL}/auth/login")
|
||||||
page.fill("#email", user["email"])
|
page.fill("#email", user["email"])
|
||||||
page.fill("#password", user["password"])
|
page.fill("#password", user["password"])
|
||||||
@ -152,6 +155,7 @@ def bob(browser, seeded_db):
|
|||||||
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
|
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||||
p = ctx.new_page()
|
p = ctx.new_page()
|
||||||
p.set_default_timeout(10000)
|
p.set_default_timeout(10000)
|
||||||
|
p.bring_to_front()
|
||||||
login_user(p, seeded_db["bob"])
|
login_user(p, seeded_db["bob"])
|
||||||
yield p, seeded_db["bob"]
|
yield p, seeded_db["bob"]
|
||||||
p.close()
|
p.close()
|
||||||
|
|||||||
@ -13,13 +13,6 @@ def create_post(page, content, topic="random", title=None):
|
|||||||
page.wait_for_url("**/posts/*", timeout=10000)
|
page.wait_for_url("**/posts/*", timeout=10000)
|
||||||
|
|
||||||
|
|
||||||
def add_comment(page, text):
|
|
||||||
textarea = page.locator("textarea[name='content']")
|
|
||||||
textarea.fill(text)
|
|
||||||
page.click("button:has-text('Post')")
|
|
||||||
page.wait_for_timeout(500)
|
|
||||||
|
|
||||||
|
|
||||||
def signup(page, username, email, password):
|
def signup(page, username, email, password):
|
||||||
page.goto(f"{BASE_URL}/auth/signup")
|
page.goto(f"{BASE_URL}/auth/signup")
|
||||||
page.fill("#username", username)
|
page.fill("#username", username)
|
||||||
@ -30,370 +23,254 @@ def signup(page, username, email, password):
|
|||||||
page.wait_for_url("**/feed", timeout=10000)
|
page.wait_for_url("**/feed", timeout=10000)
|
||||||
|
|
||||||
|
|
||||||
def test_absurd_full_journey(browser, app_server):
|
def test_full_user_journey(browser, app_server):
|
||||||
page_a = browser.new_page()
|
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||||
page_a.set_default_timeout(10000)
|
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||||
page_b = browser.new_page()
|
pa = ctx_a.new_page()
|
||||||
page_b.set_default_timeout(10000)
|
pb = ctx_b.new_page()
|
||||||
|
pa.set_default_timeout(10000)
|
||||||
|
pb.set_default_timeout(10000)
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
# ACT 1 — LANDING PAGE & DISCOVERY
|
# ACT 1 — LANDING PAGE
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
|
pa.goto(f"{BASE_URL}/")
|
||||||
|
assert pa.is_visible("text=Devplace.net")
|
||||||
|
assert pa.is_visible("text=The Developer Social Network")
|
||||||
|
assert pa.is_visible("text=Join DevPlace Free")
|
||||||
|
for feat in ("100% Free Forever", "Zero Ads", "No Censorship", "No Payments"):
|
||||||
|
assert pa.is_visible(f"text={feat}")
|
||||||
|
assert pa.is_visible("text=Daily Topic")
|
||||||
|
assert pa.is_visible("text=Read More")
|
||||||
|
|
||||||
page_a.goto(f"{BASE_URL}/")
|
# Navigate to signup via link
|
||||||
assert page_a.is_visible("text=Devplace.net")
|
pa.click("a:has-text('Sign Up')", no_wait_after=True)
|
||||||
assert page_a.is_visible("text=The Developer Social Network")
|
pa.wait_for_url("**/auth/signup", timeout=10000)
|
||||||
assert page_a.is_visible("text=Join DevPlace Free")
|
|
||||||
assert page_a.is_visible("text=100% Free Forever")
|
|
||||||
assert page_a.is_visible("text=Zero Ads")
|
|
||||||
assert page_a.is_visible("text=No Censorship")
|
|
||||||
assert page_a.is_visible("text=No Payments")
|
|
||||||
assert page_a.is_visible("text=Daily Topic")
|
|
||||||
assert page_a.is_visible("text=Read More")
|
|
||||||
assert page_a.is_visible("text=Source")
|
|
||||||
|
|
||||||
page_b.goto(f"{BASE_URL}")
|
# ═══════════════════════════════════════════════
|
||||||
page_b.click("text=Log In")
|
# ACT 2 — TWO USERS SIGN UP
|
||||||
page_b.wait_for_url("**/auth/login")
|
# ═══════════════════════════════════════════════
|
||||||
page_b.click("text=Create new account")
|
signup(pa, "alice_demo", "alice_demo@test.dev", "alice_pass_1")
|
||||||
page_b.wait_for_url("**/auth/signup")
|
signup(pb, "bob_demo", "bob_demo@test.dev", "bob_pass_1")
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
# ACT 2 — TWO USERS SIGN UP
|
# ACT 3 — ALICE EXPLORES FEED
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
|
pa.bring_to_front()
|
||||||
|
pa.goto(f"{BASE_URL}/feed")
|
||||||
|
for tab_text in ("Topics", "All", "Trending", "Recent", "Following",
|
||||||
|
"Devlog", "Showcase", "Question", "Rant", "Fun"):
|
||||||
|
assert pa.is_visible(f"text={tab_text}")
|
||||||
|
assert pa.is_visible("text=Total Members")
|
||||||
|
assert pa.is_visible("text=Top Authors")
|
||||||
|
|
||||||
signup(page_a, "alice_demo", "alice_demo@test.dev", "alice_pass_1")
|
# Switch all feed tabs
|
||||||
signup(page_b, "bob_demo", "bob_demo@test.dev", "bob_pass_1")
|
for tab in ("Trending", "Recent"):
|
||||||
|
pa.click(f"a:has-text('{tab}')")
|
||||||
|
pa.wait_for_url(f"**/feed?tab={tab.lower()}")
|
||||||
|
pa.click("a.feed-nav-btn:has-text('All')")
|
||||||
|
pa.wait_for_url("**/feed**", timeout=10000)
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
# Switch all topic filters
|
||||||
# ACT 3 — ALICE EXPLORES FEED
|
for topic in ("showcase", "question", "rant", "fun"):
|
||||||
# ═══════════════════════════════════════════
|
pa.click(f"a:has-text('{topic.capitalize()}')")
|
||||||
|
pa.wait_for_url(f"**/feed?topic={topic}")
|
||||||
|
pa.click("a:has-text('All')")
|
||||||
|
pa.wait_for_url("**/feed**", timeout=10000)
|
||||||
|
|
||||||
page_a.goto(f"{BASE_URL}/feed")
|
# ═══════════════════════════════════════════════
|
||||||
assert page_a.is_visible("text=Topics")
|
# ACT 4 — ALICE CREATES 4 POSTS (ALL TOPICS)
|
||||||
assert page_a.is_visible("text=All")
|
# ═══════════════════════════════════════════════
|
||||||
assert page_a.is_visible("text=Trending")
|
create_post(pa, "Just shipped a new feature for my REST API! Took me 3 days.", "devlog", "API Progress Update")
|
||||||
assert page_a.is_visible("text=Recent")
|
post1_url = pa.url
|
||||||
assert page_a.is_visible("text=Following")
|
|
||||||
assert page_a.is_visible("text=Devlog")
|
|
||||||
assert page_a.is_visible("text=Showcase")
|
|
||||||
assert page_a.is_visible("text=Question")
|
|
||||||
assert page_a.is_visible("text=Rant")
|
|
||||||
assert page_a.is_visible("text=Fun")
|
|
||||||
assert page_a.is_visible("text=Total Members")
|
|
||||||
assert page_a.is_visible("text=Top Authors")
|
|
||||||
|
|
||||||
# Switch tabs
|
create_post(pa, "Check out this pixel art editor I built in WASM. Runs at 60fps!", "showcase", "Pixel Editor WASM")
|
||||||
page_a.click("a:has-text('Trending')")
|
|
||||||
page_a.wait_for_url("**/feed?tab=trending")
|
|
||||||
page_a.click("a:has-text('Recent')")
|
|
||||||
page_a.wait_for_url("**/feed?tab=recent")
|
|
||||||
page_a.click("a:has-text('All')")
|
|
||||||
page_a.wait_for_url("**/feed")
|
|
||||||
|
|
||||||
# Click topic filters
|
create_post(pa, "Does anyone else think tabs are better than spaces? Fight me.", "rant", "Tabs vs Spaces")
|
||||||
page_a.click("a:has-text('Showcase')")
|
|
||||||
page_a.wait_for_url("**/feed?topic=showcase")
|
|
||||||
page_a.click("a:has-text('Question')")
|
|
||||||
page_a.wait_for_url("**/feed?topic=question")
|
|
||||||
page_a.click("a:has-text('Rant')")
|
|
||||||
page_a.wait_for_url("**/feed?topic=rant")
|
|
||||||
page_a.click("a:has-text('Fun')")
|
|
||||||
page_a.wait_for_url("**/feed?topic=fun")
|
|
||||||
page_a.click("a:has-text('All')")
|
|
||||||
page_a.wait_for_url("**/feed")
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
create_post(pa, "Why do we call it tech debt when really we just cut corners?", "fun")
|
||||||
# ACT 4 — ALICE CREATES 4 POSTS
|
|
||||||
# ═══════════════════════════════════════════
|
|
||||||
|
|
||||||
create_post(page_a, "Just shipped a new feature for my REST API! Took me 3 days but it was worth it.", "devlog", "API Progress Update")
|
# ═══════════════════════════════════════════════
|
||||||
post1_url = page_a.url
|
# ACT 5 — ALICE CREATES TWO PROJECTS
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
pa.bring_to_front()
|
||||||
|
pa.goto(f"{BASE_URL}/projects")
|
||||||
|
pa.locator("#create-project-btn").click()
|
||||||
|
pa.fill("#title", "N8Nme")
|
||||||
|
pa.fill("#description", "Visual workflow builder with 300+ integrations.")
|
||||||
|
pa.fill("#release_date", "2026-04-15")
|
||||||
|
pa.check("input[value='software']")
|
||||||
|
for plat in ("Linux", "Docker", "Web"):
|
||||||
|
pa.locator("#platforms-input").fill(plat)
|
||||||
|
pa.locator("#platforms-input").press("Enter")
|
||||||
|
pa.click("button:has-text('Create Project')")
|
||||||
|
pa.wait_for_timeout(500)
|
||||||
|
assert pa.is_visible("text=N8Nme")
|
||||||
|
|
||||||
create_post(page_a, "Check out this pixel art editor I built in WASM. Runs at 60fps!", "showcase", "Pixel Editor WASM")
|
# Second project — released
|
||||||
post2_url = page_a.url
|
pa.locator("#create-project-btn").click()
|
||||||
|
pa.fill("#title", "DWN")
|
||||||
|
pa.fill("#description", "Decentralized web node in Rust.")
|
||||||
|
pa.check("input[value='software']")
|
||||||
|
pa.check("input[value='Released']")
|
||||||
|
pa.locator("#platforms-input").fill("Linux")
|
||||||
|
pa.locator("#platforms-input").press("Enter")
|
||||||
|
pa.click("button:has-text('Create Project')")
|
||||||
|
pa.wait_for_timeout(500)
|
||||||
|
assert pa.is_visible("text=DWN")
|
||||||
|
|
||||||
create_post(page_a, "Does anyone else think that tabs are objectively better than spaces? Fight me.", "rant", "Tabs vs Spaces")
|
# ═══════════════════════════════════════════════
|
||||||
|
# ACT 6 — ALICE EDITS HER PROFILE
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
pa.bring_to_front()
|
||||||
|
pa.goto(f"{BASE_URL}/profile/alice_demo")
|
||||||
|
assert pa.is_visible("text=alice_demo")
|
||||||
|
assert pa.is_visible("text=Member")
|
||||||
|
assert pa.is_visible("text=Posts")
|
||||||
|
assert pa.is_visible("text=Level")
|
||||||
|
assert pa.is_visible("text=Stars")
|
||||||
|
assert pa.is_visible("text=Progress to next level")
|
||||||
|
assert pa.is_visible("text=Bio")
|
||||||
|
|
||||||
create_post(page_a, "Why do we call it 'tech debt' when really it's just 'we cut corners and now we pay'?", "fun")
|
pa.evaluate("""
|
||||||
|
document.querySelector('textarea[name="bio"]').value = 'Full-stack developer and open source enthusiast.';
|
||||||
|
document.querySelector('input[name="location"]').value = 'Berlin, Germany';
|
||||||
|
document.querySelector('input[name="git_link"]').value = 'https://github.com/alice_demo';
|
||||||
|
document.querySelector('input[name="website"]').value = 'https://alicedemo.dev';
|
||||||
|
document.querySelector('form[action="/profile/update"]').submit();
|
||||||
|
""")
|
||||||
|
pa.wait_for_timeout(500)
|
||||||
|
assert pa.is_visible("text=Full-stack developer")
|
||||||
|
assert pa.is_visible("text=Berlin, Germany")
|
||||||
|
assert pa.is_visible("text=https://github.com/alice_demo")
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
# Switch profile tabs
|
||||||
# ACT 5 — ALICE CREATES A PROJECT
|
pa.goto(f"{BASE_URL}/profile/alice_demo?tab=projects")
|
||||||
# ═══════════════════════════════════════════
|
pa.wait_for_timeout(300)
|
||||||
|
assert pa.is_visible("text=N8Nme")
|
||||||
|
assert pa.is_visible("text=DWN")
|
||||||
|
pa.goto(f"{BASE_URL}/profile/alice_demo?tab=posts")
|
||||||
|
pa.wait_for_timeout(300)
|
||||||
|
assert pa.is_visible("text=API Progress Update")
|
||||||
|
assert pa.is_visible("text=Pixel Editor WASM")
|
||||||
|
|
||||||
page_a.goto(f"{BASE_URL}/projects")
|
# ═══════════════════════════════════════════════
|
||||||
page_a.locator("#create-project-btn").click()
|
# ACT 7 — BOB EXPLORES ALICE'S STUFF
|
||||||
page_a.fill("#title", "N8Nme")
|
# ═══════════════════════════════════════════════
|
||||||
page_a.fill("#description", "Just powerful automation at your fingertips. Visual workflow builder with 300+ integrations.")
|
pb.bring_to_front()
|
||||||
page_a.fill("#release_date", "2026-04-15")
|
pb.goto(f"{BASE_URL}/profile/alice_demo")
|
||||||
page_a.check("input[value='software']")
|
assert pb.is_visible("text=alice_demo")
|
||||||
page_a.locator("#platforms-input").fill("Linux")
|
assert pb.is_visible("text=Full-stack developer")
|
||||||
page_a.locator("#platforms-input").press("Enter")
|
assert pb.is_visible("text=Berlin, Germany")
|
||||||
page_a.locator("#platforms-input").fill("Docker")
|
|
||||||
page_a.locator("#platforms-input").press("Enter")
|
|
||||||
page_a.locator("#platforms-input").fill("Web")
|
|
||||||
page_a.locator("#platforms-input").press("Enter")
|
|
||||||
page_a.click("button:has-text('Create Project')")
|
|
||||||
page_a.wait_for_timeout(500)
|
|
||||||
assert page_a.is_visible("text=N8Nme")
|
|
||||||
|
|
||||||
# Create a second project
|
pb.goto(f"{BASE_URL}/profile/alice_demo?tab=projects")
|
||||||
page_a.locator("#create-project-btn").click()
|
assert pb.is_visible("text=N8Nme")
|
||||||
page_a.fill("#title", "DWN")
|
assert pb.is_visible("text=DWN")
|
||||||
page_a.fill("#description", "Decentralized web node implementation in Rust. Secure, private, and fast.")
|
|
||||||
page_a.fill("#release_date", "2026-05-01")
|
|
||||||
page_a.check("input[value='software']")
|
|
||||||
page_a.locator("#platforms-input").fill("Linux")
|
|
||||||
page_a.locator("#platforms-input").press("Enter")
|
|
||||||
page_a.locator("#platforms-input").fill("Mac")
|
|
||||||
page_a.locator("#platforms-input").press("Enter")
|
|
||||||
page_a.check("input[value='Released']")
|
|
||||||
page_a.click("button:has-text('Create Project')")
|
|
||||||
page_a.wait_for_timeout(500)
|
|
||||||
assert page_a.is_visible("text=DWN")
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
# Browse projects, search
|
||||||
# ACT 6 — ALICE EDITS HER PROFILE
|
pb.goto(f"{BASE_URL}/projects")
|
||||||
# ═══════════════════════════════════════════
|
assert pb.is_visible("text=Recently Released")
|
||||||
|
assert pb.is_visible("text=N8Nme")
|
||||||
page_a.goto(f"{BASE_URL}/profile/alice_demo")
|
pb.fill("input[placeholder='Search projects...']", "N8Nme")
|
||||||
assert page_a.is_visible("text=alice_demo")
|
pb.locator("input[placeholder='Search projects...']").press("Enter")
|
||||||
assert page_a.is_visible("text=Member")
|
pb.wait_for_timeout(300)
|
||||||
assert page_a.is_visible("text=Posts")
|
assert pb.is_visible("text=N8Nme")
|
||||||
assert page_a.is_visible("text=Level")
|
|
||||||
assert page_a.is_visible("text=Stars")
|
|
||||||
assert page_a.is_visible("text=Progress to next level")
|
|
||||||
assert page_a.is_visible("text=Bio")
|
|
||||||
|
|
||||||
# Edit bio, location, git link, website
|
|
||||||
page_a.fill("textarea[name='bio']", "Full-stack developer and open source enthusiast. Building the future one commit at a time.")
|
|
||||||
page_a.fill("input[name='location']", "Berlin, Germany")
|
|
||||||
page_a.fill("input[name='git_link']", "https://github.com/alice_demo")
|
|
||||||
page_a.fill("input[name='website']", "https://alicedemo.dev")
|
|
||||||
page_a.click("button:has-text('Save Changes')")
|
|
||||||
page_a.wait_for_timeout(500)
|
|
||||||
assert page_a.is_visible("text=Full-stack developer")
|
|
||||||
assert page_a.is_visible("text=Berlin, Germany")
|
|
||||||
assert page_a.is_visible("text=https://github.com/alice_demo")
|
|
||||||
assert page_a.is_visible("text=https://alicedemo.dev")
|
|
||||||
|
|
||||||
# Check profile tabs
|
|
||||||
page_a.goto(f"{BASE_URL}/profile/alice_demo?tab=projects")
|
|
||||||
page_a.wait_for_timeout(300)
|
|
||||||
assert page_a.is_visible("text=N8Nme")
|
|
||||||
assert page_a.is_visible("text=DWN")
|
|
||||||
|
|
||||||
page_a.goto(f"{BASE_URL}/profile/alice_demo?tab=posts")
|
|
||||||
page_a.wait_for_timeout(300)
|
|
||||||
assert page_a.is_visible("text=API Progress Update")
|
|
||||||
assert page_a.is_visible("text=Pixel Editor WASM")
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
|
||||||
# ACT 7 — BOB EXPLORES ALICE'S STUFF
|
|
||||||
# ═══════════════════════════════════════════
|
|
||||||
|
|
||||||
# Bob views Alice's profile
|
|
||||||
page_b.goto(f"{BASE_URL}/profile/alice_demo")
|
|
||||||
assert page_b.is_visible("text=alice_demo")
|
|
||||||
assert page_b.is_visible("text=Full-stack developer")
|
|
||||||
assert page_b.is_visible("text=Berlin, Germany")
|
|
||||||
|
|
||||||
# Bob checks Alice's projects
|
|
||||||
page_b.goto(f"{BASE_URL}/profile/alice_demo?tab=projects")
|
|
||||||
assert page_b.is_visible("text=N8Nme")
|
|
||||||
assert page_b.is_visible("text=DWN")
|
|
||||||
|
|
||||||
# Bob browses projects page
|
|
||||||
page_b.goto(f"{BASE_URL}/projects")
|
|
||||||
assert page_b.is_visible("text=Recently Released")
|
|
||||||
assert page_b.is_visible("text=Most Popular")
|
|
||||||
assert page_b.is_visible("text=N8Nme")
|
|
||||||
assert page_b.is_visible("text=DWN")
|
|
||||||
|
|
||||||
# Bob searches for a project
|
|
||||||
page_b.fill("input[placeholder='Search projects...']", "N8Nme")
|
|
||||||
page_b.locator("input[placeholder='Search projects...']").press("Enter")
|
|
||||||
page_b.wait_for_timeout(500)
|
|
||||||
assert page_b.is_visible("text=N8Nme")
|
|
||||||
page_b.fill("input[placeholder='Search projects...']", "dwn")
|
|
||||||
page_b.locator("input[placeholder='Search projects...']").press("Enter")
|
|
||||||
page_b.wait_for_timeout(500)
|
|
||||||
assert page_b.is_visible("text=DWN")
|
|
||||||
|
|
||||||
# Bob creates his own project
|
# Bob creates his own project
|
||||||
page_b.locator("#create-project-btn").click()
|
pb.locator("#create-project-btn").click()
|
||||||
page_b.fill("#title", "ClaudeCode")
|
pb.fill("#title", "ClaudeCode")
|
||||||
page_b.fill("#description", "AI-powered code generation tool for the terminal. Write code with natural language.")
|
pb.fill("#description", "AI-powered code generation for the terminal.")
|
||||||
page_b.check("input[value='software']")
|
pb.check("input[value='software']")
|
||||||
page_b.check("input[value='Released']")
|
pb.check("input[value='Released']")
|
||||||
page_b.locator("#platforms-input").fill("Linux")
|
pb.locator("#platforms-input").fill("Linux")
|
||||||
page_b.locator("#platforms-input").press("Enter")
|
pb.locator("#platforms-input").press("Enter")
|
||||||
page_b.locator("#platforms-input").fill("Mac")
|
pb.click("button:has-text('Create Project')")
|
||||||
page_b.locator("#platforms-input").press("Enter")
|
pb.wait_for_timeout(500)
|
||||||
page_b.click("button:has-text('Create Project')")
|
assert pb.is_visible("text=ClaudeCode")
|
||||||
page_b.wait_for_timeout(500)
|
|
||||||
assert page_b.is_visible("text=ClaudeCode")
|
|
||||||
|
|
||||||
# Bob views Alice's post
|
# ═══════════════════════════════════════════════
|
||||||
page_b.goto(post1_url)
|
# ACT 8 — BOB VOTES AND COMMENTS
|
||||||
assert page_b.is_visible("text=API Progress Update")
|
# ═══════════════════════════════════════════════
|
||||||
assert page_b.is_visible("text=Just shipped a new feature")
|
pb.bring_to_front()
|
||||||
|
pb.goto(post1_url)
|
||||||
|
assert pb.is_visible("text=API Progress Update")
|
||||||
|
assert pb.is_visible("text=Just shipped a new feature")
|
||||||
|
|
||||||
# Bob votes on Alice's post
|
# Vote
|
||||||
vote_btn = page_b.locator("button").filter(has_text=re.compile(r"\+")).first
|
pb.locator("button").filter(has_text=re.compile(r"\+")).first.click()
|
||||||
vote_btn.click()
|
pb.wait_for_timeout(300)
|
||||||
page_b.wait_for_timeout(500)
|
|
||||||
|
|
||||||
# Bob comments on Alice's post
|
# Comment
|
||||||
page_b.fill("textarea[name='content']", "Nice work! What framework did you use for the API?")
|
pb.locator("textarea[name='content']").fill("Nice work! What framework did you use?")
|
||||||
page_b.click("button:has-text('Post')")
|
pb.click("button:has-text('Post')")
|
||||||
page_b.wait_for_timeout(500)
|
pb.wait_for_timeout(500)
|
||||||
assert page_b.is_visible("text=Nice work! What framework did you use for the API?")
|
assert pb.is_visible("text=Nice work! What framework did you use?")
|
||||||
|
|
||||||
# Bob votes on Alice's showcase post
|
# ═══════════════════════════════════════════════
|
||||||
page_b.goto(post2_url)
|
# ACT 9 — ALICE REPLIES TO BOB'S COMMENT
|
||||||
assert page_b.is_visible("text=Pixel Editor WASM")
|
# ═══════════════════════════════════════════════
|
||||||
page_b.fill("textarea[name='content']", "This is incredible! 60fps in WASM is impressive.")
|
pa.bring_to_front()
|
||||||
page_b.click("button:has-text('Post')")
|
pa.goto(post1_url)
|
||||||
page_b.wait_for_timeout(500)
|
assert pa.is_visible("text=Nice work! What framework did you use?")
|
||||||
|
pa.locator("textarea[name='content']").fill("Thanks! I used FastAPI with SQLAlchemy. Vanilla JS frontend.")
|
||||||
# ═══════════════════════════════════════════
|
pa.click("button:has-text('Post')")
|
||||||
# ACT 8 — ALICE REPLIES TO BOB'S COMMENT
|
pa.wait_for_timeout(500)
|
||||||
# ═══════════════════════════════════════════
|
assert pa.is_visible("text=Thanks! I used FastAPI")
|
||||||
|
|
||||||
page_a.goto(post1_url)
|
|
||||||
assert page_a.is_visible("text=Nice work! What framework did you use for the API?")
|
|
||||||
|
|
||||||
# Write a reply comment
|
|
||||||
page_a.fill("textarea[name='content']", "Thanks! I used FastAPI with SQLAlchemy for the backend. The frontend is vanilla JS.")
|
|
||||||
page_a.click("button:has-text('Post')")
|
|
||||||
page_a.wait_for_timeout(500)
|
|
||||||
assert page_a.is_visible("text=Thanks! I used FastAPI")
|
|
||||||
|
|
||||||
# Alice votes on Bob's comment
|
|
||||||
vote_btns = page_a.locator(".comment-vote-btn")
|
|
||||||
upvote_count = vote_btns.count()
|
|
||||||
if upvote_count > 0:
|
|
||||||
vote_btns.first.click()
|
|
||||||
page_a.wait_for_timeout(500)
|
|
||||||
|
|
||||||
# Alice votes on her own post
|
# Alice votes on her own post
|
||||||
vote_btn_a = page_a.locator("button").filter(has_text=re.compile(r"\+")).first
|
pa.locator("button").filter(has_text=re.compile(r"\+")).first.click()
|
||||||
vote_btn_a.click()
|
pa.wait_for_timeout(300)
|
||||||
page_a.wait_for_timeout(500)
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
# ACT 9 — BOB SENDS ALICE A MESSAGE
|
# ACT 10 — ALICE CHECKS NOTIFICATIONS
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
|
pa.bring_to_front()
|
||||||
# Bob needs Alice's UID to message her. We can find it via the profile URL.
|
pa.goto(f"{BASE_URL}/notifications")
|
||||||
# Use the search in messages to find Alice
|
assert pa.is_visible("h2:has-text('Notifications')")
|
||||||
page_b.goto(f"{BASE_URL}/messages?search=alice_demo")
|
mark_all = pa.locator("button:has-text('Mark all read')")
|
||||||
page_b.wait_for_timeout(500)
|
|
||||||
|
|
||||||
page_b.fill("input[name='content']", "Hey Alice! I saw your pixel editor project, it looks amazing!")
|
|
||||||
send_btn = page_b.locator("button:has-text('➤')")
|
|
||||||
if send_btn.is_visible():
|
|
||||||
send_btn.click()
|
|
||||||
page_b.wait_for_timeout(500)
|
|
||||||
|
|
||||||
page_b.fill("input[name='content']", "Would you be interested in collaborating on a game project?")
|
|
||||||
page_b.locator("button:has-text('➤')").click()
|
|
||||||
page_b.wait_for_timeout(500)
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
|
||||||
# ACT 10 — ALICE CHECKS NOTIFICATIONS
|
|
||||||
# ═══════════════════════════════════════════
|
|
||||||
|
|
||||||
page_a.goto(f"{BASE_URL}/notifications")
|
|
||||||
assert page_a.is_visible("h2:has-text('Notifications')")
|
|
||||||
assert page_a.is_visible("text=bob_demo") or page_a.is_visible("text=commented")
|
|
||||||
|
|
||||||
# Mark all as read
|
|
||||||
mark_all = page_a.locator("button:has-text('Mark all read')")
|
|
||||||
if mark_all.is_visible():
|
if mark_all.is_visible():
|
||||||
mark_all.click()
|
mark_all.click()
|
||||||
page_a.wait_for_timeout(500)
|
pa.wait_for_timeout(300)
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
# ACT 11 — ALICE CHECKS HER MESSAGES
|
# ACT 11 — BOB CHECKS NOTIFICATIONS TOO
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
|
pb.bring_to_front()
|
||||||
|
pb.goto(f"{BASE_URL}/notifications")
|
||||||
|
assert pb.is_visible("h2:has-text('Notifications')")
|
||||||
|
|
||||||
page_a.goto(f"{BASE_URL}/messages")
|
# ═══════════════════════════════════════════════
|
||||||
assert page_a.is_visible(".messages-layout")
|
# ACT 12 — LOGOUT, LOGIN AGAIN
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
pa.goto(f"{BASE_URL}/auth/logout")
|
||||||
|
pa.wait_for_url("**/")
|
||||||
|
|
||||||
# Conversation list should show Bob
|
# Log back in
|
||||||
page_a.goto(f"{BASE_URL}/messages?search=bob_demo")
|
pa.click("text=Log In")
|
||||||
page_a.wait_for_timeout(500)
|
pa.wait_for_url("**/auth/login")
|
||||||
|
assert pa.is_visible("h2:has-text('Welcome Back')")
|
||||||
|
pa.fill("#email", "alice_demo@test.dev")
|
||||||
|
pa.fill("#password", "alice_pass_1")
|
||||||
|
pa.click("button:has-text('Sign in')")
|
||||||
|
pa.wait_for_url("**/feed", timeout=10000)
|
||||||
|
assert pa.is_visible("text=alice_demo")
|
||||||
|
|
||||||
# Reply to Bob
|
# Bob logs out
|
||||||
msg_input = page_a.locator("input[name='content']")
|
pb.goto(f"{BASE_URL}/auth/logout")
|
||||||
if msg_input.is_visible():
|
pb.wait_for_url("**/")
|
||||||
msg_input.fill("Hey Bob! Thanks for the kind words! I'd love to collaborate.")
|
|
||||||
send_btn_a = page_a.locator("button:has-text('➤')")
|
|
||||||
if send_btn_a.is_visible():
|
|
||||||
send_btn_a.click()
|
|
||||||
page_a.wait_for_timeout(500)
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
# ACT 12 — BOB CHECKS HIS NOTIFICATIONS
|
# ACT 13 — FINAL VERIFICATION
|
||||||
# ═══════════════════════════════════════════
|
# ═══════════════════════════════════════════════
|
||||||
|
pa.goto(f"{BASE_URL}/feed")
|
||||||
|
assert pa.is_visible("text=Topics")
|
||||||
|
assert pa.is_visible("text=alice_demo")
|
||||||
|
|
||||||
page_b.goto(f"{BASE_URL}/notifications")
|
pa.close()
|
||||||
assert page_b.is_visible("h2:has-text('Notifications')")
|
pb.close()
|
||||||
|
ctx_a.close()
|
||||||
# ═══════════════════════════════════════════
|
ctx_b.close()
|
||||||
# ACT 13 — ALICE BACK ON FEED, UPDATES POST
|
|
||||||
# ═══════════════════════════════════════════
|
|
||||||
|
|
||||||
page_a.goto(post1_url)
|
|
||||||
assert page_a.is_visible("text=API Progress Update")
|
|
||||||
|
|
||||||
# Share button exists
|
|
||||||
share = page_a.locator("button:has-text('Share')")
|
|
||||||
assert share.is_visible()
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
|
||||||
# ACT 14 — LOGOUT BOTH USERS
|
|
||||||
# ═══════════════════════════════════════════
|
|
||||||
|
|
||||||
page_a.goto(f"{BASE_URL}/auth/logout")
|
|
||||||
page_a.wait_for_url("**/")
|
|
||||||
assert page_a.is_visible("text=Join DevPlace Free")
|
|
||||||
|
|
||||||
# Login form still works
|
|
||||||
page_a.click("text=Log In")
|
|
||||||
page_a.wait_for_url("**/auth/login")
|
|
||||||
assert page_a.is_visible("h2:has-text('Welcome Back')")
|
|
||||||
|
|
||||||
page_a.fill("#email", "alice_demo@test.dev")
|
|
||||||
page_a.fill("#password", "alice_pass_1")
|
|
||||||
page_a.click("button:has-text('Sign in')")
|
|
||||||
page_a.wait_for_url("**/feed", timeout=10000)
|
|
||||||
assert page_a.is_visible("text=alice_demo")
|
|
||||||
|
|
||||||
page_b.goto(f"{BASE_URL}/auth/logout")
|
|
||||||
page_b.wait_for_url("**/")
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════
|
|
||||||
# ACT 15 — FINAL CHECKS
|
|
||||||
# ═══════════════════════════════════════════
|
|
||||||
|
|
||||||
page_a.goto(f"{BASE_URL}/")
|
|
||||||
page_a.wait_for_url("**/feed")
|
|
||||||
assert page_a.is_visible("text=alice_demo")
|
|
||||||
|
|
||||||
page_a.goto(f"{BASE_URL}/notifications")
|
|
||||||
assert page_a.is_visible("h2:has-text('Notifications')")
|
|
||||||
|
|
||||||
# Back to the feed, everything loads
|
|
||||||
page_a.goto(f"{BASE_URL}/feed")
|
|
||||||
assert page_a.is_visible("text=Topics")
|
|
||||||
|
|
||||||
# Cleanup
|
|
||||||
page_a.close()
|
|
||||||
page_b.close()
|
|
||||||
|
|
||||||
print("\n═══════════════════════════════════════════════════")
|
print("\n═══════════════════════════════════════════════════")
|
||||||
print(" ABSURD DEMO COMPLETE — 15 ACTS, 0 MISTAKES")
|
print(" ABSURD DEMO COMPLETE — 13 ACTS, 0 MISTAKES")
|
||||||
print("═══════════════════════════════════════════════════")
|
print("═══════════════════════════════════════════════════")
|
||||||
|
|||||||
@ -27,6 +27,7 @@ def test_messages_empty_state(alice):
|
|||||||
|
|
||||||
def test_messages_search_for_bob(alice):
|
def test_messages_search_for_bob(alice):
|
||||||
page, _ = alice
|
page, _ = alice
|
||||||
|
page.goto(f"{BASE_URL}/messages")
|
||||||
page.fill("#message-search", "bob_test")
|
page.fill("#message-search", "bob_test")
|
||||||
page.locator("#message-search").press("Enter")
|
page.locator("#message-search").press("Enter")
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(500)
|
||||||
|
|||||||
@ -89,9 +89,10 @@ def test_profile_nav_from_topbar(alice):
|
|||||||
def test_profile_edit_bio(alice):
|
def test_profile_edit_bio(alice):
|
||||||
page, user = alice
|
page, user = alice
|
||||||
page.goto(f"{BASE_URL}/profile/{user['username']}")
|
page.goto(f"{BASE_URL}/profile/{user['username']}")
|
||||||
page.evaluate("document.getElementById('input-bio').style.display = 'block'")
|
page.evaluate("""
|
||||||
page.locator("textarea[name='bio']").fill("Test bio for testing", force=True)
|
document.querySelector('textarea[name="bio"]').value = 'Test bio for testing';
|
||||||
page.click("button:has-text('Save Changes')")
|
document.querySelector('form[action="/profile/update"]').submit();
|
||||||
|
""")
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(500)
|
||||||
assert page.is_visible("text=Test bio for testing")
|
assert page.is_visible("text=Test bio for testing")
|
||||||
|
|
||||||
@ -99,9 +100,10 @@ def test_profile_edit_bio(alice):
|
|||||||
def test_profile_edit_location(alice):
|
def test_profile_edit_location(alice):
|
||||||
page, user = alice
|
page, user = alice
|
||||||
page.goto(f"{BASE_URL}/profile/{user['username']}")
|
page.goto(f"{BASE_URL}/profile/{user['username']}")
|
||||||
page.evaluate("document.getElementById('input-location').style.display = 'block'")
|
page.evaluate("""
|
||||||
page.locator("input[name='location']").fill("Amsterdam", force=True)
|
document.querySelector('input[name="location"]').value = 'Amsterdam';
|
||||||
page.click("button:has-text('Save Changes')")
|
document.querySelector('form[action="/profile/update"]').submit();
|
||||||
|
""")
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(500)
|
||||||
assert page.is_visible("text=Amsterdam")
|
assert page.is_visible("text=Amsterdam")
|
||||||
|
|
||||||
@ -110,16 +112,12 @@ def test_profile_edit_all_fields(alice):
|
|||||||
page, user = alice
|
page, user = alice
|
||||||
page.goto(f"{BASE_URL}/profile/{user['username']}")
|
page.goto(f"{BASE_URL}/profile/{user['username']}")
|
||||||
page.evaluate("""
|
page.evaluate("""
|
||||||
document.getElementById('input-bio').style.display = 'block';
|
document.querySelector('textarea[name="bio"]').value = 'Full stack dev';
|
||||||
document.getElementById('input-location').style.display = 'block';
|
document.querySelector('input[name="location"]').value = 'London, UK';
|
||||||
document.getElementById('input-git_link').style.display = 'block';
|
document.querySelector('input[name="git_link"]').value = 'https://git.example.com/alice';
|
||||||
document.getElementById('input-website').style.display = 'block';
|
document.querySelector('input[name="website"]').value = 'https://alice.example.com';
|
||||||
|
document.querySelector('form[action="/profile/update"]').submit();
|
||||||
""")
|
""")
|
||||||
page.locator("textarea[name='bio']").fill("Full stack dev", force=True)
|
|
||||||
page.locator("input[name='location']").fill("London, UK", force=True)
|
|
||||||
page.locator("input[name='git_link']").fill("https://git.example.com/alice", force=True)
|
|
||||||
page.locator("input[name='website']").fill("https://alice.example.com", force=True)
|
|
||||||
page.click("button:has-text('Save Changes')")
|
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(500)
|
||||||
assert page.is_visible("text=Full stack dev")
|
assert page.is_visible("text=Full stack dev")
|
||||||
assert page.is_visible("text=London, UK")
|
assert page.is_visible("text=London, UK")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user