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:
@@ -0,0 +1,138 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
|
||||
export class AppContextMenu extends Component {
|
||||
connectedCallback() {
|
||||
if (this._built) {
|
||||
return;
|
||||
}
|
||||
this._built = true;
|
||||
this.build();
|
||||
this.bindGlobal();
|
||||
}
|
||||
|
||||
build() {
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "context-menu";
|
||||
menu.setAttribute("role", "menu");
|
||||
this.appendChild(menu);
|
||||
this.menu = menu;
|
||||
menu.addEventListener("click", (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
bindGlobal() {
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!this.menu.contains(e.target)) this.close();
|
||||
});
|
||||
document.addEventListener("scroll", () => this.close(), true);
|
||||
window.addEventListener("resize", () => this.close());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") this.close();
|
||||
});
|
||||
}
|
||||
|
||||
render(items) {
|
||||
this.menu.textContent = "";
|
||||
const list = document.createElement("ul");
|
||||
list.className = "context-menu-list";
|
||||
items.forEach((item) => {
|
||||
const li = document.createElement("li");
|
||||
if (item.separator) {
|
||||
li.className = "context-menu-sep";
|
||||
list.appendChild(li);
|
||||
return;
|
||||
}
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "context-menu-item" + (item.danger ? " danger" : "");
|
||||
btn.disabled = !!item.disabled;
|
||||
const icon = item.icon ? `<span class="context-menu-icon">${item.icon}</span>` : "";
|
||||
btn.innerHTML = icon + '<span class="context-menu-label"></span>';
|
||||
btn.querySelector(".context-menu-label").textContent = item.label;
|
||||
if (!item.disabled && item.onSelect) {
|
||||
btn.addEventListener("click", () => {
|
||||
this.close();
|
||||
item.onSelect();
|
||||
});
|
||||
}
|
||||
li.appendChild(btn);
|
||||
list.appendChild(li);
|
||||
});
|
||||
this.menu.appendChild(list);
|
||||
}
|
||||
|
||||
open(x, y, items) {
|
||||
if (!items || !items.length) return;
|
||||
this.render(items);
|
||||
this.menu.classList.add("visible");
|
||||
const rect = this.menu.getBoundingClientRect();
|
||||
let left = x;
|
||||
let top = y;
|
||||
if (left + rect.width > window.innerWidth) left = window.innerWidth - rect.width - 8;
|
||||
if (top + rect.height > window.innerHeight) top = window.innerHeight - rect.height - 8;
|
||||
this.menu.style.left = Math.max(8, left) + "px";
|
||||
this.menu.style.top = Math.max(8, top) + "px";
|
||||
}
|
||||
|
||||
close() {
|
||||
this.menu.classList.remove("visible");
|
||||
}
|
||||
|
||||
suppressNextClick() {
|
||||
const handler = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
document.addEventListener("click", handler, { capture: true, once: true });
|
||||
}
|
||||
|
||||
attach(host, builder) {
|
||||
host.addEventListener("contextmenu", (e) => {
|
||||
const items = builder(e);
|
||||
if (!items || !items.length) return;
|
||||
e.preventDefault();
|
||||
this.open(e.clientX, e.clientY, items);
|
||||
});
|
||||
|
||||
let timer = null;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let fired = false;
|
||||
const clear = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
host.addEventListener("touchstart", (e) => {
|
||||
if (e.touches.length !== 1) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
const touch = e.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
fired = false;
|
||||
timer = setTimeout(() => {
|
||||
fired = true;
|
||||
this.open(startX, startY, builder(e) || []);
|
||||
}, 500);
|
||||
}, { passive: true });
|
||||
host.addEventListener("touchmove", (e) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch && (Math.abs(touch.clientX - startX) > 10 || Math.abs(touch.clientY - startY) > 10)) {
|
||||
clear();
|
||||
}
|
||||
}, { passive: true });
|
||||
host.addEventListener("touchend", () => {
|
||||
clear();
|
||||
if (fired) {
|
||||
fired = false;
|
||||
this.suppressNextClick();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("dp-context-menu", AppContextMenu);
|
||||
Reference in New Issue
Block a user