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 <?php
/** /**
* UltyScan Web Interface - Workspace Report Viewer * UltyScan Web Interface - Comprehensive Report Viewer
* Generates a beautiful HTML report for a workspace * Compiles all workspace files into a single readable HTML report
*/ */
$name = preg_replace('/[^a-zA-Z0-9\-\_\.]/', '', $_GET['name'] ?? ''); $name = preg_replace('/[^a-zA-Z0-9\-\_\.]/', '', $_GET['name'] ?? '');
@@ -15,105 +15,111 @@ if (!is_dir($workspaceDir)) {
die('Workspace not found'); die('Workspace not found');
} }
// Check for existing sniper report // Collect all data from workspace
$sniperReport = $workspaceDir . '/sniper-report.html'; $report = [
if (file_exists($sniperReport)) {
// Redirect to the existing report
header('Location: /loot/workspace/' . urlencode($name) . '/sniper-report.html');
exit;
}
// Collect workspace data
$data = [
'name' => $name, 'name' => $name,
'created' => date('Y-m-d H:i:s', filectime($workspaceDir)), 'created' => date('Y-m-d H:i:s', filectime($workspaceDir)),
'modified' => date('Y-m-d H:i:s', filemtime($workspaceDir)), 'modified' => date('Y-m-d H:i:s', filemtime($workspaceDir)),
'hosts' => [], 'sections' => []
'ports' => [],
'vulnerabilities' => [],
'screenshots' => [],
'files' => []
]; ];
// Get all files in workspace // Helper function to read file safely
function scanWorkspace($dir, $prefix = '') 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 = []; $files = [];
if (is_dir($dir)) { if (!is_dir($dir)) return $files;
$items = scandir($dir); $items = scandir($dir);
foreach ($items as $item) { foreach ($items as $item) {
if ($item === '.' || $item === '..') continue; if ($item === '.' || $item === '..') continue;
$path = $dir . '/' . $item; $path = $dir . '/' . $item;
$relativePath = $prefix . $item;
if (is_dir($path)) { if (is_dir($path)) {
$files = array_merge($files, scanWorkspace($path, $prefix . $item . '/')); $files = array_merge($files, scanWorkspaceFiles($path, $relativePath . '/'));
} else { } 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[] = [ $files[] = [
'path' => $relativePath,
'fullPath' => $path,
'name' => $item, 'name' => $item,
'path' => $prefix . $item,
'size' => filesize($path), 'size' => filesize($path),
'modified' => filemtime($path) 'category' => categorizeFile($relativePath),
'extension' => $ext,
'isImage' => in_array($ext, ['png', 'jpg', 'jpeg', 'gif']),
'isBinary' => in_array($ext, $skipExtensions)
]; ];
} }
} }
}
return $files; return $files;
} }
$data['files'] = scanWorkspace($workspaceDir); $allFiles = scanWorkspaceFiles($workspaceDir);
// Parse hosts from nmap directory // Group files by category
$nmapDir = $workspaceDir . '/nmap'; $categorized = [];
if (is_dir($nmapDir)) { foreach ($allFiles as $file) {
$nmapFiles = glob($nmapDir . '/*.nmap'); $cat = $file['category'];
foreach ($nmapFiles as $file) { if (!isset($categorized[$cat])) {
$content = file_get_contents($file); $categorized[$cat] = [];
// 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]
];
}
}
} }
$categorized[$cat][] = $file;
} }
// Get screenshots // Priority order for sections
$screenshotDir = $workspaceDir . '/screenshots'; $sectionOrder = [
if (is_dir($screenshotDir)) { 'Host Information',
$screenshots = glob($screenshotDir . '/*.{png,jpg,jpeg,gif}', GLOB_BRACE); 'Port Scans',
foreach ($screenshots as $ss) { 'Vulnerability Findings',
$data['screenshots'][] = basename($ss); 'Web Server Analysis',
} 'Technology Detection',
} 'SSL/TLS Analysis',
'DNS & Subdomains',
// Parse vulnerabilities from output files 'Directory Discovery',
$outputDir = $workspaceDir . '/output'; 'WHOIS Information',
if (is_dir($outputDir)) { 'OSINT Data',
$vulnFiles = glob($outputDir . '/*nuclei*.txt'); 'Screenshots',
foreach ($vulnFiles as $file) { 'Other Findings'
$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']);
// Generate unique ID for TOC
$sectionId = 0;
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -122,20 +128,24 @@ $data['vulnerabilities'] = array_unique($data['vulnerabilities']);
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UltyScan Report - <?php echo htmlspecialchars($name); ?></title> <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> <style>
:root { :root {
--bg-primary: #0a0e17; --bg-primary: #0f1419;
--bg-secondary: #111827; --bg-secondary: #1a1f2e;
--bg-card: rgba(17, 24, 39, 0.9); --bg-tertiary: #242938;
--bg-code: #0d1117;
--accent-primary: #3b82f6; --accent-primary: #3b82f6;
--accent-secondary: #8b5cf6; --accent-secondary: #8b5cf6;
--accent-success: #10b981; --accent-success: #10b981;
--accent-warning: #f59e0b; --accent-warning: #f59e0b;
--accent-danger: #ef4444; --accent-danger: #ef4444;
--text-primary: #f3f4f6; --accent-info: #06b6d4;
--text-secondary: #9ca3af; --text-primary: #e6edf3;
--border-color: rgba(75, 85, 99, 0.4); --text-secondary: #8b949e;
--text-muted: #6e7681;
--border-color: #30363d;
--border-accent: #388bfd;
} }
* { * {
@@ -145,345 +155,516 @@ $data['vulnerabilities'] = array_unique($data['vulnerabilities']);
} }
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', -apple-system, sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-primary); color: var(--text-primary);
min-height: 100vh; line-height: 1.6;
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%);
} }
.container { .container {
max-width: 1400px; max-width: 1600px;
margin: 0 auto; margin: 0 auto;
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
} }
.header { /* Sidebar / Table of Contents */
text-align: center; .sidebar {
margin-bottom: 3rem; position: sticky;
padding: 2rem; top: 0;
background: var(--bg-card); height: 100vh;
border: 1px solid var(--border-color); overflow-y: auto;
border-radius: 16px; background: var(--bg-secondary);
border-right: 1px solid var(--border-color);
padding: 1.5rem;
} }
.header h1 { .sidebar-header {
font-size: 2.5rem; 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)); background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -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; margin-bottom: 0.5rem;
} }
.header .meta { .stats-grid {
color: var(--text-secondary);
font-size: 0.9rem;
}
.stats {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1.5rem; gap: 1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.stat-card { .stat-card {
background: var(--bg-card); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 8px;
padding: 1.5rem; padding: 1rem;
text-align: center; text-align: center;
} }
.stat-card .number { .stat-card .number {
font-size: 2.5rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-success)); color: var(--accent-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
} }
.stat-card .label { .stat-card .label {
font-size: 0.8rem;
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 0.5rem;
} }
/* Sections */
.section { .section {
background: var(--bg-card); margin-bottom: 2.5rem;
border: 1px solid var(--border-color); scroll-margin-top: 1rem;
border-radius: 16px;
padding: 1.5rem;
margin-bottom: 1.5rem;
} }
.section h2 { .section-header {
font-size: 1.25rem; display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 0.75rem; 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; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; justify-content: center;
font-size: 0.9rem;
} }
.section h2::before { .section-icon.port {
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 {
background: rgba(59, 130, 246, 0.2); background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
} }
.tag-warning { .section-icon.vuln {
background: rgba(245, 158, 11, 0.2);
color: #fbbf24;
}
.tag-danger {
background: rgba(239, 68, 68, 0.2); background: rgba(239, 68, 68, 0.2);
color: #f87171;
} }
.tag-success { .section-icon.web {
background: rgba(16, 185, 129, 0.2); background: rgba(16, 185, 129, 0.2);
color: #34d399;
} }
.vuln-item { .section-icon.dns {
padding: 0.75rem; background: rgba(139, 92, 246, 0.2);
margin-bottom: 0.5rem; }
.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); background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
font-family: monospace; margin-bottom: 1rem;
font-size: 0.85rem; overflow: hidden;
word-break: break-all;
} }
.file-list { .file-header {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.75rem;
}
.file-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; justify-content: space-between;
padding: 0.75rem; padding: 0.75rem 1rem;
background: var(--bg-secondary); background: var(--bg-tertiary);
border-radius: 8px; border-bottom: 1px solid var(--border-color);
cursor: pointer;
} }
.file-icon { .file-header:hover {
font-size: 1.5rem; background: #2d3548;
} }
.file-name { .file-name {
flex: 1; font-family: 'JetBrains Mono', monospace;
word-break: break-all; font-size: 0.85rem;
color: var(--accent-info);
} }
.file-size { .file-meta {
color: var(--text-secondary); 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; 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 { .screenshot-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem; gap: 1rem;
} }
.screenshot-item { .screenshot-card {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
} }
.screenshot-item img { .screenshot-card img {
width: 100%; width: 100%;
height: 150px; height: 200px;
object-fit: cover; object-fit: cover;
cursor: pointer;
transition: transform 0.2s;
} }
.screenshot-item .caption { .screenshot-card img:hover {
padding: 0.5rem; transform: scale(1.02);
}
.screenshot-caption {
padding: 0.75rem;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--text-secondary); color: var(--text-secondary);
text-align: center; background: var(--bg-tertiary);
} }
.empty-state { /* Empty state */
.empty-section {
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
color: var(--text-secondary); color: var(--text-muted);
font-style: italic;
} }
/* Print styles */
@media print { @media print {
.sidebar {
display: none;
}
.container {
display: block;
}
body { body {
background: white; background: white;
color: black; color: black;
} }
.section { .file-card {
border: 1px solid #ddd; 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> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header class="header"> <!-- Sidebar / Table of Contents -->
<h1>🔍 <?php echo htmlspecialchars($name); ?></h1> <aside class="sidebar">
<p class="meta"> <div class="sidebar-header">
Created: <?php echo $data['created']; ?> | <h1>🔍 UltyScan Report</h1>
Modified: <?php echo $data['modified']; ?> <div class="meta"><?php echo htmlspecialchars($name); ?></div>
<div class="meta"><?php echo $report['modified']; ?></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>
</nav>
</aside>
<!-- 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> </p>
</header> </header>
<div class="stats"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
<div class="number"><?php echo count($data['hosts']); ?></div> <div class="number"><?php echo count($allFiles); ?></div>
<div class="label">Hosts Discovered</div> <div class="label">Total Files</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="number"><?php echo count($data['ports']); ?></div> <div class="number"><?php echo count($categorized); ?></div>
<div class="label">Open Ports</div> <div class="label">Categories</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="number"><?php echo count($data['vulnerabilities']); ?></div> <div class="number"><?php
<div class="label">Findings</div> $total = 0;
</div> foreach ($allFiles as $f) $total += $f['size'];
<div class="stat-card"> echo number_format($total / 1024, 1);
<div class="number"><?php echo count($data['files']); ?></div> ?> KB</div>
<div class="label">Files Generated</div> <div class="label">Total Size</div>
</div> </div>
</div> </div>
<?php if (!empty($data['hosts'])): ?> <div class="actions">
<div class="section"> <button class="btn btn-primary" onclick="expandAll()">Expand All</button>
<h2>Discovered Hosts</h2> <button class="btn btn-secondary" onclick="collapseAll()">Collapse All</button>
<table> <button class="btn btn-secondary" onclick="window.print()">Print Report</button>
<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> </div>
<?php endif; ?>
<?php if (!empty($data['ports'])): ?> <?php
<div class="section"> $sectionId = 0;
<h2>Open Ports</h2> foreach ($sectionOrder as $section):
<table> if (!isset($categorized[$section])) continue;
<thead> $files = $categorized[$section];
<tr> $iconClass = 'other';
<th>Port</th> if (strpos($section, 'Port') !== false) $iconClass = 'port';
<th>Protocol</th> elseif (strpos($section, 'Vuln') !== false) $iconClass = 'vuln';
<th>Service</th> elseif (strpos($section, 'Web') !== false) $iconClass = 'web';
</tr> elseif (strpos($section, 'DNS') !== false) $iconClass = 'dns';
</thead> elseif (strpos($section, 'SSL') !== false) $iconClass = 'ssl';
<tbody> ?>
<?php foreach ($data['ports'] as $port): ?> <section class="section" id="section-<?php echo $sectionId++; ?>">
<tr> <div class="section-header">
<td><span class="tag tag-info"><?php echo htmlspecialchars($port['port']); ?></span></td> <span class="section-icon <?php echo $iconClass; ?>">
<td><?php echo htmlspecialchars($port['protocol']); ?></td> <?php
<td><?php echo htmlspecialchars($port['service']); ?></td> echo match ($iconClass) {
</tr> 'port' => '🔌',
<?php endforeach; ?> 'vuln' => '⚠️',
</tbody> 'web' => '🌐',
</table> 'dns' => '🔍',
'ssl' => '🔒',
default => '📄'
};
?>
</span>
<h2><?php echo $section; ?></h2>
</div> </div>
<?php endif; ?>
<?php if (!empty($data['vulnerabilities'])): ?> <?php if ($section === 'Screenshots'): ?>
<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"> <div class="screenshot-grid">
<?php foreach ($data['screenshots'] as $ss): ?> <?php foreach ($files as $file): ?>
<div class="screenshot-item"> <?php if ($file['isImage']): ?>
<img src="/loot/workspace/<?php echo urlencode($name); ?>/screenshots/<?php echo urlencode($ss); ?>" alt="<?php echo htmlspecialchars($ss); ?>"> <div class="screenshot-card">
<div class="caption"><?php echo htmlspecialchars($ss); ?></div> <img src="/loot/workspace/<?php echo urlencode($name); ?>/<?php echo $file['path']; ?>"
</div> alt="<?php echo htmlspecialchars($file['name']); ?>"
<?php endforeach; ?> onclick="window.open(this.src, '_blank')">
</div> <div class="screenshot-caption"><?php echo htmlspecialchars($file['name']); ?></div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php endforeach; ?>
<div class="section"> </div>
<h2>Generated Files</h2>
<?php if (empty($data['files'])): ?>
<div class="empty-state">No files found in this workspace.</div>
<?php else: ?> <?php else: ?>
<div class="file-list"> <?php foreach ($files as $file): ?>
<?php foreach ($data['files'] as $file): ?> <?php if ($file['isBinary']): continue;
<div class="file-item"> endif; ?>
<span class="file-icon">📄</span> <?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> <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>
<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> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div>
<?php endif; ?> <?php endif; ?>
</section>
<?php endforeach; ?>
</main>
</div> </div>
</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> </body>
</html> </html>