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:
blindxfish 2026-08-09 21:56:50 +02:00
parent 2af5110399
commit 3e9475fb61
17 changed files with 548 additions and 152 deletions

View File

@ -68,7 +68,7 @@ devplacepy/
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read | | `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read |
| `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion | | `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion |
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility | | `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility |
| `/projects/{slug}` | SEO-optimized project overview and devlog: hero with status/type/dates, stats strip (stars, updates, comments, files, forks), About and Platforms sections, and a **Devlog** timeline of every post linked to the project. The owner posts updates straight from the page (composer preset to the project with the `devlog` topic). Emits type-aware JSON-LD (VideoGame / WebApplication / SoftwareApplication / CreativeWork with rating, keywords and image) plus a `Blog`/`BlogPosting` graph for the devlog; the sitemap's `lastmod` follows the newest devlog post | | `/projects/{slug}` | Dedicated SEO-optimized project page: hero with a cover banner (first image attachment), status/type/platform chips, an owner-set **Visit Website** link (`website_url`), section tabs (Overview, Devlog, Screenshots, Comments, Files), an About section, the **Devlog** timeline of every post linked to the project, a Screenshots gallery built from image attachments, and a sidebar with links, stats (stars, updates, comments, files, forks, last update) and the author card. The owner posts updates straight from the page (composer preset to the project with the `devlog` topic). Emits type-aware JSON-LD (VideoGame / WebApplication / SoftwareApplication / CreativeWork with rating, keywords, image, screenshots and `sameAs`) plus a `Blog`/`BlogPosting` graph for the devlog; the sitemap's `lastmod` follows the newest devlog post |
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) | | `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) |
| `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` | | `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
| `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL | | `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL |

View File

@ -451,6 +451,14 @@ four ways to sign requests.
"31/12/2026", "31/12/2026",
"Optional demo date in DD/MM/YYYY format.", "Optional demo date in DD/MM/YYYY format.",
), ),
field(
"website_url",
"form",
"string",
False,
"https://myproject.dev",
"Optional official website URL (http/https).",
),
], ],
), ),
endpoint( endpoint(
@ -528,6 +536,14 @@ four ways to sign requests.
"31/12/2026", "31/12/2026",
"Optional demo date in DD/MM/YYYY format.", "Optional demo date in DD/MM/YYYY format.",
), ),
field(
"website_url",
"form",
"string",
False,
"https://myproject.dev",
"Optional official website URL (http/https).",
),
], ],
), ),
endpoint( endpoint(

View File

@ -5,7 +5,7 @@ import re
from datetime import datetime from datetime import datetime
from typing import Literal, Optional from typing import Literal, Optional
from urllib.parse import urlsplit from urllib.parse import urlsplit, urlparse
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
from devplacepy.constants import TOPICS from devplacepy.constants import TOPICS
from devplacepy.rendering import is_single_emoji from devplacepy.rendering import is_single_emoji
@ -33,6 +33,20 @@ def normalize_european_date(value):
raise ValueError("Date must be in DD/MM/YYYY format") raise ValueError("Date must be in DD/MM/YYYY format")
def normalize_website_url(value):
if not value:
return ""
text = str(value).strip()
if not text:
return ""
if not text.lower().startswith(("http://", "https://")):
text = f"https://{text}"
parsed = urlparse(text)
if parsed.scheme not in ("http", "https") or not parsed.hostname or "." not in parsed.hostname:
raise ValueError("Website must be a valid http(s) URL")
return text
def normalize_poll_options(value): def normalize_poll_options(value):
if value is None: if value is None:
return [] return []
@ -241,6 +255,7 @@ class ProjectForm(BaseModel):
) )
platforms: str = Field(default="", max_length=500) platforms: str = Field(default="", max_length=500)
status: str = Field(default="In Development", max_length=100) status: str = Field(default="In Development", max_length=100)
website_url: str = Field(default="", max_length=500)
is_private: bool = False is_private: bool = False
attachment_uids: list[str] = [] attachment_uids: list[str] = []
@ -249,6 +264,11 @@ class ProjectForm(BaseModel):
def normalize_dates(cls, value): def normalize_dates(cls, value):
return normalize_european_date(value) return normalize_european_date(value)
@field_validator("website_url")
@classmethod
def valid_website_url(cls, value):
return normalize_website_url(value)
class ProjectEditForm(BaseModel): class ProjectEditForm(BaseModel):
title: str = Field(min_length=1, max_length=200) title: str = Field(min_length=1, max_length=200)
@ -260,12 +280,18 @@ class ProjectEditForm(BaseModel):
) )
platforms: str = Field(default="", max_length=500) platforms: str = Field(default="", max_length=500)
status: str = Field(default="In Development", max_length=100) status: str = Field(default="In Development", max_length=100)
website_url: str = Field(default="", max_length=500)
@field_validator("release_date", "demo_date", mode="before") @field_validator("release_date", "demo_date", mode="before")
@classmethod @classmethod
def normalize_dates(cls, value): def normalize_dates(cls, value):
return normalize_european_date(value) return normalize_european_date(value)
@field_validator("website_url")
@classmethod
def valid_website_url(cls, value):
return normalize_website_url(value)
class BackupRunForm(BaseModel): class BackupRunForm(BaseModel):
target: Literal["database", "uploads", "keys", "full"] = "full" target: Literal["database", "uploads", "keys", "full"] = "full"

