diff --git a/README.md b/README.md index 8917e5a2..df41ab83 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ devplacepy/ | `/gists` | Code gist listing, detail, creation, and editing; left panel offers language filtering and free-text `search` (title, description, and author username), public read | | `/comments` | Comment creation, owner editing (`POST /comments/edit/{comment_uid}`), deletion | | `/projects` | Project listing (left panel offers type filtering and free-text `search` over title, description, and author username), creation, owner editing (`POST /projects/edit/{slug}`), and per-project visibility toggles: `POST /projects/{slug}/private` (owner-only visibility) and `POST /projects/{slug}/readonly` (immutable files). A project hidden by a member stays visible to administrators, but a project hidden by an administrator is visible only to that owner administrator - other administrators cannot see it, its files, or its containers (web UI and REST API alike). The primary administrator (the first Admin account) is the single exception and retains full visibility | +| `/projects/{slug}` | Dedicated 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) | | `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` | | `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL | diff --git a/devplacepy/content.py b/devplacepy/content.py index 25fc306c..597f9e65 100644 --- a/devplacepy/content.py +++ b/devplacepy/content.py @@ -854,6 +854,10 @@ def enrich_items( return enriched +def count_project_devlog(project_uid: str) -> int: + return get_table("posts").count(project_uid=project_uid, deleted_at=None) + + def get_project_devlog( project_uid: str, before: str | None = None, viewer: dict | None = None ) -> tuple[list, str | None]: diff --git a/devplacepy/docs_api/groups/content.py b/devplacepy/docs_api/groups/content.py index fac43470..61cc1680 100644 --- a/devplacepy/docs_api/groups/content.py +++ b/devplacepy/docs_api/groups/content.py @@ -362,7 +362,7 @@ four ways to sign requests. method="GET", path="/projects/{project_slug}", title="View a project", - summary="Render a project with comments. Returns an HTML page.", + summary="Render the project overview with its devlog, screenshots and comments. Returns an HTML page.", auth="public", interactive=True, params=[ @@ -373,7 +373,42 @@ four ways to sign requests. True, "PROJECT_SLUG", "Slug or UID of the project.", - ) + ), + field( + "before", + "query", + "string", + False, + "", + "Devlog pagination cursor (devlog_next_cursor from the previous page).", + ), + ], + ), + endpoint( + id="projects-screenshots", + method="POST", + path="/projects/{project_slug}/screenshots", + title="Add screenshots to a project", + summary="Link uploaded image attachments to an owned project's Screenshots gallery. Redirects to the gallery.", + auth="user", + encoding="form", + params=[ + field( + "project_slug", + "path", + "string", + True, + "my-project-1a2b3c4d", + "Project slug or uid.", + ), + field( + "attachment_uids", + "form", + "string", + True, + "ATTACHMENT_UID", + "Comma-separated attachment uids from POST /uploads/upload or /uploads/upload-url.", + ), ], ), endpoint( @@ -443,6 +478,38 @@ four ways to sign requests. "31/12/2026", "Optional demo date in DD/MM/YYYY format.", ), + field( + "website_url", + "form", + "string", + False, + "https://myproject.dev", + "Optional official website URL (http/https).", + ), + field( + "repo_url", + "form", + "string", + False, + "https://github.com/me/project", + "Optional source repository URL (http/https).", + ), + 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( @@ -520,6 +587,38 @@ four ways to sign requests. "31/12/2026", "Optional demo date in DD/MM/YYYY format.", ), + field( + "website_url", + "form", + "string", + False, + "https://myproject.dev", + "Optional official website URL (http/https).", + ), + field( + "repo_url", + "form", + "string", + False, + "https://github.com/me/project", + "Optional source repository URL (http/https).", + ), + 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( diff --git a/devplacepy/models.py b/devplacepy/models.py index f1ce4687..c0ef24b1 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -5,7 +5,7 @@ import re from datetime import datetime from typing import Literal, Optional -from urllib.parse import urlsplit +from urllib.parse import urlsplit, urlparse from pydantic import BaseModel, Field, field_validator, model_validator from devplacepy.constants import TOPICS from devplacepy.rendering import is_single_emoji @@ -33,6 +33,20 @@ def normalize_european_date(value): raise ValueError("Date must be in DD/MM/YYYY format") +def normalize_website_url(value): + if not value: + return "" + text = str(value).strip() + if not text: + return "" + if not text.lower().startswith(("http://", "https://")): + text = f"https://{text}" + parsed = urlparse(text) + if parsed.scheme not in ("http", "https") or not parsed.hostname or "." not in parsed.hostname: + raise ValueError("Website must be a valid http(s) URL") + return text + + def normalize_poll_options(value): if value is None: return [] @@ -241,6 +255,10 @@ class ProjectForm(BaseModel): ) platforms: str = Field(default="", max_length=500) status: str = Field(default="In Development", max_length=100) + website_url: str = Field(default="", max_length=500) + repo_url: str = Field(default="", max_length=500) + cover_attachment_uid: str = Field(default="", max_length=64) + logo_attachment_uid: str = Field(default="", max_length=64) is_private: bool = False attachment_uids: list[str] = [] @@ -249,6 +267,11 @@ class ProjectForm(BaseModel): def normalize_dates(cls, 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): title: str = Field(min_length=1, max_length=200) @@ -260,12 +283,21 @@ class ProjectEditForm(BaseModel): ) platforms: str = Field(default="", max_length=500) status: str = Field(default="In Development", max_length=100) + website_url: str = Field(default="", max_length=500) + repo_url: str = Field(default="", max_length=500) + 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") @classmethod def normalize_dates(cls, 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): target: Literal["database", "uploads", "keys", "full"] = "full" @@ -290,6 +322,10 @@ class ProjectFlagForm(BaseModel): value: bool = False +class ProjectScreenshotsForm(BaseModel): + attachment_uids: list[str] = [] + + class CustomizationToggleForm(BaseModel): value: bool = False diff --git a/devplacepy/routers/projects/CLAUDE.md b/devplacepy/routers/projects/CLAUDE.md index 4e30f085..137e42fa 100644 --- a/devplacepy/routers/projects/CLAUDE.md +++ b/devplacepy/routers/projects/CLAUDE.md @@ -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`. +**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. **Owner editing.** Mirrors post editing exactly: an owner-only **Edit** menu item (`data-modal="edit-project-modal"`) opens the `modal()` macro's `edit-project-modal`, a plain `POST` form to `/projects/edit/{slug}` (route `edit_project` in `routers/projects/index.py`, body `ProjectEditForm`, owner-gated through the shared `content.edit_content_item` which returns 403 JSON / redirect for non-owners and stamps `updated_at`). The modal is the create modal pre-filled from the `project` row (title, description, type/status radios pre-checked, dates via `format_date()` back to DD/MM/YYYY). `is_private`/`read_only` are NOT edited here - they stay on their dedicated toggles. The platforms tag widget reuses the create modal's `platforms-input`/`platforms`/`platforms-tags` ids; `ProfileEditor.initPlatformTags` now **seeds existing tags** from the hidden `#platforms` value on load, so both the empty create form and the pre-filled edit form work from the same code. Devii tool `edit_project`; documented in `docs_api.py` (`projects-edit`). diff --git a/devplacepy/routers/projects/index.py b/devplacepy/routers/projects/index.py index 681c6e70..bc516617 100644 --- a/devplacepy/routers/projects/index.py +++ b/devplacepy/routers/projects/index.py @@ -4,9 +4,15 @@ import logging from typing import Annotated from sqlalchemy import or_ from fastapi import Depends, APIRouter, Request -from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm +from devplacepy.models import ( + ProjectForm, + ProjectEditForm, + ProjectFlagForm, + ProjectScreenshotsForm, + ForkForm, +) from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse -from devplacepy.attachments import get_attachments_batch +from devplacepy.attachments import get_attachments_batch, link_attachments from devplacepy.database import ( get_table, get_users_by_uids, @@ -25,6 +31,7 @@ from devplacepy.database import ( get_fork_parent, count_forks, get_top_authors, + get_user_attachment, ) from devplacepy.project_files import count_files from devplacepy.services.jobs import queue @@ -41,6 +48,7 @@ from devplacepy.content import ( can_view_project_containers, can_open_workspace, get_project_devlog, + count_project_devlog, ) from devplacepy.utils import ( get_current_user, @@ -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) async def project_detail(request: Request, project_slug: str, before: str = None): 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) 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( request, title=project.get("title", "Project"), description=project.get("description", ""), seo_target=("project", project["uid"]), robots=robots, - og_image=first_image_url(project, detail["attachments"]), + og_image=cover_url or first_image_url(project, detail["attachments"]), breadcrumbs=[ {"name": "Home", "url": "/feed"}, {"name": "Projects", "url": "/projects"}, @@ -292,8 +320,14 @@ async def project_detail(request: Request, project_slug: str, before: str = None "forked_from": forked_from, "fork_count": count_forks(project["uid"]), "file_count": count_files(project["uid"]), + "comment_count": get_table("comments").count( + target_type="project", + target_uid=project["uid"], + deleted_at=None, + ), "devlog_posts": devlog_posts, "devlog_next_cursor": devlog_next_cursor, + "devlog_count": count_project_devlog(project["uid"]), }, ), model=ProjectDetailOut, @@ -409,6 +443,8 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends( user = require_user(request) title = data.title.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( "projects", @@ -422,6 +458,10 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends( "project_type": data.project_type, "platforms": data.platforms.strip(), "status": data.status, + "website_url": data.website_url or None, + "repo_url": data.repo_url or None, + "cover_attachment_uid": cover_uid, + "logo_attachment_uid": logo_uid, "is_private": 1 if data.is_private else 0, "read_only": 0, }, @@ -432,6 +472,7 @@ async def create_project(request: Request, data: Annotated[ProjectForm, Depends( data.attachment_uids, request, ) + link_attachments([u for u in (cover_uid, logo_uid) if u], "project", uid) url = f"/projects/{project_slug}" return action_result( request, url, data={"uid": uid, "slug": project_slug, "url": url} @@ -442,23 +483,71 @@ async def edit_project( request: Request, project_slug: str, data: Annotated[ProjectEditForm, Depends(json_or_form(ProjectEditForm))] ): user = require_user(request) - return edit_content_item( + fields = { + "title": data.title.strip(), + "description": data.description.strip(), + "release_date": data.release_date or None, + "demo_date": data.demo_date or None, + "project_type": data.project_type, + "platforms": data.platforms.strip(), + "status": data.status, + "website_url": data.website_url or None, + "repo_url": data.repo_url or None, + } + 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, - { - "title": data.title.strip(), - "description": data.description.strip(), - "release_date": data.release_date or None, - "demo_date": data.demo_date or None, - "project_type": data.project_type, - "platforms": data.platforms.strip(), - "status": data.status, - }, + fields, "/projects", target_type="project", ) + 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 = { ("is_private", True): "project.visibility.private", diff --git a/devplacepy/schemas/content.py b/devplacepy/schemas/content.py index 8f8c79a1..1be90e70 100644 --- a/devplacepy/schemas/content.py +++ b/devplacepy/schemas/content.py @@ -121,6 +121,10 @@ class ProjectOut(_Out): read_only: Optional[bool] = None release_date: Optional[str] = None demo_date: Optional[str] = None + website_url: Optional[str] = None + repo_url: Optional[str] = None + cover_attachment_uid: Optional[str] = None + logo_attachment_uid: Optional[str] = None created_at: Optional[str] = None updated_at: Optional[str] = None diff --git a/devplacepy/schemas/listings.py b/devplacepy/schemas/listings.py index ab77b31e..dcc0d44f 100644 --- a/devplacepy/schemas/listings.py +++ b/devplacepy/schemas/listings.py @@ -175,8 +175,10 @@ class ProjectDetailOut(_Out): forked_from: Optional[dict] = None fork_count: int = 0 file_count: int = 0 + comment_count: int = 0 devlog_posts: list[FeedItemOut] = [] devlog_next_cursor: Optional[str] = None + devlog_count: int = 0 class GistsOut(_Out): diff --git a/devplacepy/services/devii/actions/catalog/projects.py b/devplacepy/services/devii/actions/catalog/projects.py index b6f0aae6..14b1db60 100644 --- a/devplacepy/services/devii/actions/catalog/projects.py +++ b/devplacepy/services/devii/actions/catalog/projects.py @@ -47,6 +47,10 @@ PROJECTS_ACTIONS: tuple[Action, ...] = ( body("project_type", "Project type."), body("platforms", "Supported platforms."), body("status", "Project status."), + body("website_url", "Official website URL (http/https)."), + body("repo_url", "Source repository URL (http/https)."), + body("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), ), ), @@ -67,6 +71,23 @@ PROJECTS_ACTIONS: tuple[Action, ...] = ( body("project_type", "Updated project type."), body("platforms", "Updated supported platforms."), body("status", "Updated project status."), + body("website_url", "Updated official website URL (http/https)."), + body("repo_url", "Updated source repository URL (http/https)."), + 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( diff --git a/devplacepy/static/css/projects.css b/devplacepy/static/css/projects.css index f6204e54..e65ef093 100644 --- a/devplacepy/static/css/projects.css +++ b/devplacepy/static/css/projects.css @@ -223,16 +223,327 @@ } } -.project-detail-page { - max-width: 720px; +.project-page { + max-width: var(--max-content); margin: 0 auto; } -.project-detail { + +.project-shell { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); +} + +.project-shell > .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; } + +.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 { display: flex; align-items: flex-start; @@ -304,14 +615,6 @@ color: var(--warning); } -.project-platforms { - margin-bottom: 1.5rem; -} - -.project-devlog { - margin-top: 1.5rem; -} - .project-section-label { font-size: 0.75rem; font-weight: 700; diff --git a/devplacepy/templates/CLAUDE.md b/devplacepy/templates/CLAUDE.md index c9cd74e1..d9e5ab31 100644 --- a/devplacepy/templates/CLAUDE.md +++ b/devplacepy/templates/CLAUDE.md @@ -65,6 +65,7 @@ Do NOT hand-write the overlay/header markup. Use the shared macro in `templates/ Reuse these via `{% set _x = ... %}{% include %}` (the `_avatar_link.html` convention) instead of copy-pasting markup: +- `_post_composer_form.html` - the create-post form (topic selector, content/title, project select, attachments, poll builder, footer). Locals: `_composer_topic` (preselected topic, default `random`), `_composer_project` (preselected project uid or `""`). Wrapped in the `modal()` macro by `feed.html` (Create New Post) and `project_detail.html` (owner-only Post an update, preset to `devlog` + the project). Never fork a second copy of this form. - `_post_votes.html` - post +/- vote bar. Locals: `_uid`, `_my_vote`, `_count`. - `_star_vote.html` - project/gist star button. Locals: `_type` (`project`|`gist`), `_uid`, `_my_vote`, `_count`, `_btn_class`, optional `_stop` (adds `data-stop-propagation`). The star glyph (`☆`→`★` when `.voted`) comes from the `vote-star` CSS class via `::before` (`base.css`) - do not put a literal star in markup. - `_post_header.html` - post author/avatar/time header (`.post-header`). Locals: `_author`, `_time`. diff --git a/devplacepy/templates/_post_composer_form.html b/devplacepy/templates/_post_composer_form.html new file mode 100644 index 00000000..c29943ff --- /dev/null +++ b/devplacepy/templates/_post_composer_form.html @@ -0,0 +1,48 @@ +
diff --git a/devplacepy/templates/feed.html b/devplacepy/templates/feed.html index 47cb5016..608bd9fe 100644 --- a/devplacepy/templates/feed.html +++ b/devplacepy/templates/feed.html @@ -160,54 +160,7 @@ + {% call modal('create-post-modal', 'Create New Post') %} - + {% set _composer_topic = 'random' %}{% set _composer_project = '' %}{% include "_post_composer_form.html" %} {% endcall %} {% else %} + diff --git a/devplacepy/templates/project_detail.html b/devplacepy/templates/project_detail.html index 1f0ea927..5bf8ceb4 100644 --- a/devplacepy/templates/project_detail.html +++ b/devplacepy/templates/project_detail.html @@ -3,137 +3,261 @@ {% block extra_head %} + {% endblock %} {% block content %} -