diff --git a/README.md b/README.md index e4e9dfb5..4e08120d 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/devplacepy/content.py b/devplacepy/content.py index 25fc306c..597f9e65 100644 --- a/devplacepy/content.py +++ b/devplacepy/content.py @@ -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]: diff --git a/devplacepy/docs_api/groups/content.py b/devplacepy/docs_api/groups/content.py index fac43470..5b1f2c8a 100644 --- a/devplacepy/docs_api/groups/content.py +++ b/devplacepy/docs_api/groups/content.py @@ -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( diff --git a/devplacepy/models.py b/devplacepy/models.py index dec1bdf1..e167421a 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -5,7 +5,7 @@ import re from datetime import datetime from typing import Literal, Optional -from urllib.parse import urlsplit +from urllib.parse import urlsplit, urlparse from pydantic import BaseModel, Field, field_validator, model_validator from devplacepy.constants import TOPICS from devplacepy.rendering import is_single_emoji @@ -33,6 +33,20 @@ def normalize_european_date(value): raise ValueError("Date must be in DD/MM/YYYY format") +def normalize_website_url(value): + if not value: + return "" + text = str(value).strip() + if not text: + return "" + if not text.lower().startswith(("http://", "https://")): + text = f"https://{text}" + parsed = urlparse(text) + if parsed.scheme not in ("http", "https") or not parsed.hostname or "." not in parsed.hostname: + raise ValueError("Website must be a valid http(s) URL") + return text + + def normalize_poll_options(value): if value is None: return [] @@ -241,6 +255,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 diff --git a/devplacepy/routers/CLAUDE.md b/devplacepy/routers/CLAUDE.md index 344f29ca..44e7f2ff 100644 --- a/devplacepy/routers/CLAUDE.md +++ b/devplacepy/routers/CLAUDE.md @@ -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 diff --git a/devplacepy/routers/projects/CLAUDE.md b/devplacepy/routers/projects/CLAUDE.md index 4e30f085..71302e3c 100644 --- a/devplacepy/routers/projects/CLAUDE.md +++ b/devplacepy/routers/projects/CLAUDE.md @@ -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`). diff --git a/devplacepy/routers/projects/index.py b/devplacepy/routers/projects/index.py index 19197926..1c23312a 100644 --- a/devplacepy/routers/projects/index.py +++ b/devplacepy/routers/projects/index.py @@ -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", diff --git a/devplacepy/schemas/content.py b/devplacepy/schemas/content.py index 8f8c79a1..9d8f9793 100644 --- a/devplacepy/schemas/content.py +++ b/devplacepy/schemas/content.py @@ -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 diff --git a/devplacepy/schemas/listings.py b/devplacepy/schemas/listings.py index a795368a..f6ab5b57 100644 --- a/devplacepy/schemas/listings.py +++ b/devplacepy/schemas/listings.py @@ -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): diff --git a/devplacepy/seo.py b/devplacepy/seo.py index 5c4f32d1..d7b3d089 100644 --- a/devplacepy/seo.py +++ b/devplacepy/seo.py @@ -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", ) diff --git a/devplacepy/services/devii/actions/catalog/projects.py b/devplacepy/services/devii/actions/catalog/projects.py index b6f0aae6..64d22269 100644 --- a/devplacepy/services/devii/actions/catalog/projects.py +++ b/devplacepy/services/devii/actions/catalog/projects.py @@ -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( diff --git a/devplacepy/static/css/projects.css b/devplacepy/static/css/projects.css index f6204e54..d561649f 100644 --- a/devplacepy/static/css/projects.css +++ b/devplacepy/static/css/projects.css @@ -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; diff --git a/devplacepy/templates/CLAUDE.md b/devplacepy/templates/CLAUDE.md index c9cd74e1..d9e5ab31 100644 --- a/devplacepy/templates/CLAUDE.md +++ b/devplacepy/templates/CLAUDE.md @@ -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`. diff --git a/devplacepy/templates/_post_composer_form.html b/devplacepy/templates/_post_composer_form.html new file mode 100644 index 00000000..2f1a39ce --- /dev/null +++ b/devplacepy/templates/_post_composer_form.html @@ -0,0 +1,49 @@ +{# Shared create-post form. Locals: _composer_topic (preselected topic), _composer_project (preselected project uid or ""). #} +
+ {% set _topics = TOPICS %}{% set _selected = _composer_topic or 'random' %}{% include "_topic_selector.html" %} + +
+ + + 0/125000 +
+ +
+ + + 0/500 +
+ +
+ + +
+ +
+ + {% include "_attachment_form.html" %} +
+ +
+ +
+ + + +
diff --git a/devplacepy/templates/feed.html b/devplacepy/templates/feed.html index 47cb5016..608bd9fe 100644 --- a/devplacepy/templates/feed.html +++ b/devplacepy/templates/feed.html @@ -160,54 +160,7 @@ + {% call modal('create-post-modal', 'Create New Post') %} -
- {% set _topics = TOPICS %}{% set _selected = 'random' %}{% include "_topic_selector.html" %} - -
- - - 0/125000 -
- -
- - - 0/500 -
- -
- - -
- -
- - {% include "_attachment_form.html" %} -
- -
- -
- - - -
+ {% set _composer_topic = 'random' %}{% set _composer_project = '' %}{% include "_post_composer_form.html" %} {% endcall %} {% else %} + diff --git a/devplacepy/templates/project_detail.html b/devplacepy/templates/project_detail.html index 17767462..5e34752b 100644 --- a/devplacepy/templates/project_detail.html +++ b/devplacepy/templates/project_detail.html @@ -3,131 +3,254 @@ {% block extra_head %} + {% endblock %} {% block content %} -
+{% set project_url = "/projects/" ~ (project['slug'] or project['uid']) %} +{% set image_attachments = attachments | selectattr('is_image') | list %} +{% set other_attachments = attachments | rejectattr('is_image') | list %} +{% 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] %} +
← Back to Projects -
-
-

{{ render_title(project['title'], author_is_admin=is_admin(author)) }}

-
- ● {{ project.get('status', 'In Development') }} -
-
- - {% if is_private or read_only %} -
- {% if is_private %}Private{% endif %} - {% if read_only %}Read-only{% endif %} -
- {% endif %} - -
- {{ project.get('project_type', 'software').replace('_', ' ') }} - {% if project.get('release_date') %} - 📅 Released: {{ format_date(project['release_date']) }} +
+
+
+ {% if cover_src %} + {{ project['title'] }} cover image {% endif %} - {% if project.get('demo_date') %} - 🎭 Demo: {{ format_date(project['demo_date']) }} - {% endif %} -
- - {% if forked_from %} - - {% endif %} - -
- {% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %} -
- {% set _user = author %}{% set _class = none %}{% include "_user_link.html" %} - · Level {{ author.get('level', 1) if author else 1 }} -
-
- - {% if maturity_hidden(maturity, user) %} - {% set _level = maturity %}{% include "_maturity_gate.html" %} - {% else %} -
{{ render_content(project.get('description', ''), author_is_admin=is_admin(author)) }}
- {% endif %} - - {% if attachments %} - {% include "_attachment_display.html" %} - {% endif %} - - {% if platforms %} -
- -
- {% for plat in platforms %} - {{ plat.strip() }} - {% endfor %} -
-
- {% endif %} - -
- 📁 Files ({{ file_count }} files) - {% if workspace_editor_url %} - 💻 VS Code - {% endif %} - - {% 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" %} - - - +
+ {% if project.get('release_date') or project.get('demo_date') or forked_from %} +
+ {% if project.get('release_date') %} + 📅 Released: {{ format_date(project['release_date']) }} + {% endif %} + {% if project.get('demo_date') %} + 🎭 Demo: {{ format_date(project['demo_date']) }} + {% endif %} + {% if forked_from %} + ⑂ Forked from {{ render_title(forked_from['title']) }} + {% endif %} +
+ {% endif %} + +
+ 📁 Files ({{ file_count }} files) + {% if workspace_editor_url %} + 💻 VS Code + {% endif %} + {% if user %} - - {% endif %} - {% if is_owner %} - -
- - -
-
- - -
- {% endif %} - {% if is_owner or is_admin(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" %} + + +
-
- - {% 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 %} -

No devlog posts yet.

+
+ Comments {{ comment_count }} + Files {{ file_count }} + + +
+
+
+ + {% if maturity_hidden(maturity, user) %} + {% set _level = maturity %}{% include "_maturity_gate.html" %} + {% else %} +
{{ render_content(project.get('description', ''), author_is_admin=is_admin(author)) }}
+ {% endif %} + {% if other_attachments %} + {% set attachments = other_attachments %} + {% include "_attachment_display.html" %} + {% endif %} +
+ +
+
+ + {{ devlog_count }} update{{ '' if devlog_count == 1 else 's' }} + {% if is_owner %} + + {% endif %} +
+ {% 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 %} +

No devlog posts yet.{% if is_owner %} Share your first update to give this project a public build log.{% endif %}

+ {% endif %} +
+ + {% if image_attachments %} +
+ +
+ {% for shot in image_attachments %} + {{ project['title'] }} screenshot {{ loop.index }} + {% endfor %} +
+
+ {% endif %} + +
+ {% with target_uid=project['uid'], target_type="project" %} + {% include "_comment_section.html" %} + {% endwith %} +
+
+ + +
+
{% 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') %} +
+
+ + {% include "_attachment_form.html" %} + Images appear in the Screenshots gallery; other files list under About. +
+ +
+ {% endcall %} + {% call modal('edit-project-modal', 'Edit Project') %} -
+
@@ -149,6 +272,28 @@
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
@@ -192,10 +337,6 @@ {% endcall %} {% endif %} - - {% with target_uid=project['uid'], target_type="project" %} - {% include "_comment_section.html" %} - {% endwith %}
{% endblock %} {% block extra_js %} @@ -207,6 +348,3 @@ if (actions) { } {% endblock %} - - - diff --git a/devplacepy/templates/projects.html b/devplacepy/templates/projects.html index 966c8e06..0e67b2c2 100644 --- a/devplacepy/templates/projects.html +++ b/devplacepy/templates/projects.html @@ -104,7 +104,7 @@ {% call modal('create-project-modal', 'Create Project') %} -
+
@@ -126,6 +126,28 @@
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
diff --git a/events.md b/events.md index 46a48014..b1e75750 100644 --- a/events.md +++ b/events.md @@ -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` | diff --git a/tests/api/projects/devlog.py b/tests/api/projects/devlog.py index 297f2397..d3dbbe28 100644 --- a/tests/api/projects/devlog.py +++ b/tests/api/projects/devlog.py @@ -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" + diff --git a/tests/api/projects/edit.py b/tests/api/projects/edit.py index 43144e49..cfccdba0 100644 --- a/tests/api/projects/edit.py +++ b/tests/api/projects/edit.py @@ -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"] diff --git a/tests/e2e/project/files.py b/tests/e2e/project/files.py index b84fe66d..a63f0a86 100644 --- a/tests/e2e/project/files.py +++ b/tests/e2e/project/files.py @@ -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") diff --git a/tests/e2e/projects/devlog.py b/tests/e2e/projects/devlog.py index 08850f5b..b9c5319c 100644 --- a/tests/e2e/projects/devlog.py +++ b/tests/e2e/projects/devlog.py @@ -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 + diff --git a/tests/e2e/projects/index.py b/tests/e2e/projects/index.py index 0b0a2763..40292d61 100644 --- a/tests/e2e/projects/index.py +++ b/tests/e2e/projects/index.py @@ -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() diff --git a/tests/unit/models.py b/tests/unit/models.py index dc21345e..11788e60 100644 --- a/tests/unit/models.py +++ b/tests/unit/models.py @@ -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 diff --git a/tests/unit/seo.py b/tests/unit/seo.py index aaeeb277..84a64a9c 100644 --- a/tests/unit/seo.py +++ b/tests/unit/seo.py @@ -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