View File

@ -9,9 +9,9 @@ This file documents the project detail page, the per-project virtual filesystem,
Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, delete-for-owner, and (for the owner) Private/Read-only toggle buttons plus badges (see **Project visibility and read-only** below). The route is `GET /projects/{project_uid}` in `routers/projects/index.py` and 404s when the viewer cannot see a private project. The sitemap generator links to this URL (not the old `?user_uid=` query param). The detail page also links to the project filesystem at `/projects/{slug}/files`. Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, delete-for-owner, and (for the owner) Private/Read-only toggle buttons plus badges (see **Project visibility and read-only** below). The route is `GET /projects/{project_uid}` in `routers/projects/index.py` and 404s when the viewer cannot see a private project. The sitemap generator links to this URL (not the old `?user_uid=` query param). The detail page also links to the project filesystem at `/projects/{slug}/files`.
**Project overview + devlog (SEO surface).** The detail page is a professional project overview: hero (h1 title, status, type badge, dates, forked-from), author row, a `.project-stats` strip (stars / updates / comments / files / forks, the anchors jump to `#devlog` and `#comments`), an **About** section, platforms, the action row, then the **Devlog** section (`#devlog`, `h2`) listing every post whose `project_uid` points at the project via `_post_card.html` (the template loads `feed.css` for the card styles alongside `post.css` - same rule as `news.html`). `devlog_count` (`content.count_project_devlog`) and `comment_count` ride the context and `ProjectDetailOut`. The owner gets a **Post update** button (`.project-devlog-post-btn`) opening the shared create-post composer preset to `topic=devlog` + this project - the composer form lives ONCE in `templates/_post_composer_form.html` (locals `_composer_topic`, `_composer_project`) and is included by both `feed.html` and `project_detail.html`; never fork a second copy of that form. **Project overview + devlog (SEO surface).** The detail page is a dedicated project showcase (`.project-page`, full `--max-content` measure): a hero card (`.project-hero`) whose cover banner is the project's **first image attachment** (brand-gradient fallback band when there are none), the h1 title + status chip, type badge + platform chips + Private/Read-only badges, dates/forked-from meta, the author row with an owner-set **Visit Website** CTA (`projects.website_url`, optional, validated/normalized by `models.normalize_website_url` - scheme-less input gets `https://`, non-http(s) rejected; edited in the create and edit modals, `rel="noopener nofollow"` on render), and the action row (unchanged wiring incl. the `.project-actions-more` overflow). Below the hero an anchor **section tab bar** (`.project-tabs`, sticky) links Overview `#about` / Devlog `#devlog` / Screenshots `#screenshots` (only when images exist) / Comments `#comments` / the Files page - server-rendered anchors, no JS tab state, so crawlers see the whole page. The body is a two-column grid (`.project-columns`, sidebar collapses at 1024px): the main column holds **About** (description + non-image attachments), the **Devlog** (`h2`, every post whose `project_uid` points at the project via `_post_card.html` - the template loads `feed.css` for the card styles alongside `post.css`, same rule as `news.html`), a **Screenshots** gallery (all image attachments, `data-lightbox` thumbnails), and Comments; the sidebar holds Links (website / files / fork source), the Stats card (`.project-stats`, 5 `.project-stat` entries + last-update line), and the Author card. `devlog_count` (`content.count_project_devlog`) and `comment_count` ride the context and `ProjectDetailOut`; `website_url` rides `ProjectOut`. The owner gets a **Post update** button (`.project-devlog-post-btn`) opening the shared create-post composer preset to `topic=devlog` + this project - the composer form lives ONCE in `templates/_post_composer_form.html` (locals `_composer_topic`, `_composer_project`) and is included by both `feed.html` and `project_detail.html`; never fork a second copy of that form. **Locator discipline:** the page has several `Files` anchors (action row, tab bar, sidebar) and, for owners, a second hidden `textarea[name='content']`/Post button inside the composer modal - tests MUST scope (`.project-detail-actions a:has-text('Files')`, `.comment-form textarea[name='content']`).
**JSON-LD** (`seo.py`): `software_application_schema(project, base, image_url=, star_count=, comment_count=)` is type-aware via `project_schema_type` (`game` -> `VideoGame` with `gamePlatform`, `website` -> `WebApplication`, `software`/`mobile_app` -> `SoftwareApplication`, `game_asset` -> `CreativeWork`), adds `keywords` (type + platforms), `image`, an `aggregateRating` when the project has stars, and a comment `InteractionCounter`. `project_devlog_schema(project, devlog_posts, base)` emits a `Blog` node (`@id` = `{project_url}#devlog`) with one `BlogPosting` per rendered devlog entry; it returns `None` for an empty devlog (dropped by `combine`). The devlog cursor rides `base_seo_context(next_url=next_page_url(request, devlog_next_cursor))` for a crawlable `rel=next`, `keywords` carries title/type/platforms, and the sitemap's project `lastmod` is `max(created_at, updated_at, newest devlog post)` via one grouped posts query in `_build_sitemap`. The `before` devlog cursor is documented on `projects-detail` in `docs_api/groups/content.py`. **JSON-LD** (`seo.py`): `software_application_schema(project, base, image_url=, star_count=, comment_count=, screenshot_urls=)` (screenshots = up to 6 absolute image-attachment urls -> `screenshot`; `website_url` -> `sameAs`) is type-aware via `project_schema_type` (`game` -> `VideoGame` with `gamePlatform`, `website` -> `WebApplication`, `software`/`mobile_app` -> `SoftwareApplication`, `game_asset` -> `CreativeWork`), adds `keywords` (type + platforms), `image`, an `aggregateRating` when the project has stars, and a comment `InteractionCounter`. `project_devlog_schema(project, devlog_posts, base)` emits a `Blog` node (`@id` = `{project_url}#devlog`) with one `BlogPosting` per rendered devlog entry; it returns `None` for an empty devlog (dropped by `combine`). The devlog cursor rides `base_seo_context(next_url=next_page_url(request, devlog_next_cursor))` for a crawlable `rel=next`, `keywords` carries title/type/platforms, and the sitemap's project `lastmod` is `max(created_at, updated_at, newest devlog post)` via one grouped posts query in `_build_sitemap`. The `before` devlog cursor is documented on `projects-detail` in `docs_api/groups/content.py`.
**Action row overflow.** The detail page has more actions than fit one line, so `project_detail.html` keeps the engagement actions inline (Files, Share, star vote, bookmark, reactions) and collapses the rest behind a single **More** button (`.project-actions-more`) that opens the shared `app.contextMenu`. The secondary actions (Workspace, Containers, Download zip, Fork, the owner Edit/Private/Read-only/Delete controls) live as real elements inside a hidden `.project-actions-overflow` container, each tagged `data-menu-action` plus `data-menu-icon`/`data-menu-label`. `static/js/ProjectActionsMenu.js` builds the menu from those elements and each item's `onSelect` simply `.click()`s the real element, so all existing wiring is reused unchanged - `app.zipDownloader` (`data-zip-download`), `app.projectForker` (`data-fork-project`), `data-share`, the `data-modal` Edit trigger, and the delegated `data-confirm`/`data-confirm-danger` dialog on the owner forms. The open handler must `stopPropagation()` because `app.contextMenu`'s document-level close listener would otherwise dismiss it on the same click (every other caller opens it from a right-click `attach`, not a left-click). Reuse this pattern - a `More` trigger over `[data-menu-action]` real elements - for any future action row that overflows; do not duplicate controller logic into menu callbacks. **Action row overflow.** The detail page has more actions than fit one line, so `project_detail.html` keeps the engagement actions inline (Files, Share, star vote, bookmark, reactions) and collapses the rest behind a single **More** button (`.project-actions-more`) that opens the shared `app.contextMenu`. The secondary actions (Workspace, Containers, Download zip, Fork, the owner Edit/Private/Read-only/Delete controls) live as real elements inside a hidden `.project-actions-overflow` container, each tagged `data-menu-action` plus `data-menu-icon`/`data-menu-label`. `static/js/ProjectActionsMenu.js` builds the menu from those elements and each item's `onSelect` simply `.click()`s the real element, so all existing wiring is reused unchanged - `app.zipDownloader` (`data-zip-download`), `app.projectForker` (`data-fork-project`), `data-share`, the `data-modal` Edit trigger, and the delegated `data-confirm`/`data-confirm-danger` dialog on the owner forms. The open handler must `stopPropagation()` because `app.contextMenu`'s document-level close listener would otherwise dismiss it on the same click (every other caller opens it from a right-click `attach`, not a left-click). Reuse this pattern - a `More` trigger over `[data-menu-action]` real elements - for any future action row that overflows; do not duplicate controller logic into menu callbacks.

