feat(admin): Task #125 Phase 2 — Branding asset browser

Adds a visual asset library to the social calendar form, backed by
the firefrost-operations-manual branding/ directory with on-the-fly
thumbnail generation via sharp.

- src/routes/admin/branding-assets.js: /list scans both branding/ and
  docs/branding/ recursively, groups by category directory. /thumb
  generates 256px webp thumbnails from source, caches to disk keyed
  by sha1(path + mtime) so edits bust the cache automatically. Path
  traversal protection + scope check on thumb requests.
- src/views/admin/social-calendar/_assets.ejs: modal body with
  category-grouped grid of lazy-loaded thumbnails, click-to-insert.
- Form modal gets a 'Browse assets' button next to Media Notes.
- Calendar shell gets a second modal (higher z-index) and a
  sppInsertAsset() helper that appends the clicked filename to the
  media_notes textarea.

Infrastructure (not in repo):
- /opt/firefrost-ops-manual clone added to Command Center
- /etc/systemd/system/firefrost-ops-sync.{service,timer} pulls
  every 15 minutes to keep assets fresh
- /var/cache/arbiter/branding-thumbs created for thumb cache
- sharp added as Arbiter dependency (libvips 8.17.3)

Chronicler #81
This commit is contained in:
Claude
2026-04-12 01:10:09 +00:00
parent bad8036114
commit d22ff8c3c9
5 changed files with 179 additions and 1 deletions

View File

@@ -0,0 +1,113 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const sharp = require('sharp');
const OPS_ROOT = '/opt/firefrost-ops-manual';
const CACHE_DIR = '/var/cache/arbiter/branding-thumbs';
const THUMB_SIZE = 256;
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
// Scan roots — both /branding and /docs/branding
const SCAN_ROOTS = [
{ label: 'branding', rel: 'branding' },
{ label: 'docs/branding', rel: 'docs/branding' }
];
function walkImages(dir, baseRel) {
const out = [];
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return out; }
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const full = path.join(dir, entry.name);
const rel = path.posix.join(baseRel, entry.name);
if (entry.isDirectory()) {
out.push(...walkImages(full, rel));
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (IMAGE_EXTS.has(ext)) {
try {
const st = fs.statSync(full);
out.push({
rel, full,
name: entry.name,
category: path.posix.dirname(rel),
size: st.size,
mtime: st.mtimeMs
});
} catch { /* skip */ }
}
}
}
return out;
}
function listAllAssets() {
const all = [];
for (const root of SCAN_ROOTS) {
const abs = path.join(OPS_ROOT, root.rel);
all.push(...walkImages(abs, root.rel));
}
// Sort by category, then name
all.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
return all;
}
// GET /admin/branding-assets/list — modal body
router.get('/list', (req, res) => {
try {
const assets = listAllAssets();
// Group by category
const byCategory = {};
for (const a of assets) {
if (!byCategory[a.category]) byCategory[a.category] = [];
byCategory[a.category].push(a);
}
res.render('admin/social-calendar/_assets', { byCategory, total: assets.length, layout: false });
} catch (err) {
console.error('Asset list error:', err);
res.status(500).send("<div class='p-6 text-red-500'>Error loading assets.</div>");
}
});
// GET /admin/branding-assets/thumb?path=branding/logos/firefrost-emblem-512.png
router.get('/thumb', async (req, res) => {
try {
const rel = String(req.query.path || '');
// Security: must be under one of our scan roots, no path traversal
const normalized = path.posix.normalize(rel);
if (normalized.includes('..') || path.isAbsolute(normalized)) {
return res.status(400).send('Bad path');
}
const allowed = SCAN_ROOTS.some(r => normalized.startsWith(r.rel + '/'));
if (!allowed) return res.status(400).send('Out of scope');
const source = path.join(OPS_ROOT, normalized);
if (!fs.existsSync(source)) return res.status(404).send('Not found');
const stat = fs.statSync(source);
// Cache key: sha1 of path + mtime so edits bust cache automatically
const key = crypto.createHash('sha1').update(normalized + ':' + stat.mtimeMs).digest('hex');
const cachePath = path.join(CACHE_DIR, key + '.webp');
if (!fs.existsSync(cachePath)) {
await sharp(source)
.resize(THUMB_SIZE, THUMB_SIZE, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 80 })
.toFile(cachePath);
}
res.set('Content-Type', 'image/webp');
res.set('Cache-Control', 'private, max-age=86400');
fs.createReadStream(cachePath).pipe(res);
} catch (err) {
console.error('Thumb generation error:', err);
res.status(500).send('Thumb error');
}
});
module.exports = router;

