Compare commits

..
Author SHA1 Message Date
blindxfishandClaude Fable 5 8f9f461244 Hero text shadow and an owner Add screenshots flow in the More menu
The hero overlay title, tagline and author row get a dark text shadow
so they stay readable over bright cover art. The owner's More menu
gains Add screenshots: a modal with the shared attachment uploader
whose POST /projects/{slug}/screenshots (ProjectScreenshotsForm,
owner-only, audit project.screenshots.add, catalogued in events.md)
links the uploaded uids through the same attachments.link_attachments
choke point as create/edit and returns to #screenshots. Documented as
projects-screenshots in the API docs, exposed to Devii as
project_add_screenshots (upload via upload_file/attach_url first), and
covered by an api test for the owner flow, the gallery render, and the
non-owner 403.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 22:27:27 +02:00
blindxfishandClaude Fable 5 530ae2b5bb Wrap the project page in one encompassing dark card with lighter inner panels
The showcase reference nests the whole project inside a single dark
card while inner panels sit a step lighter - the previous layout had
it inverted (dark cards straight on the page ground). A .project-shell
container now wraps the hero, tab bar and columns with the bg-card
surface and clipped corners, and every inner panel (tab bar, sidebar
cards, devlog post cards, empty state, comments section) flips to the
lighter bg-secondary elevation inside it. The tab bar loses its sticky
positioning (impossible inside the shell's overflow clip) and the hero
sheds its own card chrome since the shell provides it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 22:19:39 +02:00
blindxfishandClaude Fable 5 8a2e0e6e70 Align the project hero with the showcase design: owner cover, logo, repo link
The hero now matches the showcase reference: the title block, tagline
(first description line), type/platform chips, author row and started
date render OVERLAID on the cover banner behind a bottom scrim, with
the optional project logo as a framed tile beside them and the Visit
Website CTA on the right. The section tab bar switches to the underline
style with Overview active.

Owners control the missing pieces from the create and edit modals,
which are now multipart: cover_image and logo_image file uploads
(stored as bare uploaded filenames via save_inline_image, the
posts.image pattern; a new upload replaces the previous one) plus a
repo_url sibling of website_url (same normalization/validation). The
cover feeds og:image and the schema image, the logo becomes schema
thumbnailUrl, and sameAs now carries website + repository. repo_url
rides ProjectOut, the Devii create/edit actions and the API docs;
tests cover repo normalization, the multipart upload round-trip and
the hero render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 22:12:25 +02:00
blindxfishandClaude Fable 5 3e9475fb61 Dedicate the project page to the project: hero, tabs, screenshots, sidebar
The project detail page becomes a full project showcase on the site's
content measure. The hero card opens with a cover banner from the
project's first image attachment (brand-gradient band as fallback),
then title + status chip, type/platform chips, dates and forked-from
meta, the author row with an owner-set Visit Website CTA, and the
unchanged action row. A sticky anchor tab bar (Overview, Devlog,
Screenshots when images exist, Comments, Files) navigates the page
with plain server-rendered anchors so crawlers index one complete
document. The two-column body keeps About (description + non-image
attachments), the Devlog timeline and the comment thread in the main
column, adds a Screenshots gallery built from image attachments
(lightbox-wired thumbnails), and a sidebar with Links (website, files,
fork source), the Stats card with a last-update line, and the Author
card.

New optional projects.website_url rides the whole stack: normalized
and validated in models (scheme-less input gets https://, non-http(s)
rejected), settable in the create and edit modals, on ProjectOut, in
the Devii create/edit actions and the API docs, rendered as the hero
CTA and Links entry with rel noopener nofollow, and emitted as
schema.org sameAs. The app schema also gains screenshot urls from the
image attachments. The e2e project comment/files tests scope their
locators (.comment-form textarea, .project-detail-actions a) per the
documented dual-control idiom - the composer modal made the bare
selectors ambiguous - and new unit/api tests cover URL normalization,
sameAs/screenshot schema output, the cover, and the gallery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:56:50 +02:00
blindxfishandClaude Fable 5 2af5110399 Make the project page an SEO-optimized overview with a devlog environment
The project detail page becomes a professional project overview: hero
with status/type/dates/forked-from, a stats strip (stars, updates,
comments, files, forks with #devlog/#comments anchors), an About
section, platforms, and the Devlog timeline under a proper h2 - now
rendered with feed.css loaded so the post cards are actually styled.
The owner posts updates from the page itself: a Post update button
opens the shared create-post composer preset to the devlog topic and
this project. The composer form is extracted into
_post_composer_form.html and reused by feed.html - one form, two
surfaces.

SEO: software_application_schema is type-aware via project_schema_type
(game -> VideoGame with gamePlatform, website -> WebApplication,
software/mobile_app -> SoftwareApplication, game_asset -> CreativeWork)
and now carries keywords, image, an aggregateRating from stars, and a
comment InteractionCounter. project_devlog_schema emits a Blog node
with one BlogPosting per devlog entry. The detail route feeds both,
adds meta keywords, and exposes the devlog cursor as rel=next; the
sitemap's project lastmod follows the newest devlog post via one
grouped query. devlog_count/comment_count ride ProjectDetailOut, the
docs projects-detail endpoint documents the before cursor, and the
routers/projects CLAUDE.md, templates CLAUDE.md and README document the
new surface. Unit, api and e2e tests cover the schema mapping, the
JSON-LD in the rendered page, the stats strip, and the preset composer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:36:51 +02:00
blindxfishandClaude Fable 5 3fca4be72e Flatten control accents: no gradient or glow on buttons, tabs, or the FAB
The reference design uses flat primary buttons, so every interactive
control drops the brand gradient and the accent glow: .btn-primary
(flat accent, accent-hover on hover), the feed nav tabs, the projects
tabs, the sidebar active link, and the create-post FAB (which keeps a
neutral elevation shadow). Gradients remain only on decorative meters
and stripes (XP fills, progress bars, the project card top stripe).
The now-unused --glow-accent token is removed, and deepsearch.css
loses its var() fallback on the progress gradient.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:36:51 +02:00
blindxfishandClaude Fable 5 c09e6b328c Fix the member home layout: align the Code Farm promo, fill sparse post rows
The signed-in dashboard is a 1200px two-column grid, but the shared
Code Farm promo section stayed at the guest page's 960px centered
measure, so it floated misaligned under the dashboard columns. A
landing-game-dashboard modifier (applied only when a user is signed
in) stretches it to the dashboard's own measure and gutters. The
Latest Posts grid becomes repeat(auto-fit, minmax(320px, 1fr)) so a
single post fills the row instead of leaving a dead half-column while
two or more still lay out as the original two columns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:32:53 +02:00
blindxfishandClaude Fable 5 2921edd7f3 Retarget the theme to the devplace main-branch design (indigo + rust)
The reference design is the devplace Next.js main branch, not the
terminal branch the previous commit tracked. Swap the token values to
that design language: deep indigo page (#271b5b) over near-black indigo
cards (#13112a/#1a1736), #11101f hairlines, purple-tinted text ramp
(#e9e4ff/#c8c3e5/#9b93c9), burnt-orange primary accent (#b73f1e) with
a raspberry hover (#af3050) and a rust-to-plum brand gradient, white
on-accent text, soft rounded radii (8/12/16px), the reference's diffuse
soft/medium/strong shadows, a warm accent glow, and Trebuchet MS as the
UI font (system font - the vendored JetBrains Mono files are removed
along with their base.html link).

Follow-through updated to match: theme-color meta + PWA manifest
(#271b5b), offline page, avatar fallback SVG, statistics chart palette
(brand ramp), chat light-theme overrides, OG image + app icons
regenerated in the new palette, and the /docs/styles-colors guide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:22:32 +02:00
blindxfishandClaude Fable 5 c19ff19de2 Align the platform look and feel with the devplace.net terminal design
Retheme every token in variables.css to the terminal design language:
near-black surfaces (#0a0a0f/#111118/#1a1a24), terminal green accent
(#22c55e) with dark on-accent text, gray text ramp, #222233 borders,
sharp 4-8px radii, neutral depth shadows, and JetBrains Mono as the
primary UI font (vendored latin woff2, no CDN).

Follow-through outside the token file: white-on-accent buttons/tabs now
use the new --on-accent token for contrast, three var() fallback
violations shipping the old palette are gone, the dead --bg-hover token
is replaced with --bg-card-hover, avatar fallback SVG, statistics chart
palette, chat light-theme overrides, offline page, PWA manifest,
theme-color meta, OG image + app icons regenerated in the new palette,
and the /docs/styles-colors style guide now documents the new values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 20:55:46 +02:00
54 changed files with 1592 additions and 383 deletions
+1
View File
@@ -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 SEO-optimized project page: hero with an owner-uploaded cover banner and project logo (falling back to the first image attachment), the title overlaid on the banner, status/type/platform chips, owner-set **Website** and **Repository** links, section tabs (Overview, Devlog, Screenshots, Comments, Files), an About section, the **Devlog** timeline of every post linked to the project, a Screenshots gallery built from image attachments (the owner adds more from the More menu), and a sidebar with links, stats (stars, updates, comments, files, forks, last update) and the author card. The owner posts updates straight from the page (composer preset to the project with the `devlog` topic). Emits type-aware JSON-LD (VideoGame / WebApplication / SoftwareApplication / CreativeWork with rating, keywords, image, screenshots and `sameAs`) plus a `Blog`/`BlogPosting` graph for the devlog; the sitemap's `lastmod` follows the newest devlog post |
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) |
| `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
| `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL |
+1 -1
View File
@@ -28,6 +28,6 @@ def generate_avatar_svg(seed: str) -> str:
initial = seed[:1].upper() if seed else "?"
return (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">'
f'<rect width="100" height="100" rx="50" fill="#ff6b35"/>'
f'<rect width="100" height="100" rx="50" fill="#b73f1e"/>'
f'<text x="50" y="65" text-anchor="middle" fill="white" font-size="40" font-weight="700" font-family="sans-serif">{initial}</text></svg>'
)
+4
View File
@@ -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]:
+69 -2
View File
@@ -362,7 +362,7 @@ four ways to sign requests.
method="GET",
path="/projects/{project_slug}",
title="View a project",
summary="Render a project with comments. Returns an HTML page.",
summary="Render a project overview with its devlog and comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
@@ -373,7 +373,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,22 @@ 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).",
),
],
),
endpoint(
@@ -520,6 +571,22 @@ 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).",
),
],
),
endpoint(
+33 -1
View File
@@ -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,8 @@ 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)
is_private: bool = False
attachment_uids: list[str] = []
@@ -249,6 +265,11 @@ class ProjectForm(BaseModel):
def normalize_dates(cls, value):
return normalize_european_date(value)
@field_validator("website_url", "repo_url")
@classmethod
def valid_website_url(cls, value):
return normalize_website_url(value)
class ProjectEditForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
@@ -260,12 +281,19 @@ 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)
@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_website_url(cls, value):
return normalize_website_url(value)
class BackupRunForm(BaseModel):
target: Literal["database", "uploads", "keys", "full"] = "full"
@@ -290,6 +318,10 @@ class ProjectFlagForm(BaseModel):
value: bool = False
class ProjectScreenshotsForm(BaseModel):
attachment_uids: list[str] = []
class CustomizationToggleForm(BaseModel):
value: bool = False
+1 -1
View File
@@ -246,7 +246,7 @@ The feed page (`GET /feed`) is accessible without authentication:
All SEO features are implemented across the following locations:
### Core SEO utilities
- `devplacepy/seo.py` - JSON-LD schema generators (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, SoftwareApplication), meta description truncation, schema combiner, sitemap XML generator
- `devplacepy/seo.py` - JSON-LD schema generators (WebSite, BreadcrumbList, DiscussionForumPosting, ProfilePage, the type-aware project schema via `project_schema_type` - VideoGame/WebApplication/SoftwareApplication/CreativeWork - plus the devlog `Blog`/`BlogPosting` graph `project_devlog_schema`), meta description truncation, schema combiner, sitemap XML generator (project `lastmod` = latest of created/updated/newest devlog post)
- `routers/seo.py` - robots.txt and sitemap.xml routes
### SEO template context
+4
View File
@@ -9,6 +9,10 @@ This file documents the project detail page, the per-project virtual filesystem,
Each project card links to `/projects/{project_uid}` showing full project details, author info, platforms, star count, delete-for-owner, and (for the owner) Private/Read-only toggle buttons plus badges (see **Project visibility and read-only** below). The route is `GET /projects/{project_uid}` in `routers/projects/index.py` and 404s when the viewer cannot see a private project. The sitemap generator links to this URL (not the old `?user_uid=` query param). The detail page also links to the project filesystem at `/projects/{slug}/files`.
**Project overview + devlog (SEO surface).** The detail page is a dedicated project showcase (`.project-page`, full `--max-content` measure): a hero card (`.project-hero`) whose cover banner is the owner-uploaded `projects.cover_image` falling back to the project's **first image attachment** (brand-gradient band when neither exists), with the title block OVERLAID on the banner behind a bottom scrim (`.project-hero-overlay`) next to the optional `projects.logo_image` tile - both are bare uploaded filenames served at `/static/uploads/{name}` via `attachments.save_inline_image` (the `posts.image` pattern; the create/edit modal forms are `multipart/form-data` with plain `cover_image`/`logo_image` file inputs read by `_uploaded_project_images`, a new upload replaces the old, no removal control) - the h1 title + status chip, type badge + platform chips + Private/Read-only badges, dates/forked-from meta, the author row with owner-set **Visit Website** and **Repository** links (`projects.website_url`/`projects.repo_url`, optional, both validated/normalized by `models.normalize_website_url` - scheme-less input gets `https://`, non-http(s) rejected; edited in the create and edit modals, `rel="noopener nofollow"` on render), and the action row (unchanged wiring incl. the `.project-actions-more` overflow). Below the hero an anchor **section tab bar** (`.project-tabs`, sticky) links Overview `#about` / Devlog `#devlog` / Screenshots `#screenshots` (only when images exist) / Comments `#comments` / the Files page - server-rendered anchors, no JS tab state, so crawlers see the whole page. The body is a two-column grid (`.project-columns`, sidebar collapses at 1024px): the main column holds **About** (description + non-image attachments), the **Devlog** (`h2`, every post whose `project_uid` points at the project via `_post_card.html` - the template loads `feed.css` for the card styles alongside `post.css`, same rule as `news.html`), a **Screenshots** gallery (all image attachments, `data-lightbox` thumbnails; the owner adds more via the More-menu **Add screenshots** modal - `_attachment_form.html` uploads to `/uploads/upload`, then `POST /projects/{slug}/screenshots` (`ProjectScreenshotsForm`, owner-only, audit `project.screenshots.add`) links the uids through the same `attachments.link_attachments` choke point as create/edit; Devii action `project_add_screenshots`, docs id `projects-screenshots`), and Comments; the sidebar holds Links (website / files / fork source), the Stats card (`.project-stats`, 5 `.project-stat` entries + last-update line), and the Author card. `devlog_count` (`content.count_project_devlog`) and `comment_count` ride the context and `ProjectDetailOut`; `website_url` rides `ProjectOut`. The owner gets a **Post update** button (`.project-devlog-post-btn`) opening the shared create-post composer preset to `topic=devlog` + this project - the composer form lives ONCE in `templates/_post_composer_form.html` (locals `_composer_topic`, `_composer_project`) and is included by both `feed.html` and `project_detail.html`; never fork a second copy of that form. **Locator discipline:** the page has several `Files` anchors (action row, tab bar, sidebar) and, for owners, a second hidden `textarea[name='content']`/Post button inside the composer modal - tests MUST scope (`.project-detail-actions a:has-text('Files')`, `.comment-form textarea[name='content']`).
**JSON-LD** (`seo.py`): `software_application_schema(project, base, image_url=, star_count=, comment_count=, screenshot_urls=)` (screenshots = up to 6 absolute image-attachment urls -> `screenshot`; `website_url` + `repo_url` -> `sameAs`; `logo_image` -> `thumbnailUrl`; the og/schema image prefers `cover_image`) is type-aware via `project_schema_type` (`game` -> `VideoGame` with `gamePlatform`, `website` -> `WebApplication`, `software`/`mobile_app` -> `SoftwareApplication`, `game_asset` -> `CreativeWork`), adds `keywords` (type + platforms), `image`, an `aggregateRating` when the project has stars, and a comment `InteractionCounter`. `project_devlog_schema(project, devlog_posts, base)` emits a `Blog` node (`@id` = `{project_url}#devlog`) with one `BlogPosting` per rendered devlog entry; it returns `None` for an empty devlog (dropped by `combine`). The devlog cursor rides `base_seo_context(next_url=next_page_url(request, devlog_next_cursor))` for a crawlable `rel=next`, `keywords` carries title/type/platforms, and the sitemap's project `lastmod` is `max(created_at, updated_at, newest devlog post)` via one grouped posts query in `_build_sitemap`. The `before` devlog cursor is documented on `projects-detail` in `docs_api/groups/content.py`.
**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`).
+154 -45
View File
@@ -4,9 +4,19 @@ import logging
from typing import Annotated
from sqlalchemy import or_
from fastapi import Depends, APIRouter, Request
from devplacepy.models import ProjectForm, ProjectEditForm, ProjectFlagForm, ForkForm
from devplacepy.models import (
ProjectForm,
ProjectEditForm,
ProjectFlagForm,
ProjectScreenshotsForm,
ForkForm,
)
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from devplacepy.attachments import get_attachments_batch
from devplacepy.attachments import (
get_attachments_batch,
link_attachments,
save_inline_image,
)
from devplacepy.database import (
get_table,
get_users_by_uids,
@@ -41,6 +51,7 @@ from devplacepy.content import (
can_view_project_containers,
can_open_workspace,
get_project_devlog,
count_project_devlog,
)
from devplacepy.utils import (
get_current_user,
@@ -52,10 +63,12 @@ from devplacepy.utils import (
XP_PROJECT,
)
from devplacepy.seo import (
absolute_url,
base_seo_context,
site_url,
website_schema,
software_application_schema,
project_devlog_schema,
list_page_seo,
next_page_url,
)
@@ -206,40 +219,6 @@ async def project_detail(request: Request, project_slug: str, before: str = None
user["uid"], resolve_object_url("project", project["uid"])
)
base = site_url(request)
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
seo_ctx = base_seo_context(
request,
title=project.get("title", "Project"),
description=project.get("description", ""),
seo_target=("project", project["uid"]),
robots=robots,
og_image=first_image_url(project, detail["attachments"]),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
{
"name": project.get("title", "Project"),
"url": f"/projects/{project['slug'] or project['uid']}",
},
],
schemas=[website_schema(base), software_application_schema(project, base)],
)
viewer_can_workspace = can_open_workspace(project, user)
workspace_editor_url = (
_editor_url(project, user) if viewer_can_workspace else ""
)
parent = get_fork_parent(project["uid"])
forked_from = (
{
"uid": parent["uid"],
"slug": parent.get("slug") or parent["uid"],
"title": parent.get("title") or "project",
}
if parent
else None
)
devlog_posts, devlog_next_cursor = get_project_devlog(
project["uid"], before=before, viewer=user
)
@@ -257,6 +236,76 @@ async def project_detail(request: Request, project_slug: str, before: str = None
item["reactions"] = reactions_map.get(uid, {"counts": {}, "mine": []})
item["bookmarked"] = uid in bookmark_set
item["poll"] = polls_map.get(uid)
devlog_count = count_project_devlog(project["uid"])
base = site_url(request)
robots = "noindex,nofollow" if project.get("is_private") else "index,follow"
cover_url = (
f"/static/uploads/{project['cover_image']}"
if project.get("cover_image")
else None
)
og_image = cover_url or first_image_url(project, detail["attachments"])
screenshots = [a for a in detail["attachments"] if a.get("is_image")]
comment_total = get_table("comments").count(
target_type="project", target_uid=project["uid"], deleted_at=None
)
platforms = [
p.strip() for p in (project.get("platforms") or "").split(",") if p.strip()
]
keyword_parts = [
project.get("title", ""),
project.get("project_type", "").replace("_", " "),
*platforms,
"devlog",
"developer project",
]
seo_ctx = base_seo_context(
request,
title=project.get("title", "Project"),
description=project.get("description", ""),
seo_target=("project", project["uid"]),
robots=robots,
og_image=og_image,
keywords=", ".join(part for part in keyword_parts if part),
next_url=next_page_url(request, devlog_next_cursor),
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Projects", "url": "/projects"},
{
"name": project.get("title", "Project"),
"url": f"/projects/{project['slug'] or project['uid']}",
},
],
schemas=[
website_schema(base),
software_application_schema(
{**project, "author_name": (detail.get("author") or {}).get("username", "Unknown")},
base,
image_url=absolute_url(base, og_image),
star_count=detail.get("star_count", 0),
comment_count=comment_total,
screenshot_urls=[
absolute_url(base, a["url"]) for a in screenshots[:6]
],
),
project_devlog_schema(project, devlog_posts, base),
],
)
viewer_can_workspace = can_open_workspace(project, user)
workspace_editor_url = (
_editor_url(project, user) if viewer_can_workspace else ""
)
parent = get_fork_parent(project["uid"])
forked_from = (
{
"uid": parent["uid"],
"slug": parent.get("slug") or parent["uid"],
"title": parent.get("title") or "project",
}
if parent
else None
)
return respond(
request,
@@ -279,8 +328,10 @@ async def project_detail(request: Request, project_slug: str, before: str = None
"forked_from": forked_from,
"fork_count": count_forks(project["uid"]),
"file_count": count_files(project["uid"]),
"comment_count": comment_total,
"devlog_posts": devlog_posts,
"devlog_next_cursor": devlog_next_cursor,
"devlog_count": devlog_count,
},
),
model=ProjectDetailOut,
@@ -391,11 +442,27 @@ async def delete_project(request: Request, project_slug: str):
request, "projects", "project", user, project_slug, "/projects"
)
async def _uploaded_project_images(request: Request) -> dict:
images = {}
try:
form = await request.form()
except Exception:
return images
for field in ("cover_image", "logo_image"):
upload = form.get(field)
if upload is not None and hasattr(upload, "filename") and upload.filename:
filename = save_inline_image(await upload.read(), upload.filename)
if filename:
images[field] = filename
return images
@router.post("/create")
async def create_project(request: Request, data: Annotated[ProjectForm, Depends(json_or_form(ProjectForm))]):
user = require_user(request)
title = data.title.strip()
description = data.description.strip()
images = await _uploaded_project_images(request)
uid, project_slug = create_content_item(
"projects",
@@ -409,6 +476,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_image": images.get("cover_image"),
"logo_image": images.get("logo_image"),
"is_private": 1 if data.is_private else 0,
"read_only": 0,
},
@@ -429,24 +500,62 @@ async def edit_project(
request: Request, project_slug: str, data: Annotated[ProjectEditForm, Depends(json_or_form(ProjectEditForm))]
):
user = require_user(request)
fields = {
"title": data.title.strip(),
"description": data.description.strip(),
"release_date": data.release_date or None,
"demo_date": data.demo_date or None,
"project_type": data.project_type,
"platforms": data.platforms.strip(),
"status": data.status,
"website_url": data.website_url or None,
"repo_url": data.repo_url or None,
}
fields.update(await _uploaded_project_images(request))
return edit_content_item(
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",
)
@router.post("/{project_slug}/screenshots")
async def add_project_screenshots(
request: Request,
project_slug: str,
data: Annotated[ProjectScreenshotsForm, Depends(json_or_form(ProjectScreenshotsForm))],
):
user = require_user(request)
project = resolve_by_slug(get_table("projects"), project_slug)
if not project:
raise not_found("Project not found")
if not is_owner(project, user):
if wants_json(request):
return json_error(403, "Not allowed")
return RedirectResponse(url=f"/projects/{project_slug}", status_code=302)
link_attachments(data.attachment_uids, "project", project["uid"])
audit.record(
request,
"project.screenshots.add",
user=user,
target_type="project",
target_uid=project["uid"],
target_label=project.get("title"),
metadata={"attachment_count": len(data.attachment_uids)},
summary=f"{user['username']} added {len(data.attachment_uids)} screenshot(s) to project {project.get('title')}",
links=[audit.target("project", project["uid"], project.get("title"))],
)
url = f"/projects/{project['slug'] or project['uid']}#screenshots"
return action_result(
request,
url,
data={"uid": project["uid"], "linked": len(data.attachment_uids), "url": url},
)
_FLAG_EVENTS = {
("is_private", True): "project.visibility.private",
("is_private", False): "project.visibility.public",
+4
View File
@@ -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_image: Optional[str] = None
logo_image: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
+2
View File
@@ -172,8 +172,10 @@ class ProjectDetailOut(_Out):
forked_from: Optional[dict] = None
fork_count: int = 0
file_count: int = 0
comment_count: int = 0
devlog_posts: list[FeedItemOut] = []
devlog_next_cursor: Optional[str] = None
devlog_count: int = 0
class GistsOut(_Out):
+118 -8
View File
@@ -8,7 +8,6 @@ from urllib.parse import urlencode
from xml.etree.ElementTree import Element, tostring
from xml.dom import minidom
from devplacepy.config import SITE_URL
from devplacepy.utils import strip_html
logger = logging.getLogger(__name__)
@@ -139,19 +138,113 @@ def profile_page_schema(profile_user, post_count, base_url):
}
def software_application_schema(project, base_url):
return {
"@type": "SoftwareApplication",
PROJECT_SCHEMA_TYPES = {
"game": "VideoGame",
"game_asset": "CreativeWork",
"software": "SoftwareApplication",
"mobile_app": "SoftwareApplication",
"website": "WebApplication",
}
def project_schema_type(project_type):
return PROJECT_SCHEMA_TYPES.get((project_type or "").lower(), "SoftwareApplication")
def software_application_schema(
project,
base_url,
image_url="",
star_count=0,
comment_count=0,
screenshot_urls=None,
):
schema_type = project_schema_type(project.get("project_type"))
platforms = [p.strip() for p in (project.get("platforms") or "").split(",") if p.strip()]
author_name = project.get("author_name", "Unknown")
schema = {
"@type": schema_type,
"name": project.get("title", "Untitled"),
"description": truncate(plain_markdown(project.get("description", "")), 300),
"url": f"{base_url}/projects/{project.get('slug') or project['uid']}",
"applicationCategory": "DeveloperApplication",
"operatingSystem": project.get("platforms", "Cross-platform"),
"author": {"@type": "Person", "name": project.get("author_name", "Unknown")},
"operatingSystem": project.get("platforms") or "Cross-platform",
"author": {"@type": "Person", "name": author_name},
"creator": {"@type": "Person", "name": author_name},
"datePublished": project.get("created_at", ""),
"dateModified": project.get("updated_at") or project.get("created_at", ""),
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
}
if schema_type in ("SoftwareApplication", "WebApplication"):
schema["applicationCategory"] = "DeveloperApplication"
if schema_type == "WebApplication":
schema["browserRequirements"] = "Requires JavaScript"
if schema_type == "VideoGame" and platforms:
schema["gamePlatform"] = platforms
if project.get("release_date"):
schema["releasedEvent"] = {
"@type": "PublicationEvent",
"startDate": project["release_date"],
}
keywords = [project.get("project_type", "").replace("_", " ").strip(), *platforms]
keywords = [k for k in keywords if k]
if keywords:
schema["keywords"] = ", ".join(keywords)
if image_url:
schema["image"] = image_url
if screenshot_urls:
schema["screenshot"] = list(screenshot_urls)
if project.get("logo_image"):
schema["thumbnailUrl"] = f"{base_url}/static/uploads/{project['logo_image']}"
same_as = [
url for url in (project.get("website_url"), project.get("repo_url")) if url
]
if same_as:
schema["sameAs"] = same_as
if star_count > 0:
schema["aggregateRating"] = {
"@type": "AggregateRating",
"ratingValue": "5",
"ratingCount": str(star_count),
"bestRating": "5",
"worstRating": "1",
}
if comment_count > 0:
schema["interactionStatistic"] = {
"@type": "InteractionCounter",
"interactionType": "https://schema.org/CommentAction",
"userInteractionCount": comment_count,
}
return schema
def project_devlog_schema(project, devlog_posts, base_url):
if not devlog_posts:
return None
project_url = f"{base_url}/projects/{project.get('slug') or project['uid']}"
entries = []
for item in devlog_posts:
post = item.get("post") or {}
author = item.get("author") or {}
entry = {
"@type": "BlogPosting",
"headline": post.get("title") or truncate(plain_markdown(post.get("content", "")), 80) or "Untitled update",
"url": f"{base_url}/posts/{post.get('slug') or post.get('uid', '')}",
"articleBody": truncate(plain_markdown(post.get("content", "")), 300),
"datePublished": post.get("created_at", ""),
"dateModified": post.get("updated_at") or post.get("created_at", ""),
"author": {"@type": "Person", "name": author.get("username") or "Unknown"},
}
if item.get("comment_count"):
entry["commentCount"] = item["comment_count"]
entries.append(entry)
return {
"@type": "Blog",
"@id": f"{project_url}#devlog",
"name": f"{project.get('title', 'Project')} devlog",
"url": f"{project_url}#devlog",
"about": project.get("title", "Project"),
"blogPost": entries,
}
def web_application_schema(name, description, path, base_url, category="DeveloperApplication"):
@@ -461,6 +554,12 @@ def _build_sitemap(base_url):
)
if "projects" in db.tables:
latest_devlog = {}
if "posts" in db.tables:
for row in db.query(
"SELECT project_uid, MAX(created_at) AS latest FROM posts WHERE deleted_at IS NULL AND project_uid IS NOT NULL GROUP BY project_uid"
):
latest_devlog[row["project_uid"]] = row["latest"]
projects = _collect(
get_table("projects"),
SITEMAP_URL_LIMIT,
@@ -470,10 +569,21 @@ def _build_sitemap(base_url):
for p in projects:
if p.get("is_private"):
continue
lastmod = max(
filter(
None,
(
p.get("created_at", ""),
p.get("updated_at") or "",
latest_devlog.get(p["uid"], ""),
),
),
default="",
)
urlset.append(
url_element(
f"{base_url}/projects/{p.get('slug') or p['uid']}",
lastmod=p.get("created_at", ""),
lastmod=lastmod,
changefreq="weekly",
priority="0.6",
)
@@ -47,6 +47,8 @@ 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("attachment_uids", ATTACHMENTS),
),
),
@@ -67,6 +69,21 @@ 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)."),
),
),
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(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+7 -7
View File
@@ -205,14 +205,14 @@
}
.admin-btn-primary {
background: var(--accent, var(--info));
color: var(--white);
background: var(--accent);
color: var(--on-accent);
border-color: transparent;
}
.admin-btn-primary:hover {
background: var(--accent-hover, var(--accent));
color: var(--white);
background: var(--accent-hover);
color: var(--on-accent);
}
.admin-btn-danger {
@@ -399,7 +399,7 @@
.pagination-btn:hover {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
border-color: var(--accent);
text-decoration: none;
}
@@ -443,12 +443,12 @@
.pagination-page-active {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
}
.pagination-page-active:hover {
background: var(--accent-hover);
color: var(--white);
color: var(--on-accent);
}
.pagination-ellipsis {
+1 -1
View File
@@ -117,7 +117,7 @@
font-size: 1rem;
font-weight: 700;
background: var(--accent);
color: var(--white);
color: var(--on-accent);
border-radius: var(--radius);
}
+7 -9
View File
@@ -444,15 +444,13 @@ img {
}
.btn-primary {
background: var(--accent-gradient);
color: var(--white);
box-shadow: 0 2px 12px rgba(255, 95, 70, 0.3);
background: var(--accent);
color: var(--on-accent);
}
.btn-primary:hover {
background: var(--accent-hover);
color: var(--white);
box-shadow: var(--glow-accent);
transform: translateY(-1px);
}
.btn-secondary {
@@ -554,7 +552,7 @@ img {
justify-content: center;
font-weight: 700;
font-size: 1rem;
color: var(--white);
color: var(--on-accent);
flex-shrink: 0;
overflow: hidden;
}
@@ -589,7 +587,7 @@ img {
pointer-events: none;
}
.presence-dot.online { background: var(--success, #2f9e44); }
.presence-dot.online { background: var(--success); }
.award-badge {
position: absolute;
@@ -650,7 +648,7 @@ img {
.nav-bell { position: relative; display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.nav-badge {
position: absolute; top: -6px; right: -8px; z-index: 1; min-width: 16px; height: 16px;
padding: 0 0.25rem; border-radius: 8px; background: var(--accent); color: var(--white);
padding: 0 0.25rem; border-radius: 8px; background: var(--accent); color: var(--on-accent);
font-size: 0.6875rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
@@ -884,7 +882,7 @@ img {
button.comment-form-submit {
padding: 0.375rem 0.5rem;
background: var(--accent);
color: var(--white);
color: var(--on-accent);
font-weight: 600;
font-size: 0.8125rem;
line-height: 1.4;
+2 -2
View File
@@ -232,7 +232,7 @@ dp-chat[mode="embed"] {
.message-bubble.mine {
align-self: flex-end;
background: var(--accent);
color: var(--white);
color: var(--on-accent);
border-bottom-right-radius: 4px;
}
@@ -429,7 +429,7 @@ dp-chat[mode="embed"] {
height: 36px;
padding: 0;
background: var(--accent);
color: var(--white);
color: var(--on-accent);
border-radius: 50%;
display: flex;
align-items: center;
+1 -1
View File
@@ -209,7 +209,7 @@
.cm-suggest li:hover,
.cm-suggest li.cm-suggest-active {
background: var(--bg-hover, var(--border));
background: var(--bg-card-hover);
}
.cm-suggest li.cm-suggest-active {
+4 -4
View File
@@ -160,7 +160,7 @@
.ds-progress-bar {
height: 100%;
width: 0;
background: var(--accent-gradient, var(--accent));
background: var(--accent-gradient);
transition: width 0.3s ease;
}
@@ -241,7 +241,7 @@
.ds-phase.is-done .ds-phase-dot {
background: var(--accent);
border-color: var(--accent);
color: var(--white);
color: var(--on-accent);
}
.ds-phase.is-done .ds-phase-name {
@@ -546,7 +546,7 @@ dp-deepsearch-chat {
.ds-chat-msg.user .ds-chat-bubble {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
}
.ds-chat-msg.agent .ds-chat-bubble {
@@ -597,7 +597,7 @@ dp-deepsearch-chat {
.ds-chat-send {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
border: none;
border-radius: var(--radius-input);
padding: 0 var(--space-md);
+1 -1
View File
@@ -292,7 +292,7 @@ dp-docs-chat {
.docs-chat-send {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
border: none;
border-radius: var(--radius);
padding: 0.625rem 1.25rem;
+2 -2
View File
@@ -431,7 +431,7 @@ textarea.param-input {
.format-option.active {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
}
.format-fixed {
@@ -621,7 +621,7 @@ pre.code-pre > .code-gutter {
.docs-search-snippet mark {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
padding: 0 0.125rem;
border-radius: 3px;
}
+6 -7
View File
@@ -38,9 +38,8 @@
}
.feed-nav-btn.active {
background: var(--accent-gradient);
color: var(--white);
box-shadow: var(--glow-accent);
background: var(--accent);
color: var(--on-accent);
}
.feed-nav-actions {
@@ -285,7 +284,7 @@
min-width: 1.25rem;
padding: 0 0.375rem;
border-radius: 999px;
background: var(--success, #2f9e44);
background: var(--success);
color: var(--white);
font-size: 0.6875rem;
text-align: center;
@@ -368,14 +367,14 @@
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--accent-gradient);
color: var(--white);
background: var(--accent);
color: var(--on-accent);
font-size: 2rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--glow-accent);
box-shadow: var(--shadow);
z-index: var(--z-fab);
border: none;
cursor: pointer;
+2 -2
View File
@@ -35,11 +35,11 @@
text-decoration: none;
}
.issues-filter:hover {
background: var(--bg-hover);
background: var(--bg-card-hover);
}
.issues-filter.active {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
border-color: var(--accent);
}
.issues-modal-hint {
+14 -8
View File
@@ -70,7 +70,7 @@
.dashboard-btn-primary {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
border-color: var(--accent);
font-weight: 700;
padding: 0.55rem 1.5rem;
@@ -123,7 +123,7 @@
.dashboard-xp-fill {
height: 100%;
background: var(--accent-gradient, linear-gradient(90deg, var(--accent), #f0932b));
background: var(--accent-gradient);
border-radius: 3px;
transition: width 0.4s ease;
}
@@ -171,7 +171,7 @@
.dashboard-posts-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 0.875rem;
}
@@ -466,7 +466,7 @@
font-size: 1.125rem;
border-radius: var(--radius-lg);
background: var(--accent);
color: var(--white);
color: var(--on-accent);
font-weight: 700;
}
@@ -567,7 +567,7 @@
padding: 0.7rem 2rem;
border-radius: var(--radius-lg);
background: var(--accent);
color: var(--white);
color: var(--on-accent);
font-size: 0.9375rem;
font-weight: 700;
}
@@ -637,7 +637,7 @@
.landing-xp-fill {
height: 100%;
background: var(--accent-gradient, linear-gradient(90deg, var(--accent), #f0932b));
background: var(--accent-gradient);
border-radius: 3px;
transition: width 0.4s ease;
}
@@ -970,7 +970,7 @@
margin-top: auto;
align-self: flex-start;
background: var(--accent);
color: var(--white);
color: var(--on-accent);
border: none;
border-radius: var(--radius-lg);
padding: 0.6rem 1.1rem;
@@ -1004,6 +1004,12 @@
z-index: 1;
}
.landing-game-dashboard {
max-width: 1200px;
padding: 0 1.25rem;
margin-top: 2rem;
}
.landing-game-body {
position: relative;
overflow: hidden;
@@ -1105,7 +1111,7 @@
font-size: 1.05rem;
border-radius: var(--radius-lg);
background: var(--accent);
color: var(--white);
color: var(--on-accent);
font-weight: 700;
text-decoration: none;
transition: filter 0.15s ease, transform 0.15s ease;
+1 -1
View File
@@ -257,7 +257,7 @@
font-size: 0.875rem;
font-weight: 600;
background: var(--accent);
color: var(--white);
color: var(--on-accent);
border-radius: var(--radius);
}
+4 -4
View File
@@ -49,7 +49,7 @@
}
.profile-presence.online {
color: var(--success, #2f9e44);
color: var(--success);
}
.profile-presence::before {
@@ -64,7 +64,7 @@
}
.profile-presence.online::before {
background: var(--success, #2f9e44);
background: var(--success);
}
.profile-stats {
@@ -231,7 +231,7 @@ a.profile-stat-value:hover {
.profile-tab.active {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
}
.profile-posts {
@@ -464,7 +464,7 @@ a.profile-stat-value:hover {
}
.ai-correction-status[data-state="success"] {
color: var(--success, #2f9e44);
color: var(--success);
}
.ai-correction-status[data-state="error"] {
+2 -2
View File
@@ -100,7 +100,7 @@
.pf-node-row.active {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
}
.pf-node-row.pf-selected {
@@ -110,7 +110,7 @@
.pf-node-row.pf-selected.active {
background: var(--accent);
color: var(--white);
color: var(--on-accent);
}
.pf-node-row.pf-dragging {
+324 -7
View File
@@ -27,9 +27,8 @@
}
.projects-tab.active {
background: var(--accent-gradient);
color: var(--white);
box-shadow: var(--glow-accent);
background: var(--accent);
color: var(--on-accent);
}
.projects-header {
@@ -224,16 +223,293 @@
}
}
.project-detail-page {
max-width: 720px;
.project-page {
max-width: var(--max-content);
margin: 0 auto;
}
.project-detail {
/* One encompassing dark card holds the whole project; panels inside it
sit on the lighter secondary surface (the reference's elev-1/elev-2 split). */
.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-body,
.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-hero {
overflow: hidden;
}
.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-tagline,
.project-hero-overlay .project-detail-author {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 2px 12px rgba(0, 0, 0, 0.6);
}
.project-hero-overlay .project-detail-header {
margin-bottom: 0.375rem;
justify-content: flex-start;
gap: 0.75rem;
align-items: center;
}
.project-tagline {
font-size: 0.9375rem;
color: var(--text-secondary);
margin-bottom: 0.625rem;
}
.project-hero-chips {
margin-bottom: 0.625rem;
}
.project-hero-overlay .project-detail-author {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.project-hero-ctas {
flex-shrink: 0;
}
.project-hero-body {
padding: 1rem 1.5rem 1.25rem;
}
.project-tabs {
display: flex;
gap: 0.25rem;
margin: 1rem 0;
background: var(--bg-card);
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;
position: sticky;
top: calc(var(--nav-height) + 4rem);
}
.project-sidebar-card {
background: var(--bg-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-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-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-comments {
margin-top: 1.5rem;
}
@media (max-width: 1024px) {
.project-columns {
grid-template-columns: minmax(0, 1fr);
}
.project-sidebar {
position: static;
}
.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;
@@ -305,7 +581,28 @@
color: var(--warning);
}
.project-platforms {
.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-about {
margin-bottom: 1.5rem;
}
@@ -313,6 +610,26 @@
margin-top: 1.5rem;
}
.project-devlog-header {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: 0.75rem;
}
.project-devlog-header .project-section-label {
margin-bottom: 0;
}
.project-devlog-count {
font-size: 0.75rem;
color: var(--text-muted);
}
.project-devlog-post-btn {
margin-left: auto;
}
.project-section-label {
font-size: 0.75rem;
font-weight: 700;
+2 -3
View File
@@ -76,10 +76,9 @@
.sidebar-link.active,
.sidebar-link.active:hover {
background: var(--accent-gradient);
color: var(--white);
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
box-shadow: var(--glow-accent);
}
.sidebar-dot {
+31 -31
View File
@@ -1,41 +1,41 @@
/* retoor <retoor@molodetz.nl> */
:root {
--bg-primary: #080413;
--bg-secondary: #120821;
--bg-card: #1a1030;
--bg-card-hover: #241640;
--bg-input: #140b26;
--bg-modal: #1a1030;
--bg-primary: #271b5b;
--bg-secondary: #1a1736;
--bg-card: #13112a;
--bg-card-hover: #1a1736;
--bg-input: #1a1736;
--bg-modal: #13112a;
--accent: #ff6b35;
--accent-rgb: 255, 107, 53;
--accent-hover: #ff7d4d;
--accent-light: rgba(255, 107, 53, 0.12);
--accent-2: #ff4f8b;
--accent-gradient: linear-gradient(135deg, #ff6b3d, #ff4f6d);
--accent: #b73f1e;
--accent-rgb: 183, 63, 30;
--accent-hover: #af3050;
--accent-light: rgba(183, 63, 30, 0.14);
--accent-2: #af3050;
--accent-gradient: linear-gradient(135deg, #b73f1e, #af3050, #8f3b71);
--on-accent: #fff;
--text-primary: #f4eefb;
--text-secondary: #b8a8d0;
--text-muted: #7a6a90;
--text-primary: #e9e4ff;
--text-secondary: #c8c3e5;
--text-muted: #9b93c9;
--border: rgba(255, 255, 255, 0.08);
--border-light: rgba(255, 255, 255, 0.14);
--border: #11101f;
--border-light: #2f2a55;
--success: #4caf50;
--warning: #ff9800;
--danger: #e53935;
--info: #42a5f5;
--success: #34d399;
--warning: #fbbf24;
--danger: #f87171;
--info: #60a5fa;
--radius: 12px;
--radius-input: 14px;
--radius-lg: 18px;
--radius-xl: 24px;
--radius: 8px;
--radius-input: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.25);
--shadow: 0 4px 20px rgba(0, 0, 0, 0.28);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.45);
--glow-accent: 0 6px 24px rgba(255, 95, 70, 0.45);
--shadow-sm: 0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04);
--shadow: 0 4px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 40px -10px rgba(0, 0, 0, 0.15), 0 2px 10px -2px rgba(0, 0, 0, 0.05);
--white: #fff;
--overlay-dark: rgba(0, 0, 0, 0.7);
@@ -48,7 +48,7 @@
--space-xl: 1.5rem;
--space-2xl: 2rem;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-sans: "Trebuchet MS", "Segoe UI", Tahoma, Verdana, sans-serif;
--font-mono: "SF Mono", Monaco, "Cascadia Code", monospace;
--nav-height: 56px;
@@ -76,5 +76,5 @@
--topic-fun: #ffab00;
--topic-politics: #00bcd4;
--bg-gradient: linear-gradient(135deg, #080413 0%, #160a28 50%, #080413 100%);
--bg-gradient: linear-gradient(135deg, #271b5b 0%, #1d1545 50%, #271b5b 100%);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

+7 -7
View File
@@ -1,14 +1,14 @@
// retoor <retoor@molodetz.nl>
const CHART_COLORS = [
"#ff6b35",
"#7c4dff",
"#00bfa5",
"#448aff",
"#b73f1e",
"#8f3b71",
"#34d399",
"#3a4b70",
"#ffab00",
"#ff5252",
"#42a5f5",
"#ff4f8b",
"#f87171",
"#61477c",
"#af3050",
];
export class StatisticsCharts {
+2 -2
View File
@@ -9,8 +9,8 @@
"orientation": "any",
"lang": "en",
"dir": "ltr",
"background_color": "#0f0a1a",
"theme_color": "#0f0a1a",
"background_color": "#271b5b",
"theme_color": "#271b5b",
"icons": [
{
"src": "/static/icon-192.png",
+8 -8
View File
@@ -7,11 +7,11 @@
<title>Offline - DevPlace</title>
<style>
:root {
--bg-primary: #0f0a1a;
--bg-card: #221436;
--accent: #ff6b35;
--text-primary: #f0e8f8;
--text-muted: #9a8db0;
--bg-primary: #271b5b;
--bg-card: #13112a;
--accent: #b73f1e;
--text-primary: #e9e4ff;
--text-muted: #9b93c9;
}
* { box-sizing: border-box; }
body {
@@ -22,21 +22,21 @@
justify-content: center;
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: "Trebuchet MS", "Segoe UI", Tahoma, Verdana, sans-serif;
padding: 1.5rem;
}
.offline-card {
max-width: 360px;
text-align: center;
background: var(--bg-card);
border-radius: 16px;
border-radius: 12px;
padding: 2.5rem 2rem;
}
.offline-mark {
width: 72px;
height: 72px;
margin: 0 auto 1.25rem;
border-radius: 16px;
border-radius: 12px;
background: var(--accent);
color: var(--bg-primary);
font-size: 2.75rem;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 63 KiB

+7 -6
View File
@@ -1,13 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#0f0a1a"/>
<rect width="1200" height="630" fill="#271b5b"/>
<rect x="0" y="0" width="1200" height="630" fill="url(#g)"/>
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ff6b35" stop-opacity="0.15"/>
<stop offset="100%" stop-color="#1a1028" stop-opacity="0"/>
<stop offset="0%" stop-color="#b73f1e" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#13112a" stop-opacity="0.6"/>
</linearGradient>
</defs>
<text x="600" y="280" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="96" font-weight="800" fill="#f0e8f8">DevPlace</text>
<text x="600" y="370" text-anchor="middle" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="36" fill="#b8a8d0">The Developer Social Network</text>
<circle cx="600" cy="480" r="8" fill="#ff6b35"/>
<text x="600" y="290" text-anchor="middle" font-family="'Trebuchet MS', 'Segoe UI', Tahoma, sans-serif" font-size="104" font-weight="800" fill="#e9e4ff">DevPlace</text>
<rect x="430" y="326" width="340" height="8" rx="4" fill="#b73f1e"/>
<text x="600" y="404" text-anchor="middle" font-family="'Trebuchet MS', 'Segoe UI', Tahoma, sans-serif" font-size="36" fill="#c8c3e5">The Developer Social Network</text>
<circle cx="600" cy="478" r="8" fill="#b73f1e"/>
</svg>

Before

Width:  |  Height:  |  Size: 832 B

After

Width:  |  Height:  |  Size: 908 B

+1
View File
@@ -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`.
@@ -0,0 +1,49 @@
{# Shared create-post form. Locals: _composer_topic (preselected topic), _composer_project (preselected project uid or ""). #}
<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">&#x1F4CA;</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>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0f0a1a">
<meta name="theme-color" content="#271b5b">
<meta name="asset-version" content="{{ static_version }}">
<title>{% if page_title %}{{ page_title }}{% else %}DevPlace - The Developer Social Network{% endif %}</title>
<meta name="description" content="{% if meta_description %}{{ meta_description }}{% else %}Track industry shifts. Discover bold releases. Share what you're building in an open environment built by developers, for developers.{% endif %}">
+35 -35
View File
@@ -8,7 +8,7 @@ The palette is defined once, as CSS custom properties on `:root` in `static/css/
DevPlace is kept open for hours, often at night. The theme is therefore **dark, low-glare, and deliberately quiet**: deep violet-black backgrounds, soft lavender text, and a *single* warm accent.
- **One accent, used sparingly.** A single orange (`--accent`, `#ff6b35`) marks what matters: the primary action, the active navigation item, links, and the current vote. Because nothing else competes for it, the accent always means "act on this". A second strong colour used decoratively breaks it.
- **One accent, used sparingly.** A single burnt orange (`--accent`, `#b73f1e`) marks what matters: the primary action, the active navigation item, links, and the current vote. Because nothing else competes for it, the accent always means "act on this". A second strong colour used decoratively breaks it.
- **Backgrounds layer by elevation.** A near-black page sits behind slightly lighter cards, behind a lighter hover state. Depth comes from these few background steps plus a soft shadow, not from borders alone.
- **Text is a three-step hierarchy.** Primary for content, secondary for supporting text, muted for metadata. There is no fourth shade.
- **Colour with meaning is reserved.** Status colours (success, warning, danger, info) and topic colours carry semantics. They never "brighten up" a layout.
@@ -19,20 +19,20 @@ Layered from the page backdrop up to interactive surfaces.
| Token | Value | Use it for | Do not |
|-------|-------|-----------|--------|
| `--bg-primary` | `#080413` | The page backdrop (`body`). | Put cards or inputs directly on it without a surface token. |
| `--bg-secondary` | `#120821` | The top nav, dropdowns, the mobile panel, demo frames. | Use as a card body in the content area. |
| `--bg-card` | `#1a1030` | Cards, panels, the standard content surface. | Use for the page backdrop. |
| `--bg-card-hover` | `#241640` | Hover state of cards, list rows, and ghost buttons. | Use as a resting background. |
| `--bg-input` | `#140b26` | Inputs, textareas, selects. | Use as a content card surface. |
| `--bg-modal` | `#1a1030` | Modal card body. | Use outside modals. |
| `--bg-primary` | `#271b5b` | The page backdrop (`body`). | Put cards or inputs directly on it without a surface token. |
| `--bg-secondary` | `#1a1736` | The top nav, dropdowns, the mobile panel, demo frames. | Use as a card body in the content area. |
| `--bg-card` | `#13112a` | Cards, panels, the standard content surface. | Use for the page backdrop. |
| `--bg-card-hover` | `#1a1736` | Hover state of cards, list rows, and ghost buttons. | Use as a resting background. |
| `--bg-input` | `#1a1736` | Inputs, textareas, selects. | Use as a content card surface. |
| `--bg-modal` | `#13112a` | Modal card body. | Use outside modals. |
| `--bg-gradient` | violet gradient | Large hero / landing surfaces only. | Apply to small components. |
## Accent
| Token | Value | Use it for | Do not |
|-------|-------|-----------|--------|
| `--accent` | `#ff6b35` | The single primary action, active nav link, links, the voted star, focus border. | A second decorative colour, large fills, or more than one primary action per view. |
| `--accent-hover` | `#ff7d4d` | Hover state of accent surfaces and links. | Resting state. |
| `--accent` | `#b73f1e` | The single primary action, active nav link, links, the voted star, focus border. | A second decorative colour, large fills, or more than one primary action per view. |
| `--accent-hover` | `#af3050` | Hover state of accent surfaces and links. | Resting state. |
| `--accent-light` | `rgba(255,107,53,.12)` | The tinted background behind an *active* nav item or self-highlight row. | Body text (too low contrast). |
| `--accent-rgb` | `255, 107, 53` | Composing any other accent tint: `rgba(var(--accent-rgb), 0.3)`. | Writing `rgba(255, 107, 53, ...)` literally anywhere. |
@@ -42,19 +42,19 @@ A strict three-step hierarchy. Never introduce a fourth text shade.
| Token | Value | Use it for |
|-------|-------|-----------|
| `--text-primary` | `#f4eefb` | Headings and body content. |
| `--text-secondary` | `#b8a8d0` | Supporting copy, labels, secondary buttons. |
| `--text-muted` | `#7a6a90` | Timestamps, counts, hints, metadata. |
| `--text-primary` | `#e9e4ff` | Headings and body content. |
| `--text-secondary` | `#c8c3e5` | Supporting copy, labels, secondary buttons. |
| `--text-muted` | `#9b93c9` | Timestamps, counts, hints, metadata. |
## Borders, radius, and shadow
| Token | Value | Use it for |
|-------|-------|-----------|
| `--border` | `rgba(255,255,255,.08)` | The default hairline on cards, inputs, dividers. |
| `--border-light` | `rgba(255,255,255,.14)` | Hover/emphasis borders. |
| `--radius` | `12px` | Buttons, inputs, small controls. |
| `--radius-lg` | `18px` | Cards and panels. |
| `--radius-xl` | `24px` | Large feature surfaces. |
| `--border` | `#11101f` | The default hairline on cards, inputs, dividers. |
| `--border-light` | `#2f2a55` | Hover/emphasis borders. |
| `--radius` | `8px` | Buttons, inputs, small controls. |
| `--radius-lg` | `12px` | Cards and panels. |
| `--radius-xl` | `16px` | Large feature surfaces. |
| `--shadow-sm` / `--shadow` / `--shadow-lg` | (shadows) | Card hover, raised panels, modals respectively. |
## Status colours (semantic only)
@@ -63,10 +63,10 @@ These encode meaning and must only be used to convey it.
| Token | Value | Means |
|-------|-------|-------|
| `--success` | `#4caf50` | Success, healthy, online. |
| `--warning` | `#ff9800` | Caution; also the colour of a cast vote star. |
| `--danger` | `#e53935` | Errors, destructive actions, the create-post button. |
| `--info` | `#42a5f5` | Neutral information. |
| `--success` | `#34d399` | Success, healthy, online. |
| `--warning` | `#fbbf24` | Caution; also the colour of a cast vote star. |
| `--danger` | `#f87171` | Errors, destructive actions, the create-post button. |
| `--info` | `#60a5fa` | Neutral information. |
## Topic colours (badges only)
@@ -83,7 +83,7 @@ Used exclusively for topic badges (`.badge-devlog`, `.badge-showcase`, …) and
## Rules
1. **Never hardcode a colour.** Use the token: `color: var(--text-secondary)`, never `color: #b8a8d0`. If a value is missing, add a token to `variables.css` rather than inlining a hex. Accent tints at other alphas compose the channel token: `rgba(var(--accent-rgb), 0.3)`.
1. **Never hardcode a colour.** Use the token: `color: var(--text-secondary)`, never `color: #c8c3e5`. If a value is missing, add a token to `variables.css` rather than inlining a hex. Accent tints at other alphas compose the channel token: `rgba(var(--accent-rgb), 0.3)`.
2. **Never give a global token a fallback.** Write `var(--accent)`, never `var(--accent, #hex)`. A fallback silently masks a dead or misspelled token: the page keeps rendering, but from a value nobody maintains.
3. **Feature palettes are file-scoped token blocks.** A page with its own semantic palette (the Devii terminal, the AI-usage grades) defines it once as custom properties at the top of its own stylesheet (`devii.css` scopes `--devii-*` on `devii-terminal`; `isslop.css` scopes `--isslop-*` on `:root`) and references only those below. Raw hex values may exist in exactly two places: `variables.css` and these file-scoped blocks.
4. **One accent per view.** Exactly one primary action should carry `--accent`. Everything else is `--btn-secondary`/ghost.
@@ -124,20 +124,20 @@ The reference below is rendered live from the tokens, so it always reflects the
<div class="component-demo">
<div class="component-demo-title">Live palette</div>
<div class="sg-swatch-grid">
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-primary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-primary</span><span class="sg-swatch-hex">#080413</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-secondary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-secondary</span><span class="sg-swatch-hex">#120821</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-card"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card</span><span class="sg-swatch-hex">#1a1030</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-card-hover"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card-hover</span><span class="sg-swatch-hex">#241640</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-accent"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--accent</span><span class="sg-swatch-hex">#ff6b35</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-accent-hover"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--accent-hover</span><span class="sg-swatch-hex">#ff7d4d</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-text-primary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-primary</span><span class="sg-swatch-hex">#f4eefb</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-text-secondary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-secondary</span><span class="sg-swatch-hex">#b8a8d0</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-text-muted"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-muted</span><span class="sg-swatch-hex">#7a6a90</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-primary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-primary</span><span class="sg-swatch-hex">#271b5b</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-secondary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-secondary</span><span class="sg-swatch-hex">#1a1736</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-card"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card</span><span class="sg-swatch-hex">#13112a</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-card-hover"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card-hover</span><span class="sg-swatch-hex">#1a1736</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-accent"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--accent</span><span class="sg-swatch-hex">#b73f1e</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-accent-hover"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--accent-hover</span><span class="sg-swatch-hex">#af3050</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-text-primary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-primary</span><span class="sg-swatch-hex">#e9e4ff</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-text-secondary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-secondary</span><span class="sg-swatch-hex">#c8c3e5</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-text-muted"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-muted</span><span class="sg-swatch-hex">#9b93c9</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-border"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--border</span><span class="sg-swatch-hex">rgba(255,255,255,.08)</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-success"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--success</span><span class="sg-swatch-hex">#4caf50</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-warning"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--warning</span><span class="sg-swatch-hex">#ff9800</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-danger"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--danger</span><span class="sg-swatch-hex">#e53935</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-info"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--info</span><span class="sg-swatch-hex">#42a5f5</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-success"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--success</span><span class="sg-swatch-hex">#34d399</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-warning"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--warning</span><span class="sg-swatch-hex">#fbbf24</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-danger"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--danger</span><span class="sg-swatch-hex">#f87171</span></span></div>
<div class="sg-swatch"><span class="sg-chip sg-chip-info"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--info</span><span class="sg-swatch-hex">#60a5fa</span></span></div>
</div>
</div>
+1 -48
View File
@@ -160,54 +160,7 @@
<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') %}
<form id="create-post-form" method="POST" action="/posts/create" enctype="multipart/form-data">
{% 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">&#x1F4CA;</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>
{% set _composer_topic = 'random' %}{% set _composer_project = '' %}{% include "_post_composer_form.html" %}
{% endcall %}
{% else %}
<a href="/auth/login" class="feed-fab login-required" aria-label="Log in">+</a>
+1 -1
View File
@@ -146,7 +146,7 @@
</section>
{% endif %}
<section class="landing-game" aria-labelledby="landing-game-heading">
<section class="landing-game{% if user %} landing-game-dashboard{% endif %}" aria-labelledby="landing-game-heading">
<div class="landing-game-glow"></div>
<div class="landing-game-body">
<span class="landing-game-eyebrow">New on DevPlace</span>
+253 -115
View File
@@ -3,131 +3,254 @@
{% block extra_head %}
<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/feed.css') }}">
{% endblock %}
{% block content %}
<div class="project-detail-page">
{% set project_url = "/projects/" ~ (project['slug'] or project['uid']) %}
{% set image_attachments = attachments | selectattr('is_image') | list %}
{% set other_attachments = attachments | rejectattr('is_image') | list %}
{% set cover_src = ('/static/uploads/' ~ project['cover_image']) if project.get('cover_image') else (image_attachments[0]['url'] if image_attachments else none) %}
{% set logo_src = ('/static/uploads/' ~ project['logo_image']) if project.get('logo_image') else none %}
{% set tagline = (project.get('description', '') or '').split('\n')[0] %}
<div class="project-page">
<a href="/projects" class="back-link">&larr; Back to Projects</a>
<article class="project-detail">
<div class="project-detail-header">
<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 %}">
&#x25CF; {{ project.get('status', 'In Development') }}
</div>
</div>
{% if is_private or read_only %}
<div class="project-detail-meta">
{% if is_private %}<span class="badge badge-type">Private</span>{% endif %}
{% if read_only %}<span class="badge badge-type">Read-only</span>{% endif %}
</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>&#x1F4C5; Released: {{ format_date(project['release_date']) }}</span>
<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 %}
{% if project.get('demo_date') %}
<span>&#x1F3AD; Demo: {{ format_date(project['demo_date']) }}</span>
{% endif %}
</div>
{% if forked_from %}
<div class="project-detail-meta">
<span>&#x2442; Forked from <a href="/projects/{{ forked_from['slug'] }}">{{ render_title(forked_from['title']) }}</a></span>
</div>
{% endif %}
<div class="project-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div>
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
<span class="meta-muted">&middot; Level {{ author.get('level', 1) if author else 1 }}</span>
</div>
</div>
{% 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 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>
{% endif %}
<div class="project-detail-actions">
<a href="/projects/{{ project['slug'] or project['uid'] }}/files" class="project-star-btn"><span class="icon">&#x1F4C1;</span><span class="label"> Files ({{ file_count }} files)</span></a>
{% if workspace_editor_url %}
<a href="{{ workspace_editor_url }}" target="_blank" rel="noopener" class="project-star-btn"><span class="icon">&#x1F4BB;</span><span class="label"> VS Code</span></a>
{% endif %}
<button type="button" class="project-star-btn" data-share="/projects/{{ project['slug'] or project['uid'] }}"><span class="icon">&#x1F517;</span><span class="label"> Share</span></button>
{% 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" %}
{% endif %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _owner = project['user_uid'] %}{% set _owner_name = (author or {}).get('username', '') %}{% set _class = "project-star-btn" %}{% include "_report_button.html" %}
<button type="button" class="project-star-btn project-actions-more" aria-haspopup="menu" aria-expanded="false" aria-label="More actions"><span class="icon">&#x22EF;</span><span class="label"> More</span></button>
<div class="project-actions-overflow" hidden>
{% if viewer_can_workspace %}
<a href="/projects/{{ project['slug'] or project['uid'] }}/workspace" data-menu-action data-menu-icon="&#x1F4BB;" data-menu-label="Workspace">Workspace</a>
<div class="project-cover-scrim" aria-hidden="true"></div>
<div class="project-hero-overlay">
{% if logo_src %}
<img class="project-logo" src="{{ logo_src }}" alt="{{ project['title'] }} logo" loading="eager">
{% endif %}
{% if viewer_can_containers %}
<a href="/projects/{{ project['slug'] or project['uid'] }}/containers" data-menu-action data-menu-icon="&#x1F5A5;&#xFE0F;" data-menu-label="Containers">Containers</a>
<div class="project-hero-headline">
<div class="project-detail-header">
<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 %}">
&#x25CF; {{ project.get('status', 'In Development') }}
</div>
</div>
{% if tagline %}
<p class="project-tagline">{{ render_title(tagline, author_is_admin=is_admin(author)) }}</p>
{% endif %}
<div class="project-detail-meta project-hero-chips">
<span class="badge badge-type">{{ project.get('project_type', 'software').replace('_', ' ') }}</span>
{% for plat in platforms %}
<span class="platform-tag">{{ plat.strip() }}</span>
{% endfor %}
{% if is_private %}<span class="badge badge-type">Private</span>{% endif %}
{% if read_only %}<span class="badge badge-type">Read-only</span>{% endif %}
</div>
<div class="project-detail-author">
{% set _size = 32 %}{% set _size_class = "sm" %}{% set _user = author %}{% include "_avatar_link.html" %}
<div>
{% set _user = author %}{% set _class = none %}{% include "_user_link.html" %}
<span class="meta-muted">&middot; Level {{ author.get('level', 1) if author else 1 }}</span>
</div>
{% if project.get('created_at') %}
<span class="meta-muted">&#x1F331; Started {{ dt_ago(project['created_at']) }}</span>
{% endif %}
</div>
</div>
{% if project.get('website_url') %}
<div class="project-hero-ctas">
<a href="{{ project['website_url'] }}" target="_blank" rel="noopener nofollow" class="btn btn-primary project-website-btn"><span class="icon">&#x1F310;</span> Visit Website</a>
</div>
{% endif %}
<button type="button" data-zip-download="/projects/{{ project['slug'] or project['uid'] }}/zip" data-menu-action data-menu-icon="&#x1F4E6;" data-menu-label="Download zip">Download zip</button>
</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>&#x1F4C5; Released: {{ format_date(project['release_date']) }}</span>
{% endif %}
{% if project.get('demo_date') %}
<span>&#x1F3AD; Demo: {{ format_date(project['demo_date']) }}</span>
{% endif %}
{% if forked_from %}
<span>&#x2442; Forked from <a href="/projects/{{ forked_from['slug'] }}">{{ render_title(forked_from['title']) }}</a></span>
{% endif %}
</div>
{% endif %}
<div class="project-detail-actions">
<a href="{{ project_url }}/files" class="project-star-btn"><span class="icon">&#x1F4C1;</span><span class="label"> Files ({{ file_count }} files)</span></a>
{% if workspace_editor_url %}
<a href="{{ workspace_editor_url }}" target="_blank" rel="noopener" class="project-star-btn"><span class="icon">&#x1F4BB;</span><span class="label"> VS Code</span></a>
{% endif %}
<button type="button" class="project-star-btn" data-share="{{ project_url }}"><span class="icon">&#x1F517;</span><span class="label"> Share</span></button>
{% 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="&#x2442;" data-menu-label="Fork">Fork</button>
{% endif %}
{% if is_owner %}
<button type="button" data-modal="edit-project-modal" data-menu-action data-menu-icon="&#x270F;&#xFE0F;" data-menu-label="Edit">Edit</button>
<form method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/private">
<input type="hidden" name="value" value="{{ 0 if is_private else 1 }}">
<button type="submit" data-confirm-danger data-confirm="{% if is_private %}Make this project public? Everyone will be able to see the project and all its files.{% else %}Make this project private? Only you and administrators will be able to see it.{% endif %}" data-menu-action data-menu-icon="{% if is_private %}&#x1F513;{% else %}&#x1F512;{% 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 method="POST" action="/projects/{{ project['slug'] or project['uid'] }}/readonly">
<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 %}&#x1F4DD;{% else %}&#x1F6AB;{% 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>
{% endif %}
{% if is_owner or is_admin(user) %}
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}">
<button type="submit" data-confirm-danger data-confirm="Delete this project?" data-menu-action data-menu-icon="&#x1F5D1;&#xFE0F;" data-menu-label="Delete">Delete</button>
</form>
{% 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 %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _bookmarked = bookmarked %}{% include "_bookmark_button.html" %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _reactions = reactions %}{% include "_reaction_bar.html" %}
{% set _type = "project" %}{% set _uid = project['uid'] %}{% set _owner = project['user_uid'] %}{% set _owner_name = (author or {}).get('username', '') %}{% set _class = "project-star-btn" %}{% include "_report_button.html" %}
<button type="button" class="project-star-btn project-actions-more" aria-haspopup="menu" aria-expanded="false" aria-label="More actions"><span class="icon">&#x22EF;</span><span class="label"> More</span></button>
<div class="project-actions-overflow" hidden>
{% if viewer_can_workspace %}
<a href="{{ project_url }}/workspace" data-menu-action data-menu-icon="&#x1F4BB;" data-menu-label="Workspace">Workspace</a>
{% endif %}
{% if viewer_can_containers %}
<a href="{{ project_url }}/containers" data-menu-action data-menu-icon="&#x1F5A5;&#xFE0F;" data-menu-label="Containers">Containers</a>
{% endif %}
<button type="button" data-zip-download="{{ project_url }}/zip" data-menu-action data-menu-icon="&#x1F4E6;" data-menu-label="Download zip">Download zip</button>
{% if user %}
<button type="button" data-fork-project="{{ project_url }}/fork" data-fork-name="{{ project['title'] }}" data-menu-action data-menu-icon="&#x2442;" data-menu-label="Fork">Fork</button>
{% endif %}
{% if is_owner %}
<button type="button" data-modal="edit-project-modal" data-menu-action data-menu-icon="&#x270F;&#xFE0F;" data-menu-label="Edit">Edit</button>
<button type="button" data-modal="add-screenshots-modal" data-menu-action data-menu-icon="&#x1F5BC;&#xFE0F;" data-menu-label="Add screenshots">Add screenshots</button>
<form method="POST" action="{{ project_url }}/private">
<input type="hidden" name="value" value="{{ 0 if is_private else 1 }}">
<button type="submit" data-confirm-danger data-confirm="{% if is_private %}Make this project public? Everyone will be able to see the project and all its files.{% else %}Make this project private? Only you and administrators will be able to see it.{% endif %}" data-menu-action data-menu-icon="{% if is_private %}&#x1F513;{% else %}&#x1F512;{% 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 method="POST" action="{{ project_url }}/readonly">
<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 %}&#x1F4DD;{% else %}&#x1F6AB;{% 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>
{% endif %}
{% if is_owner or is_admin(user) %}
<form method="POST" action="/projects/delete/{{ project['slug'] or project['uid'] }}">
<button type="submit" data-confirm-danger data-confirm="Delete this project?" data-menu-action data-menu-icon="&#x1F5D1;&#xFE0F;" data-menu-label="Delete">Delete</button>
</form>
{% endif %}
</div>
</div>
</div>
</article>
<section class="project-devlog">
<h3 class="project-section-label">Devlog</h3>
{% if 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" %}
{% endfor %}
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
{% else %}
<p class="empty-state">No devlog posts yet.</p>
<nav class="project-tabs" aria-label="Project sections">
<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 image_attachments %}
<a href="#screenshots" class="project-tab">Screenshots <span class="project-tab-count">{{ image_attachments | length }}</span></a>
{% endif %}
</section>
<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">&#x270D;&#xFE0F;</span> Post update</button>
{% endif %}
</div>
{% if 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" %}
{% endfor %}
{% set next_cursor = devlog_next_cursor %}{% include "_load_more.html" %}
{% else %}
<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 %}
</section>
{% if image_attachments %}
<section class="project-screenshots" id="screenshots">
<h2 class="project-section-label">Screenshots</h2>
<div class="project-screenshot-grid">
{% for shot in image_attachments %}
<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>
</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">&#x1F310; Website</a></li>
{% endif %}
{% if project.get('repo_url') %}
<li><a href="{{ project['repo_url'] }}" target="_blank" rel="noopener nofollow">&#x1F4BB; Repository</a></li>
{% endif %}
<li><a href="{{ project_url }}/files">&#x1F4C1; Browse files</a></li>
{% if forked_from %}
<li><a href="/projects/{{ forked_from['slug'] }}">&#x2442; 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 }} &middot; {{ author.get('stars', 0) if author else 0 }} stars</span>
</div>
</div>
</div>
</aside>
</div>
</div>
{% 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">&#x1F5BC;&#xFE0F;</span>Add screenshots</button>
</div>
</form>
{% endcall %}
{% call modal('edit-project-modal', 'Edit Project') %}
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}">
<form method="POST" action="/projects/edit/{{ project['slug'] or project['uid'] }}" enctype="multipart/form-data">
<div class="auth-field auth-field-gap">
<label for="edit-project-title">Title</label>
<input type="text" id="edit-project-title" name="title" required maxlength="200" value="{{ project['title'] }}">
@@ -149,6 +272,28 @@
</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 for="edit-project-cover_image">Cover image (optional{% if project.get('cover_image') %}, replaces current{% endif %})</label>
<input type="file" id="edit-project-cover_image" name="cover_image" accept="image/*">
</div>
<div class="auth-field">
<label for="edit-project-logo_image">Project logo (optional{% if project.get('logo_image') %}, replaces current{% endif %})</label>
<input type="file" id="edit-project-logo_image" name="logo_image" accept="image/*">
</div>
</div>
<div class="auth-field auth-field-gap">
<label>Type</label>
<div class="flex-wrap-gap" role="group" aria-label="Type">
@@ -192,10 +337,6 @@
</form>
{% endcall %}
{% endif %}
{% with target_uid=project['uid'], target_type="project" %}
{% include "_comment_section.html" %}
{% endwith %}
</div>
{% endblock %}
{% block extra_js %}
@@ -207,6 +348,3 @@ if (actions) {
}
</script>
{% endblock %}
+23 -1
View File
@@ -104,7 +104,7 @@
<button type="button" class="feed-fab" id="create-project-btn" title="Add Project" data-modal="create-project-modal" aria-label="Add Project">+</button>
{% call modal('create-project-modal', 'Create Project') %}
<form method="POST" action="/projects/create">
<form method="POST" action="/projects/create" enctype="multipart/form-data">
<div class="auth-field auth-field-gap">
<label for="title">Title</label>
<input type="text" id="title" name="title" required maxlength="200" placeholder="Project name">
@@ -126,6 +126,28 @@
</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 for="cover_image">Cover image (optional)</label>
<input type="file" id="cover_image" name="cover_image" accept="image/*">
</div>
<div class="auth-field">
<label for="logo_image">Project logo (optional)</label>
<input type="file" id="logo_image" name="logo_image" accept="image/*">
</div>
</div>
<div class="auth-field auth-field-gap">
<label>Type</label>
<div class="flex-wrap-gap" role="group" aria-label="Type">
+1
View File
@@ -421,6 +421,7 @@ Every state-changing action in DevPlace records one append-only row through `dev
| `project.fork.request` | `routers/projects/index.py` |
| `project.readonly.disable` | `routers/projects/index.py` |
| `project.readonly.enable` | `routers/projects/index.py` |
| `project.screenshots.add` | `routers/projects/index.py` |
| `project.visibility.private` | `routers/projects/index.py` |
| `project.visibility.public` | `routers/projects/index.py` |
| `project.zip.request` | `routers/projects/index.py` |
+186
View File
@@ -271,3 +271,189 @@ def test_devlog_works_for_guest_visitor(app_server):
assert "devlog_posts" in body
assert body["devlog_posts"] == []
def test_devlog_count_in_json(app_server):
"""devlog_count reflects every linked post, beyond the rendered page."""
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 test_project_page_emits_typed_json_ld(app_server):
"""A game project renders VideoGame JSON-LD; the devlog renders a Blog graph."""
session, name = _member()
title = _unique("dlseo")
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": title,
"description": "SEO schema test project",
"project_type": "game",
"status": "In Development",
"platforms": "PC,Web",
},
)
assert r.status_code == 200, r.text[:300]
project = r.json()["data"]
slug = project["slug"] or project["uid"]
user = _db_user(name)
marker = _unique("dlseopost")
_create_post_direct(project["uid"], user["uid"], 0, marker=marker)
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert '"VideoGame"' in html, "Expected VideoGame JSON-LD for a game project"
assert '"Blog"' in html, "Expected a Blog node for the devlog"
assert '"BlogPosting"' in html, "Expected BlogPosting entries for devlog posts"
assert '"gamePlatform"' in html, "Expected gamePlatform from the platforms field"
def test_project_page_cover_and_screenshots_from_image_attachments(app_server):
"""An image attachment becomes the hero cover, the Screenshots section, and schema screenshots."""
import io
from PIL import Image
session, _ = _member()
buf = io.BytesIO()
Image.new("RGB", (8, 8), (30, 60, 120)).save(buf, "PNG")
r = session.post(
f"{BASE_URL}/uploads/upload",
files={"file": ("shot.png", buf.getvalue(), "image/png")},
)
assert r.status_code == 201, r.text[:300]
attachment_uid = r.json()["uid"]
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": _unique("dlshot"),
"description": "Cover test project",
"project_type": "game",
"status": "In Development",
"platforms": "PC",
"attachment_uids": attachment_uid,
},
)
assert r.status_code == 200, r.text[:300]
slug = r.json()["data"]["slug"]
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert 'class="project-cover"' in html, "Expected the image attachment as hero cover"
assert "project-screenshot-grid" in html, "Expected the Screenshots section"
assert '"screenshot"' in html, "Expected screenshot urls in the JSON-LD"
def test_owner_uploaded_cover_and_logo_render_in_hero(app_server):
"""Multipart cover_image/logo_image uploads land on the row and in the hero."""
import io
from PIL import Image
session, _ = _member()
def png(color):
buf = io.BytesIO()
Image.new("RGB", (12, 6), color).save(buf, "PNG")
return buf.getvalue()
r = session.post(
f"{BASE_URL}/projects/create",
headers=JSON,
data={
"title": _unique("dlhero"),
"description": "Hero art test project",
"project_type": "game",
"status": "In Development",
"platforms": "PC",
},
files={
"cover_image": ("cover.png", png((10, 20, 90)), "image/png"),
"logo_image": ("logo.png", png((90, 20, 10)), "image/png"),
},
)
assert r.status_code == 200, r.text[:300]
slug = r.json()["data"]["slug"]
refresh_snapshot()
row = get_table("projects").find_one(slug=slug)
assert row["cover_image"], "cover_image filename expected on the row"
assert row["logo_image"], "logo_image filename expected on the row"
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert f"/static/uploads/{row['cover_image']}" in html
assert f"/static/uploads/{row['logo_image']}" in html
assert 'class="project-logo"' in html
assert '"thumbnailUrl"' in html, "Expected the logo as schema thumbnailUrl"
def test_owner_adds_screenshots_from_the_page(app_server):
"""POST /projects/{slug}/screenshots links uploaded images into the gallery; non-owners are refused."""
import io
from PIL import Image
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert "project-screenshot-grid" not in html
buf = io.BytesIO()
Image.new("RGB", (10, 10), (5, 120, 60)).save(buf, "PNG")
r = session.post(
f"{BASE_URL}/uploads/upload",
files={"file": ("gallery.png", buf.getvalue(), "image/png")},
)
assert r.status_code == 201, r.text[:300]
uid = r.json()["uid"]
r = session.post(
f"{BASE_URL}/projects/{slug}/screenshots",
headers=JSON,
data={"attachment_uids": uid},
)
assert r.status_code == 200, r.text[:300]
assert r.json()["data"]["linked"] == 1
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert "project-screenshot-grid" in html, "Expected the gallery after linking"
intruder, _ = _member()
r = intruder.post(
f"{BASE_URL}/projects/{slug}/screenshots",
headers=JSON,
data={"attachment_uids": uid},
)
assert r.status_code == 403, r.text[:300]
def test_project_page_json_ld_rating_from_stars(app_server):
"""Stars surface as an aggregateRating; zero stars emit none."""
session, _ = _member()
project = _create_project(session)
slug = project["slug"] or project["uid"]
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert '"aggregateRating"' not in html, "No rating expected without stars"
r = session.post(
f"{BASE_URL}/votes/project/{project['uid']}",
data={"value": "1"},
headers={"X-Requested-With": "fetch"},
)
assert r.status_code == 200, r.text[:300]
html = session.get(f"{BASE_URL}/projects/{slug}").text
assert '"aggregateRating"' in html, "Expected aggregateRating once starred"
+38
View File
@@ -105,6 +105,44 @@ def test_owner_can_edit_project(app_server):
assert row["platforms"] == "Linux,Web"
def test_owner_can_set_and_clear_website_url(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 a website 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 '"sameAs"' in html
r = requests.post(
f"{BASE_URL}/projects/edit/{slug}",
headers=_h_project_visibility(key),
data={
"title": "Website Via Api",
"description": "website removed",
"website_url": "",
},
allow_redirects=False,
)
assert r.status_code == 200
assert get_table("projects").find_one(slug=slug)["website_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):
_, _, owner_key = _signup_project_visibility()
slug = _create_project_project_visibility(owner_key, "Owner Edit Guard")["slug"]
+1 -1
View File
@@ -119,7 +119,7 @@ def _alice_key():
def test_files_link_on_detail(alice):
page, _ = alice
_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()
link.click()
page.wait_for_url("**/files", wait_until="domcontentloaded")
+41 -1
View File
@@ -85,7 +85,7 @@ def test_devlog_empty_state_on_project_page(alice):
devlog_section = page.locator(".project-devlog")
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()
@@ -187,3 +187,43 @@ def test_devlog_multiple_posts_order(alice):
f"Expected newest post first: {markers[-1]}, got: {first_text}"
)
def test_project_stats_strip_visible(alice):
"""The overview stats strip 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
+7 -7
View File
@@ -767,7 +767,7 @@ def test_project_comments_form_visible(alice):
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
assert page.is_visible("text=Comments")
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):
@@ -778,8 +778,8 @@ def test_project_comment_create(alice):
page.fill("#description", "Project for creating a comment")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Great project!")
page.click("button:has-text('Post')")
page.fill(".comment-form textarea[name='content']", "Great project!")
page.click(".comment-form button:has-text('Post')")
page.wait_for_timeout(500)
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.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "First comment")
page.click("button:has-text('Post')")
page.fill(".comment-form textarea[name='content']", "First comment")
page.click(".comment-form button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=First comment")
page.click("button:has-text('Reply')")
@@ -812,8 +812,8 @@ def test_project_comment_delete(alice):
page.fill("#description", "Project for testing delete")
page.click("button:has-text('Create Project')")
page.wait_for_url(f"{BASE_URL}/projects/*", wait_until="domcontentloaded")
page.fill("textarea[name='content']", "Comment to delete")
page.click("button:has-text('Post')")
page.fill(".comment-form textarea[name='content']", "Comment to delete")
page.click(".comment-form button:has-text('Post')")
page.wait_for_timeout(500)
assert page.is_visible("text=Comment to delete")
page.locator(".comment-action-btn:has-text('Delete')").click()
+18
View File
@@ -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"
def test_project_form_website_url_normalizes_and_validates():
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, website_url="http://x.dev/a?b=1").website_url
== "http://x.dev/a?b=1"
)
assert ProjectForm(**base, repo_url="github.com/me/x").repo_url == "https://github.com/me/x"
assert normalize_website_url(" ") == ""
with pytest.raises(ValidationError):
ProjectForm(**base, website_url="javascript:alert(1)")
with pytest.raises(ValidationError):
ProjectForm(**base, website_url="not a url")
def test_reaction_form_accepts_any_single_emoji():
from devplacepy.models import ReactionForm
+93
View File
@@ -49,6 +49,99 @@ def test_news_article_publisher_is_organization():
assert schema["publisher"]["@type"] == "Organization"
def test_project_schema_type_mapping():
assert seo.project_schema_type("game") == "VideoGame"
assert seo.project_schema_type("game_asset") == "CreativeWork"
assert seo.project_schema_type("website") == "WebApplication"
assert seo.project_schema_type("software") == "SoftwareApplication"
assert seo.project_schema_type("mobile_app") == "SoftwareApplication"
assert seo.project_schema_type(None) == "SoftwareApplication"
assert seo.project_schema_type("unknown") == "SoftwareApplication"
def test_software_application_schema_is_type_aware():
game = seo.software_application_schema(
{"uid": "p1", "slug": "p1-g", "title": "G", "project_type": "game", "platforms": "PC, Web"},
"https://x.test",
)
assert game["@type"] == "VideoGame"
assert game["gamePlatform"] == ["PC", "Web"]
assert "applicationCategory" not in game
web = seo.software_application_schema(
{"uid": "p2", "slug": "p2-w", "title": "W", "project_type": "website"},
"https://x.test",
)
assert web["@type"] == "WebApplication"
assert web["browserRequirements"] == "Requires JavaScript"
assert web["applicationCategory"] == "DeveloperApplication"
def test_software_application_schema_rating_and_image():
project = {"uid": "p1", "slug": "p1-s", "title": "S", "project_type": "software"}
plain = seo.software_application_schema(project, "https://x.test")
assert "aggregateRating" not in plain
assert "image" not in plain
rich = seo.software_application_schema(
project,
"https://x.test",
image_url="https://x.test/img.png",
star_count=7,
comment_count=3,
)
assert rich["aggregateRating"]["ratingCount"] == "7"
assert rich["image"] == "https://x.test/img.png"
assert rich["interactionStatistic"]["userInteractionCount"] == 3
def test_software_application_schema_website_and_screenshots():
project = {
"uid": "p1",
"slug": "p1-s",
"title": "S",
"project_type": "software",
"website_url": "https://myproject.dev",
}
schema = seo.software_application_schema(
project,
"https://x.test",
screenshot_urls=["https://x.test/a.png", "https://x.test/b.png"],
)
assert schema["sameAs"] == ["https://myproject.dev"]
assert schema["screenshot"] == ["https://x.test/a.png", "https://x.test/b.png"]
bare = seo.software_application_schema(
{"uid": "p2", "slug": "p2-s", "title": "B"}, "https://x.test"
)
assert "sameAs" not in bare
assert "screenshot" not in bare
def test_project_devlog_schema_builds_blog_graph():
project = {"uid": "p1", "slug": "p1-s", "title": "Nebula"}
assert seo.project_devlog_schema(project, [], "https://x.test") is None
posts = [
{
"post": {
"uid": "a1",
"slug": "a1-first",
"title": "First update",
"content": "Progress!",
"created_at": "2026-01-01T00:00:00+00:00",
},
"author": {"username": "dev"},
"comment_count": 2,
}
]
schema = seo.project_devlog_schema(project, posts, "https://x.test")
assert schema["@type"] == "Blog"
assert schema["url"] == "https://x.test/projects/p1-s#devlog"
entry = schema["blogPost"][0]
assert entry["@type"] == "BlogPosting"
assert entry["headline"] == "First update"
assert entry["url"] == "https://x.test/posts/a1-first"
assert entry["author"]["name"] == "dev"
assert entry["commentCount"] == 2
def test_site_url_precedence(monkeypatch):
import devplacepy.database as database