View File

@ -231,6 +231,7 @@ async def project_detail(request: Request, project_slug: str, before: str = None
base = site_url(request) base = site_url(request)
robots = "noindex,nofollow" if project.get("is_private") else "index,follow" robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
og_image = first_image_url(project, detail["attachments"]) og_image = first_image_url(project, detail["attachments"])
screenshots = [a for a in detail["attachments"] if a.get("is_image")]
comment_total = get_table("comments").count( comment_total = get_table("comments").count(
target_type="project", target_uid=project["uid"], deleted_at=None target_type="project", target_uid=project["uid"], deleted_at=None
) )
@ -269,6 +270,9 @@ async def project_detail(request: Request, project_slug: str, before: str = None
image_url=absolute_url(base, og_image), image_url=absolute_url(base, og_image),
star_count=detail.get("star_count", 0), star_count=detail.get("star_count", 0),
comment_count=comment_total, comment_count=comment_total,
screenshot_urls=[
absolute_url(base, a["url"]) for a in screenshots[:6]
],
), ),
project_devlog_schema(project, devlog_posts, base), project_devlog_schema(project, devlog_posts, base),
], ],
@ -441,6 +445,7 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
"project_type": data.project_type, "project_type": data.project_type,
"platforms": data.platforms.strip(), "platforms": data.platforms.strip(),
"status": data.status, "status": data.status,
"website_url": data.website_url or None,
"is_private": 1 if data.is_private else 0, "is_private": 1 if data.is_private else 0,
"read_only": 0, "read_only": 0,
}, },
@ -474,6 +479,7 @@ async def edit_project(
"project_type": data.project_type, "project_type": data.project_type,
"platforms": data.platforms.strip(), "platforms": data.platforms.strip(),
"status": data.status, "status": data.status,
"website_url": data.website_url or None,
}, },
"/projects", "/projects",
target_type="project", target_type="project",

