Dedicate the project page to the project

The project detail page becomes a full project showcase built entirely
from existing platform mechanisms. One encompassing dark card wraps the
page; inner panels (tab bar, sidebar cards, devlog entries, comments)
sit one elevation lighter. The hero opens with a cover banner and an
optional logo tile, both plain attachment references
(cover_attachment_uid/logo_attachment_uid) uploaded through the
standard dp-upload attachment widget and linked via the existing
link_attachments choke point - the route validates each uid belongs to
the actor and is an image, and an empty value on edit keeps the current
one. The title block, type/platform chips and author row overlay the
banner behind a scrim with a dark text shadow, next to an owner-set
Visit Website CTA; website_url and repo_url are normalized in models
and render with rel noopener nofollow.

An anchor tab bar (Overview, Devlog, Screenshots when present,
Comments, Files) navigates the page. The main column keeps About, the
devlog timeline (with devlog_count and an owner Post update button
opening the shared composer preset to the devlog topic + project - the
form now lives once in _post_composer_form.html, included by feed.html
and project_detail.html), a Screenshots gallery built from image
attachments minus the cover/logo (thumbnails, lightbox, 12 rendered),
and the comment thread; the sidebar holds Links, Stats and the Author
card. Owners add gallery images from the More menu via
POST /projects/{slug}/screenshots (owner-only, audit
project.screenshots.add, Devii action project_add_screenshots, docs id
projects-screenshots). comment_count/devlog_count ride
ProjectDetailOut, the new fields ride ProjectOut, and the create/edit
faces (modals, Devii actions, API docs) carry them. The project
comment/files e2e tests scope their locators per the documented
dual-control idiom, and new unit/api/e2e tests cover URL normalization,
the counts, the hero attachment guard, the screenshots flow and the
preset composer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 23:00:12 +02:00
co-authored by Claude Fable 5
parent 782bcec5bc
commit 72e088c160
22 changed files with 1150 additions and 206 deletions
+121
View File
@@ -271,3 +271,124 @@ def test_devlog_works_for_guest_visitor(app_server):
assert "devlog_posts" in body
assert body["devlog_posts"] == []
def test_devlog_count_and_comment_count_in_json(app_server):
"""devlog_count and comment_count ride the detail JSON."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
for i in range(3):
_create_post_direct(project["uid"], user["uid"], i)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert body["devlog_count"] == 3
assert body["comment_count"] == 0
def _upload_image(session, name="shot.png", color=(30, 60, 120)):
import io
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (10, 10), color).save(buf, "PNG")
r = session.post(
f"{BASE_URL}/uploads/upload",
files={"file": (name, buf.getvalue(), "image/png")},
)
assert r.status_code == 201, r.text[:300]
return r.json()["uid"]
def test_cover_and_logo_attachments_render_in_hero(app_server):
"""cover/logo attachment uids resolve to the hero banner and logo tile."""
session, _ = _member()
cover_uid = _upload_image(session, "cover.png", (10, 20, 90))
logo_uid = _upload_image(session, "logo.png", (90, 20, 10))
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": _unique("dlhero"),
"description": "Hero art test project",
"project_type": "game",
"status": "In Development",
"platforms": "PC",
"cover_attachment_uid": cover_uid,
"logo_attachment_uid": logo_uid,
},
)
assert r.status_code == 200, r.text[:300]
slug = r.json()["data"]["slug"]
refresh_snapshot()
row = get_table("projects").find_one(slug=slug)
assert row["cover_attachment_uid"] == cover_uid
assert row["logo_attachment_uid"] == logo_uid
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert 'class="project-cover-img"' in html
assert 'class="project-logo"' in html
assert "project-screenshot-grid" not in html, (
"Cover and logo must not repeat in the Screenshots gallery"
)
def test_hero_attachment_uid_rejects_foreign_and_missing(app_server):
"""A foreign or unknown attachment uid is ignored rather than linked."""
owner, _ = _member()
other, _ = _member()
foreign_uid = _upload_image(other, "foreign.png")
r = owner.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": _unique("dlreject"),
"description": "Hero guard test project",
"project_type": "software",
"status": "In Development",
"platforms": "",
"cover_attachment_uid": foreign_uid,
"logo_attachment_uid": "does-not-exist",
},
)
assert r.status_code == 200, r.text[:300]
slug = r.json()["data"]["slug"]
refresh_snapshot()
row = get_table("projects").find_one(slug=slug)
assert row["cover_attachment_uid"] is None
assert row["logo_attachment_uid"] is None
def test_owner_adds_screenshots_from_the_page(app_server):
"""POST /projects/{slug}/screenshots links uploads into the gallery; non-owners are refused."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
assert "project-screenshot-grid" not in session.get(f"{BASE_URL}/projects/{slug}").text
uid = _upload_image(session, "gallery.png", (5, 120, 60))
r = session.post(
f"{BASE_URL}/projects/{slug}/screenshots",
headers=JSON,
data={"attachment_uids": uid},
)
assert r.status_code == 200, r.text[:300]
assert r.json()["data"]["linked"] == 1
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert "project-screenshot-grid" in html, "Expected the gallery after linking"
intruder, _ = _member()
r = intruder.post(
f"{BASE_URL}/projects/{slug}/screenshots",
headers=JSON,
data={"attachment_uids": uid},
)
assert r.status_code == 403, r.text[:300]
+41
View File
@@ -105,6 +105,47 @@ def test_owner_can_edit_project(app_server):
assert row["platforms"] == "Linux,Web"
def test_owner_can_set_and_clear_link_urls(app_server):
_, _, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Website Via Api")["slug"]
r = requests.post(
f"{BASE_URL}/projects/edit/{slug}",
headers=_h_project_visibility(key),
data={
"title": "Website Via Api",
"description": "has links now",
"website_url": "myproject.dev/docs",
"repo_url": "github.com/me/website-via-api",
},
allow_redirects=False,
)
assert r.status_code == 200 and r.json()["ok"] is True
row = get_table("projects").find_one(slug=slug)
assert row["website_url"] == "https://myproject.dev/docs"
assert row["repo_url"] == "https://github.com/me/website-via-api"
html = requests.get(f"{BASE_URL}/projects/{slug}").text
assert "Visit Website" in html
assert "Repository" in html
r = requests.post(
f"{BASE_URL}/projects/edit/{slug}",
headers=_h_project_visibility(key),
data={
"title": "Website Via Api",
"description": "links removed",
"website_url": "",
"repo_url": "",
},
allow_redirects=False,
)
assert r.status_code == 200
row = get_table("projects").find_one(slug=slug)
assert row["website_url"] is None
assert row["repo_url"] is None
assert "Visit Website" not in requests.get(f"{BASE_URL}/projects/{slug}").text
def test_non_owner_cannot_edit_project(app_server):
_, _, owner_key = _signup_project_visibility()
slug = _create_project_project_visibility(owner_key, "Owner Edit Guard")["slug"]
+1 -1
View File
@@ -119,7 +119,7 @@ def _alice_key():
def test_files_link_on_detail(alice):
page, _ = alice
_make_project_ui(page, "UI Files Link")
link = page.locator("a:has-text('Files')")
link = page.locator(".project-detail-actions a:has-text('Files')")
expect(link).to_be_visible()
link.click()
page.wait_for_url("**/files", wait_until="domcontentloaded")
+40 -1
View File
@@ -85,7 +85,7 @@ def test_devlog_empty_state_on_project_page(alice):
devlog_section = page.locator(".project-devlog")
expect(devlog_section).to_be_visible()
expect(devlog_section.locator("h3:has-text('Devlog')")).to_be_visible()
expect(devlog_section.locator("h2:has-text('Devlog')")).to_be_visible()
expect(page.locator(".empty-state:has-text('No devlog posts yet.')")).to_be_visible()
@@ -187,3 +187,42 @@ def test_devlog_multiple_posts_order(alice):
f"Expected newest post first: {markers[-1]}, got: {first_text}"
)
def test_project_stats_card_visible(alice):
"""The sidebar Stats card shows stars, updates, comments, files, forks."""
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
_seed_project_post(project_uid, owner_uid, 0)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
stats = page.locator(".project-stats")
expect(stats).to_be_visible()
expect(stats.locator(".project-stat")).to_have_count(5)
expect(stats.locator(".project-stat:has-text('update')")).to_contain_text("1")
def test_owner_post_update_button_opens_preset_composer(alice):
"""The owner's Post update button opens the composer preselected to this project."""
page, _ = alice
_create_project_ui(page, f"Composer Project {uuid4().hex[:6]}")
button = page.locator(".project-devlog-post-btn")
expect(button).to_be_visible()
button.click()
modal = page.locator("#create-post-modal")
expect(modal).to_be_visible()
expect(modal.locator("input[name='topic'][value='devlog']")).to_be_checked()
selected = modal.locator("#project_uid").input_value()
assert selected != "", "Expected the project preselected in the composer"
def test_guest_sees_no_post_update_button(app_server):
"""Guests and non-owners get no Post update control."""
import requests
slug, _, _ = _seed_project()
r = requests.get(f"{BASE_URL}/projects/{slug}")
assert r.status_code == 200
assert "project-devlog-post-btn" not in r.text
+7 -7
View File
@@ -767,7 +767,7 @@ def test_project_comments_form_visible(alice):
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert page.is_visible("text=Comments")
assert page.is_visible("text=No comments yet")
assert page.is_visible("textarea[name='content']")
assert page.is_visible(".comment-form textarea[name='content']")
def test_project_comment_create(alice):
@@ -778,8 +778,8 @@ def test_project_comment_create(alice):
page.fill("#description", "Project for creating a comment")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Great project!")
page.click("button:has-text('Post')")
page.fill(".comment-form textarea[name='content']", "Great project!")
page.click(".comment-form button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Great project!")
@@ -792,8 +792,8 @@ def test_project_comment_reply(alice):
page.fill("#description", "Project for testing reply")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "First comment")
page.click("button:has-text('Post')")
page.fill(".comment-form textarea[name='content']", "First comment")
page.click(".comment-form button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=First comment")
page.click("button:has-text('Reply')")
@@ -812,8 +812,8 @@ def test_project_comment_delete(alice):
page.fill("#description", "Project for testing delete")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Comment to delete")
page.click("button:has-text('Post')")
page.fill(".comment-form textarea[name='content']", "Comment to delete")
page.click(".comment-form button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Comment to delete")
page.locator(".comment-action-btn:has-text('Delete')").click()
+18
View File
@@ -47,6 +47,24 @@ def test_isslop_run_form_normalizes_typos_and_bare_domains():
assert IsslopRunForm(url="http:/x.dev/a").url == "http://x.dev/a"
def test_project_form_link_urls_normalize_and_validate():
from devplacepy.models import ProjectForm, normalize_website_url
base = {"title": "T", "description": "D"}
assert ProjectForm(**base).website_url == ""
assert ProjectForm(**base, website_url="myproject.dev").website_url == "https://myproject.dev"
assert ProjectForm(**base, repo_url="github.com/me/x").repo_url == "https://github.com/me/x"
assert (
ProjectForm(**base, website_url="http://x.dev/a?b=1").website_url
== "http://x.dev/a?b=1"
)
assert normalize_website_url(" ") == ""
with pytest.raises(ValidationError):
ProjectForm(**base, website_url="javascript:alert(1)")
with pytest.raises(ValidationError):
ProjectForm(**base, repo_url="not a url")
def test_reaction_form_accepts_any_single_emoji():
from devplacepy.models import ReactionForm