← back to Project Portfolio Viewer
portfolio-viewer.ts
1227 lines
/**
* Project Portfolio Viewer
*
* Beautiful web interface to browse and explore all projects
* Port: 7899
*/
import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
const app = express();
const PORT = 7899;
const PROJECTS_DIR = '/root/Projects';
interface ProjectInfo {
name: string;
path: string;
displayName: string;
description: string;
type: string;
lastModified: Date;
size: number;
hasPackageJson: boolean;
hasIndexHtml: boolean;
technology: string[];
port?: number;
url?: string;
}
app.use(express.json());
// 404-guard: never serve .bak / .backup / .pre-* / .orig snapshots even if a
// static-root were added later. Fleet-wide standing rule (see CLAUDE.md).
app.use((req, res, next) => {
if (/\.(bak|backup|orig)(\.|$)|\/\.pre-|\.pre-[^/]*$/i.test(req.path)) {
res.status(404).send('Not Found');
return;
}
next();
});
// Scan projects and gather metadata
async function scanProjects(): Promise<ProjectInfo[]> {
const projects: ProjectInfo[] = [];
try {
const entries = await fs.readdir(PROJECTS_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.')) continue;
if (entry.name === 'project-portfolio-viewer') continue;
const projectPath = path.join(PROJECTS_DIR, entry.name);
const stats = await fs.stat(projectPath);
const port = await detectPort(projectPath, entry.name);
const url = port ? `http://45.61.58.125:${port}` : undefined;
const projectInfo: ProjectInfo = {
name: entry.name,
path: projectPath,
displayName: entry.name.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
description: await getProjectDescription(projectPath),
type: await getProjectType(projectPath),
lastModified: stats.mtime,
size: await getDirectorySize(projectPath),
hasPackageJson: await fileExists(path.join(projectPath, 'package.json')),
hasIndexHtml: await fileExists(path.join(projectPath, 'index.html')),
technology: await detectTechnology(projectPath),
port,
url
};
projects.push(projectInfo);
}
return projects.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
} catch (error) {
console.error('Error scanning projects:', error);
return [];
}
}
async function getProjectDescription(projectPath: string): Promise<string> {
const packageJsonPath = path.join(projectPath, 'package.json');
try {
const content = await fs.readFile(packageJsonPath, 'utf-8');
const pkg = JSON.parse(content);
return pkg.description || pkg.name || 'No description available';
} catch {
return 'No description available';
}
}
async function getProjectType(projectPath: string): Promise<string> {
const hasPackageJson = await fileExists(path.join(projectPath, 'package.json'));
const hasIndexHtml = await fileExists(path.join(projectPath, 'index.html'));
const hasApp = await fileExists(path.join(projectPath, 'app'));
if (hasApp && hasPackageJson) return 'Next.js App';
if (hasPackageJson) return 'Node.js Project';
if (hasIndexHtml) return 'Static Website';
return 'General Project';
}
async function detectTechnology(projectPath: string): Promise<string[]> {
const tech: string[] = [];
const packageJsonPath = path.join(projectPath, 'package.json');
try {
const content = await fs.readFile(packageJsonPath, 'utf-8');
const pkg = JSON.parse(content);
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (deps['next']) tech.push('Next.js');
if (deps['react']) tech.push('React');
if (deps['vue']) tech.push('Vue');
if (deps['express']) tech.push('Express');
if (deps['typescript']) tech.push('TypeScript');
if (deps['@capacitor/core']) tech.push('Capacitor');
if (deps['tailwindcss']) tech.push('Tailwind');
} catch {}
return tech;
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async function detectPort(projectPath: string, projectName: string): Promise<number | undefined> {
// Try package.json first
const packageJsonPath = path.join(projectPath, 'package.json');
try {
const content = await fs.readFile(packageJsonPath, 'utf-8');
const pkg = JSON.parse(content);
// Check for port in package.json
if (pkg.port) return pkg.port;
if (pkg.config?.port) return pkg.config.port;
} catch {}
// Try to find PORT in main files
const mainFiles = ['index.ts', 'server.ts', 'app.ts', 'index.js', 'server.js', 'app.js'];
for (const file of mainFiles) {
const filePath = path.join(projectPath, file);
try {
const content = await fs.readFile(filePath, 'utf-8');
// Look for PORT = NUMBER or .listen(NUMBER
const portMatch = content.match(/(?:PORT\s*=\s*|listen\((?:process\.env\.PORT\s*\|\|\s*)?)([\d]+)/);
if (portMatch && portMatch[1]) {
return parseInt(portMatch[1]);
}
} catch {}
}
return undefined;
}
async function getDirectorySize(dirPath: string): Promise<number> {
let size = 0;
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.name === 'node_modules') continue;
if (entry.name === '.next') continue;
if (entry.isDirectory()) {
size += await getDirectorySize(entryPath);
} else {
const stats = await fs.stat(entryPath);
size += stats.size;
}
}
} catch {}
return size;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
// Sort helper — applied server-side so a ?sort= query param works for both
// the HTML page and the JSON API.
function sortProjects(list: ProjectInfo[], sort: string): ProjectInfo[] {
const arr = list.slice();
switch (sort) {
case 'oldest':
return arr.sort((a, b) => a.lastModified.getTime() - b.lastModified.getTime());
case 'name-asc':
return arr.sort((a, b) => a.displayName.localeCompare(b.displayName));
case 'name-desc':
return arr.sort((a, b) => b.displayName.localeCompare(a.displayName));
case 'size-desc':
return arr.sort((a, b) => b.size - a.size);
case 'size-asc':
return arr.sort((a, b) => a.size - b.size);
case 'type':
return arr.sort((a, b) => a.type.localeCompare(b.type) || a.displayName.localeCompare(b.displayName));
case 'newest':
default:
return arr.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime());
}
}
// API endpoint
app.get('/api/projects', async (req, res) => {
const projects = await scanProjects();
const sort = typeof req.query.sort === 'string' ? req.query.sort : 'newest';
res.json(sortProjects(projects, sort));
});
// Main page
app.get('/', async (req, res) => {
const projectsRaw = await scanProjects();
const sort = typeof req.query.sort === 'string' ? req.query.sort : 'newest';
const projects = sortProjects(projectsRaw, sort);
// Add cache-busting headers
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Surrogate-Control', 'no-store');
const version = Date.now();
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Project Portfolio Viewer v2.0 - Mobile Optimized</title>
<style data-version="${version}">
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
background: rgba(255, 255, 255, 0.95);
padding: 30px 20px;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
margin-bottom: 20px;
text-align: center;
}
.header h1 {
font-size: 3em;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 10px;
}
.header p {
color: #666;
font-size: 1.2em;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.stat-card {
background: rgba(255, 255, 255, 0.95);
padding: 20px 15px;
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
text-align: center;
}
.stat-card .number {
font-size: 2.5em;
font-weight: bold;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.stat-card .label {
color: #666;
margin-top: 10px;
font-size: 1em;
}
.filters {
background: rgba(255, 255, 255, 0.95);
padding: 15px;
border-radius: 12px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.15);
margin-bottom: 20px;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.filter-btn {
background: #f0f0f0;
border: 2px solid transparent;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
font-size: 0.9em;
transition: all 0.3s;
}
.filter-btn:hover {
background: #e0e0e0;
}
.filter-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.search-box {
flex: 1;
min-width: 250px;
}
.search-box input {
width: 100%;
padding: 12px 20px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1em;
transition: all 0.3s;
}
.search-box input:focus {
outline: none;
border-color: #667eea;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 25px;
margin-bottom: 40px;
}
.project-card {
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
overflow: hidden;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
transition: all 0.3s;
cursor: pointer;
}
.project-card:hover {
transform: translateY(-10px);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
.project-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 25px;
color: white;
}
.project-title {
font-size: 1.5em;
font-weight: bold;
margin-bottom: 8px;
}
.project-type {
background: rgba(255, 255, 255, 0.2);
display: inline-block;
padding: 5px 12px;
border-radius: 12px;
font-size: 0.85em;
}
.project-body {
padding: 25px;
}
.project-description {
color: #666;
margin-bottom: 20px;
line-height: 1.6;
}
.project-tech {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.tech-badge {
background: #f0f0f0;
padding: 5px 12px;
border-radius: 12px;
font-size: 0.85em;
color: #667eea;
font-weight: 600;
}
.project-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
}
.meta-item {
display: flex;
align-items: center;
gap: 8px;
color: #666;
font-size: 0.9em;
}
.project-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.action-btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.btn-secondary:hover {
background: #e0e0e0;
}
@media (max-width: 768px) {
body {
padding: 4px;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
}
.header {
padding: 8px 10px;
margin-bottom: 8px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.98);
border-left: 4px solid #e74c3c;
}
.header h1 {
font-size: 1.2em;
margin-bottom: 2px;
color: #e74c3c;
}
.header h1::after {
content: " 📱";
font-size: 0.8em;
}
.header p {
font-size: 0.75em;
}
.stats {
grid-template-columns: repeat(2, 1fr);
gap: 4px;
margin-bottom: 8px;
}
.stat-card {
padding: 8px 4px;
background: rgba(255, 255, 255, 0.98);
}
.stat-card .number {
font-size: 1.4em;
color: #e74c3c;
font-weight: 700;
}
.stat-card .label {
font-size: 0.7em;
margin-top: 2px;
}
.filters {
padding: 8px;
gap: 4px;
margin-bottom: 8px;
display: block;
background: rgba(255, 255, 255, 0.98);
}
.filter-btn {
display: inline-block;
padding: 4px 8px;
font-size: 0.75em;
margin: 2px;
background: #e74c3c;
color: white;
border: 1px solid #c0392b;
}
.filter-btn.active {
background: #c0392b;
}
.search-box {
width: 100%;
margin-top: 8px;
min-width: 100%;
}
.search-box input {
width: 100%;
padding: 12px 15px;
font-size: 16px;
border-radius: 8px;
-webkit-appearance: none;
border: 2px solid #e74c3c;
background: white;
}
.search-box input:focus {
outline: none;
border-color: #c0392b;
box-shadow: 0 0 0 3px rgba(231, 76, 60, 0.2);
}
.projects-grid {
grid-template-columns: 1fr;
gap: 8px;
}
.project-card {
border-radius: 8px;
border: 2px solid rgba(231, 76, 60, 0.3);
}
.project-header {
padding: 10px;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
}
.project-title {
font-size: 1.1em;
color: white;
}
.project-type {
font-size: 0.7em;
padding: 3px 6px;
background: rgba(255, 255, 255, 0.3);
color: white;
}
.project-body {
padding: 10px;
}
.project-description {
font-size: 0.85em;
margin-bottom: 12px;
}
.tech-badge {
font-size: 0.75em;
padding: 4px 10px;
}
.meta-item {
font-size: 0.8em;
}
.action-btn {
padding: 10px;
font-size: 0.9em;
}
.controls {
flex-direction: column;
width: 100%;
gap: 8px;
}
.btn {
width: 100%;
padding: 10px;
font-size: 0.9em;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 Project Portfolio</h1>
<p>Browse and explore all your projects <small style="opacity: 0.6;">v${version}</small></p>
</div>
<div class="stats">
<div class="stat-card">
<div class="number">${projects.length}</div>
<div class="label">Total Projects</div>
</div>
<div class="stat-card">
<div class="number">${projects.filter(p => p.type.includes('Next.js')).length}</div>
<div class="label">Next.js Apps</div>
</div>
<div class="stat-card">
<div class="number">${projects.filter(p => p.hasPackageJson).length}</div>
<div class="label">Node Projects</div>
</div>
<div class="stat-card">
<div class="number">${projects.filter(p => p.hasIndexHtml && !p.hasPackageJson).length}</div>
<div class="label">Static Sites</div>
</div>
</div>
<div class="filters">
<button class="filter-btn active" onclick="filterProjects('all')">All Projects</button>
<button class="filter-btn" onclick="filterProjects('nextjs')">Next.js</button>
<button class="filter-btn" onclick="filterProjects('nodejs')">Node.js</button>
<button class="filter-btn" onclick="filterProjects('static')">Static</button>
<label style="display:flex;align-items:center;gap:6px;font-size:.85em;color:#555;">
Sort
<select id="sortSelect" style="padding:6px 10px;border:1px solid #ddd;border-radius:6px;font-size:.9em;">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="name-asc">Name A→Z</option>
<option value="name-desc">Name Z→A</option>
<option value="type">Type</option>
<option value="size-desc">Size ↓</option>
<option value="size-asc">Size ↑</option>
</select>
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:.85em;color:#555;">
Density
<input id="densityRange" type="range" min="200" max="500" step="10" value="350" style="width:120px;">
</label>
<div class="search-box">
<input type="text" id="searchInput" placeholder="🔍 Search projects..." oninput="searchProjects()">
</div>
</div>
<div class="projects-grid" id="projectsGrid">
${projects.map(project => `
<div class="project-card" data-type="${project.type}" data-name="${project.name.toLowerCase()}">
<div class="project-header">
<div class="project-title">${project.displayName}</div>
<span class="project-type">${project.type}</span>
</div>
<div class="project-body">
<div class="project-description">${project.description}</div>
${project.technology.length > 0 ? `
<div class="project-tech">
${project.technology.map(tech => `<span class="tech-badge">${tech}</span>`).join('')}
</div>
` : ''}
<div class="project-meta">
<div class="meta-item">
📅 ${new Date(project.lastModified).toLocaleDateString()}
</div>
<div class="meta-item">
💾 ${formatBytes(project.size)}
</div>
</div>
<div class="project-actions">
${project.url ? `<button class="action-btn btn-primary" onclick="window.open('${project.url}', '_blank', 'noopener,noreferrer')">
🚀 Open Project ${project.port ? `(Port ${project.port})` : ''}
</button>` : '<button class="action-btn" disabled style="opacity: 0.5;">No URL Found</button>'}
<button class="action-btn btn-secondary" onclick="viewDetails('${project.name}')">
Details
</button>
</div>
</div>
</div>
`).join('')}
</div>
</div>
<script>
// Hydrate sort + density from localStorage BEFORE first paint settles.
(function hydrateControls() {
try {
var LS_SORT = 'ppv.sort';
var LS_DENS = 'ppv.density';
var qs = new URLSearchParams(location.search);
var serverSort = qs.get('sort') || 'newest';
var savedSort = localStorage.getItem(LS_SORT);
var sortSel = document.getElementById('sortSelect');
if (sortSel) sortSel.value = savedSort || serverSort;
// If saved sort differs from what server rendered, reload once with it.
if (savedSort && savedSort !== serverSort) {
qs.set('sort', savedSort);
location.replace(location.pathname + '?' + qs.toString());
return;
}
var savedDens = parseInt(localStorage.getItem(LS_DENS) || '0', 10);
var grid = document.getElementById('projectsGrid');
var rng = document.getElementById('densityRange');
if (savedDens && grid) {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(' + savedDens + 'px, 1fr))';
if (rng) rng.value = String(savedDens);
}
if (sortSel) {
sortSel.addEventListener('change', function () {
localStorage.setItem(LS_SORT, sortSel.value);
var q = new URLSearchParams(location.search);
q.set('sort', sortSel.value);
location.href = location.pathname + '?' + q.toString();
});
}
if (rng && grid) {
rng.addEventListener('input', function () {
var v = parseInt(rng.value, 10);
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(' + v + 'px, 1fr))';
localStorage.setItem(LS_DENS, String(v));
});
}
} catch (e) { /* localStorage disabled — controls still work in-session */ }
})();
function filterProjects(type) {
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
event.target.classList.add('active');
const cards = document.querySelectorAll('.project-card');
cards.forEach(card => {
const cardType = card.dataset.type.toLowerCase();
if (type === 'all') {
card.style.display = 'block';
} else if (type === 'nextjs' && cardType.includes('next.js')) {
card.style.display = 'block';
} else if (type === 'nodejs' && cardType.includes('node.js')) {
card.style.display = 'block';
} else if (type === 'static' && cardType.includes('static')) {
card.style.display = 'block';
} else {
card.style.display = 'none';
}
});
}
function searchProjects() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const cards = document.querySelectorAll('.project-card');
cards.forEach(card => {
const name = card.dataset.name;
if (name.includes(searchTerm)) {
card.style.display = 'block';
} else {
card.style.display = 'none';
}
});
}
function viewDetails(name) {
window.location.href = '/project/' + name;
}
</script>
</body>
</html>
`);
});
// Individual project details page
app.get('/project/:name', async (req, res) => {
const projectName = req.params.name;
const projects = await scanProjects();
const project = projects.find(p => p.name === projectName);
if (!project) {
res.status(404).send('Project not found');
return;
}
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${project.displayName} - Project Details</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 40px 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.back-btn {
display: inline-block;
padding: 10px 20px;
background: #f0f0f0;
border-radius: 8px;
text-decoration: none;
color: #333;
margin-bottom: 30px;
transition: all 0.3s;
}
.back-btn:hover {
background: #e0e0e0;
}
h1 {
font-size: 2.5em;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 20px;
}
.detail-grid {
display: grid;
grid-template-columns: 200px 1fr;
gap: 20px;
margin-top: 30px;
}
.detail-label {
font-weight: bold;
color: #667eea;
}
.detail-value {
color: #666;
}
.tech-list {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.tech-badge {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 8px 16px;
border-radius: 12px;
font-weight: 600;
}
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-btn">← Back to Projects</a>
<h1>${project.displayName}</h1>
<div class="detail-grid">
<div class="detail-label">Project Name:</div>
<div class="detail-value">${project.name}</div>
<div class="detail-label">Type:</div>
<div class="detail-value">${project.type}</div>
<div class="detail-label">Description:</div>
<div class="detail-value">${project.description}</div>
<div class="detail-label">Path:</div>
<div class="detail-value">${project.path}</div>
<div class="detail-label">Last Modified:</div>
<div class="detail-value">${new Date(project.lastModified).toLocaleString()}</div>
<div class="detail-label">Size:</div>
<div class="detail-value">${formatBytes(project.size)}</div>
<div class="detail-label">Technologies:</div>
<div class="tech-list">
${project.technology.length > 0
? project.technology.map(tech => `<span class="tech-badge">${tech}</span>`).join('')
: '<span class="detail-value">None detected</span>'}
</div>
<div class="detail-label">Has package.json:</div>
<div class="detail-value">${project.hasPackageJson ? '✅ Yes' : '❌ No'}</div>
<div class="detail-label">Has index.html:</div>
<div class="detail-value">${project.hasIndexHtml ? '✅ Yes' : '❌ No'}</div>
</div>
</div>
</body>
</html>
`);
});
// Health check
// Mobile-specific endpoint - NEW URL to bypass cache completely
app.get('/mobile', async (req, res) => {
const projects = await scanProjects();
// Add cache-busting headers
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Surrogate-Control', 'no-store');
const version = Date.now();
// Force mobile view by directly applying mobile styles (no media query)
res.send(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Project Portfolio - Mobile View v${version}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
min-height: 100vh;
padding: 4px;
}
.header {
background: rgba(255, 255, 255, 0.98);
padding: 8px 10px;
margin-bottom: 8px;
border-radius: 8px;
border-left: 4px solid #e74c3c;
text-align: center;
}
.header h1 {
font-size: 1.2em;
margin-bottom: 2px;
color: #e74c3c;
}
.header p {
font-size: 0.75em;
color: #666;
}
.notice {
background: #fff3cd;
border: 2px solid #ffc107;
padding: 12px;
margin-bottom: 10px;
border-radius: 8px;
text-align: center;
font-size: 0.9em;
}
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
margin-bottom: 8px;
}
.stat-card {
background: rgba(255, 255, 255, 0.98);
padding: 8px 4px;
border-radius: 8px;
text-align: center;
}
.stat-card .number {
font-size: 1.4em;
font-weight: 700;
color: #e74c3c;
}
.stat-card .label {
font-size: 0.7em;
color: #666;
margin-top: 2px;
}
.filters {
background: rgba(255, 255, 255, 0.98);
padding: 8px;
margin-bottom: 8px;
border-radius: 8px;
}
.filter-btn {
display: inline-block;
padding: 4px 8px;
margin: 2px;
background: #e74c3c;
color: white;
border: 1px solid #c0392b;
border-radius: 6px;
cursor: pointer;
font-size: 0.75em;
}
.filter-btn.active {
background: #c0392b;
}
.search-box {
width: 100%;
margin-top: 8px;
}
.search-box input {
width: 100%;
padding: 12px 15px;
font-size: 16px;
border: 2px solid #e74c3c;
border-radius: 8px;
background: white;
}
.projects-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.project-card {
background: white;
border-radius: 8px;
overflow: hidden;
border: 2px solid rgba(231, 76, 60, 0.3);
}
.project-header {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
padding: 10px;
color: white;
}
.project-title {
font-size: 1.1em;
font-weight: 600;
margin-bottom: 4px;
}
.project-type {
display: inline-block;
background: rgba(255, 255, 255, 0.3);
padding: 3px 6px;
border-radius: 4px;
font-size: 0.7em;
}
.project-body {
padding: 10px;
}
.project-description {
font-size: 0.85em;
color: #666;
margin-bottom: 8px;
}
.tech-badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.tech-badge {
background: #f0f0f0;
padding: 3px 8px;
border-radius: 4px;
font-size: 0.7em;
}
.project-meta {
font-size: 0.75em;
color: #999;
margin-bottom: 8px;
}
.action-btn {
background: #e74c3c;
color: white;
padding: 10px;
border-radius: 6px;
text-align: center;
font-size: 0.9em;
font-weight: 600;
}
</style>
</head>
<body>
<div class="header">
<h1>📱 Project Portfolio MOBILE</h1>
<p>v${version}</p>
</div>
<div class="notice">
🎯 This is the MOBILE-OPTIMIZED view with RED theme
</div>
<div class="stats">
<div class="stat-card">
<div class="number">${projects.length}</div>
<div class="label">Total Projects</div>
</div>
<div class="stat-card">
<div class="number">${projects.filter((p: any) => p.hasPackageJson).length}</div>
<div class="label">Node.js Apps</div>
</div>
</div>
<div class="filters">
<button class="filter-btn active">All</button>
<button class="filter-btn">Node.js</button>
<button class="filter-btn">HTML</button>
<div class="search-box">
<input type="text" placeholder="Search projects...">
</div>
</div>
<div class="projects-grid">
${projects.slice(0, 5).map((p: any) => `
<div class="project-card">
<div class="project-header">
<div class="project-title">${p.displayName}</div>
<span class="project-type">${p.type}</span>
</div>
<div class="project-body">
<div class="project-description">${p.description || 'No description'}</div>
<div class="tech-badges">
${p.technology.map((t: string) => `<span class="tech-badge">${t}</span>`).join('')}
</div>
<div class="project-meta">
Last modified: ${new Date(p.lastModified).toLocaleDateString()}
</div>
<div class="action-btn">View Details</div>
</div>
</div>
`).join('')}
</div>
<div class="notice" style="margin-top: 10px;">
If you see RED background and this notice, the mobile view is working! ✅
</div>
</body>
</html>
`);
});
app.get('/api/health', (req, res) => {
res.json({
status: 'online',
port: PORT,
uptime: process.uptime(),
projectsDir: PROJECTS_DIR
});
});
app.listen(PORT, '127.0.0.1', () => {
console.log('');
console.log('🚀 Project Portfolio Viewer');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`🌍 External: http://45.61.58.125:${PORT}`);
console.log(`🏠 Local: http://localhost:${PORT}`);
console.log('');
console.log(`📁 Projects Directory: ${PROJECTS_DIR}`);
console.log('✅ Portfolio viewer ready...');
console.log('');
});