Redesign UI and add configurable branding logo

- Modern dark/light theme with CSS variables, pills, cards and a sticky nav
- New Branding section in admin settings: upload logo (png/jpg/svg/webp/gif,
  2 MB) plus width/height sliders, proportional scale and reset
- Logo served publicly via /logo and displayed on admin portal and the
  customer upload page; files persist under ./data/logo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-16 11:24:45 +02:00
parent 0770259d3d
commit 4567e93aa2
5 changed files with 828 additions and 331 deletions
+66 -1
View File
@@ -12,7 +12,9 @@ const janitor = require('./janitor');
const PORT = parseInt(process.env.PORT || '3000', 10);
const UPLOAD_ROOT = process.env.UPLOAD_ROOT || '/data/uploads';
const LOGO_DIR = process.env.LOGO_DIR || '/data/logo';
fs.mkdirSync(LOGO_DIR, { recursive: true });
settings.seedFromEnv();
fs.mkdirSync(UPLOAD_ROOT, { recursive: true });
@@ -83,6 +85,14 @@ function canAccessCustomer(user, customer, needWrite = false) {
// ---------- Setup & Auth API ----------
const publicApi = express.Router();
publicApi.get('/branding', (req, res) => {
res.json({
logo_filename: settings.get('logo_filename', ''),
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
logo_height_px: parseInt(settings.get('logo_height_px', '0'), 10),
});
});
publicApi.get('/status', (req, res) => {
const u = auth.getSessionUser(req);
res.json({
@@ -189,11 +199,14 @@ api.get('/settings', auth.requireAdmin, (req, res) => {
res.json({
public_base_url: settings.get('public_base_url', ''),
janitor_interval_minutes: parseInt(settings.get('janitor_interval_minutes', '30'), 10),
logo_filename: settings.get('logo_filename', ''),
logo_width_px: parseInt(settings.get('logo_width_px', '0'), 10),
logo_height_px: parseInt(settings.get('logo_height_px', '0'), 10),
});
});
api.put('/settings', auth.requireAdmin, (req, res) => {
const { public_base_url, janitor_interval_minutes } = req.body || {};
const { public_base_url, janitor_interval_minutes, logo_width_px, logo_height_px } = req.body || {};
if (public_base_url !== undefined) {
const v = String(public_base_url || '').trim().replace(/\/+$/, '');
settings.set('public_base_url', v);
@@ -203,6 +216,48 @@ api.put('/settings', auth.requireAdmin, (req, res) => {
settings.set('janitor_interval_minutes', String(n));
janitor.restart(n * 60 * 1000);
}
if (logo_width_px !== undefined) {
const n = Math.max(0, Math.min(800, parseInt(logo_width_px, 10) || 0));
settings.set('logo_width_px', String(n));
}
if (logo_height_px !== undefined) {
const n = Math.max(0, Math.min(600, parseInt(logo_height_px, 10) || 0));
settings.set('logo_height_px', String(n));
}
res.json({ ok: true });
});
// --- Logo (admin manages, public serves) ---
const logoUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
const ok = /^image\/(png|jpeg|gif|svg\+xml|webp)$/.test(file.mimetype);
cb(ok ? null : new Error('invalid image type'), ok);
},
});
api.post('/logo', auth.requireAdmin, logoUpload.single('logo'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no file' });
const ext = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/svg+xml': '.svg',
'image/webp': '.webp',
}[req.file.mimetype] || '';
const oldFn = settings.get('logo_filename', '');
if (oldFn) { try { fs.unlinkSync(path.join(LOGO_DIR, oldFn)); } catch {} }
const fn = `logo${ext}`;
fs.writeFileSync(path.join(LOGO_DIR, fn), req.file.buffer);
settings.set('logo_filename', fn);
res.json({ ok: true, filename: fn });
});
api.delete('/logo', auth.requireAdmin, (req, res) => {
const fn = settings.get('logo_filename', '');
if (fn) { try { fs.unlinkSync(path.join(LOGO_DIR, fn)); } catch {} }
settings.set('logo_filename', '');
res.json({ ok: true });
});
@@ -420,6 +475,16 @@ app.post('/u/:token/upload', uploadAuth, upload.single('file'), (req, res) => {
res.json({ ok: true, file: { name: f.filename, path: rel, size: f.size } });
});
// Public logo (no auth) — used by admin & upload page
app.get('/logo', (req, res) => {
const fn = settings.get('logo_filename', '');
if (!fn) return res.status(404).end();
const fp = path.join(LOGO_DIR, fn);
if (!fs.existsSync(fp)) return res.status(404).end();
res.setHeader('Cache-Control', 'public, max-age=60');
res.sendFile(fp);
});
// ---------- Root ----------
app.get('/', (req, res) => res.redirect('/admin/'));