Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to CV Manager will be documented in this file.

Format follows [Keep a Changelog](https://keepachangelog.com/), versioning follows [Semantic Versioning](https://semver.org/).

## [1.17.0] - 2026-03-09

### Added
- **Profiles** section: save named snapshots of your CV data, switch between them, set one as the public-facing default, and import a JSON file directly as a profile without touching the live database.
- **Custom CSS** section: add any CSS that is injected into the public CV page on every request — lets users fully customise the look of their site.
- `POST /api/datasets/from-json` — import a `.json` file as a named profile without overwriting live CV data. The file is read into memory and stored as a dataset; it is not kept on the server.

### Changed
- Removed the block-based page editor in favour of a simpler **inline page editor**: every section on a page is always visible and directly editable — no modals, no drag-and-drop. Pages (Home, Projects, Contact) are now listed under a **Pages** nav group.
- Sidebar reorganised into three groups: **Pages**, **Site** (CV Profiles, Custom CSS, Media), and **Tools** (Uptime, Settings). Logo now shows "Website Builder" sub-label.
- Admin app now opens on the Home page editor instead of a placeholder on login.
- JSON import description clarified: the file is read into memory and not stored on the server; data is written directly to the database, replacing existing entries.
- Removed the "Import CV from HTML" import option (the "save page as HTML and re-upload" workflow) to simplify the Settings import panel.
- Media library JS simplified: removed picker-modal code that was only used by the old block editors.

## [1.16.3] - 2026-03-08

### Changed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "site-manager",
"version": "1.16.3",
"version": "1.17.0",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
Expand Down
4 changes: 4 additions & 0 deletions public-readonly/site.html
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,14 @@
/* Size */
.w-3 { width:.75rem; }
.h-3 { height:.75rem; }
.w-3\.5 { width:.875rem; }
.h-3\.5 { height:.875rem; }
.w-5 { width:1.25rem; }
.h-5 { height:1.25rem; }
.w-6 { width:1.5rem; }
.h-6 { height:1.5rem; }
.w-8 { width:2rem; }
.h-8 { height:2rem; }
.w-10 { width:2.5rem; }
.h-10 { height:2.5rem; }
.w-full { width:100%; }
Expand Down
1,188 changes: 601 additions & 587 deletions public/manager/index.html

Large diffs are not rendered by default.

225 changes: 222 additions & 3 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,14 @@ function getTrackingCode() {
} catch (e) { return ''; }
}

function getCustomCss() {
try {
const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get('customCss');
// Strip any </style> to prevent breaking out of the injected style tag
return (setting?.value || '').replace(/<\/style>/gi, '');
} catch (e) { return ''; }
}

