feat: add project file system with CRUD, upload, inline editing, and video attachment support

- Add new `/projects/{slug}/files` endpoint group for per-project filesystem operations including directory and file CRUD, upload, and inline editing with public read and owner write access
- Extend attachment system to support video formats (webm, ogv, mov, m4v) with proper file icons and MIME types
- Implement configurable allowed file types via `allowed_file_types` site setting, replacing hardcoded `ALLOWED_UPLOAD_TYPES` with dynamic `allowed_extensions()` and `is_extension_allowed()` functions
- Add `delete_all_project_files()` call in `delete_content_item()` to clean up project files when a project is deleted
- Create database indexes on `project_files` table for `(project_uid, path)` and `(project_uid, parent_path)` to optimize file lookups
- Introduce `docs_prose.py` module with `render_prose()` function that renders Markdown content inside `data-render` divs using mistune, enabling dynamic prose rendering in documentation pages
- Enhance docs search with Markdown-aware text stripping (`_demarkdown()`) and improved HTML/script/style sanitization for better search indexing
- Update documentation API samples to reflect new attachment response fields (`is_image`, `is_video`, `mime_type`) and note video format support
- Update README to document the new project files endpoint and clarify AI gateway attribution for guest Devii sessions
This commit is contained in:
2026-06-08 20:51:09 +00:00
parent e0535bb7c5
commit 97eb58fc19
110 changed files with 5534 additions and 603 deletions
@@ -0,0 +1,126 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class AppDialog extends Component {
connectedCallback() {
if (this._built) {
return;
}
this._built = true;
this.resolver = null;
this.mode = "confirm";
this.lastFocus = null;
this.build();
}
build() {
const overlay = document.createElement("div");
overlay.className = "dialog-overlay";
overlay.setAttribute("role", "dialog");
overlay.setAttribute("aria-modal", "true");
overlay.innerHTML =
'<div class="card dialog-card">' +
'<div class="modal-header"><h3 class="dialog-title"></h3>' +
'<button type="button" class="modal-close btn-ghost btn-icon dialog-close">&times;</button></div>' +
'<p class="dialog-message"></p>' +
'<div class="dialog-field" hidden><label class="dialog-field-label"></label>' +
'<input type="text" class="dialog-input" autocomplete="off"></div>' +
'<div class="modal-footer">' +
'<button type="button" class="btn btn-secondary dialog-cancel">Cancel</button>' +
'<button type="button" class="btn btn-primary dialog-confirm">OK</button>' +
"</div></div>";
this.appendChild(overlay);
this.overlay = overlay;
this.titleEl = overlay.querySelector(".dialog-title");
this.messageEl = overlay.querySelector(".dialog-message");
this.field = overlay.querySelector(".dialog-field");
this.fieldLabel = overlay.querySelector(".dialog-field-label");
this.input = overlay.querySelector(".dialog-input");
this.cancelBtn = overlay.querySelector(".dialog-cancel");
this.confirmBtn = overlay.querySelector(".dialog-confirm");
overlay.addEventListener("click", (e) => {
if (e.target === overlay) this.dismiss();
});
overlay.querySelector(".dialog-close").addEventListener("click", () => this.dismiss());
this.cancelBtn.addEventListener("click", () => this.dismiss());
this.confirmBtn.addEventListener("click", () => this.accept());
this.input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
this.accept();
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.overlay.classList.contains("visible")) {
e.preventDefault();
this.dismiss();
}
});
}
open(mode, options) {
const opts = options || {};
this.mode = mode;
this.titleEl.textContent = opts.title || (mode === "prompt" ? "Rename" : mode === "alert" ? "Notice" : "Confirm");
this.messageEl.textContent = opts.message || "";
this.messageEl.hidden = !opts.message;
this.confirmBtn.textContent = opts.confirmLabel || (mode === "alert" ? "OK" : mode === "prompt" ? "Save" : "Confirm");
this.cancelBtn.textContent = opts.cancelLabel || "Cancel";
this.cancelBtn.hidden = mode === "alert";
this.confirmBtn.classList.toggle("dialog-danger", !!opts.danger);
if (mode === "prompt") {
this.field.hidden = false;
this.fieldLabel.textContent = opts.label || "";
this.fieldLabel.hidden = !opts.label;
this.input.value = opts.value || "";
this.input.placeholder = opts.placeholder || "";
} else {
this.field.hidden = true;
}
this.lastFocus = document.activeElement;
this.overlay.classList.add("visible");
const focusTarget = mode === "prompt" ? this.input : this.confirmBtn;
setTimeout(() => {
focusTarget.focus();
if (mode === "prompt") this.input.select();
}, 20);
return new Promise((resolve) => { this.resolver = resolve; });
}
accept() {
const value = this.mode === "prompt" ? this.input.value : true;
this.close(value);
}
dismiss() {
this.close(this.mode === "prompt" ? null : this.mode === "alert" ? undefined : false);
}
close(value) {
this.overlay.classList.remove("visible");
if (this.lastFocus && this.lastFocus.focus) this.lastFocus.focus();
const resolve = this.resolver;
this.resolver = null;
if (resolve) resolve(value);
}
confirm(options) {
return this.open("confirm", options);
}
prompt(options) {
return this.open("prompt", options);
}
alert(options) {
return this.open("alert", options);
}
}
customElements.define("dp-dialog", AppDialog);