Dedicated SEO-optimized project page with a devlog environment #165

Closed
blindxfish wants to merge 5 commits from blindxfish/devplacepy:pr2-project-devlog-seo into master
25 changed files with 1441 additions and 234 deletions

View File

@ -68,6 +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}` | Dedicated SEO-optimized project page: hero with an owner-uploaded cover banner and project logo (falling back to the first image attachment), the title overlaid on the banner, status/type/platform chips, owner-set **Website** and **Repository** links, 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 (the owner adds more from the More menu), 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 |

View File

@ -854,6 +854,10 @@ def enrich_items(
return enriched
def count_project_devlog(project_uid: str) -> int:
return get_table("posts").count(project_uid=project_uid, deleted_at=None)
def get_project_devlog(
project_uid: str, before: str | None = None, viewer: dict | None = None
) -> tuple[list, str | None]:

View File

@ -362,7 +362,7 @@ four ways to sign requests.
method="GET",
path="/projects/{project_slug}",
title="View a project",
summary="Render a project with comments. Returns an HTML page.",
summary="Render a project overview with its devlog and comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
@ -373,7 +373,42 @@ four ways to sign requests.
True,
"PROJECT_SLUG",
"Slug or UID of the project.",
)
),
field(
"before",
"query",
"string",
False,
"",
"Devlog pagination cursor (devlog_next_cursor from the previous page).",
),
],
),
endpoint(
id="projects-screenshots",
method="POST",
path="/projects/{project_slug}/screenshots",
title="Add screenshots to a project",
summary="Link uploaded image attachments to an owned project's Screenshots gallery. Redirects to the gallery.",
auth="user",
encoding="form",
params=[
field(
"project_slug",
"path",
"string",
True,
"my-project-1a2b3c4d",
"Project slug or uid.",
),
field(
"attachment_uids",
"form",
"string",
True,
"ATTACHMENT_UID",
"Comma-separated attachment uids from POST /uploads/upload or /uploads/upload-url.",
),
],
),
endpoint(
@ -443,6 +478,22 @@ 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).",
),
field(
"repo_url",
"form",
"string",
False,
"https://github.com/me/project",
"Optional source repository URL (http/https).",
),
],
),
endpoint(
@ -520,6 +571,22 @@ 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).",
),
field(
"repo_url",
"form",
"string",
False,
"https://github.com/me/project",
"Optional source repository URL (http/https).",
),
],
),
endpoint(

View File

@ -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,8 @@ 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)
repo_url: str = Field(default="", max_length=500)
is_private: bool = False
attachment_uids: list[str] = []
@ -249,6 +265,11 @@ class ProjectForm(BaseModel):
def normalize_dates(cls, value):
return normalize_european_date(value)
@field_validator("website_url", "repo_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 +281,19 @@ 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)
repo_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", "repo_url")
@classmethod
def valid_website_url(cls, value):
return normalize_website_url(value)
class BackupRunForm(BaseModel):
target: Literal["database", "uploads", "keys", "full"] = "full"
@ -290,6 +318,10 @@ class ProjectFlagForm(BaseModel):
value: bool = False
class ProjectScreenshotsForm(BaseModel):
attachment_uids: list[str] = []
class CustomizationToggleForm(BaseModel):
value: bool = False

View File

@ -246,7 +246,7 @@ The feed page (`GET /feed`) is accessible without authentication:
All SEO features are implemented across the following locations:
### Core SEO utilities
- `devplacepy/seo.py` - JSON-LD schema generators (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication), meta description truncation, schema combiner, sitemap XML generator
- `devplacepy/seo.py` - JSON-LD schema generators (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, the type-aware project schema via `project_schema_type` - VideoGame/WebApplication/SoftwareApplication/CreativeWork - plus the devlog `Blog`/`BlogPosting` graph `project_devlog_schema`), meta description truncation, schema combiner, sitemap XML generator (project `lastmod` = latest of created/updated/newest devlog post)
- `routers/seo.py` - robots.txt and sitemap.xml routes
### SEO template context

View File

@ -9,6 +9,10 @@ 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 dedicated project showcase (`.project-page`, full `--max-content` measure): a hero card (`.project-hero`) whose cover banner is the owner-uploaded `projects.cover_image` falling back to the project's **first image attachment** (brand-gradient band when neither exists), with the title block OVERLAID on the banner behind a bottom scrim (`.project-hero-overlay`) next to the optional `projects.logo_image` tile - both are bare uploaded filenames served at `/static/uploads/{name}` via `attachments.save_inline_image` (the `posts.image` pattern; the create/edit modal forms are `multipart/form-data` with plain `cover_image`/`logo_image` file inputs read by `_uploaded_project_images`, a new upload replaces the old, no removal control) - the h1 title + status chip, type badge + platform chips + Private/Read-only badges, dates/forked-from meta, the author row with owner-set **Visit Website** and **Repository** links (`projects.website_url`/`projects.repo_url`, optional, both 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; the owner adds more via the More-menu **Add screenshots** modal - `_attachment_form.html` uploads to `/uploads/upload`, then `POST /projects/{slug}/screenshots` (`ProjectScreenshotsForm`, owner-only, audit `project.screenshots.add`) links the uids through the same `attachments.link_attachments` choke point as create/edit; Devii action `project_add_screenshots`, docs id `projects-screenshots`), 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=, screenshot_urls=)` (screenshots = up to 6 absolute image-attachment urls -> `screenshot`; `website_url` + `repo_url` -> `sameAs`; `logo_image` -> `thumbnailUrl`; the og/schema image prefers `cover_image`) 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.
**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`).

View File

@ -4,9 +4,19 @@ 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,
save_inline_image,
)
from devplacepy.database import (
get_table,
get_users_by_uids,
@ -41,6 +51,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,
@ -52,10 +63,12 @@ from devplacepy.utils import (
XP_PROJECT,
)
from devplacepy.seo import (
absolute_url,
base_seo_context,
site_url,
website_schema,
software_application_schema,
project_devlog_schema,
list_page_seo,
next_page_url,
)
@ -206,40 +219,6 @@ async def project_detail(request: Request, project_slug: str, before: str = None
user["uid"], resolve_object_url("project", project["uid"])
)
base = site_url(request)
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
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"]),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
{
"name": project.get("title", "Project"),
"url": f"/projects/{project['slug'] or project['uid']}",
},
],
schemas=[website_schema(base), software_application_schema(project, base)],
)
viewer_can_workspace = can_open_workspace(project, user)
workspace_editor_url = (
_editor_url(project, user) if viewer_can_workspace else ""
)
parent = get_fork_parent(project["uid"])
forked_from = (
{
"uid": parent["uid"],
"slug": parent.get("slug") or parent["uid"],
"title": parent.get("title") or "project",
}
if parent
else None
)
devlog_posts, devlog_next_cursor = get_project_devlog(
project["uid"], before=before, viewer=user
)
@ -257,6 +236,76 @@ async def project_detail(request: Request, project_slug: str, before: str = None
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
devlog_count = count_project_devlog(project["uid"])
base = site_url(request)
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
cover_url = (
f"/static/uploads/{project['cover_image']}"
if project.get("cover_image")
else None
)
og_image = cover_url or 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
)
platforms = [
p.strip() for p in (project.get("platforms") or "").split(",") if p.strip()
]
keyword_parts = [
project.get("title", ""),
project.get("project_type", "").replace("_", " "),
*platforms,
"devlog",
"developer project",
]
seo_ctx = base_seo_context(
request,
title=project.get("title", "Project"),
description=project.get("description", ""),
seo_target=("project", project["uid"]),
robots=robots,
og_image=og_image,
keywords=", ".join(part for part in keyword_parts if part),
next_url=next_page_url(request, devlog_next_cursor),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
{
"name": project.get("title", "Project"),
"url": f"/projects/{project['slug'] or project['uid']}",
},
],
schemas=[
website_schema(base),
software_application_schema(
{**project, "author_name": (detail.get("author") or {}).get("username", "Unknown")},
base,
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),
],
)
viewer_can_workspace = can_open_workspace(project, user)
workspace_editor_url = (
_editor_url(project, user) if viewer_can_workspace else ""
)
parent = get_fork_parent(project["uid"])
forked_from = (
{
"uid": parent["uid"],
"slug": parent.get("slug") or parent["uid"],
"title": parent.get("title") or "project",
}
if parent
else None
)
return respond(
request,
@ -279,8 +328,10 @@ 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": comment_total,
"devlog_posts": devlog_posts,
"devlog_next_cursor": devlog_next_cursor,
"devlog_count": devlog_count,
},
),
model=ProjectDetailOut,
@ -391,11 +442,27 @@ async def delete_project(request: Request, project_slug: str):
request, "projects", "project", user, project_slug, "/projects"
)
async def _uploaded_project_images(request: Request) -> dict:
images = {}
try:
form = await request.form()
except Exception:
return images
for field in ("cover_image", "logo_image"):
upload = form.get(field)
if upload is not None and hasattr(upload, "filename") and upload.filename:
filename = save_inline_image(await upload.read(), upload.filename)
if filename:
images[field] = filename
return images
@router.post("/create")
async def create_project(request: Request, data: Annotated[ProjectForm, Depends(json_or_form(ProjectForm))]):
user = require_user(request)
title = data.title.strip()
description = data.description.strip()
images = await _uploaded_project_images(request)
uid, project_slug = create_content_item(
"projects",
@ -409,6 +476,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_image": images.get("cover_image"),
"logo_image": images.get("logo_image"),
"is_private": 1 if data.is_private else 0,
"read_only": 0,
},
@ -429,24 +500,62 @@ async def edit_project(
request: Request, project_slug: str, data: Annotated[ProjectEditForm, Depends(json_or_form(ProjectEditForm))]
):
user = require_user(request)
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,
}
fields.update(await _uploaded_project_images(request))
return 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",
)
@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",
("is_private", False): "project.visibility.public",

View File

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

View File

@ -172,8 +172,10 @@ class ProjectDetailOut(_Out):
forked_from: Optional[dict] = None
fork_count: int = 0
file_count: int = 0
comment_count: int = 0
devlog_posts: list[FeedItemOut] = []
devlog_next_cursor: Optional[str] = None
devlog_count: int = 0
class GistsOut(_Out):

View File

@ -8,7 +8,6 @@ from urllib.parse import urlencode
from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom
from devplacepy.config import SITE_URL
from devplacepy.utils import strip_html
logger = logging.getLogger(__name__)
@ -139,19 +138,113 @@ def profile_page_schema(profile_user, post_count, base_url):
}
def software_application_schema(project, base_url):
return {
"@type": "SoftwareApplication",
PROJECT_SCHEMA_TYPES = {
"game": "VideoGame",
"game_asset": "CreativeWork",
"software": "SoftwareApplication",
"mobile_app": "SoftwareApplication",
"website": "WebApplication",
}
def project_schema_type(project_type):
return PROJECT_SCHEMA_TYPES.get((project_type or "").lower(), "SoftwareApplication")
def software_application_schema(
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()]
author_name = project.get("author_name", "Unknown")
schema = {
"@type": schema_type,
"name": project.get("title", "Untitled"),
"description": truncate(plain_markdown(project.get("description", "")), 300),
"url": f"{base_url}/projects/{project.get('slug') or project['uid']}",
"applicationCategory": "DeveloperApplication",
"operatingSystem": project.get("platforms", "Cross-platform"),
"author": {"@type": "Person", "name": project.get("author_name", "Unknown")},
"operatingSystem": project.get("platforms") or "Cross-platform",
"author": {"@type": "Person", "name": author_name},
"creator": {"@type": "Person", "name": author_name},
"datePublished": project.get("created_at", ""),
"dateModified": project.get("updated_at") or project.get("created_at", ""),
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
}
if schema_type in ("SoftwareApplication", "WebApplication"):
schema["applicationCategory"] = "DeveloperApplication"
if schema_type == "WebApplication":
schema["browserRequirements"] = "Requires JavaScript"
if schema_type == "VideoGame" and platforms:
schema["gamePlatform"] = platforms
if project.get("release_date"):
schema["releasedEvent"] = {
"@type": "PublicationEvent",
"startDate": project["release_date"],
}
keywords = [project.get("project_type", "").replace("_", " ").strip(), *platforms]
keywords = [k for k in keywords if k]
if keywords:
schema["keywords"] = ", ".join(keywords)
if image_url:
schema["image"] = image_url
if screenshot_urls:
schema["screenshot"] = list(screenshot_urls)
if project.get("logo_image"):
schema["thumbnailUrl"] = f"{base_url}/static/uploads/{project['logo_image']}"
same_as = [
url for url in (project.get("website_url"), project.get("repo_url")) if url
]
if same_as:
schema["sameAs"] = same_as
if star_count > 0:
schema["aggregateRating"] = {
"@type": "AggregateRating",
"ratingValue": "5",
"ratingCount": str(star_count),
"bestRating": "5",
"worstRating": "1",
}
if comment_count > 0:
schema["interactionStatistic"] = {
"@type": "InteractionCounter",
"interactionType": "https://schema.org/CommentAction",
"userInteractionCount": comment_count,
}
return schema
def project_devlog_schema(project, devlog_posts, base_url):
if not devlog_posts:
return None
project_url = f"{base_url}/projects/{project.get('slug') or project['uid']}"
entries = []
for item in devlog_posts:
post = item.get("post") or {}
author = item.get("author") or {}
entry = {
"@type": "BlogPosting",
"headline": post.get("title") or truncate(plain_markdown(post.get("content", "")), 80) or "Untitled update",
"url": f"{base_url}/posts/{post.get('slug') or post.get('uid', '')}",
"articleBody": truncate(plain_markdown(post.get("content", "")), 300),
"datePublished": post.get("created_at", ""),
"dateModified": post.get("updated_at") or post.get("created_at", ""),
"author": {"@type": "Person", "name": author.get("username") or "Unknown"},
}
if item.get("comment_count"):
entry["commentCount"] = item["comment_count"]
entries.append(entry)
return {
"@type": "Blog",
"@id": f"{project_url}#devlog",
"name": f"{project.get('title', 'Project')} devlog",
"url": f"{project_url}#devlog",
"about": project.get("title", "Project"),
"blogPost": entries,
}
def web_application_schema(name, description, path, base_url, category="DeveloperApplication"):
@ -461,6 +554,12 @@ def _build_sitemap(base_url):
)
if "projects" in db.tables:
latest_devlog = {}
if "posts" in db.tables:
for row in db.query(
"SELECT project_uid, MAX(created_at) AS latest FROM posts WHERE deleted_at IS NULL AND project_uid IS NOT NULL GROUP BY project_uid"
):
latest_devlog[row["project_uid"]] = row["latest"]
projects = _collect(
get_table("projects"),
SITEMAP_URL_LIMIT,
@ -470,10 +569,21 @@ def _build_sitemap(base_url):
for p in projects:
if p.get("is_private"):
continue
lastmod = max(
filter(
None,
(
p.get("created_at", ""),
p.get("updated_at") or "",
latest_devlog.get(p["uid"], ""),
),
),
default="",
)
urlset.append(
url_element(
f"{base_url}/projects/{p.get('slug') or p['uid']}",
lastmod=p.get("created_at", ""),
lastmod=lastmod,
changefreq="weekly",
priority="0.6",
)

View File

@ -47,6 +47,8 @@ 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("repo_url", "Source repository URL (http/https)."),
body("attachment_uids", ATTACHMENTS),
),
),
@ -67,6 +69,21 @@ 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)."),
body("repo_url", "Updated source repository URL (http/https)."),
),
),
Action(
name="project_add_screenshots",
method="POST",
path="/projects/{project_slug}/screenshots",
summary="Add screenshots to an owned project (upload first via upload_file or attach_url, then pass the attachment uids)",
params=(
path(
"project_slug",
"Exact project slug copied from a /projects/... link in a listing response; do not build it from the title.",
),
body("attachment_uids", ATTACHMENTS, required=True),
),
),
Action(

View File

@ -223,16 +223,293 @@
}
}
.project-detail-page {
max-width: 720px;
.project-page {
max-width: var(--max-content);
margin: 0 auto;
}
.project-detail {
/* One encompassing dark card holds the whole project; panels inside it
sit on the lighter secondary surface (the reference's elev-1/elev-2 split). */
.project-shell {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.project-shell-body,
.project-shell > .project-tabs,
.project-shell > .project-columns {
margin-left: 1.5rem;
margin-right: 1.5rem;
}
.project-shell > .project-columns {
margin-bottom: 1.5rem;
}
.project-shell .project-tabs,
.project-shell .project-sidebar-card,
.project-shell .post-card,
.project-shell .empty-state,
.project-shell .comments-section {
background: var(--bg-secondary);
}
.project-hero {
overflow: hidden;
}
.project-cover {
position: relative;
min-height: 320px;
display: flex;
align-items: flex-end;
background: var(--bg-secondary);
}
.project-cover-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.project-cover-fallback {
min-height: 200px;
background: var(--accent-gradient);
}
.project-cover-fallback::before {
content: "";
position: absolute;
inset: 0;
background: var(--overlay-dark);
}
.project-cover-scrim {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30%, rgba(0, 0, 0, 0.78) 100%);
}
.project-hero-overlay {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
gap: 1.25rem;
width: 100%;
padding: 1.5rem;
}
.project-logo {
width: 112px;
height: 112px;
object-fit: cover;
border-radius: var(--radius-lg);
border: 2px solid var(--border-light);
background: var(--bg-card);
box-shadow: var(--shadow);
flex-shrink: 0;
}
.project-hero-headline {
min-width: 0;
flex: 1;
}
.project-hero-overlay .project-detail-title,
.project-hero-overlay .project-tagline,
.project-hero-overlay .project-detail-author {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 2px 12px rgba(0, 0, 0, 0.6);
}
.project-hero-overlay .project-detail-header {
margin-bottom: 0.375rem;
justify-content: flex-start;
gap: 0.75rem;
align-items: center;
}
.project-tagline {
font-size: 0.9375rem;
color: var(--text-secondary);
margin-bottom: 0.625rem;
}
.project-hero-chips {
margin-bottom: 0.625rem;
}
.project-hero-overlay .project-detail-author {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.project-hero-ctas {
flex-shrink: 0;
}
.project-hero-body {
padding: 1rem 1.5rem 1.25rem;
}
.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 0.5rem;
flex-wrap: wrap;
}
.project-tab {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.75rem 1rem;
border-bottom: 2px solid transparent;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
}
.project-tab:hover {
color: var(--text-primary);
}
.project-tab.active {
color: var(--text-primary);
border-bottom-color: var(--accent);
}
.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 {
min-height: 240px;
}
}
@media (max-width: 768px) {
.project-hero-overlay {
flex-wrap: wrap;
align-items: flex-start;
gap: 0.75rem;
}
.project-logo {
width: 72px;
height: 72px;
}
.project-cover {
min-height: 200px;
}
}
.project-detail-header {
display: flex;
align-items: flex-start;
@ -304,7 +581,28 @@
color: var(--warning);
}
.project-platforms {
.project-stats {
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);
}
.project-stat a {
color: var(--text-muted);
}
.project-stat a:hover {
color: var(--accent);
}
.project-stat-value {
font-weight: 700;
color: var(--text-primary);
}
.project-about {
margin-bottom: 1.5rem;
}
@ -312,6 +610,26 @@
margin-top: 1.5rem;
}
.project-devlog-header {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: 0.75rem;
}
.project-devlog-header .project-section-label {
margin-bottom: 0;
}
.project-devlog-count {
font-size: 0.75rem;
color: var(--text-muted);
}
.project-devlog-post-btn {
margin-left: auto;
}
.project-section-label {
font-size: 0.75rem;
font-weight: 700;

View File

@ -65,6 +65,7 @@ Do NOT hand-write the overlay/header markup. Use the shared macro in `templates/
Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` convention) instead of copy-pasting markup:
- `_post_composer_form.html` - the create-post form (topic selector, content/title, project select, attachments, poll builder, footer). Locals: `_composer_topic` (preselected topic, default `random`), `_composer_project` (preselected project uid or `""`). Wrapped in the `modal()` macro by `feed.html` (Create New Post) and `project_detail.html` (owner-only Post an update, preset to `devlog` + the project). Never fork a second copy of this form.
- `_post_votes.html` - post +/- vote bar. Locals: `_uid`, `_my_vote`, `_count`.
- `_star_vote.html` - project/gist star button. Locals: `_type` (`project`|`gist`), `_uid`, `_my_vote`, `_count`, `_btn_class`, optional `_stop` (adds `data-stop-propagation`). The star glyph (`☆`→`★` when `.voted`) comes from the `vote-star` CSS class via `::before` (`base.css`) - do not put a literal star in markup.
- `_post_header.html` - post author/avatar/time header (`.post-header`). Locals: `_author`, `_time`.

View File

@ -0,0 +1,49 @@
{# Shared create-post form. Locals: _composer_topic (preselected topic), _composer_project (preselected project uid or ""). #}
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
{% set _topics = TOPICS %}{% set _selected = _composer_topic or 'random' %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="post-content">What are you sharing?</label>
<textarea id="post-content" name="content" required maxlength="125000" placeholder="What's on your mind?" class="min-h-120" data-mention></textarea>
<small class="hint-text"><span id="post-content-count">0/125000</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="post-title">Title (optional)</label>
<input type="text" id="post-title" name="title" maxlength="500" placeholder="Post title">
<small class="hint-text"><span id="post-title-count">0/500</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="project_uid">Link to Project (Optional)</label>
<select id="project_uid" name="project_uid">
<option value="">No project</option>
{% for p in get_user_projects(user['uid']) %}
<option value="{{ p['uid'] }}" {% if _composer_project and p['uid'] == _composer_project %}selected{% endif %}>{{ p['title'] }}</option>
{% endfor %}
</select>
</div>
<div class="auth-field auth-field-gap">
<label>Attach files (images, video, audio, documents)</label>
{% include "_attachment_form.html" %}
</div>
<div class="auth-field auth-field-gap">
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">&#x1F4CA;</span> <span data-poll-toggle-label>Add poll</span></button>
</div>
<div class="poll-builder" data-poll-builder hidden>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" aria-label="Poll question" class="poll-builder-question" disabled>
<div class="poll-builder-options" data-poll-options>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" aria-label="Poll option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" aria-label="Poll option 2" disabled></div>
</div>
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
<p class="poll-builder-error" data-poll-error hidden></p>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Post</button>
</div>
</form>

View File

@ -160,54 +160,7 @@
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post" aria-label="Create New Post">+</a>
{% call modal('create-post-modal', 'Create New Post') %}
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
{% set _topics = TOPICS %}{% set _selected = 'random' %}{% include "_topic_selector.html" %}
<div class="auth-field auth-field-gap">
<label for="post-content">What are you sharing?</label>
<textarea id="post-content" name="content" required maxlength="125000" placeholder="What's on your mind?" class="min-h-120" data-mention></textarea>
<small class="hint-text"><span id="post-content-count">0/125000</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="post-title">Title (optional)</label>
<input type="text" id="post-title" name="title" maxlength="500" placeholder="Post title">
<small class="hint-text"><span id="post-title-count">0/500</span></small>
</div>
<div class="auth-field auth-field-gap">
<label for="project_uid">Link to Project (Optional)</label>
<select id="project_uid" name="project_uid">
<option value="">No project</option>
{% for p in get_user_projects(user['uid']) %}
<option value="{{ p['uid'] }}">{{ p['title'] }}</option>
{% endfor %}
</select>
</div>
<div class="auth-field auth-field-gap">
<label>Attach files (images, video, audio, documents)</label>
{% include "_attachment_form.html" %}
</div>
<div class="auth-field auth-field-gap">
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">&#x1F4CA;</span> <span data-poll-toggle-label>Add poll</span></button>
</div>
<div class="poll-builder" data-poll-builder hidden>
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" aria-label="Poll question" class="poll-builder-question" disabled>
<div class="poll-builder-options" data-poll-options>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" aria-label="Poll option 1" disabled></div>
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" aria-label="Poll option 2" disabled></div>
</div>
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
<p class="poll-builder-error" data-poll-error hidden></p>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Post</button>
</div>
</form>
{% set _composer_topic = 'random' %}{% set _composer_project = '' %}{% include "_post_composer_form.html" %}
{% endcall %}
{% else %}
<a href="/auth/login" class="feed-fab login-required" aria-label="Log in">+</a>

View File

@ -3,131 +3,254 @@
{% block extra_head %}
<link rel="stylesheet" href="{{ static_url('/static/css/projects.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
{% endblock %}
{% 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 %}
{% set cover_src = ('/static/uploads/' ~ project['cover_image']) if project.get('cover_image') else (image_attachments[0]['url'] if image_attachments else none) %}
{% set logo_src = ('/static/uploads/' ~ project['logo_image']) if project.get('logo_image') else none %}
{% set tagline = (project.get('description', '') or '').split('\n')[0] %}
<div class="project-page">
<a href="/projects" class="back-link">&larr; Back to Projects</a>
<article class="project-detail">
<div class="project-detail-header">
<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 %}">
&#x25CF; {{ project.get('status', 'In Development') }}
</div>
</div>
{% if is_private or read_only %}
<div class="project-detail-meta">
{% if is_private %}<span class="badge badge-type">Private</span>{% endif %}
{% if read_only %}<span class="badge badge-type">Read-only</span>{% endif %}
</div>
{% endif %}
<div class="project-detail-meta">
<span class="badge badge-type">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
{% if project.get('release_date') %}
<span>&#x1F4C5; Released: {{ format_date(project['release_date']) }}</span>
<div class="project-shell">
<article class="project-hero">
<div class="project-cover{% if not cover_src %} project-cover-fallback{% endif %}">
{% if cover_src %}
<img class="project-cover-img" src="{{ cover_src }}" alt="{{ project['title'] }} cover image" loading="eager">
{% endif %}
{% if project.get('demo_date') %}
<span>&#x1F3AD; Demo: {{ format_date(project['demo_date']) }}</span>
{% endif %}
</div>
{% 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>
</div>
{% endif %}
<div class="project-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div>
{% 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>
</div>
</div>
{% 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 attachments %}
{% include "_attachment_display.html" %}
{% endif %}
{% if platforms %}
<div class="project-platforms">
<h4 class="project-section-label">Platforms</h4>
<div class="project-card-platforms">
{% for plat in platforms %}
<span class="platform-tag">{{ plat.strip() }}</span>
{% endfor %}
</div>
</div>
{% endif %}
<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>
{% 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>
{% 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>
{% 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" %}
{% endif %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _owner = project['user_uid'] %}{% set _owner_name = (author or {}).get('username', '') %}{% set _class = "project-star-btn" %}{% include "_report_button.html" %}
<button type="button" class="project-star-btn project-actions-more" aria-haspopup="menu" aria-expanded="false" aria-label="More actions"><span class="icon">&#x22EF;</span><span class="label"> More</span></button>
<div class="project-actions-overflow" hidden>
{% 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>
<div class="project-cover-scrim" aria-hidden="true"></div>
<div class="project-hero-overlay">
{% if logo_src %}
<img class="project-logo" src="{{ logo_src }}" alt="{{ project['title'] }} logo" loading="eager">
{% endif %}
{% 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>
<div class="project-hero-headline">
<div class="project-detail-header">
<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 %}">
&#x25CF; {{ project.get('status', 'In Development') }}
</div>
</div>
{% if tagline %}
<p class="project-tagline">{{ render_title(tagline, author_is_admin=is_admin(author)) }}</p>
{% endif %}
<div class="project-detail-meta project-hero-chips">
<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 read_only %}<span class="badge badge-type">Read-only</span>{% endif %}
</div>
<div class="project-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div>
{% 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>
</div>
{% if project.get('created_at') %}
<span class="meta-muted">&#x1F331; Started {{ dt_ago(project['created_at']) }}</span>
{% endif %}
</div>
</div>
{% if project.get('website_url') %}
<div class="project-hero-ctas">
<a href="{{ project['website_url'] }}" target="_blank" rel="noopener nofollow" class="btn btn-primary project-website-btn"><span class="icon">&#x1F310;</span> Visit Website</a>
</div>
{% 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>
</div>
</div>
<div class="project-hero-body">
{% if project.get('release_date') or project.get('demo_date') or forked_from %}
<div class="project-detail-meta">
{% if project.get('release_date') %}
<span>&#x1F4C5; Released: {{ format_date(project['release_date']) }}</span>
{% endif %}
{% if project.get('demo_date') %}
<span>&#x1F3AD; Demo: {{ format_date(project['demo_date']) }}</span>
{% endif %}
{% if forked_from %}
<span>&#x2442; Forked from <a href="/projects/{{ forked_from['slug'] }}">{{ render_title(forked_from['title']) }}</a></span>
{% endif %}
</div>
{% endif %}
<div class="project-detail-actions">
<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 %}
<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 %}
<button type="button" class="project-star-btn" data-share="{{ project_url }}"><span class="icon">&#x1F517;</span><span class="label"> Share</span></button>
{% 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>
{% endif %}
{% 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>
<form method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/private">
<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>
</form>
<form method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/readonly">
<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>
</form>
{% endif %}
{% if is_owner or is_admin(user) %}
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}">
<button type="submit" data-confirm-danger data-confirm="Delete this project?" data-menu-action data-menu-icon="&#x1F5D1;&#xFE0F;" data-menu-label="Delete">Delete</button>
</form>
{% 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 %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _owner = project['user_uid'] %}{% set _owner_name = (author or {}).get('username', '') %}{% set _class = "project-star-btn" %}{% include "_report_button.html" %}
<button type="button" class="project-star-btn project-actions-more" aria-haspopup="menu" aria-expanded="false" aria-label="More actions"><span class="icon">&#x22EF;</span><span class="label"> More</span></button>
<div class="project-actions-overflow" hidden>
{% if viewer_can_workspace %}
<a href="{{ project_url }}/workspace" data-menu-action data-menu-icon="&#x1F4BB;" data-menu-label="Workspace">Workspace</a>
{% endif %}
{% if viewer_can_containers %}
<a href="{{ project_url }}/containers" data-menu-action data-menu-icon="&#x1F5A5;&#xFE0F;" data-menu-label="Containers">Containers</a>
{% endif %}
<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 %}
<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 %}
{% 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="add-screenshots-modal" data-menu-action data-menu-icon="&#x1F5BC;&#xFE0F;" data-menu-label="Add screenshots">Add screenshots</button>
<form method="POST" action="{{ project_url }}/private">
<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>
</form>
<form method="POST" action="{{ project_url }}/readonly">
<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>
</form>
{% endif %}
{% if is_owner or is_admin(user) %}
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}">
<button type="submit" data-confirm-danger data-confirm="Delete this project?" data-menu-action data-menu-icon="&#x1F5D1;&#xFE0F;" data-menu-label="Delete">Delete</button>
</form>
{% endif %}
</div>
</div>
</div>
</article>
<section class="project-devlog">
<h3 class="project-section-label">Devlog</h3>
{% if devlog_posts %}
{% for item in devlog_posts %}
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
{% endfor %}
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
{% else %}
<p class="empty-state">No devlog posts yet.</p>
<nav class="project-tabs" aria-label="Project sections">
<a href="#about" class="project-tab active">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 %}
</section>
<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">
<div class="project-devlog-header">
<h2 class="project-section-label">Devlog</h2>
<span class="project-devlog-count">{{ devlog_count }} update{{ '' if devlog_count == 1 else 's' }}</span>
{% if is_owner %}
<button type="button" class="btn btn-primary btn-sm project-devlog-post-btn" data-modal="create-post-modal"><span class="icon">&#x270D;&#xFE0F;</span> Post update</button>
{% endif %}
</div>
{% if devlog_posts %}
{% for item in devlog_posts %}
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
{% endfor %}
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
{% else %}
<p class="empty-state">No devlog posts yet.{% if is_owner %} Share your first update to give this project a public build log.{% endif %}</p>
{% endif %}
</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 %}
{% if project.get('repo_url') %}
<li><a href="{{ project['repo_url'] }}" target="_blank" rel="noopener nofollow">&#x1F4BB; Repository</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>
</div>
{% if is_owner %}
{% call modal('create-post-modal', 'Post an update') %}
{% set _composer_topic = 'devlog' %}{% set _composer_project = project['uid'] %}{% include "_post_composer_form.html" %}
{% endcall %}
{% call modal('add-screenshots-modal', 'Add screenshots') %}
<form method="POST" action="{{ project_url }}/screenshots">
<div class="auth-field auth-field-gap">
<label>Upload images</label>
{% include "_attachment_form.html" %}
<small class="hint-text">Images appear in the Screenshots gallery; other files list under About.</small>
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F5BC;&#xFE0F;</span>Add screenshots</button>
</div>
</form>
{% endcall %}
{% call modal('edit-project-modal', 'Edit Project') %}
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}">
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}" enctype="multipart/form-data">
<div class="auth-field auth-field-gap">
<label for="edit-project-title">Title</label>
<input type="text" id="edit-project-title" name="title" required maxlength="200" value="{{ project['title'] }}">
@ -149,6 +272,28 @@
</div>
</div>
<div class="grid-2col">
<div class="auth-field">
<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">
<label for="edit-project-repo_url">Repository (optional)</label>
<input type="url" id="edit-project-repo_url" name="repo_url" maxlength="500" placeholder="https://github.com/me/project" value="{{ project.get('repo_url', '') or '' }}">
</div>
</div>
<div class="grid-2col">
<div class="auth-field">
<label for="edit-project-cover_image">Cover image (optional{% if project.get('cover_image') %}, replaces current{% endif %})</label>
<input type="file" id="edit-project-cover_image" name="cover_image" accept="image/*">
</div>
<div class="auth-field">
<label for="edit-project-logo_image">Project logo (optional{% if project.get('logo_image') %}, replaces current{% endif %})</label>
<input type="file" id="edit-project-logo_image" name="logo_image" accept="image/*">
</div>
</div>
<div class="auth-field auth-field-gap">
<label>Type</label>
<div class="flex-wrap-gap" role="group" aria-label="Type">
@ -192,10 +337,6 @@
</form>
{% endcall %}
{% endif %}
{% with target_uid=project['uid'], target_type="project" %}
{% include "_comment_section.html" %}
{% endwith %}
</div>
{% endblock %}
{% block extra_js %}
@ -207,6 +348,3 @@ if (actions) {
}
</script>
{% endblock %}

View File

@ -104,7 +104,7 @@
<button type="button" class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal" aria-label="Add Project">+</button>
{% call modal('create-project-modal', 'Create Project') %}
<form method="POST" action="/projects/create">
<form method="POST" action="/projects/create" enctype="multipart/form-data">
<div class="auth-field auth-field-gap">
<label for="title">Title</label>
<input type="text" id="title" name="title" required maxlength="200" placeholder="Project name">
@ -126,6 +126,28 @@
</div>
</div>
<div class="grid-2col">
<div class="auth-field">
<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">
<label for="repo_url">Repository (optional)</label>
<input type="url" id="repo_url" name="repo_url" maxlength="500" placeholder="https://github.com/me/project">
</div>
</div>
<div class="grid-2col">
<div class="auth-field">
<label for="cover_image">Cover image (optional)</label>
<input type="file" id="cover_image" name="cover_image" accept="image/*">
</div>
<div class="auth-field">
<label for="logo_image">Project logo (optional)</label>
<input type="file" id="logo_image" name="logo_image" accept="image/*">
</div>
</div>
<div class="auth-field auth-field-gap">
<label>Type</label>
<div class="flex-wrap-gap" role="group" aria-label="Type">

View File

@ -421,6 +421,7 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `project.fork.request` | `routers/projects/index.py` |
| `project.readonly.disable` | `routers/projects/index.py` |
| `project.readonly.enable` | `routers/projects/index.py` |
| `project.screenshots.add` | `routers/projects/index.py` |
| `project.visibility.private` | `routers/projects/index.py` |
| `project.visibility.public` | `routers/projects/index.py` |
| `project.zip.request` | `routers/projects/index.py` |

View File

@ -271,3 +271,189 @@ def test_devlog_works_for_guest_visitor(app_server):
assert "devlog_posts" in body
assert body["devlog_posts"] == []
def test_devlog_count_in_json(app_server):
"""devlog_count reflects every linked post, beyond the rendered page."""
session, name = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
user = _db_user(name)
for i in range(3):
_create_post_direct(project["uid"], user["uid"], i)
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
assert r.status_code == 200, r.text[:300]
body = r.json()
assert body["devlog_count"] == 3
assert body["comment_count"] == 0
def test_project_page_emits_typed_json_ld(app_server):
"""A game project renders VideoGame JSON-LD; the devlog renders a Blog graph."""
session, name = _member()
title = _unique("dlseo")
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": title,
"description": "SEO schema test project",
"project_type": "game",
"status": "In Development",
"platforms": "PC,Web",
},
)
assert r.status_code == 200, r.text[:300]
project = r.json()["data"]
slug = project["slug"] or project["uid"]
user = _db_user(name)
marker = _unique("dlseopost")
_create_post_direct(project["uid"], user["uid"], 0, marker=marker)
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert '"VideoGame"' in html, "Expected VideoGame JSON-LD for a game project"
assert '"Blog"' in html, "Expected a Blog node for the devlog"
assert '"BlogPosting"' in html, "Expected BlogPosting entries for devlog posts"
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_owner_uploaded_cover_and_logo_render_in_hero(app_server):
"""Multipart cover_image/logo_image uploads land on the row and in the hero."""
import io
from PIL import Image
session, _ = _member()
def png(color):
buf = io.BytesIO()
Image.new("RGB", (12, 6), color).save(buf, "PNG")
return buf.getvalue()
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": _unique("dlhero"),
"description": "Hero art test project",
"project_type": "game",
"status": "In Development",
"platforms": "PC",
},
files={
"cover_image": ("cover.png", png((10, 20, 90)), "image/png"),
"logo_image": ("logo.png", png((90, 20, 10)), "image/png"),
},
)
assert r.status_code == 200, r.text[:300]
slug = r.json()["data"]["slug"]
refresh_snapshot()
row = get_table("projects").find_one(slug=slug)
assert row["cover_image"], "cover_image filename expected on the row"
assert row["logo_image"], "logo_image filename expected on the row"
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert f"/static/uploads/{row['cover_image']}" in html
assert f"/static/uploads/{row['logo_image']}" in html
assert 'class="project-logo"' in html
assert '"thumbnailUrl"' in html, "Expected the logo as schema thumbnailUrl"
def test_owner_adds_screenshots_from_the_page(app_server):
"""POST /projects/{slug}/screenshots links uploaded images into the gallery; non-owners are refused."""
import io
from PIL import Image
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert "project-screenshot-grid" not in html
buf = io.BytesIO()
Image.new("RGB", (10, 10), (5, 120, 60)).save(buf, "PNG")
r = session.post(
f"{BASE_URL}/uploads/upload",
files={"file": ("gallery.png", buf.getvalue(), "image/png")},
)
assert r.status_code == 201, r.text[:300]
uid = r.json()["uid"]
r = session.post(
f"{BASE_URL}/projects/{slug}/screenshots",
headers=JSON,
data={"attachment_uids": uid},
)
assert r.status_code == 200, r.text[:300]
assert r.json()["data"]["linked"] == 1
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert "project-screenshot-grid" in html, "Expected the gallery after linking"
intruder, _ = _member()
r = intruder.post(
f"{BASE_URL}/projects/{slug}/screenshots",
headers=JSON,
data={"attachment_uids": uid},
)
assert r.status_code == 403, r.text[:300]
def test_project_page_json_ld_rating_from_stars(app_server):
"""Stars surface as an aggregateRating; zero stars emit none."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert '"aggregateRating"' not in html, "No rating expected without stars"
r = session.post(
f"{BASE_URL}/votes/project/{project['uid']}",
data={"value": "1"},
headers={"X-Requested-With": "fetch"},
)
assert r.status_code == 200, r.text[:300]
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert '"aggregateRating"' in html, "Expected aggregateRating once starred"

View File

@ -105,6 +105,44 @@ def test_owner_can_edit_project(app_server):
assert row["platforms"] == "Linux,Web"
def test_owner_can_set_and_clear_website_url(app_server):
_, _, key = _signup_project_visibility()
slug = _create_project_project_visibility(key, "Website Via Api")["slug"]
r = requests.post(
f"{BASE_URL}/projects/edit/{slug}",
headers=_h_project_visibility(key),
data={
"title": "Website Via Api",
"description": "has a website now",
"website_url": "myproject.dev/docs",
"repo_url": "github.com/me/website-via-api",
},
allow_redirects=False,
)
assert r.status_code == 200 and r.json()["ok"] is True
row = get_table("projects").find_one(slug=slug)
assert row["website_url"] == "https://myproject.dev/docs"
assert row["repo_url"] == "https://github.com/me/website-via-api"
html = requests.get(f"{BASE_URL}/projects/{slug}").text
assert "Visit Website" in html
assert '"sameAs"' in html
r = requests.post(
f"{BASE_URL}/projects/edit/{slug}",
headers=_h_project_visibility(key),
data={
"title": "Website Via Api",
"description": "website removed",
"website_url": "",
},
allow_redirects=False,
)
assert r.status_code == 200
assert get_table("projects").find_one(slug=slug)["website_url"] is None
assert "Visit Website" not in requests.get(f"{BASE_URL}/projects/{slug}").text
def test_non_owner_cannot_edit_project(app_server):
_, _, owner_key = _signup_project_visibility()
slug = _create_project_project_visibility(owner_key, "Owner Edit Guard")["slug"]

View File

@ -119,7 +119,7 @@ def _alice_key():
def test_files_link_on_detail(alice):
page, _ = alice
_make_project_ui(page, "UI Files Link")
link = page.locator("a:has-text('Files')")
link = page.locator(".project-detail-actions a:has-text('Files')")
expect(link).to_be_visible()
link.click()
page.wait_for_url("**/files", wait_until="domcontentloaded")

View File

@ -85,7 +85,7 @@ def test_devlog_empty_state_on_project_page(alice):
devlog_section = page.locator(".project-devlog")
expect(devlog_section).to_be_visible()
expect(devlog_section.locator("h3:has-text('Devlog')")).to_be_visible()
expect(devlog_section.locator("h2:has-text('Devlog')")).to_be_visible()
expect(page.locator(".empty-state:has-text('No devlog posts yet.')")).to_be_visible()
@ -187,3 +187,43 @@ def test_devlog_multiple_posts_order(alice):
f"Expected newest post first: {markers[-1]}, got: {first_text}"
)
def test_project_stats_strip_visible(alice):
"""The overview stats strip shows stars, updates, comments, files, forks."""
page, _ = alice
slug, project_uid, owner_uid = _seed_project()
_seed_project_post(project_uid, owner_uid, 0)
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
stats = page.locator(".project-stats")
expect(stats).to_be_visible()
expect(stats.locator(".project-stat")).to_have_count(5)
expect(stats.locator(".project-stat:has-text('update')")).to_contain_text("1")
def test_owner_post_update_button_opens_preset_composer(alice):
"""The owner's Post update button opens the composer preselected to this project."""
page, _ = alice
_create_project_ui(page, f"Composer Project {uuid4().hex[:6]}")
button = page.locator(".project-devlog-post-btn")
expect(button).to_be_visible()
button.click()
modal = page.locator("#create-post-modal")
expect(modal).to_be_visible()
expect(modal.locator("input[name='topic'][value='devlog']")).to_be_checked()
selected = modal.locator("#project_uid").input_value()
assert selected != "", "Expected the project preselected in the composer"
def test_guest_sees_no_post_update_button(app_server):
"""Guests and non-owners get no Post update control."""
import requests
slug, _, _ = _seed_project()
r = requests.get(f"{BASE_URL}/projects/{slug}")
assert r.status_code == 200
assert "project-devlog-post-btn" not in r.text

View File

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

View File

@ -47,6 +47,24 @@ def test_isslop_run_form_normalizes_typos_and_bare_domains():
assert IsslopRunForm(url="http:/x.dev/a").url == "http://x.dev/a"
def test_project_form_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 ProjectForm(**base, repo_url="github.com/me/x").repo_url == "https://github.com/me/x"
assert normalize_website_url(" ") == ""
with pytest.raises(ValidationError):
ProjectForm(**base, website_url="javascript:alert(1)")
with pytest.raises(ValidationError):
ProjectForm(**base, website_url="not a url")
def test_reaction_form_accepts_any_single_emoji():
from devplacepy.models import ReactionForm

View File

@ -49,6 +49,99 @@ def test_news_article_publisher_is_organization():
assert schema["publisher"]["@type"] == "Organization"
def test_project_schema_type_mapping():
assert seo.project_schema_type("game") == "VideoGame"
assert seo.project_schema_type("game_asset") == "CreativeWork"
assert seo.project_schema_type("website") == "WebApplication"
assert seo.project_schema_type("software") == "SoftwareApplication"
assert seo.project_schema_type("mobile_app") == "SoftwareApplication"
assert seo.project_schema_type(None) == "SoftwareApplication"
assert seo.project_schema_type("unknown") == "SoftwareApplication"
def test_software_application_schema_is_type_aware():
game = seo.software_application_schema(
{"uid": "p1", "slug": "p1-g", "title": "G", "project_type": "game", "platforms": "PC, Web"},
"https://x.test",
)
assert game["@type"] == "VideoGame"
assert game["gamePlatform"] == ["PC", "Web"]
assert "applicationCategory" not in game
web = seo.software_application_schema(
{"uid": "p2", "slug": "p2-w", "title": "W", "project_type": "website"},
"https://x.test",
)
assert web["@type"] == "WebApplication"
assert web["browserRequirements"] == "Requires JavaScript"
assert web["applicationCategory"] == "DeveloperApplication"
def test_software_application_schema_rating_and_image():
project = {"uid": "p1", "slug": "p1-s", "title": "S", "project_type": "software"}
plain = seo.software_application_schema(project, "https://x.test")
assert "aggregateRating" not in plain
assert "image" not in plain
rich = seo.software_application_schema(
project,
"https://x.test",
image_url="https://x.test/img.png",
star_count=7,
comment_count=3,
)
assert rich["aggregateRating"]["ratingCount"] == "7"
assert rich["image"] == "https://x.test/img.png"
assert rich["interactionStatistic"]["userInteractionCount"] == 3
def test_software_application_schema_website_and_screenshots():
project = {
"uid": "p1",
"slug": "p1-s",
"title": "S",
"project_type": "software",
"website_url": "https://myproject.dev",
}
schema = seo.software_application_schema(
project,
"https://x.test",
screenshot_urls=["https://x.test/a.png", "https://x.test/b.png"],
)
assert schema["sameAs"] == ["https://myproject.dev"]
assert schema["screenshot"] == ["https://x.test/a.png", "https://x.test/b.png"]
bare = seo.software_application_schema(
{"uid": "p2", "slug": "p2-s", "title": "B"}, "https://x.test"
)
assert "sameAs" not in bare
assert "screenshot" not in bare
def test_project_devlog_schema_builds_blog_graph():
project = {"uid": "p1", "slug": "p1-s", "title": "Nebula"}
assert seo.project_devlog_schema(project, [], "https://x.test") is None
posts = [
{
"post": {
"uid": "a1",
"slug": "a1-first",
"title": "First update",
"content": "Progress!",
"created_at": "2026-01-01T00:00:00+00:00",
},
"author": {"username": "dev"},
"comment_count": 2,
}
]
schema = seo.project_devlog_schema(project, posts, "https://x.test")
assert schema["@type"] == "Blog"
assert schema["url"] == "https://x.test/projects/p1-s#devlog"
entry = schema["blogPost"][0]
assert entry["@type"] == "BlogPosting"
assert entry["headline"] == "First update"
assert entry["url"] == "https://x.test/posts/a1-first"
assert entry["author"]["name"] == "dev"
assert entry["commentCount"] == 2
def test_site_url_precedence(monkeypatch):
import devplacepy.database as database