function servePublicIndex(req, res) {
try {
// Check if a default dataset exists — serve from it instead of live DB
Expand Down Expand Up @@ -256,6 +264,10 @@ function servePublicIndex(req, res) {
// Inject default dataset slug (no DATASET_PREVIEW = no preview banner)
const datasetScript = `<script>window.DATASET_SLUG = "${defaultDataset.slug}";</script>`;
html = html.replace('</head>', `${datasetScript}</head>`);

// Inject custom CSS
const customCss = getCustomCss();
if (customCss) html = html.replace('</head>', `<style id="custom-css">${customCss}</style></head>`);

return res.type('html').send(html);
}
Expand Down Expand Up @@ -283,7 +295,11 @@ function servePublicIndex(req, res) {
if (trackingCode) {
html = html.replace('<head>', `<head>\n${trackingCode}`);
}


// Inject custom CSS
const customCss = getCustomCss();
if (customCss) html = html.replace('</head>', `<style id="custom-css">${customCss}</style></head>`);
Comment on lines +299 to +301

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire custom CSS injection into active public routes

The new custom-CSS injection is implemented inside servePublicIndex, but the public app still serves /, /projects, and /contacts via direct res.sendFile(...) handlers, so these pages never pass through this injection path. In practice, saved CSS only affects routes that use serveDatasetPage (for example /v/:slug), leaving the main public site unchanged.

Useful? React with 👍 / 👎.


res.type('html').send(html);
} catch (err) { res.sendFile(path.join(__dirname, '../public-readonly/index.html')); }
}
Expand Down Expand Up @@ -321,6 +337,10 @@ function serveDatasetPage(req, res) {
if (trackingCode) {
html = html.replace('<head>', `<head>\n${trackingCode}`);
}

// Inject custom CSS
const customCss = getCustomCss();
if (customCss) html = html.replace('</head>', `<style id="custom-css">${customCss}</style></head>`);

res.type('html').send(html);
} catch (err) {
Expand Down Expand Up @@ -770,7 +790,166 @@ if (!PUBLIC_ONLY) {
db.prepare('INSERT OR IGNORE INTO pages (slug, title, sort_order) VALUES (?, ?, ?)').run(p.slug, p.title, p.sort_order);
});

// Step 4: Auto-create "Default" dataset from live DB if no default exists
// Seed example blocks into default pages if none exist yet (fresh install demo content)
const blockCount = db.prepare('SELECT COUNT(*) as n FROM blocks').get().n;
if (blockCount === 0) {
Comment on lines +794 to +795

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict demo seeding to first-run initialization

The startup seed runs whenever blocks is empty, not just on a fresh install. If an existing user intentionally removes all sections to keep pages blank, the next restart repopulates all default pages with demo content, unexpectedly reintroducing data they deleted.

Useful? React with 👍 / 👎.

const insertBlock = db.prepare(
'INSERT INTO blocks (page_slug, type, content, sort_order, enabled) VALUES (?, ?, ?, ?, 1)'
);

// ── Home page ──────────────────────────────────────────────────────────
insertBlock.run('home', 'hero', JSON.stringify({
title: "Hi, I'm Alex Rivera",
subtitle: "Full-stack developer building fast, accessible web experiences. Open to new opportunities.",
primary_button_text: "See My Work",
primary_button_link: "/projects",
background_image: ""
}), 0);

insertBlock.run('home', 'timeline', JSON.stringify({
title: "Recent Experience",
items: [
{
role: "Senior Frontend Engineer",
company: "Vercel",
start_date: "2022-03",
end_date: "",
description: "Leading UI performance initiatives across the dashboard, reducing Time-to-Interactive by 40%. Championing design-system adoption across 6 product teams."
},
{
role: "Full-Stack Developer",
company: "Stripe",
start_date: "2019-07",
end_date: "2022-02",
description: "Built internal tooling and merchant-facing integrations in React and Go. Owned the invoicing module end-to-end."
}
]
}), 1);

insertBlock.run('home', 'contact', JSON.stringify({
email: "alex@example.com",
github: "https://github.com/alexrivera",
linkedin: "https://linkedin.com/in/alexrivera",
website: "https://alexrivera.dev"
}), 2);

// ── Projects page ──────────────────────────────────────────────────────
insertBlock.run('projects', 'hero', JSON.stringify({
title: "Projects",
subtitle: "A selection of things I've built — from open-source tools to production apps.",
primary_button_text: "",
primary_button_link: "",
background_image: ""
}), 0);

insertBlock.run('projects', 'projects_grid', JSON.stringify({
title: "",
projects: [
{
name: "Logship",
description: "Real-time log aggregation pipeline handling 50k events/s. Built with Go, Kafka, and a React dashboard.",
link: "https://github.com/alexrivera/logship",
tech_stack: ["Go", "Kafka", "React", "PostgreSQL"],
image: ""
},
{
name: "FormKit Pro",
description: "Headless form-builder library for React with schema-driven validation, conditional fields, and multi-step flows.",
link: "https://github.com/alexrivera/formkit-pro",
tech_stack: ["TypeScript", "React", "Zod"],
image: ""
},
{
name: "PocketCV",
description: "Self-hosted resume manager (this very app!). Generates a clean public CV page from a SQLite database.",
link: "https://github.com/alexrivera/pocketcv",
tech_stack: ["Node.js", "Express", "SQLite", "Tailwind"],
image: ""
},
{
name: "Dispatch",
description: "Lightweight webhook relay with retries, failure queues, and a simple dashboard for monitoring delivery status.",
link: "https://github.com/alexrivera/dispatch",
tech_stack: ["Go", "Redis", "Docker"],
image: ""
},
{
name: "Inkdrop Theme Pack",
description: "Collection of 12 high-contrast and low-contrast editor themes for the Inkdrop note-taking app. 2k+ installs.",
link: "https://github.com/alexrivera/inkdrop-themes",
tech_stack: ["CSS"],
image: ""
},
{
name: "OSS Contributions",
description: "Regular contributor to Next.js docs, Radix UI accessibility layer, and the Remix router. View all on GitHub.",
link: "https://github.com/alexrivera",
tech_stack: ["Various"],
image: ""
}
]
}), 1);

// ── CV page ────────────────────────────────────────────────────────────
insertBlock.run('cv', 'hero', JSON.stringify({
title: "Curriculum Vitae",
subtitle: "Alex Rivera · Full-Stack Engineer · San Francisco, CA",
primary_button_text: "Download PDF",
primary_button_link: "#",
background_image: ""
}), 0);

insertBlock.run('cv', 'timeline', JSON.stringify({
title: "Work Experience",
items: [
{
role: "Senior Frontend Engineer",
company: "Vercel",
start_date: "2022-03",
end_date: "",
description: "Leading UI performance initiatives across the dashboard. Reduced Time-to-Interactive by 40% through code-splitting and streaming SSR. Mentoring 3 junior engineers."
},
{
role: "Full-Stack Developer",
company: "Stripe",
start_date: "2019-07",
end_date: "2022-02",
description: "Built internal tooling and merchant-facing integrations in React and Go. Owned the invoicing module: design, implementation, testing, and on-call."
},
{
role: "Software Engineer",
company: "Shopify",
start_date: "2017-06",
end_date: "2019-06",
description: "Worked on the Storefront APIs team, expanding GraphQL schema coverage and improving SDK developer experience."
}
]
}), 1);

insertBlock.run('cv', 'timeline', JSON.stringify({
title: "Education",
items: [
{
role: "B.Sc. Computer Science",
company: "University of Waterloo",
start_date: "2013-09",
end_date: "2017-04",
description: "Specialization in Distributed Systems. Dean's Honor List. Capstone: real-time collaborative code editor (WebRTC + CRDT)."
}
]
}), 2);

// ── Contacts page ──────────────────────────────────────────────────────
insertBlock.run('contacts', 'contact', JSON.stringify({
email: "alex@example.com",
github: "https://github.com/alexrivera",
linkedin: "https://linkedin.com/in/alexrivera",
website: "https://alexrivera.dev"
}), 0);

console.log('Seeded example content into default pages');
}

// Runs AFTER Step 3 so that profile and section_visibility rows are guaranteed to exist.
// Creates a Default dataset on every install (fresh or existing) so the Open modal is never empty.
try {
Expand Down Expand Up @@ -2442,6 +2621,31 @@ if (PUBLIC_ONLY) {
} catch (err) { res.status(500).json({ error: err.message }); }
});