View File

@ -121,6 +121,7 @@ class ProjectOut(_Out):
read_only: Optional[bool] = None read_only: Optional[bool] = None
release_date: Optional[str] = None release_date: Optional[str] = None
demo_date: Optional[str] = None demo_date: Optional[str] = None
website_url: Optional[str] = None
created_at: Optional[str] = None created_at: Optional[str] = None
updated_at: Optional[str] = None updated_at: Optional[str] = None

View File

@ -152,7 +152,12 @@ def project_schema_type(project_type):
def software_application_schema( def software_application_schema(
project, base_url, image_url="", star_count=0, comment_count=0 project,
base_url,
image_url="",
star_count=0,
comment_count=0,
screenshot_urls=None,
): ):
schema_type = project_schema_type(project.get("project_type")) schema_type = project_schema_type(project.get("project_type"))
platforms = [p.strip() for p in (project.get("platforms") or "").split(",") if p.strip()] platforms = [p.strip() for p in (project.get("platforms") or "").split(",") if p.strip()]
@ -186,6 +191,10 @@ def software_application_schema(
schema["keywords"] = ", ".join(keywords) schema["keywords"] = ", ".join(keywords)
if image_url: if image_url:
schema["image"] = image_url schema["image"] = image_url
if screenshot_urls:
schema["screenshot"] = list(screenshot_urls)
if project.get("website_url"):
schema["sameAs"] = [project["website_url"]]
if star_count > 0: if star_count > 0:
schema["aggregateRating"] = { schema["aggregateRating"] = {
"@type": "AggregateRating", "@type": "AggregateRating",

View File

@ -47,6 +47,7 @@ PROJECTS_ACTIONS: tuple[Action, ...] = (
body("project_type", "Project type."), body("project_type", "Project type."),
body("platforms", "Supported platforms."), body("platforms", "Supported platforms."),
body("status", "Project status."), body("status", "Project status."),
body("website_url", "Official website URL (http/https)."),
body("attachment_uids", ATTACHMENTS), body("attachment_uids", ATTACHMENTS),
), ),
), ),
@ -67,6 +68,7 @@ PROJECTS_ACTIONS: tuple[Action, ...] = (
body("project_type", "Updated project type."), body("project_type", "Updated project type."),
body("platforms", "Updated supported platforms."), body("platforms", "Updated supported platforms."),
body("status", "Updated project status."), body("status", "Updated project status."),
body("website_url", "Updated official website URL (http/https)."),
), ),
), ),
Action( Action(

View File

@ -223,16 +223,181 @@
} }
} }
.project-detail-page { .project-page {
max-width: 720px; max-width: var(--max-content);
margin: 0 auto; margin: 0 auto;
} }
.project-detail {
.project-hero {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.project-cover {
height: 220px;
background: var(--bg-secondary);
}
.project-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.project-cover-fallback {
height: 96px;
background: var(--accent-gradient);
opacity: 0.55;
}
.project-hero-body {
padding: 1.5rem; padding: 1.5rem;
} }
.project-website-btn {
margin-left: auto;
}
.project-tabs {
display: flex;
gap: 0.25rem;
margin: 1rem 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 0.25rem;
flex-wrap: wrap;
position: sticky;
top: calc(var(--nav-height) + 0.5rem);
z-index: 1;
}
.project-tab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
}
.project-tab:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.project-tab-count {
font-size: 0.6875rem;
font-weight: 700;
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: var(--overlay-light);
border: 1px solid var(--border);
color: var(--text-muted);
}
.project-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 1.25rem;
align-items: start;
}
.project-main {
min-width: 0;
}
.project-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
position: sticky;
top: calc(var(--nav-height) + 4rem);
}
.project-sidebar-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
}
.project-link-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.875rem;
}
.project-link-list a {
color: var(--text-secondary);
}
.project-link-list a:hover {
color: var(--accent);
}
.project-last-update {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.project-author-card {
display: flex;
align-items: center;
gap: 0.75rem;
}
.project-author-meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.8125rem;
}
.project-screenshots {
margin-top: 1.5rem;
}
.project-screenshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.75rem;
}
.project-screenshot {
width: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
border-radius: var(--radius);
border: 1px solid var(--border);
cursor: zoom-in;
}
.project-comments {
margin-top: 1.5rem;
}
@media (max-width: 1024px) {
.project-columns {
grid-template-columns: minmax(0, 1fr);
}
.project-sidebar {
position: static;
}
.project-cover {
height: 160px;
}
}
.project-detail-header { .project-detail-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -304,19 +469,10 @@
color: var(--warning); color: var(--warning);
} }
.project-platforms {
margin-bottom: 1.5rem;
}
.project-stats { .project-stats {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-lg); gap: var(--space-sm) var(--space-lg);
margin-bottom: 1rem;
padding: var(--space-md) var(--space-lg);
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--text-muted); color: var(--text-muted);
} }

View File

