Create comprehensive report viewer with full file contents

This commit is contained in:
2026-01-01 18:28:24 +11:00
parent 57436da1bf
commit 00b27b9468

View File

@@ -1,8 +1,8 @@
<?php
/**
* UltyScan Web Interface - Workspace Report Viewer
* Generates a beautiful HTML report for a workspace
* UltyScan Web Interface - Comprehensive Report Viewer
* Compiles all workspace files into a single readable HTML report
*/
$name = preg_replace('/[^a-zA-Z0-9\-\_\.]/', '', $_GET['name'] ?? '');
@@ -15,105 +15,111 @@ if (!is_dir($workspaceDir)) {
die('Workspace not found');
}
// Check for existing sniper report
$sniperReport = $workspaceDir . '/sniper-report.html';
if (file_exists($sniperReport)) {
// Redirect to the existing report
header('Location: /loot/workspace/' . urlencode($name) . '/sniper-report.html');
exit;
}
// Collect workspace data
$data = [
// Collect all data from workspace
$report = [
'name' => $name,
'created' => date('Y-m-d H:i:s', filectime($workspaceDir)),
'modified' => date('Y-m-d H:i:s', filemtime($workspaceDir)),
'hosts' => [],
'ports' => [],
'vulnerabilities' => [],
'screenshots' => [],
'files' => []
'sections' => []
];
// Get all files in workspace
function scanWorkspace($dir, $prefix = '')
// Helper function to read file safely
function readFileContent($path, $maxLines = 500)
{
if (!file_exists($path) || !is_readable($path)) return null;
$content = file_get_contents($path);
if ($content === false) return null;
// Limit very long files
$lines = explode("\n", $content);
if (count($lines) > $maxLines) {
$content = implode("\n", array_slice($lines, 0, $maxLines));
$content .= "\n\n... [Truncated - " . (count($lines) - $maxLines) . " more lines]";
}
return $content;
}
// Helper to determine file category
function categorizeFile($filename)
{
$lower = strtolower($filename);
if (strpos($lower, 'nmap') !== false) return 'Port Scans';
if (strpos($lower, 'nuclei') !== false) return 'Vulnerability Findings';
if (strpos($lower, 'nikto') !== false) return 'Web Server Analysis';
if (strpos($lower, 'whatweb') !== false) return 'Technology Detection';
if (strpos($lower, 'dns') !== false || strpos($lower, 'subdomain') !== false) return 'DNS & Subdomains';
if (strpos($lower, 'whois') !== false) return 'WHOIS Information';
if (strpos($lower, 'ssl') !== false || strpos($lower, 'cert') !== false) return 'SSL/TLS Analysis';
if (strpos($lower, 'dir') !== false || strpos($lower, 'brute') !== false) return 'Directory Discovery';
if (strpos($lower, 'host') !== false) return 'Host Information';
if (strpos($lower, 'osint') !== false) return 'OSINT Data';
if (strpos($lower, 'screenshot') !== false) return 'Screenshots';
return 'Other Findings';
}
// Recursively scan workspace
function scanWorkspaceFiles($dir, $prefix = '')
{
$files = [];
if (is_dir($dir)) {
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
if (is_dir($path)) {
$files = array_merge($files, scanWorkspace($path, $prefix . $item . '/'));
} else {
$files[] = [
'name' => $item,
'path' => $prefix . $item,
'size' => filesize($path),
'modified' => filemtime($path)
];
}
if (!is_dir($dir)) return $files;
$items = scandir($dir);
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item;
$relativePath = $prefix . $item;
if (is_dir($path)) {
$files = array_merge($files, scanWorkspaceFiles($path, $relativePath . '/'));
} else {
$ext = strtolower(pathinfo($item, PATHINFO_EXTENSION));
// Skip binary and image files for content reading
$skipExtensions = ['png', 'jpg', 'jpeg', 'gif', 'pdf', 'zip', 'tar', 'gz', 'exe', 'bin'];
$files[] = [
'path' => $relativePath,
'fullPath' => $path,
'name' => $item,
'size' => filesize($path),
'category' => categorizeFile($relativePath),
'extension' => $ext,
'isImage' => in_array($ext, ['png', 'jpg', 'jpeg', 'gif']),
'isBinary' => in_array($ext, $skipExtensions)
];
}
}
return $files;
}
$data['files'] = scanWorkspace($workspaceDir);
$allFiles = scanWorkspaceFiles($workspaceDir);
// Parse hosts from nmap directory
$nmapDir = $workspaceDir . '/nmap';
if (is_dir($nmapDir)) {
$nmapFiles = glob($nmapDir . '/*.nmap');
foreach ($nmapFiles as $file) {
$content = file_get_contents($file);
// Extract host info
if (preg_match('/Nmap scan report for (.+)/', $content, $matches)) {
$host = trim($matches[1]);
if (!in_array($host, $data['hosts'])) {
$data['hosts'][] = $host;
}
}
// Extract ports
if (preg_match_all('/(\d+)\/(tcp|udp)\s+open\s+(\S+)/', $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$data['ports'][] = [
'port' => $match[1],
'protocol' => $match[2],
'service' => $match[3]
];
}
}
// Group files by category
$categorized = [];
foreach ($allFiles as $file) {
$cat = $file['category'];
if (!isset($categorized[$cat])) {
$categorized[$cat] = [];
}
$categorized[$cat][] = $file;
}
// Get screenshots
$screenshotDir = $workspaceDir . '/screenshots';
if (is_dir($screenshotDir)) {
$screenshots = glob($screenshotDir . '/*.{png,jpg,jpeg,gif}', GLOB_BRACE);
foreach ($screenshots as $ss) {
$data['screenshots'][] = basename($ss);
}
}
// Parse vulnerabilities from output files
$outputDir = $workspaceDir . '/output';
if (is_dir($outputDir)) {
$vulnFiles = glob($outputDir . '/*nuclei*.txt');
foreach ($vulnFiles as $file) {
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '[') !== false && strpos($line, ']') !== false) {
$data['vulnerabilities'][] = trim($line);
}
}
}
}
// Unique ports
$data['ports'] = array_unique($data['ports'], SORT_REGULAR);
$data['vulnerabilities'] = array_unique($data['vulnerabilities']);
// Priority order for sections
$sectionOrder = [
'Host Information',
'Port Scans',
'Vulnerability Findings',
'Web Server Analysis',
'Technology Detection',
'SSL/TLS Analysis',
'DNS & Subdomains',
'Directory Discovery',
'WHOIS Information',
'OSINT Data',
'Screenshots',
'Other Findings'
];
// Generate unique ID for TOC
$sectionId = 0;
?>
<!DOCTYPE html>
<html lang="en">
@@ -122,20 +128,24 @@ $data['vulnerabilities'] = array_unique($data['vulnerabilities']);
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UltyScan Report - <?php echo htmlspecialchars($name); ?></title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-primary: #0a0e17;
--bg-secondary: #111827;
--bg-card: rgba(17, 24, 39, 0.9);
--bg-primary: #0f1419;
--bg-secondary: #1a1f2e;
--bg-tertiary: #242938;
--bg-code: #0d1117;
--accent-primary: #3b82f6;
--accent-secondary: #8b5cf6;
--accent-success: #10b981;
--accent-warning: #f59e0b;
--accent-danger: #ef4444;
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--border-color: rgba(75, 85, 99, 0.4);
--accent-info: #06b6d4;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--border-color: #30363d;
--border-accent: #388bfd;
}
* {
@@ -145,345 +155,516 @@ $data['vulnerabilities'] = array_unique($data['vulnerabilities']);
}
body {
font-family: 'Inter', sans-serif;
font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
padding: 2rem;
background-image:
radial-gradient(ellipse at 20% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 50%),
radial-gradient(ellipse at 80% 50%, rgba(139, 92, 246, 0.08) 0%, transparent 50%);
line-height: 1.6;
}
.container {
max-width: 1400px;
max-width: 1600px;
margin: 0 auto;
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.header {
text-align: center;
margin-bottom: 3rem;
padding: 2rem;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
/* Sidebar / Table of Contents */
.sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
padding: 1.5rem;
}
.header h1 {
font-size: 2.5rem;
.sidebar-header {
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 1rem;
}
.sidebar-header h1 {
font-size: 1.25rem;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.25rem;
}
.sidebar-header .meta {
font-size: 0.75rem;
color: var(--text-secondary);
}
.toc-section {
margin-bottom: 1rem;
}
.toc-title {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.5rem;
font-weight: 600;
}
.toc-link {
display: block;
padding: 0.4rem 0.75rem;
color: var(--text-secondary);
text-decoration: none;
border-radius: 6px;
font-size: 0.85rem;
transition: all 0.15s;
margin-bottom: 2px;
}
.toc-link:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.toc-link .count {
float: right;
font-size: 0.75rem;
background: var(--bg-tertiary);
padding: 0.1rem 0.4rem;
border-radius: 10px;
}
/* Main Content */
.main-content {
padding: 2rem 3rem;
overflow-x: hidden;
}
.report-header {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
}
.report-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.header .meta {
color: var(--text-secondary);
font-size: 0.9rem;
}
.stats {
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg-card);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 1.5rem;
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.stat-card .number {
font-size: 2.5rem;
font-size: 1.75rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-success));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
color: var(--accent-primary);
}
.stat-card .label {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.5rem;
}
/* Sections */
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 1.5rem;
margin-bottom: 1.5rem;
margin-bottom: 2.5rem;
scroll-margin-top: 1rem;
}
.section h2 {
font-size: 1.25rem;
.section-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
border-bottom: 2px solid var(--border-color);
}
.section-header h2 {
font-size: 1.25rem;
color: var(--text-primary);
}
.section-icon {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: center;
font-size: 0.9rem;
}
.section h2::before {
content: '';
width: 4px;
height: 20px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
border-radius: 2px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
th {
background: var(--bg-secondary);
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
}
tr:hover td {
background: rgba(59, 130, 246, 0.05);
}
.tag {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
}
.tag-info {
.section-icon.port {
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
}
.tag-warning {
background: rgba(245, 158, 11, 0.2);
color: #fbbf24;
}
.tag-danger {
.section-icon.vuln {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.tag-success {
.section-icon.web {
background: rgba(16, 185, 129, 0.2);
color: #34d399;
}
.vuln-item {
padding: 0.75rem;
margin-bottom: 0.5rem;
.section-icon.dns {
background: rgba(139, 92, 246, 0.2);
}
.section-icon.ssl {
background: rgba(245, 158, 11, 0.2);
}
.section-icon.other {
background: rgba(107, 114, 128, 0.2);
}
/* File Cards */
.file-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-family: monospace;
font-size: 0.85rem;
word-break: break-all;
margin-bottom: 1rem;
overflow: hidden;
}
.file-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.75rem;
}
.file-item {
.file-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
cursor: pointer;
}
.file-icon {
font-size: 1.5rem;
.file-header:hover {
background: #2d3548;
}
.file-name {
flex: 1;
word-break: break-all;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: var(--accent-info);
}
.file-size {
color: var(--text-secondary);
.file-meta {
font-size: 0.75rem;
color: var(--text-muted);
}
.file-content {
padding: 1rem;
max-height: 600px;
overflow-y: auto;
}
.file-content pre {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-primary);
margin: 0;
}
/* Syntax highlighting for common patterns */
.highlight-ip {
color: #79c0ff;
}
.highlight-port {
color: #a5d6ff;
}
.highlight-vuln {
color: #ffa657;
}
.highlight-critical {
color: #ff7b72;
}
.highlight-success {
color: #7ee787;
}
.highlight-info {
color: #a371f7;
}
/* Image section */
.screenshot-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.screenshot-item {
.screenshot-card {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
.screenshot-item img {
.screenshot-card img {
width: 100%;
height: 150px;
height: 200px;
object-fit: cover;
cursor: pointer;
transition: transform 0.2s;
}
.screenshot-item .caption {
padding: 0.5rem;
.screenshot-card img:hover {
transform: scale(1.02);
}
.screenshot-caption {
padding: 0.75rem;
font-size: 0.8rem;
color: var(--text-secondary);
text-align: center;
background: var(--bg-tertiary);
}
.empty-state {
/* Empty state */
.empty-section {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
color: var(--text-muted);
font-style: italic;
}
/* Print styles */
@media print {
.sidebar {
display: none;
}
.container {
display: block;
}
body {
background: white;
color: black;
}
.section {
border: 1px solid #ddd;
.file-card {
break-inside: avoid;
}
}
/* Collapsible */
.collapsed .file-content {
display: none;
}
.toggle-icon {
transition: transform 0.2s;
}
.collapsed .toggle-icon {
transform: rotate(-90deg);
}
/* Actions */
.actions {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.15s;
}
.btn-primary {
background: var(--accent-primary);
color: white;
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<h1>🔍 <?php echo htmlspecialchars($name); ?></h1>
<p class="meta">
Created: <?php echo $data['created']; ?> |
Modified: <?php echo $data['modified']; ?>
</p>
</header>
<!-- Sidebar / Table of Contents -->
<aside class="sidebar">
<div class="sidebar-header">
<h1>🔍 UltyScan Report</h1>
<div class="meta"><?php echo htmlspecialchars($name); ?></div>
<div class="meta"><?php echo $report['modified']; ?></div>
</div>
<div class="stats">
<div class="stat-card">
<div class="number"><?php echo count($data['hosts']); ?></div>
<div class="label">Hosts Discovered</div>
</div>
<div class="stat-card">
<div class="number"><?php echo count($data['ports']); ?></div>
<div class="label">Open Ports</div>
</div>
<div class="stat-card">
<div class="number"><?php echo count($data['vulnerabilities']); ?></div>
<div class="label">Findings</div>
</div>
<div class="stat-card">
<div class="number"><?php echo count($data['files']); ?></div>
<div class="label">Files Generated</div>
</div>
</div>
<?php if (!empty($data['hosts'])): ?>
<div class="section">
<h2>Discovered Hosts</h2>
<table>
<thead>
<tr>
<th>Host</th>
</tr>
</thead>
<tbody>
<?php foreach ($data['hosts'] as $host): ?>
<tr>
<td><?php echo htmlspecialchars($host); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if (!empty($data['ports'])): ?>
<div class="section">
<h2>Open Ports</h2>
<table>
<thead>
<tr>
<th>Port</th>
<th>Protocol</th>
<th>Service</th>
</tr>
</thead>
<tbody>
<?php foreach ($data['ports'] as $port): ?>
<tr>
<td><span class="tag tag-info"><?php echo htmlspecialchars($port['port']); ?></span></td>
<td><?php echo htmlspecialchars($port['protocol']); ?></td>
<td><?php echo htmlspecialchars($port['service']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if (!empty($data['vulnerabilities'])): ?>
<div class="section">
<h2>Vulnerability Findings</h2>
<?php foreach ($data['vulnerabilities'] as $vuln): ?>
<div class="vuln-item"><?php echo htmlspecialchars($vuln); ?></div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($data['screenshots'])): ?>
<div class="section">
<h2>Screenshots</h2>
<div class="screenshot-grid">
<?php foreach ($data['screenshots'] as $ss): ?>
<div class="screenshot-item">
<img src="/loot/workspace/<?php echo urlencode($name); ?>/screenshots/<?php echo urlencode($ss); ?>" alt="<?php echo htmlspecialchars($ss); ?>">
<div class="caption"><?php echo htmlspecialchars($ss); ?></div>
</div>
<nav class="toc">
<div class="toc-section">
<div class="toc-title">Sections</div>
<?php foreach ($sectionOrder as $section): ?>
<?php if (isset($categorized[$section])): ?>
<a href="#section-<?php echo $sectionId++; ?>" class="toc-link">
<?php echo $section; ?>
<span class="count"><?php echo count($categorized[$section]); ?></span>
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</nav>
</aside>
<div class="section">
<h2>Generated Files</h2>
<?php if (empty($data['files'])): ?>
<div class="empty-state">No files found in this workspace.</div>
<?php else: ?>
<div class="file-list">
<?php foreach ($data['files'] as $file): ?>
<div class="file-item">
<span class="file-icon">📄</span>
<span class="file-name"><?php echo htmlspecialchars($file['path']); ?></span>
<span class="file-size"><?php echo number_format($file['size'] / 1024, 1); ?> KB</span>
</div>
<?php endforeach; ?>
<!-- Main Content -->
<main class="main-content">
<header class="report-header">
<h1><?php echo htmlspecialchars($name); ?></h1>
<p style="color: var(--text-secondary);">
Generated: <?php echo $report['created']; ?> |
Last Modified: <?php echo $report['modified']; ?>
</p>
</header>
<div class="stats-grid">
<div class="stat-card">
<div class="number"><?php echo count($allFiles); ?></div>
<div class="label">Total Files</div>
</div>
<?php endif; ?>
</div>
<div class="stat-card">
<div class="number"><?php echo count($categorized); ?></div>
<div class="label">Categories</div>
</div>
<div class="stat-card">
<div class="number"><?php
$total = 0;
foreach ($allFiles as $f) $total += $f['size'];
echo number_format($total / 1024, 1);
?> KB</div>
<div class="label">Total Size</div>
</div>
</div>
<div class="actions">
<button class="btn btn-primary" onclick="expandAll()">Expand All</button>
<button class="btn btn-secondary" onclick="collapseAll()">Collapse All</button>
<button class="btn btn-secondary" onclick="window.print()">Print Report</button>
</div>
<?php
$sectionId = 0;
foreach ($sectionOrder as $section):
if (!isset($categorized[$section])) continue;
$files = $categorized[$section];
$iconClass = 'other';
if (strpos($section, 'Port') !== false) $iconClass = 'port';
elseif (strpos($section, 'Vuln') !== false) $iconClass = 'vuln';
elseif (strpos($section, 'Web') !== false) $iconClass = 'web';
elseif (strpos($section, 'DNS') !== false) $iconClass = 'dns';
elseif (strpos($section, 'SSL') !== false) $iconClass = 'ssl';
?>
<section class="section" id="section-<?php echo $sectionId++; ?>">
<div class="section-header">
<span class="section-icon <?php echo $iconClass; ?>">
<?php
echo match ($iconClass) {
'port' => '🔌',
'vuln' => '⚠️',
'web' => '🌐',
'dns' => '🔍',
'ssl' => '🔒',
default => '📄'
};
?>
</span>
<h2><?php echo $section; ?></h2>
</div>
<?php if ($section === 'Screenshots'): ?>
<div class="screenshot-grid">
<?php foreach ($files as $file): ?>
<?php if ($file['isImage']): ?>
<div class="screenshot-card">
<img src="/loot/workspace/<?php echo urlencode($name); ?>/<?php echo $file['path']; ?>"
alt="<?php echo htmlspecialchars($file['name']); ?>"
onclick="window.open(this.src, '_blank')">
<div class="screenshot-caption"><?php echo htmlspecialchars($file['name']); ?></div>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
<?php else: ?>
<?php foreach ($files as $file): ?>
<?php if ($file['isBinary']): continue;
endif; ?>
<?php $content = readFileContent($file['fullPath']); ?>
<?php if ($content === null || trim($content) === ''): continue;
endif; ?>
<div class="file-card">
<div class="file-header" onclick="this.parentElement.classList.toggle('collapsed')">
<div>
<span class="file-name"><?php echo htmlspecialchars($file['path']); ?></span>
</div>
<div class="file-meta">
<?php echo number_format($file['size'] / 1024, 1); ?> KB
<span class="toggle-icon">▼</span>
</div>
</div>
<div class="file-content">
<pre><?php echo htmlspecialchars($content); ?></pre>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</section>
<?php endforeach; ?>
</main>
</div>
<script>
function expandAll() {
document.querySelectorAll('.file-card').forEach(card => {
card.classList.remove('collapsed');
});
}
function collapseAll() {
document.querySelectorAll('.file-card').forEach(card => {
card.classList.add('collapsed');
});
}
// Start with files collapsed for faster loading
document.addEventListener('DOMContentLoaded', () => {
collapseAll();
});
</script>
</body>
</html>