Hero text shadow and an owner Add screenshots flow in the More menu
Some checks failed
DevPlace CI / test (pull_request) Has been cancelled
Some checks failed
DevPlace CI / test (pull_request) Has been cancelled
The hero overlay title, tagline and author row get a dark text shadow
so they stay readable over bright cover art. The owner's More menu
gains Add screenshots: a modal with the shared attachment uploader
whose POST /projects/{slug}/screenshots (ProjectScreenshotsForm,
owner-only, audit project.screenshots.add, catalogued in events.md)
links the uploaded uids through the same attachments.link_attachments
choke point as create/edit and returns to #screenshots. Documented as
projects-screenshots in the API docs, exposed to Devii as
project_add_screenshots (upload via upload_file/attach_url first), and
covered by an api test for the owner flow, the gallery render, and the
non-owner 403.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
530ae2b5bb
commit
8f9f461244
@ -68,7 +68,7 @@ devplacepy/
|
||||
| `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read |
|
||||
| `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion |
|
||||
| `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility |
|
||||
| `/projects/{slug}` | 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, 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}` | 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 |
|
||||
|
||||
@ -384,6 +384,33 @@ four ways to sign requests.
|
||||
),
|
||||
],
|
||||
),
|
||||
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(
|
||||
id="projects-create",
|
||||
method="POST",
|
||||
|
||||
@ -318,6 +318,10 @@ class ProjectFlagForm(BaseModel):
|
||||
value: bool = False
|
||||
|
||||
|
||||
class ProjectScreenshotsForm(BaseModel):
|
||||
attachment_uids: list[str] = []
|
||||
|
||||
|
||||
class CustomizationToggleForm(BaseModel):
|
||||
value: bool = False
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ 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), 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']`).
|
||||
**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`.
|
||||
|
||||
|
||||
@ -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, save_inline_image
|
||||
from devplacepy.attachments import (
|
||||
get_attachments_batch,
|
||||
link_attachments,
|
||||
save_inline_image,
|
||||
)
|
||||
from devplacepy.database import (
|
||||
get_table,
|
||||
get_users_by_uids,
|
||||
@ -512,6 +522,40 @@ async def edit_project(
|
||||
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",
|
||||
|
||||
@ -73,6 +73,19 @@ PROJECTS_ACTIONS: tuple[Action, ...] = (
|
||||
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(
|
||||
name="delete_project",
|
||||
method="POST",
|
||||
|
||||
@ -321,6 +321,12 @@
|
||||
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;
|
||||
|
||||
@ -104,6 +104,7 @@
|
||||
{% endif %}
|
||||
{% if is_owner %}
|
||||
<button type="button" data-modal="edit-project-modal" data-menu-action data-menu-icon="✏️" data-menu-label="Edit">Edit</button>
|
||||
<button type="button" data-modal="add-screenshots-modal" data-menu-action data-menu-icon="🖼️" data-menu-label="Add screenshots">Add screenshots</button>
|
||||
<form method="POST" action="{{ project_url }}/private">
|
||||
<input type="hidden" name="value" value="{{ 0 if is_private else 1 }}">
|
||||
<button type="submit" data-confirm-danger data-confirm="{% if is_private %}Make this project public? Everyone will be able to see the project and all its files.{% else %}Make this project private? Only you and administrators will be able to see it.{% endif %}" data-menu-action data-menu-icon="{% if is_private %}🔓{% else %}🔒{% endif %}" data-menu-label="{% if is_private %}Make public{% else %}Make private{% endif %}">{% if is_private %}Make public{% else %}Make private{% endif %}</button>
|
||||
@ -234,6 +235,20 @@
|
||||
{% set _composer_topic = 'devlog' %}{% set _composer_project = project['uid'] %}{% include "_post_composer_form.html" %}
|
||||
{% endcall %}
|
||||
|
||||
{% call modal('add-screenshots-modal', 'Add screenshots') %}
|
||||
<form method="POST" action="{{ project_url }}/screenshots">
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label>Upload images</label>
|
||||
{% include "_attachment_form.html" %}
|
||||
<small class="hint-text">Images appear in the Screenshots gallery; other files list under About.</small>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary"><span class="icon">🖼️</span>Add screenshots</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endcall %}
|
||||
|
||||
{% call modal('edit-project-modal', 'Edit Project') %}
|
||||
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}" enctype="multipart/form-data">
|
||||
<div class="auth-field auth-field-gap">
|
||||
|
||||
@ -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` |
|
||||
|
||||
@ -397,6 +397,47 @@ def test_owner_uploaded_cover_and_logo_render_in_hero(app_server):
|
||||
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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user