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
|
||||
make install # pip install -e .
|
||||
make dev # uvicorn --reload on port 10500
|
||||
make test # run all tests headless
|
||||
make test-headed # run tests in visible browser
|
||||
make test # 95 tests headless
|
||||
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
|
||||
|
||||
- **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)
|
||||
- **Username:** letters, numbers, hyphens, underscores only. 3-32 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
|
||||
|
||||
@ -45,14 +41,119 @@ Use `hawk` (not ruff/mypy): `hawk .`
|
||||
- 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).
|
||||
- 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`.
|
||||
- `DEVPLACE_DATABASE_URL` env var overrides the SQLite path (used by tests for isolation).
|
||||
- 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).
|
||||
|
||||
## 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).
|
||||
- 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`.
|
||||
- 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/`.
|
||||
|
||||
## 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.templating import templates
|
||||
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(
|
||||
level=logging.INFO,
|
||||
@ -26,6 +26,7 @@ app.include_router(profile.router, prefix="/profile")
|
||||
app.include_router(messages.router, prefix="/messages")
|
||||
app.include_router(notifications.router, prefix="/notifications")
|
||||
app.include_router(votes.router, prefix="/votes")
|
||||
app.include_router(avatar.router, prefix="/avatar")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
|
||||
@ -59,6 +59,9 @@ async def signup(request: Request):
|
||||
)
|
||||
|
||||
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({
|
||||
"uid": uid,
|
||||
"username": username,
|
||||
@ -68,7 +71,7 @@ async def signup(request: Request):
|
||||
"location": "",
|
||||
"git_link": "",
|
||||
"website": "",
|
||||
"avatar": "",
|
||||
"avatar_style": avatar_style,
|
||||
"role": "Member",
|
||||
"level": 1,
|
||||
"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")
|
||||
raw_comments = list(comments_table.find(post_uid=post_uid, order_by=["created_at"]))
|
||||
comments = []
|
||||
comment_map = {}
|
||||
for c in raw_comments:
|
||||
commenter = users_table.find_one(uid=c["user_uid"])
|
||||
comments.append({
|
||||
comment_map[c["uid"]] = {
|
||||
"comment": c,
|
||||
"author": commenter,
|
||||
"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))),
|
||||
"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", {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"post": post,
|
||||
"author": author,
|
||||
"comments": comments,
|
||||
"comments": top_level,
|
||||
"time_ago": time_ago(post["created_at"]),
|
||||
})
|
||||
|
||||
@ -52,15 +52,21 @@ async def update_profile(request: Request):
|
||||
location = form.get("location", "").strip()
|
||||
git_link = form.get("git_link", "").strip()
|
||||
website = form.get("website", "").strip()
|
||||
avatar_style = form.get("avatar_style", "").strip()
|
||||
|
||||
users = get_table("users")
|
||||
users.update({
|
||||
update_data = {
|
||||
"uid": user["uid"],
|
||||
"bio": bio,
|
||||
"location": location,
|
||||
"git_link": git_link,
|
||||
"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']}")
|
||||
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-lg { width: 80px; height: 80px; font-size: 2rem; }
|
||||
.avatar-img { border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
@ -188,6 +189,16 @@ img {
|
||||
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 {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@ -8,6 +8,10 @@
|
||||
.feed-sidebar {
|
||||
position: sticky;
|
||||
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 {
|
||||
@ -22,7 +26,7 @@
|
||||
.feed-categories {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.feed-category {
|
||||
@ -37,7 +41,7 @@
|
||||
}
|
||||
|
||||
.feed-category:hover {
|
||||
background: var(--bg-card);
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@ -76,6 +80,7 @@
|
||||
|
||||
.feed-nav-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
|
||||
.feed-nav-btn.active {
|
||||
@ -89,6 +94,19 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -99,12 +117,13 @@
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
padding: 1.25rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.post-card:hover {
|
||||
border-color: var(--border-light);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.post-header {
|
||||
@ -114,21 +133,29 @@
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.post-author-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.post-author {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.post-author-role {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.post-time {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.post-topic {
|
||||
@ -140,14 +167,19 @@
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 0.75rem;
|
||||
word-break: break-word;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-actions {
|
||||
@ -159,14 +191,18 @@
|
||||
}
|
||||
|
||||
.post-action-btn {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.post-action-btn:hover {
|
||||
@ -174,10 +210,15 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.post-action-btn.voted {
|
||||
.post-action-btn.voted,
|
||||
.post-action-btn.vote-up {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.post-action-btn.share {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.feed-right {
|
||||
position: sticky;
|
||||
top: calc(var(--nav-height) + 1rem);
|
||||
@ -190,7 +231,7 @@
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.daily-topic-label {
|
||||
@ -205,12 +246,14 @@
|
||||
.daily-topic-card h4 {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.375rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.daily-topic-card p {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.daily-topic-card .topic-links {
|
||||
@ -227,7 +270,7 @@
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.community-stats h3 {
|
||||
@ -242,16 +285,28 @@
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0;
|
||||
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 {
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.stat-row .value {
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.feed-fab {
|
||||
@ -267,14 +322,17 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--shadow-lg);
|
||||
box-shadow: 0 4px 16px rgba(255, 107, 53, 0.4);
|
||||
transition: all 0.2s;
|
||||
z-index: 100;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feed-fab:hover {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(255, 107, 53, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
|
||||
@ -28,8 +28,8 @@
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
padding: 1.25rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.notification-card.unread {
|
||||
@ -73,12 +73,4 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.75rem 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.comment:last-child {
|
||||
border-bottom: none;
|
||||
.comment-replies {
|
||||
margin-top: 0.75rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
|
||||
.comment-votes {
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
{% endif %}
|
||||
</a>
|
||||
<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">
|
||||
<span class="topnav-user-name">{{ user['username'] }}</span>
|
||||
<span class="topnav-user-role">{{ user.get('role', 'Member') }}</span>
|
||||
|
||||
@ -159,13 +159,12 @@
|
||||
{% for item in posts %}
|
||||
<article class="post-card fade-in">
|
||||
<div class="post-header">
|
||||
<div class="avatar avatar-sm">{{ item.author['username'][:1].upper() if item.author else '?' }}</div>
|
||||
<div>
|
||||
<div class="post-author">{{ item.author['username'] if item.author else 'Unknown' }}
|
||||
{% if item.author and item.author.get('role') %}
|
||||
<span class="post-author-role">· {{ item.author['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<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 class="post-author-wrap">
|
||||
<span class="post-author">{{ item.author['username'] if item.author else 'Unknown' }}</span>
|
||||
{% if item.author and item.author.get('role') %}
|
||||
<span class="post-author-role">{{ item.author['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="post-time">{{ item.time_ago }}</span>
|
||||
</div>
|
||||
@ -183,23 +182,21 @@
|
||||
<div class="post-actions">
|
||||
<form method="POST" action="/votes/post/{{ item.post['uid'] }}" style="display:inline;">
|
||||
<input type="hidden" name="value" value="1">
|
||||
<button type="submit" class="post-action-btn" data-vote="1" data-target="{{ item.post['uid'] }}" data-type="post">
|
||||
<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) }}
|
||||
</button>
|
||||
</form>
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn">
|
||||
💬 {{ item.comment_count }}
|
||||
</a>
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn" style="margin-left: auto;">
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn share">
|
||||
↗︎ Open
|
||||
</a>
|
||||
<button class="post-action-btn">🔗 Share</button>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
||||
<p style="color: var(--text-muted); font-size: 0.9375rem;">No posts yet. Be the first!</p>
|
||||
</div>
|
||||
<div class="empty-state">No posts yet. Be the first!</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@ -234,7 +231,7 @@
|
||||
{% for author in top_authors %}
|
||||
<div class="stat-row">
|
||||
<span class="label">
|
||||
<span class="avatar avatar-sm" style="display: inline-flex; width: 20px; height: 20px; font-size: 0.625rem; vertical-align: middle; margin-right: 0.375rem;">{{ author['username'][:1].upper() }}</span>
|
||||
<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'] }}
|
||||
</span>
|
||||
<span class="value">{{ author.get('stars', 0) }}</span>
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
<div class="messages-conversations">
|
||||
{% for conv in conversations %}
|
||||
<a href="/messages?with_uid={{ conv.other_user['uid'] }}" class="conversation-item {% if current_conversation == conv.other_user['uid'] %}active{% endif %}">
|
||||
<div class="avatar avatar-sm">{{ conv.other_user['username'][:1].upper() }}</div>
|
||||
<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-name">{{ conv.other_user['username'] }}</div>
|
||||
<div class="conversation-preview">{{ conv.last_message[:60] }}</div>
|
||||
@ -60,9 +60,7 @@
|
||||
<span class="conversation-time">{{ conv.last_message_at[:10] }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<div style="padding: 2rem 1rem; text-align: center; color: var(--text-muted); font-size: 0.875rem;">
|
||||
No conversations yet
|
||||
</div>
|
||||
<div class="empty-state" style="border: none;">No conversations yet</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@ -70,7 +68,7 @@
|
||||
<div class="messages-main">
|
||||
{% if other_user %}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@ -52,7 +52,7 @@
|
||||
<div class="notifications-list">
|
||||
{% for item in notifications %}
|
||||
<div class="notification-card {% if not item.notification['read'] %}unread{% endif %}">
|
||||
<div class="avatar avatar-sm">{{ item.actor['username'][:1].upper() if item.actor else '?' }}</div>
|
||||
<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-text">{{ item.notification['message'] }}</div>
|
||||
<div class="notification-time">{{ item.time_ago }}</div>
|
||||
@ -62,9 +62,7 @@
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="notifications-empty">
|
||||
<p>No notifications yet</p>
|
||||
</div>
|
||||
<div class="empty-state">No notifications yet</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -62,7 +62,7 @@
|
||||
|
||||
<article class="post-detail">
|
||||
<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 class="post-detail-author">{{ author['username'] if author else 'Unknown' }}
|
||||
{% if author and author.get('role') %}
|
||||
@ -97,8 +97,9 @@
|
||||
<section class="comments-section">
|
||||
<h3>Comments</h3>
|
||||
|
||||
{% for item in comments %}
|
||||
<div class="comment">
|
||||
{% macro render_comment(item, depth=0) %}
|
||||
<div class="comment" style="margin-left: {{ depth * 1.5 }}rem;">
|
||||
<div class="comment-thread-line"></div>
|
||||
<div class="comment-votes">
|
||||
<form method="POST" action="/votes/comment/{{ item.comment['uid'] }}">
|
||||
<input type="hidden" name="value" value="1">
|
||||
@ -113,7 +114,7 @@
|
||||
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<div class="avatar avatar-sm" style="width: 24px; height: 24px; font-size: 0.625rem;">{{ item.author['username'][:1].upper() if item.author else '?' }}</div>
|
||||
<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-time">{{ item.time_ago }}</span>
|
||||
</div>
|
||||
@ -126,8 +127,20 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.children %}
|
||||
<div class="comment-replies">
|
||||
{% for child in item.children %}
|
||||
{{ render_comment(child, depth + 1) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% for item in comments %}
|
||||
{{ render_comment(item, 0) }}
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted); font-size: 0.875rem; text-align: center; padding: 2rem 0;">No comments yet. Start the discussion.</p>
|
||||
{% endfor %}
|
||||
@ -135,7 +148,7 @@
|
||||
{% if user %}
|
||||
<form class="comment-form" method="POST" action="/comments/create">
|
||||
<input type="hidden" name="post_uid" value="{{ post['uid'] }}">
|
||||
<div class="avatar avatar-sm">{{ user['username'][:1].upper() }}</div>
|
||||
<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>
|
||||
<div class="comment-form-actions">
|
||||
<button type="button" title="Add emoji">😀</button>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/profile.css">
|
||||
<link rel="stylesheet" href="/static/css/projects.css">
|
||||
<style>
|
||||
.topnav {
|
||||
position: fixed;
|
||||
@ -45,8 +46,8 @@
|
||||
<div class="profile-layout">
|
||||
<aside class="profile-sidebar">
|
||||
<div class="profile-card">
|
||||
<div class="profile-avatar-wrap">
|
||||
<div class="avatar avatar-lg">{{ profile_user['username'][:1].upper() }}</div>
|
||||
<div class="profile-avatar-wrap" style="position: relative;">
|
||||
<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 class="profile-name">{{ profile_user['username'] }}</div>
|
||||
{% if profile_user.get('role') %}
|
||||
@ -87,7 +88,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if user and user['uid'] == profile_user['uid'] %}
|
||||
{% if user and user['uid'] == profile_user['uid'] %}
|
||||
<div class="profile-info">
|
||||
<form method="POST" action="/profile/update">
|
||||
<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;">
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
@ -174,13 +192,12 @@
|
||||
{% for item in posts %}
|
||||
<article class="post-card fade-in">
|
||||
<div class="post-header">
|
||||
<div class="avatar avatar-sm">{{ profile_user['username'][:1].upper() }}</div>
|
||||
<div>
|
||||
<div class="post-author">{{ profile_user['username'] }}
|
||||
{% if profile_user.get('role') %}
|
||||
<span class="post-author-role">· {{ profile_user['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<img src="{{ avatar_url(profile_user.get('avatar_style', 'initials'), profile_user['username'], 32) }}" class="avatar-img avatar-sm" alt="{{ profile_user['username'] }}">
|
||||
<div class="post-author-wrap">
|
||||
<span class="post-author">{{ profile_user['username'] }}</span>
|
||||
{% if profile_user.get('role') %}
|
||||
<span class="post-author-role">{{ profile_user['role'] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="post-time">{{ item.time_ago }}</span>
|
||||
</div>
|
||||
@ -194,33 +211,38 @@
|
||||
<div class="post-actions">
|
||||
<span class="post-action-btn" style="cursor: default;">+{{ item.post.get('stars', 0) }}</span>
|
||||
<span class="post-action-btn" style="cursor: default;">💬 {{ item.comment_count }}</span>
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn" style="margin-left: auto;">↗︎ Open</a>
|
||||
<a href="/posts/{{ item.post['uid'] }}" class="post-action-btn share">↗︎ Open</a>
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
||||
<p style="color: var(--text-muted);">No posts yet.</p>
|
||||
</div>
|
||||
<div class="empty-state">No posts yet.</div>
|
||||
{% endfor %}
|
||||
{% elif current_tab == 'projects' %}
|
||||
{% for p in projects %}
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1rem; font-weight: 700; margin-bottom: 0.25rem;">{{ p['title'] }}</h3>
|
||||
<p style="font-size: 0.8125rem; color: var(--text-secondary);">{{ p.get('description', '')[:200] }}</p>
|
||||
<div style="margin-top: 0.5rem; display: flex; gap: 0.375rem;">
|
||||
<span class="badge" style="background: var(--border); color: var(--text-muted);">{{ p.get('project_type', 'software') }}</span>
|
||||
<span class="badge" style="background: var(--border); color: var(--text-muted);">{{ p.get('status', 'In Development') }}</span>
|
||||
<div class="project-card fade-in">
|
||||
<div class="project-card-header">
|
||||
<h3 class="project-card-title">{{ p['title'] }}</h3>
|
||||
</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>
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
||||
<p style="color: var(--text-muted);">No projects yet.</p>
|
||||
</div>
|
||||
<div class="empty-state">No projects yet.</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 3rem 1rem;">
|
||||
<p style="color: var(--text-muted);">Activity coming soon.</p>
|
||||
</div>
|
||||
<div class="empty-state">Activity coming soon.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -122,9 +122,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="text-align: center; padding: 3rem 1rem; grid-column: 1 / -1;">
|
||||
<p style="color: var(--text-muted); font-size: 0.9375rem;">No projects found. Create one!</p>
|
||||
</div>
|
||||
<div class="empty-state" style="grid-column: 1 / -1;">No projects found. Create one!</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -47,7 +47,26 @@
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="auth-footer">
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from devplacepy.config import TEMPLATES_DIR
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.avatar import avatar_url, avatar_styles
|
||||
|
||||
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_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]",
|
||||
"python-dotenv",
|
||||
"aiofiles",
|
||||
"httpx",
|
||||
"dicebear",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@ -94,10 +94,12 @@ def browser_context(browser):
|
||||
def page(browser_context):
|
||||
p = browser_context.new_page()
|
||||
p.set_default_timeout(10000)
|
||||
p.bring_to_front()
|
||||
yield p
|
||||
p.close()
|
||||
|
||||
def signup_user(page, user):
|
||||
page.bring_to_front()
|
||||
page.goto(f"{BASE_URL}/auth/signup")
|
||||
page.fill("#username", user["username"])
|
||||
page.fill("#email", user["email"])
|
||||
@ -108,6 +110,7 @@ def signup_user(page, user):
|
||||
|
||||
|
||||
def login_user(page, user):
|
||||
page.bring_to_front()
|
||||
page.goto(f"{BASE_URL}/auth/login")
|
||||
page.fill("#email", user["email"])
|
||||
page.fill("#password", user["password"])
|
||||
@ -152,6 +155,7 @@ def bob(browser, seeded_db):
|
||||
ctx = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||
p = ctx.new_page()
|
||||
p.set_default_timeout(10000)
|
||||
p.bring_to_front()
|
||||
login_user(p, seeded_db["bob"])
|
||||
yield p, seeded_db["bob"]
|
||||
p.close()
|
||||
|
||||
@ -13,13 +13,6 @@ def create_post(page, content, topic="random", title=None):
|
||||
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):
|
||||
page.goto(f"{BASE_URL}/auth/signup")
|
||||
page.fill("#username", username)
|
||||
@ -30,370 +23,254 @@ def signup(page, username, email, password):
|
||||
page.wait_for_url("**/feed", timeout=10000)
|
||||
|
||||
|
||||
def test_absurd_full_journey(browser, app_server):
|
||||
page_a = browser.new_page()
|
||||
page_a.set_default_timeout(10000)
|
||||
page_b = browser.new_page()
|
||||
page_b.set_default_timeout(10000)
|
||||
def test_full_user_journey(browser, app_server):
|
||||
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
|
||||
pa = ctx_a.new_page()
|
||||
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}/")
|
||||
assert page_a.is_visible("text=Devplace.net")
|
||||
assert page_a.is_visible("text=The Developer Social Network")
|
||||
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")
|
||||
# Navigate to signup via link
|
||||
pa.click("a:has-text('Sign Up')", no_wait_after=True)
|
||||
pa.wait_for_url("**/auth/signup", timeout=10000)
|
||||
|
||||
page_b.goto(f"{BASE_URL}")
|
||||
page_b.click("text=Log In")
|
||||
page_b.wait_for_url("**/auth/login")
|
||||
page_b.click("text=Create new account")
|
||||
page_b.wait_for_url("**/auth/signup")
|
||||
# ═══════════════════════════════════════════════
|
||||
# ACT 2 — TWO USERS SIGN UP
|
||||
# ═══════════════════════════════════════════════
|
||||
signup(pa, "alice_demo", "alice_demo@test.dev", "alice_pass_1")
|
||||
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")
|
||||
signup(page_b, "bob_demo", "bob_demo@test.dev", "bob_pass_1")
|
||||
# Switch all feed tabs
|
||||
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)
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# ACT 3 — ALICE EXPLORES FEED
|
||||
# ═══════════════════════════════════════════
|
||||
# Switch all topic filters
|
||||
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")
|
||||
assert page_a.is_visible("text=All")
|
||||
assert page_a.is_visible("text=Trending")
|
||||
assert page_a.is_visible("text=Recent")
|
||||
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")
|
||||
# ═══════════════════════════════════════════════
|
||||
# ACT 4 — ALICE CREATES 4 POSTS (ALL TOPICS)
|
||||
# ═══════════════════════════════════════════════
|
||||
create_post(pa, "Just shipped a new feature for my REST API! Took me 3 days.", "devlog", "API Progress Update")
|
||||
post1_url = pa.url
|
||||
|
||||
# Switch tabs
|
||||
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")
|
||||
create_post(pa, "Check out this pixel art editor I built in WASM. Runs at 60fps!", "showcase", "Pixel Editor WASM")
|
||||
|
||||
# Click topic filters
|
||||
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, "Does anyone else think tabs are better than spaces? Fight me.", "rant", "Tabs vs Spaces")
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# ACT 4 — ALICE CREATES 4 POSTS
|
||||
# ═══════════════════════════════════════════
|
||||
create_post(pa, "Why do we call it tech debt when really we just cut corners?", "fun")
|
||||
|
||||
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")
|
||||
post2_url = page_a.url
|
||||
# Second project — released
|
||||
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")
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# ACT 5 — ALICE CREATES A PROJECT
|
||||
# ═══════════════════════════════════════════
|
||||
# Switch profile tabs
|
||||
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()
|
||||
page_a.fill("#title", "N8Nme")
|
||||
page_a.fill("#description", "Just powerful automation at your fingertips. Visual workflow builder with 300+ integrations.")
|
||||
page_a.fill("#release_date", "2026-04-15")
|
||||
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("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")
|
||||
# ═══════════════════════════════════════════════
|
||||
# ACT 7 — BOB EXPLORES ALICE'S STUFF
|
||||
# ═══════════════════════════════════════════════
|
||||
pb.bring_to_front()
|
||||
pb.goto(f"{BASE_URL}/profile/alice_demo")
|
||||
assert pb.is_visible("text=alice_demo")
|
||||
assert pb.is_visible("text=Full-stack developer")
|
||||
assert pb.is_visible("text=Berlin, Germany")
|
||||
|
||||
# Create a second project
|
||||
page_a.locator("#create-project-btn").click()
|
||||
page_a.fill("#title", "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")
|
||||
pb.goto(f"{BASE_URL}/profile/alice_demo?tab=projects")
|
||||
assert pb.is_visible("text=N8Nme")
|
||||
assert pb.is_visible("text=DWN")
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# ACT 6 — ALICE EDITS HER PROFILE
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
page_a.goto(f"{BASE_URL}/profile/alice_demo")
|
||||
assert page_a.is_visible("text=alice_demo")
|
||||
assert page_a.is_visible("text=Member")
|
||||
assert page_a.is_visible("text=Posts")
|
||||
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")
|
||||
# Browse projects, search
|
||||
pb.goto(f"{BASE_URL}/projects")
|
||||
assert pb.is_visible("text=Recently Released")
|
||||
assert pb.is_visible("text=N8Nme")
|
||||
pb.fill("input[placeholder='Search projects...']", "N8Nme")
|
||||
pb.locator("input[placeholder='Search projects...']").press("Enter")
|
||||
pb.wait_for_timeout(300)
|
||||
assert pb.is_visible("text=N8Nme")
|
||||
|
||||
# Bob creates his own project
|
||||
page_b.locator("#create-project-btn").click()
|
||||
page_b.fill("#title", "ClaudeCode")
|
||||
page_b.fill("#description", "AI-powered code generation tool for the terminal. Write code with natural language.")
|
||||
page_b.check("input[value='software']")
|
||||
page_b.check("input[value='Released']")
|
||||
page_b.locator("#platforms-input").fill("Linux")
|
||||
page_b.locator("#platforms-input").press("Enter")
|
||||
page_b.locator("#platforms-input").fill("Mac")
|
||||
page_b.locator("#platforms-input").press("Enter")
|
||||
page_b.click("button:has-text('Create Project')")
|
||||
page_b.wait_for_timeout(500)
|
||||
assert page_b.is_visible("text=ClaudeCode")
|
||||
pb.locator("#create-project-btn").click()
|
||||
pb.fill("#title", "ClaudeCode")
|
||||
pb.fill("#description", "AI-powered code generation for the terminal.")
|
||||
pb.check("input[value='software']")
|
||||
pb.check("input[value='Released']")
|
||||
pb.locator("#platforms-input").fill("Linux")
|
||||
pb.locator("#platforms-input").press("Enter")
|
||||
pb.click("button:has-text('Create Project')")
|
||||
pb.wait_for_timeout(500)
|
||||
assert pb.is_visible("text=ClaudeCode")
|
||||
|
||||
# Bob views Alice's post
|
||||
page_b.goto(post1_url)
|
||||
assert page_b.is_visible("text=API Progress Update")
|
||||
assert page_b.is_visible("text=Just shipped a new feature")
|
||||
# ═══════════════════════════════════════════════
|
||||
# ACT 8 — BOB VOTES AND COMMENTS
|
||||
# ═══════════════════════════════════════════════
|
||||
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_btn = page_b.locator("button").filter(has_text=re.compile(r"\+")).first
|
||||
vote_btn.click()
|
||||
page_b.wait_for_timeout(500)
|
||||
# Vote
|
||||
pb.locator("button").filter(has_text=re.compile(r"\+")).first.click()
|
||||
pb.wait_for_timeout(300)
|
||||
|
||||
# Bob comments on Alice's post
|
||||
page_b.fill("textarea[name='content']", "Nice work! What framework did you use for the API?")
|
||||
page_b.click("button:has-text('Post')")
|
||||
page_b.wait_for_timeout(500)
|
||||
assert page_b.is_visible("text=Nice work! What framework did you use for the API?")
|
||||
# Comment
|
||||
pb.locator("textarea[name='content']").fill("Nice work! What framework did you use?")
|
||||
pb.click("button:has-text('Post')")
|
||||
pb.wait_for_timeout(500)
|
||||
assert pb.is_visible("text=Nice work! What framework did you use?")
|
||||
|
||||
# Bob votes on Alice's showcase post
|
||||
page_b.goto(post2_url)
|
||||
assert page_b.is_visible("text=Pixel Editor WASM")
|
||||
page_b.fill("textarea[name='content']", "This is incredible! 60fps in WASM is impressive.")
|
||||
page_b.click("button:has-text('Post')")
|
||||
page_b.wait_for_timeout(500)
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# ACT 8 — ALICE REPLIES TO BOB'S COMMENT
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
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)
|
||||
# ═══════════════════════════════════════════════
|
||||
# ACT 9 — ALICE REPLIES TO BOB'S COMMENT
|
||||
# ═══════════════════════════════════════════════
|
||||
pa.bring_to_front()
|
||||
pa.goto(post1_url)
|
||||
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')")
|
||||
pa.wait_for_timeout(500)
|
||||
assert pa.is_visible("text=Thanks! I used FastAPI")
|
||||
|
||||
# Alice votes on her own post
|
||||
vote_btn_a = page_a.locator("button").filter(has_text=re.compile(r"\+")).first
|
||||
vote_btn_a.click()
|
||||
page_a.wait_for_timeout(500)
|
||||
pa.locator("button").filter(has_text=re.compile(r"\+")).first.click()
|
||||
pa.wait_for_timeout(300)
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# ACT 9 — BOB SENDS ALICE A MESSAGE
|
||||
# ═══════════════════════════════════════════
|
||||
|
||||
# Bob needs Alice's UID to message her. We can find it via the profile URL.
|
||||
# Use the search in messages to find Alice
|
||||
page_b.goto(f"{BASE_URL}/messages?search=alice_demo")
|
||||
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')")
|
||||
# ═══════════════════════════════════════════════
|
||||
# ACT 10 — ALICE CHECKS NOTIFICATIONS
|
||||
# ═══════════════════════════════════════════════
|
||||
pa.bring_to_front()
|
||||
pa.goto(f"{BASE_URL}/notifications")
|
||||
assert pa.is_visible("h2:has-text('Notifications')")
|
||||
mark_all = pa.locator("button:has-text('Mark all read')")
|
||||
if mark_all.is_visible():
|
||||
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
|
||||
page_a.goto(f"{BASE_URL}/messages?search=bob_demo")
|
||||
page_a.wait_for_timeout(500)
|
||||
# Log back in
|
||||
pa.click("text=Log In")
|
||||
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
|
||||
msg_input = page_a.locator("input[name='content']")
|
||||
if msg_input.is_visible():
|
||||
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)
|
||||
# Bob logs out
|
||||
pb.goto(f"{BASE_URL}/auth/logout")
|
||||
pb.wait_for_url("**/")
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 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")
|
||||
assert page_b.is_visible("h2:has-text('Notifications')")
|
||||
|
||||
# ═══════════════════════════════════════════
|
||||
# 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()
|
||||
pa.close()
|
||||
pb.close()
|
||||
ctx_a.close()
|
||||
ctx_b.close()
|
||||
|
||||
print("\n═══════════════════════════════════════════════════")
|
||||
print(" ABSURD DEMO COMPLETE — 15 ACTS, 0 MISTAKES")
|
||||
print(" ABSURD DEMO COMPLETE — 13 ACTS, 0 MISTAKES")
|
||||
print("═══════════════════════════════════════════════════")
|
||||
|
||||
@ -27,6 +27,7 @@ def test_messages_empty_state(alice):
|
||||
|
||||
def test_messages_search_for_bob(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/messages")
|
||||
page.fill("#message-search", "bob_test")
|
||||
page.locator("#message-search").press("Enter")
|
||||
page.wait_for_timeout(500)
|
||||
|
||||
@ -89,9 +89,10 @@ def test_profile_nav_from_topbar(alice):
|
||||
def test_profile_edit_bio(alice):
|
||||
page, user = alice
|
||||
page.goto(f"{BASE_URL}/profile/{user['username']}")
|
||||
page.evaluate("document.getElementById('input-bio').style.display = 'block'")
|
||||
page.locator("textarea[name='bio']").fill("Test bio for testing", force=True)
|
||||
page.click("button:has-text('Save Changes')")
|
||||
page.evaluate("""
|
||||
document.querySelector('textarea[name="bio"]').value = 'Test bio for testing';
|
||||
document.querySelector('form[action="/profile/update"]').submit();
|
||||
""")
|
||||
page.wait_for_timeout(500)
|
||||
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):
|
||||
page, user = alice
|
||||
page.goto(f"{BASE_URL}/profile/{user['username']}")
|
||||
page.evaluate("document.getElementById('input-location').style.display = 'block'")
|
||||
page.locator("input[name='location']").fill("Amsterdam", force=True)
|
||||
page.click("button:has-text('Save Changes')")
|
||||
page.evaluate("""
|
||||
document.querySelector('input[name="location"]').value = 'Amsterdam';
|
||||
document.querySelector('form[action="/profile/update"]').submit();
|
||||
""")
|
||||
page.wait_for_timeout(500)
|
||||
assert page.is_visible("text=Amsterdam")
|
||||
|
||||
@ -110,16 +112,12 @@ def test_profile_edit_all_fields(alice):
|
||||
page, user = alice
|
||||
page.goto(f"{BASE_URL}/profile/{user['username']}")
|
||||
page.evaluate("""
|
||||
document.getElementById('input-bio').style.display = 'block';
|
||||
document.getElementById('input-location').style.display = 'block';
|
||||
document.getElementById('input-git_link').style.display = 'block';
|
||||
document.getElementById('input-website').style.display = 'block';
|
||||
document.querySelector('textarea[name="bio"]').value = 'Full stack dev';
|
||||
document.querySelector('input[name="location"]').value = 'London, UK';
|
||||
document.querySelector('input[name="git_link"]').value = 'https://git.example.com/alice';
|
||||
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)
|
||||
assert page.is_visible("text=Full stack dev")
|
||||
assert page.is_visible("text=London, UK")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user