← 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)