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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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", {});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 = "📎";
|
||||
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">📎</span><span>${file.name}</span>`;
|
||||
div.appendChild(iconDiv);
|
||||
}
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "attachment-remove";
|
||||
remove.innerHTML = "×";
|
||||
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;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">📦</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" ? "📁" : "📄"}</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: "📄", onSelect: () => this.newFile("") },
|
||||
{ label: "New folder", icon: "📁", onSelect: () => this.newFolder("") },
|
||||
{ label: "Upload here", icon: "⬆", 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: "➡", onSelect: () => this.promptMove(Array.from(this.selected)) },
|
||||
{ separator: true },
|
||||
{ label: `Delete ${count} items`, icon: "🗑", danger: true, onSelect: () => this.remove(path) },
|
||||
];
|
||||
}
|
||||
const dir = type === "dir" ? path : this.dirOf(path);
|
||||
const items = [];
|
||||
if (type === "file") {
|
||||
items.push({ label: "Open", icon: "📄", onSelect: () => this.openFile(path) });
|
||||
} else {
|
||||
items.push({ label: "New file here", icon: "📄", onSelect: () => this.newFile(dir) });
|
||||
items.push({ label: "New folder here", icon: "📁", onSelect: () => this.newFolder(dir) });
|
||||
items.push({ label: "Upload here", icon: "⬆", onSelect: () => this.uploadTo(dir) });
|
||||
}
|
||||
items.push({ separator: true });
|
||||
items.push({ label: "Rename / move", icon: "✏", onSelect: () => this.rename(path) });
|
||||
items.push({ label: "Delete", icon: "🗑", 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); }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`, {});
|
||||
|
||||
@@ -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 };
|
||||
@@ -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">×</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">📎</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 = "×";
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user