This commit is contained in:
2026-07-07 16:09:28 +02:00
parent 32c8bbe0a9
commit 5083efb150
42 changed files with 2556 additions and 2806 deletions
+94
View File
@@ -0,0 +1,94 @@
This file documents JS module organization, custom web components, and shared frontend utilities under `static/js/`. Claude Code auto-loads it whenever a file under this directory is read or edited.
## JS module organization (hard rules)
- All JS in `static/js/` is ES6 modules, one class per file, instantiated as `app` and reachable everywhere. The browser-side application root is `Application.js`.
- Javascript files must be in module (ES6) format and imported as module format. It must be as object oriented as possible and a file per class. There must always be a main application class called `Application`, instantiated as `app`, accessible everywhere.
- Never use JavaScript 3rd party frameworks unless specified.
- CDN scripts referenced from `base.html` (marked, highlight.js, emoji-picker-element) MUST use `defer` or `type="module"` - otherwise `wait_until="domcontentloaded"` in Playwright tests will time out.
- **Static asset URLs are boot-versioned for cache-busting.** Assets are served `public, immutable, max-age=31536000` (1 year, for the Lighthouse "efficient cache policy" score) while a restart still busts every browser. Never hardcode a bare `/static/...` href/src: templates wrap it in the `static_url(path)` Jinja global (`templating.py`) and runtime JS that builds an absolute static URL uses `assetUrl(path)` (`static/js/assetVersion.js`, reads `<meta name="asset-version">` rendered in `base.html`). Both emit a `/static/v<STATIC_VERSION>/...` path segment (`config.STATIC_VERSION` = `DEVPLACE_STATIC_VERSION` env or `int(time.time())` at process start), so a deploy = a restart = a new timestamp = a new URL for every asset. Both helpers no-op for non-`/static/` paths and for `/static/uploads/`, so they are safe to wrap around dynamic values.
- **Path segment, never a query string - do NOT switch this to `?v=`.** The frontend is unbundled ES6 with ~130 relative imports (`./Http.js`) and zero absolute ones. A `?v=` only versions the entry `<script>`; its transitive relative imports would resolve to unversioned URLs and stay frozen under `immutable`, so a deploy would not propagate JS for a year. A path segment is inherited automatically by the entire module graph and by relative CSS `url()`.
- **Serving:** `main.py` mounts a `CachedStaticFiles` at `/static/v{STATIC_VERSION}` (immutable 1y; `service-worker.js` -> `no-cache`) plus the plain `/static` mount as the unversioned fallback; nginx mirrors this with `location ~ ^/static/v\d+/` (immutable 1y) and a short `max-age=3600` on the unversioned `/static/`. `/static/uploads/` (user content, stable DB paths) is never versioned; `service-worker.js` keeps a stable unversioned URL with `no-cache` so its registration scope is stable and a new worker always deploys.
- **Multi-worker:** the version is captured **once at launch** and shared via `DEVPLACE_STATIC_VERSION` (set in `make prod` = `$(date +%s)` and the Dockerfile's `sh -c` CMD) - otherwise two workers could compute timestamps a second apart and serve mismatched versioned mounts (a 404 on the other worker). Unset in dev (`--reload`) so each reload refreshes it.
## Custom web components (`static/js/components/`)
Self-contained, presentational UI is built as custom elements with the `dp-` prefix:
`dp-avatar`, `dp-code`, `dp-content`, `dp-title`, `dp-toast`, `dp-dialog`, `dp-context-menu`, `dp-upload`, `dp-lightbox`.
Conventions:
- Each extends `Component` (`static/js/components/Component.js`), a thin `HTMLElement` base with `attr(name, fallback)`, `boolAttr(name)`, `intAttr(name, fallback)` helpers.
- **Light DOM only** (no `attachShadow`), so the site's global CSS applies - matching the existing `devii-*` elements. `dp-dialog`/`dp-context-menu` reuse the existing `.dialog-*`/`.context-menu*` styles in `components.css`; `dp-toast` styles are in `components.css` (`.dp-toast-host`/`.dp-toast`).
- Each file self-registers via `customElements.define` at the bottom. `components/index.js` imports them all and is imported by `Application.js`, so every element is defined on every page (including `/docs`).
- The behavioural singletons are created once in `Application.js` and exposed as `app.dialog`, `app.contextMenu`, `app.toast`. The `dp-dialog`/`dp-context-menu` elements keep the exact pre-existing `Dialog`/`ContextMenu` APIs (`confirm`/`prompt`/`alert`/`open`, `attach`/`open`/`close`) so existing callers (`ProjectFiles`, `ApiKeyManager`, `ModalManager`, `PushManager`, `ApiTester`) are unchanged. `Avatar`/`CodeBlock`/`Toast`/`ContentRenderer` remain as static/service modules (widely imported); the matching elements (`dp-avatar`, `dp-code`, `dp-content`) reuse them. `dp-content` wraps the shared `contentRenderer` engine that also powers the `data-render` attribute (driven by `ContentEnhancer`) - the engine stays a reusable service, the element is its component face. `dp-content` captures its raw markdown source before `contentRenderer.applyTo` overwrites `textContent`, then mounts a hover/focus reveal `.content-copy-btn` in its top-right corner that copies that original source to the clipboard (mirroring `CodeBlock.copyButton`); CSS lives beside `.code-copy-btn` in `docs.css`.
- **`dp-content` / `dp-title` are CLIENT-only now (live contexts).** `dp-content` (`AppContent.js`) and its inline companion `dp-title` (`AppTitle.js`, `contentRenderer.renderInline`) render markdown/emoji/media (and, for title, inline-only with an inline-tag allowlist) on the client from `textContent`. **They are no longer used for server-rendered content/titles** - those moved to the backend `render_content`/`render_title` globals (see the root CLAUDE.md "Content rendering pipeline" section) for SEO. The components remain defined and are used only where content is generated client-side after load: `dp-content` in the DeepSearch chat (`AppDeepsearchChat.js`) and the planning report (`PlanningGenerator.js` -> `admin_issues_planning.html`). Keep them for those live cases and future use; do NOT wrap server-known content/titles in them. Both are now used ONLY for live/client-generated content (chat, planning report, dynamically inserted bubbles); server-rendered content/titles use the backend `render_content`/`render_title` globals instead. The components are intentionally kept for those live contexts and future use.
- **What is NOT a component (by design):** partial-bound controllers (`ReactionBar`, `VoteManager`, `BookmarkManager`, `PollManager`, `CommentManager`, etc.) enhance server-rendered `_*.html` markup rather than render standalone UI; pure utilities (`Http`, `DomUtils`, `FormManager`, `Poller`, `JobPoller`, `OptimisticAction`) have no UI. These stay plain ES6 modules. The four optimistic engagement controllers extend the shared `OptimisticAction` base - see "Shared frontend utilities" below. Self-contained presentational widgets become components; partial-bound controllers (votes, reactions, comments) and pure utilities stay plain modules because they enhance server-rendered markup rather than render standalone UI.
- Documented in the public docs **Components** section (`templates/docs/component-*.html`, registered under `SECTION_COMPONENTS` in the `routers/docs` package). Each page has a `data-render` markdown block (with example markup HTML-escaped as `&lt;dp-...&gt;`, since `data-render` re-parses `textContent` and would otherwise instantiate the example) plus a live demo in a separate block whose `<script type="module">` imports its own component module (it runs before `Application.js`).
- **`dp-lightbox` (`AppLightbox.js`, `app.lightbox`, `static/css/lightbox.css`)** is the single image lightbox. It is **attribute-wired, not call-wired**: the singleton installs ONE delegated `document` click listener and opens any clicked `img[data-lightbox]` (showing `data-full` when present, else the image's own src; `alt` as caption; ignored when the image sits inside an `<a>`). To make a thumbnail open it, just add `data-lightbox` - never attach a click handler. Attachment thumbnails carry `data-lightbox`+`data-full` (`_attachment_display.html`); markdown content images are marked centrally in `ContentEnhancer.initContentRenderer` (one place covers every `data-render` surface), so embedded images are always clickable. The old hand-rolled `.attachment-lightbox` markup/CSS was dead (never wired) and has been removed.
- `dp-upload` (the single file-upload button used everywhere) replaced the old `AttachmentUploader`; see "The upload button (`dp-upload`)" below.
## Shared frontend utilities (do not re-implement these)
A small set of plain ES6 modules under `static/js/` own the cross-cutting patterns so feature code stays tiny. Reach for these instead of hand-rolling a loop, a fetch, or a click handler. `Http` (`static/js/Http.js`) is the single fetch helper (`getJson`, `sendForm`, and `send` which throws `error.message` on non-2xx or a `200 {ok:false}` body); every live-update loop uses `Poller` (`new Poller(fn, intervalMs, {pauseHidden})`); async-job status polls use `JobPoller.run(statusUrl, {onDone, onFailed, onTimeout})` (`ProjectForker`, `ZipDownloader`); click-to-POST engagement controllers (`VoteManager`/`ReactionBar`/`BookmarkManager`/`PollManager`) extend `OptimisticAction` and call `this.submit(url, params, errorTarget, render)`. Floating windows (container terminals and Devii) extend `FloatingWindow`. **Scroll restoration is `ScrollMemory`** (`static/js/ScrollMemory.js`, `app.scrollMemory`): per-tab (sessionStorage) positions keyed by exact `path+search`, restored ONLY on `back_forward`/`reload` navigations or a click on `a.back-link`/`[data-scroll-back]`/breadcrumb/previous-trail-URL links, applied via a layout-stable rAF loop that aborts on user input; it sets `history.scrollRestoration = "manual"` site-wide and upgrades query-less back-link hrefs to the exact previous URL - mark any "back to X" anchor with the `back-link` class and never hand-roll scroll persistence.
Detail on each utility:
- **`Http` (`static/js/Http.js`, global `window.Http`).** The single HTTP helper. `getJson(url)` (GET -> JSON, throws on non-2xx); `sendForm(url, params)` (POST form-encoded, follows the `/auth/login` redirect via `Http.toLogin()`, throws a bare status on failure, returns JSON); `send(url, params)` (POST form-encoded that throws `data.error.message` on `!ok` **or** a 200 body with `ok:false` - the manager-style error the container/admin UIs surface in a toast); `postJson`/`postForm`/`toLogin`. Container files (`ContainerManager`, `ContainerList`, `ContainerInstance`, `ContainerTerminal`), `ServiceMonitor`, and `ProjectFiles` all route through it - none re-implement `fetch`.
- **`Poller` (`static/js/Poller.js`).** `new Poller(fn, intervalMs, { immediate = true, pauseHidden = false })` runs `fn` on an interval with `start()`/`stop()`/`tick()`; `tick()` swallows errors so one failed poll never kills the loop, and `pauseHidden` skips the tick while `document.hidden`. Used by every live-update loop: `CounterManager` (30s, `pauseHidden`), `ContainerManager` (3s), `ContainerList` (4s), `AiUsageMonitor`, `ServiceMonitor`, and `ContainerInstance`'s detail (4s) + logs (3s). Store the `Poller`, not a raw interval id.
- **`JobPoller` (`static/js/JobPoller.js`).** `JobPoller.run(statusUrl, { onDone, onFailed, onTimeout, intervalMs = 1500, maxAttempts = 200 })` returns a Promise; it polls `Http.getJson(statusUrl)`, swallows transient fetch errors, and fires the matching callback on `status === "done"|"failed"` or timeout. This is the one place the async-job status-poll lives - `ProjectForker` and `ZipDownloader` both call it with their own navigate/download/toast callbacks.
- **`OptimisticAction` (`static/js/OptimisticAction.js`).** Base with one method, `submit(url, params, errorTarget, render)`: `Http.sendForm` -> `render(result)` on success -> `console.error` + (when `errorTarget` is given) `Toast.flash(errorTarget, "Error", 1500)` on failure. `VoteManager`/`ReactionBar`/`BookmarkManager`/`PollManager` `extend` it and call `this.submit(...)` for their POST, **keeping their own event wiring** (so `ReactionBar`'s palette toggle and `PollManager`'s multi-action handlers and `VoteManager`'s per-button `stopPropagation` are untouched). Pass `errorTarget` only where the old code toasted (`VoteManager`); the others pass `null` to keep their console-only behaviour.
- **`FloatingWindow` / `WindowManager` (`static/js/components/`).** The draggable window base and shared z-order manager - documented in the Container manager section; both the container terminals and the Devii terminal extend `FloatingWindow`.
- **`ScrollMemory` (`static/js/ScrollMemory.js`, `app.scrollMemory`).** Site-wide, per-tab scroll restoration - the fix for the "back to feed jumps to top" class of bugs. It sets `history.scrollRestoration = "manual"` once (never rely on the browser's auto-restore, which fires before late-loading content settles and never applies to normal link navigations), so ALL scroll restoration flows through this one module. State lives in `sessionStorage` (per-tab by definition, exactly the required scope): a position map keyed by exact `pathname + search` (hash ignored; saved by a throttled passive scroll listener plus a final write on `pagehide`/hidden `visibilitychange`, pruned to 50 entries / 60 min), a visited-URL trail (capped at 20), and a one-shot click-intent flag (30s validity, consumed on every load).
**When it restores** - only when the situation is genuinely "going back": (1) navigation type `back_forward` (browser back without bfcache; with bfcache - `pageshow` `persisted` - the frozen page already has its scroll, so it only re-syncs the trail and clears the intent flag), (2) `reload`, (3) a same-origin click on `a.back-link` / `a[data-scroll-back]` / a breadcrumb link / any link whose target equals the trail's previous URL, which stamps the intent flag the next load matches. A fresh visit (topnav, address bar, redirect after POST) never restores, and a URL with a `#fragment` always wins over restoration.
**How it restores reliably**: a `requestAnimationFrame` loop re-applies the target position (clamped to the current `scrollHeight`, `behavior: "instant"` so the global `html { scroll-behavior: smooth }` never animates it) until the document height has been stable at the target for 10 frames or a 4s deadline passes, and aborts instantly on the first `wheel`/`touchstart`/`keydown`/`pointerdown` so it never fights the user. It also **upgrades bare back-links on load**: when the previous trail URL has the same pathname as a query-less `a.back-link`/`[data-scroll-back]` href (post page's `/feed` vs the `/feed?tab=recent&before=...` the user actually came from), the href is rewritten to the exact previous URL so tab/topic/cursor AND scroll survive the round trip. Give any new "back to X" anchor the `back-link` class (or `data-scroll-back`) and it participates automatically - never hand-roll `scrollTo` persistence per page. Guarded by `tests/e2e/feed.py::test_feed_scroll_restored_via_back_link` / `_via_browser_back` / `_not_restored_on_fresh_visit`.
- **On-screen keyboard reflow (mobile).** `FloatingWindow` listens on `window.visualViewport` `resize`/`scroll` and, while fullscreen/maximized, sets the window inline `height`/`top` to the visual viewport (the area NOT covered by the phone keyboard) instead of letting the keyboard push the window off-screen; it also toggles the `fw-keyboard-visible` class whose CSS drops the fixed `bottom` so the inline size wins. The Devii terminal mirrors this in its own `_setState` / `_onVisualViewport2` (`devii-keyboard-visible`) and `_ensureInputVisible` keeps the focused `.devii-input` pinned just above the keyboard; `ContainerTerminal._onResize` re-fits xterm against the shrunken viewport and `scrollToBottom`s. `AppContextMenu.open` clamps into the visual-viewport rect (`offsetLeft/Top` + `width/height`) so Paste stays on-screen above the keyboard, and the menu no longer closes on a visualViewport-only height change (keyboard appearing) - only a true `window.innerWidth` change closes it. All paths fall back to `window.inner*` when `visualViewport` is absent, so desktop and old browsers are untouched.
## Clickable avatars and usernames
Every avatar and username in the UI links to the user's profile page. Use the `_avatar_link.html` and `_user_link.html` include components:
```html
{% set _user = item.author %}
{% set _size = 32 %}
{% set _size_class = "sm" %}
{% include "_avatar_link.html" %}
<a href="/profile/{{ user['username'] }}" class="post-author-link">{{ user['username'] }}</a>
```
The include files expect: `_user` (dict), `_size` (pixels), `_size_class` ("sm"|"md"|"lg").
Affected templates: `base.html`, `feed.html`, `post.html`, `profile.html`, `messages.html`, `notifications.html`.
For clickable avatars/usernames, reuse `templates/_avatar_link.html` and `templates/_user_link.html` via `{% include %}` with `_user`/`_size`/`_size_class` locals.
## Image upload
When a user uploads an image during post creation, the markdown `![](/static/uploads/{filename})` is appended to the post content. The ContentRenderer then renders it as an `<img>`. All URLs are relative.
```python
content += f"\n\n![](/static/uploads/{image_filename})"
```
File validation: max 5MB, allowed extensions: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`.
## Attachments and media
`attachments.py` is the single store for files referenced via `attachment_uids` (posts, comments, projects, gists, messages, issues). One gate, `is_extension_allowed(ext)`, decides what may be uploaded and is used by both `routers/uploads.py` and `store_attachment()` - do not reintroduce a second check. Its semantics: when the admin `allowed_file_types` setting is non-empty it is the **authoritative** allowlist (mime auto-detected via `mimetypes` for extensions outside the built-in `ALLOWED_UPLOAD_TYPES` map); when empty it falls back to the built-in map, which includes images plus the browser-playable video formats `.mp4/.webm/.ogv/.mov/.m4v`. (Before this, `allowed_file_types` could only *narrow* the hardcoded list, never add to it - which is why adding a video extension there had no effect.)
**Ingesting a file from a URL.** `store_attachment_from_url(url, user_uid, filename=None)` (async, in `attachments.py`) is the remote counterpart to `store_attachment`: it downloads the URL on the server through `fetch_remote_file()` - SSRF-guarded (`_guard_public_url` resolves the host and refuses private/loopback/reserved/multicast addresses, mirroring the Devii fetch guard) and size-capped (streams, aborting once `_get_max_upload_bytes()` is exceeded) - resolves a filename from the URL path or the response `Content-Type` (`MIME_TO_EXT`), then calls `store_attachment()` so the bytes land in the **exact same** pipeline (validation, thumbnailing, DB row). It raises `RemoteFetchError(message, status)` which the route maps to an HTTP status. It is exposed at `POST /uploads/upload-url` (`UploadUrlForm{url, filename?}`, `require_user_api`) and as the Devii catalog action `attach_url` (handler `http`, `requires_auth=True`); both return the same record as `/uploads/upload`. The returned `uid` binds to a resource the same way as any upload - via `attachment_uids` at create/edit time - so attaching a remote image is just `attach_url` then `create_post`/`create_project`/etc. with that uid. Do not re-download remote files in a router; reuse this helper so the guard and size cap stay in one place.
`_row_to_attachment()` / `store_attachment()` expose `is_image` and `is_video` (derived from the mime prefix). The shared partial `templates/_attachment_display.html` branches image -> `<img>`, video -> `<video controls preload="metadata" class="gallery-video">`, else download link; rendering through this one partial is what makes video work across every feature at once. `AttachmentOut` (`schemas.py`) carries both flags - add new display keys there too or JSON drops them.
Media is served **inline** (not forced-download) for known-safe types only. The set `INLINE_MEDIA_EXTENSIONS` in `main.py` (`UploadStaticFiles`) and the matching `map $uri $upload_disposition` in `nginx/nginx.conf.template` must stay in sync: images/video/audio -> `inline` (so `<video>` plays and seeks via Range), everything else -> `attachment`. SVG is deliberately excluded from both (stored-XSS defense). `ContentRenderer.js` embeds direct video URLs typed into content via `videoExtRe`, mirroring its image handling.
### The upload button (`dp-upload`)
Every file-upload UI is the one custom element `dp-upload` (`static/js/components/AppUpload.js`); it replaced `AttachmentUploader.js` (deleted). It shows a clean button with a count badge + removable filename chips (no thumbnail grid), and runs in one of three `mode`s:
- `attachment` (default, the 7 forms via `_attachment_form.html` / inline in `_comment_form.html` and `messages.html`): uploads each file to `/uploads/upload` on select and keeps the hidden `<input name="attachment_uids">` the routers already link - **server contract unchanged**. Limits come from `max-size`/`max-files`/`allowed-types` attributes (fed by the Jinja globals).
- `direct` (the project file browser, `project_files.html`): uploads to a custom `endpoint` with `field-name` plus a settable `extraFields` (e.g. `{path}`) and emits `dp-upload:uploaded` / `dp-upload:done` / `dp-upload:error`. `ProjectFiles.uploadTo(dir)` sets `extraFields` then calls `widget.open()`, and refreshes the tree on `done`.
- `field` (create-post image, `feed.html`): wraps a real `<input type="file" name="image">` that submits with the form - no AJAX, inline-image flow unchanged.
The component validates size/type/count and reports errors via `app.toast`. CSS is `.dp-upload-*` in `components.css`; the old `.attachment-upload-*` upload-widget styles were removed, but the `.attachment-gallery`/`.attachment-lightbox` display styles (for already-saved attachments) remain.