-
- ${imagePickerWidget(null, p.image||'', 'proj-image')}
+
+
+
-
`;
}
-function addProjectItem() {
- const list = document.getElementById('projectsList');
- const count = list.querySelectorAll('.project-item').length;
+function addInlineTimelineItem(listId) {
+ const list = document.getElementById(listId);
+ if (!list) return;
+ const count = list.querySelectorAll('.inline-tl-row').length;
const div = document.createElement('div');
- div.innerHTML = projectItemHTML({}, count);
- const newItem = list.appendChild(div.firstElementChild);
- newItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ div.innerHTML = inlineTimelineRow({}, count);
+ list.appendChild(div.firstElementChild);
}
-function removeProjectItem(btn) {
- btn.closest('.project-item').remove();
- // Re-number
- document.querySelectorAll('.project-item').forEach((el, i) => {
- const label = el.querySelector('span');
- if (label) label.textContent = 'Project ' + (i + 1);
- });
+function collectPageSectionContent(blockId, type) {
+ const p = `s${blockId}_`;
+ const g = id => { const el = document.getElementById(id); return el ? el.value : ''; };
+ if (type === 'hero') return {
+ title: g(`${p}title`), subtitle: g(`${p}subtitle`),
+ primary_button_text: g(`${p}btn_text`), primary_button_link: g(`${p}btn_link`),
+ background_image: g(`${p}bg`),
+ };
+ if (type === 'contact') return {
+ email: g(`${p}email`), github: g(`${p}github`),
+ linkedin: g(`${p}linkedin`), website: g(`${p}website`),
+ };
+ if (type === 'projects_grid') {
+ const projects = [];
+ document.getElementById(`${p}plist`)?.querySelectorAll('.inline-proj-row').forEach(row => {
+ projects.push({
+ name: row.querySelector('.ipj-name')?.value || '',
+ link: row.querySelector('.ipj-link')?.value || '',
+ description: row.querySelector('.ipj-desc')?.value || '',
+ tech_stack: (row.querySelector('.ipj-tech')?.value || '').split(',').map(s => s.trim()).filter(Boolean),
+ });
+ });
+ return { title: g(`${p}title`), projects };
+ }
+ if (type === 'timeline') {
+ const items = [];
+ document.getElementById(`${p}tlist`)?.querySelectorAll('.inline-tl-row').forEach(row => {
+ items.push({
+ role: row.querySelector('.itl-role')?.value || '',
+ company: row.querySelector('.itl-company')?.value || '',
+ start_date: row.querySelector('.itl-start')?.value || '',
+ end_date: row.querySelector('.itl-end')?.value || '',
+ description: row.querySelector('.itl-desc')?.value || '',
+ });
+ });
+ return { title: g(`${p}title`), items };
+ }
+ return {};
}
-function projectsGridCollect() {
- const projects = [];
- document.querySelectorAll('.project-item').forEach(el => {
- projects.push({
- name: el.querySelector('.proj-name')?.value || '',
- link: el.querySelector('.proj-link')?.value || '',
- description: el.querySelector('.proj-description')?.value || '',
- image: el.querySelector('.proj-image')?.value || '',
- tech_stack: (el.querySelector('.proj-tech_stack')?.value || '').split(',').map(s => s.trim()).filter(Boolean),
- });
- });
- return { title: v('ef_title'), projects };
+async function savePageSection(blockId) {
+ const block = state.pageBlocks.find(b => b.id === blockId);
+ if (!block) return;
+ try {
+ const content = collectPageSectionContent(blockId, block.type);
+ await api(`/api/blocks/${blockId}`, { method: 'PUT', body: { content } });
+ block.content = content;
+ toast('Section saved');
+ } catch (err) { toast('Save failed: ' + err.message, 'error'); }
}
-// ── Timeline editor
-function timelineEditor(c) {
- const items = c.items || [];
- return `
-
-
-
-
-
-
-
-
-
-
-
- ${items.map((it, i) => timelineItemHTML(it, i)).join('')}
-
-
-
`;
+async function togglePageSection(blockId, enabled) {
+ try {
+ await api(`/api/blocks/${blockId}`, { method: 'PUT', body: { enabled } });
+ const block = state.pageBlocks.find(b => b.id === blockId);
+ if (block) block.enabled = enabled;
+ } catch (err) { toast('Failed to update', 'error'); await loadPageEditor(state.currentPage); }
}
-function timelineItemHTML(it, i) {
- return `
-
-
- Item ${i + 1}
-
-
-
-
-
-
-
-
-
`;
+async function deletePageSection(blockId) {
+ if (!confirm('Remove this section?')) return;
+ try {
+ await api(`/api/blocks/${blockId}`, { method: 'DELETE' });
+ state.pageBlocks = state.pageBlocks.filter(b => b.id !== blockId);
+ renderPageEditor(state.pageBlocks);
+ toast('Section removed');
+ } catch (err) { toast('Delete failed: ' + err.message, 'error'); }
}
-function addTimelineItem() {
- const list = document.getElementById('timelineList');
- const count = list.querySelectorAll('.timeline-item').length;
- const div = document.createElement('div');
- div.innerHTML = timelineItemHTML({}, count);
- const newItem = list.appendChild(div.firstElementChild);
- newItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+function toggleAddSectionPicker() {
+ document.getElementById('addSectionPicker').classList.toggle('hidden');
}
-function removeTimelineItem(btn) {
- btn.closest('.timeline-item').remove();
- document.querySelectorAll('.timeline-item').forEach((el, i) => {
- const label = el.querySelector('span');
- if (label) label.textContent = 'Item ' + (i + 1);
- });
+async function addPageSection(type) {
+ const defaults = {
+ hero: { title: '', subtitle: '', primary_button_text: '', primary_button_link: '', background_image: '' },
+ projects_grid: { title: 'Projects', projects: [] },
+ timeline: { title: 'Experience', items: [] },
+ contact: { email: '', github: '', linkedin: '', website: '' },
+ };
+ try {
+ await api(`/api/pages/${state.currentPage}/blocks`, { method: 'POST', body: { type, content: defaults[type] || {} } });
+ document.getElementById('addSectionPicker').classList.add('hidden');
+ await loadPageEditor(state.currentPage);
+ toast('Section added');
+ } catch (err) { toast('Failed: ' + err.message, 'error'); }
}
-function timelineCollect() {
- const items = [];
- document.querySelectorAll('.timeline-item').forEach(el => {
- items.push({
- role: el.querySelector('.tl-role')?.value || '',
- company: el.querySelector('.tl-company')?.value || '',
- start_date: el.querySelector('.tl-start_date')?.value || '',
- end_date: el.querySelector('.tl-end_date')?.value || '',
- description: el.querySelector('.tl-description')?.value || '',
- });
- });
- return { title: v('ef_title'), items };
+// ═══════════════════════════════════════════════════════
+// PROFILES
+// ═══════════════════════════════════════════════════════
+async function loadProfiles() {
+ try {
+ state.profiles = await api('/api/datasets');
+ renderProfiles();
+ } catch (err) { toast('Failed to load profiles: ' + err.message, 'error'); }
}
-// ── Contact editor
-function contactEditor(c) {
- return `
-
`;
+function renderProfiles() {
+ const list = document.getElementById('profileList');
+ const empty = document.getElementById('profileEmpty');
+ const profiles = state.profiles || [];
+ if (!profiles.length) { list.innerHTML = ''; empty.classList.remove('hidden'); return; }
+ empty.classList.add('hidden');
+ list.innerHTML = profiles.map(p => {
+ const updated = p.updated_at ? new Date(p.updated_at).toLocaleDateString() : '';
+ return `
+
+
+
+ ${escHtml(p.name)}
+ ${p.is_default ? 'Default' : ''}
+
+
${updated ? 'Updated ' + updated : ''}
+
+
+ ${!p.is_default ? `
` : ''}
+
+ ${!p.is_default ? `
` : ''}
+
+
`;
+ }).join('');
}
-function contactCollect() {
- return {
- email: v('ef_email'),
- github: v('ef_github'),
- linkedin: v('ef_linkedin'),
- website: v('ef_website'),
- };
+async function saveCurrentAsProfile() {
+ const name = document.getElementById('newProfileName').value.trim();
+ if (!name) { toast('Enter a profile name', 'error'); return; }
+ try {
+ await api('/api/datasets', { method: 'POST', body: { name } });
+ document.getElementById('newProfileName').value = '';
+ toast('Profile saved: ' + name);
+ await loadProfiles();
+ } catch (err) { toast('Save failed: ' + err.message, 'error'); }
}
-// ── Collect & save
-function collectContent(type) {
- if (type === 'hero') return heroCollect();
- if (type === 'projects_grid') return projectsGridCollect();
- if (type === 'timeline') return timelineCollect();
- if (type === 'contact') return contactCollect();
- return {};
+async function loadProfile(id) {
+ if (!confirm('Load this profile? Your current live CV data will be replaced.')) return;
+ try {
+ const data = await api(`/api/datasets/${id}/load`, { method: 'POST' });
+ toast('Profile loaded: ' + data.name);
+ } catch (err) { toast('Load failed: ' + err.message, 'error'); }
}
-async function saveBlock() {
- const id = state.editingBlockId;
- const type = state.editingBlockType;
- if (!id) return;
+async function setDefaultProfile(id) {
try {
- const content = collectContent(type);
- await api(`/api/blocks/${id}`, { method: 'PUT', body: { content } });
- closeModal('modalEditBlock');
- await loadBlocks(state.currentPage);
- toast('Block saved');
- } catch (err) { toast('Save failed: ' + err.message, 'error'); }
+ await api(`/api/datasets/${id}/default`, { method: 'PUT' });
+ toast('Default profile updated');
+ await loadProfiles();
+ } catch (err) { toast('Failed: ' + err.message, 'error'); }
}
-// ── Image Picker Widget (generates HTML for image field + browse button)
-// Uses data attributes to avoid complex escaping in onclick handlers
-function imagePickerWidget(id, value, cssClass) {
- const idAttr = id ? `id="${escAttr(id)}"` : '';
- const classAttr = `class="field${cssClass ? ' ' + cssClass : ''}"`;
- const pickerId = 'picker_' + (id || cssClass || Math.random().toString(36).slice(2));
- const previewId = 'prev_' + pickerId;
- return `
-
-
-
-
-
-
-
})
-
-
`;
+async function deleteProfile(id) {
+ if (!confirm('Delete this profile?')) return;
+ try {
+ await api(`/api/datasets/${id}`, { method: 'DELETE' });
+ toast('Profile deleted');
+ await loadProfiles();
+ } catch (err) { toast('Delete failed: ' + err.message, 'error'); }
}
-// Global delegated click handler for media picker "Browse" buttons
-document.addEventListener('click', function(e) {
- // Image picker "Browse" button
- const pickerBtn = e.target.closest('[data-open-picker]');
- if (pickerBtn) {
- const pickerId = pickerBtn.dataset.openPicker;
- openMediaPicker(function(url) {
- document.querySelectorAll(`[data-picker-field="${pickerId}"]`).forEach(inp => {
- inp.value = url;
- updateImagePreview('prev_' + pickerId, url);
- });
- });
- return;
- }
+function triggerProfileJsonImport() {
+ const name = document.getElementById('importProfileName').value.trim();
+ if (!name) { toast('Enter a profile name first', 'error'); return; }
+ document.getElementById('profileJsonInput').click();
+}
- // Uptime monitor edit button (uses data attributes to avoid escaping issues)
- const editBtn = e.target.closest('[data-edit-monitor]');
- if (editBtn) {
- openEditMonitor(
- editBtn.dataset.editMonitor,
- editBtn.dataset.monitorName,
- editBtn.dataset.monitorUrl
- );
- }
-});
+async function importJsonAsProfile(input) {
+ const file = input.files && input.files[0];
+ if (!file) return;
+ input.value = '';
+ const name = document.getElementById('importProfileName').value.trim();
+ if (!name) { toast('Enter a profile name', 'error'); return; }
+ const fd = new FormData();
+ fd.append('file', file);
+ fd.append('name', name);
+ try {
+ const res = await fetch('/api/datasets/from-json', { method: 'POST', body: fd });
+ const data = await res.json();
+ if (!res.ok) { toast(data.error || 'Import failed', 'error'); return; }
+ document.getElementById('importProfileName').value = '';
+ toast(data.created ? 'Profile imported: ' + name : 'Profile updated: ' + name);
+ await loadProfiles();
+ } catch (err) { toast('Import failed: ' + err.message, 'error'); }
+}
+// ═══════════════════════════════════════════════════════
+// CUSTOM CSS
+// ═══════════════════════════════════════════════════════
+async function loadCustomCSS() {
+ try {
+ const data = await api('/api/custom-css');
+ document.getElementById('customCssInput').value = data.css || '';
+ } catch (err) { toast('Failed to load CSS', 'error'); }
+}
-function updateImagePreview(previewId, url) {
- const el = document.getElementById(previewId);
- if (!el) return;
- if (url) {
- el.classList.remove('hidden');
- el.querySelector('img').src = url;
- } else {
- el.classList.add('hidden');
- }
+async function saveCustomCSS() {
+ const css = document.getElementById('customCssInput').value;
+ try {
+ await api('/api/custom-css', { method: 'PUT', body: { css } });
+ toast('Custom CSS saved — changes are live');
+ } catch (err) { toast('Save failed: ' + err.message, 'error'); }
}
// ═══════════════════════════════════════════════════════
@@ -1135,54 +1182,25 @@
Add Monitor
async function loadMedia() {
try {
state.mediaFiles = await api('/api/media');
- renderMediaGrid('mediaGridMain', null);
+ renderMediaGrid();
document.getElementById('mediaEmpty').classList.toggle('hidden', state.mediaFiles.length > 0);
} catch (err) { toast('Failed to load media', 'error'); }
}
-function renderMediaGrid(containerId, onSelect) {
- const el = document.getElementById(containerId);
+function renderMediaGrid() {
+ const el = document.getElementById('mediaGridMain');
if (!el) return;
if (!state.mediaFiles.length) { el.innerHTML = ''; return; }
el.innerHTML = state.mediaFiles.map(f => `
-
})
+
`).join('');
- // Attach click handlers via delegation (avoids escaping issues)
el.onclick = (e) => {
const img = e.target.closest('img[data-url]');
- if (!img) return;
- const url = img.dataset.url;
- if (img.dataset.picker) {
- pickerSelect(url);
- } else {
- showMediaPreview(url);
- }
+ if (img) window.open(img.dataset.url, '_blank');
};
}
-function showMediaPreview(url) {
- // Simple: open in new tab
- window.open(url, '_blank');
-}
-
-function openMediaPicker(callback) {
- state.mediaPickerCallback = callback;
- loadMedia().then(() => {
- renderMediaGrid('mediaPickerInner', callback);
- document.getElementById('mediaPickerEmpty').classList.toggle('hidden', state.mediaFiles.length > 0);
- });
- openModal('modalMediaPicker');
-}
-
-function pickerSelect(url) {
- if (state.mediaPickerCallback) {
- state.mediaPickerCallback(url);
- state.mediaPickerCallback = null;
- }
- closeModal('modalMediaPicker');
-}
-
// ═══════════════════════════════════════════════════════
// FILE UPLOAD
// ═══════════════════════════════════════════════════════
@@ -1193,17 +1211,16 @@
Add Monitor
function handleDragLeave(zoneId) {
document.getElementById(zoneId).classList.remove('active');
}
-function handleDrop(e, zoneId, context) {
+function handleDrop(e, zoneId) {
e.preventDefault();
document.getElementById(zoneId).classList.remove('active');
- uploadFiles(e.dataTransfer.files, context);
+ uploadFiles(e.dataTransfer.files);
}
-async function uploadFiles(files, context) {
- const isPicker = context === 'picker';
- const progressEl = document.getElementById(isPicker ? 'pickerUploadProgress' : 'mainUploadProgress');
- const pctEl = document.getElementById(isPicker ? 'pickerUploadPct' : 'mainUploadPct');
- const fillEl = document.getElementById(isPicker ? 'pickerUploadFill' : 'mainUploadFill');
+async function uploadFiles(files) {
+ const progressEl = document.getElementById('mainUploadProgress');
+ const pctEl = document.getElementById('mainUploadPct');
+ const fillEl = document.getElementById('mainUploadFill');
for (const file of files) {
if (!file.type.startsWith('image/')) { toast('Only images allowed', 'error'); continue; }
@@ -1213,7 +1230,6 @@
Add Monitor
pctEl.textContent = '0%';
fillEl.style.width = '0%';
- // Fake progress ticks while uploading
let pct = 0;
const ticker = setInterval(() => {
pct = Math.min(pct + 10, 85);
@@ -1228,18 +1244,10 @@
Add Monitor
pctEl.textContent = '100%';
await new Promise(r => setTimeout(r, 400));
progressEl.classList.add('hidden');
-
if (result.url) {
state.mediaFiles.unshift({ url: result.url, filename: result.filename });
- if (isPicker) {
- renderMediaGrid('mediaPickerInner', state.mediaPickerCallback);
- document.getElementById('mediaPickerEmpty').classList.add('hidden');
- // Auto-select the uploaded image if in picker context
- if (state.mediaPickerCallback) pickerSelect(result.url);
- } else {
- renderMediaGrid('mediaGridMain', null);
- document.getElementById('mediaEmpty').classList.add('hidden');
- }
+ renderMediaGrid();
+ document.getElementById('mediaEmpty').classList.add('hidden');
toast('Uploaded: ' + result.filename);
} else {
toast(result.error || 'Upload failed', 'error');
@@ -1461,8 +1469,14 @@
Add Monitor
function openModal(id) { document.getElementById(id).classList.remove('hidden'); }
function closeModal(id) { document.getElementById(id).classList.add('hidden'); }
document.addEventListener('keydown', e => {
- if (e.key === 'Escape') {
- ['modalAddBlock','modalEditBlock','modalMediaPicker','modalAddMonitor'].forEach(closeModal);
+ if (e.key === 'Escape') closeModal('modalAddMonitor');
+});
+
+// Delegated click: uptime monitor edit buttons
+document.addEventListener('click', function(e) {
+ const editBtn = e.target.closest('[data-edit-monitor]');
+ if (editBtn) {
+ openEditMonitor(editBtn.dataset.editMonitor, editBtn.dataset.monitorName, editBtn.dataset.monitorUrl);
}
});
diff --git a/src/server.js b/src/server.js
index 0c572e2..7b0b3fc 100644
--- a/src/server.js
+++ b/src/server.js
@@ -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 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
@@ -256,6 +264,10 @@ function servePublicIndex(req, res) {
// Inject default dataset slug (no DATASET_PREVIEW = no preview banner)
const datasetScript = ``;
html = html.replace('', `${datasetScript}`);
+
+ // Inject custom CSS
+ const customCss = getCustomCss();
+ if (customCss) html = html.replace('', ``);
return res.type('html').send(html);
}
@@ -283,7 +295,11 @@ function servePublicIndex(req, res) {
if (trackingCode) {
html = html.replace('', `\n${trackingCode}`);
}
-
+
+ // Inject custom CSS
+ const customCss = getCustomCss();
+ if (customCss) html = html.replace('', ``);
+
res.type('html').send(html);
} catch (err) { res.sendFile(path.join(__dirname, '../public-readonly/index.html')); }
}
@@ -321,6 +337,10 @@ function serveDatasetPage(req, res) {
if (trackingCode) {
html = html.replace('', `\n${trackingCode}`);
}
+
+ // Inject custom CSS
+ const customCss = getCustomCss();
+ if (customCss) html = html.replace('', ``);
res.type('html').send(html);
} catch (err) {
@@ -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) {
+ 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 {
@@ -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)
@@ -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) => {
@@ -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 {
diff --git a/version.json b/version.json
index 086abab..e1d4c4b 100644
--- a/version.json
+++ b/version.json
@@ -1,4 +1,4 @@
{
- "version": "1.16.3",
+ "version": "1.17.0",
"changelog": "https://github.com/Aedankerr/site-manager/blob/main/CHANGELOG.md"
-}
\ No newline at end of file
+}