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:
113
services/arbiter-3.0/src/routes/admin/branding-assets.js
Normal file
113
services/arbiter-3.0/src/routes/admin/branding-assets.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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">×</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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user