Last active
January 26, 2026 06:51
-
-
Save andyg2/9763bf3e75b1c057ccadad9d39b31ef7 to your computer and use it in GitHub Desktop.
Lightweight, single-file Kanban board for tracking handmade projects. Features drag-and-drop progress, priority levels, and metadata for materials and deadlines. Built with Tailwind CSS and SortableJS; persists data to LocalStorage.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Project Studio Tracker</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/lucide@latest"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js"></script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@400;600;700&display=swap'); | |
| body { | |
| font-family: 'Quicksand', sans-serif; | |
| background-color: #f8fafc; | |
| } | |
| .kanban-column { | |
| min-height: 70vh; | |
| transition: background-color 0.2s ease; | |
| } | |
| .sortable-ghost { | |
| opacity: 0.4; | |
| transform: scale(0.95); | |
| background: #e2e8f0 !important; | |
| } | |
| .priority-high { | |
| border-left: 4px solid #ef4444; | |
| } | |
| .priority-medium { | |
| border-left: 4px solid #f59e0b; | |
| } | |
| .priority-low { | |
| border-left: 4px solid #10b981; | |
| } | |
| </style> | |
| </head> | |
| <body class="p-4 md:p-8"> | |
| <!-- Header Section --> | |
| <div class="max-w-7xl mx-auto flex flex-col md:flex-row justify-between items-center mb-8 gap-4"> | |
| <div> | |
| <h1 class="text-4xl font-bold text-slate-800 flex items-center gap-3"> | |
| <i data-lucide="layout" class="text-indigo-600"></i> | |
| Project Studio | |
| </h1> | |
| <p class="text-slate-500 text-lg">Manage your creations and track progress.</p> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <!-- Data Actions --> | |
| <button onclick="exportData()" title="Export Data (Backup)" | |
| class="p-3 text-slate-500 hover:text-indigo-600 bg-white border border-slate-200 rounded-full shadow-sm transition-all"> | |
| <i data-lucide="download"></i> | |
| </button> | |
| <label title="Import Data" class="p-3 text-slate-500 hover:text-indigo-600 bg-white border border-slate-200 rounded-full shadow-sm transition-all cursor-pointer"> | |
| <i data-lucide="upload"></i> | |
| <input type="file" id="importFile" class="hidden" accept=".json" onchange="importData(event)"> | |
| </label> | |
| <button onclick="openModal()" class="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-full font-bold shadow-lg transition-all flex items-center gap-2"> | |
| <i data-lucide="plus"></i> New Project | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Kanban Board --> | |
| <div class="max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> | |
| <!-- Columns use data-status for logic mapping --> | |
| <div class="flex flex-col"> | |
| <div class="flex items-center justify-between mb-4 px-2"> | |
| <h2 class="font-bold text-slate-700 uppercase tracking-wider flex items-center gap-2"><i data-lucide="brain-circuit" class="w-4 h-4 text-slate-400"></i> Planning</h2> | |
| <span id="count-planning" class="bg-slate-200 text-slate-600 text-xs px-2 py-1 rounded-full">0</span> | |
| </div> | |
| <div id="col-planning" data-status="planning" class="kanban-column bg-slate-100/60 rounded-xl p-3 border-2 border-dashed border-slate-200 space-y-4"></div> | |
| </div> | |
| <div class="flex flex-col"> | |
| <div class="flex items-center justify-between mb-4 px-2"> | |
| <h2 class="font-bold text-blue-700 uppercase tracking-wider flex items-center gap-2"><i data-lucide="hammer" class="w-4 h-4 text-blue-400"></i> Creating</h2> | |
| <span id="count-sculpting" class="bg-blue-100 text-blue-600 text-xs px-2 py-1 rounded-full">0</span> | |
| </div> | |
| <div id="col-sculpting" data-status="sculpting" class="kanban-column bg-blue-50/50 rounded-xl p-3 border-2 border-dashed border-blue-100 space-y-4"></div> | |
| </div> | |
| <div class="flex flex-col"> | |
| <div class="flex items-center justify-between mb-4 px-2"> | |
| <h2 class="font-bold text-amber-700 uppercase tracking-wider flex items-center gap-2"><i data-lucide="wand-2" class="w-4 h-4 text-amber-400"></i> Editing</h2> | |
| <span id="count-decorating" class="bg-amber-100 text-amber-600 text-xs px-2 py-1 rounded-full">0</span> | |
| </div> | |
| <div id="col-decorating" data-status="decorating" class="kanban-column bg-amber-50/50 rounded-xl p-3 border-2 border-dashed border-amber-100 space-y-4"></div> | |
| </div> | |
| <div class="flex flex-col"> | |
| <div class="flex items-center justify-between mb-4 px-2"> | |
| <h2 class="font-bold text-emerald-700 uppercase tracking-wider flex items-center gap-2"><i data-lucide="party-popper" class="w-4 h-4 text-emerald-400"></i> Finished | |
| </h2> | |
| <span id="count-finished" class="bg-emerald-100 text-emerald-600 text-xs px-2 py-1 rounded-full">0</span> | |
| </div> | |
| <div id="col-finished" data-status="finished" class="kanban-column bg-emerald-50/50 rounded-xl p-3 border-2 border-dashed border-emerald-100 space-y-4"></div> | |
| </div> | |
| </div> | |
| <!-- Modal Form --> | |
| <div id="modal" class="fixed inset-0 bg-slate-900/60 hidden flex items-center justify-center p-4 z-50 overflow-y-auto"> | |
| <div class="bg-white rounded-2xl w-full max-w-md p-6 shadow-2xl my-8"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h3 id="modal-title" class="text-2xl font-bold text-slate-800">New Project</h3> | |
| <button onclick="closeModal()" class="text-slate-400 hover:text-slate-600 p-1"><i data-lucide="x"></i></button> | |
| </div> | |
| <form id="figurine-form" class="space-y-4"> | |
| <input type="hidden" id="edit-id"> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div class="col-span-2"> | |
| <label class="block text-sm font-semibold text-slate-600 mb-1">Character / Object *</label> | |
| <input type="text" id="character" required class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"> | |
| </div> | |
| <div class="col-span-2"> | |
| <label class="block text-sm font-semibold text-slate-600 mb-1">Material</label> | |
| <input type="text" id="material" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none" placeholder="e.g. Clay, Wood, Yarn"> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label class="block text-sm font-semibold text-slate-600 mb-1">Recipient</label> | |
| <input type="text" id="recipient" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-semibold text-slate-600 mb-1">Due Date</label> | |
| <input type="date" id="dueDate" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"> | |
| </div> | |
| </div> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label class="block text-sm font-semibold text-slate-600 mb-1">Size</label> | |
| <select id="size" class="w-full px-4 py-2 border rounded-lg bg-white"> | |
| <option value="Small">Small</option> | |
| <option value="Medium" selected>Medium</option> | |
| <option value="Large">Large</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-semibold text-slate-600 mb-1">Priority</label> | |
| <select id="priority" class="w-full px-4 py-2 border rounded-lg bg-white"> | |
| <option value="low">Low</option> | |
| <option value="medium" selected>Medium</option> | |
| <option value="high">High</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-semibold text-slate-600 mb-1">Current Stage</label> | |
| <select id="status" class="w-full px-4 py-2 border rounded-lg text-indigo-800 font-bold bg-indigo-50"> | |
| <option value="planning">📋 Planning</option> | |
| <option value="sculpting">🛠️ Creating</option> | |
| <option value="decorating">✨ Editing / Polishing</option> | |
| <option value="finished">✅ Finished!</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-semibold text-slate-600 mb-1">Notes</label> | |
| <textarea id="description" rows="2" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"></textarea> | |
| </div> | |
| <div class="flex gap-3 pt-4"> | |
| <button type="button" onclick="closeModal()" class="flex-1 px-4 py-2 border border-slate-300 rounded-lg">Cancel</button> | |
| <button type="submit" class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg font-bold">Save</button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| <script> | |
| const STORAGE_KEY = 'clay_projects'; | |
| let figurines = JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; | |
| const form = document.getElementById('figurine-form'); | |
| const modal = document.getElementById('modal'); | |
| renderBoard(); | |
| initDragAndDrop(); | |
| form.addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| const id = document.getElementById('edit-id').value || Date.now().toString(); | |
| const figurineData = { | |
| id, | |
| character: document.getElementById('character').value, | |
| material: document.getElementById('material').value, | |
| recipient: document.getElementById('recipient').value, | |
| dueDate: document.getElementById('dueDate').value, | |
| size: document.getElementById('size').value, | |
| priority: document.getElementById('priority').value, | |
| status: document.getElementById('status').value, | |
| description: document.getElementById('description').value, | |
| updatedAt: new Date().toISOString() | |
| }; | |
| const index = figurines.findIndex(f => f.id === id); | |
| if (index > -1) figurines[index] = figurineData; | |
| else figurines.push(figurineData); | |
| saveAndRender(); | |
| closeModal(); | |
| }); | |
| function saveAndRender() { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(figurines)); | |
| renderBoard(); | |
| } | |
| function initDragAndDrop() { | |
| ['col-planning', 'col-sculpting', 'col-decorating', 'col-finished'].forEach(colId => { | |
| new Sortable(document.getElementById(colId), { | |
| group: 'kanban', | |
| animation: 150, | |
| ghostClass: 'sortable-ghost', | |
| onEnd: (evt) => { | |
| const newStatus = evt.to.getAttribute('data-status'); | |
| const itemId = evt.item.getAttribute('data-id'); | |
| const index = figurines.findIndex(f => f.id === itemId); | |
| if (index > -1) { | |
| figurines[index].status = newStatus; | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(figurines)); | |
| updateCounts(); | |
| } | |
| } | |
| }); | |
| }); | |
| } | |
| function renderBoard() { | |
| ['planning', 'sculpting', 'decorating', 'finished'].forEach(c => document.getElementById(`col-${c}`).innerHTML = ''); | |
| const pMap = { high: 1, medium: 2, low: 3 }; | |
| [...figurines].sort((a, b) => pMap[a.priority] - pMap[b.priority]).forEach(f => { | |
| const card = document.createElement('div'); | |
| card.setAttribute('data-id', f.id); | |
| card.className = `bg-white p-4 rounded-xl shadow-sm border border-slate-200 priority-${f.priority} hover:shadow-md transition-shadow cursor-grab active:cursor-grabbing relative group`; | |
| card.onclick = (e) => { if (!e.target.closest('button')) editFigurine(f.id); }; | |
| card.innerHTML = ` | |
| <div class="flex justify-between items-start mb-1 pointer-events-none"> | |
| <h4 class="font-bold text-slate-800 text-lg leading-tight">${f.character}</h4> | |
| <span class="text-[10px] font-bold uppercase px-2 py-0.5 rounded-full bg-slate-100 text-slate-500 whitespace-nowrap ml-2">${f.size}</span> | |
| </div> | |
| ${f.material ? `<p class="text-xs font-semibold text-indigo-600 flex items-center gap-1 mb-2 pointer-events-none"><i data-lucide="package" class="w-3 h-3"></i> ${f.material}</p>` : ''} | |
| <div class="space-y-1 mb-3 pointer-events-none text-sm text-slate-600"> | |
| ${f.recipient ? `<p class="flex items-center gap-1"><i data-lucide="user" class="w-3 h-3"></i> ${f.recipient}</p>` : ''} | |
| ${f.dueDate ? `<p class="flex items-center gap-1 ${isOverdue(f.dueDate) ? 'text-red-500 font-bold' : 'text-slate-500'}"><i data-lucide="calendar" class="w-3 h-3"></i> ${formatDate(f.dueDate)}</p>` : ''} | |
| </div> | |
| <p class="text-xs text-slate-400 italic line-clamp-2 pointer-events-none">${f.description || ''}</p> | |
| <div class="mt-4 flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <button onclick="deleteFigurine('${f.id}')" class="p-1.5 text-slate-300 hover:text-red-500 hover:bg-red-50 rounded"><i data-lucide="trash-2" class="w-4 h-4"></i></button> | |
| </div>`; | |
| document.getElementById(`col-${f.status}`).appendChild(card); | |
| }); | |
| updateCounts(); | |
| lucide.createIcons(); | |
| } | |
| function updateCounts() { | |
| ['planning', 'sculpting', 'decorating', 'finished'].forEach(c => { | |
| document.getElementById(`count-${c}`).innerText = figurines.filter(f => f.status === c).length; | |
| }); | |
| } | |
| function exportData() { | |
| const dataStr = JSON.stringify(figurines, null, 2); | |
| const blob = new Blob([dataStr], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = `project-studio-backup-${new Date().toISOString().split('T')[0]}.json`; | |
| link.click(); | |
| } | |
| function importData(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = function (e) { | |
| try { | |
| const imported = JSON.parse(e.target.result); | |
| if (Array.isArray(imported) && confirm('This will replace your current list. Continue?')) { | |
| figurines = imported; | |
| saveAndRender(); | |
| } | |
| } catch (err) { alert('Invalid file format.'); } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| function openModal() { form.reset(); document.getElementById('edit-id').value = ''; document.getElementById('modal-title').innerText = 'New Project'; modal.classList.remove('hidden'); } | |
| function closeModal() { modal.classList.add('hidden'); } | |
| function editFigurine(id) { | |
| const f = figurines.find(fig => fig.id === id); | |
| if (!f) return; | |
| document.getElementById('edit-id').value = f.id; | |
| document.getElementById('character').value = f.character; | |
| document.getElementById('material').value = f.material || ''; | |
| document.getElementById('recipient').value = f.recipient; | |
| document.getElementById('dueDate').value = f.dueDate; | |
| document.getElementById('size').value = f.size; | |
| document.getElementById('priority').value = f.priority; | |
| document.getElementById('status').value = f.status; | |
| document.getElementById('description').value = f.description; | |
| document.getElementById('modal-title').innerText = 'Edit Project'; | |
| modal.classList.remove('hidden'); | |
| } | |
| function deleteFigurine(id) { if (confirm('Delete this project?')) { figurines = figurines.filter(f => f.id !== id); saveAndRender(); } } | |
| function formatDate(d) { return new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } | |
| function isOverdue(d) { return d && new Date(d).getTime() < new Date().setHours(0, 0, 0, 0); } | |
| window.onclick = (e) => { if (e.target == modal) closeModal(); } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment