More udpates to documentation generation

This commit is contained in:
bunker-admin 2026-02-17 10:36:41 -07:00
parent d3287a0fa4
commit 58dc1942ec
840 changed files with 1228847 additions and 95 deletions

7
.gitignore vendored
View File

@ -9,11 +9,8 @@ node_modules/
/configs/code-server/.config/*
!/configs/code-server/.config/.gitkeep
# MkDocs cache and built site (created by containers)
/mkdocs/.cache/*
!/mkdocs/.cache/.gitkeep
/mkdocs/site/*
!/mkdocs/site/.gitkeep
# Root assets (generated by containers)
/assets/
# Homepage logs (created by container)
/configs/homepage/logs/*

View File

@ -49,6 +49,8 @@ import {
BuildOutlined,
HolderOutlined,
QuestionCircleOutlined,
UploadOutlined,
InboxOutlined,
} from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import type { OnMount } from '@monaco-editor/react';
@ -72,6 +74,13 @@ const DEFAULT_TREE_WIDTH = 200;
const MIN_TREE_WIDTH = 160;
const MAX_TREE_WIDTH = 400;
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico']);
function isImageFile(filePath: string): boolean {
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
return IMAGE_EXTENSIONS.has(ext);
}
function filePathToMkDocsUrl(filePath: string): string {
let url = filePath.replace(/\.md$/, '');
if (url.endsWith('/index') || url === 'index') {
@ -119,30 +128,49 @@ function invalidateTreeCache(): void {
}
}
// URL Preview Bar Component (shows production + localhost URLs above iframe)
// URL Preview Bar Component (shows full clickable URLs above iframe)
const URLPreviewBar = ({ filePath, config }: { filePath: string | null; config: ServicesConfig | null }) => {
const { token } = theme.useToken();
// Only show for markdown files
if (!filePath || !filePath.endsWith('.md')) return null;
// Transform file path to URL path (reuse existing logic from filePathToMkDocsUrl)
// Transform file path to URL path
let urlPath = filePath.replace(/\.md$/, '');
if (urlPath.endsWith('/index') || urlPath === 'index') {
urlPath = urlPath.replace(/\/?index$/, '');
}
const suffix = urlPath ? `/${urlPath}/` : '/';
// Use buildServiceUrl for environment-aware URL construction
const baseUrl = config
? buildServiceUrl(config.mkdocsSubdomain, config.domain, config.mkdocsPort)
const hostname = window.location.hostname;
const isRealDomain = hostname.includes('.');
// Build URLs — production is the static site at root domain, not the dev preview
const productionUrl = config
? `${window.location.protocol}//${config.domain}${suffix}`
: null;
const localhostUrl = config
? `http://localhost:${config.mkdocsPort}${suffix}`
: null;
const productionUrl = baseUrl ? `${baseUrl}/${urlPath}${urlPath ? '/' : ''}` : '';
const localhostUrl = productionUrl; // Same URL works for both environments now
const openUrl = (url: string) => {
window.open(url, '_blank', 'noopener,noreferrer');
};
const urlLinkStyle: React.CSSProperties = {
fontFamily: 'monospace',
fontSize: 11,
color: token.colorPrimary,
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: 360,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
};
return (
<div
style={{
@ -152,51 +180,29 @@ const URLPreviewBar = ({ filePath, config }: { filePath: string | null; config:
padding: '0 8px',
background: token.colorBgElevated,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
gap: 8,
gap: 12,
flexShrink: 0,
overflow: 'hidden',
}}
>
<Typography.Text
style={{
fontSize: 11,
textTransform: 'uppercase',
letterSpacing: 0.5,
color: token.colorTextSecondary,
fontWeight: 600,
userSelect: 'none',
marginRight: 4,
}}
>
Preview:
</Typography.Text>
<Space size={8}>
{/* Production URL Button */}
{productionUrl && (
<Tooltip title={productionUrl} mouseEnterDelay={0.3}>
<Button
type="default"
size="small"
icon={<ExportOutlined />}
onClick={() => openUrl(productionUrl)}
style={{ height: 24, fontSize: 12 }}
>
Production
</Button>
<span style={urlLinkStyle} onClick={() => openUrl(productionUrl)}>
<ExportOutlined style={{ fontSize: 10, flexShrink: 0 }} />
{productionUrl}
</span>
</Tooltip>
)}
{/* Localhost URL Button */}
{/* Only show localhost URL when on localhost (on real domain they resolve the same) */}
{!isRealDomain && localhostUrl && (
<Tooltip title={localhostUrl} mouseEnterDelay={0.3}>
<Button
type="default"
size="small"
icon={<ExportOutlined />}
onClick={() => openUrl(localhostUrl)}
style={{ height: 24, fontSize: 12 }}
>
Localhost
</Button>
<span style={urlLinkStyle} onClick={() => openUrl(localhostUrl)}>
<ExportOutlined style={{ fontSize: 10, flexShrink: 0 }} />
{localhostUrl}
</span>
</Tooltip>
</Space>
)}
</div>
);
};
@ -393,6 +399,10 @@ export default function DocsPage() {
const [modalInput, setModalInput] = useState('');
const [contextPath, setContextPath] = useState<string>('');
const [dragOver, setDragOver] = useState(false);
const dragCounter = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dragging = useRef<'split' | 'tree' | false>(false);
const editorRef = useRef<HTMLDivElement>(null);
@ -619,9 +629,39 @@ export default function DocsPage() {
};
}, [ctxMenu]);
// Select an image file (no text loading, just set selectedFile)
const selectImageFile = useCallback((filePath: string) => {
setSelectedFile(filePath);
setFileContent('');
setOriginalContent('');
setDirty(false);
if (previewIframeRef.current) {
// Navigate preview to the image's parent page (if any)
previewIframeRef.current.src = '/mkdocs-proxy/';
}
}, []);
const onTreeSelect = useCallback(async (keys: React.Key[]) => {
if (keys.length === 0) return;
const path = keys[0] as string;
// Image files — just select, no text loading
if (isImageFile(path)) {
if (dirty) {
Modal.confirm({
title: 'Unsaved Changes',
content: `Save changes to ${selectedFile} before switching?`,
okText: 'Save',
cancelText: 'Discard',
onOk: async () => { await saveFile(); selectImageFile(path); },
onCancel: () => { setDirty(false); selectImageFile(path); },
});
return;
}
selectImageFile(path);
return;
}
if (dirty) {
Modal.confirm({
title: 'Unsaved Changes',
@ -697,6 +737,10 @@ export default function DocsPage() {
fetchTree(false, true);
}, [fetchTree]);
const handleUploadButtonClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
// File tree context menu
const getContextMenuItems = useCallback((nodePath: string, isDirectory: boolean): MenuProps['items'] => {
const items: MenuProps['items'] = [];
@ -704,6 +748,7 @@ export default function DocsPage() {
items.push(
{ key: 'newFile', icon: <FileAddOutlined />, label: 'New File', onClick: () => { setContextPath(nodePath); setModalInput(''); setModalType('newFile'); } },
{ key: 'newFolder', icon: <FolderAddOutlined />, label: 'New Folder', onClick: () => { setContextPath(nodePath); setModalInput(''); setModalType('newFolder'); } },
{ key: 'upload', icon: <UploadOutlined />, label: 'Upload File', onClick: () => { setSelectedFile(nodePath); handleUploadButtonClick(); } },
{ type: 'divider' },
);
}
@ -712,7 +757,7 @@ export default function DocsPage() {
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
);
return items;
}, []);
}, [handleUploadButtonClick]);
const handleDelete = useCallback(async (filePath: string) => {
Modal.confirm({
@ -807,14 +852,6 @@ export default function DocsPage() {
});
}, []);
const expandAll = useCallback(() => {
setExpandedKeys(collectAllDirKeys(fileTree));
}, [fileTree]);
const collapseAll = useCallback(() => {
setExpandedKeys([]);
}, []);
const toggleExpand = useCallback((key: string) => {
setExpandedKeys(prev =>
prev.includes(key)
@ -936,6 +973,105 @@ export default function DocsPage() {
return find(fileTree);
}, [fileTree]);
const allExpanded = expandedKeys.length > 0;
const toggleExpandAll = useCallback(() => {
if (allExpanded) {
setExpandedKeys([]);
} else {
setExpandedKeys(collectAllDirKeys(fileTree));
}
}, [allExpanded, fileTree]);
// Upload files (images, pdfs, etc.) to the docs directory
const handleUploadFiles = useCallback(async (files: FileList | File[]) => {
const fileArray = Array.from(files);
if (fileArray.length === 0) return;
// Determine target directory: selected folder, or parent of selected file, or root
let targetDir = '';
if (selectedFile) {
if (isDirectoryPath(selectedFile)) {
targetDir = selectedFile;
} else if (selectedFile.includes('/')) {
targetDir = selectedFile.substring(0, selectedFile.lastIndexOf('/'));
}
}
const hideLoading = messageApi.loading(`Uploading ${fileArray.length} file${fileArray.length > 1 ? 's' : ''}...`, 0);
let successCount = 0;
let lastMdPath: string | null = null;
for (const file of fileArray) {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('path', targetDir);
const res = await api.post<{ success: boolean; path: string }>('/docs/upload', formData);
successCount++;
if (file.name.endsWith('.md')) {
lastMdPath = res.data.path;
}
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Upload failed';
messageApi.error(`Failed to upload ${file.name}: ${msg}`);
}
}
hideLoading();
if (successCount > 0) {
messageApi.success(`Uploaded ${successCount} file${successCount > 1 ? 's' : ''}`);
invalidateTreeCache();
await fetchTree(false, true);
if (lastMdPath) {
await loadFile(lastMdPath);
}
}
}, [selectedFile, isDirectoryPath, messageApi, fetchTree, loadFile]);
// Drag-and-drop handlers for the file tree panel
const handleTreeDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current++;
if (e.dataTransfer.types.includes('Files')) {
setDragOver(true);
}
}, []);
const handleTreeDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleTreeDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current--;
if (dragCounter.current <= 0) {
dragCounter.current = 0;
setDragOver(false);
}
}, []);
const handleTreeDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setDragOver(false);
if (e.dataTransfer.files.length > 0) {
handleUploadFiles(e.dataTransfer.files);
}
}, [handleUploadFiles]);
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleUploadFiles(e.target.files);
e.target.value = ''; // Reset so same file can be re-selected
}
}, [handleUploadFiles]);
if (isMobile) {
return (
<Result status="info" title="Desktop Required" subTitle="The documentation editor requires a desktop browser with a larger screen." />
@ -1039,53 +1175,91 @@ export default function DocsPage() {
flexDirection: 'column',
background: token.colorBgContainer,
flexShrink: 0,
position: 'relative',
}}
onDragEnter={handleTreeDragEnter}
onDragOver={handleTreeDragOver}
onDragLeave={handleTreeDragLeave}
onDrop={handleTreeDrop}
>
{/* Drag-and-drop overlay */}
{dragOver && (
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 10,
background: `${token.colorPrimary}22`,
border: `2px dashed ${token.colorPrimary}`,
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 8,
pointerEvents: 'none',
}}
>
<InboxOutlined style={{ fontSize: 32, color: token.colorPrimary }} />
<Typography.Text style={{ color: token.colorPrimary, fontWeight: 600, fontSize: 13 }}>
Drop files here
</Typography.Text>
</div>
)}
{/* Hidden file input for upload button fallback */}
<input
ref={fileInputRef}
type="file"
multiple
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico,.pdf,.zip"
style={{ display: 'none' }}
onChange={handleFileInputChange}
/>
{/* Toolbar */}
<div
style={{
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 6px 0 10px',
justifyContent: 'flex-end',
padding: '0 4px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
overflow: 'hidden',
flexWrap: 'nowrap',
gap: 1,
}}
>
<Typography.Text style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, color: token.colorTextSecondary, fontWeight: 600, userSelect: 'none' }}>
Files
</Typography.Text>
<Space size={4}>
<Tooltip title="Filter" mouseEnterDelay={0.4}>
<Button
type="text"
size="small"
icon={<SearchOutlined />}
onClick={toggleFilter}
aria-label="Filter files"
style={{ width: 28, height: 28, color: filterVisible ? token.colorPrimary : token.colorTextSecondary }}
/>
</Tooltip>
<Tooltip title="Refresh" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<ReloadOutlined />} onClick={refreshTree} aria-label="Refresh file tree" style={{ width: 28, height: 28, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="Expand All" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<NodeExpandOutlined />} onClick={expandAll} aria-label="Expand all folders" style={{ width: 28, height: 28, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="Collapse All" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<NodeCollapseOutlined />} onClick={collapseAll} aria-label="Collapse all folders" style={{ width: 28, height: 28, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="New File" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<FileAddOutlined />} onClick={handleNewFileRoot} aria-label="Create new file" style={{ width: 28, height: 28, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="New Folder" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<FolderAddOutlined />} onClick={handleNewFolderRoot} aria-label="Create new folder" style={{ width: 28, height: 28, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="Hide Panel" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={toggleTree} aria-label="Hide file tree panel" style={{ width: 28, height: 28, color: token.colorTextSecondary }} />
</Tooltip>
</Space>
<Tooltip title="Filter" mouseEnterDelay={0.4}>
<Button
type="text"
size="small"
icon={<SearchOutlined />}
onClick={toggleFilter}
aria-label="Filter files"
style={{ width: 26, height: 26, minWidth: 26, color: filterVisible ? token.colorPrimary : token.colorTextSecondary }}
/>
</Tooltip>
<Tooltip title="Refresh" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<ReloadOutlined />} onClick={refreshTree} aria-label="Refresh file tree" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title={allExpanded ? 'Collapse All' : 'Expand All'} mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={allExpanded ? <NodeCollapseOutlined /> : <NodeExpandOutlined />} onClick={toggleExpandAll} aria-label={allExpanded ? 'Collapse all folders' : 'Expand all folders'} style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="New File" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<FileAddOutlined />} onClick={handleNewFileRoot} aria-label="Create new file" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="New Folder" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<FolderAddOutlined />} onClick={handleNewFolderRoot} aria-label="Create new folder" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="Upload File" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<UploadOutlined />} onClick={handleUploadButtonClick} aria-label="Upload file" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
</Tooltip>
<Tooltip title="Hide Panel" mouseEnterDelay={0.4}>
<Button type="text" size="small" icon={<MenuFoldOutlined />} onClick={toggleTree} aria-label="Hide file tree panel" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
</Tooltip>
</div>
{/* Filter input (toggled) */}
@ -1105,6 +1279,18 @@ export default function DocsPage() {
)}
{/* Tree */}
<Dropdown
menu={{
items: [
{ key: 'newFile', icon: <FileAddOutlined />, label: 'New File', onClick: handleNewFileRoot },
{ key: 'newFolder', icon: <FolderAddOutlined />, label: 'New Folder', onClick: handleNewFolderRoot },
{ type: 'divider' },
{ key: 'upload', icon: <UploadOutlined />, label: 'Upload File', onClick: handleUploadButtonClick },
{ key: 'refresh', icon: <ReloadOutlined />, label: 'Refresh', onClick: refreshTree },
],
}}
trigger={['contextMenu']}
>
<div style={{ flex: 1, overflow: 'auto' }} className="docs-tree">
<Tree
treeData={treeData}
@ -1136,6 +1322,7 @@ export default function DocsPage() {
toggleExpand(nodePath);
}
}}
onContextMenu={(e) => e.stopPropagation()}
style={{
display: 'block',
overflow: 'hidden',
@ -1155,6 +1342,7 @@ export default function DocsPage() {
style={{ fontSize: 13 }}
/>
</div>
</Dropdown>
</div>
{/* Tree resize handle */}
@ -1318,12 +1506,48 @@ export default function DocsPage() {
</div>
)}
{/* Monaco Editor */}
{/* Editor / Image Viewer */}
<div style={{ flex: 1, minHeight: 0 }}>
{fileLoading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin />
</div>
) : selectedFile && isImageFile(selectedFile) ? (
/* Image preview */
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: token.colorBgLayout,
overflow: 'auto',
padding: 24,
}}
>
<img
src={`/mkdocs-proxy/${selectedFile}`}
alt={selectedFile}
style={{
maxWidth: '100%',
maxHeight: 'calc(100% - 60px)',
objectFit: 'contain',
borderRadius: 4,
background: `repeating-conic-gradient(${token.colorBorderSecondary} 0% 25%, transparent 0% 50%) 50% / 16px 16px`,
}}
/>
<div style={{ marginTop: 12, textAlign: 'center' }}>
<Typography.Text type="secondary" style={{ fontSize: 12, fontFamily: 'monospace' }}>
{selectedFile}
</Typography.Text>
<div style={{ marginTop: 4 }}>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
MkDocs reference: <Typography.Text code copyable style={{ fontSize: 11 }}>{`![](${selectedFile.split('/').pop()})`}</Typography.Text>
</Typography.Text>
</div>
</div>
</div>
) : selectedFile ? (
<Editor
language={selectedFile.endsWith('.md') ? 'markdown' : selectedFile.endsWith('.yml') || selectedFile.endsWith('.yaml') ? 'yaml' : selectedFile.endsWith('.json') ? 'json' : selectedFile.endsWith('.css') ? 'css' : selectedFile.endsWith('.html') ? 'html' : selectedFile.endsWith('.js') ? 'javascript' : 'plaintext'}

View File

@ -1,4 +1,4 @@
import { readdir, readFile, writeFile, mkdir, rm, rename, stat } from 'fs/promises';
import { readdir, readFile, writeFile, mkdir, rm, rename, stat, copyFile } from 'fs/promises';
import { resolve as pathResolve, join, normalize, dirname, extname } from 'path';
import crypto from 'crypto';
import { env } from '../../config/env';
@ -250,6 +250,29 @@ function isEditableFile(relativePath: string): boolean {
return ['.md', '.txt', '.yml', '.yaml', '.json', '.css', '.html', '.js'].includes(ext);
}
const ALLOWED_UPLOAD_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
'.pdf', '.zip',
]);
async function uploadFile(relativePath: string, sourcePath: string): Promise<void> {
const ext = extname(relativePath).toLowerCase();
if (!ALLOWED_UPLOAD_EXTENSIONS.has(ext)) {
throw new Error(`File type not allowed: ${ext}`);
}
const fullPath = safeResolve(relativePath);
await mkdir(dirname(fullPath), { recursive: true });
await copyFile(sourcePath, fullPath);
// Invalidate tree cache (structure changed)
try {
await redis.del(TREE_CACHE_KEY);
} catch (err) {
logger.warn('Failed to invalidate tree cache after upload:', err);
}
}
async function invalidateTreeCache(): Promise<void> {
try {
await redis.del(TREE_CACHE_KEY);
@ -265,6 +288,7 @@ export const docsFilesService = {
createFile,
deleteFile,
renameFile,
uploadFile,
safeResolve,
isEditableFile,
invalidateTreeCache,

View File

@ -1,4 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express';
import multer from 'multer';
import { rm } from 'fs/promises';
import { extname } from 'path';
import { authenticate } from '../../middleware/auth.middleware';
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
import { env } from '../../config/env';
@ -104,6 +107,57 @@ router.post(
},
);
// --- File Upload ---
const ALLOWED_UPLOAD_EXTENSIONS = new Set([
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
'.pdf', '.zip',
]);
const upload = multer({
storage: multer.diskStorage({}), // temp dir
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
fileFilter: (_req, file, cb) => {
const ext = extname(file.originalname).toLowerCase();
if (ALLOWED_UPLOAD_EXTENSIONS.has(ext)) {
cb(null, true);
} else {
cb(new Error(`File type not allowed: ${ext}`));
}
},
});
// POST /api/docs/upload — upload binary file (image, pdf, etc.)
router.post(
'/upload',
upload.single('file'),
async (req: Request, res: Response, next: NextFunction) => {
const tempPath = req.file?.path;
try {
cm_docs_operations.inc({ operation: 'upload' });
if (!req.file) {
res.status(400).json({ error: { message: 'No file provided', code: 'VALIDATION_ERROR' } });
return;
}
const targetDir = (req.body as { path?: string }).path || '';
const fileName = req.file.originalname;
const relativePath = targetDir ? `${targetDir}/${fileName}` : fileName;
await docsFilesService.uploadFile(relativePath, req.file.path);
// Clean up temp file
try { await rm(req.file.path); } catch { /* ignore */ }
res.json({ success: true, path: relativePath });
} catch (err) {
// Clean up temp file on error
if (tempPath) { try { await rm(tempPath); } catch { /* ignore */ } }
handleFileError(err, res, next);
}
},
);
// --- File Management Endpoints ---
// GET /api/docs/files — list file tree

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Some files were not shown because too many files have changed in this diff Show More