[object Object]

← back to Project Portfolio Viewer

add sort+density controls, .bak/.pre- 404 middleware, noopener noreferrer

c9c5cf212673ac42d37bde472854a0d34deed82c · 2026-05-19 15:14:54 -0700 · Steve Abrams

- /api/projects and / now honor ?sort= query param via sortProjects()
- sort <select> (newest/oldest/name/type/size) + density <input range>
  added to filters bar, both persist to localStorage (ppv.sort/ppv.density)
  and hydrate BEFORE first render
- middleware 404-guard rejects any path matching .bak/.backup/.orig/.pre-
- window.open Open-Project button passes 'noopener,noreferrer' features

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit c9c5cf212673ac42d37bde472854a0d34deed82c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 19 15:14:54 2026 -0700

    add sort+density controls, .bak/.pre- 404 middleware, noopener noreferrer
    
    - /api/projects and / now honor ?sort= query param via sortProjects()
    - sort <select> (newest/oldest/name/type/size) + density <input range>
      added to filters bar, both persist to localStorage (ppv.sort/ppv.density)
      and hydrate BEFORE first render
    - middleware 404-guard rejects any path matching .bak/.backup/.orig/.pre-
    - window.open Open-Project button passes 'noopener,noreferrer' features
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 portfolio-viewer.ts | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 96 insertions(+), 3 deletions(-)

diff --git a/portfolio-viewer.ts b/portfolio-viewer.ts
index a4dfc31..dcf89b1 100644
--- a/portfolio-viewer.ts
+++ b/portfolio-viewer.ts
@@ -30,6 +30,16 @@ interface ProjectInfo {
 
 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[] = [];
@@ -184,15 +194,41 @@ function formatBytes(bytes: number): string {
   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();
-  res.json(projects);
+  const sort = typeof req.query.sort === 'string' ? req.query.sort : 'newest';
+  res.json(sortProjects(projects, sort));
 });
 
 // Main page
 app.get('/', async (req, res) => {
-  const projects = await scanProjects();
+  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');
@@ -641,6 +677,22 @@ app.get('/', async (req, res) => {
       <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>
@@ -669,7 +721,7 @@ app.get('/', async (req, res) => {
               </div>
             </div>
             <div class="project-actions">
-              ${project.url ? `<button class="action-btn btn-primary" onclick="window.open('${project.url}', '_blank')">
+              ${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}')">
@@ -683,6 +735,47 @@ app.get('/', async (req, res) => {
   </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');

← 8198e0e untrack portfolio-viewer.ts.backup + broaden .gitignore (.ba  ·  back to Project Portfolio Viewer  ·  (newest)