Skip to content

Instantly share code, notes, and snippets.

@andyg2
Last active January 26, 2026 06:51
Show Gist options
  • Select an option

  • Save andyg2/9763bf3e75b1c057ccadad9d39b31ef7 to your computer and use it in GitHub Desktop.

Select an option

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.
<!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