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
+20 -153
View File
@@ -1,123 +1,3 @@
.attachment-upload-container button.attachment-upload-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.5rem;
border-radius: var(--radius);
font-size: 1.125rem;
color: var(--text-muted);
cursor: pointer;
background: none;
border: 1px solid transparent;
line-height: 1;
}
.attachment-upload-container button.attachment-upload-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
}
.attachment-upload-container button.attachment-upload-btn.dragover {
border-color: var(--accent);
background: var(--accent-light);
}
.attachment-upload-container button.attachment-upload-btn.uploading {
pointer-events: none;
opacity: 0.6;
}
.attachment-preview-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
flex: 0 0 100%;
width: 100%;
}
.attachment-preview-list:empty,
.attachment-upload-error:empty {
display: none;
}
.attachment-preview {
position: relative;
width: 96px;
height: 96px;
border-radius: var(--radius);
border: 1px solid var(--border);
overflow: hidden;
background: var(--bg-card-hover);
}
.attachment-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachment-preview .file-icon {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 0.625rem;
color: var(--text-muted);
padding: 0.25rem;
text-align: center;
word-break: break-all;
}
.attachment-preview .file-icon .icon {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
.attachment-preview .file-size {
font-size: 0.625rem;
color: var(--text-muted);
margin-top: 0.125rem;
}
.attachment-preview .attachment-remove {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
color: #fff;
border: none;
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.attachment-preview:hover .attachment-remove {
opacity: 1;
}
.attachment-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: var(--bg-primary);
}
.attachment-progress-bar {
height: 100%;
background: var(--accent);
}
.attachment-gallery {
display: flex;
flex-wrap: wrap;
@@ -146,6 +26,26 @@
object-fit: cover;
}
.attachment-gallery-item:has(.gallery-video) {
width: 100%;
max-width: 480px;
height: auto;
aspect-ratio: 16 / 9;
cursor: default;
background: #000;
}
.attachment-gallery-item:has(.gallery-video):hover {
transform: none;
}
.attachment-gallery-item .gallery-video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.attachment-gallery-item .non-image {
width: 100%;
height: 100%;
@@ -224,42 +124,9 @@
justify-content: center;
}
.attachment-upload-error {
font-size: 0.75rem;
color: var(--danger);
margin-top: 0.375rem;
}
.attachment-counter {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 0.25rem;
text-align: right;
}
.attachment-uploading-text {
font-size: 0.75rem;
color: var(--text-muted);
text-align: center;
margin-top: 0.5rem;
}
@media (max-width: 480px) {
.attachment-gallery-item {
width: 80px;
height: 80px;
}
.attachment-preview {
width: 72px;
height: 72px;
}
}
@media (hover: none) and (pointer: coarse) {
.attachment-preview .attachment-remove {
width: 32px;
height: 32px;
font-size: 1rem;
opacity: 1;
}
}
+25
View File
@@ -0,0 +1,25 @@
/* retoor <retoor@molodetz.nl> */
.component-demo {
margin: 1rem 0 1.5rem;
padding: 1.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
}
.component-demo-title {
margin: 0 0 0.75rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
}
.component-demo-stage {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
+278
View File
@@ -0,0 +1,278 @@
.dialog-overlay {
display: none;
position: fixed;
inset: 0;
background: var(--overlay-dark);
z-index: 2100;
align-items: center;
justify-content: center;
}
.dialog-overlay.visible {
display: flex;
}
.dialog-card {
width: 100%;
max-width: 460px;
margin: 1rem;
}
.dialog-message {
color: var(--text-secondary);
margin-bottom: 1rem;
white-space: pre-wrap;
word-break: break-word;
}
.dialog-field {
margin-bottom: 1rem;
}
.dialog-field-label {
display: block;
font-size: 0.8125rem;
color: var(--text-secondary);
margin-bottom: 0.375rem;
}
.dialog-input {
width: 100%;
padding: 0.6rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-primary);
font-family: inherit;
font-size: 0.9375rem;
}
.dialog-input:focus {
outline: none;
border-color: var(--accent);
}
.dialog-danger {
background: var(--danger);
border-color: var(--danger);
}
.dialog-danger:hover {
background: var(--danger);
filter: brightness(1.1);
}
.context-menu {
display: none;
position: fixed;
z-index: 1900;
min-width: 180px;
max-width: 280px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 0.25rem;
overflow: hidden;
}
.context-menu.visible {
display: block;
}
.context-menu-list {
list-style: none;
margin: 0;
padding: 0;
}
.context-menu-sep {
height: 1px;
margin: 0.25rem 0.5rem;
background: var(--border);
}
.context-menu-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
border: none;
background: none;
color: var(--text-primary);
font-family: inherit;
font-size: 0.875rem;
text-align: left;
cursor: pointer;
border-radius: 6px;
}
.context-menu-item:hover {
background: var(--bg-card-hover);
}
.context-menu-item:disabled {
color: var(--text-muted);
cursor: default;
background: none;
}
.context-menu-item.danger {
color: var(--danger);
}
.context-menu-icon {
width: 1.25rem;
text-align: center;
}
dp-content {
display: block;
}
dp-upload {
display: block;
}
dp-upload[hidden] {
display: none;
}
.dp-upload-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
color: var(--text-primary);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.dp-upload-btn:hover {
background: var(--bg-card-hover);
border-color: var(--border-light);
}
.dp-upload-btn.dragover {
border-color: var(--accent);
background: var(--accent-light);
}
.dp-upload-btn.uploading {
opacity: 0.6;
pointer-events: none;
}
.dp-upload-icon {
font-size: 1rem;
line-height: 1;
}
.dp-upload-count {
color: var(--accent);
font-weight: 600;
}
.dp-upload-chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}
.dp-upload-chips:empty {
margin-top: 0;
}
.dp-upload-chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
max-width: 16rem;
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
color: var(--text-secondary);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.dp-upload-chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dp-upload-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
font-size: 1rem;
line-height: 1;
color: var(--text-muted);
background: none;
border: none;
border-radius: var(--radius);
cursor: pointer;
}
.dp-upload-chip-remove:hover {
color: var(--danger);
background: var(--bg-card-hover);
}
.dp-upload-error {
margin-top: 0.375rem;
font-size: 0.8125rem;
color: var(--danger);
}
.dp-upload-error:empty {
margin-top: 0;
}
.dp-toast-host {
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 1100;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.dp-toast {
min-width: 12rem;
max-width: 22rem;
padding: 0.625rem 0.875rem;
border-radius: var(--radius);
background: var(--bg-card);
color: var(--text-primary);
border: 1px solid var(--border);
box-shadow: var(--shadow);
font-size: 0.875rem;
opacity: 0;
transform: translateY(0.5rem);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dp-toast.visible {
opacity: 1;
transform: translateY(0);
}
.dp-toast-success {
border-color: var(--success);
}
.dp-toast-error {
border-color: var(--danger);
}
+39
View File
@@ -57,6 +57,45 @@
position: relative;
}
.docs-content table {
width: 100%;
max-width: 100%;
display: block;
overflow-x: auto;
border-collapse: collapse;
margin: 1rem 0;
font-size: 0.875rem;
-webkit-overflow-scrolling: touch;
}
.docs-content th,
.docs-content td {
border: 1px solid var(--border);
padding: 0.5rem 0.75rem;
text-align: left;
vertical-align: top;
}
.docs-content th {
background: var(--bg-card-hover);
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.docs-content td {
color: var(--text-secondary);
}
.docs-content tbody tr:nth-child(even) td {
background: rgba(255, 255, 255, 0.02);
}
.docs-content td code,
.docs-content th code {
white-space: nowrap;
}
.code-copy-btn {
position: absolute;
top: 0.5rem;
+8
View File
@@ -143,6 +143,14 @@
border: 1px solid var(--border);
}
.rendered-content video {
max-width: 100%;
border-radius: var(--radius);
margin: 0.5rem 0;
border: 1px solid var(--border);
background: #000;
}
.rendered-content a[href^="http"]::after {
content: "\u2197";
display: inline-block;
+3 -2
View File
@@ -181,13 +181,14 @@
min-width: 0;
}
.messages-input-area .attachment-upload-container {
.messages-input-area dp-upload {
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
}
.messages-input-area .attachment-preview-list {
.messages-input-area dp-upload .dp-upload-chips {
flex: 0 0 100%;
order: -1;
margin-top: 0;
+156
View File
@@ -412,3 +412,159 @@ a.profile-stat-value:hover {
color: var(--text-muted);
font-size: 0.85rem;
}
.ai-usage-card {
padding: var(--space-lg);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-card);
margin-bottom: var(--space-lg);
}
.ai-usage-head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.ai-usage-title {
font-size: 0.95rem;
margin: 0;
}
.ai-usage-updated {
font-size: 0.8rem;
color: var(--text-secondary);
}
.ai-usage-empty {
color: var(--text-muted);
font-size: 0.85rem;
margin: 0;
}
.ai-quota {
margin-bottom: var(--space-md);
}
.ai-quota-bar {
height: 10px;
border-radius: 999px;
background: var(--bg-secondary);
border: 1px solid var(--border);
overflow: hidden;
}
.ai-quota-fill {
display: block;
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 0.3s ease;
}
.ai-quota-fill.ai-quota-danger {
background: var(--danger, #d9534f);
}
.ai-quota-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-xs);
margin-top: var(--space-xs);
}
.ai-quota-pct {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
}
.ai-quota-detail {
font-size: 0.78rem;
color: var(--text-muted);
}
.ai-usage-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--space-sm);
}
.ai-usage-stat {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-secondary);
}
.ai-usage-value {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
}
.ai-usage-label {
font-size: 0.72rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ai-usage-spark-wrap {
margin-top: var(--space-md);
}
.ai-usage-spark-label {
font-size: 0.72rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ai-usage-spark {
display: flex;
align-items: flex-end;
gap: 2px;
height: 48px;
margin-top: var(--space-xs);
}
.ai-usage-bar {
flex: 1 1 auto;
min-width: 2px;
background: var(--accent);
border-radius: 2px 2px 0 0;
opacity: 0.75;
}
.ai-usage-table {
width: 100%;
margin-top: var(--space-md);
border-collapse: collapse;
font-size: 0.82rem;
}
.ai-usage-table th,
.ai-usage-table td {
text-align: left;
padding: var(--space-xs) var(--space-sm);
border-bottom: 1px solid var(--border);
}
.ai-usage-table th {
color: var(--text-muted);
font-weight: 600;
}
.ai-usage-table td {
color: var(--text-secondary);
}
+226
View File
@@ -0,0 +1,226 @@
.pf-page {
display: flex;
flex-direction: column;
height: calc(100vh - 2rem);
min-height: 480px;
}
.pf-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
padding-top: calc(var(--nav-height) + 0.5rem);
scroll-margin-top: calc(var(--nav-height) + 1rem);
}
.pf-toolbar .btn {
scroll-margin-top: calc(var(--nav-height) + 1rem);
}
.pf-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.pf-toolbar .btn {
padding: 0.35rem 0.75rem;
font-size: 0.8125rem;
}
.pf-status {
font-size: 0.8125rem;
color: var(--accent);
min-width: 4rem;
}
.pf-body {
display: flex;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
background: var(--bg-card);
}
.pf-tree {
width: 280px;
flex-shrink: 0;
overflow: auto;
padding: 0.5rem;
border-right: 1px solid var(--border);
background: var(--bg-card-hover);
}
.pf-tree-empty {
padding: 1rem;
font-size: 0.8125rem;
color: var(--text-muted);
}
.pf-tree-list {
list-style: none;
margin: 0;
padding: 0;
}
.pf-children {
padding-left: 1rem;
}
.pf-node.collapsed > .pf-children {
display: none;
}
.pf-node-row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.25rem 0.4rem;
border: none;
background: none;
color: var(--text-secondary);
font-size: 0.8125rem;
text-align: left;
cursor: pointer;
border-radius: 4px;
}
.pf-node-row:hover {
background: var(--bg-card);
color: var(--text-primary);
}
.pf-node-row.active {
background: var(--accent);
color: #fff;
}
.pf-node-row.pf-selected {
background: var(--accent-light);
color: var(--text-primary);
}
.pf-node-row.pf-selected.active {
background: var(--accent);
color: #fff;
}
.pf-node-row.pf-dragging {
opacity: 0.5;
}
.pf-node-row.pf-dragover {
outline: 2px dashed var(--accent);
outline-offset: -2px;
background: var(--accent-light);
}
.pf-tree.pf-root-drop {
outline: 2px dashed var(--accent);
outline-offset: -4px;
}
.pf-root-row {
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.pf-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pf-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.pf-editor-wrap[hidden],
.pf-preview[hidden],
.pf-empty[hidden],
.pf-current[hidden] {
display: none;
}
.pf-current {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-family: monospace;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
min-height: 1.25rem;
}
.pf-editor-wrap {
flex: 1;
min-height: 0;
}
.pf-editor-wrap .CodeMirror {
height: 100%;
font-size: 0.9rem;
}
.pf-preview {
flex: 1;
overflow: auto;
padding: 1rem;
display: flex;
align-items: flex-start;
justify-content: center;
}
.pf-preview img,
.pf-preview video {
max-width: 100%;
max-height: 100%;
border-radius: var(--radius);
}
.pf-download {
color: var(--accent);
font-size: 0.9375rem;
}
.pf-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
padding: 1rem;
text-align: center;
}
.pf-empty-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.pf-empty-icon {
font-size: 2.5rem;
opacity: 0.5;
}
.pf-empty-msg {
font-size: 0.875rem;
}
@media (max-width: 768px) {
.pf-tree {
width: 180px;
}
}
+7 -1
View File
@@ -54,7 +54,13 @@ class ApiKeyManager {
const btn = this.card.querySelector("[data-regen-api-key]");
if (!btn) return;
btn.addEventListener("click", async () => {
if (!window.confirm("Regenerate your API key? The current key stops working immediately.")) return;
const ok = await window.app.dialog.confirm({
title: "Regenerate API key",
message: "Regenerate your API key? The current key stops working immediately.",
confirmLabel: "Regenerate",
danger: true,
});
if (!ok) return;
btn.disabled = true;
try {
const result = await Http.postJson("/profile/regenerate-api-key", {});
+7 -1
View File
@@ -495,7 +495,13 @@ export class ApiTester {
async send() {
if (this.config.destructive || this.config.method === "DELETE") {
if (!window.confirm("This performs a real action on your account. Continue?")) return;
const ok = await window.app.dialog.confirm({
title: "Run request",
message: "This performs a real action on your account. Continue?",
confirmLabel: "Run",
danger: true,
});
if (!ok) return;
}
const values = this.values();
const auth = this.auth();
+7
View File
@@ -1,3 +1,4 @@
import "./components/index.js";
import { ModalManager } from "./ModalManager.js";
import { FormManager } from "./FormManager.js";
import { VoteManager } from "./VoteManager.js";
@@ -15,11 +16,16 @@ import { ReactionBar } from "./ReactionBar.js";
import { BookmarkManager } from "./BookmarkManager.js";
import { PollManager } from "./PollManager.js";
import { ApiKeyManager } from "./ApiKeyManager.js";
import { UserAiUsage } from "./UserAiUsage.js";
import { Tabs } from "./Tabs.js";
import { DeviiTerminal } from "./DeviiTerminal.js";
class Application {
constructor() {
this.dialog = document.createElement("dp-dialog");
this.contextMenu = document.createElement("dp-context-menu");
this.toast = document.createElement("dp-toast");
document.body.append(this.dialog, this.contextMenu, this.toast);
this.modals = new ModalManager();
this.forms = new FormManager();
this.votes = new VoteManager();
@@ -37,6 +43,7 @@ class Application {
this.bookmarks = new BookmarkManager();
this.polls = new PollManager();
this.apiKey = new ApiKeyManager();
this.userAiUsage = new UserAiUsage();
this.tabs = new Tabs();
this.devii = new DeviiTerminal();
}
-193
View File
@@ -1,193 +0,0 @@
import { Toast } from "./Toast.js";
export class AttachmentUploader {
constructor(form) {
this.form = form;
this.attachments = [];
this.maxSize = parseInt(form.dataset.maxSize || "10", 10) * 1024 * 1024;
this.maxFiles = parseInt(form.dataset.maxFiles || "10", 10);
this.allowedTypes = (form.dataset.allowedTypes || "").split(",").map((t) => t.trim()).filter(Boolean);
this.container = form.querySelector(".attachment-upload-container");
if (!this.container) return;
this.uploadedUidsInput = null;
this.initZone();
}
initZone() {
this.button = document.createElement("button");
this.button.type = "button";
this.button.className = "attachment-upload-btn";
this.button.innerHTML = "&#x1F4CE;";
this.button.title = "Attach files";
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", () => this.handleFiles(input.files));
this.button.addEventListener("click", (e) => {
e.preventDefault();
input.click();
});
this.button.addEventListener("dragover", (e) => {
e.preventDefault();
this.button.classList.add("dragover");
});
this.button.addEventListener("dragleave", () => {
this.button.classList.remove("dragover");
});
this.button.addEventListener("drop", (e) => {
e.preventDefault();
this.button.classList.remove("dragover");
this.handleFiles(e.dataTransfer.files);
});
this.previewList = document.createElement("div");
this.previewList.className = "attachment-preview-list";
this.errorEl = document.createElement("div");
this.errorEl.className = "attachment-upload-error";
this.container.appendChild(this.button);
this.container.appendChild(input);
this.container.after(this.errorEl);
this.container.after(this.previewList);
this.uploadedUidsInput = document.createElement("input");
this.uploadedUidsInput.type = "hidden";
this.uploadedUidsInput.name = "attachment_uids";
this.form.appendChild(this.uploadedUidsInput);
}
async handleFiles(files) {
this.errorEl.textContent = "";
const remaining = this.maxFiles - this.attachments.length;
if (files.length > remaining) {
this.showError(`You can only add ${remaining} more file(s).`);
return;
}
for (const file of files) {
if (file.size > this.maxSize) {
this.showError(`"${file.name}" exceeds the ${this.form.dataset.maxSize || 10}MB limit.`);
continue;
}
if (this.allowedTypes.length > 0) {
const ext = "." + file.name.split(".").pop().toLowerCase();
if (!this.allowedTypes.includes(ext)) {
this.showError(`"${file.name}" type is not allowed.`);
continue;
}
}
await this.uploadFile(file);
}
}
async uploadFile(file) {
this.button.classList.add("uploading");
const preview = this.createPreview(file);
this.previewList.appendChild(preview);
const progressBar = preview.querySelector(".attachment-progress-bar");
const formData = new FormData();
formData.append("file", file);
try {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/uploads/upload");
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable && progressBar) {
const pct = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = pct + "%";
}
});
const result = await new Promise((resolve, reject) => {
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); }
catch { reject(new Error("Invalid response")); }
} else {
reject(new Error(xhr.responseText || "Upload failed"));
}
};
xhr.onerror = () => reject(new Error("Network error"));
xhr.send(formData);
});
if (result && result.uid) {
this.attachments.push(result);
this.updateUids();
preview.dataset.uid = result.uid;
if (result.thumbnail_url) {
const img = preview.querySelector("img");
if (img) {
this.revokePreview(preview);
img.src = result.thumbnail_url;
}
}
}
} catch (err) {
this.showError(err.message);
this.revokePreview(preview);
preview.remove();
} finally {
this.button.classList.remove("uploading");
}
}
createPreview(file) {
const div = document.createElement("div");
div.className = "attachment-preview";
const isImage = file.type && file.type.startsWith("image/");
if (isImage) {
const img = document.createElement("img");
img.src = URL.createObjectURL(file);
img.alt = file.name;
div.appendChild(img);
} else {
const iconDiv = document.createElement("div");
iconDiv.className = "file-icon";
iconDiv.innerHTML = `<span class="icon">&#x1F4CE;</span><span>${file.name}</span>`;
div.appendChild(iconDiv);
}
const remove = document.createElement("button");
remove.type = "button";
remove.className = "attachment-remove";
remove.innerHTML = "&times;";
remove.addEventListener("click", () => {
const idx = this.attachments.findIndex((a) => a.uid === div.dataset.uid);
if (idx !== -1) {
this.attachments.splice(idx, 1);
this.updateUids();
}
this.revokePreview(div);
div.remove();
});
div.appendChild(remove);
const progress = document.createElement("div");
progress.className = "attachment-progress";
progress.innerHTML = '<div class="attachment-progress-bar" style="width:0%"></div>';
div.appendChild(progress);
return div;
}
revokePreview(preview) {
const img = preview.querySelector("img");
if (img && img.src.startsWith("blob:")) {
URL.revokeObjectURL(img.src);
}
}
updateUids() {
const uids = this.attachments.map((a) => a.uid);
this.uploadedUidsInput.value = uids.join(",");
}
showError(msg) {
Toast.flash(this.errorEl, msg, 5000, "");
}
}
window.AttachmentUploader = AttachmentUploader;
-16
View File
@@ -1,7 +1,6 @@
import { contentRenderer } from "./ContentRenderer.js";
import { MentionInput } from "./MentionInput.js";
import { EmojiPicker } from "./EmojiPicker.js";
import { AttachmentUploader } from "./AttachmentUploader.js";
export class ContentEnhancer {
constructor() {
@@ -9,7 +8,6 @@ export class ContentEnhancer {
this.initEmojiPickers();
this.initMentionInputs();
this.initImageFallbacks();
this.initAttachmentManagers();
}
initContentRenderer() {
@@ -61,18 +59,4 @@ export class ContentEnhancer {
});
});
}
initAttachmentManagers() {
document.querySelectorAll(".attachment-upload-container").forEach((container) => {
if (container.dataset.attachmentInitialized) return;
container.dataset.attachmentInitialized = "true";
const form = container.closest("form");
if (form) {
form.dataset.maxSize = container.dataset.maxSize;
form.dataset.maxFiles = container.dataset.maxFiles;
form.dataset.allowedTypes = container.dataset.allowedTypes || "";
new AttachmentUploader(form);
}
});
}
}
+10 -1
View File
@@ -2,6 +2,7 @@ export class ContentRenderer {
constructor() {
this.emojiMap = this.buildEmojiMap();
this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i;
this.videoExtRe = /\.(mp4|webm|ogg|ogv|mov|m4v)(\?.*)?$/i;
this.youtubeRe = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
}
@@ -166,11 +167,19 @@ export class ContentRenderer {
return img;
}
if (this.videoExtRe.test(url)) {
const video = document.createElement("video");
video.src = url;
video.controls = true;
video.preload = "metadata";
return video;
}
return null;
}
walkNodes(root, callback) {
const skipTags = new Set(["CODE", "PRE", "A", "IFRAME", "IMG", "SCRIPT", "STYLE"]);
const skipTags = new Set(["CODE", "PRE", "A", "IFRAME", "IMG", "VIDEO", "SCRIPT", "STYLE"]);
const iter = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null, false);
let node;
while ((node = iter.nextNode())) {
+27
View File
@@ -7,6 +7,7 @@ export class DeviiTerminal {
constructor() {
this.element = null;
this.avatar = null;
this.bindEscapeTrigger();
this.ready = this.init();
}
@@ -42,6 +43,32 @@ export class DeviiTerminal {
}
}
bindEscapeTrigger() {
document.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || event.repeat) return;
if (this._canTriggerOnEscape()) this.open();
},
true,
);
}
_canTriggerOnEscape() {
if (this.element && this.element.state !== "closed") return false;
if (document.querySelector(".modal-overlay.visible")) return false;
if (document.querySelector(".dialog-overlay.visible")) return false;
if (document.querySelector(".context-menu.visible")) return false;
const mobilePanel = document.getElementById("mobile-panel");
if (mobilePanel && mobilePanel.classList.contains("open")) return false;
const active = document.activeElement;
if (active) {
const editable = ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName);
if (editable || active.isContentEditable) return false;
}
return true;
}
async open() {
await this.ready;
if (this.element) this.element.open();
+4 -17
View File
@@ -1,3 +1,5 @@
import { LANGUAGE_MODES } from "./codemirrorModes.js";
class GistEditor {
constructor(textareaId, langSelectId) {
this.editor = null;
@@ -38,23 +40,8 @@ class GistEditor {
const langSelect = document.getElementById(this.langSelectId);
if (langSelect) {
langSelect.addEventListener("change", () => {
let mode = langSelect.value;
const modeMap = {
"c": "text/x-csrc",
"cpp": "text/x-c++src",
"java": "text/x-java",
"bash": "text/x-sh",
"ruby": "text/x-ruby",
"php": "text/x-php",
"perl": "text/x-perl",
"haskell": "text/x-haskell",
"lua": "text/x-lua",
"elixir": "text/x-elixir",
"dart": "text/x-dart",
"r": "text/x-rsrc",
"scala": "text/x-scala",
};
this.editor.setOption("mode", modeMap[mode] || mode);
const mode = langSelect.value;
this.editor.setOption("mode", LANGUAGE_MODES[mode] || mode);
});
}
+26 -6
View File
@@ -46,12 +46,32 @@ export class ModalManager {
}
initConfirmations() {
document.querySelectorAll("[data-confirm]").forEach((btn) => {
btn.addEventListener("click", (e) => {
if (!confirm(btn.dataset.confirm)) {
e.preventDefault();
}
document.addEventListener("click", (e) => {
const el = e.target.closest("[data-confirm]");
if (!el) return;
if (el.dataset.confirmed === "1") {
delete el.dataset.confirmed;
return;
}
e.preventDefault();
e.stopImmediatePropagation();
const proceed = () => {
el.dataset.confirmed = "1";
el.click();
};
const dialog = window.app && window.app.dialog;
if (!dialog) {
if (confirm(el.dataset.confirm)) proceed();
return;
}
dialog.confirm({
message: el.dataset.confirm,
title: el.dataset.confirmTitle || "Please confirm",
confirmLabel: el.dataset.confirmLabel,
danger: el.dataset.confirmDanger !== undefined,
}).then((ok) => {
if (ok) proceed();
});
});
}, true);
}
}
+573
View File
@@ -0,0 +1,573 @@
import { Toast } from "./Toast.js";
import { modeForFilename } from "./codemirrorModes.js";
export class ProjectFiles {
constructor(root) {
this.root = root;
this.slug = root.dataset.slug;
this.isOwner = root.dataset.owner === "1";
this.base = `/projects/${this.slug}/files`;
this.files = [];
this.editor = null;
this.currentPath = null;
this.currentDir = "";
this.selected = new Set();
this.order = [];
this.anchor = null;
this.dragPaths = null;
this.tree = root.querySelector("#pf-tree");
this.preview = root.querySelector("#pf-preview");
this.empty = root.querySelector("#pf-empty");
this.emptyMsg = root.querySelector(".pf-empty-msg");
this.currentLabel = root.querySelector("#pf-current");
this.editorWrap = root.querySelector("#pf-editor-wrap");
this.uploadWidget = root.querySelector("#pf-upload-widget");
this.status = root.querySelector("#pf-status");
}
notify(message) {
if (this.status) Toast.flash(this.status, message, 3000, "");
}
init() {
const data = this.root.querySelector("#pf-data");
this.files = data ? JSON.parse(data.textContent || "[]") : [];
this.initEditor();
this.bindToolbar();
this.bindTree();
this.renderTree();
this.openDefault();
}
firstFilePath() {
for (const path of this.order) {
const node = this.files.find((file) => file.path === path);
if (node && node.type === "file") return path;
}
return null;
}
openDefault() {
const files = this.files.filter((file) => file.type === "file");
const readme = files.find((file) => file.name.toLowerCase() === "readme.md")
|| files.find((file) => file.name.toLowerCase().startsWith("readme."));
const path = readme ? readme.path : this.firstFilePath();
if (!path) {
this.showEmpty(this.isOwner ? "Create a file to get started." : "No files yet.");
return;
}
this.selected = new Set([path]);
this.anchor = path;
this.applySelection();
this.markActive(path);
this.currentDir = this.dirOf(path);
this.openFile(path);
}
markActive(path) {
this.root.querySelectorAll(".pf-node-row.active").forEach((el) => el.classList.remove("active"));
const row = this.root.querySelector(`.pf-node-row[data-path="${CSS.escape(path)}"]`);
if (row) row.classList.add("active");
}
initEditor() {
if (typeof CodeMirror === "undefined") return;
const textarea = this.root.querySelector("#project-file-editor");
if (!textarea) return;
this.editor = CodeMirror.fromTextArea(textarea, {
lineNumbers: true,
mode: "plaintext",
theme: "monokai",
indentUnit: 4,
tabSize: 4,
lineWrapping: true,
readOnly: !this.isOwner,
extraKeys: { "Ctrl-S": () => this.save() },
});
this.editor.setSize(null, "100%");
}
bindToolbar() {
const on = (id, handler) => {
const el = this.root.querySelector(id);
if (el) el.addEventListener("click", (e) => { e.preventDefault(); handler(); });
};
on("#pf-new-file", () => this.newFile());
on("#pf-new-folder", () => this.newFolder());
on("#pf-upload", () => this.uploadTo(this.currentDir));
on("#pf-save", () => this.save());
on("#pf-rename", () => this.rename());
on("#pf-delete", () => this.remove());
if (this.uploadWidget) {
this.uploadWidget.addEventListener("dp-upload:done", () => this.refresh());
this.uploadWidget.addEventListener("dp-upload:error", (e) => this.notify(e.detail.message));
}
}
bindTree() {
this.tree.addEventListener("click", (e) => {
if (e.target === this.tree) {
this.selected.clear();
this.applySelection();
}
});
if (!this.isOwner) return;
if (window.app && window.app.contextMenu) {
window.app.contextMenu.attach(this.tree, (e) => this.buildMenu(e));
}
this.tree.addEventListener("dragover", (e) => {
if (!this.dragPaths) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
this.tree.classList.add("pf-root-drop");
});
this.tree.addEventListener("dragleave", (e) => {
if (e.target === this.tree) this.tree.classList.remove("pf-root-drop");
});
this.tree.addEventListener("drop", (e) => {
if (!this.dragPaths) return;
e.preventDefault();
this.tree.classList.remove("pf-root-drop");
this.onDrop("");
});
}
dirOf(path) {
return path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
}
basename(path) {
return path.split("/").pop();
}
joinDir(dir, name) {
const clean = name.replace(/^\/+/, "");
return dir ? `${dir}/${clean}` : clean;
}
buildTree() {
const nodes = new Map();
this.files.forEach((file) => nodes.set(file.path, { ...file, children: [] }));
const roots = [];
this.files.forEach((file) => {
const node = nodes.get(file.path);
const parent = file.path.includes("/") ? nodes.get(file.path.slice(0, file.path.lastIndexOf("/"))) : null;
(parent ? parent.children : roots).push(node);
});
const sort = (list) => {
list.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
list.forEach((node) => sort(node.children));
};
sort(roots);
return roots;
}
renderTree() {
this.order = [];
const known = new Set(this.files.map((f) => f.path));
this.selected = new Set([...this.selected].filter((p) => known.has(p)));
this.tree.textContent = "";
this.tree.appendChild(this.renderRootRow());
const roots = this.buildTree();
if (!roots.length) {
const hint = document.createElement("div");
hint.className = "pf-tree-empty";
hint.textContent = this.isOwner ? "Empty project. Create a file or folder." : "No files yet.";
this.tree.appendChild(hint);
return;
}
this.tree.appendChild(this.renderNodes(roots));
this.applySelection();
}
renderRootRow() {
const row = document.createElement("button");
row.type = "button";
row.className = "pf-node-row pf-root-row";
row.dataset.path = "";
row.dataset.type = "dir";
row.draggable = false;
row.innerHTML = '<span class="pf-icon">&#x1F4E6;</span><span class="pf-label">project root</span>';
row.addEventListener("click", () => {
this.selected.clear();
this.applySelection();
this.root.querySelectorAll(".pf-node-row.active").forEach((el) => el.classList.remove("active"));
row.classList.add("active");
this.currentPath = null;
this.currentDir = "";
this.showEmpty("Project root");
});
if (this.isOwner) {
row.addEventListener("dragover", (e) => {
if (!this.dragPaths) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
row.classList.add("pf-dragover");
});
row.addEventListener("dragleave", () => row.classList.remove("pf-dragover"));
row.addEventListener("drop", (e) => {
if (!this.dragPaths) return;
e.preventDefault();
e.stopPropagation();
row.classList.remove("pf-dragover");
this.onDrop("");
});
}
return row;
}
renderNodes(list) {
const ul = document.createElement("ul");
ul.className = "pf-tree-list";
list.forEach((node) => {
this.order.push(node.path);
const li = document.createElement("li");
li.className = `pf-node pf-${node.type}`;
const row = document.createElement("button");
row.type = "button";
row.className = "pf-node-row";
row.dataset.path = node.path;
row.dataset.type = node.type;
row.draggable = this.isOwner;
row.innerHTML = `<span class="pf-icon">${node.type === "dir" ? "&#x1F4C1;" : "&#x1F4C4;"}</span><span class="pf-label"></span>`;
row.querySelector(".pf-label").textContent = node.name;
row.addEventListener("click", (e) => this.onRowClick(node, li, e));
if (this.isOwner) this.bindDrag(row, node);
li.appendChild(row);
if (node.type === "dir") {
const child = this.renderNodes(node.children);
child.classList.add("pf-children");
li.appendChild(child);
}
ul.appendChild(li);
});
return ul;
}
bindDrag(row, node) {
row.addEventListener("dragstart", (e) => {
const paths = this.selected.has(node.path) && this.selected.size ? Array.from(this.selected) : [node.path];
this.dragPaths = paths;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", paths.join("\n"));
row.classList.add("pf-dragging");
});
row.addEventListener("dragend", () => {
row.classList.remove("pf-dragging");
this.dragPaths = null;
this.root.querySelectorAll(".pf-dragover").forEach((el) => el.classList.remove("pf-dragover"));
this.tree.classList.remove("pf-root-drop");
});
if (node.type !== "dir") return;
row.addEventListener("dragover", (e) => {
if (!this.dragPaths) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
row.classList.add("pf-dragover");
});
row.addEventListener("dragleave", () => row.classList.remove("pf-dragover"));
row.addEventListener("drop", (e) => {
if (!this.dragPaths) return;
e.preventDefault();
e.stopPropagation();
row.classList.remove("pf-dragover");
this.onDrop(node.path);
});
}
onDrop(targetDir) {
const paths = this.dragPaths || [];
this.dragPaths = null;
if (paths.length) this.moveInto(targetDir, paths);
}
onRowClick(node, li, e) {
if (e.shiftKey && this.anchor) {
this.selectRange(this.anchor, node.path);
return;
}
if (e.ctrlKey || e.metaKey) {
if (this.selected.has(node.path)) this.selected.delete(node.path);
else this.selected.add(node.path);
this.anchor = node.path;
this.applySelection();
return;
}
this.selected = new Set([node.path]);
this.anchor = node.path;
this.applySelection();
this.activate(node, li);
}
activate(node, li) {
this.root.querySelectorAll(".pf-node-row.active").forEach((el) => el.classList.remove("active"));
const row = li.querySelector(".pf-node-row");
if (row) row.classList.add("active");
if (node.type === "dir") {
li.classList.toggle("collapsed");
this.currentDir = node.path;
return;
}
this.currentDir = this.dirOf(node.path);
this.openFile(node.path);
}
selectRange(fromPath, toPath) {
const i = this.order.indexOf(fromPath);
const j = this.order.indexOf(toPath);
if (i < 0 || j < 0) return;
const [a, b] = i <= j ? [i, j] : [j, i];
this.selected = new Set(this.order.slice(a, b + 1));
this.applySelection();
}
applySelection() {
this.root.querySelectorAll(".pf-node-row").forEach((row) => {
row.classList.toggle("pf-selected", this.selected.has(row.dataset.path));
});
if (this.status && this.selected.size > 1) Toast.flash(this.status, `${this.selected.size} selected`, 1500, "");
}
buildMenu(e) {
if (!this.isOwner) return [];
const rowEl = e.target.closest(".pf-node-row");
if (!rowEl) {
return [
{ label: "New file", icon: "&#x1F4C4;", onSelect: () => this.newFile("") },
{ label: "New folder", icon: "&#x1F4C1;", onSelect: () => this.newFolder("") },
{ label: "Upload here", icon: "&#x2B06;", onSelect: () => this.uploadTo("") },
];
}
const path = rowEl.dataset.path;
const type = rowEl.dataset.type;
if (this.selected.size > 1 && this.selected.has(path)) {
const count = this.selected.size;
return [
{ label: `Move ${count} items to...`, icon: "&#x27A1;", onSelect: () => this.promptMove(Array.from(this.selected)) },
{ separator: true },
{ label: `Delete ${count} items`, icon: "&#x1F5D1;", danger: true, onSelect: () => this.remove(path) },
];
}
const dir = type === "dir" ? path : this.dirOf(path);
const items = [];
if (type === "file") {
items.push({ label: "Open", icon: "&#x1F4C4;", onSelect: () => this.openFile(path) });
} else {
items.push({ label: "New file here", icon: "&#x1F4C4;", onSelect: () => this.newFile(dir) });
items.push({ label: "New folder here", icon: "&#x1F4C1;", onSelect: () => this.newFolder(dir) });
items.push({ label: "Upload here", icon: "&#x2B06;", onSelect: () => this.uploadTo(dir) });
}
items.push({ separator: true });
items.push({ label: "Rename / move", icon: "&#x270F;", onSelect: () => this.rename(path) });
items.push({ label: "Delete", icon: "&#x1F5D1;", danger: true, onSelect: () => this.remove(path) });
return items;
}
async openFile(path) {
try {
const res = await fetch(`${this.base}/raw?path=${encodeURIComponent(path)}`, { headers: { "Accept": "application/json" } });
const data = await res.json();
if (!res.ok) throw new Error(data.error ? data.error.message : "Could not open file");
this.currentPath = path;
this.currentLabel.textContent = path;
this.currentLabel.hidden = false;
if (data.is_binary) {
this.showPreview(data);
} else {
this.showEditor(path, data.content || "");
}
} catch (err) {
this.notify(err.message);
}
}
showEditor(path, content) {
this.empty.hidden = true;
this.preview.hidden = true;
this.editorWrap.hidden = false;
if (this.editor) {
this.editor.setOption("mode", modeForFilename(path));
this.editor.setValue(content);
setTimeout(() => this.editor.refresh(), 50);
}
}
showPreview(data) {
this.empty.hidden = true;
this.editorWrap.hidden = true;
this.preview.hidden = false;
this.preview.textContent = "";
const mime = data.mime_type || "";
if (mime.startsWith("image/")) {
const img = document.createElement("img");
img.src = data.url;
img.alt = data.name;
this.preview.appendChild(img);
} else if (mime.startsWith("video/")) {
const video = document.createElement("video");
video.src = data.url;
video.controls = true;
video.preload = "metadata";
this.preview.appendChild(video);
} else {
const link = document.createElement("a");
link.href = data.url;
link.textContent = `Download ${data.name}`;
link.className = "pf-download";
link.setAttribute("download", data.name);
this.preview.appendChild(link);
}
}
showEmpty(message) {
this.editorWrap.hidden = true;
this.preview.hidden = true;
this.empty.hidden = false;
if (this.emptyMsg) this.emptyMsg.textContent = message || "Select a file to view or edit.";
this.currentLabel.textContent = "";
this.currentLabel.hidden = true;
}
async post(action, body) {
const res = await fetch(`${this.base}/${action}`, {
method: "POST",
headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body).toString(),
});
const data = await res.json();
if (!res.ok || data.ok === false) {
throw new Error(data.error ? data.error.message : "Operation failed");
}
return data;
}
async refresh(openPath) {
const res = await fetch(this.base, { headers: { "Accept": "application/json" } });
const data = await res.json();
this.files = data.files || [];
this.renderTree();
if (openPath) this.openFile(openPath);
}
async promptInput(options) {
const dialog = window.app && window.app.dialog;
if (dialog) return dialog.prompt(options);
return window.prompt(options.message || options.label || options.title || "", options.value || "");
}
async confirmAction(options) {
const dialog = window.app && window.app.dialog;
if (dialog) return dialog.confirm(options);
return window.confirm(options.message);
}
async newFile(baseDir) {
const dir = baseDir !== undefined ? baseDir : this.currentDir;
const name = await this.promptInput({
title: "New file",
label: dir ? `Inside ${dir}/` : "In project root",
placeholder: "main.py",
confirmLabel: "Create",
});
if (!name || !name.trim()) return;
const path = this.joinDir(dir, name.trim());
try {
await this.post("write", { path, content: "" });
await this.refresh(path);
} catch (err) { this.notify(err.message); }
}
async newFolder(baseDir) {
const dir = baseDir !== undefined ? baseDir : this.currentDir;
const name = await this.promptInput({
title: "New folder",
label: dir ? `Inside ${dir}/` : "In project root",
placeholder: "src",
confirmLabel: "Create",
});
if (!name || !name.trim()) return;
const path = this.joinDir(dir, name.trim());
try {
await this.post("mkdir", { path });
this.currentDir = path;
await this.refresh();
} catch (err) { this.notify(err.message); }
}
uploadTo(dir) {
if (!this.uploadWidget) return;
this.uploadWidget.extraFields = { path: dir };
this.uploadWidget.open();
}
async save() {
if (!this.currentPath || !this.editor) return;
try {
await this.post("write", { path: this.currentPath, content: this.editor.getValue() });
this.notify("Saved");
} catch (err) { this.notify(err.message); }
}
async rename(target) {
const from = target || this.currentPath || this.currentDir;
if (!from) { this.notify("Select a file or folder first"); return; }
const to = await this.promptInput({
title: "Rename / move",
label: "New path",
value: from,
confirmLabel: "Save",
});
if (!to || !to.trim() || to.trim() === from) return;
try {
await this.post("move", { from_path: from, to_path: to.trim() });
await this.refresh(this.currentPath === from ? to.trim() : null);
} catch (err) { this.notify(err.message); }
}
async remove(target) {
const multi = this.selected.size > 1 && (!target || this.selected.has(target));
const paths = multi ? Array.from(this.selected) : [target || this.currentPath || this.currentDir].filter(Boolean);
if (!paths.length) { this.notify("Select a file or folder first"); return; }
const message = paths.length > 1
? `Delete ${paths.length} items? This cannot be undone.`
: `Delete "${paths[0]}"? This cannot be undone.`;
const ok = await this.confirmAction({ title: "Delete", message, danger: true, confirmLabel: "Delete" });
if (!ok) return;
try {
for (const path of paths) await this.post("delete", { path });
this.selected.clear();
this.currentPath = null;
await this.refresh();
this.openDefault();
} catch (err) { this.notify(err.message); }
}
async promptMove(paths) {
const to = await this.promptInput({
title: `Move ${paths.length} items`,
label: "Target directory (empty for project root)",
placeholder: "src",
confirmLabel: "Move",
});
if (to === null || to === undefined) return;
await this.moveInto(to.trim().replace(/^\/+|\/+$/g, ""), paths);
}
async moveInto(targetDir, paths) {
const valid = paths.filter((path) => {
if (targetDir === path || targetDir.startsWith(`${path}/`)) return false;
if (targetDir === this.dirOf(path)) return false;
return true;
});
if (!valid.length) return;
try {
for (const path of valid) {
const to = targetDir ? `${targetDir}/${this.basename(path)}` : this.basename(path);
await this.post("move", { from_path: path, to_path: to });
}
this.selected.clear();
await this.refresh();
} catch (err) { this.notify(err.message); }
}
}
+4 -1
View File
@@ -62,7 +62,10 @@ export class PushManager {
} catch (error) {
console.error("Error registering push notifications:", error);
if (!silent) {
alert("Enabling push notifications failed. Please check your browser settings and try again.\n\n" + error);
window.app.dialog.alert({
title: "Push notifications",
message: "Enabling push notifications failed. Please check your browser settings and try again.\n\n" + error,
});
}
}
}
-2
View File
@@ -30,8 +30,6 @@ class ServiceMonitor {
async runAction(button) {
const action = button.dataset.action;
const name = button.dataset.service;
const confirmation = button.dataset.confirm;
if (confirmation && !window.confirm(confirmation)) return;
button.disabled = true;
try {
await Http.sendForm(`/admin/services/${name}/${action}`, {});
+97
View File
@@ -0,0 +1,97 @@
import { Http } from "./Http.js";
class UserAiUsage {
constructor() {
this.card = document.querySelector("[data-ai-usage]");
if (!this.card) return;
this.uid = this.card.dataset.uid || "";
this.body = this.card.querySelector("[data-ai-usage-body]");
this.updated = this.card.querySelector("[data-ai-usage-updated]");
this.load();
}
async load() {
if (!this.uid) return;
try {
const data = await Http.getJson(`/admin/users/${this.uid}/ai-usage?hours=24`);
this.render(data);
} catch {
this.body.innerHTML = '<p class="ai-usage-empty">Usage unavailable.</p>';
}
}
render(data) {
if (!data || !data.requests) {
this.body.innerHTML = '<p class="ai-usage-empty">No AI usage in the last 24 hours.</p>';
if (this.updated) this.updated.textContent = "";
return;
}
const cost = data.cost || {};
const tokens = data.tokens || {};
const latency = data.latency || {};
const cards = [
["Requests", String(data.requests)],
["Success", `${data.success_pct}%`],
["Errors", `${data.error_pct}%`],
["Cost 24h", this.money(cost.window_usd)],
["Projected 30d", this.money(cost.projected_30d_usd)],
["Cost / request", this.money(cost.per_request_usd)],
["Cost / hour", this.money(cost.per_hour_usd)],
["Total tokens", this.count(tokens.total)],
["Prompt tokens", this.count(tokens.prompt)],
["Completion tokens", this.count(tokens.completion)],
["Avg latency", `${Math.round(latency.avg_ms || 0)} ms`],
["Avg tokens/s", String(latency.avg_tps || 0)],
];
const grid = cards
.map(([label, value]) => `<div class="ai-usage-stat"><span class="ai-usage-value">${value}</span><span class="ai-usage-label">${label}</span></div>`)
.join("");
const models = (data.by_model || [])
.map((m) => `<tr><td>${this.escape(m.key)}</td><td>${m.requests}</td><td>${this.count(m.total_tokens)}</td><td>${this.money(m.cost_usd)}</td></tr>`)
.join("");
const modelTable = models
? `<table class="ai-usage-table"><thead><tr><th>Model</th><th>Requests</th><th>Tokens</th><th>Cost</th></tr></thead><tbody>${models}</tbody></table>`
: "";
this.body.innerHTML = `<div class="ai-usage-grid">${grid}</div>${this.spark(data.hourly)}${modelTable}`;
if (this.updated) {
this.updated.textContent = `last used ${this.when(data.last_used)}`;
}
}
spark(hourly) {
const rows = hourly || [];
if (!rows.length) return "";
const peak = Math.max(...rows.map((h) => h.cost_usd || 0)) || 1;
const bars = rows
.map((h) => {
const height = Math.max(2, Math.round((h.cost_usd || 0) / peak * 100));
return `<span class="ai-usage-bar" style="height:${height}%" title="${h.hour}: ${this.money(h.cost_usd)} / ${h.requests} req"></span>`;
})
.join("");
return `<div class="ai-usage-spark-wrap"><span class="ai-usage-spark-label">Cost per hour</span><div class="ai-usage-spark">${bars}</div></div>`;
}
money(value) {
return `$${Number(value || 0).toFixed(4)}`;
}
count(value) {
return Number(value || 0).toLocaleString();
}
when(value) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString();
}
escape(text) {
const div = document.createElement("div");
div.textContent = String(text == null ? "" : text);
return div.innerHTML;
}
}
window.UserAiUsage = UserAiUsage;
export { UserAiUsage };
+57
View File
@@ -0,0 +1,57 @@
export const LANGUAGE_MODES = {
"c": "text/x-csrc",
"cpp": "text/x-c++src",
"java": "text/x-java",
"bash": "text/x-sh",
"ruby": "text/x-ruby",
"php": "text/x-php",
"perl": "text/x-perl",
"haskell": "text/x-haskell",
"lua": "text/x-lua",
"elixir": "text/x-elixir",
"dart": "text/x-dart",
"r": "text/x-rsrc",
"scala": "text/x-scala",
};
export const EXTENSION_MODES = {
"py": "python",
"js": "javascript", "mjs": "javascript", "cjs": "javascript", "jsx": "javascript",
"ts": "text/typescript", "tsx": "text/typescript",
"json": "application/json", "jsonc": "application/json",
"c": "text/x-csrc", "h": "text/x-csrc",
"cpp": "text/x-c++src", "cc": "text/x-c++src", "hpp": "text/x-c++src", "hh": "text/x-c++src",
"cs": "text/x-csharp",
"java": "text/x-java",
"go": "go",
"rb": "text/x-ruby",
"php": "text/x-php",
"pl": "text/x-perl", "pm": "text/x-perl",
"lua": "text/x-lua",
"r": "text/x-rsrc",
"dart": "text/x-dart",
"swift": "swift",
"hs": "text/x-haskell",
"sql": "sql",
"sh": "text/x-sh", "bash": "text/x-sh", "zsh": "text/x-sh",
"yaml": "yaml", "yml": "yaml",
"md": "markdown", "markdown": "markdown",
"html": "htmlmixed", "htm": "htmlmixed",
"xml": "xml", "svg": "xml",
"css": "css", "scss": "css", "sass": "css", "less": "css",
};
const FILENAME_MODES = {
"dockerfile": "text/x-sh",
"makefile": "text/x-sh",
".gitignore": "text/plain",
".env": "text/plain",
};
export function modeForFilename(name) {
const lower = (name || "").toLowerCase();
if (FILENAME_MODES[lower]) return FILENAME_MODES[lower];
const dot = lower.lastIndexOf(".");
const ext = dot >= 0 ? lower.slice(dot + 1) : "";
return EXTENSION_MODES[ext] || "plaintext";
}
@@ -0,0 +1,32 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { Avatar } from "../Avatar.js";
export class AppAvatar extends Component {
static get observedAttributes() {
return ["username", "size"];
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
if (this.isConnected) {
this.render();
}
}
render() {
const username = this.attr("username");
const size = this.intAttr("size", 24);
this.textContent = "";
if (!username) {
return;
}
this.appendChild(Avatar.imgElement(username, size));
}
}
customElements.define("dp-avatar", AppAvatar);
@@ -0,0 +1,31 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { CodeBlock } from "../CodeBlock.js";
export class AppCode extends Component {
connectedCallback() {
if (this._enhanced) {
return;
}
this._enhanced = true;
let pre = this.querySelector("pre");
if (!pre) {
const source = this.textContent.replace(/^\n+|\s+$/g, "");
this.textContent = "";
pre = document.createElement("pre");
const code = document.createElement("code");
code.textContent = source;
pre.appendChild(code);
this.appendChild(pre);
}
const code = pre.querySelector("code");
const language = this.attr("language");
if (code && language) {
code.className = `language-${language}`;
}
CodeBlock.enhance(pre, { lineNumbers: this.boolAttr("line-numbers") });
}
}
customElements.define("dp-code", AppCode);
@@ -0,0 +1,24 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
import { contentRenderer } from "../ContentRenderer.js";
export class AppContent extends Component {
connectedCallback() {
if (this._rendered) {
return;
}
if (typeof DOMPurify === "undefined" || typeof marked === "undefined") {
window.addEventListener("load", () => this.connectedCallback(), { once: true });
return;
}
this._rendered = true;
this.classList.add("rendered-content");
contentRenderer.applyTo(this);
if (typeof hljs !== "undefined") {
this.querySelectorAll("pre code").forEach((block) => hljs.highlightElement(block));
}
}
}
customElements.define("dp-content", AppContent);
@@ -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);
@@ -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);
@@ -0,0 +1,26 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class AppToast extends Component {
connectedCallback() {
this.classList.add("dp-toast-host");
}
show(message, options = {}) {
const type = options.type || "info";
const ms = options.ms || 3000;
const toast = document.createElement("div");
toast.className = `dp-toast dp-toast-${type}`;
toast.textContent = message;
this.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("visible"));
setTimeout(() => {
toast.classList.remove("visible");
setTimeout(() => toast.remove(), 200);
}, ms);
return toast;
}
}
customElements.define("dp-toast", AppToast);
@@ -0,0 +1,255 @@
// retoor <retoor@molodetz.nl>
import { Component } from "./Component.js";
export class AppUpload extends Component {
connectedCallback() {
if (this._built) {
return;
}
this._built = true;
this.mode = this.attr("mode", "attachment");
this.endpoint = this.attr("endpoint", "/uploads/upload");
this.fieldName = this.attr("field-name", "file");
this.uidsName = this.attr("name", "attachment_uids");
this.maxBytes = this.intAttr("max-size", 10) * 1024 * 1024;
this.maxFiles = this.intAttr("max-files", 10);
this.allowedTypes = this.attr("allowed-types", "")
.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
this.extraFields = {};
this.items = [];
this.build();
}
build() {
this.button = document.createElement("button");
this.button.type = "button";
this.button.className = "dp-upload-btn";
this.button.innerHTML =
'<span class="dp-upload-icon">&#x1F4CE;</span>' +
'<span class="dp-upload-label"></span>' +
'<span class="dp-upload-count" hidden></span>';
this.button.querySelector(".dp-upload-label").textContent = this.attr("label", "Attach files");
this.countEl = this.button.querySelector(".dp-upload-count");
this.input = document.createElement("input");
this.input.type = "file";
this.input.className = "dp-upload-input";
this.input.hidden = true;
if (this.boolAttr("multiple")) {
this.input.multiple = true;
}
if (this.boolAttr("directory")) {
this.input.webkitdirectory = true;
}
const accept = this.attr("accept", "");
if (accept) {
this.input.accept = accept;
}
if (this.mode === "field") {
this.input.name = this.fieldName;
}
this.chips = document.createElement("div");
this.chips.className = "dp-upload-chips";
this.errorEl = document.createElement("div");
this.errorEl.className = "dp-upload-error";
this.button.addEventListener("click", (e) => {
e.preventDefault();
this.open();
});
this.button.addEventListener("dragover", (e) => {
e.preventDefault();
this.button.classList.add("dragover");
});
this.button.addEventListener("dragleave", () => this.button.classList.remove("dragover"));
this.button.addEventListener("drop", (e) => {
e.preventDefault();
this.button.classList.remove("dragover");
this.handleFiles(e.dataTransfer.files);
});
this.input.addEventListener("change", () => this.handleFiles(this.input.files));
this.append(this.button, this.input, this.chips, this.errorEl);
if (this.mode === "attachment") {
this.uidsInput = document.createElement("input");
this.uidsInput.type = "hidden";
this.uidsInput.name = this.uidsName;
this.append(this.uidsInput);
}
}
open() {
this.input.click();
}
clear() {
this.items = [];
if (this.mode === "field") {
this.input.value = "";
}
if (this.uidsInput) {
this.uidsInput.value = "";
}
this.renderChips();
this.updateCount();
}
async handleFiles(fileList) {
this.clearError();
const files = Array.from(fileList || []);
if (!files.length) {
return;
}
if (this.mode === "field") {
this.setFieldFiles(files);
return;
}
if (this.mode === "attachment") {
const remaining = this.maxFiles - this.items.length;
if (files.length > remaining) {
this.showError(`You can only add ${Math.max(0, remaining)} more file(s).`);
return;
}
}
for (const file of files) {
if (this.validate(file)) {
await this.upload(file);
}
}
this.input.value = "";
if (this.mode === "direct") {
this.emit("done", {});
}
}
validate(file) {
if (file.size > this.maxBytes) {
this.showError(`"${file.name}" exceeds the ${this.intAttr("max-size", 10)}MB limit.`);
return false;
}
if (this.allowedTypes.length) {
const ext = "." + file.name.split(".").pop().toLowerCase();
if (!this.allowedTypes.includes(ext)) {
this.showError(`"${file.name}" type is not allowed.`);
return false;
}
}
return true;
}
async upload(file) {
this.button.classList.add("uploading");
const body = new FormData();
body.append(this.fieldName, file);
for (const [key, value] of Object.entries(this.extraFields)) {
body.append(key, value);
}
try {
const response = await fetch(this.endpoint, {
method: "POST",
headers: { "Accept": "application/json", "X-Requested-With": "fetch" },
body,
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false || data.error) {
const message = (data.error && (data.error.message || data.error)) || "Upload failed";
throw new Error(message);
}
if (this.mode === "attachment" && data.uid) {
this.items.push({ uid: data.uid, name: file.name });
this.syncUids();
this.renderChips();
}
this.emit("uploaded", { file, result: data });
} catch (error) {
this.showError(error.message);
this.emit("error", { file, message: error.message });
} finally {
this.button.classList.remove("uploading");
this.updateCount();
}
}
setFieldFiles(files) {
if (this.input.multiple) {
this.items.push(...files.map((file) => ({ file, name: file.name })));
} else {
this.items = files.slice(0, 1).map((file) => ({ file, name: file.name }));
}
this.applyFieldInput();
this.renderChips();
this.updateCount();
}
applyFieldInput() {
const transfer = new DataTransfer();
for (const item of this.items) {
transfer.items.add(item.file);
}
this.input.files = transfer.files;
}
removeAt(index) {
this.items.splice(index, 1);
if (this.mode === "attachment") {
this.syncUids();
}
if (this.mode === "field") {
this.applyFieldInput();
}
this.renderChips();
this.updateCount();
}
renderChips() {
this.chips.textContent = "";
this.items.forEach((item, index) => {
const chip = document.createElement("span");
chip.className = "dp-upload-chip";
const name = document.createElement("span");
name.className = "dp-upload-chip-name";
name.textContent = item.name;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "dp-upload-chip-remove";
remove.innerHTML = "&times;";
remove.setAttribute("aria-label", `Remove ${item.name}`);
remove.addEventListener("click", () => this.removeAt(index));
chip.append(name, remove);
this.chips.appendChild(chip);
});
}
updateCount() {
const count = this.items.length;
this.countEl.hidden = count === 0;
this.countEl.textContent = count ? `(${count})` : "";
this.emit("change", { count });
}
syncUids() {
if (this.uidsInput) {
this.uidsInput.value = this.items.map((item) => item.uid).join(",");
}
}
emit(name, detail) {
this.dispatchEvent(new CustomEvent(`dp-upload:${name}`, { detail, bubbles: true }));
}
showError(message) {
this.errorEl.textContent = message;
if (window.app && window.app.toast) {
window.app.toast.show(message, { type: "error" });
}
}
clearError() {
this.errorEl.textContent = "";
}
}
customElements.define("dp-upload", AppUpload);
@@ -0,0 +1,17 @@
// retoor <retoor@molodetz.nl>
export class Component extends HTMLElement {
attr(name, fallback = "") {
const value = this.getAttribute(name);
return value === null ? fallback : value;
}
boolAttr(name) {
return this.hasAttribute(name);
}
intAttr(name, fallback = 0) {
const value = parseInt(this.getAttribute(name), 10);
return Number.isNaN(value) ? fallback : value;
}
}
+9
View File
@@ -0,0 +1,9 @@
// retoor <retoor@molodetz.nl>
import "./AppAvatar.js";
import "./AppCode.js";
import "./AppContent.js";
import "./AppUpload.js";
import "./AppToast.js";
import "./AppDialog.js";
import "./AppContextMenu.js";
+19 -2
View File
@@ -58,12 +58,20 @@ export default class DeviiTerminalElement extends HTMLElement {
window.addEventListener("beforeunload", () => this._captureFocusForUnload());
this._identity = null;
this._fetchIdentity().then((id) => { this._identity = id; });
window.addEventListener("focus", () => this._checkIdentity());
window.addEventListener("focus", () => {
this._checkIdentity();
this._reportVisibility();
});
window.addEventListener("blur", () => this._reportVisibility());
document.addEventListener("visibilitychange", () => {
if (!document.hidden) this._checkIdentity();
this._reportVisibility();
});
this.socket = new DeviiSocket({
onOpen: () => this._notice("connected."),
onOpen: () => {
this._notice("connected.");
this._reportVisibility();
},
onClose: () => this._notice("disconnected, reconnecting..."),
onMessage: (message) => this._onMessage(message),
});
@@ -479,6 +487,15 @@ export default class DeviiTerminalElement extends HTMLElement {
}
}
_reportVisibility() {
if (!this.socket) return;
this.socket.send({
type: "visibility",
visible: !document.hidden,
focused: document.hasFocus(),
});
}
async _clientRequest(message) {
let result;
try {