Spaces:
Running
Running
Download cms.html from sehanlim/portfolio-cms: direct link, hf CLI and curl.
- Browser
- Download file 43.6 kB
-
https://huggingface.co/spaces/sehanlim/portfolio-cms/resolve/main/cms.html
- Command line
-
hf download hf://spaces/sehanlim/portfolio-cms/cms.html
-
curl -L -o cms.html https://huggingface.co/spaces/sehanlim/portfolio-cms/resolve/main/cms.html
43.6 kB
| <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> |