View File

@@ -17,6 +17,7 @@ const discordAuditRouter = require('./discord-audit');
const systemRouter = require('./system');
const socialRouter = require('./social');
const socialCalendarRouter = require('./social-calendar');
const brandingAssetsRouter = require('./branding-assets');
const infrastructureRouter = require('./infrastructure');
const aboutRouter = require('./about');
const mcpLogsRouter = require('./mcp-logs');
@@ -125,6 +126,7 @@ router.use('/discord', discordAuditRouter);
router.use('/system', systemRouter);
router.use('/social', socialRouter);
router.use('/social-calendar', socialCalendarRouter);
router.use('/branding-assets', brandingAssetsRouter);
router.use('/infrastructure', infrastructureRouter);
router.use('/about', aboutRouter);
router.use('/mcp-logs', mcpLogsRouter);

View File

@@ -0,0 +1,36 @@
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-bold dark:text-white">🎨 Branding Assets</h2>
<p class="text-xs text-gray-500"><%= total %> images across <%= Object.keys(byCategory).length %> categories. Click to insert filename into media notes.</p>
</div>
<button onclick="document.getElementById('spp-assets-modal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600 text-2xl leading-none">&times;</button>
</div>
<% if (total === 0) { %>
<div class="text-center py-12 text-gray-500">
<div class="text-4xl mb-2">📭</div>
<div class="text-sm">No branding assets found. Add files to <code>branding/</code> in the ops manual repo.</div>
</div>
<% } %>
<% Object.keys(byCategory).sort().forEach(function(cat) { %>
<div class="mb-6">
<h3 class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400 mb-2 pb-1 border-b border-gray-200 dark:border-gray-700"><%= cat %></h3>
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2">
<% byCategory[cat].forEach(function(asset) { %>
<div class="group border border-gray-200 dark:border-gray-700 rounded overflow-hidden hover:border-pink-400 cursor-pointer bg-gray-50 dark:bg-gray-900"
onclick="window.sppInsertAsset('<%= asset.rel.replace(/'/g, "\\'") %>')">
<div class="aspect-square bg-white dark:bg-gray-800 flex items-center justify-center overflow-hidden">
<img src="/admin/branding-assets/thumb?path=<%= encodeURIComponent(asset.rel) %>"
alt="<%= asset.name %>"
loading="lazy"
class="max-w-full max-h-full object-contain">
</div>
<div class="p-1 text-[10px] text-gray-600 dark:text-gray-300 truncate" title="<%= asset.name %>"><%= asset.name %></div>
</div>
<% }); %>
</div>
</div>
<% }); %>
</div>

View File

@@ -48,7 +48,17 @@
</div>
<div>
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-300 mb-1">Media Notes <span class="text-gray-400 font-normal">(what to use — visual library coming soon)</span></label>
<div class="flex items-center justify-between mb-1">
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-300">Media Notes <span class="text-gray-400 font-normal">(what to use)</span></label>
<button type="button"
hx-get="/admin/branding-assets/list"
hx-target="#spp-assets-modal-body"
hx-swap="innerHTML"
onclick="document.getElementById('spp-assets-modal').classList.remove('hidden')"
class="text-xs bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300 hover:bg-pink-200 dark:hover:bg-pink-900/60 px-2 py-1 rounded">
🎨 Browse assets
</button>
</div>
<textarea name="media_notes" rows="2" class="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm" placeholder="e.g. 'Trinity portrait, logos/firefrost-logo-dark.png'"><%= plan.media_notes || '' %></textarea>
</div>

View File

@@ -30,6 +30,13 @@
</div>
</div>
<!-- Asset browser modal (higher z-index so it sits on top of the form modal) -->
<div id="spp-assets-modal" class="hidden fixed inset-0 bg-black/70 z-[60] flex items-center justify-center p-4" onclick="if(event.target===this) this.classList.add('hidden')">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div id="spp-assets-modal-body"></div>
</div>
</div>
<script>
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.xhr.status === 200 || evt.detail.xhr.status === 201) {
@@ -38,4 +45,14 @@
}
}
});
// Called when a thumbnail is clicked — append filename to media_notes textarea
window.sppInsertAsset = function(relPath) {
var ta = document.querySelector('#spp-modal textarea[name="media_notes"]');
if (ta) {
var prefix = ta.value && !ta.value.endsWith('\n') ? '\n' : '';
ta.value = ta.value + prefix + relPath;
ta.focus();
}
document.getElementById('spp-assets-modal').classList.add('hidden');
};
</script>