// Import a .json file as a named profile — does NOT overwrite live CV data.
// The file is read into memory, parsed, and stored as a dataset entry. The file itself is not kept.
app.post('/api/datasets/from-json', managerApiRateLimiter, uploadRateLimiter, jsonImportUpload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No JSON file uploaded.' });
const name = (req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'Profile name is required.' });
let data;
try { data = JSON.parse(req.file.buffer.toString('utf8')); }
catch (e) { return res.status(400).json({ error: 'Invalid JSON file. The file could not be parsed.' }); }
if (!data || typeof data !== 'object' || Array.isArray(data)) return res.status(400).json({ error: 'Invalid CV JSON format.' });
data = normalizeCVImportData(data);
try {
const existing = db.prepare('SELECT id FROM saved_datasets WHERE name = ?').get(name);
if (existing) {
db.prepare('UPDATE saved_datasets SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(data), existing.id);
res.json({ success: true, id: existing.id, updated: true });
} else {
const result = db.prepare('INSERT INTO saved_datasets (name, data) VALUES (?, ?)').run(name, JSON.stringify(data));
const newId = result.lastInsertRowid;
try { const slug = generateSlug(name, newId); db.prepare('UPDATE saved_datasets SET slug = ? WHERE id = ?').run(slug, newId); } catch (e) {}
res.json({ success: true, id: newId, created: true });
}
} catch (err) { res.status(500).json({ error: err.message }); }
});

// ── Website Manager API routes ──────────────────────────────────────────

// Rate limiter for all website manager API routes (300 req/min per IP)
Expand All @@ -2454,7 +2658,7 @@ if (PUBLIC_ONLY) {
else { managerApiRateLimit[ip].count++; if (managerApiRateLimit[ip].count > 300) return res.status(429).json({ error: 'Too many requests' }); }
next();
}
app.use(['/auth/me', '/auth/login', '/auth/logout', '/api/site', '/api/pages', '/api/blocks', '/api/uploads', '/api/media', '/api/uptime', '/manager'], managerApiRateLimiter);
app.use(['/auth/me', '/auth/login', '/auth/logout', '/api/site', '/api/custom-css', '/api/pages', '/api/blocks', '/api/uploads', '/api/media', '/api/uptime', '/manager'], managerApiRateLimiter);

// Auth JSON endpoints (used by manager UI)
app.get('/auth/me', (req, res) => {
Expand Down Expand Up @@ -2509,6 +2713,21 @@ if (PUBLIC_ONLY) {
} catch (err) { res.status(500).json({ error: err.message }); }
});

// Custom CSS — stored in settings table and injected into the public CV page on every request
app.get('/api/custom-css', managerApiRateLimiter, requireAuth, (req, res) => {
try {
const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get('customCss');
res.json({ css: setting?.value || '' });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/custom-css', managerApiRateLimiter, requireAuth, (req, res) => {
try {
const css = typeof req.body.css === 'string' ? req.body.css : '';
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run('customCss', css);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});

// Pages
app.get('/api/pages', managerApiRateLimiter, requireAuth, (req, res) => {
try {
Expand Down
4 changes: 2 additions & 2 deletions version.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"version": "1.16.3",
"version": "1.17.0",
"changelog": "https://github.com/Aedankerr/site-manager/blob/main/CHANGELOG.md"
}
}