diff --git a/README.md b/README.md
index be3ad81f..c2cbeb82 100644
--- a/README.md
+++ b/README.md
@@ -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 |
| `/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/{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) |
| `/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 |
diff --git a/devplacepy/docs_api/groups/content.py b/devplacepy/docs_api/groups/content.py
index bf6019bc..611ac819 100644
--- a/devplacepy/docs_api/groups/content.py
+++ b/devplacepy/docs_api/groups/content.py
@@ -451,6 +451,14 @@ four ways to sign requests.
"31/12/2026",
"Optional demo date in DD/MM/YYYY format.",
),
+ field(
+ "website_url",
+ "form",
+ "string",
+ False,
+ "https://myproject.dev",
+ "Optional official website URL (http/https).",
+ ),
],
),
endpoint(
@@ -528,6 +536,14 @@ four ways to sign requests.
"31/12/2026",
"Optional demo date in DD/MM/YYYY format.",
),
+ field(
+ "website_url",
+ "form",
+ "string",
+ False,
+ "https://myproject.dev",
+ "Optional official website URL (http/https).",
+ ),
],
),
endpoint(
diff --git a/devplacepy/models.py b/devplacepy/models.py
index dec1bdf1..aa609aae 100644
--- a/devplacepy/models.py
+++ b/devplacepy/models.py
@@ -5,7 +5,7 @@ import re
from datetime import datetime
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 devplacepy.constants import TOPICS
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")
+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):
if value is None:
return []
@@ -241,6 +255,7 @@ class ProjectForm(BaseModel):
)
platforms: str = Field(default="", max_length=500)
status: str = Field(default="In Development", max_length=100)
+ website_url: str = Field(default="", max_length=500)
is_private: bool = False
attachment_uids: list[str] = []
@@ -249,6 +264,11 @@ class ProjectForm(BaseModel):
def normalize_dates(cls, 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):
title: str = Field(min_length=1, max_length=200)
@@ -260,12 +280,18 @@ class ProjectEditForm(BaseModel):
)
platforms: str = Field(default="", max_length=500)
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")
@classmethod
def normalize_dates(cls, 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):
target: Literal["database", "uploads", "keys", "full"] = "full"
diff --git a/devplacepy/routers/projects/CLAUDE.md b/devplacepy/routers/projects/CLAUDE.md
index 99ad25b6..3d529aba 100644
--- a/devplacepy/routers/projects/CLAUDE.md
+++ b/devplacepy/routers/projects/CLAUDE.md
@@ -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`.
-**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.
diff --git a/devplacepy/routers/projects/index.py b/devplacepy/routers/projects/index.py
index 1a574941..e9b2d40c 100644
--- a/devplacepy/routers/projects/index.py
+++ b/devplacepy/routers/projects/index.py
@@ -231,6 +231,7 @@ async def project_detail(request: Request, project_slug: str, before: str = None
base = site_url(request)
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
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(
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),
star_count=detail.get("star_count", 0),
comment_count=comment_total,
+ screenshot_urls=[
+ absolute_url(base, a["url"]) for a in screenshots[:6]
+ ],
),
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,
"platforms": data.platforms.strip(),
"status": data.status,
+ "website_url": data.website_url or None,
"is_private": 1 if data.is_private else 0,
"read_only": 0,
},
@@ -474,6 +479,7 @@ async def edit_project(
"project_type": data.project_type,
"platforms": data.platforms.strip(),
"status": data.status,
+ "website_url": data.website_url or None,
},
"/projects",
target_type="project",
diff --git a/devplacepy/schemas/content.py b/devplacepy/schemas/content.py
index 8f8c79a1..29c6f47b 100644
--- a/devplacepy/schemas/content.py
+++ b/devplacepy/schemas/content.py
@@ -121,6 +121,7 @@ class ProjectOut(_Out):
read_only: Optional[bool] = None
release_date: Optional[str] = None
demo_date: Optional[str] = None
+ website_url: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
diff --git a/devplacepy/seo.py b/devplacepy/seo.py
index e151a970..9391ce74 100644
--- a/devplacepy/seo.py
+++ b/devplacepy/seo.py
@@ -152,7 +152,12 @@ def project_schema_type(project_type):
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"))
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)
if 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:
schema["aggregateRating"] = {
"@type": "AggregateRating",
diff --git a/devplacepy/services/devii/actions/catalog/projects.py b/devplacepy/services/devii/actions/catalog/projects.py
index b6f0aae6..9af20818 100644
--- a/devplacepy/services/devii/actions/catalog/projects.py
+++ b/devplacepy/services/devii/actions/catalog/projects.py
@@ -47,6 +47,7 @@ PROJECTS_ACTIONS: tuple[Action, ...] = (
body("project_type", "Project type."),
body("platforms", "Supported platforms."),
body("status", "Project status."),
+ body("website_url", "Official website URL (http/https)."),
body("attachment_uids", ATTACHMENTS),
),
),
@@ -67,6 +68,7 @@ PROJECTS_ACTIONS: tuple[Action, ...] = (
body("project_type", "Updated project type."),
body("platforms", "Updated supported platforms."),
body("status", "Updated project status."),
+ body("website_url", "Updated official website URL (http/https)."),
),
),
Action(
diff --git a/devplacepy/static/css/projects.css b/devplacepy/static/css/projects.css
index c46d3c98..15d1e432 100644
--- a/devplacepy/static/css/projects.css
+++ b/devplacepy/static/css/projects.css
@@ -223,16 +223,181 @@
}
}
-.project-detail-page {
- max-width: 720px;
+.project-page {
+ max-width: var(--max-content);
margin: 0 auto;
}
-.project-detail {
+
+.project-hero {
background: var(--bg-card);
border: 1px solid var(--border);
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;
}
+
+.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 {
display: flex;
align-items: flex-start;
@@ -304,19 +469,10 @@
color: var(--warning);
}
-.project-platforms {
- margin-bottom: 1.5rem;
-}
-
.project-stats {
- display: flex;
- flex-wrap: wrap;
- gap: 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);
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--space-sm) var(--space-lg);
font-size: 0.8125rem;
color: var(--text-muted);
}
diff --git a/devplacepy/templates/project_detail.html b/devplacepy/templates/project_detail.html
index 1470e794..9dca1c92 100644
--- a/devplacepy/templates/project_detail.html
+++ b/devplacepy/templates/project_detail.html
@@ -6,145 +6,210 @@
{% endblock %}
{% block content %}
-
+{% 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 %}
+