@ -6,10 +6,21 @@
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}"> <link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="project-detail-page"> {% set project_url = "/projects/" ~ (project['slug'] or project['uid']) %}
{% set image_attachments = attachments | selectattr('is_image') | list %}
{% set other_attachments = attachments | rejectattr('is_image') | list %}
<div class="project-page">
<a href="/projects" class="back-link">&larr; Back to Projects</a> <a href="/projects" class="back-link">&larr; Back to Projects</a>
<article class="project-detail"> <article class="project-hero">
{% if image_attachments %}
<div class="project-cover">
<img src="{{ image_attachments[0]['url'] }}" alt="{{ project['title'] }} cover image" loading="eager">
</div>
{% else %}
<div class="project-cover project-cover-fallback" aria-hidden="true"></div>
{% endif %}
<div class="project-hero-body">
<div class="project-detail-header"> <div class="project-detail-header">
<h1 class="project-detail-title">{{ render_title(project['title'], author_is_admin=is_admin(author)) }}</h1> <h1 class="project-detail-title">{{ render_title(project['title'], author_is_admin=is_admin(author)) }}</h1>
<div class="project-status {% if project.get('status') == 'Released' %}released{% else %}dev{% endif %}"> <div class="project-status {% if project.get('status') == 'Released' %}released{% else %}dev{% endif %}">
@ -17,15 +28,16 @@
</div> </div>
</div> </div>
{% if is_private or read_only %}
<div class="project-detail-meta"> <div class="project-detail-meta">
<span class="badge badge-type">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
{% for plat in platforms %}
<span class="platform-tag">{{ plat.strip() }}</span>
{% endfor %}
{% if is_private %}<span class="badge badge-type">Private</span>{% endif %} {% if is_private %}<span class="badge badge-type">Private</span>{% endif %}
{% if read_only %}<span class="badge badge-type">Read-only</span>{% endif %} {% if read_only %}<span class="badge badge-type">Read-only</span>{% endif %}
</div> </div>
{% endif %}
<div class="project-detail-meta"> <div class="project-detail-meta">
<span class="badge badge-type">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
{% if project.get('release_date') %} {% if project.get('release_date') %}
<span>&#x1F4C5; Released: {{ format_date(project['release_date']) }}</span> <span>&#x1F4C5; Released: {{ format_date(project['release_date']) }}</span>
{% endif %} {% endif %}
@ -35,13 +47,10 @@
{% if project.get('created_at') %} {% if project.get('created_at') %}
<span>&#x1F331; Started {{ dt_ago(project['created_at']) }}</span> <span>&#x1F331; Started {{ dt_ago(project['created_at']) }}</span>
{% endif %} {% endif %}
</div>
{% if forked_from %} {% if forked_from %}
<div class="project-detail-meta">
<span>&#x2442; Forked from <a href="/projects/{{ forked_from['slug'] }}">{{ render_title(forked_from['title']) }}</a></span> <span>&#x2442; Forked from <a href="/projects/{{ forked_from['slug'] }}">{{ render_title(forked_from['title']) }}</a></span>
</div>
{% endif %} {% endif %}
</div>
<div class="project-detail-author"> <div class="project-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %} {% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
@ -49,46 +58,17 @@
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %} {% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
<span class="meta-muted">&middot; Level {{ author.get('level', 1) if author else 1 }}</span> <span class="meta-muted">&middot; Level {{ author.get('level', 1) if author else 1 }}</span>
</div> </div>
</div> {% if project.get('website_url') %}
<a href="{{ project['website_url'] }}" target="_blank" rel="noopener nofollow" class="btn btn-primary btn-sm project-website-btn"><span class="icon">&#x1F310;</span> Visit Website</a>
<div class="project-stats">
<span class="project-stat"><span class="project-stat-value">{{ star_count }}</span> stars</span>
<span class="project-stat"><a href="#devlog"><span class="project-stat-value">{{ devlog_count }}</span> update{{ '' if devlog_count == 1 else 's' }}</a></span>
<span class="project-stat"><a href="#comments"><span class="project-stat-value">{{ comment_count }}</span> comment{{ '' if comment_count == 1 else 's' }}</a></span>
<span class="project-stat"><a href="/projects/{{ project['slug'] or project['uid'] }}/files"><span class="project-stat-value">{{ file_count }}</span> file{{ '' if file_count == 1 else 's' }}</a></span>
<span class="project-stat"><span class="project-stat-value">{{ fork_count }}</span> fork{{ '' if fork_count == 1 else 's' }}</span>
</div>
{% if maturity_hidden(maturity, user) %}
{% set _level = maturity %}{% include "_maturity_gate.html" %}
{% else %}
<section class="project-about">
<h2 class="project-section-label">About</h2>
<div class="project-detail-desc rendered-content">{{ render_content(project.get('description', ''), author_is_admin=is_admin(author)) }}</div>
</section>
{% endif %} {% endif %}
{% if attachments %}
{% include "_attachment_display.html" %}
{% endif %}
{% if platforms %}
<div class="project-platforms">
<h2 class="project-section-label">Platforms</h2>
<div class="project-card-platforms">
{% for plat in platforms %}
<span class="platform-tag">{{ plat.strip() }}</span>
{% endfor %}
</div> </div>
</div>
{% endif %}
<div class="project-detail-actions"> <div class="project-detail-actions">
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn"><span class="icon">&#x1F4C1;</span><span class="label"> Files ({{ file_count }} files)</span></a> <a href="{{ project_url }}/files" class="project-star-btn"><span class="icon">&#x1F4C1;</span><span class="label"> Files ({{ file_count }} files)</span></a>
{% if workspace_editor_url %} {% if workspace_editor_url %}
<a href="{{ workspace_editor_url }}" target="_blank" rel="noopener" class="project-star-btn"><span class="icon">&#x1F4BB;</span><span class="label"> VS Code</span></a> <a href="{{ workspace_editor_url }}" target="_blank" rel="noopener" class="project-star-btn"><span class="icon">&#x1F4BB;</span><span class="label"> VS Code</span></a>
{% endif %} {% endif %}
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}"><span class="icon">&#x1F517;</span><span class="label"> Share</span></button> <button type="button" class="project-star-btn" data-share="{{ project_url }}"><span class="icon">&#x1F517;</span><span class="label"> Share</span></button>
{% if user %} {% if user %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %} {% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
{% endif %} {% endif %}
@ -99,22 +79,22 @@
<div class="project-actions-overflow" hidden> <div class="project-actions-overflow" hidden>
{% if viewer_can_workspace %} {% if viewer_can_workspace %}
<a href="/projects/{{ project['slug'] or project['uid'] }}/workspace" data-menu-action data-menu-icon="&#x1F4BB;" data-menu-label="Workspace">Workspace</a> <a href="{{ project_url }}/workspace" data-menu-action data-menu-icon="&#x1F4BB;" data-menu-label="Workspace">Workspace</a>
{% endif %} {% endif %}
{% if viewer_can_containers %} {% if viewer_can_containers %}
<a href="/projects/{{ project['slug'] or project['uid'] }}/containers" data-menu-action data-menu-icon="&#x1F5A5;&#xFE0F;" data-menu-label="Containers">Containers</a> <a href="{{ project_url }}/containers" data-menu-action data-menu-icon="&#x1F5A5;&#xFE0F;" data-menu-label="Containers">Containers</a>
{% endif %} {% endif %}
<button type="button" data-zip-download="/projects/{{ project['slug'] or project['uid'] }}/zip" data-menu-action data-menu-icon="&#x1F4E6;" data-menu-label="Download zip">Download zip</button> <button type="button" data-zip-download="{{ project_url }}/zip" data-menu-action data-menu-icon="&#x1F4E6;" data-menu-label="Download zip">Download zip</button>
{% if user %} {% if user %}
<button type="button" data-fork-project="/projects/{{ project['slug'] or project['uid'] }}/fork" data-fork-name="{{ project['title'] }}" data-menu-action data-menu-icon="&#x2442;" data-menu-label="Fork">Fork</button> <button type="button" data-fork-project="{{ project_url }}/fork" data-fork-name="{{ project['title'] }}" data-menu-action data-menu-icon="&#x2442;" data-menu-label="Fork">Fork</button>
{% endif %} {% endif %}
{% if is_owner %} {% if is_owner %}
<button type="button" data-modal="edit-project-modal" data-menu-action data-menu-icon="&#x270F;&#xFE0F;" data-menu-label="Edit">Edit</button> <button type="button" data-modal="edit-project-modal" data-menu-action data-menu-icon="&#x270F;&#xFE0F;" data-menu-label="Edit">Edit</button>
<form method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/private"> <form method="POST" action="{{ project_url }}/private">
<input type="hidden" name="value" value="{{ 0 if is_private else 1 }}"> <input type="hidden" name="value" value="{{ 0 if is_private else 1 }}">
<button type="submit" data-confirm-danger data-confirm="{% if is_private %}Make this project public? Everyone will be able to see the project and all its files.{% else %}Make this project private? Only you and administrators will be able to see it.{% endif %}" data-menu-action data-menu-icon="{% if is_private %}&#x1F513;{% else %}&#x1F512;{% endif %}" data-menu-label="{% if is_private %}Make public{% else %}Make private{% endif %}">{% if is_private %}Make public{% else %}Make private{% endif %}</button> <button type="submit" data-confirm-danger data-confirm="{% if is_private %}Make this project public? Everyone will be able to see the project and all its files.{% else %}Make this project private? Only you and administrators will be able to see it.{% endif %}" data-menu-action data-menu-icon="{% if is_private %}&#x1F513;{% else %}&#x1F512;{% endif %}" data-menu-label="{% if is_private %}Make public{% else %}Make private{% endif %}">{% if is_private %}Make public{% else %}Make private{% endif %}</button>
</form> </form>
<form method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/readonly"> <form method="POST" action="{{ project_url }}/readonly">
<input type="hidden" name="value" value="{{ 0 if read_only else 1 }}"> <input type="hidden" name="value" value="{{ 0 if read_only else 1 }}">
<button type="submit" data-confirm-danger data-confirm="{% if read_only %}Allow file changes again for this project?{% else %}Make this project read-only? Files become immutable until you turn this off.{% endif %}" data-menu-action data-menu-icon="{% if read_only %}&#x1F4DD;{% else %}&#x1F6AB;{% endif %}" data-menu-label="{% if read_only %}Allow edits{% else %}Make read-only{% endif %}">{% if read_only %}Allow edits{% else %}Make read-only{% endif %}</button> <button type="submit" data-confirm-danger data-confirm="{% if read_only %}Allow file changes again for this project?{% else %}Make this project read-only? Files become immutable until you turn this off.{% endif %}" data-menu-action data-menu-icon="{% if read_only %}&#x1F4DD;{% else %}&#x1F6AB;{% endif %}" data-menu-label="{% if read_only %}Allow edits{% else %}Make read-only{% endif %}">{% if read_only %}Allow edits{% else %}Make read-only{% endif %}</button>
</form> </form>
@ -126,8 +106,34 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
</article> </article>
<nav class="project-tabs" aria-label="Project sections">
<a href="#about" class="project-tab">Overview</a>
<a href="#devlog" class="project-tab">Devlog <span class="project-tab-count">{{ devlog_count }}</span></a>
{% if image_attachments %}
<a href="#screenshots" class="project-tab">Screenshots <span class="project-tab-count">{{ image_attachments | length }}</span></a>
{% endif %}
<a href="#comments" class="project-tab">Comments <span class="project-tab-count">{{ comment_count }}</span></a>
<a href="{{ project_url }}/files" class="project-tab">Files <span class="project-tab-count">{{ file_count }}</span></a>
</nav>
<div class="project-columns">
<div class="project-main">
<section class="project-about" id="about">
<h2 class="project-section-label">About</h2>
{% if maturity_hidden(maturity, user) %}
{% set _level = maturity %}{% include "_maturity_gate.html" %}
{% else %}
<div class="project-detail-desc rendered-content">{{ render_content(project.get('description', ''), author_is_admin=is_admin(author)) }}</div>
{% endif %}
{% if other_attachments %}
{% set attachments = other_attachments %}
{% include "_attachment_display.html" %}
{% endif %}
</section>
<section class="project-devlog" id="devlog"> <section class="project-devlog" id="devlog">
<div class="project-devlog-header"> <div class="project-devlog-header">
<h2 class="project-section-label">Devlog</h2> <h2 class="project-section-label">Devlog</h2>
@ -146,6 +152,65 @@
{% endif %} {% endif %}
</section> </section>
{% if image_attachments %}
<section class="project-screenshots" id="screenshots">
<h2 class="project-section-label">Screenshots</h2>
<div class="project-screenshot-grid">
{% for shot in image_attachments %}
<img src="{{ shot['thumbnail_url'] or shot['url'] }}" data-lightbox data-full="{{ shot['url'] }}" alt="{{ project['title'] }} screenshot {{ loop.index }}" loading="lazy" class="project-screenshot">
{% endfor %}
</div>
</section>
{% endif %}
<section class="project-comments" id="comments">
{% with target_uid=project['uid'], target_type="project" %}
{% include "_comment_section.html" %}
{% endwith %}
</section>
</div>
<aside class="project-sidebar">
<div class="project-sidebar-card">
<h2 class="project-section-label">Links</h2>
<ul class="project-link-list">
{% if project.get('website_url') %}
<li><a href="{{ project['website_url'] }}" target="_blank" rel="noopener nofollow">&#x1F310; Website</a></li>
{% endif %}
<li><a href="{{ project_url }}/files">&#x1F4C1; Browse files</a></li>
{% if forked_from %}
<li><a href="/projects/{{ forked_from['slug'] }}">&#x2442; Fork source: {{ render_title(forked_from['title']) }}</a></li>
{% endif %}
</ul>
</div>
<div class="project-sidebar-card">
<h2 class="project-section-label">Stats</h2>
<div class="project-stats">
<span class="project-stat"><span class="project-stat-value">{{ star_count }}</span> stars</span>
<span class="project-stat"><a href="#devlog"><span class="project-stat-value">{{ devlog_count }}</span> update{{ '' if devlog_count == 1 else 's' }}</a></span>
<span class="project-stat"><a href="#comments"><span class="project-stat-value">{{ comment_count }}</span> comment{{ '' if comment_count == 1 else 's' }}</a></span>
<span class="project-stat"><a href="{{ project_url }}/files"><span class="project-stat-value">{{ file_count }}</span> file{{ '' if file_count == 1 else 's' }}</a></span>
<span class="project-stat"><span class="project-stat-value">{{ fork_count }}</span> fork{{ '' if fork_count == 1 else 's' }}</span>
</div>
{% if devlog_posts %}
<div class="project-last-update">Last update {{ dt_ago(devlog_posts[0].post['created_at']) }}</div>
{% endif %}
</div>
<div class="project-sidebar-card">
<h2 class="project-section-label">Author</h2>
<div class="project-author-card">
{% set _size = 40 %}{% set _size_class = "md" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div class="project-author-meta">
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
<span class="meta-muted">Level {{ author.get('level', 1) if author else 1 }} &middot; {{ author.get('stars', 0) if author else 0 }} stars</span>
</div>
</div>
</div>
</aside>
</div>
{% if is_owner %} {% if is_owner %}
{% call modal('create-post-modal', 'Post an update') %} {% call modal('create-post-modal', 'Post an update') %}
{% set _composer_topic = 'devlog' %}{% set _composer_project = project['uid'] %}{% include "_post_composer_form.html" %} {% set _composer_topic = 'devlog' %}{% set _composer_project = project['uid'] %}{% include "_post_composer_form.html" %}
@ -174,6 +239,11 @@
</div> </div>
</div> </div>
<div class="auth-field auth-field-gap">
<label for="edit-project-website_url">Website (optional)</label>
<input type="url" id="edit-project-website_url" name="website_url" maxlength="500" placeholder="https://myproject.dev" value="{{ project.get('website_url', '') or '' }}">
</div>
<div class="auth-field auth-field-gap"> <div class="auth-field auth-field-gap">
<label>Type</label> <label>Type</label>
<div class="flex-wrap-gap" role="group" aria-label="Type"> <div class="flex-wrap-gap" role="group" aria-label="Type">
@ -217,12 +287,6 @@
</form> </form>
{% endcall %} {% endcall %}
{% endif %} {% endif %}
<div id="comments">
{% with target_uid=project['uid'], target_type="project" %}
{% include "_comment_section.html" %}
{% endwith %}
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}

View File

@ -126,6 +126,11 @@
</div> </div>
</div> </div>
<div class="auth-field auth-field-gap">
<label for="website_url">Website (optional)</label>
<input type="url" id="website_url" name="website_url" maxlength="500" placeholder="https://myproject.dev">
</div>
<div class="auth-field auth-field-gap"> <div class="auth-field auth-field-gap">
<label>Type</label> <label>Type</label>
<div class="flex-wrap-gap" role="group" aria-label="Type"> <div class="flex-wrap-gap" role="group" aria-label="Type">

View File

@ -319,6 +319,42 @@ def test_project_page_emits_typed_json_ld(app_server):
assert '"gamePlatform"' in html, "Expected gamePlatform from the platforms field" 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): def test_project_page_json_ld_rating_from_stars(app_server):
"""Stars surface as an aggregateRating; zero stars emit none.""" """Stars surface as an aggregateRating; zero stars emit none."""
session, _ = _member() session, _ = _member()

