← 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('');
});