Dedicate the project page to the project
DevPlace CI / test (pull_request) Has been cancelled

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
+2
View File
@@ -9,6 +9,8 @@ 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 page.** The detail page is a dedicated project showcase: one encompassing dark card (`.project-shell`, the site `--bg-card` surface with clipped corners) wraps the hero, the section tab bar and the two-column body, and every inner panel (tab bar, sidebar cards, devlog post cards, empty state, comments section) sits one elevation lighter on `--bg-secondary`. The hero's cover banner is the attachment referenced by `projects.cover_attachment_uid`, falling back to the first image attachment (brand-gradient band when neither exists); the title block, type/platform chips and author row render OVERLAID on the banner behind a bottom scrim (dark text-shadow for readability) beside the optional `projects.logo_attachment_uid` tile, with an owner-set **Visit Website** CTA (`projects.website_url`). Cover and logo ride the ONE existing upload pipeline: `dp-upload` widgets (`name="cover_attachment_uid"`/`"logo_attachment_uid"`, `max-files="1"`) in the create/edit modals upload to `/uploads/upload`, the route validates each uid via `database.get_user_attachment` (must exist, belong to the actor, be an image - `_hero_attachment_uid`) and links it to the project through `attachments.link_attachments`; an empty value on edit keeps the current image (no removal control). `website_url`/`repo_url` are normalized by `models.normalize_website_url` (scheme-less input gets `https://`, non-http(s) rejected) and render with `rel="noopener nofollow"`. Below the hero an anchor **section tab bar** (`.project-tabs`, underline style, Overview `.active`) links `#about` / `#devlog` / `#screenshots` (only when gallery images exist) / `#comments` / the Files page - server-rendered anchors, no JS tab state. The main column holds **About** (description + non-image attachments), the **Devlog** (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`, the same rule as `news.html`) with `devlog_count` (`content.count_project_devlog`) and an owner **Post update** button (`.project-devlog-post-btn`) opening the shared composer preset to `topic=devlog` + this project (the form lives ONCE in `templates/_post_composer_form.html`, locals `_composer_topic`/`_composer_project`, included by `feed.html` and `project_detail.html` - never fork a second copy), a **Screenshots** gallery (image attachments minus the cover/logo, thumbnails, `data-lightbox`, capped at 12 rendered), and the comment thread; the sidebar holds Links (website/repository/files/fork source), the Stats card (5 `.project-stat` entries + a last-update line) and the Author card. Owners add gallery images via the More-menu **Add screenshots** modal: `_attachment_form.html` uploads, then `POST /projects/{slug}/screenshots` (`ProjectScreenshotsForm`, owner-only, audit `project.screenshots.add`) links the uids through the same `link_attachments` choke point; Devii action `project_add_screenshots`, docs id `projects-screenshots`. `comment_count`/`devlog_count` ride `ProjectDetailOut`; the new project fields ride `ProjectOut`; the page og:image prefers the cover attachment. **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']`).
**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.
**Owner editing.** Mirrors post editing exactly: an owner-only **Edit** menu item (`data-modal="edit-project-modal"`) opens the `modal()` macro's `edit-project-modal`, a plain `POST` form to `/projects/edit/{slug}` (route `edit_project` in `routers/projects/index.py`, body `ProjectEditForm`, owner-gated through the shared `content.edit_content_item` which returns 403 JSON / redirect for non-owners and stamps `updated_at`). The modal is the create modal pre-filled from the `project` row (title, description, type/status radios pre-checked, dates via `format_date()` back to DD/MM/YYYY). `is_private`/`read_only` are NOT edited here - they stay on their dedicated toggles. The platforms tag widget reuses the create modal's `platforms-input`/`platforms`/`platforms-tags` ids; `ProfileEditor.initPlatformTags` now **seeds existing tags** from the hidden `#platforms` value on load, so both the empty create form and the pre-filled edit form work from the same code. Devii tool `edit_project`; documented in `docs_api.py` (`projects-edit`).
+102 -13
View File
@@ -4,9 +4,15 @@ import logging
from typing import Annotated
from sqlalchemy import or_
from fastapi import Depends, APIRouter, Request
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
from devplacepy.models import (
ProjectForm,
ProjectEditForm,
ProjectFlagForm,
ProjectScreenshotsForm,
ForkForm,
)
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.attachments import get_attachments_batch
from devplacepy.attachments import get_attachments_batch, link_attachments
from devplacepy.database import (
get_table,
get_users_by_uids,
@@ -25,6 +31,7 @@ from devplacepy.database import (
get_fork_parent,
count_forks,
get_top_authors,
get_user_attachment,
)
from devplacepy.project_files import count_files
from devplacepy.services.jobs import queue
@@ -41,6 +48,7 @@ from devplacepy.content import (
can_view_project_containers,
can_open_workspace,
get_project_devlog,
count_project_devlog,
)
from devplacepy.utils import (
get_current_user,
@@ -196,6 +204,18 @@ def _editor_launch(project: dict, user: dict) -> dict:
}
def _hero_attachment_uid(user: dict, raw_uid: str) -> str | None:
uid = (raw_uid or "").strip()
if not uid:
return None
attachment = get_user_attachment(uid)
if not attachment or attachment.get("user_uid") != user["uid"]:
return None
if not attachment.get("is_image"):
return None
return uid
@router.get("/{project_slug}", response_class=HTMLResponse)
async def project_detail(request: Request, project_slug: str, before: str = None):
user = get_current_user(request)
@@ -215,13 +235,21 @@ 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"
cover_url = next(
(
a["url"]
for a in detail["attachments"]
if a["uid"] == project.get("cover_attachment_uid") and a.get("is_image")
),
None,
)
seo_ctx = base_seo_context(
request,
title=project.get("title", "Project"),
description=project.get("description", ""),
seo_target=("project", project["uid"]),
robots=robots,
og_image=first_image_url(project, detail["attachments"]),
og_image=cover_url or first_image_url(project, detail["attachments"]),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
@@ -292,8 +320,14 @@ async def project_detail(request: Request, project_slug: str, before: str = None
"forked_from": forked_from,
"fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]),
"comment_count": get_table("comments").count(
target_type="project",
target_uid=project["uid"],
deleted_at=None,
),
"devlog_posts": devlog_posts,
"devlog_next_cursor": devlog_next_cursor,
"devlog_count": count_project_devlog(project["uid"]),
},
),
model=ProjectDetailOut,
@@ -409,6 +443,8 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
user = require_user(request)
title = data.title.strip()
description = data.description.strip()
cover_uid = _hero_attachment_uid(user, data.cover_attachment_uid)
logo_uid = _hero_attachment_uid(user, data.logo_attachment_uid)
uid, project_slug = create_content_item(
"projects",
@@ -422,6 +458,10 @@ 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,
"repo_url": data.repo_url or None,
"cover_attachment_uid": cover_uid,
"logo_attachment_uid": logo_uid,
"is_private": 1 if data.is_private else 0,
"read_only": 0,
},
@@ -432,6 +472,7 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
data.attachment_uids,
request,
)
link_attachments([u for u in (cover_uid, logo_uid) if u], "project", uid)
url = f"/projects/{project_slug}"
return action_result(
request, url, data={"uid": uid, "slug": project_slug, "url": url}
@@ -442,23 +483,71 @@ async def edit_project(
request: Request, project_slug: str, data: Annotated[ProjectEditForm, Depends(json_or_form(ProjectEditForm))]
):
user = require_user(request)
return edit_content_item(
fields = {
"title": data.title.strip(),
"description": data.description.strip(),
"release_date": data.release_date or None,
"demo_date": data.demo_date or None,
"project_type": data.project_type,
"platforms": data.platforms.strip(),
"status": data.status,
"website_url": data.website_url or None,
"repo_url": data.repo_url or None,
}
hero_uids = []
for field in ("cover_attachment_uid", "logo_attachment_uid"):
uid = _hero_attachment_uid(user, getattr(data, field))
if uid:
fields[field] = uid
hero_uids.append(uid)
result = edit_content_item(
request,
"projects",
user,
project_slug,
{
"title": data.title.strip(),
"description": data.description.strip(),
"release_date": data.release_date or None,
"demo_date": data.demo_date or None,
"project_type": data.project_type,
"platforms": data.platforms.strip(),
"status": data.status,
},
fields,
"/projects",
target_type="project",
)
if hero_uids:
project = resolve_by_slug(get_table("projects"), project_slug)
if project and is_owner(project, user):
link_attachments(hero_uids, "project", project["uid"])
return result
@router.post("/{project_slug}/screenshots")
async def add_project_screenshots(
request: Request,
project_slug: str,
data: Annotated[ProjectScreenshotsForm, Depends(json_or_form(ProjectScreenshotsForm))],
):
user = require_user(request)
project = resolve_by_slug(get_table("projects"), project_slug)
if not project:
raise not_found("Project not found")
if not is_owner(project, user):
if wants_json(request):
return json_error(403, "Not allowed")
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)
link_attachments(data.attachment_uids, "project", project["uid"])
audit.record(
request,
"project.screenshots.add",
user=user,
target_type="project",
target_uid=project["uid"],
target_label=project.get("title"),
metadata={"attachment_count": len(data.attachment_uids)},
summary=f"{user['username']} added {len(data.attachment_uids)} screenshot(s) to project {project.get('title')}",
links=[audit.target("project", project["uid"], project.get("title"))],
)
url = f"/projects/{project['slug'] or project['uid']}#screenshots"
return action_result(
request,
url,
data={"uid": project["uid"], "linked": len(data.attachment_uids), "url": url},
)
_FLAG_EVENTS = {
("is_private", True): "project.visibility.private",