Dedicate the project page to the project: hero, tabs, screenshots, sidebar
The project detail page becomes a full project showcase on the site's content measure. The hero card opens with a cover banner from the project's first image attachment (brand-gradient band as fallback), then title + status chip, type/platform chips, dates and forked-from meta, the author row with an owner-set Visit Website CTA, and the unchanged action row. A sticky anchor tab bar (Overview, Devlog, Screenshots when images exist, Comments, Files) navigates the page with plain server-rendered anchors so crawlers index one complete document. The two-column body keeps About (description + non-image attachments), the Devlog timeline and the comment thread in the main column, adds a Screenshots gallery built from image attachments (lightbox-wired thumbnails), and a sidebar with Links (website, files, fork source), the Stats card with a last-update line, and the Author card. New optional projects.website_url rides the whole stack: normalized and validated in models (scheme-less input gets https://, non-http(s) rejected), settable in the create and edit modals, on ProjectOut, in the Devii create/edit actions and the API docs, rendered as the hero CTA and Links entry with rel noopener nofollow, and emitted as schema.org sameAs. The app schema also gains screenshot urls from the image attachments. The e2e project comment/files tests scope their locators (.comment-form textarea, .project-detail-actions a) per the documented dual-control idiom - the composer modal made the bare selectors ambiguous - and new unit/api tests cover URL normalization, sameAs/screenshot schema output, the cover, and the gallery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -319,6 +319,42 @@ def test_project_page_emits_typed_json_ld(app_server):
|
||||
assert '"gamePlatform"' in html, "Expected gamePlatform from the platforms field"
|
||||
|
||||
|
||||
def test_project_page_cover_and_screenshots_from_image_attachments(app_server):
|
||||
"""An image attachment becomes the hero cover, the Screenshots section, and schema screenshots."""
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
session, _ = _member()
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), (30, 60, 120)).save(buf, "PNG")
|
||||
r = session.post(
|
||||
f"{BASE_URL}/uploads/upload",
|
||||
files={"file": ("shot.png", buf.getvalue(), "image/png")},
|
||||
)
|
||||
assert r.status_code == 201, r.text[:300]
|
||||
attachment_uid = r.json()["uid"]
|
||||
|
||||
r = session.post(
|
||||
f"{BASE_URL}/projects/create",
|
||||
headers=JSON,
|
||||
data={
|
||||
"title": _unique("dlshot"),
|
||||
"description": "Cover test project",
|
||||
"project_type": "game",
|
||||
"status": "In Development",
|
||||
"platforms": "PC",
|
||||
"attachment_uids": attachment_uid,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text[:300]
|
||||
slug = r.json()["data"]["slug"]
|
||||
|
||||
html = session.get(f"{BASE_URL}/projects/{slug}").text
|
||||
assert 'class="project-cover"' in html, "Expected the image attachment as hero cover"
|
||||
assert "project-screenshot-grid" in html, "Expected the Screenshots section"
|
||||
assert '"screenshot"' in html, "Expected screenshot urls in the JSON-LD"
|
||||
|
||||
|
||||
def test_project_page_json_ld_rating_from_stars(app_server):
|
||||
"""Stars surface as an aggregateRating; zero stars emit none."""
|
||||
session, _ = _member()
|
||||
|
||||
@@ -105,6 +105,42 @@ def test_owner_can_edit_project(app_server):
|
||||
assert row["platforms"] == "Linux,Web"
|
||||
|
||||
|
||||
def test_owner_can_set_and_clear_website_url(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 a website now",
|
||||
"website_url": "myproject.dev/docs",
|
||||
},
|
||||
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"
|
||||
|
||||
html = requests.get(f"{BASE_URL}/projects/{slug}").text
|
||||
assert "Visit Website" in html
|
||||
assert '"sameAs"' in html
|
||||
|
||||
r = requests.post(
|
||||
f"{BASE_URL}/projects/edit/{slug}",
|
||||
headers=_h_project_visibility(key),
|
||||
data={
|
||||
"title": "Website Via Api",
|
||||
"description": "website removed",
|
||||
"website_url": "",
|
||||
},
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert get_table("projects").find_one(slug=slug)["website_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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -47,6 +47,23 @@ 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_website_url_normalizes_and_validates():
|
||||
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, 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, website_url="not a url")
|
||||
|
||||
|
||||
def test_reaction_form_accepts_any_single_emoji():
|
||||
from devplacepy.models import ReactionForm
|
||||
|
||||
|
||||
@@ -93,6 +93,28 @@ def test_software_application_schema_rating_and_image():
|
||||
assert rich["interactionStatistic"]["userInteractionCount"] == 3
|
||||
|
||||
|
||||
def test_software_application_schema_website_and_screenshots():
|
||||
project = {
|
||||
"uid": "p1",
|
||||
"slug": "p1-s",
|
||||
"title": "S",
|
||||
"project_type": "software",
|
||||
"website_url": "https://myproject.dev",
|
||||
}
|
||||
schema = seo.software_application_schema(
|
||||
project,
|
||||
"https://x.test",
|
||||
screenshot_urls=["https://x.test/a.png", "https://x.test/b.png"],
|
||||
)
|
||||
assert schema["sameAs"] == ["https://myproject.dev"]
|
||||
assert schema["screenshot"] == ["https://x.test/a.png", "https://x.test/b.png"]
|
||||
bare = seo.software_application_schema(
|
||||
{"uid": "p2", "slug": "p2-s", "title": "B"}, "https://x.test"
|
||||
)
|
||||
assert "sameAs" not in bare
|
||||
assert "screenshot" not in bare
|
||||
|
||||
|
||||
def test_project_devlog_schema_builds_blog_graph():
|
||||
project = {"uid": "p1", "slug": "p1-s", "title": "Nebula"}
|
||||
assert seo.project_devlog_schema(project, [], "https://x.test") is None
|
||||
|
||||
Reference in New Issue
Block a user