Initial commit: MVP+ wish board with FastAPI + Tailwind
This commit is contained in:
commit
cd945a6128
2277 files changed
+843095
No files matched your search
+137
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Доска желаний</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
.dragging{opacity:.5;transform:rotate(5deg)}.drag-over{background:#e0e7ff;border:2px dashed #6366f1}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-500 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<h1 class="text-5xl font-bold text-white text-center mb-8 drop-shadow-lg">✨ Доска желаний</h1>
|
||||
<div id="app"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API='/api', WS='ws://'+location.host+'/ws';
|
||||
let ws, currentBoard=null, editingList=null, editingCard=null;
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(WS);
|
||||
ws.onmessage = e => {
|
||||
const m = JSON.parse(e.data);
|
||||
if (m.type==='board:create'||m.type==='board:delete'||m.type==='board:update') refresh();
|
||||
if (m.type==='list:create'||m.type==='list:update'||m.type==='list:delete') refresh();
|
||||
if (m.type==='card:create'||m.type==='card:update'||m.type==='card:delete') refresh();
|
||||
};
|
||||
ws.onclose = () => setTimeout(connect, 3000);
|
||||
}
|
||||
|
||||
async function fetchJSON(url, opts={}) {
|
||||
const r = await fetch(url, opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!currentBoard) {
|
||||
const b = await fetchJSON(API+'/boards');
|
||||
if (b.length && !currentBoard) currentBoard = b[0];
|
||||
render();
|
||||
} else {
|
||||
const lists = await fetchJSON(API+'/boards/'+currentBoard.id+'/lists');
|
||||
renderBoard(lists);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const app = document.getElementById('app');
|
||||
if (!currentBoard) {
|
||||
app.innerHTML = `
|
||||
<div class="bg-white rounded-3xl shadow-2xl p-8 max-w-md mx-auto">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-6 text-center">Создать доску</h2>
|
||||
<form onsubmit="createBoard(event)">
|
||||
<div class="mb-4"><label class="block text-sm font-medium text-gray-700 mb-2">Название</label>
|
||||
<input type="text" id="bn" class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-indigo-500" placeholder="Моя доска" required></div>
|
||||
<div class="mb-6"><label class="block text-sm font-medium text-gray-700 mb-2">Цвет</label>
|
||||
<div class="flex gap-2 flex-wrap" id="cp"></div>
|
||||
<input type="hidden" id="bc" value="#6366f1"></div>
|
||||
<button type="submit" class="w-full bg-indigo-600 text-white py-3 rounded-xl hover:bg-indigo-700 font-medium">Создать</button>
|
||||
</form></div>`;
|
||||
['#6366f1','#8b5cf6','#ec4899','#f43f5e','#f97316','#eab308','#22c55e','#06b6d4'].forEach((c,i)=>{
|
||||
const b=document.createElement('button');
|
||||
b.type='button'; b.className=`w-10 h-10 rounded-full border-2 ${i===0?'border-gray-800 scale-110':'border-transparent'}`;
|
||||
b.style.background=c; b.onclick=()=>{document.querySelectorAll('#cp button').forEach(x=>x.className='w-10 h-10 rounded-full border-2 border-transparent');b.className='w-10 h-10 rounded-full border-2 border-gray-800 scale-110';document.getElementById('bc').value=c};
|
||||
document.getElementById('cp').appendChild(b);
|
||||
});
|
||||
} else refresh();
|
||||
}
|
||||
|
||||
function renderBoard(lists) {
|
||||
document.getElementById('app').innerHTML = `
|
||||
<div class="bg-white/10 backdrop-blur-md rounded-3xl shadow-2xl p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-3"><div class="w-4 h-4 rounded-full" style="background:${currentBoard.color}"></div>
|
||||
<h2 class="text-2xl font-bold text-white">${currentBoard.name}</h2></div>
|
||||
<button onclick="delBoard()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm">Удалить</button>
|
||||
</div>
|
||||
<div class="flex gap-4 overflow-x-auto pb-4 min-h-[400px]" id="lc">${lists.map(l=>renderList(l)).join('')}</div>
|
||||
<div class="mt-6 flex gap-2">
|
||||
<input type="text" id="nl" class="flex-1 px-4 py-3 bg-white/20 backdrop-blur-sm text-white placeholder-white/60 border border-white/30 rounded-xl focus:ring-2 focus:ring-white/50" placeholder="Новый список..." onkeypress="if(event.key==='Enter')addList()">
|
||||
<button onclick="addList()" class="px-6 py-3 bg-white text-gray-800 rounded-xl hover:bg-gray-100 font-medium">Добавить</button>
|
||||
</div></div>`;
|
||||
lists.forEach(l=>loadCards(l.id));
|
||||
}
|
||||
|
||||
function renderList(list) {
|
||||
return `<div class="bg-white rounded-2xl shadow-lg p-4 min-w-[280px] max-w-[320px] flex-shrink-0" data-lid="${list.id}"
|
||||
ondragover="event.preventDefault();this.classList.add('drag-over')" ondragleave="this.classList.remove('drag-over')" ondrop="dropList(event,'${list.id}')">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
${editingList===list.id?`<input type="text" class="flex-1 px-2 py-1 border border-gray-300 rounded text-sm font-semibold" value="${list.title}" onblur="saveTitle('${list.id}',this.value)" onkeypress="if(event.key==='Enter')saveTitle('${list.id}',this.value)">`
|
||||
:`<h3 class="font-semibold text-gray-800 cursor-pointer hover:text-gray-600" ondblclick="editTitle('${list.id}','${list.title.replace(/'/g,"\\'")}')">${list.title}</h3>`}
|
||||
<button onclick="delList('${list.id}')" class="p-1 text-gray-400 hover:text-red-600"><svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg></button>
|
||||
</div>
|
||||
<div class="space-y-2 min-h-[100px]" id="cc-${list.id}"></div>
|
||||
<div class="mt-3 pt-3 border-t border-gray-200"><div class="flex gap-1">
|
||||
<input type="text" class="flex-1 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-indigo-500" placeholder="Карточка..." onkeypress="if(event.key==='Enter')addCard('${list.id}',this)">
|
||||
<button onclick="addCard('${list.id}',this.previousElementSibling)" class="px-3 py-1 bg-indigo-600 text-white text-sm rounded hover:bg-indigo-700">+</button></div></div></div>`;
|
||||
}
|
||||
|
||||
function renderCard(card) {
|
||||
return `<div class="bg-white border border-gray-200 rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow cursor-move" draggable="true" data-cid="${card.id}"
|
||||
ondragstart="ds(event,'${card.id}')" ondragover="event.preventDefault();this.classList.add('drag-over')" ondragleave="this.classList.remove('drag-over')" ondrop="dc(event,'${card.id}')">
|
||||
${editingCard===card.id?`<div class="space-y-2"><input type="text" class="w-full px-2 py-1 border border-gray-300 rounded text-sm" value="${card.title}" id="et-${card.id}"><textarea class="w-full px-2 py-1 border border-gray-300 rounded text-sm resize-none" rows="3" id="ed-${card.id}">${card.description}</textarea>
|
||||
<div class="flex gap-2"><button onclick="saveCard('${card.id}')" class="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700">OK</button><button onclick="render()" class="text-xs px-2 py-1 bg-gray-600 text-white rounded hover:bg-gray-700">Отмена</button></div></div>`
|
||||
:`<div><p class="text-sm font-medium">${card.title}</p>${card.description?`<p class="text-xs text-gray-600 mt-1 line-clamp-2">${card.description}</p>`:''}
|
||||
<div class="flex justify-end mt-2 gap-1"><button onclick="editCard('${card.id}')" class="text-xs text-gray-400 hover:text-gray-600">✏️</button><button onclick="delCard('${card.id}')" class="text-xs text-gray-400 hover:text-red-600">🗑️</button></div></div>`}</div>`;
|
||||
}
|
||||
|
||||
async function loadCards(lid) {
|
||||
const cards = await fetchJSON(API+'/lists/'+lid+'/cards');
|
||||
document.getElementById('cc-'+lid).innerHTML = cards.map(c=>renderCard(c)).join('');
|
||||
}
|
||||
|
||||
async function createBoard(e) { e.preventDefault(); const b = await fetchJSON(API+'/boards',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:document.getElementById('bn').value,color:document.getElementById('bc').value})}); currentBoard=b; render(); }
|
||||
async function delBoard() { if(confirm('Удалить?')){await fetch(API+'/boards/'+currentBoard.id,{method:'DELETE'});const b=await fetchJSON(API+'/boards');currentBoard=b[0]||null;render();} }
|
||||
async function addList() { const v=document.getElementById('nl').value;if(!v.trim())return; await fetch(API+'/boards/'+currentBoard.id+'/lists',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:v.trim()})});document.getElementById('nl').value='';refresh(); }
|
||||
async function delList(lid) { if(confirm('Удалить?')){await fetch(API+'/lists/'+lid,{method:'DELETE'});refresh();} }
|
||||
async function editTitle(lid,t) { editingList=lid; render(); }
|
||||
async function saveTitle(lid,v) { if(v.trim()){await fetch(API+'/lists/'+lid,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:v.trim()})});editingList=null;render();} }
|
||||
async function addCard(lid,input) { if(!input.value.trim())return; await fetch(API+'/lists/'+lid+'/cards',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:input.value.trim()})});input.value='';refresh(); }
|
||||
async function delCard(cid) { await fetch(API+'/cards/'+cid,{method:'DELETE'});refresh(); }
|
||||
async function editCard(cid) { editingCard=cid; refresh(); }
|
||||
async function saveCard(cid) { const lid=document.querySelector('[data-lid]').dataset.lid; const t=document.getElementById('et-'+cid).value, d=document.getElementById('ed-'+cid).value; await fetch(API+'/cards/'+cid,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:t,description:d})});editingCard=null;refresh(); }
|
||||
|
||||
let dragged=null;
|
||||
function ds(e,cid) { dragged=cid; e.target.classList.add('dragging'); }
|
||||
function dropList(e,lid) { e.preventDefault(); e.target.closest('[data-lid]').classList.remove('drag-over'); }
|
||||
async function dropCard(e,tcid) {
|
||||
e.preventDefault(); const tl=e.target.closest('[data-lid]'), sl=document.querySelector('[data-cid="'+dragged+'"]').closest('[data-lid]');
|
||||
if(tl&&sl&&dragged){tl.classList.remove('drag-over');const sc=await fetchJSON(API+'/lists/'+sl.dataset.lid+'/cards'),tc=await fetchJSON(API+'/lists/'+tl.dataset.lid+'/cards');const card=sc.find(c=>c.id===dragged);if(card){sc.splice(sc.findIndex(c=>c.id===dragged),1);tc.push(card);await fetch(API+'/lists/'+sl.dataset.lid+'/cards',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(sc)});await fetch(API+'/lists/'+tl.dataset.lid+'/cards',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(tc)});}dragged=null;refresh();}
|
||||
}
|
||||
|
||||
connect(); render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user