← Назадimport { authFetch } from '../../lib/api';
import { useState, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Folder, FolderOpen, FileText, ChevronRight, ChevronDown,
Loader2, AlertCircle, Copy, Check, Download,
} from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogClose, DialogBody } from '../ui/dialog';
interface FileNode {
name: string;
type: 'file' | 'dir';
path: string;
size?: number;
ext?: string;
isText?: boolean;
children?: FileNode[];
}
interface FilesData {
path: string;
tree: FileNode[];
}
interface FilesModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
projectId: string | null;
}
const PROJECT_NAMES: Record<string, string> = {
system: 'Dashboard',
'bender-bot': 'Bender Bot',
piewell: 'Piewell.com',
'futures-screener': 'Futures Screener',
affiliate: 'Affiliate',
ideas: 'Ideas',
alphapulse: 'AlphaPulse',
'options-screener': 'Опционный скринер',
'youtube-ai-channel': 'YouTube AI Channel',
'trading-bot': 'Trading Bot',
};
const FILE_ICONS: Record<string, string> = {
'.js': '🟨', '.mjs': '🟨', '.cjs': '🟨',
'.ts': '🔷', '.tsx': '⚛️', '.jsx': '⚛️',
'.json': '📋', '.md': '📝', '.txt': '📄',
'.yaml': '⚙️', '.yml': '⚙️', '.toml': '⚙️', '.ini': '⚙️', '.cfg': '⚙️', '.conf': '⚙️',
'.sh': '🔧', '.bash': '🔧', '.zsh': '🔧',
'.py': '🐍', '.rb': '💎', '.php': '🐘', '.go': '🐹', '.rs': '🦀',
'.css': '🎨', '.scss': '🎨', '.sass': '🎨', '.less': '🎨',
'.html': '🌐', '.xml': '🌐', '.svg': '🖼️',
'.sql': '🗃️', '.graphql': '📡', '.prisma': '🔺',
'.env': '🔒', '.gitignore': '🚫', '.dockerignore': '🚫',
'.dockerfile': '🐳',
'.lock': '🔒',
'.c': '⚙️', '.cpp': '⚙️', '.h': '⚙️', '.java': '☕', '.cs': '🔵',
};
function getFileIcon(ext?: string): string {
if (!ext) return '📄';
return FILE_ICONS[ext] || '📄';
}
function formatSize(size?: number): string {
if (!size) return '';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
function FileTreeNode({
node,
selectedPath,
onSelectFile,
depth = 0,
}: {
node: FileNode;
selectedPath: string | null;
onSelectFile: (path: string) => void;
depth?: number;
}) {
const [expanded, setExpanded] = useState(depth < 1);
const indent = depth * 14 + 8;
if (node.type === 'dir') {
return (
<div>
<button
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1.5 w-full text-left py-1 rounded hover:bg-white/5 text-sm transition-colors"
style={{ paddingLeft: `${indent}px`, paddingRight: '8px' }}
>
{expanded
? <ChevronDown className="w-3 h-3 text-gray-500 shrink-0" />
: <ChevronRight className="w-3 h-3 text-gray-500 shrink-0" />}
{expanded
? <FolderOpen className="w-3.5 h-3.5 text-yellow-400 shrink-0" />
: <Folder className="w-3.5 h-3.5 text-yellow-500 shrink-0" />}
<span className="text-gray-300 truncate">{node.name}</span>
</button>
{expanded && node.children && node.children.length > 0 && (
<div>
{node.children.map((child) => (
<FileTreeNode
key={child.path}
node={child}
selectedPath={selectedPath}
onSelectFile={onSelectFile}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
const isSelected = selectedPath === node.path;
const isText = node.isText !== false;
return (
<button
onClick={() => isText && onSelectFile(node.path)}
disabled={!isText}
title={isText ? node.path : `${node.name} (binary)`}
className={`flex items-center gap-1.5 w-full text-left py-1 rounded text-sm transition-colors ${
isSelected
? 'bg-primary/20 text-primary'
: 'hover:bg-white/5 text-gray-400 hover:text-gray-200'
} ${!isText ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}`}
style={{ paddingLeft: `${indent}px`, paddingRight: '8px' }}
>
<span className="text-sm leading-none shrink-0">{getFileIcon(node.ext)}</span>
<span className="truncate flex-1">{node.name}</span>
{node.size !== undefined && node.size > 0 && (
<span className="text-xs text-gray-600 shrink-0 ml-1">{formatSize(node.size)}</span>
)}
</button>
);
}
export function FilesModal({ open, onOpenChange, projectId }: FilesModalProps) {
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleCopy = useCallback((text: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, []);
const handleDownload = useCallback((filePath: string) => {
const token = localStorage.getItem('dashboard_token') || '';
const url = `/api/download?file=${encodeURIComponent(filePath.replace(/^\/home\/app\//, ''))}`;
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filePath.split('/').pop() || 'file';
a.click();
URL.revokeObjectURL(a.href);
});
}, []);
const {
data: filesData,
isLoading: treeLoading,
error: treeError,
} = useQuery<FilesData, Error>({
queryKey: ['files-tree', projectId],
queryFn: async () => {
const res = await authFetch(`/api/projects/${projectId}/files`);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
},
enabled: open && !!projectId,
staleTime: 30000,
});
const {
data: fileContent,
isLoading: contentLoading,
error: contentError,
} = useQuery<{ content: string; size: number; path: string }, Error>({
queryKey: ['file-content', projectId, selectedFile],
queryFn: async () => {
const res = await authFetch(
`/api/projects/${projectId}/files/content?path=${encodeURIComponent(selectedFile!)}`,
);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
},
enabled: open && !!projectId,
staleTime: 30000,
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl h-[90vh] overflow-hidden flex flex-col p-6">
<DialogHeader className="shrink-0">
<DialogTitle>
Файлы: {projectId ? (PROJECT_NAMES[projectId] ?? projectId) : ''}
</DialogTitle>
<DialogClose onClick={() => onOpenChange(false)} />
</DialogHeader>
<DialogBody className="flex-1 min-h-0 overflow-hidden py-4 -mx-6 px-6 flex flex-col">
{/* Loading tree */}
{treeLoading && (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
)}
{/* Tree error */}
{treeError && (
<div className="flex items-center justify-center py-20">
<div className="text-center space-y-3 max-w-md">
<AlertCircle className="w-10 h-10 text-danger mx-auto" />
<div className="text-danger font-semibold">Папка проекта не настроена</div>
<div className="text-xs text-gray-400 font-mono bg-black/30 px-4 py-3 rounded-lg text-left whitespace-pre-wrap">
{treeError.message}
</div>
</div>
</div>
)}
{/* Main split view */}
{!treeLoading && !treeError && filesData && (
<div className="flex flex-col md:flex-row flex-1 min-h-0 gap-4">
{/* LEFT: file tree */}
<div className="w-full md:w-64 md:shrink-0 flex flex-col bg-white/[0.03] rounded-xl border border-white/10 min-h-0 max-h-48 md:max-h-none">
<div
className="px-3 py-2 text-xs text-gray-600 font-mono truncate border-b border-white/5 shrink-0"
title={filesData.path}
>
{filesData.path}
</div>
<div className="flex-1 overflow-y-auto p-1">
{filesData.tree.length === 0 ? (
<div className="text-sm text-gray-500 px-2 py-4">Папка пуста</div>
) : (
filesData.tree.map((node) => (
<FileTreeNode
key={node.path}
node={node}
selectedPath={selectedFile}
onSelectFile={setSelectedFile}
/>
))
)}
</div>
</div>
{/* RIGHT: content viewer */}
<div className="flex-1 flex flex-col bg-black/20 rounded-xl border border-white/10 min-h-0 min-w-0">
{!selectedFile && (
<div className="flex-1 flex items-center justify-center text-gray-600">
<div className="text-center">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-30" />
<div className="text-sm">Выберите файл для просмотра</div>
</div>
</div>
)}
{selectedFile && contentLoading && (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
)}
{selectedFile && contentError && (
<div className="flex-1 flex items-center justify-center">
<div className="text-danger text-sm text-center px-4">
{contentError.message}
</div>
</div>
)}
{selectedFile && fileContent && !contentLoading && (
<>
{/* File header — две строки: путь + кнопки */}
<div className="flex flex-col gap-1 px-4 py-2 border-b border-white/10 shrink-0">
<span className="text-xs text-gray-400 font-mono truncate">{fileContent.path}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-600">{formatSize(fileContent.size)}</span>
<button
onClick={() => handleCopy(fileContent.content)}
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-white/5 hover:bg-white/10 text-gray-400 hover:text-white transition-colors"
>
{copied ? <Check className="w-3 h-3 text-green-400" /> : <Copy className="w-3 h-3" />}
{copied ? 'Скопировано' : 'Копировать'}
</button>
<button
onClick={() => handleDownload(fileContent.path)}
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
>
<Download className="w-3 h-3" />
Скачать
</button>
</div>
</div>
{/* Scrollable content — оба направления */}
<pre className="flex-1 overflow-x-auto overflow-y-auto p-4 text-xs text-gray-200 font-mono leading-relaxed whitespace-pre">
{fileContent.content}
</pre>
</>
)}
</div>
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
);
}