Align the project hero with the showcase design: owner cover, logo, repo link
The hero now matches the showcase reference: the title block, tagline (first description line), type/platform chips, author row and started date render OVERLAID on the cover banner behind a bottom scrim, with the optional project logo as a framed tile beside them and the Visit Website CTA on the right. The section tab bar switches to the underline style with Overview active. Owners control the missing pieces from the create and edit modals, which are now multipart: cover_image and logo_image file uploads (stored as bare uploaded filenames via save_inline_image, the posts.image pattern; a new upload replaces the previous one) plus a repo_url sibling of website_url (same normalization/validation). The cover feeds og:image and the schema image, the logo becomes schema thumbnailUrl, and sameAs now carries website + repository. repo_url rides ProjectOut, the Devii create/edit actions and the API docs; tests cover repo normalization, the multipart upload round-trip and the hero render. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3e9475fb61
commit
8a2e0e6e70
@ -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 |
|
| `/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 |
|
| `/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` | 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 a cover banner (first image attachment), status/type/platform chips, an owner-set **Visit Website** link (`website_url`), 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, 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) |
|
| `/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` |
|
| `/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 |
|
| `/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 |
|
||||||
|
|||||||
@ -459,6 +459,14 @@ four ways to sign requests.
|
|||||||
"https://myproject.dev",
|
"https://myproject.dev",
|
||||||
"Optional official website URL (http/https).",
|
"Optional official website URL (http/https).",
|
||||||
),
|
),
|
||||||
|
field(
|
||||||
|
"repo_url",
|
||||||
|
"form",
|
||||||
|
"string",
|
||||||
|
False,
|
||||||
|
"https://github.com/me/project",
|
||||||
|
"Optional source repository URL (http/https).",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
endpoint(
|
endpoint(
|
||||||
@ -544,6 +552,14 @@ four ways to sign requests.
|
|||||||
"https://myproject.dev",
|
"https://myproject.dev",
|
||||||
"Optional official website URL (http/https).",
|
"Optional official website URL (http/https).",
|
||||||
),
|
),
|
||||||
|
field(
|
||||||
|
"repo_url",
|
||||||
|
"form",
|
||||||
|
"string",
|
||||||
|
False,
|
||||||
|
"https://github.com/me/project",
|
||||||
|
"Optional source repository URL (http/https).",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
endpoint(
|
endpoint(
|
||||||
|
|||||||
@ -256,6 +256,7 @@ class ProjectForm(BaseModel):
|
|||||||
platforms: str = Field(default="", max_length=500)
|
platforms: str = Field(default="", max_length=500)
|
||||||
status: str = Field(default="In Development", max_length=100)
|
status: str = Field(default="In Development", max_length=100)
|
||||||
website_url: str = Field(default="", max_length=500)
|
website_url: str = Field(default="", max_length=500)
|
||||||
|
repo_url: str = Field(default="", max_length=500)
|
||||||
is_private: bool = False
|
is_private: bool = False
|
||||||
attachment_uids: list[str] = []
|
attachment_uids: list[str] = []
|
||||||
|
|
||||||
@ -264,7 +265,7 @@ class ProjectForm(BaseModel):
|
|||||||
def normalize_dates(cls, value):
|
def normalize_dates(cls, value):
|
||||||
return normalize_european_date(value)
|
return normalize_european_date(value)
|
||||||
|
|
||||||
@field_validator("website_url")
|
@field_validator("website_url", "repo_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
def valid_website_url(cls, value):
|
def valid_website_url(cls, value):
|
||||||
return normalize_website_url(value)
|
return normalize_website_url(value)
|
||||||
@ -281,13 +282,14 @@ class ProjectEditForm(BaseModel):
|
|||||||
platforms: str = Field(default="", max_length=500)
|
platforms: str = Field(default="", max_length=500)
|
||||||
status: str = Field(default="In Development", max_length=100)
|
status: str = Field(default="In Development", max_length=100)
|
||||||
website_url: str = Field(default="", max_length=500)
|
website_url: str = Field(default="", max_length=500)
|
||||||
|
repo_url: str = Field(default="", max_length=500)
|
||||||
|
|
||||||
@field_validator("release_date", "demo_date", mode="before")
|
@field_validator("release_date", "demo_date", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def normalize_dates(cls, value):
|
def normalize_dates(cls, value):
|
||||||
return normalize_european_date(value)
|
return normalize_european_date(value)
|
||||||
|
|
||||||
@field_validator("website_url")
|
@field_validator("website_url", "repo_url")
|
||||||
@classmethod
|
@classmethod
|
||||||
def valid_website_url(cls, value):
|
def valid_website_url(cls, value):
|
||||||
return normalize_website_url(value)
|
return normalize_website_url(value)
|
||||||
|
|||||||
@ -9,9 +9,9 @@ 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`.
|
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 project's **first image attachment** (brand-gradient fallback band when there are none), the h1 title + status chip, type badge + platform chips + Private/Read-only badges, dates/forked-from meta, the author row with an owner-set **Visit Website** CTA (`projects.website_url`, optional, 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), 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` -> `sameAs`) 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`.
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from sqlalchemy import or_
|
|||||||
from fastapi import Depends, APIRouter, Request
|
from fastapi import Depends, APIRouter, Request
|
||||||
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
|
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||||
from devplacepy.attachments import get_attachments_batch
|
from devplacepy.attachments import get_attachments_batch, save_inline_image
|
||||||
from devplacepy.database import (
|
from devplacepy.database import (
|
||||||
get_table,
|
get_table,
|
||||||
get_users_by_uids,
|
get_users_by_uids,
|
||||||
@ -230,7 +230,12 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
|||||||
|
|
||||||
base = site_url(request)
|
base = site_url(request)
|
||||||
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
|
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
|
||||||
og_image = first_image_url(project, detail["attachments"])
|
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")]
|
screenshots = [a for a in detail["attachments"] if a.get("is_image")]
|
||||||
comment_total = get_table("comments").count(
|
comment_total = get_table("comments").count(
|
||||||
target_type="project", target_uid=project["uid"], deleted_at=None
|
target_type="project", target_uid=project["uid"], deleted_at=None
|
||||||
@ -427,11 +432,27 @@ async def delete_project(request: Request, project_slug: str):
|
|||||||
request, "projects", "project", user, project_slug, "/projects"
|
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")
|
@router.post("/create")
|
||||||
async def create_project(request: Request, data: Annotated[ProjectForm, Depends(json_or_form(ProjectForm))]):
|
async def create_project(request: Request, data: Annotated[ProjectForm, Depends(json_or_form(ProjectForm))]):
|
||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
title = data.title.strip()
|
title = data.title.strip()
|
||||||
description = data.description.strip()
|
description = data.description.strip()
|
||||||
|
images = await _uploaded_project_images(request)
|
||||||
|
|
||||||
uid, project_slug = create_content_item(
|
uid, project_slug = create_content_item(
|
||||||
"projects",
|
"projects",
|
||||||
@ -446,6 +467,9 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
|
|||||||
"platforms": data.platforms.strip(),
|
"platforms": data.platforms.strip(),
|
||||||
"status": data.status,
|
"status": data.status,
|
||||||
"website_url": data.website_url or None,
|
"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,
|
"is_private": 1 if data.is_private else 0,
|
||||||
"read_only": 0,
|
"read_only": 0,
|
||||||
},
|
},
|
||||||
@ -466,21 +490,24 @@ async def edit_project(
|
|||||||
request: Request, project_slug: str, data: Annotated[ProjectEditForm, Depends(json_or_form(ProjectEditForm))]
|
request: Request, project_slug: str, data: Annotated[ProjectEditForm, Depends(json_or_form(ProjectEditForm))]
|
||||||
):
|
):
|
||||||
user = require_user(request)
|
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(
|
return edit_content_item(
|
||||||
request,
|
request,
|
||||||
"projects",
|
"projects",
|
||||||
user,
|
user,
|
||||||
project_slug,
|
project_slug,
|
||||||
{
|
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,
|
|
||||||
},
|
|
||||||
"/projects",
|
"/projects",
|
||||||
target_type="project",
|
target_type="project",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -122,6 +122,9 @@ class ProjectOut(_Out):
|
|||||||
release_date: Optional[str] = None
|
release_date: Optional[str] = None
|
||||||
demo_date: Optional[str] = None
|
demo_date: Optional[str] = None
|
||||||
website_url: 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
|
created_at: Optional[str] = None
|
||||||
updated_at: Optional[str] = None
|
updated_at: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
@ -193,8 +193,13 @@ def software_application_schema(
|
|||||||
schema["image"] = image_url
|
schema["image"] = image_url
|
||||||
if screenshot_urls:
|
if screenshot_urls:
|
||||||
schema["screenshot"] = list(screenshot_urls)
|
schema["screenshot"] = list(screenshot_urls)
|
||||||
if project.get("website_url"):
|
if project.get("logo_image"):
|
||||||
schema["sameAs"] = [project["website_url"]]
|
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:
|
if star_count > 0:
|
||||||
schema["aggregateRating"] = {
|
schema["aggregateRating"] = {
|
||||||
"@type": "AggregateRating",
|
"@type": "AggregateRating",
|
||||||
|
|||||||
@ -48,6 +48,7 @@ PROJECTS_ACTIONS: tuple[Action, ...] = (
|
|||||||
body("platforms", "Supported platforms."),
|
body("platforms", "Supported platforms."),
|
||||||
body("status", "Project status."),
|
body("status", "Project status."),
|
||||||
body("website_url", "Official website URL (http/https)."),
|
body("website_url", "Official website URL (http/https)."),
|
||||||
|
body("repo_url", "Source repository URL (http/https)."),
|
||||||
body("attachment_uids", ATTACHMENTS),
|
body("attachment_uids", ATTACHMENTS),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -69,6 +70,7 @@ PROJECTS_ACTIONS: tuple[Action, ...] = (
|
|||||||
body("platforms", "Updated supported platforms."),
|
body("platforms", "Updated supported platforms."),
|
||||||
body("status", "Updated project status."),
|
body("status", "Updated project status."),
|
||||||
body("website_url", "Updated official website URL (http/https)."),
|
body("website_url", "Updated official website URL (http/https)."),
|
||||||
|
body("repo_url", "Updated source repository URL (http/https)."),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Action(
|
Action(
|
||||||
|
|||||||
@ -237,29 +237,94 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-cover {
|
.project-cover {
|
||||||
height: 220px;
|
position: relative;
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-cover img {
|
.project-cover-img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-cover-fallback {
|
.project-cover-fallback {
|
||||||
height: 96px;
|
min-height: 200px;
|
||||||
background: var(--accent-gradient);
|
background: var(--accent-gradient);
|
||||||
opacity: 0.55;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-hero-body {
|
.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;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-website-btn {
|
.project-logo {
|
||||||
margin-left: auto;
|
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-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 {
|
.project-tabs {
|
||||||
@ -269,7 +334,7 @@
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 0.25rem;
|
padding: 0 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: calc(var(--nav-height) + 0.5rem);
|
top: calc(var(--nav-height) + 0.5rem);
|
||||||
@ -280,18 +345,22 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
padding: 0.375rem 0.875rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: var(--radius);
|
border-bottom: 2px solid transparent;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-tab:hover {
|
.project-tab:hover {
|
||||||
background: var(--bg-card-hover);
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-tab.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.project-tab-count {
|
.project-tab-count {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@ -394,7 +463,22 @@
|
|||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
.project-cover {
|
.project-cover {
|
||||||
height: 160px;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,34 +9,60 @@
|
|||||||
{% set project_url = "/projects/" ~ (project['slug'] or project['uid']) %}
|
{% set project_url = "/projects/" ~ (project['slug'] or project['uid']) %}
|
||||||
{% set image_attachments = attachments | selectattr('is_image') | list %}
|
{% set image_attachments = attachments | selectattr('is_image') | list %}
|
||||||
{% set other_attachments = attachments | rejectattr('is_image') | list %}
|
{% set other_attachments = attachments | rejectattr('is_image') | list %}
|
||||||
|
{% set cover_src = ('/static/uploads/' ~ project['cover_image']) if project.get('cover_image') else (image_attachments[0]['url'] if image_attachments else none) %}
|
||||||
|
{% set logo_src = ('/static/uploads/' ~ project['logo_image']) if project.get('logo_image') else none %}
|
||||||
|
{% set tagline = (project.get('description', '') or '').split('\n')[0] %}
|
||||||
<div class="project-page">
|
<div class="project-page">
|
||||||
<a href="/projects" class="back-link">← Back to Projects</a>
|
<a href="/projects" class="back-link">← Back to Projects</a>
|
||||||
|
|
||||||
<article class="project-hero">
|
<article class="project-hero">
|
||||||
{% if image_attachments %}
|
<div class="project-cover{% if not cover_src %} project-cover-fallback{% endif %}">
|
||||||
<div class="project-cover">
|
{% if cover_src %}
|
||||||
<img src="{{ image_attachments[0]['url'] }}" alt="{{ project['title'] }} cover image" loading="eager">
|
<img class="project-cover-img" src="{{ cover_src }}" alt="{{ project['title'] }} cover image" loading="eager">
|
||||||
</div>
|
{% endif %}
|
||||||
{% else %}
|
<div class="project-cover-scrim" aria-hidden="true"></div>
|
||||||
<div class="project-cover project-cover-fallback" aria-hidden="true"></div>
|
<div class="project-hero-overlay">
|
||||||
{% endif %}
|
{% if logo_src %}
|
||||||
<div class="project-hero-body">
|
<img class="project-logo" src="{{ logo_src }}" alt="{{ project['title'] }} logo" loading="eager">
|
||||||
<div class="project-detail-header">
|
{% endif %}
|
||||||
<h1 class="project-detail-title">{{ render_title(project['title'], author_is_admin=is_admin(author)) }}</h1>
|
<div class="project-hero-headline">
|
||||||
<div class="project-status {% if project.get('status') == 'Released' %}released{% else %}dev{% endif %}">
|
<div class="project-detail-header">
|
||||||
● {{ project.get('status', 'In Development') }}
|
<h1 class="project-detail-title">{{ render_title(project['title'], author_is_admin=is_admin(author)) }}</h1>
|
||||||
|
<div class="project-status {% if project.get('status') == 'Released' %}released{% else %}dev{% endif %}">
|
||||||
|
● {{ project.get('status', 'In Development') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if tagline %}
|
||||||
|
<p class="project-tagline">{{ render_title(tagline, author_is_admin=is_admin(author)) }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="project-detail-meta project-hero-chips">
|
||||||
|
<span class="badge badge-type">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
|
||||||
|
{% for plat in platforms %}
|
||||||
|
<span class="platform-tag">{{ plat.strip() }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% if is_private %}<span class="badge badge-type">Private</span>{% endif %}
|
||||||
|
{% if read_only %}<span class="badge badge-type">Read-only</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="project-detail-author">
|
||||||
|
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
||||||
|
<div>
|
||||||
|
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
|
||||||
|
<span class="meta-muted">· Level {{ author.get('level', 1) if author else 1 }}</span>
|
||||||
|
</div>
|
||||||
|
{% if project.get('created_at') %}
|
||||||
|
<span class="meta-muted">🌱 Started {{ dt_ago(project['created_at']) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if project.get('website_url') %}
|
||||||
|
<div class="project-hero-ctas">
|
||||||
|
<a href="{{ project['website_url'] }}" target="_blank" rel="noopener nofollow" class="btn btn-primary project-website-btn"><span class="icon">🌐</span> Visit Website</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="project-detail-meta">
|
<div class="project-hero-body">
|
||||||
<span class="badge badge-type">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
|
{% if project.get('release_date') or project.get('demo_date') or forked_from %}
|
||||||
{% for plat in platforms %}
|
|
||||||
<span class="platform-tag">{{ plat.strip() }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
{% if is_private %}<span class="badge badge-type">Private</span>{% endif %}
|
|
||||||
{% if read_only %}<span class="badge badge-type">Read-only</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="project-detail-meta">
|
<div class="project-detail-meta">
|
||||||
{% if project.get('release_date') %}
|
{% if project.get('release_date') %}
|
||||||
<span>📅 Released: {{ format_date(project['release_date']) }}</span>
|
<span>📅 Released: {{ format_date(project['release_date']) }}</span>
|
||||||
@ -44,24 +70,11 @@
|
|||||||
{% if project.get('demo_date') %}
|
{% if project.get('demo_date') %}
|
||||||
<span>🎭 Demo: {{ format_date(project['demo_date']) }}</span>
|
<span>🎭 Demo: {{ format_date(project['demo_date']) }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if project.get('created_at') %}
|
|
||||||
<span>🌱 Started {{ dt_ago(project['created_at']) }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if forked_from %}
|
{% if forked_from %}
|
||||||
<span>⑂ Forked from <a href="/projects/{{ forked_from['slug'] }}">{{ render_title(forked_from['title']) }}</a></span>
|
<span>⑂ Forked from <a href="/projects/{{ forked_from['slug'] }}">{{ render_title(forked_from['title']) }}</a></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="project-detail-author">
|
|
||||||
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
|
||||||
<div>
|
|
||||||
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
|
|
||||||
<span class="meta-muted">· Level {{ author.get('level', 1) if author else 1 }}</span>
|
|
||||||
</div>
|
|
||||||
{% if project.get('website_url') %}
|
|
||||||
<a href="{{ project['website_url'] }}" target="_blank" rel="noopener nofollow" class="btn btn-primary btn-sm project-website-btn"><span class="icon">🌐</span> Visit Website</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="project-detail-actions">
|
<div class="project-detail-actions">
|
||||||
<a href="{{ project_url }}/files" class="project-star-btn"><span class="icon">📁</span><span class="label"> Files ({{ file_count }} files)</span></a>
|
<a href="{{ project_url }}/files" class="project-star-btn"><span class="icon">📁</span><span class="label"> Files ({{ file_count }} files)</span></a>
|
||||||
@ -110,7 +123,7 @@
|
|||||||
</article>
|
</article>
|
||||||
|
|
||||||
<nav class="project-tabs" aria-label="Project sections">
|
<nav class="project-tabs" aria-label="Project sections">
|
||||||
<a href="#about" class="project-tab">Overview</a>
|
<a href="#about" class="project-tab active">Overview</a>
|
||||||
<a href="#devlog" class="project-tab">Devlog <span class="project-tab-count">{{ devlog_count }}</span></a>
|
<a href="#devlog" class="project-tab">Devlog <span class="project-tab-count">{{ devlog_count }}</span></a>
|
||||||
{% if image_attachments %}
|
{% if image_attachments %}
|
||||||
<a href="#screenshots" class="project-tab">Screenshots <span class="project-tab-count">{{ image_attachments | length }}</span></a>
|
<a href="#screenshots" class="project-tab">Screenshots <span class="project-tab-count">{{ image_attachments | length }}</span></a>
|
||||||
@ -177,6 +190,9 @@
|
|||||||
{% if project.get('website_url') %}
|
{% if project.get('website_url') %}
|
||||||
<li><a href="{{ project['website_url'] }}" target="_blank" rel="noopener nofollow">🌐 Website</a></li>
|
<li><a href="{{ project['website_url'] }}" target="_blank" rel="noopener nofollow">🌐 Website</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if project.get('repo_url') %}
|
||||||
|
<li><a href="{{ project['repo_url'] }}" target="_blank" rel="noopener nofollow">💻 Repository</a></li>
|
||||||
|
{% endif %}
|
||||||
<li><a href="{{ project_url }}/files">📁 Browse files</a></li>
|
<li><a href="{{ project_url }}/files">📁 Browse files</a></li>
|
||||||
{% if forked_from %}
|
{% if forked_from %}
|
||||||
<li><a href="/projects/{{ forked_from['slug'] }}">⑂ Fork source: {{ render_title(forked_from['title']) }}</a></li>
|
<li><a href="/projects/{{ forked_from['slug'] }}">⑂ Fork source: {{ render_title(forked_from['title']) }}</a></li>
|
||||||
@ -217,7 +233,7 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
{% call modal('edit-project-modal', 'Edit Project') %}
|
{% call modal('edit-project-modal', 'Edit Project') %}
|
||||||
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}">
|
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}" enctype="multipart/form-data">
|
||||||
<div class="auth-field auth-field-gap">
|
<div class="auth-field auth-field-gap">
|
||||||
<label for="edit-project-title">Title</label>
|
<label for="edit-project-title">Title</label>
|
||||||
<input type="text" id="edit-project-title" name="title" required maxlength="200" value="{{ project['title'] }}">
|
<input type="text" id="edit-project-title" name="title" required maxlength="200" value="{{ project['title'] }}">
|
||||||
@ -239,9 +255,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-field auth-field-gap">
|
<div class="grid-2col">
|
||||||
<label for="edit-project-website_url">Website (optional)</label>
|
<div class="auth-field">
|
||||||
<input type="url" id="edit-project-website_url" name="website_url" maxlength="500" placeholder="https://myproject.dev" value="{{ project.get('website_url', '') or '' }}">
|
<label for="edit-project-website_url">Website (optional)</label>
|
||||||
|
<input type="url" id="edit-project-website_url" name="website_url" maxlength="500" placeholder="https://myproject.dev" value="{{ project.get('website_url', '') or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="edit-project-repo_url">Repository (optional)</label>
|
||||||
|
<input type="url" id="edit-project-repo_url" name="repo_url" maxlength="500" placeholder="https://github.com/me/project" value="{{ project.get('repo_url', '') or '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2col">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="edit-project-cover_image">Cover image (optional{% if project.get('cover_image') %}, replaces current{% endif %})</label>
|
||||||
|
<input type="file" id="edit-project-cover_image" name="cover_image" accept="image/*">
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="edit-project-logo_image">Project logo (optional{% if project.get('logo_image') %}, replaces current{% endif %})</label>
|
||||||
|
<input type="file" id="edit-project-logo_image" name="logo_image" accept="image/*">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-field auth-field-gap">
|
<div class="auth-field auth-field-gap">
|
||||||
|
|||||||
@ -104,7 +104,7 @@
|
|||||||
<button type="button" class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal" aria-label="Add Project">+</button>
|
<button type="button" class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal" aria-label="Add Project">+</button>
|
||||||
|
|
||||||
{% call modal('create-project-modal', 'Create Project') %}
|
{% call modal('create-project-modal', 'Create Project') %}
|
||||||
<form method="POST" action="/projects/create">
|
<form method="POST" action="/projects/create" enctype="multipart/form-data">
|
||||||
<div class="auth-field auth-field-gap">
|
<div class="auth-field auth-field-gap">
|
||||||
<label for="title">Title</label>
|
<label for="title">Title</label>
|
||||||
<input type="text" id="title" name="title" required maxlength="200" placeholder="Project name">
|
<input type="text" id="title" name="title" required maxlength="200" placeholder="Project name">
|
||||||
@ -126,9 +126,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-field auth-field-gap">
|
<div class="grid-2col">
|
||||||
<label for="website_url">Website (optional)</label>
|
<div class="auth-field">
|
||||||
<input type="url" id="website_url" name="website_url" maxlength="500" placeholder="https://myproject.dev">
|
<label for="website_url">Website (optional)</label>
|
||||||
|
<input type="url" id="website_url" name="website_url" maxlength="500" placeholder="https://myproject.dev">
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="repo_url">Repository (optional)</label>
|
||||||
|
<input type="url" id="repo_url" name="repo_url" maxlength="500" placeholder="https://github.com/me/project">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2col">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="cover_image">Cover image (optional)</label>
|
||||||
|
<input type="file" id="cover_image" name="cover_image" accept="image/*">
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="logo_image">Project logo (optional)</label>
|
||||||
|
<input type="file" id="logo_image" name="logo_image" accept="image/*">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-field auth-field-gap">
|
<div class="auth-field auth-field-gap">
|
||||||
|
|||||||
@ -355,6 +355,48 @@ def test_project_page_cover_and_screenshots_from_image_attachments(app_server):
|
|||||||
assert '"screenshot"' in html, "Expected screenshot urls in the JSON-LD"
|
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_project_page_json_ld_rating_from_stars(app_server):
|
def test_project_page_json_ld_rating_from_stars(app_server):
|
||||||
"""Stars surface as an aggregateRating; zero stars emit none."""
|
"""Stars surface as an aggregateRating; zero stars emit none."""
|
||||||
session, _ = _member()
|
session, _ = _member()
|
||||||
|
|||||||
@ -115,12 +115,14 @@ def test_owner_can_set_and_clear_website_url(app_server):
|
|||||||
"title": "Website Via Api",
|
"title": "Website Via Api",
|
||||||
"description": "has a website now",
|
"description": "has a website now",
|
||||||
"website_url": "myproject.dev/docs",
|
"website_url": "myproject.dev/docs",
|
||||||
|
"repo_url": "github.com/me/website-via-api",
|
||||||
},
|
},
|
||||||
allow_redirects=False,
|
allow_redirects=False,
|
||||||
)
|
)
|
||||||
assert r.status_code == 200 and r.json()["ok"] is True
|
assert r.status_code == 200 and r.json()["ok"] is True
|
||||||
row = get_table("projects").find_one(slug=slug)
|
row = get_table("projects").find_one(slug=slug)
|
||||||
assert row["website_url"] == "https://myproject.dev/docs"
|
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
|
html = requests.get(f"{BASE_URL}/projects/{slug}").text
|
||||||
assert "Visit Website" in html
|
assert "Visit Website" in html
|
||||||
|
|||||||
@ -57,6 +57,7 @@ def test_project_form_website_url_normalizes_and_validates():
|
|||||||
ProjectForm(**base, website_url="http://x.dev/a?b=1").website_url
|
ProjectForm(**base, website_url="http://x.dev/a?b=1").website_url
|
||||||
== "http://x.dev/a?b=1"
|
== "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(" ") == ""
|
assert normalize_website_url(" ") == ""
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
ProjectForm(**base, website_url="javascript:alert(1)")
|
ProjectForm(**base, website_url="javascript:alert(1)")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user