Dedicate the project page to the project
Some checks are pending
DevPlace CI / test (pull_request) Blocked by required conditions
Some checks are pending
DevPlace CI / test (pull_request) Blocked by required conditions
The project detail page becomes a full project showcase built entirely
from existing platform mechanisms. One encompassing dark card wraps the
page; inner panels (tab bar, sidebar cards, devlog entries, comments)
sit one elevation lighter. The hero opens with a cover banner and an
optional logo tile, both plain attachment references
(cover_attachment_uid/logo_attachment_uid) uploaded through the
standard dp-upload attachment widget and linked via the existing
link_attachments choke point - the route validates each uid belongs to
the actor and is an image, and an empty value on edit keeps the current
one. The title block, type/platform chips and author row overlay the
banner behind a scrim with a dark text shadow, next to an owner-set
Visit Website CTA; website_url and repo_url are normalized in models
and render with rel noopener nofollow.
An anchor tab bar (Overview, Devlog, Screenshots when present,
Comments, Files) navigates the page. The main column keeps About, the
devlog timeline (with devlog_count and an owner Post update button
opening the shared composer preset to the devlog topic + project - the
form now lives once in _post_composer_form.html, included by feed.html
and project_detail.html), a Screenshots gallery built from image
attachments minus the cover/logo (thumbnails, lightbox, 12 rendered),
and the comment thread; the sidebar holds Links, Stats and the Author
card. Owners add gallery images from the More menu via
POST /projects/{slug}/screenshots (owner-only, audit
project.screenshots.add, Devii action project_add_screenshots, docs id
projects-screenshots). comment_count/devlog_count ride
ProjectDetailOut, the new fields ride ProjectOut, and the create/edit
faces (modals, Devii actions, API docs) carry them. The project
comment/files e2e tests scope their locators per the documented
dual-control idiom, and new unit/api/e2e tests cover URL normalization,
the counts, the hero attachment guard, the screenshots flow and the
preset composer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
782bcec5bc
commit
72e088c160
@ -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 |
|
| `/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 project page: one encompassing card with a cover banner and project logo (owner-uploaded through the standard attachment uploader), 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 (owners post updates straight from the page via the shared composer preset to the `devlog` topic), a Screenshots gallery built from image attachments (owners add more from the More menu), and a sidebar with links, stats and the author card |
|
||||||
| `/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 |
|
||||||
|
|||||||
@ -854,6 +854,10 @@ def enrich_items(
|
|||||||
return enriched
|
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(
|
def get_project_devlog(
|
||||||
project_uid: str, before: str | None = None, viewer: dict | None = None
|
project_uid: str, before: str | None = None, viewer: dict | None = None
|
||||||
) -> tuple[list, str | None]:
|
) -> tuple[list, str | None]:
|
||||||
|
|||||||
@ -362,7 +362,7 @@ four ways to sign requests.
|
|||||||
method="GET",
|
method="GET",
|
||||||
path="/projects/{project_slug}",
|
path="/projects/{project_slug}",
|
||||||
title="View a project",
|
title="View a project",
|
||||||
summary="Render a project with comments. Returns an HTML page.",
|
summary="Render the project overview with its devlog, screenshots and comments. Returns an HTML page.",
|
||||||
auth="public",
|
auth="public",
|
||||||
interactive=True,
|
interactive=True,
|
||||||
params=[
|
params=[
|
||||||
@ -373,7 +373,42 @@ four ways to sign requests.
|
|||||||
True,
|
True,
|
||||||
"PROJECT_SLUG",
|
"PROJECT_SLUG",
|
||||||
"Slug or UID of the project.",
|
"Slug or UID of the project.",
|
||||||
)
|
),
|
||||||
|
field(
|
||||||
|
"before",
|
||||||
|
"query",
|
||||||
|
"string",
|
||||||
|
False,
|
||||||
|
"",
|
||||||
|
"Devlog pagination cursor (devlog_next_cursor from the previous page).",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
endpoint(
|
||||||
|
id="projects-screenshots",
|
||||||
|
method="POST",
|
||||||
|
path="/projects/{project_slug}/screenshots",
|
||||||
|
title="Add screenshots to a project",
|
||||||
|
summary="Link uploaded image attachments to an owned project's Screenshots gallery. Redirects to the gallery.",
|
||||||
|
auth="user",
|
||||||
|
encoding="form",
|
||||||
|
params=[
|
||||||
|
field(
|
||||||
|
"project_slug",
|
||||||
|
"path",
|
||||||
|
"string",
|
||||||
|
True,
|
||||||
|
"my-project-1a2b3c4d",
|
||||||
|
"Project slug or uid.",
|
||||||
|
),
|
||||||
|
field(
|
||||||
|
"attachment_uids",
|
||||||
|
"form",
|
||||||
|
"string",
|
||||||
|
True,
|
||||||
|
"ATTACHMENT_UID",
|
||||||
|
"Comma-separated attachment uids from POST /uploads/upload or /uploads/upload-url.",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
endpoint(
|
endpoint(
|
||||||
@ -443,6 +478,38 @@ four ways to sign requests.
|
|||||||
"31/12/2026",
|
"31/12/2026",
|
||||||
"Optional demo date in DD/MM/YYYY format.",
|
"Optional demo date in DD/MM/YYYY format.",
|
||||||
),
|
),
|
||||||
|
field(
|
||||||
|
"website_url",
|
||||||
|
"form",
|
||||||
|
"string",
|
||||||
|
False,
|
||||||
|
"https://myproject.dev",
|
||||||
|
"Optional official website URL (http/https).",
|
||||||
|
),
|
||||||
|
field(
|
||||||
|
"repo_url",
|
||||||
|
"form",
|
||||||
|
"string",
|
||||||
|
False,
|
||||||
|
"https://github.com/me/project",
|
||||||
|
"Optional source repository URL (http/https).",
|
||||||
|
),
|
||||||
|
field(
|
||||||
|
"cover_attachment_uid",
|
||||||
|
"form",
|
||||||
|
"string",
|
||||||
|
False,
|
||||||
|
"ATTACHMENT_UID",
|
||||||
|
"Optional attachment uid of an uploaded cover image.",
|
||||||
|
),
|
||||||
|
field(
|
||||||
|
"logo_attachment_uid",
|
||||||
|
"form",
|
||||||
|
"string",
|
||||||
|
False,
|
||||||
|
"ATTACHMENT_UID",
|
||||||
|
"Optional attachment uid of an uploaded project logo.",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
endpoint(
|
endpoint(
|
||||||
@ -520,6 +587,38 @@ four ways to sign requests.
|
|||||||
"31/12/2026",
|
"31/12/2026",
|
||||||
"Optional demo date in DD/MM/YYYY format.",
|
"Optional demo date in DD/MM/YYYY format.",
|
||||||
),
|
),
|
||||||
|
field(
|
||||||
|
"website_url",
|
||||||
|
"form",
|
||||||
|
"string",
|
||||||
|
False,
|
||||||
|
"https://myproject.dev",
|
||||||
|
"Optional official website URL (http/https).",
|
||||||
|
),
|
||||||
|
field(
|
||||||
|
"repo_url",
|
||||||
|
"form",
|
||||||
|
"string",
|
||||||
|
False,
|
||||||
|
"https://github.com/me/project",
|
||||||
|
"Optional source repository URL (http/https).",
|
||||||
|
),
|
||||||
|
field(
|
||||||
|
"cover_attachment_uid",
|
||||||
|
"form",
|
||||||
|
"string",
|
||||||
|
False,
|
||||||
|
"ATTACHMENT_UID",
|
||||||
|
"Optional attachment uid of an uploaded cover image.",
|
||||||
|
),
|
||||||
|
field(
|
||||||
|
"logo_attachment_uid",
|
||||||
|
"form",
|
||||||
|
"string",
|
||||||
|
False,
|
||||||
|
"ATTACHMENT_UID",
|
||||||
|
"Optional attachment uid of an uploaded project logo.",
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
endpoint(
|
endpoint(
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import re
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit, urlparse
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
from devplacepy.constants import TOPICS
|
from devplacepy.constants import TOPICS
|
||||||
from devplacepy.rendering import is_single_emoji
|
from devplacepy.rendering import is_single_emoji
|
||||||
@ -33,6 +33,20 @@ def normalize_european_date(value):
|
|||||||
raise ValueError("Date must be in DD/MM/YYYY format")
|
raise ValueError("Date must be in DD/MM/YYYY format")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_website_url(value):
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if not text.lower().startswith(("http://", "https://")):
|
||||||
|
text = f"https://{text}"
|
||||||
|
parsed = urlparse(text)
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname or "." not in parsed.hostname:
|
||||||
|
raise ValueError("Website must be a valid http(s) URL")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def normalize_poll_options(value):
|
def normalize_poll_options(value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return []
|
return []
|
||||||
@ -241,6 +255,10 @@ 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)
|
||||||
|
repo_url: str = Field(default="", max_length=500)
|
||||||
|
cover_attachment_uid: str = Field(default="", max_length=64)
|
||||||
|
logo_attachment_uid: str = Field(default="", max_length=64)
|
||||||
is_private: bool = False
|
is_private: bool = False
|
||||||
attachment_uids: list[str] = []
|
attachment_uids: list[str] = []
|
||||||
|
|
||||||
@ -249,6 +267,11 @@ 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", "repo_url")
|
||||||
|
@classmethod
|
||||||
|
def valid_link_url(cls, value):
|
||||||
|
return normalize_website_url(value)
|
||||||
|
|
||||||
|
|
||||||
class ProjectEditForm(BaseModel):
|
class ProjectEditForm(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=200)
|
title: str = Field(min_length=1, max_length=200)
|
||||||
@ -260,12 +283,21 @@ 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)
|
||||||
|
repo_url: str = Field(default="", max_length=500)
|
||||||
|
cover_attachment_uid: str = Field(default="", max_length=64)
|
||||||
|
logo_attachment_uid: str = Field(default="", max_length=64)
|
||||||
|
|
||||||
@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", "repo_url")
|
||||||
|
@classmethod
|
||||||
|
def valid_link_url(cls, value):
|
||||||
|
return normalize_website_url(value)
|
||||||
|
|
||||||
|
|
||||||
class BackupRunForm(BaseModel):
|
class BackupRunForm(BaseModel):
|
||||||
target: Literal["database", "uploads", "keys", "full"] = "full"
|
target: Literal["database", "uploads", "keys", "full"] = "full"
|
||||||
@ -290,6 +322,10 @@ class ProjectFlagForm(BaseModel):
|
|||||||
value: bool = False
|
value: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectScreenshotsForm(BaseModel):
|
||||||
|
attachment_uids: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class CustomizationToggleForm(BaseModel):
|
class CustomizationToggleForm(BaseModel):
|
||||||
value: bool = False
|
value: bool = False
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,8 @@ 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 page.** The detail page is a dedicated project showcase: one encompassing dark card (`.project-shell`, the site `--bg-card` surface with clipped corners) wraps the hero, the section tab bar and the two-column body, and every inner panel (tab bar, sidebar cards, devlog post cards, empty state, comments section) sits one elevation lighter on `--bg-secondary`. The hero's cover banner is the attachment referenced by `projects.cover_attachment_uid`, falling back to the first image attachment (brand-gradient band when neither exists); the title block, type/platform chips and author row render OVERLAID on the banner behind a bottom scrim (dark text-shadow for readability) beside the optional `projects.logo_attachment_uid` tile, with an owner-set **Visit Website** CTA (`projects.website_url`). Cover and logo ride the ONE existing upload pipeline: `dp-upload` widgets (`name="cover_attachment_uid"`/`"logo_attachment_uid"`, `max-files="1"`) in the create/edit modals upload to `/uploads/upload`, the route validates each uid via `database.get_user_attachment` (must exist, belong to the actor, be an image - `_hero_attachment_uid`) and links it to the project through `attachments.link_attachments`; an empty value on edit keeps the current image (no removal control). `website_url`/`repo_url` are normalized by `models.normalize_website_url` (scheme-less input gets `https://`, non-http(s) rejected) and render with `rel="noopener nofollow"`. Below the hero an anchor **section tab bar** (`.project-tabs`, underline style, Overview `.active`) links `#about` / `#devlog` / `#screenshots` (only when gallery images exist) / `#comments` / the Files page - server-rendered anchors, no JS tab state. The main column holds **About** (description + non-image attachments), the **Devlog** (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`, the same rule as `news.html`) with `devlog_count` (`content.count_project_devlog`) and an owner **Post update** button (`.project-devlog-post-btn`) opening the shared composer preset to `topic=devlog` + this project (the form lives ONCE in `templates/_post_composer_form.html`, locals `_composer_topic`/`_composer_project`, included by `feed.html` and `project_detail.html` - never fork a second copy), a **Screenshots** gallery (image attachments minus the cover/logo, thumbnails, `data-lightbox`, capped at 12 rendered), and the comment thread; the sidebar holds Links (website/repository/files/fork source), the Stats card (5 `.project-stat` entries + a last-update line) and the Author card. Owners add gallery images via the More-menu **Add screenshots** modal: `_attachment_form.html` uploads, then `POST /projects/{slug}/screenshots` (`ProjectScreenshotsForm`, owner-only, audit `project.screenshots.add`) links the uids through the same `link_attachments` choke point; Devii action `project_add_screenshots`, docs id `projects-screenshots`. `comment_count`/`devlog_count` ride `ProjectDetailOut`; the new project fields ride `ProjectOut`; the page og:image prefers the cover attachment. **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']`).
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
**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`).
|
**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`).
|
||||||
|
|||||||
@ -4,9 +4,15 @@ import logging
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from sqlalchemy import or_
|
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,
|
||||||
|
ProjectScreenshotsForm,
|
||||||
|
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, link_attachments
|
||||||
from devplacepy.database import (
|
from devplacepy.database import (
|
||||||
get_table,
|
get_table,
|
||||||
get_users_by_uids,
|
get_users_by_uids,
|
||||||
@ -25,6 +31,7 @@ from devplacepy.database import (
|
|||||||
get_fork_parent,
|
get_fork_parent,
|
||||||
count_forks,
|
count_forks,
|
||||||
get_top_authors,
|
get_top_authors,
|
||||||
|
get_user_attachment,
|
||||||
)
|
)
|
||||||
from devplacepy.project_files import count_files
|
from devplacepy.project_files import count_files
|
||||||
from devplacepy.services.jobs import queue
|
from devplacepy.services.jobs import queue
|
||||||
@ -41,6 +48,7 @@ from devplacepy.content import (
|
|||||||
can_view_project_containers,
|
can_view_project_containers,
|
||||||
can_open_workspace,
|
can_open_workspace,
|
||||||
get_project_devlog,
|
get_project_devlog,
|
||||||
|
count_project_devlog,
|
||||||
)
|
)
|
||||||
from devplacepy.utils import (
|
from devplacepy.utils import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
@ -196,6 +204,18 @@ def _editor_launch(project: dict, user: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _hero_attachment_uid(user: dict, raw_uid: str) -> str | None:
|
||||||
|
uid = (raw_uid or "").strip()
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
attachment = get_user_attachment(uid)
|
||||||
|
if not attachment or attachment.get("user_uid") != user["uid"]:
|
||||||
|
return None
|
||||||
|
if not attachment.get("is_image"):
|
||||||
|
return None
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_slug}", response_class=HTMLResponse)
|
@router.get("/{project_slug}", response_class=HTMLResponse)
|
||||||
async def project_detail(request: Request, project_slug: str, before: str = None):
|
async def project_detail(request: Request, project_slug: str, before: str = None):
|
||||||
user = get_current_user(request)
|
user = get_current_user(request)
|
||||||
@ -215,13 +235,21 @@ 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"
|
||||||
|
cover_url = next(
|
||||||
|
(
|
||||||
|
a["url"]
|
||||||
|
for a in detail["attachments"]
|
||||||
|
if a["uid"] == project.get("cover_attachment_uid") and a.get("is_image")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
seo_ctx = base_seo_context(
|
seo_ctx = base_seo_context(
|
||||||
request,
|
request,
|
||||||
title=project.get("title", "Project"),
|
title=project.get("title", "Project"),
|
||||||
description=project.get("description", ""),
|
description=project.get("description", ""),
|
||||||
seo_target=("project", project["uid"]),
|
seo_target=("project", project["uid"]),
|
||||||
robots=robots,
|
robots=robots,
|
||||||
og_image=first_image_url(project, detail["attachments"]),
|
og_image=cover_url or first_image_url(project, detail["attachments"]),
|
||||||
breadcrumbs=[
|
breadcrumbs=[
|
||||||
{"name": "Home", "url": "/feed"},
|
{"name": "Home", "url": "/feed"},
|
||||||
{"name": "Projects", "url": "/projects"},
|
{"name": "Projects", "url": "/projects"},
|
||||||
@ -292,8 +320,14 @@ async def project_detail(request: Request, project_slug: str, before: str = None
|
|||||||
"forked_from": forked_from,
|
"forked_from": forked_from,
|
||||||
"fork_count": count_forks(project["uid"]),
|
"fork_count": count_forks(project["uid"]),
|
||||||
"file_count": count_files(project["uid"]),
|
"file_count": count_files(project["uid"]),
|
||||||
|
"comment_count": get_table("comments").count(
|
||||||
|
target_type="project",
|
||||||
|
target_uid=project["uid"],
|
||||||
|
deleted_at=None,
|
||||||
|
),
|
||||||
"devlog_posts": devlog_posts,
|
"devlog_posts": devlog_posts,
|
||||||
"devlog_next_cursor": devlog_next_cursor,
|
"devlog_next_cursor": devlog_next_cursor,
|
||||||
|
"devlog_count": count_project_devlog(project["uid"]),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
model=ProjectDetailOut,
|
model=ProjectDetailOut,
|
||||||
@ -409,6 +443,8 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
|
|||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
title = data.title.strip()
|
title = data.title.strip()
|
||||||
description = data.description.strip()
|
description = data.description.strip()
|
||||||
|
cover_uid = _hero_attachment_uid(user, data.cover_attachment_uid)
|
||||||
|
logo_uid = _hero_attachment_uid(user, data.logo_attachment_uid)
|
||||||
|
|
||||||
uid, project_slug = create_content_item(
|
uid, project_slug = create_content_item(
|
||||||
"projects",
|
"projects",
|
||||||
@ -422,6 +458,10 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
|
|||||||
"project_type": data.project_type,
|
"project_type": data.project_type,
|
||||||
"platforms": data.platforms.strip(),
|
"platforms": data.platforms.strip(),
|
||||||
"status": data.status,
|
"status": data.status,
|
||||||
|
"website_url": data.website_url or None,
|
||||||
|
"repo_url": data.repo_url or None,
|
||||||
|
"cover_attachment_uid": cover_uid,
|
||||||
|
"logo_attachment_uid": logo_uid,
|
||||||
"is_private": 1 if data.is_private else 0,
|
"is_private": 1 if data.is_private else 0,
|
||||||
"read_only": 0,
|
"read_only": 0,
|
||||||
},
|
},
|
||||||
@ -432,6 +472,7 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends(
|
|||||||
data.attachment_uids,
|
data.attachment_uids,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
|
link_attachments([u for u in (cover_uid, logo_uid) if u], "project", uid)
|
||||||
url = f"/projects/{project_slug}"
|
url = f"/projects/{project_slug}"
|
||||||
return action_result(
|
return action_result(
|
||||||
request, url, data={"uid": uid, "slug": project_slug, "url": url}
|
request, url, data={"uid": uid, "slug": project_slug, "url": url}
|
||||||
@ -442,12 +483,7 @@ 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)
|
||||||
return edit_content_item(
|
fields = {
|
||||||
request,
|
|
||||||
"projects",
|
|
||||||
user,
|
|
||||||
project_slug,
|
|
||||||
{
|
|
||||||
"title": data.title.strip(),
|
"title": data.title.strip(),
|
||||||
"description": data.description.strip(),
|
"description": data.description.strip(),
|
||||||
"release_date": data.release_date or None,
|
"release_date": data.release_date or None,
|
||||||
@ -455,10 +491,63 @@ async def edit_project(
|
|||||||
"project_type": data.project_type,
|
"project_type": data.project_type,
|
||||||
"platforms": data.platforms.strip(),
|
"platforms": data.platforms.strip(),
|
||||||
"status": data.status,
|
"status": data.status,
|
||||||
},
|
"website_url": data.website_url or None,
|
||||||
|
"repo_url": data.repo_url or None,
|
||||||
|
}
|
||||||
|
hero_uids = []
|
||||||
|
for field in ("cover_attachment_uid", "logo_attachment_uid"):
|
||||||
|
uid = _hero_attachment_uid(user, getattr(data, field))
|
||||||
|
if uid:
|
||||||
|
fields[field] = uid
|
||||||
|
hero_uids.append(uid)
|
||||||
|
result = edit_content_item(
|
||||||
|
request,
|
||||||
|
"projects",
|
||||||
|
user,
|
||||||
|
project_slug,
|
||||||
|
fields,
|
||||||
"/projects",
|
"/projects",
|
||||||
target_type="project",
|
target_type="project",
|
||||||
)
|
)
|
||||||
|
if hero_uids:
|
||||||
|
project = resolve_by_slug(get_table("projects"), project_slug)
|
||||||
|
if project and is_owner(project, user):
|
||||||
|
link_attachments(hero_uids, "project", project["uid"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@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 = {
|
_FLAG_EVENTS = {
|
||||||
("is_private", True): "project.visibility.private",
|
("is_private", True): "project.visibility.private",
|
||||||
|
|||||||
@ -121,6 +121,10 @@ class ProjectOut(_Out):
|
|||||||
read_only: Optional[bool] = None
|
read_only: Optional[bool] = None
|
||||||
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
|
||||||
|
repo_url: Optional[str] = None
|
||||||
|
cover_attachment_uid: Optional[str] = None
|
||||||
|
logo_attachment_uid: Optional[str] = None
|
||||||
created_at: Optional[str] = None
|
created_at: Optional[str] = None
|
||||||
updated_at: Optional[str] = None
|
updated_at: Optional[str] = None
|
||||||
|
|
||||||
|
|||||||
@ -175,8 +175,10 @@ class ProjectDetailOut(_Out):
|
|||||||
forked_from: Optional[dict] = None
|
forked_from: Optional[dict] = None
|
||||||
fork_count: int = 0
|
fork_count: int = 0
|
||||||
file_count: int = 0
|
file_count: int = 0
|
||||||
|
comment_count: int = 0
|
||||||
devlog_posts: list[FeedItemOut] = []
|
devlog_posts: list[FeedItemOut] = []
|
||||||
devlog_next_cursor: Optional[str] = None
|
devlog_next_cursor: Optional[str] = None
|
||||||
|
devlog_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
class GistsOut(_Out):
|
class GistsOut(_Out):
|
||||||
|
|||||||
@ -47,6 +47,10 @@ PROJECTS_ACTIONS: tuple[Action, ...] = (
|
|||||||
body("project_type", "Project type."),
|
body("project_type", "Project type."),
|
||||||
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("repo_url", "Source repository URL (http/https)."),
|
||||||
|
body("cover_attachment_uid", "Attachment uid of an uploaded cover image (upload_file/attach_url first)."),
|
||||||
|
body("logo_attachment_uid", "Attachment uid of an uploaded project logo (upload_file/attach_url first)."),
|
||||||
body("attachment_uids", ATTACHMENTS),
|
body("attachment_uids", ATTACHMENTS),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -67,6 +71,23 @@ PROJECTS_ACTIONS: tuple[Action, ...] = (
|
|||||||
body("project_type", "Updated project type."),
|
body("project_type", "Updated project type."),
|
||||||
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("repo_url", "Updated source repository URL (http/https)."),
|
||||||
|
body("cover_attachment_uid", "Attachment uid of a new cover image; empty keeps the current one."),
|
||||||
|
body("logo_attachment_uid", "Attachment uid of a new project logo; empty keeps the current one."),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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(
|
Action(
|
||||||
|
|||||||
@ -223,16 +223,327 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-detail-page {
|
.project-page {
|
||||||
max-width: 720px;
|
max-width: var(--max-content);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.project-detail {
|
|
||||||
|
.project-shell {
|
||||||
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);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-shell > .project-tabs,
|
||||||
|
.project-shell > .project-columns {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
margin-right: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-shell > .project-columns {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-shell .project-tabs,
|
||||||
|
.project-shell .project-sidebar-card,
|
||||||
|
.project-shell .post-card,
|
||||||
|
.project-shell .empty-state,
|
||||||
|
.project-shell .comments-section {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover {
|
||||||
|
position: relative;
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover-img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover-fallback {
|
||||||
|
min-height: 200px;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover-fallback::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--overlay-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-cover-scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 30%, rgba(0, 0, 0, 0.78) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-hero-overlay {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 1.25rem;
|
||||||
|
width: 100%;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-logo {
|
||||||
|
width: 112px;
|
||||||
|
height: 112px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
background: var(--bg-card);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-hero-headline {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-hero-overlay .project-detail-title,
|
||||||
|
.project-hero-overlay .project-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.5rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-hero-chips {
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-hero-overlay .project-detail-author {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-hero-ctas {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-hero-body {
|
||||||
|
padding: 1rem 1.5rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tab.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-tab-count {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--overlay-light);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 300px;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-sidebar-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link-list {
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link-list a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link-list a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-sm) var(--space-lg);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-stat a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-stat a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-stat-value {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-last-update {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-author-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-author-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-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-screenshots {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-screenshot-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-screenshot {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-screenshot-more {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-comments {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.project-columns {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
.project-cover {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.project-hero-overlay {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.project-logo {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
}
|
||||||
|
.project-cover {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.project-detail-header {
|
.project-detail-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@ -304,14 +615,6 @@
|
|||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-platforms {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-devlog {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-section-label {
|
.project-section-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@ -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:
|
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`.
|
- `_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.
|
- `_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`.
|
- `_post_header.html` - post author/avatar/time header (`.post-header`). Locals: `_author`, `_time`.
|
||||||
|
|||||||
48
devplacepy/templates/_post_composer_form.html
Normal file
48
devplacepy/templates/_post_composer_form.html
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
|
||||||
|
{% set _topics = TOPICS %}{% set _selected = _composer_topic or 'random' %}{% include "_topic_selector.html" %}
|
||||||
|
|
||||||
|
<div class="auth-field auth-field-gap">
|
||||||
|
<label for="post-content">What are you sharing?</label>
|
||||||
|
<textarea id="post-content" name="content" required maxlength="125000" placeholder="What's on your mind?" class="min-h-120" data-mention></textarea>
|
||||||
|
<small class="hint-text"><span id="post-content-count">0/125000</span></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-field auth-field-gap">
|
||||||
|
<label for="post-title">Title (optional)</label>
|
||||||
|
<input type="text" id="post-title" name="title" maxlength="500" placeholder="Post title">
|
||||||
|
<small class="hint-text"><span id="post-title-count">0/500</span></small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-field auth-field-gap">
|
||||||
|
<label for="project_uid">Link to Project (Optional)</label>
|
||||||
|
<select id="project_uid" name="project_uid">
|
||||||
|
<option value="">No project</option>
|
||||||
|
{% for p in get_user_projects(user['uid']) %}
|
||||||
|
<option value="{{ p['uid'] }}" {% if _composer_project and p['uid'] == _composer_project %}selected{% endif %}>{{ p['title'] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-field auth-field-gap">
|
||||||
|
<label>Attach files (images, video, audio, documents)</label>
|
||||||
|
{% include "_attachment_form.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-field auth-field-gap">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">📊</span> <span data-poll-toggle-label>Add poll</span></button>
|
||||||
|
</div>
|
||||||
|
<div class="poll-builder" data-poll-builder hidden>
|
||||||
|
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" aria-label="Poll question" class="poll-builder-question" disabled>
|
||||||
|
<div class="poll-builder-options" data-poll-options>
|
||||||
|
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" aria-label="Poll option 1" disabled></div>
|
||||||
|
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" aria-label="Poll option 2" disabled></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
|
||||||
|
<p class="poll-builder-error" data-poll-error hidden></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Post</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@ -160,54 +160,7 @@
|
|||||||
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post" aria-label="Create New Post">+</a>
|
<a href="#" class="feed-fab" data-modal="create-post-modal" title="Create New Post" aria-label="Create New Post">+</a>
|
||||||
|
|
||||||
{% call modal('create-post-modal', 'Create New Post') %}
|
{% call modal('create-post-modal', 'Create New Post') %}
|
||||||
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
|
{% set _composer_topic = 'random' %}{% set _composer_project = '' %}{% include "_post_composer_form.html" %}
|
||||||
{% set _topics = TOPICS %}{% set _selected = 'random' %}{% include "_topic_selector.html" %}
|
|
||||||
|
|
||||||
<div class="auth-field auth-field-gap">
|
|
||||||
<label for="post-content">What are you sharing?</label>
|
|
||||||
<textarea id="post-content" name="content" required maxlength="125000" placeholder="What's on your mind?" class="min-h-120" data-mention></textarea>
|
|
||||||
<small class="hint-text"><span id="post-content-count">0/125000</span></small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="auth-field auth-field-gap">
|
|
||||||
<label for="post-title">Title (optional)</label>
|
|
||||||
<input type="text" id="post-title" name="title" maxlength="500" placeholder="Post title">
|
|
||||||
<small class="hint-text"><span id="post-title-count">0/500</span></small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="auth-field auth-field-gap">
|
|
||||||
<label for="project_uid">Link to Project (Optional)</label>
|
|
||||||
<select id="project_uid" name="project_uid">
|
|
||||||
<option value="">No project</option>
|
|
||||||
{% for p in get_user_projects(user['uid']) %}
|
|
||||||
<option value="{{ p['uid'] }}">{{ p['title'] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="auth-field auth-field-gap">
|
|
||||||
<label>Attach files (images, video, audio, documents)</label>
|
|
||||||
{% include "_attachment_form.html" %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="auth-field auth-field-gap">
|
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-poll-toggle><span class="icon">📊</span> <span data-poll-toggle-label>Add poll</span></button>
|
|
||||||
</div>
|
|
||||||
<div class="poll-builder" data-poll-builder hidden>
|
|
||||||
<input type="text" name="poll_question" maxlength="200" placeholder="Poll question" aria-label="Poll question" class="poll-builder-question" disabled>
|
|
||||||
<div class="poll-builder-options" data-poll-options>
|
|
||||||
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 1" aria-label="Poll option 1" disabled></div>
|
|
||||||
<div class="poll-builder-row"><input type="text" name="poll_options" maxlength="100" placeholder="Option 2" aria-label="Poll option 2" disabled></div>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-ghost btn-sm" data-poll-add-option>+ Add option</button>
|
|
||||||
<p class="poll-builder-error" data-poll-error hidden></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-primary">Post</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/auth/login" class="feed-fab login-required" aria-label="Log in">+</a>
|
<a href="/auth/login" class="feed-fab login-required" aria-label="Log in">+</a>
|
||||||
|
|||||||
@ -3,73 +3,80 @@
|
|||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<link rel="stylesheet" href="{{ static_url('/static/css/projects.css') }}">
|
<link rel="stylesheet" href="{{ static_url('/static/css/projects.css') }}">
|
||||||
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
|
<link rel="stylesheet" href="{{ static_url('/static/css/post.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ static_url('/static/css/feed.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="project-detail-page">
|
{% set project_url = "/projects/" ~ (project['slug'] or project['uid']) %}
|
||||||
|
{% set cover = attachments | selectattr('uid', 'equalto', project.get('cover_attachment_uid', '')) | selectattr('is_image') | list | first %}
|
||||||
|
{% set logo = attachments | selectattr('uid', 'equalto', project.get('logo_attachment_uid', '')) | selectattr('is_image') | list | first %}
|
||||||
|
{% set hero_uids = [(cover or {}).get('uid'), (logo or {}).get('uid')] %}
|
||||||
|
{% set gallery = attachments | selectattr('is_image') | rejectattr('uid', 'in', hero_uids) | list %}
|
||||||
|
{% set other_attachments = attachments | rejectattr('is_image') | list %}
|
||||||
|
{% set cover_src = (cover or {}).get('url') or ((gallery | first or {}).get('url')) %}
|
||||||
|
<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-detail">
|
<div class="project-shell">
|
||||||
|
<article class="project-hero">
|
||||||
|
<div class="project-cover{% if not cover_src %} project-cover-fallback{% endif %}">
|
||||||
|
{% if cover_src %}
|
||||||
|
<img class="project-cover-img" src="{{ cover_src }}" alt="{{ project['title'] }} cover image" loading="eager">
|
||||||
|
{% endif %}
|
||||||
|
<div class="project-cover-scrim" aria-hidden="true"></div>
|
||||||
|
<div class="project-hero-overlay">
|
||||||
|
{% if logo %}
|
||||||
|
<img class="project-logo" src="{{ logo['thumbnail_url'] or logo['url'] }}" alt="{{ project['title'] }} logo" loading="eager">
|
||||||
|
{% endif %}
|
||||||
|
<div class="project-hero-headline">
|
||||||
<div class="project-detail-header">
|
<div class="project-detail-header">
|
||||||
<h1 class="project-detail-title">{{ render_title(project['title'], author_is_admin=is_admin(author)) }}</h1>
|
<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 %}">
|
<div class="project-status {% if project.get('status') == 'Released' %}released{% else %}dev{% endif %}">
|
||||||
● {{ project.get('status', 'In Development') }}
|
● {{ project.get('status', 'In Development') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="project-detail-meta project-hero-chips">
|
||||||
{% if is_private or read_only %}
|
<span class="badge badge-type">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
|
||||||
<div class="project-detail-meta">
|
{% for plat in platforms %}
|
||||||
|
<span class="platform-tag">{{ plat.strip() }}</span>
|
||||||
|
{% endfor %}
|
||||||
{% if is_private %}<span class="badge badge-type">Private</span>{% endif %}
|
{% if is_private %}<span class="badge badge-type">Private</span>{% endif %}
|
||||||
{% if read_only %}<span class="badge badge-type">Read-only</span>{% endif %}
|
{% if read_only %}<span class="badge badge-type">Read-only</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="project-detail-meta">
|
|
||||||
<span class="badge badge-type">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
|
|
||||||
{% if project.get('release_date') %}
|
|
||||||
<span>📅 Released: {{ format_date(project['release_date']) }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if project.get('demo_date') %}
|
|
||||||
<span>🎭 Demo: {{ format_date(project['demo_date']) }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if forked_from %}
|
|
||||||
<div class="project-detail-meta">
|
|
||||||
<span>⑂ Forked from <a href="/projects/{{ forked_from['slug'] }}">{{ render_title(forked_from['title']) }}</a></span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="project-detail-author">
|
<div class="project-detail-author">
|
||||||
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
||||||
<div>
|
<div>
|
||||||
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
|
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
|
||||||
<span class="meta-muted">· Level {{ author.get('level', 1) if author else 1 }}</span>
|
<span class="meta-muted">· Level {{ author.get('level', 1) if author else 1 }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% if project.get('created_at') %}
|
||||||
|
<span class="meta-muted">🌱 Started {{ dt_ago(project['created_at']) }}</span>
|
||||||
{% if maturity_hidden(maturity, user) %}
|
|
||||||
{% set _level = maturity %}{% include "_maturity_gate.html" %}
|
|
||||||
{% else %}
|
|
||||||
<div class="project-detail-desc rendered-content">{{ render_content(project.get('description', ''), author_is_admin=is_admin(author)) }}</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if attachments %}
|
|
||||||
{% include "_attachment_display.html" %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if platforms %}
|
|
||||||
<div class="project-platforms">
|
|
||||||
<h4 class="project-section-label">Platforms</h4>
|
|
||||||
<div class="project-card-platforms">
|
|
||||||
{% for plat in platforms %}
|
|
||||||
<span class="platform-tag">{{ plat.strip() }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</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"><span class="icon">🌐</span> Visit Website</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="project-hero-body">
|
||||||
|
{% if project.get('release_date') or project.get('demo_date') or forked_from %}
|
||||||
|
<div class="project-detail-meta">
|
||||||
|
{% if project.get('release_date') %}
|
||||||
|
<span>📅 Released: {{ format_date(project['release_date']) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if project.get('demo_date') %}
|
||||||
|
<span>🎭 Demo: {{ format_date(project['demo_date']) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if forked_from %}
|
||||||
|
<span>⑂ Forked from <a href="/projects/{{ forked_from['slug'] }}">{{ render_title(forked_from['title']) }}</a></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="project-detail-actions">
|
<div class="project-detail-actions">
|
||||||
<a href="/projects/{{ project['slug'] or project['uid'] }}/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>
|
||||||
{% if workspace_editor_url %}
|
{% if workspace_editor_url %}
|
||||||
{% set _url = workspace_editor_url %}
|
{% set _url = workspace_editor_url %}
|
||||||
{% set _uid = project['uid'] %}
|
{% set _uid = project['uid'] %}
|
||||||
@ -81,7 +88,7 @@
|
|||||||
{% set _label = "Editor" %}
|
{% set _label = "Editor" %}
|
||||||
{% include "_editor_open.html" %}
|
{% include "_editor_open.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}"><span class="icon">🔗</span><span class="label"> Share</span></button>
|
<button type="button" class="project-star-btn" data-share="{{ project_url }}"><span class="icon">🔗</span><span class="label"> Share</span></button>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
|
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _my_vote = my_vote %}{% set _count = star_count %}{% set _btn_class = "project-star-btn" %}{% include "_star_vote.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -92,22 +99,23 @@
|
|||||||
|
|
||||||
<div class="project-actions-overflow" hidden>
|
<div class="project-actions-overflow" hidden>
|
||||||
{% if viewer_can_workspace %}
|
{% if viewer_can_workspace %}
|
||||||
<a href="/projects/{{ project['slug'] or project['uid'] }}/workspace" data-menu-action data-menu-icon="💻" data-menu-label="Workspace">Workspace</a>
|
<a href="{{ project_url }}/workspace" data-menu-action data-menu-icon="💻" data-menu-label="Workspace">Workspace</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if viewer_can_containers %}
|
{% if viewer_can_containers %}
|
||||||
<a href="/projects/{{ project['slug'] or project['uid'] }}/containers" data-menu-action data-menu-icon="🖥️" data-menu-label="Containers">Containers</a>
|
<a href="{{ project_url }}/containers" data-menu-action data-menu-icon="🖥️" data-menu-label="Containers">Containers</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button type="button" data-zip-download="/projects/{{ project['slug'] or project['uid'] }}/zip" data-menu-action data-menu-icon="📦" data-menu-label="Download zip">Download zip</button>
|
<button type="button" data-zip-download="{{ project_url }}/zip" data-menu-action data-menu-icon="📦" data-menu-label="Download zip">Download zip</button>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<button type="button" data-fork-project="/projects/{{ project['slug'] or project['uid'] }}/fork" data-fork-name="{{ project['title'] }}" data-menu-action data-menu-icon="⑂" data-menu-label="Fork">Fork</button>
|
<button type="button" data-fork-project="{{ project_url }}/fork" data-fork-name="{{ project['title'] }}" data-menu-action data-menu-icon="⑂" data-menu-label="Fork">Fork</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if is_owner %}
|
{% 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="edit-project-modal" data-menu-action data-menu-icon="✏️" data-menu-label="Edit">Edit</button>
|
||||||
<form method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/private">
|
<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 }}">
|
<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>
|
<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>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/readonly">
|
<form method="POST" action="{{ project_url }}/readonly">
|
||||||
<input type="hidden" name="value" value="{{ 0 if read_only else 1 }}">
|
<input type="hidden" name="value" value="{{ 0 if read_only else 1 }}">
|
||||||
<button type="submit" data-confirm-danger data-confirm="{% if read_only %}Allow file changes again for this project?{% else %}Make this project read-only? Files become immutable until you turn this off.{% endif %}" data-menu-action data-menu-icon="{% if read_only %}📝{% else %}🚫{% endif %}" data-menu-label="{% if read_only %}Allow edits{% else %}Make read-only{% endif %}">{% if read_only %}Allow edits{% else %}Make read-only{% endif %}</button>
|
<button type="submit" data-confirm-danger data-confirm="{% if read_only %}Allow file changes again for this project?{% else %}Make this project read-only? Files become immutable until you turn this off.{% endif %}" data-menu-action data-menu-icon="{% if read_only %}📝{% else %}🚫{% endif %}" data-menu-label="{% if read_only %}Allow edits{% else %}Make read-only{% endif %}">{% if read_only %}Allow edits{% else %}Make read-only{% endif %}</button>
|
||||||
</form>
|
</form>
|
||||||
@ -119,21 +127,137 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<section class="project-devlog">
|
<nav class="project-tabs" aria-label="Project sections">
|
||||||
<h3 class="project-section-label">Devlog</h3>
|
<a href="#about" class="project-tab active">Overview</a>
|
||||||
|
<a href="#devlog" class="project-tab">Devlog <span class="project-tab-count">{{ devlog_count }}</span></a>
|
||||||
|
{% if gallery %}
|
||||||
|
<a href="#screenshots" class="project-tab">Screenshots <span class="project-tab-count">{{ gallery | length }}</span></a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="#comments" class="project-tab">Comments <span class="project-tab-count">{{ comment_count }}</span></a>
|
||||||
|
<a href="{{ project_url }}/files" class="project-tab">Files <span class="project-tab-count">{{ file_count }}</span></a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="project-columns">
|
||||||
|
<div class="project-main">
|
||||||
|
<section class="project-about" id="about">
|
||||||
|
<h2 class="project-section-label">About</h2>
|
||||||
|
{% if maturity_hidden(maturity, user) %}
|
||||||
|
{% set _level = maturity %}{% include "_maturity_gate.html" %}
|
||||||
|
{% else %}
|
||||||
|
<div class="project-detail-desc rendered-content">{{ render_content(project.get('description', ''), author_is_admin=is_admin(author)) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if other_attachments %}
|
||||||
|
{% set attachments = other_attachments %}
|
||||||
|
{% include "_attachment_display.html" %}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="project-devlog" id="devlog">
|
||||||
|
<div class="project-devlog-header">
|
||||||
|
<h2 class="project-section-label">Devlog</h2>
|
||||||
|
<span class="project-devlog-count">{{ devlog_count }} update{{ '' if devlog_count == 1 else 's' }}</span>
|
||||||
|
{% if is_owner %}
|
||||||
|
<button type="button" class="btn btn-primary btn-sm project-devlog-post-btn" data-modal="create-post-modal"><span class="icon">✍️</span> Post update</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% if devlog_posts %}
|
{% if devlog_posts %}
|
||||||
{% for item in devlog_posts %}
|
{% for item in devlog_posts %}
|
||||||
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
|
{% set _author = item.author %}{% set _time = item.time_ago %}{% set _show_share = false %}{% set _show_comment_form = false %}{% include "_post_card.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
|
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-state">No devlog posts yet.</p>
|
<p class="empty-state">No devlog posts yet.{% if is_owner %} Share your first update to give this project a public build log.{% endif %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if gallery %}
|
||||||
|
<section class="project-screenshots" id="screenshots">
|
||||||
|
<h2 class="project-section-label">Screenshots</h2>
|
||||||
|
<div class="project-screenshot-grid">
|
||||||
|
{% for shot in gallery[:12] %}
|
||||||
|
<img src="{{ shot['thumbnail_url'] or shot['url'] }}" data-lightbox data-full="{{ shot['url'] }}" alt="{{ project['title'] }} screenshot {{ loop.index }}" loading="lazy" class="project-screenshot">
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if gallery | length > 12 %}
|
||||||
|
<p class="project-screenshot-more">Showing 12 of {{ gallery | length }} screenshots.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="project-comments" id="comments">
|
||||||
|
{% with target_uid=project['uid'], target_type="project" %}
|
||||||
|
{% include "_comment_section.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="project-sidebar">
|
||||||
|
<div class="project-sidebar-card">
|
||||||
|
<h2 class="project-section-label">Links</h2>
|
||||||
|
<ul class="project-link-list">
|
||||||
|
{% if project.get('website_url') %}
|
||||||
|
<li><a href="{{ project['website_url'] }}" target="_blank" rel="noopener nofollow">🌐 Website</a></li>
|
||||||
|
{% 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>
|
||||||
|
{% if forked_from %}
|
||||||
|
<li><a href="/projects/{{ forked_from['slug'] }}">⑂ Fork source: {{ render_title(forked_from['title']) }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-sidebar-card">
|
||||||
|
<h2 class="project-section-label">Stats</h2>
|
||||||
|
<div class="project-stats">
|
||||||
|
<span class="project-stat"><span class="project-stat-value">{{ star_count }}</span> stars</span>
|
||||||
|
<span class="project-stat"><a href="#devlog"><span class="project-stat-value">{{ devlog_count }}</span> update{{ '' if devlog_count == 1 else 's' }}</a></span>
|
||||||
|
<span class="project-stat"><a href="#comments"><span class="project-stat-value">{{ comment_count }}</span> comment{{ '' if comment_count == 1 else 's' }}</a></span>
|
||||||
|
<span class="project-stat"><a href="{{ project_url }}/files"><span class="project-stat-value">{{ file_count }}</span> file{{ '' if file_count == 1 else 's' }}</a></span>
|
||||||
|
<span class="project-stat"><span class="project-stat-value">{{ fork_count }}</span> fork{{ '' if fork_count == 1 else 's' }}</span>
|
||||||
|
</div>
|
||||||
|
{% if devlog_posts %}
|
||||||
|
<div class="project-last-update">Last update {{ dt_ago(devlog_posts[0].post['created_at']) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-sidebar-card">
|
||||||
|
<h2 class="project-section-label">Author</h2>
|
||||||
|
<div class="project-author-card">
|
||||||
|
{% set _size = 40 %}{% set _size_class = "md" %}{% set _user = author %}{% include "_avatar_link.html" %}
|
||||||
|
<div class="project-author-meta">
|
||||||
|
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
|
||||||
|
<span class="meta-muted">Level {{ author.get('level', 1) if author else 1 }} · {{ author.get('stars', 0) if author else 0 }} stars</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if is_owner %}
|
{% if is_owner %}
|
||||||
|
{% call modal('create-post-modal', 'Post an update') %}
|
||||||
|
{% set _composer_topic = 'devlog' %}{% set _composer_project = project['uid'] %}{% include "_post_composer_form.html" %}
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
{% call modal('add-screenshots-modal', 'Add screenshots') %}
|
||||||
|
<form method="POST" action="{{ project_url }}/screenshots">
|
||||||
|
<div class="auth-field auth-field-gap">
|
||||||
|
<label>Upload images</label>
|
||||||
|
{% include "_attachment_form.html" %}
|
||||||
|
<small class="hint-text">Images appear in the Screenshots gallery; other files list under About.</small>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary"><span class="icon">🖼️</span>Add screenshots</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% 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'] }}">
|
||||||
<div class="auth-field auth-field-gap">
|
<div class="auth-field auth-field-gap">
|
||||||
@ -157,6 +281,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2col">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="edit-project-website_url">Website (optional)</label>
|
||||||
|
<input type="url" id="edit-project-website_url" name="website_url" maxlength="500" placeholder="https://myproject.dev" value="{{ project.get('website_url', '') or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="edit-project-repo_url">Repository (optional)</label>
|
||||||
|
<input type="url" id="edit-project-repo_url" name="repo_url" maxlength="500" placeholder="https://github.com/me/project" value="{{ project.get('repo_url', '') or '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2col">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label>Cover image{% if cover %} (replaces current){% endif %}</label>
|
||||||
|
<dp-upload name="cover_attachment_uid" max-files="1" accept="image/*" allowed-types="png,jpg,jpeg,gif,webp" max-size="{{ max_upload_size_mb() }}" label="Upload cover"></dp-upload>
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label>Project logo{% if logo %} (replaces current){% endif %}</label>
|
||||||
|
<dp-upload name="logo_attachment_uid" max-files="1" accept="image/*" allowed-types="png,jpg,jpeg,gif,webp" max-size="{{ max_upload_size_mb() }}" label="Upload logo"></dp-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="auth-field auth-field-gap">
|
<div class="auth-field auth-field-gap">
|
||||||
<label>Type</label>
|
<label>Type</label>
|
||||||
<div class="flex-wrap-gap" role="group" aria-label="Type">
|
<div class="flex-wrap-gap" role="group" aria-label="Type">
|
||||||
@ -200,10 +346,6 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% with target_uid=project['uid'], target_type="project" %}
|
|
||||||
{% include "_comment_section.html" %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_js %}
|
{% block extra_js %}
|
||||||
@ -215,6 +357,3 @@ if (actions) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -126,6 +126,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2col">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="website_url">Website (optional)</label>
|
||||||
|
<input type="url" id="website_url" name="website_url" maxlength="500" placeholder="https://myproject.dev">
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label for="repo_url">Repository (optional)</label>
|
||||||
|
<input type="url" id="repo_url" name="repo_url" maxlength="500" placeholder="https://github.com/me/project">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2col">
|
||||||
|
<div class="auth-field">
|
||||||
|
<label>Cover image (optional)</label>
|
||||||
|
<dp-upload name="cover_attachment_uid" max-files="1" accept="image/*" allowed-types="png,jpg,jpeg,gif,webp" max-size="{{ max_upload_size_mb() }}" label="Upload cover"></dp-upload>
|
||||||
|
</div>
|
||||||
|
<div class="auth-field">
|
||||||
|
<label>Project logo (optional)</label>
|
||||||
|
<dp-upload name="logo_attachment_uid" max-files="1" accept="image/*" allowed-types="png,jpg,jpeg,gif,webp" max-size="{{ max_upload_size_mb() }}" label="Upload logo"></dp-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="auth-field auth-field-gap">
|
<div class="auth-field auth-field-gap">
|
||||||
<label>Type</label>
|
<label>Type</label>
|
||||||
<div class="flex-wrap-gap" role="group" aria-label="Type">
|
<div class="flex-wrap-gap" role="group" aria-label="Type">
|
||||||
|
|||||||
@ -422,6 +422,7 @@ Every state-changing action in DevPlace records one append-only row through `dev
|
|||||||
| `project.fork.request` | `routers/projects/index.py` |
|
| `project.fork.request` | `routers/projects/index.py` |
|
||||||
| `project.readonly.disable` | `routers/projects/index.py` |
|
| `project.readonly.disable` | `routers/projects/index.py` |
|
||||||
| `project.readonly.enable` | `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.private` | `routers/projects/index.py` |
|
||||||
| `project.visibility.public` | `routers/projects/index.py` |
|
| `project.visibility.public` | `routers/projects/index.py` |
|
||||||
| `project.zip.request` | `routers/projects/index.py` |
|
| `project.zip.request` | `routers/projects/index.py` |
|
||||||
|
|||||||
@ -271,3 +271,124 @@ def test_devlog_works_for_guest_visitor(app_server):
|
|||||||
assert "devlog_posts" in body
|
assert "devlog_posts" in body
|
||||||
assert body["devlog_posts"] == []
|
assert body["devlog_posts"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_devlog_count_and_comment_count_in_json(app_server):
|
||||||
|
"""devlog_count and comment_count ride the detail JSON."""
|
||||||
|
session, name = _member()
|
||||||
|
project = _create_project(session)
|
||||||
|
slug = project["slug"] or project["uid"]
|
||||||
|
user = _db_user(name)
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
_create_post_direct(project["uid"], user["uid"], i)
|
||||||
|
|
||||||
|
r = session.get(f"{BASE_URL}/projects/{slug}", headers=JSON)
|
||||||
|
assert r.status_code == 200, r.text[:300]
|
||||||
|
body = r.json()
|
||||||
|
assert body["devlog_count"] == 3
|
||||||
|
assert body["comment_count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_image(session, name="shot.png", color=(30, 60, 120)):
|
||||||
|
import io
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (10, 10), color).save(buf, "PNG")
|
||||||
|
r = session.post(
|
||||||
|
f"{BASE_URL}/uploads/upload",
|
||||||
|
files={"file": (name, buf.getvalue(), "image/png")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text[:300]
|
||||||
|
return r.json()["uid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cover_and_logo_attachments_render_in_hero(app_server):
|
||||||
|
"""cover/logo attachment uids resolve to the hero banner and logo tile."""
|
||||||
|
session, _ = _member()
|
||||||
|
cover_uid = _upload_image(session, "cover.png", (10, 20, 90))
|
||||||
|
logo_uid = _upload_image(session, "logo.png", (90, 20, 10))
|
||||||
|
|
||||||
|
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",
|
||||||
|
"cover_attachment_uid": cover_uid,
|
||||||
|
"logo_attachment_uid": logo_uid,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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_attachment_uid"] == cover_uid
|
||||||
|
assert row["logo_attachment_uid"] == logo_uid
|
||||||
|
|
||||||
|
html = session.get(f"{BASE_URL}/projects/{slug}").text
|
||||||
|
assert 'class="project-cover-img"' in html
|
||||||
|
assert 'class="project-logo"' in html
|
||||||
|
assert "project-screenshot-grid" not in html, (
|
||||||
|
"Cover and logo must not repeat in the Screenshots gallery"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hero_attachment_uid_rejects_foreign_and_missing(app_server):
|
||||||
|
"""A foreign or unknown attachment uid is ignored rather than linked."""
|
||||||
|
owner, _ = _member()
|
||||||
|
other, _ = _member()
|
||||||
|
foreign_uid = _upload_image(other, "foreign.png")
|
||||||
|
|
||||||
|
r = owner.post(
|
||||||
|
f"{BASE_URL}/projects/create",
|
||||||
|
headers=JSON,
|
||||||
|
data={
|
||||||
|
"title": _unique("dlreject"),
|
||||||
|
"description": "Hero guard test project",
|
||||||
|
"project_type": "software",
|
||||||
|
"status": "In Development",
|
||||||
|
"platforms": "",
|
||||||
|
"cover_attachment_uid": foreign_uid,
|
||||||
|
"logo_attachment_uid": "does-not-exist",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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_attachment_uid"] is None
|
||||||
|
assert row["logo_attachment_uid"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_adds_screenshots_from_the_page(app_server):
|
||||||
|
"""POST /projects/{slug}/screenshots links uploads into the gallery; non-owners are refused."""
|
||||||
|
session, _ = _member()
|
||||||
|
project = _create_project(session)
|
||||||
|
slug = project["slug"] or project["uid"]
|
||||||
|
|
||||||
|
assert "project-screenshot-grid" not in session.get(f"{BASE_URL}/projects/{slug}").text
|
||||||
|
|
||||||
|
uid = _upload_image(session, "gallery.png", (5, 120, 60))
|
||||||
|
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]
|
||||||
|
|||||||
@ -105,6 +105,47 @@ def test_owner_can_edit_project(app_server):
|
|||||||
assert row["platforms"] == "Linux,Web"
|
assert row["platforms"] == "Linux,Web"
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_can_set_and_clear_link_urls(app_server):
|
||||||
|
_, _, key = _signup_project_visibility()
|
||||||
|
slug = _create_project_project_visibility(key, "Website Via Api")["slug"]
|
||||||
|
r = requests.post(
|
||||||
|
f"{BASE_URL}/projects/edit/{slug}",
|
||||||
|
headers=_h_project_visibility(key),
|
||||||
|
data={
|
||||||
|
"title": "Website Via Api",
|
||||||
|
"description": "has links now",
|
||||||
|
"website_url": "myproject.dev/docs",
|
||||||
|
"repo_url": "github.com/me/website-via-api",
|
||||||
|
},
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["ok"] is True
|
||||||
|
row = get_table("projects").find_one(slug=slug)
|
||||||
|
assert row["website_url"] == "https://myproject.dev/docs"
|
||||||
|
assert row["repo_url"] == "https://github.com/me/website-via-api"
|
||||||
|
|
||||||
|
html = requests.get(f"{BASE_URL}/projects/{slug}").text
|
||||||
|
assert "Visit Website" in html
|
||||||
|
assert "Repository" in html
|
||||||
|
|
||||||
|
r = requests.post(
|
||||||
|
f"{BASE_URL}/projects/edit/{slug}",
|
||||||
|
headers=_h_project_visibility(key),
|
||||||
|
data={
|
||||||
|
"title": "Website Via Api",
|
||||||
|
"description": "links removed",
|
||||||
|
"website_url": "",
|
||||||
|
"repo_url": "",
|
||||||
|
},
|
||||||
|
allow_redirects=False,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
row = get_table("projects").find_one(slug=slug)
|
||||||
|
assert row["website_url"] is None
|
||||||
|
assert row["repo_url"] is None
|
||||||
|
assert "Visit Website" not in requests.get(f"{BASE_URL}/projects/{slug}").text
|
||||||
|
|
||||||
|
|
||||||
def test_non_owner_cannot_edit_project(app_server):
|
def test_non_owner_cannot_edit_project(app_server):
|
||||||
_, _, owner_key = _signup_project_visibility()
|
_, _, owner_key = _signup_project_visibility()
|
||||||
slug = _create_project_project_visibility(owner_key, "Owner Edit Guard")["slug"]
|
slug = _create_project_project_visibility(owner_key, "Owner Edit Guard")["slug"]
|
||||||
|
|||||||
@ -119,7 +119,7 @@ def _alice_key():
|
|||||||
def test_files_link_on_detail(alice):
|
def test_files_link_on_detail(alice):
|
||||||
page, _ = alice
|
page, _ = alice
|
||||||
_make_project_ui(page, "UI Files Link")
|
_make_project_ui(page, "UI Files Link")
|
||||||
link = page.locator("a:has-text('Files')")
|
link = page.locator(".project-detail-actions a:has-text('Files')")
|
||||||
expect(link).to_be_visible()
|
expect(link).to_be_visible()
|
||||||
link.click()
|
link.click()
|
||||||
page.wait_for_url("**/files", wait_until="domcontentloaded")
|
page.wait_for_url("**/files", wait_until="domcontentloaded")
|
||||||
|
|||||||
@ -85,7 +85,7 @@ def test_devlog_empty_state_on_project_page(alice):
|
|||||||
|
|
||||||
devlog_section = page.locator(".project-devlog")
|
devlog_section = page.locator(".project-devlog")
|
||||||
expect(devlog_section).to_be_visible()
|
expect(devlog_section).to_be_visible()
|
||||||
expect(devlog_section.locator("h3:has-text('Devlog')")).to_be_visible()
|
expect(devlog_section.locator("h2:has-text('Devlog')")).to_be_visible()
|
||||||
expect(page.locator(".empty-state:has-text('No devlog posts yet.')")).to_be_visible()
|
expect(page.locator(".empty-state:has-text('No devlog posts yet.')")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
@ -187,3 +187,42 @@ def test_devlog_multiple_posts_order(alice):
|
|||||||
f"Expected newest post first: {markers[-1]}, got: {first_text}"
|
f"Expected newest post first: {markers[-1]}, got: {first_text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_stats_card_visible(alice):
|
||||||
|
"""The sidebar Stats card shows stars, updates, comments, files, forks."""
|
||||||
|
page, _ = alice
|
||||||
|
slug, project_uid, owner_uid = _seed_project()
|
||||||
|
_seed_project_post(project_uid, owner_uid, 0)
|
||||||
|
|
||||||
|
page.goto(f"{BASE_URL}/projects/{slug}", wait_until="domcontentloaded")
|
||||||
|
|
||||||
|
stats = page.locator(".project-stats")
|
||||||
|
expect(stats).to_be_visible()
|
||||||
|
expect(stats.locator(".project-stat")).to_have_count(5)
|
||||||
|
expect(stats.locator(".project-stat:has-text('update')")).to_contain_text("1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_owner_post_update_button_opens_preset_composer(alice):
|
||||||
|
"""The owner's Post update button opens the composer preselected to this project."""
|
||||||
|
page, _ = alice
|
||||||
|
_create_project_ui(page, f"Composer Project {uuid4().hex[:6]}")
|
||||||
|
|
||||||
|
button = page.locator(".project-devlog-post-btn")
|
||||||
|
expect(button).to_be_visible()
|
||||||
|
button.click()
|
||||||
|
|
||||||
|
modal = page.locator("#create-post-modal")
|
||||||
|
expect(modal).to_be_visible()
|
||||||
|
expect(modal.locator("input[name='topic'][value='devlog']")).to_be_checked()
|
||||||
|
selected = modal.locator("#project_uid").input_value()
|
||||||
|
assert selected != "", "Expected the project preselected in the composer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_guest_sees_no_post_update_button(app_server):
|
||||||
|
"""Guests and non-owners get no Post update control."""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
slug, _, _ = _seed_project()
|
||||||
|
r = requests.get(f"{BASE_URL}/projects/{slug}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "project-devlog-post-btn" not in r.text
|
||||||
|
|||||||
@ -767,7 +767,7 @@ def test_project_comments_form_visible(alice):
|
|||||||
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
||||||
assert page.is_visible("text=Comments")
|
assert page.is_visible("text=Comments")
|
||||||
assert page.is_visible("text=No comments yet")
|
assert page.is_visible("text=No comments yet")
|
||||||
assert page.is_visible("textarea[name='content']")
|
assert page.is_visible(".comment-form textarea[name='content']")
|
||||||
|
|
||||||
|
|
||||||
def test_project_comment_create(alice):
|
def test_project_comment_create(alice):
|
||||||
@ -778,8 +778,8 @@ def test_project_comment_create(alice):
|
|||||||
page.fill("#description", "Project for creating a comment")
|
page.fill("#description", "Project for creating a comment")
|
||||||
page.click("button:has-text('Create Project')")
|
page.click("button:has-text('Create Project')")
|
||||||
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
||||||
page.fill("textarea[name='content']", "Great project!")
|
page.fill(".comment-form textarea[name='content']", "Great project!")
|
||||||
page.click("button:has-text('Post')")
|
page.click(".comment-form button:has-text('Post')")
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(500)
|
||||||
assert page.is_visible("text=Great project!")
|
assert page.is_visible("text=Great project!")
|
||||||
|
|
||||||
@ -792,8 +792,8 @@ def test_project_comment_reply(alice):
|
|||||||
page.fill("#description", "Project for testing reply")
|
page.fill("#description", "Project for testing reply")
|
||||||
page.click("button:has-text('Create Project')")
|
page.click("button:has-text('Create Project')")
|
||||||
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
||||||
page.fill("textarea[name='content']", "First comment")
|
page.fill(".comment-form textarea[name='content']", "First comment")
|
||||||
page.click("button:has-text('Post')")
|
page.click(".comment-form button:has-text('Post')")
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(500)
|
||||||
assert page.is_visible("text=First comment")
|
assert page.is_visible("text=First comment")
|
||||||
page.click("button:has-text('Reply')")
|
page.click("button:has-text('Reply')")
|
||||||
@ -812,8 +812,8 @@ def test_project_comment_delete(alice):
|
|||||||
page.fill("#description", "Project for testing delete")
|
page.fill("#description", "Project for testing delete")
|
||||||
page.click("button:has-text('Create Project')")
|
page.click("button:has-text('Create Project')")
|
||||||
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
|
||||||
page.fill("textarea[name='content']", "Comment to delete")
|
page.fill(".comment-form textarea[name='content']", "Comment to delete")
|
||||||
page.click("button:has-text('Post')")
|
page.click(".comment-form button:has-text('Post')")
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(500)
|
||||||
assert page.is_visible("text=Comment to delete")
|
assert page.is_visible("text=Comment to delete")
|
||||||
page.locator(".comment-action-btn:has-text('Delete')").click()
|
page.locator(".comment-action-btn:has-text('Delete')").click()
|
||||||
|
|||||||
@ -47,6 +47,24 @@ def test_isslop_run_form_normalizes_typos_and_bare_domains():
|
|||||||
assert IsslopRunForm(url="http:/x.dev/a").url == "http://x.dev/a"
|
assert IsslopRunForm(url="http:/x.dev/a").url == "http://x.dev/a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_project_form_link_urls_normalize_and_validate():
|
||||||
|
from devplacepy.models import ProjectForm, normalize_website_url
|
||||||
|
|
||||||
|
base = {"title": "T", "description": "D"}
|
||||||
|
assert ProjectForm(**base).website_url == ""
|
||||||
|
assert ProjectForm(**base, website_url="myproject.dev").website_url == "https://myproject.dev"
|
||||||
|
assert ProjectForm(**base, repo_url="github.com/me/x").repo_url == "https://github.com/me/x"
|
||||||
|
assert (
|
||||||
|
ProjectForm(**base, website_url="http://x.dev/a?b=1").website_url
|
||||||
|
== "http://x.dev/a?b=1"
|
||||||
|
)
|
||||||
|
assert normalize_website_url(" ") == ""
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ProjectForm(**base, website_url="javascript:alert(1)")
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ProjectForm(**base, repo_url="not a url")
|
||||||
|
|
||||||
|
|
||||||
def test_reaction_form_accepts_any_single_emoji():
|
def test_reaction_form_accepts_any_single_emoji():
|
||||||
from devplacepy.models import ReactionForm
|
from devplacepy.models import ReactionForm
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user