Compare commits
No commits in common. "58dc1942ecca3a2f75a8398c58b098db95db64f0" and "4370cbacf419996edb004d648786b081dfe33083" have entirely different histories.
58dc1942ec
...
4370cbacf4
7
.gitignore
vendored
@ -9,8 +9,11 @@ node_modules/
|
|||||||
/configs/code-server/.config/*
|
/configs/code-server/.config/*
|
||||||
!/configs/code-server/.config/.gitkeep
|
!/configs/code-server/.config/.gitkeep
|
||||||
|
|
||||||
# Root assets (generated by containers)
|
# MkDocs cache and built site (created by containers)
|
||||||
/assets/
|
/mkdocs/.cache/*
|
||||||
|
!/mkdocs/.cache/.gitkeep
|
||||||
|
/mkdocs/site/*
|
||||||
|
!/mkdocs/site/.gitkeep
|
||||||
|
|
||||||
# Homepage logs (created by container)
|
# Homepage logs (created by container)
|
||||||
/configs/homepage/logs/*
|
/configs/homepage/logs/*
|
||||||
|
|||||||
@ -48,9 +48,6 @@ import {
|
|||||||
FontSizeOutlined,
|
FontSizeOutlined,
|
||||||
BuildOutlined,
|
BuildOutlined,
|
||||||
HolderOutlined,
|
HolderOutlined,
|
||||||
QuestionCircleOutlined,
|
|
||||||
UploadOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import type { OnMount } from '@monaco-editor/react';
|
import type { OnMount } from '@monaco-editor/react';
|
||||||
@ -74,13 +71,6 @@ const DEFAULT_TREE_WIDTH = 200;
|
|||||||
const MIN_TREE_WIDTH = 160;
|
const MIN_TREE_WIDTH = 160;
|
||||||
const MAX_TREE_WIDTH = 400;
|
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 {
|
function filePathToMkDocsUrl(filePath: string): string {
|
||||||
let url = filePath.replace(/\.md$/, '');
|
let url = filePath.replace(/\.md$/, '');
|
||||||
if (url.endsWith('/index') || url === 'index') {
|
if (url.endsWith('/index') || url === 'index') {
|
||||||
@ -128,49 +118,30 @@ function invalidateTreeCache(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// URL Preview Bar Component (shows full clickable URLs above iframe)
|
// URL Preview Bar Component (shows production + localhost URLs above iframe)
|
||||||
const URLPreviewBar = ({ filePath, config }: { filePath: string | null; config: ServicesConfig | null }) => {
|
const URLPreviewBar = ({ filePath, config }: { filePath: string | null; config: ServicesConfig | null }) => {
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
// Only show for markdown files
|
// Only show for markdown files
|
||||||
if (!filePath || !filePath.endsWith('.md')) return null;
|
if (!filePath || !filePath.endsWith('.md')) return null;
|
||||||
|
|
||||||
// Transform file path to URL path
|
// Transform file path to URL path (reuse existing logic from filePathToMkDocsUrl)
|
||||||
let urlPath = filePath.replace(/\.md$/, '');
|
let urlPath = filePath.replace(/\.md$/, '');
|
||||||
if (urlPath.endsWith('/index') || urlPath === 'index') {
|
if (urlPath.endsWith('/index') || urlPath === 'index') {
|
||||||
urlPath = urlPath.replace(/\/?index$/, '');
|
urlPath = urlPath.replace(/\/?index$/, '');
|
||||||
}
|
}
|
||||||
const suffix = urlPath ? `/${urlPath}/` : '/';
|
|
||||||
|
|
||||||
const hostname = window.location.hostname;
|
// Use buildServiceUrl for environment-aware URL construction
|
||||||
const isRealDomain = hostname.includes('.');
|
const baseUrl = config
|
||||||
|
? buildServiceUrl(config.mkdocsSubdomain, config.domain, config.mkdocsPort)
|
||||||
// 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;
|
: null;
|
||||||
|
const productionUrl = baseUrl ? `${baseUrl}/${urlPath}${urlPath ? '/' : ''}` : '';
|
||||||
|
const localhostUrl = productionUrl; // Same URL works for both environments now
|
||||||
|
|
||||||
const openUrl = (url: string) => {
|
const openUrl = (url: string) => {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -180,29 +151,51 @@ const URLPreviewBar = ({ filePath, config }: { filePath: string | null; config:
|
|||||||
padding: '0 8px',
|
padding: '0 8px',
|
||||||
background: token.colorBgElevated,
|
background: token.colorBgElevated,
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
gap: 12,
|
gap: 8,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{productionUrl && (
|
<Typography.Text
|
||||||
<Tooltip title={productionUrl} mouseEnterDelay={0.3}>
|
style={{
|
||||||
<span style={urlLinkStyle} onClick={() => openUrl(productionUrl)}>
|
fontSize: 11,
|
||||||
<ExportOutlined style={{ fontSize: 10, flexShrink: 0 }} />
|
textTransform: 'uppercase',
|
||||||
{productionUrl}
|
letterSpacing: 0.5,
|
||||||
</span>
|
color: token.colorTextSecondary,
|
||||||
</Tooltip>
|
fontWeight: 600,
|
||||||
)}
|
userSelect: 'none',
|
||||||
|
marginRight: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview:
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
{/* Only show localhost URL when on localhost (on real domain they resolve the same) */}
|
<Space size={8}>
|
||||||
{!isRealDomain && localhostUrl && (
|
{/* Production URL Button */}
|
||||||
<Tooltip title={localhostUrl} mouseEnterDelay={0.3}>
|
<Tooltip title={productionUrl} mouseEnterDelay={0.3}>
|
||||||
<span style={urlLinkStyle} onClick={() => openUrl(localhostUrl)}>
|
<Button
|
||||||
<ExportOutlined style={{ fontSize: 10, flexShrink: 0 }} />
|
type="default"
|
||||||
{localhostUrl}
|
size="small"
|
||||||
</span>
|
icon={<ExportOutlined />}
|
||||||
|
onClick={() => openUrl(productionUrl)}
|
||||||
|
style={{ height: 24, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Production
|
||||||
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
|
||||||
|
{/* Localhost URL Button */}
|
||||||
|
<Tooltip title={localhostUrl} mouseEnterDelay={0.3}>
|
||||||
|
<Button
|
||||||
|
type="default"
|
||||||
|
size="small"
|
||||||
|
icon={<ExportOutlined />}
|
||||||
|
onClick={() => openUrl(localhostUrl)}
|
||||||
|
style={{ height: 24, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Localhost
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -399,10 +392,6 @@ export default function DocsPage() {
|
|||||||
const [modalInput, setModalInput] = useState('');
|
const [modalInput, setModalInput] = useState('');
|
||||||
const [contextPath, setContextPath] = useState<string>('');
|
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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const dragging = useRef<'split' | 'tree' | false>(false);
|
const dragging = useRef<'split' | 'tree' | false>(false);
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
@ -413,12 +402,11 @@ export default function DocsPage() {
|
|||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
// Fetch file tree
|
// Fetch file tree
|
||||||
const fetchTree = useCallback(async (showLoading = true, force = false) => {
|
const fetchTree = useCallback(async (showLoading = true) => {
|
||||||
try {
|
try {
|
||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
setFetchError(false);
|
setFetchError(false);
|
||||||
const url = force ? '/docs/files?force=true' : '/docs/files';
|
const res = await api.get<FileNode[]>('/docs/files');
|
||||||
const res = await api.get<FileNode[]>(url);
|
|
||||||
setFileTree(res.data);
|
setFileTree(res.data);
|
||||||
setCachedTree(res.data);
|
setCachedTree(res.data);
|
||||||
} catch {
|
} catch {
|
||||||
@ -629,39 +617,9 @@ export default function DocsPage() {
|
|||||||
};
|
};
|
||||||
}, [ctxMenu]);
|
}, [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[]) => {
|
const onTreeSelect = useCallback(async (keys: React.Key[]) => {
|
||||||
if (keys.length === 0) return;
|
if (keys.length === 0) return;
|
||||||
const path = keys[0] as string;
|
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) {
|
if (dirty) {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Unsaved Changes',
|
title: 'Unsaved Changes',
|
||||||
@ -732,15 +690,6 @@ export default function DocsPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshTree = useCallback(() => {
|
|
||||||
invalidateTreeCache();
|
|
||||||
fetchTree(false, true);
|
|
||||||
}, [fetchTree]);
|
|
||||||
|
|
||||||
const handleUploadButtonClick = useCallback(() => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// File tree context menu
|
// File tree context menu
|
||||||
const getContextMenuItems = useCallback((nodePath: string, isDirectory: boolean): MenuProps['items'] => {
|
const getContextMenuItems = useCallback((nodePath: string, isDirectory: boolean): MenuProps['items'] => {
|
||||||
const items: MenuProps['items'] = [];
|
const items: MenuProps['items'] = [];
|
||||||
@ -748,7 +697,6 @@ export default function DocsPage() {
|
|||||||
items.push(
|
items.push(
|
||||||
{ key: 'newFile', icon: <FileAddOutlined />, label: 'New File', onClick: () => { setContextPath(nodePath); setModalInput(''); setModalType('newFile'); } },
|
{ 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: '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' },
|
{ type: 'divider' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -757,7 +705,7 @@ export default function DocsPage() {
|
|||||||
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
|
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
|
||||||
);
|
);
|
||||||
return items;
|
return items;
|
||||||
}, [handleUploadButtonClick]);
|
}, []);
|
||||||
|
|
||||||
const handleDelete = useCallback(async (filePath: string) => {
|
const handleDelete = useCallback(async (filePath: string) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
@ -852,6 +800,14 @@ export default function DocsPage() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const expandAll = useCallback(() => {
|
||||||
|
setExpandedKeys(collectAllDirKeys(fileTree));
|
||||||
|
}, [fileTree]);
|
||||||
|
|
||||||
|
const collapseAll = useCallback(() => {
|
||||||
|
setExpandedKeys([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleExpand = useCallback((key: string) => {
|
const toggleExpand = useCallback((key: string) => {
|
||||||
setExpandedKeys(prev =>
|
setExpandedKeys(prev =>
|
||||||
prev.includes(key)
|
prev.includes(key)
|
||||||
@ -892,10 +848,6 @@ export default function DocsPage() {
|
|||||||
// Header actions
|
// Header actions
|
||||||
const headerActions = useMemo(() => (
|
const headerActions = useMemo(() => (
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
<Tooltip title="MkDocs Reference">
|
|
||||||
<Button type="text" icon={<QuestionCircleOutlined />} onClick={() => window.open('https://squidfunk.github.io/mkdocs-material/reference/', '_blank', 'noopener,noreferrer')} size="middle" />
|
|
||||||
</Tooltip>
|
|
||||||
<div style={{ width: 1, height: 24, background: token.colorBorderSecondary, margin: '0 4px' }} />
|
|
||||||
<Tooltip title="Editor + Preview">
|
<Tooltip title="Editor + Preview">
|
||||||
<Button type={layout === 'split' ? 'primary' : 'text'} icon={<ColumnWidthOutlined />} onClick={() => setLayout('split')} size="middle" />
|
<Button type={layout === 'split' ? 'primary' : 'text'} icon={<ColumnWidthOutlined />} onClick={() => setLayout('split')} size="middle" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -973,105 +925,6 @@ export default function DocsPage() {
|
|||||||
return find(fileTree);
|
return find(fileTree);
|
||||||
}, [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) {
|
if (isMobile) {
|
||||||
return (
|
return (
|
||||||
<Result status="info" title="Desktop Required" subTitle="The documentation editor requires a desktop browser with a larger screen." />
|
<Result status="info" title="Desktop Required" subTitle="The documentation editor requires a desktop browser with a larger screen." />
|
||||||
@ -1175,91 +1028,50 @@ export default function DocsPage() {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
background: token.colorBgContainer,
|
background: token.colorBgContainer,
|
||||||
flexShrink: 0,
|
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 */}
|
{/* Toolbar */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 36,
|
height: 36,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'space-between',
|
||||||
padding: '0 4px',
|
padding: '0 6px 0 10px',
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
overflow: 'hidden',
|
|
||||||
flexWrap: 'nowrap',
|
|
||||||
gap: 1,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tooltip title="Filter" mouseEnterDelay={0.4}>
|
<Typography.Text style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, color: token.colorTextSecondary, fontWeight: 600, userSelect: 'none' }}>
|
||||||
<Button
|
Files
|
||||||
type="text"
|
</Typography.Text>
|
||||||
size="small"
|
<Space size={4}>
|
||||||
icon={<SearchOutlined />}
|
<Tooltip title="Filter" mouseEnterDelay={0.4}>
|
||||||
onClick={toggleFilter}
|
<Button
|
||||||
aria-label="Filter files"
|
type="text"
|
||||||
style={{ width: 26, height: 26, minWidth: 26, color: filterVisible ? token.colorPrimary : token.colorTextSecondary }}
|
size="small"
|
||||||
/>
|
icon={<SearchOutlined />}
|
||||||
</Tooltip>
|
onClick={toggleFilter}
|
||||||
<Tooltip title="Refresh" mouseEnterDelay={0.4}>
|
aria-label="Filter files"
|
||||||
<Button type="text" size="small" icon={<ReloadOutlined />} onClick={refreshTree} aria-label="Refresh file tree" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
|
style={{ width: 28, height: 28, color: filterVisible ? token.colorPrimary : token.colorTextSecondary }}
|
||||||
</Tooltip>
|
/>
|
||||||
<Tooltip title={allExpanded ? 'Collapse All' : 'Expand All'} mouseEnterDelay={0.4}>
|
</Tooltip>
|
||||||
<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 title="Expand All" mouseEnterDelay={0.4}>
|
||||||
</Tooltip>
|
<Button type="text" size="small" icon={<NodeExpandOutlined />} onClick={expandAll} aria-label="Expand all folders" style={{ width: 28, height: 28, color: token.colorTextSecondary }} />
|
||||||
<Tooltip title="New File" mouseEnterDelay={0.4}>
|
</Tooltip>
|
||||||
<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 title="Collapse All" mouseEnterDelay={0.4}>
|
||||||
</Tooltip>
|
<Button type="text" size="small" icon={<NodeCollapseOutlined />} onClick={collapseAll} aria-label="Collapse all folders" style={{ width: 28, height: 28, color: token.colorTextSecondary }} />
|
||||||
<Tooltip title="New Folder" mouseEnterDelay={0.4}>
|
</Tooltip>
|
||||||
<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 title="New File" mouseEnterDelay={0.4}>
|
||||||
</Tooltip>
|
<Button type="text" size="small" icon={<FileAddOutlined />} onClick={handleNewFileRoot} aria-label="Create new file" style={{ width: 28, height: 28, color: token.colorTextSecondary }} />
|
||||||
<Tooltip title="Upload File" mouseEnterDelay={0.4}>
|
</Tooltip>
|
||||||
<Button type="text" size="small" icon={<UploadOutlined />} onClick={handleUploadButtonClick} aria-label="Upload file" style={{ width: 26, height: 26, minWidth: 26, color: token.colorTextSecondary }} />
|
<Tooltip title="New Folder" mouseEnterDelay={0.4}>
|
||||||
</Tooltip>
|
<Button type="text" size="small" icon={<FolderAddOutlined />} onClick={handleNewFolderRoot} aria-label="Create new folder" style={{ width: 28, height: 28, color: token.colorTextSecondary }} />
|
||||||
<Tooltip title="Hide Panel" mouseEnterDelay={0.4}>
|
</Tooltip>
|
||||||
<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 title="Hide Panel" mouseEnterDelay={0.4}>
|
||||||
</Tooltip>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter input (toggled) */}
|
{/* Filter input (toggled) */}
|
||||||
@ -1279,18 +1091,6 @@ export default function DocsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tree */}
|
{/* 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">
|
<div style={{ flex: 1, overflow: 'auto' }} className="docs-tree">
|
||||||
<Tree
|
<Tree
|
||||||
treeData={treeData}
|
treeData={treeData}
|
||||||
@ -1322,7 +1122,6 @@ export default function DocsPage() {
|
|||||||
toggleExpand(nodePath);
|
toggleExpand(nodePath);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => e.stopPropagation()}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'block',
|
display: 'block',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -1342,7 +1141,6 @@ export default function DocsPage() {
|
|||||||
style={{ fontSize: 13 }}
|
style={{ fontSize: 13 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Dropdown>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tree resize handle */}
|
{/* Tree resize handle */}
|
||||||
@ -1506,48 +1304,12 @@ export default function DocsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Editor / Image Viewer */}
|
{/* Monaco Editor */}
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
{fileLoading ? (
|
{fileLoading ? (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||||
<Spin />
|
<Spin />
|
||||||
</div>
|
</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 }}>{`.pop()})`}</Typography.Text>
|
|
||||||
</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : selectedFile ? (
|
) : selectedFile ? (
|
||||||
<Editor
|
<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'}
|
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'}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { readdir, readFile, writeFile, mkdir, rm, rename, stat, copyFile } from 'fs/promises';
|
import { readdir, readFile, writeFile, mkdir, rm, rename, stat } from 'fs/promises';
|
||||||
import { resolve as pathResolve, join, normalize, dirname, extname } from 'path';
|
import { resolve as pathResolve, join, normalize, dirname, extname } from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
@ -18,8 +18,7 @@ const DOCS_ROOT = pathResolve(env.MKDOCS_DOCS_PATH);
|
|||||||
// Redis cache configuration
|
// Redis cache configuration
|
||||||
const CACHE_KEY_PREFIX = 'DOCS_CACHE:';
|
const CACHE_KEY_PREFIX = 'DOCS_CACHE:';
|
||||||
const TREE_CACHE_KEY = `${CACHE_KEY_PREFIX}tree`;
|
const TREE_CACHE_KEY = `${CACHE_KEY_PREFIX}tree`;
|
||||||
const TREE_CACHE_TTL = 30; // 30 seconds — short so external changes show quickly
|
const FILE_CACHE_TTL = 60 * 60; // 1 hour
|
||||||
const FILE_CONTENT_CACHE_TTL = 60 * 60; // 1 hour for file content
|
|
||||||
|
|
||||||
function hashFilePath(path: string): string {
|
function hashFilePath(path: string): string {
|
||||||
return crypto.createHash('sha256').update(path).digest('hex').substring(0, 16);
|
return crypto.createHash('sha256').update(path).digest('hex').substring(0, 16);
|
||||||
@ -98,7 +97,7 @@ async function listTree(dir: string = DOCS_ROOT, relBase: string = ''): Promise<
|
|||||||
// Cache root result
|
// Cache root result
|
||||||
if (dir === DOCS_ROOT && !relBase) {
|
if (dir === DOCS_ROOT && !relBase) {
|
||||||
try {
|
try {
|
||||||
await redis.setex(TREE_CACHE_KEY, TREE_CACHE_TTL, JSON.stringify(nodes));
|
await redis.setex(TREE_CACHE_KEY, FILE_CACHE_TTL, JSON.stringify(nodes));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to cache docs tree:', err);
|
logger.warn('Failed to cache docs tree:', err);
|
||||||
}
|
}
|
||||||
@ -130,7 +129,7 @@ async function readFileContent(relativePath: string): Promise<string> {
|
|||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
try {
|
try {
|
||||||
await redis.setex(cacheKey, FILE_CONTENT_CACHE_TTL, content);
|
await redis.setex(cacheKey, FILE_CACHE_TTL, content);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to cache file content:', err);
|
logger.warn('Failed to cache file content:', err);
|
||||||
}
|
}
|
||||||
@ -250,37 +249,6 @@ function isEditableFile(relativePath: string): boolean {
|
|||||||
return ['.md', '.txt', '.yml', '.yaml', '.json', '.css', '.html', '.js'].includes(ext);
|
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);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Failed to invalidate tree cache:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const docsFilesService = {
|
export const docsFilesService = {
|
||||||
listTree,
|
listTree,
|
||||||
readFileContent,
|
readFileContent,
|
||||||
@ -288,8 +256,6 @@ export const docsFilesService = {
|
|||||||
createFile,
|
createFile,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
renameFile,
|
renameFile,
|
||||||
uploadFile,
|
|
||||||
safeResolve,
|
safeResolve,
|
||||||
isEditableFile,
|
isEditableFile,
|
||||||
invalidateTreeCache,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
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 { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
@ -107,68 +104,14 @@ 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 ---
|
// --- File Management Endpoints ---
|
||||||
|
|
||||||
// GET /api/docs/files — list file tree
|
// GET /api/docs/files — list file tree
|
||||||
router.get(
|
router.get(
|
||||||
'/files',
|
'/files',
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
cm_docs_operations.inc({ operation: 'list' });
|
cm_docs_operations.inc({ operation: 'list' });
|
||||||
if (req.query['force'] === 'true') {
|
|
||||||
await docsFilesService.invalidateTreeCache();
|
|
||||||
}
|
|
||||||
const tree = await docsFilesService.listTree();
|
const tree = await docsFilesService.listTree();
|
||||||
res.json(tree);
|
res.json(tree);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 67 KiB |