portfolio-cms / cms.html
sehanlim's picture
Initial: Portfolio CMS with 4 themes + built-in editor
6aabeb4 verified
Raw History Blame Contribute Delete
43.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Portfolio CMS</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f8fafc; --bg-elevated: #fff; --fg: #1e293b; --fg-muted: #64748b;
--accent: #2563eb; --accent-hover: #1d4ed8; --accent-light: #dbeafe;
--border: #e2e8f0; --border-strong: #cbd5e1;
--danger: #ef4444; --success: #22c55e;
--shadow: 0 1px 3px rgba(0,0,0,0.1);
--radius: 8px;
}
[data-theme="dark"] {
--bg: #0f172a; --bg-elevated: #1e293b; --fg: #f1f5f9; --fg-muted: #94a3b8;
--accent: #3b82f6; --accent-hover: #60a5fa; --accent-light: #1e3a5f;
--border: #334155; --border-strong: #475569;
}
* { box-sizing: border-box; }
body { font-family: 'Inter', sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; min-height: 100vh; }
a { color: var(--accent); }
button { font-family: inherit; cursor: pointer; }
.container { max-width: 1000px; margin: 0 auto; padding: 2rem 1.5rem; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); flex-wrap: wrap; gap: 1rem; }
h1 { font-size: 1.75rem; font-weight: 700; }
.nav-tabs { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
.nav-tab { padding: 0.5rem 1rem; border: none; background: transparent; color: var(--fg-muted); font-weight: 500; border-radius: var(--radius); transition: all 0.2s; }
.nav-tab:hover { background: var(--accent-light); color: var(--accent); }
.nav-tab.active { background: var(--accent); color: #fff; }
.card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; margin-bottom: 1.5rem; box-shadow: var(--shadow); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
.card-title { font-weight: 600; font-size: 1.1rem; }
.btn { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; border-radius: var(--radius); font-weight: 500; font-size: 0.875rem; transition: all 0.2s; border: 1px solid transparent; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary { background: var(--bg); color: var(--fg); border-color: var(--border); }
.btn-secondary:hover { background: var(--border); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: #dc2626; }
.btn-sm { padding: 0.35rem 0.75rem; font-size: 0.8rem; }
.form-group { margin-bottom: 1rem; }
.form-group label { display: block; margin-bottom: 0.4rem; font-weight: 500; font-size: 0.9rem; }
.form-group input, .form-group textarea, .form-group select { width: 100%; padding: 0.625rem 0.875rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--fg); font-family: inherit; font-size: 0.95rem; transition: border 0.2s, box-shadow 0.2s; }
.form-group input:focus, .form-group textarea:focus, .form-group select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } }
.item-list { display: flex; flex-direction: column; gap: 0.75rem; }
.item-card { display: grid; grid-template-columns: 1fr auto; gap: 1rem; align-items: start; padding: 1rem; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); }
.item-card.expanded .item-fields { display: grid; }
.item-fields { display: none; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.75rem; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); width: 100%; }
.item-preview { font-weight: 500; color: var(--fg); }
.item-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
.tag-input { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; min-height: 42px; padding: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); }
.tag-input:focus-within { border-color: var(--accent); }
.tag { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.25rem 0.6rem; background: var(--accent-light); color: var(--accent); border-radius: 999px; font-size: 0.8rem; font-weight: 500; }
.tag button { background: none; border: none; color: inherit; padding: 0; line-height: 1; font-size: 1rem; }
.tag-input input { flex: 1; min-width: 120px; border: none; background: transparent; padding: 0; font-size: 0.9rem; }
.tag-input input:focus { outline: none; box-shadow: none; }
.color-picker { width: 40px; height: 32px; border: none; border-radius: 4px; cursor: pointer; }
.switch { display: flex; align-items: center; gap: 0.75rem; }
.switch input { width: 44px; height: 24px; appearance: none; background: var(--border); border-radius: 999px; position: relative; cursor: pointer; transition: background 0.2s; }
.switch input:checked { background: var(--accent); }
.switch input::before { content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; background: #fff; border-radius: 50%; transition: transform 0.2s; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
.switch input:checked::before { transform: translateX(20px); }
.switch input:focus { outline: none; box-shadow: 0 0 0 3px var(--accent-light); }
.toolbar { position: sticky; bottom: 0; background: var(--bg); border-top: 1px solid var(--border); padding: 1rem 0; margin-top: 2rem; display: flex; justify-content: space-between; flex-wrap: wrap; gap: 1rem; }
.toolbar-left { display: flex; gap: 0.75rem; flex-wrap: wrap; }
.status { font-size: 0.85rem; color: var(--fg-muted); }
.status.saved { color: var(--success); }
.empty-state { text-align: center; padding: 3rem; color: var(--fg-muted); }
.empty-state .btn { margin-top: 1rem; }
.drag-handle { color: var(--fg-muted); cursor: grab; padding: 0.25rem; }
.drag-handle:active { cursor: grabbing; }
.section-desc { font-size: 0.875rem; color: var(--fg-muted); margin-bottom: 1rem; }
.image-preview { max-width: 100%; max-height: 150px; border-radius: var(--radius); border: 1px solid var(--border); margin-top: 0.5rem; display: none; }
.image-preview.visible { display: block; }
.file-input-wrapper { position: relative; }
.file-input-wrapper input[type="file"] { position: absolute; opacity: 0; width: 100%; height: 100%; cursor: pointer; }
.import-export { display: flex; gap: 0.5rem; flex-wrap: wrap; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>⚙️ Portfolio CMS</h1>
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<a href="index.html" class="btn btn-secondary" target="_blank">👁️ View Portfolio</a>
<label class="switch" style="margin: 0;">
<input type="checkbox" id="themeToggle" aria-label="Dark mode">
<span>Dark</span>
</label>
</div>
</header>
<nav class="nav-tabs" role="tablist" aria-label="CMS sections">
<button class="nav-tab active" role="tab" data-tab="profile">Profile</button>
<button class="nav-tab" role="tab" data-tab="projects">Projects</button>
<button class="nav-tab" role="tab" data-tab="skills">Skills</button>
<button class="nav-tab" role="tab" data-tab="experience">Experience</button>
<button class="nav-tab" role="tab" data-tab="education">Education</button>
<button class="nav-tab" role="tab" data-tab="theme">Theme</button>
</nav>
<!-- Profile Tab -->
<section id="tab-profile" role="tabpanel" aria-labelledby="profile-tab">
<div class="card">
<div class="card-header"><h2 class="card-title">Basic Info</h2></div>
<div class="form-row">
<div class="form-group">
<label>Name</label>
<input type="text" id="pName" placeholder="Your Name">
</div>
<div class="form-group">
<label>Title / Role</label>
<input type="text" id="pTitle" placeholder="Full Stack Developer">
</div>
</div>
<div class="form-group">
<label>Bio</label>
<textarea id="pBio" rows="4" placeholder="Tell visitors about yourself..."></textarea>
</div>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Avatar</h2></div>
<div class="form-group">
<label>Avatar Image URL</label>
<input type="url" id="pAvatar" placeholder="https://example.com/avatar.jpg">
<img id="pAvatarPreview" class="image-preview" alt="Avatar preview">
</div>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Social Links</h2></div>
<p class="section-desc">Add your social profiles. Leave URL empty to hide.</p>
<div class="item-list" id="socialList"></div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addSocial()">+ Add Social Link</button>
</div>
</section>
<!-- Projects Tab -->
<section id="tab-projects" role="tabpanel" aria-labelledby="projects-tab" hidden>
<div class="card">
<div class="card-header"><h2 class="card-title">Projects</h2></div>
<p class="section-desc">Showcase your work. Click a project to edit.</p>
<div class="item-list" id="projectsList"></div>
<button type="button" class="btn btn-primary btn-sm" onclick="addProject()">+ Add Project</button>
</div>
</section>
<!-- Skills Tab -->
<section id="tab-skills" role="tabpanel" aria-labelledby="skills-tab" hidden>
<div class="card">
<div class="card-header"><h2 class="card-title">Skill Categories</h2></div>
<p class="section-desc">Organize skills by category with emoji icons.</p>
<div class="item-list" id="skillsList"></div>
<button type="button" class="btn btn-primary btn-sm" onclick="addSkillCategory()">+ Add Category</button>
</div>
</section>
<!-- Experience Tab -->
<section id="tab-experience" role="tabpanel" aria-labelledby="experience-tab" hidden>
<div class="card">
<div class="card-header"><h2 class="card-title">Work Experience</h2></div>
<p class="section-desc">Add your professional history in reverse chronological order.</p>
<div class="item-list" id="experienceList"></div>
<button type="button" class="btn btn-primary btn-sm" onclick="addExperience()">+ Add Position</button>
</div>
</section>
<!-- Education Tab -->
<section id="tab-education" role="tabpanel" aria-labelledby="education-tab" hidden>
<div class="card">
<div class="card-header"><h2 class="card-title">Education</h2></div>
<div class="item-list" id="educationList"></div>
<button type="button" class="btn btn-primary btn-sm" onclick="addEducation()">+ Add Education</button>
</div>
</section>
<!-- Theme Tab -->
<section id="tab-theme" role="tabpanel" aria-labelledby="theme-tab" hidden>
<div class="card">
<div class="card-header"><h2 class="card-title">Theme Selection</h2></div>
<p class="section-desc">Choose the visual theme for your portfolio.</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<label style="display: block; padding: 1rem; border: 2px solid var(--border); border-radius: var(--radius); cursor: pointer; transition: all 0.2s;" onmouseover="this.style.borderColor='var(--accent)'" onmouseout="this.style.borderColor='var(--border)'">
<input type="radio" name="theme" value="minimalist" class="theme-radio" style="margin-right: 0.5rem;">
<strong>Modern Minimalist</strong><br>
<small style="color: var(--fg-muted);">Clean, spacious, professional</small>
</label>
<label style="display: block; padding: 1rem; border: 2px solid var(--border); border-radius: var(--radius); cursor: pointer;">
<input type="radio" name="theme" value="cyberpunk" class="theme-radio" style="margin-right: 0.5rem;">
<strong>Cyberpunk / Dark Dev</strong><br>
<small style="color: var(--fg-muted);">Neon accents, terminal aesthetic</small>
</label>
<label style="display: block; padding: 1rem; border: 2px solid var(--border); border-radius: var(--radius); cursor: pointer;">
<input type="radio" name="theme" value="elegant" class="theme-radio" style="margin-right: 0.5rem;">
<strong>Elegant Serif</strong><br>
<small style="color: var(--fg-muted);">Typography-focused, editorial</small>
</label>
<label style="display: block; padding: 1rem; border: 2px solid var(--border); border-radius: var(--radius); cursor: pointer;">
<input type="radio" name="theme" value="brutalist" class="theme-radio" style="margin-right: 0.5rem;">
<strong>Neo-Brutalism</strong><br>
<small style="color: var(--fg-muted);">Bold, high contrast, raw</small>
</label>
</div>
</div>
<div class="card">
<div class="card-header"><h2 class="card-title">Custom Colors (Advanced)</h2></div>
<p class="section-desc">Override theme colors. Leave empty to use theme defaults.</p>
<div class="form-row">
<div class="form-group">
<label>Primary Color</label>
<input type="color" id="customPrimary" class="color-picker">
</div>
<div class="form-group">
<label>Background Color</label>
<input type="color" id="customBg" class="color-picker">
</div>
</div>
</div>
</section>
<!-- Toolbar -->
<div class="toolbar">
<div class="toolbar-left">
<div class="import-export">
<button type="button" class="btn btn-secondary btn-sm" onclick="exportData()">📥 Export JSON</button>
<label class="btn btn-secondary btn-sm" style="cursor: pointer;">
📤 Import JSON
<input type="file" accept=".json" style="display: none;" onchange="importData(this.files[0])">
</label>
</div>
<button type="button" class="btn btn-danger btn-sm" onclick="resetData()">🗑️ Reset to Defaults</button>
</div>
<div>
<span class="status" id="status">Ready</span>
<button type="button" class="btn btn-primary btn-sm" onclick="saveAll()">💾 Save All</button>
</div>
</div>
</div>
<script>
const DEFAULT_DATA = {
profile: {
name: "Your Name",
title: "Full Stack Developer",
bio: "Passionate developer crafting digital experiences with modern technologies. 10+ years building web applications.",
avatar: "",
links: [
{ platform: "github", url: "https://github.com", icon: "github" },
{ platform: "linkedin", url: "https://linkedin.com", icon: "linkedin" },
{ platform: "twitter", url: "https://twitter.com", icon: "twitter" },
{ platform: "email", url: "mailto:you@example.com", icon: "email" }
]
},
projects: [
{ id: 1, title: "E-Commerce Platform", description: "Full-stack e-commerce solution with payment integration, inventory management, and admin dashboard.", image: "", tags: ["React", "Node.js", "PostgreSQL", "Stripe"], demoUrl: "#", codeUrl: "#" },
{ id: 2, title: "Task Management App", description: "Real-time collaborative task manager with drag-and-drop boards, notifications, and team workspaces.", image: "", tags: ["Vue 3", "Firebase", "Tailwind"], demoUrl: "#", codeUrl: "#" },
{ id: 3, title: "Analytics Dashboard", description: "Data visualization dashboard with real-time metrics, custom reports, and export functionality.", image: "", tags: ["Python", "FastAPI", "Chart.js", "Docker"], demoUrl: "#", codeUrl: "#" }
],
skills: [
{ category: "Frontend", icon: "🎨", items: ["React", "Vue 3", "TypeScript", "Tailwind CSS", "Vite"] },
{ category: "Backend", icon: "⚙️", items: ["Node.js", "Python", "Go", "PostgreSQL", "MongoDB", "Redis"] },
{ category: "DevOps", icon: "☁️", items: ["Docker", "Kubernetes", "AWS", "CI/CD", "Terraform"] },
{ category: "Tools", icon: "🔧", items: ["Git", "VS Code", "Figma", "Postman", "Jest"] }
],
experience: [
{ id: 1, role: "Senior Full Stack Developer", company: "Tech Company Inc.", period: "2021 - Present", description: "Leading frontend architecture for SaaS platform. Mentoring junior developers. Reduced bundle size by 40%." },
{ id: 2, role: "Full Stack Developer", company: "StartupXYZ", period: "2018 - 2021", description: "Built and maintained multiple client projects. Implemented CI/CD pipelines. Introduced TypeScript across codebase." },
{ id: 3, role: "Junior Developer", company: "Web Agency", period: "2015 - 2018", description: "Developed WordPress themes and custom plugins. Learned modern JS frameworks. Collaborated with design team." }
],
education: [
{ degree: "Bachelor of Computer Science", school: "University of Technology", year: "2015" }
],
theme: "minimalist",
customColors: { primary: "", bg: "" }
};
let data = loadData();
let nextId = getNextId();
// Tab switching
document.querySelectorAll('.nav-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('[role="tabpanel"]').forEach(p => p.hidden = true);
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).hidden = false;
});
});
// Theme toggle (CMS only)
const themeToggle = document.getElementById('themeToggle');
themeToggle.checked = document.body.dataset.theme === 'dark' || localStorage.getItem('cmsTheme') === 'dark';
themeToggle.addEventListener('change', () => {
document.body.dataset.theme = themeToggle.checked ? 'dark' : 'light';
localStorage.setItem('cmsTheme', themeToggle.checked ? 'dark' : 'light');
});
// Load & Save
function loadData() {
const stored = localStorage.getItem('portfolioData');
return stored ? JSON.parse(stored) : DEFAULT_DATA;
}
function saveData() {
localStorage.setItem('portfolioData', JSON.stringify(data));
showStatus('Saved!', true);
}
function getNextId() {
const allIds = [
...data.projects.map(p => p.id),
...data.skills.map(s => s.id || 0),
...data.experience.map(e => e.id),
...data.education.map(e => e.id || 0)
];
return Math.max(0, ...allIds) + 1;
}
function showStatus(msg, success = false) {
const el = document.getElementById('status');
el.textContent = msg;
el.classList.toggle('saved', success);
setTimeout(() => { el.textContent = 'Ready'; el.classList.remove('saved'); }, 2000);
}
// Render all tabs
function renderAll() {
renderProfile();
renderProjects();
renderSkills();
renderExperience();
renderEducation();
renderTheme();
}
// ===== PROFILE =====
function renderProfile() {
const p = data.profile;
document.getElementById('pName').value = p.name;
document.getElementById('pTitle').value = p.title;
document.getElementById('pBio').value = p.bio;
document.getElementById('pAvatar').value = p.avatar || '';
updateAvatarPreview();
['pName', 'pTitle', 'pBio', 'pAvatar'].forEach(id => {
document.getElementById(id).addEventListener('input', debounce(saveProfile, 300));
});
renderSocial();
}
function saveProfile() {
data.profile.name = document.getElementById('pName').value;
data.profile.title = document.getElementById('pTitle').value;
data.profile.bio = document.getElementById('pBio').value;
data.profile.avatar = document.getElementById('pAvatar').value;
saveData();
}
function updateAvatarPreview() {
const input = document.getElementById('pAvatar');
const preview = document.getElementById('pAvatarPreview');
if (input.value) { preview.src = input.value; preview.classList.add('visible'); }
else { preview.classList.remove('visible'); }
}
document.getElementById('pAvatar').addEventListener('input', updateAvatarPreview);
function renderSocial() {
const list = document.getElementById('socialList');
list.innerHTML = '';
const platforms = ['github', 'linkedin', 'twitter', 'email', 'website', 'youtube', 'devto', 'medium'];
data.profile.links.forEach((link, idx) => {
const card = document.createElement('div');
card.className = 'item-card';
card.innerHTML = `
<div class="item-preview">${link.platform} ${link.url ? '✓' : ''}</div>
<div class="item-actions">
<button class="btn btn-secondary btn-sm" onclick="toggleSocialExpand(${idx})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="removeSocial(${idx})">Remove</button>
</div>
<div class="item-fields" id="socialFields-${idx}">
<div class="form-group">
<label>Platform</label>
<select id="socialPlatform-${idx}">
${platforms.map(p => `<option value="${p}" ${p === link.platform ? 'selected' : ''}>${p}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>URL</label>
<input type="url" id="socialUrl-${idx}" value="${link.url || ''}" placeholder="https://...">
</div>
</div>
`;
list.appendChild(card);
document.getElementById(`socialPlatform-${idx}`).addEventListener('change', () => updateSocial(idx));
document.getElementById(`socialUrl-${idx}`).addEventListener('input', debounce(() => updateSocial(idx), 300));
});
}
function toggleSocialExpand(idx) {
document.getElementById(`socialFields-${idx}`).style.display =
document.getElementById(`socialFields-${idx}`).style.display === 'grid' ? 'none' : 'grid';
}
function updateSocial(idx) {
data.profile.links[idx].platform = document.getElementById(`socialPlatform-${idx}`).value;
data.profile.links[idx].url = document.getElementById(`socialUrl-${idx}`).value;
data.profile.links[idx].icon = data.profile.links[idx].platform;
saveData();
renderSocial();
}
function addSocial() {
data.profile.links.push({ platform: 'github', url: '', icon: 'github' });
saveData();
renderSocial();
}
function removeSocial(idx) {
if (confirm('Remove this social link?')) {
data.profile.links.splice(idx, 1);
saveData();
renderSocial();
}
}
// ===== PROJECTS =====
function renderProjects() {
const list = document.getElementById('projectsList');
list.innerHTML = '';
if (data.projects.length === 0) {
list.innerHTML = '<div class="empty-state">No projects yet.<br><button class="btn btn-primary btn-sm" onclick="addProject()">Add First Project</button></div>';
return;
}
data.projects.forEach(proj => {
const card = document.createElement('div');
card.className = 'item-card';
card.innerHTML = `
<div class="item-preview">${proj.title}</div>
<div class="item-actions">
<button class="btn btn-secondary btn-sm" onclick="toggleProjectExpand(${proj.id})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="removeProject(${proj.id})">Delete</button>
</div>
<div class="item-fields" id="projectFields-${proj.id}">
<div class="form-group">
<label>Title</label>
<input type="text" value="${proj.title}" oninput="updateProject(${proj.id}, 'title', this.value)">
</div>
<div class="form-group">
<label>Description</label>
<textarea rows="3" oninput="updateProject(${proj.id}, 'description', this.value)">${proj.description}</textarea>
</div>
<div class="form-group">
<label>Image URL</label>
<input type="url" value="${proj.image || ''}" oninput="updateProject(${proj.id}, 'image', this.value)" id="projImg-${proj.id}">
<img id="projImgPreview-${proj.id}" class="image-preview" alt="Preview">
</div>
<div class="form-row">
<div class="form-group">
<label>Demo URL</label>
<input type="url" value="${proj.demoUrl || ''}" oninput="updateProject(${proj.id}, 'demoUrl', this.value)" placeholder="https://demo.com">
</div>
<div class="form-group">
<label>Code URL</label>
<input type="url" value="${proj.codeUrl || ''}" oninput="updateProject(${proj.id}, 'codeUrl', this.value)" placeholder="https://github.com/...">
</div>
</div>
<div class="form-group">
<label>Tags</label>
<div class="tag-input" id="projTags-${proj.id}">
${proj.tags.map(t => `<span class="tag">${t}<button onclick="removeProjectTag(${proj.id}, '${t.replace(/'/g, "\\'")}')">×</button></span>`).join('')}
<input type="text" placeholder="Add tag + Enter" onkeydown="if(event.key==='Enter'){event.preventDefault();addProjectTag(${proj.id}, this.value); this.value=''}">
</div>
</div>
</div>
`;
list.appendChild(card);
document.getElementById(`projImg-${proj.id}`).addEventListener('input', () => updateProjectImagePreview(proj.id));
});
}
function toggleProjectExpand(id) {
const fields = document.getElementById(`projectFields-${id}`);
fields.style.display = fields.style.display === 'grid' ? 'none' : 'grid';
}
function updateProject(id, field, value) {
const proj = data.projects.find(p => p.id === id);
if (proj) { proj[field] = value; saveData(); }
}
function updateProjectImagePreview(id) {
const input = document.getElementById(`projImg-${id}`);
const preview = document.getElementById(`projImgPreview-${id}`);
if (input.value) { preview.src = input.value; preview.classList.add('visible'); }
else { preview.classList.remove('visible'); }
}
function addProjectTag(id, tag) {
tag = tag.trim();
if (!tag) return;
const proj = data.projects.find(p => p.id === id);
if (proj && !proj.tags.includes(tag)) { proj.tags.push(tag); saveData(); renderProjects(); }
}
function removeProjectTag(id, tag) {
const proj = data.projects.find(p => p.id === id);
if (proj) { proj.tags = proj.tags.filter(t => t !== tag); saveData(); renderProjects(); }
}
function addProject() {
const newProj = { id: nextId++, title: "New Project", description: "", image: "", tags: [], demoUrl: "", codeUrl: "" };
data.projects.unshift(newProj);
saveData();
renderProjects();
setTimeout(() => toggleProjectExpand(newProj.id), 100);
}
function removeProject(id) {
if (confirm('Delete this project?')) {
data.projects = data.projects.filter(p => p.id !== id);
saveData();
renderProjects();
}
}
// ===== SKILLS =====
function renderSkills() {
const list = document.getElementById('skillsList');
list.innerHTML = '';
data.skills.forEach((skill, idx) => {
const card = document.createElement('div');
card.className = 'item-card';
card.innerHTML = `
<div class="item-preview">${skill.icon} ${skill.category}</div>
<div class="item-actions">
<button class="btn btn-secondary btn-sm" onclick="toggleSkillExpand(${idx})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="removeSkill(${idx})">Delete</button>
</div>
<div class="item-fields" id="skillFields-${idx}">
<div class="form-row">
<div class="form-group">
<label>Category</label>
<input type="text" value="${skill.category}" oninput="updateSkill(${idx}, 'category', this.value)">
</div>
<div class="form-group">
<label>Icon (emoji)</label>
<input type="text" value="${skill.icon}" maxlength="2" oninput="updateSkill(${idx}, 'icon', this.value)" style="max-width: 60px;">
</div>
</div>
<div class="form-group">
<label>Skills (comma separated)</label>
<input type="text" value="${skill.items.join(', ')}" oninput="updateSkillItems(${idx}, this.value)">
</div>
</div>
`;
list.appendChild(card);
});
}
function toggleSkillExpand(idx) {
const fields = document.getElementById(`skillFields-${idx}`);
fields.style.display = fields.style.display === 'grid' ? 'none' : 'grid';
}
function updateSkill(idx, field, value) {
if (data.skills[idx]) { data.skills[idx][field] = value; saveData(); renderSkills(); }
}
function updateSkillItems(idx, value) {
if (data.skills[idx]) {
data.skills[idx].items = value.split(',').map(s => s.trim()).filter(Boolean);
saveData();
}
}
function addSkillCategory() {
data.skills.push({ category: "New Category", icon: "⭐", items: [] });
saveData();
renderSkills();
setTimeout(() => toggleSkillExpand(data.skills.length - 1), 100);
}
function removeSkill(idx) {
if (confirm('Delete this skill category?')) {
data.skills.splice(idx, 1);
saveData();
renderSkills();
}
}
// ===== EXPERIENCE =====
function renderExperience() {
const list = document.getElementById('experienceList');
list.innerHTML = '';
data.experience.forEach(exp => {
const card = document.createElement('div');
card.className = 'item-card';
card.innerHTML = `
<div class="item-preview">${exp.role} @ ${exp.company}</div>
<div class="item-actions">
<button class="btn btn-secondary btn-sm" onclick="toggleExpExpand(${exp.id})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="removeExperience(${exp.id})">Delete</button>
</div>
<div class="item-fields" id="expFields-${exp.id}">
<div class="form-row">
<div class="form-group">
<label>Role</label>
<input type="text" value="${exp.role}" oninput="updateExperience(${exp.id}, 'role', this.value)">
</div>
<div class="form-group">
<label>Company</label>
<input type="text" value="${exp.company}" oninput="updateExperience(${exp.id}, 'company', this.value)">
</div>
</div>
<div class="form-group">
<label>Period</label>
<input type="text" value="${exp.period}" oninput="updateExperience(${exp.id}, 'period', this.value)" placeholder="2021 - Present">
</div>
<div class="form-group">
<label>Description</label>
<textarea rows="3" oninput="updateExperience(${exp.id}, 'description', this.value)">${exp.description}</textarea>
</div>
</div>
`;
list.appendChild(card);
});
}
function toggleExpExpand(id) {
const fields = document.getElementById(`expFields-${id}`);
fields.style.display = fields.style.display === 'grid' ? 'none' : 'grid';
}
function updateExperience(id, field, value) {
const exp = data.experience.find(e => e.id === id);
if (exp) { exp[field] = value; saveData(); }
}
function addExperience() {
const newExp = { id: nextId++, role: "New Role", company: "Company", period: "2024 - Present", description: "" };
data.experience.unshift(newExp);
saveData();
renderExperience();
setTimeout(() => toggleExpExpand(newExp.id), 100);
}
function removeExperience(id) {
if (confirm('Delete this experience?')) {
data.experience = data.experience.filter(e => e.id !== id);
saveData();
renderExperience();
}
}
// ===== EDUCATION =====
function renderEducation() {
const list = document.getElementById('educationList');
list.innerHTML = '';
data.education.forEach((edu, idx) => {
const card = document.createElement('div');
card.className = 'item-card';
card.innerHTML = `
<div class="item-preview">${edu.degree}, ${edu.school}</div>
<div class="item-actions">
<button class="btn btn-secondary btn-sm" onclick="toggleEduExpand(${idx})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="removeEducation(${idx})">Delete</button>
</div>
<div class="item-fields" id="eduFields-${idx}">
<div class="form-group">
<label>Degree</label>
<input type="text" value="${edu.degree}" oninput="updateEducation(${idx}, 'degree', this.value)">
</div>
<div class="form-group">
<label>School</label>
<input type="text" value="${edu.school}" oninput="updateEducation(${idx}, 'school', this.value)">
</div>
<div class="form-group">
<label>Year</label>
<input type="text" value="${edu.year}" oninput="updateEducation(${idx}, 'year', this.value)" placeholder="2015">
</div>
</div>
`;
list.appendChild(card);
});
}
function toggleEduExpand(idx) {
const fields = document.getElementById(`eduFields-${idx}`);
fields.style.display = fields.style.display === 'grid' ? 'none' : 'grid';
}
function updateEducation(idx, field, value) {
if (data.education[idx]) { data.education[idx][field] = value; saveData(); }
}
function addEducation() {
data.education.push({ degree: "New Degree", school: "School", year: "2024" });
saveData();
renderEducation();
setTimeout(() => toggleEduExpand(data.education.length - 1), 100);
}
function removeEducation(idx) {
if (confirm('Delete this education entry?')) {
data.education.splice(idx, 1);
saveData();
renderEducation();
}
}
// ===== THEME =====
function renderTheme() {
document.querySelector(`input[name="theme"][value="${data.theme}"]`)?.checked = true;
document.querySelectorAll('.theme-radio').forEach(radio => {
radio.addEventListener('change', () => {
data.theme = radio.value;
saveData();
// Apply to preview if opened
const preview = window.open('', '_blank');
if (preview && !preview.closed) {
preview.postMessage({ type: 'themeChange', theme: radio.value }, '*');
}
});
});
document.getElementById('customPrimary').value = data.customColors?.primary || '';
document.getElementById('customBg').value = data.customColors?.bg || '';
document.getElementById('customPrimary').addEventListener('input', () => {
data.customColors = data.customColors || {}; data.customColors.primary = document.getElementById('customPrimary').value; saveData();
});
document.getElementById('customBg').addEventListener('input', () => {
data.customColors = data.customColors || {}; data.customColors.bg = document.getElementById('customBg').value; saveData();
});
}
// ===== TOOLBAR ACTIONS =====
function saveAll() {
saveProfile();
saveData();
}
function exportData() {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'portfolio-data.json'; a.click();
URL.revokeObjectURL(url);
showStatus('Exported!', true);
}
function importData(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const imported = JSON.parse(e.target.result);
data = { ...DEFAULT_DATA, ...imported };
nextId = getNextId();
saveData();
renderAll();
showStatus('Imported!', true);
} catch (err) {
alert('Invalid JSON file');
}
};
reader.readAsText(file);
}
function resetData() {
if (confirm('Reset ALL data to defaults? This cannot be undone.')) {
localStorage.removeItem('portfolioData');
data = DEFAULT_DATA;
nextId = getNextId();
renderAll();
showStatus('Reset to defaults', true);
}
}
// Debounce helper
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// Initialize
renderAll();
</script>
</body>
</html>