View File

@ -105,6 +105,42 @@ def test_owner_can_edit_project(app_server):
assert row["platforms"] == "Linux,Web" 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): def test_non_owner_cannot_edit_project(app_server):
_, _, owner_key = _signup_project_visibility() _, _, owner_key = _signup_project_visibility()
slug = _create_project_project_visibility(owner_key, "Owner Edit Guard")["slug"] slug = _create_project_project_visibility(owner_key, "Owner Edit Guard")["slug"]

View File

@ -119,7 +119,7 @@ def _alice_key():
def test_files_link_on_detail(alice): def test_files_link_on_detail(alice):
page, _ = alice page, _ = alice
_make_project_ui(page, "UI Files Link") _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() expect(link).to_be_visible()
link.click() link.click()
page.wait_for_url("**/files", wait_until="domcontentloaded") page.wait_for_url("**/files", wait_until="domcontentloaded")

View File

@ -767,7 +767,7 @@ def test_project_comments_form_visible(alice):
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded") page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert page.is_visible("text=Comments") assert page.is_visible("text=Comments")
assert page.is_visible("text=No comments yet") 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): 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.fill("#description", "Project for creating a comment")
page.click("button:has-text('Create Project')") page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded") page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Great project!") page.fill(".comment-form textarea[name='content']", "Great project!")
page.click("button:has-text('Post')") page.click(".comment-form button:has-text('Post')")
page.wait_for_timeout(500) page.wait_for_timeout(500)
assert page.is_visible("text=Great project!") 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.fill("#description", "Project for testing reply")
page.click("button:has-text('Create Project')") page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded") page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "First comment") page.fill(".comment-form textarea[name='content']", "First comment")
page.click("button:has-text('Post')") page.click(".comment-form button:has-text('Post')")
page.wait_for_timeout(500) page.wait_for_timeout(500)
assert page.is_visible("text=First comment") assert page.is_visible("text=First comment")
page.click("button:has-text('Reply')") page.click("button:has-text('Reply')")
@ -812,8 +812,8 @@ def test_project_comment_delete(alice):
page.fill("#description", "Project for testing delete") page.fill("#description", "Project for testing delete")
page.click("button:has-text('Create Project')") page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded") page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Comment to delete") page.fill(".comment-form textarea[name='content']", "Comment to delete")
page.click("button:has-text('Post')") page.click(".comment-form button:has-text('Post')")
page.wait_for_timeout(500) page.wait_for_timeout(500)
assert page.is_visible("text=Comment to delete") assert page.is_visible("text=Comment to delete")
page.locator(".comment-action-btn:has-text('Delete')").click() page.locator(".comment-action-btn:has-text('Delete')").click()

View File

@ -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" 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(): def test_reaction_form_accepts_any_single_emoji():
from devplacepy.models import ReactionForm from devplacepy.models import ReactionForm

View File

@ -93,6 +93,28 @@ def test_software_application_schema_rating_and_image():
assert rich["interactionStatistic"]["userInteractionCount"] == 3 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(): def test_project_devlog_schema_builds_blog_graph():
project = {"uid": "p1", "slug": "p1-s", "title": "Nebula"} project = {"uid": "p1", "slug": "p1-s", "title": "Nebula"}
assert seo.project_devlog_schema(project, [], "https://x.test") is None assert seo.project_devlog_schema(project, [], "https://x.test") is None