diff --git a/README.md b/README.md index e4e9dfb5..be3ad81f 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}` | SEO-optimized project overview and devlog: hero with status/type/dates, stats strip (stars, updates, comments, files, forks), About and Platforms sections, and a **Devlog** timeline of every post linked to the project. The owner posts updates straight from the page (composer preset to the project with the `devlog` topic). Emits type-aware JSON-LD (VideoGame / WebApplication / SoftwareApplication / CreativeWork with rating, keywords and image) plus a `Blog`/`BlogPosting` graph for the devlog; the sitemap's `lastmod` follows the newest devlog post | | `/projects/{slug}/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..bf6019bc 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,15 @@ 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( 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..99ad25b6 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 professional project overview: hero (h1 title, status, type badge, dates, forked-from), author row, a `.project-stats` strip (stars / updates / comments / files / forks, the anchors jump to `#devlog` and `#comments`), an **About** section, platforms, the action row, then the **Devlog** section (`#devlog`, `h2`) listing every post whose `project_uid` points at the project via `_post_card.html` (the template loads `feed.css` for the card styles alongside `post.css` - same rule as `news.html`). `devlog_count` (`content.count_project_devlog`) and `comment_count` ride the context and `ProjectDetailOut`. The owner gets a **Post update** button (`.project-devlog-post-btn`) opening the shared create-post composer preset to `topic=devlog` + this project - the composer form lives ONCE in `templates/_post_composer_form.html` (locals `_composer_topic`, `_composer_project`) and is included by both `feed.html` and `project_detail.html`; never fork a second copy of that form. + +**JSON-LD** (`seo.py`): `software_application_schema(project, base, image_url=, star_count=, comment_count=)` is type-aware via `project_schema_type` (`game` -> `VideoGame` with `gamePlatform`, `website` -> `WebApplication`, `software`/`mobile_app` -> `SoftwareApplication`, `game_asset` -> `CreativeWork`), adds `keywords` (type + platforms), `image`, an `aggregateRating` when the project has stars, and a comment `InteractionCounter`. `project_devlog_schema(project, devlog_posts, base)` emits a `Blog` node (`@id` = `{project_url}#devlog`) with one `BlogPosting` per rendered devlog entry; it returns `None` for an empty devlog (dropped by `combine`). The devlog cursor rides `base_seo_context(next_url=next_page_url(request, devlog_next_cursor))` for a crawlable `rel=next`, `keywords` carries title/type/platforms, and the sitemap's project `lastmod` is `max(created_at, updated_at, newest devlog post)` via one grouped posts query in `_build_sitemap`. The `before` devlog cursor is documented on `projects-detail` in `docs_api/groups/content.py`. + **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..1a574941 100644 --- a/devplacepy/routers/projects/index.py +++ b/devplacepy/routers/projects/index.py @@ -41,6 +41,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 +53,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 +209,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 +226,67 @@ 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" + og_image = first_image_url(project, detail["attachments"]) + 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, + ), + 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 +309,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, 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..e151a970 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,99 @@ 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 +): + 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 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 +540,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 +555,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/static/css/projects.css b/devplacepy/static/css/projects.css index f6204e54..c46d3c98 100644 --- a/devplacepy/static/css/projects.css +++ b/devplacepy/static/css/projects.css @@ -308,10 +308,60 @@ margin-bottom: 1.5rem; } +.project-stats { + display: flex; + flex-wrap: wrap; + gap: var(--space-lg); + margin-bottom: 1rem; + padding: var(--space-md) var(--space-lg); + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius); + 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; +} + .project-devlog { 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 ""). #} +
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 _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..1470e794 100644 --- a/devplacepy/templates/project_detail.html +++ b/devplacepy/templates/project_detail.html @@ -3,6 +3,7 @@ {% block extra_head %} + {% endblock %} {% block content %}No devlog posts yet.
+No devlog posts yet.{% if is_owner %} Share your first update to give this project a public build log.{% endif %}
{% endif %}