Files

272 lines
17 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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}
.card-image{width:100%;height:120px;object-fit:cover;border-radius:0.5rem;margin-bottom:0.5rem}
.status-want{border-left:4px solid #3b82f6}.status-in_progress{border-left:4px solid #f59e0b}.status-bought{border-left:4px solid #10b981}.status-deferred{border-left:4px solid #6b7280}
.star{cursor:pointer;font-size:1.2rem}.star:hover{transform:scale(1.2)}
</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.startsWith('board:') || m.type.startsWith('list:') || m.type.startsWith('card:')) 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();
}
async function renderBoard(lists) {
const stats = await fetchJSON(API+'/stats/'+currentBoard.id);
const categories = await fetchJSON(API+'/categories/'+currentBoard.id);
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>
<div class="flex gap-2">
<div class="bg-white/20 backdrop-blur-sm rounded-lg px-4 py-2 text-white text-sm">
💰 Итого: ${stats.total.toLocaleString()} ₽
</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>
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-blue-600/80 backdrop-blur-sm rounded-lg p-3 text-white text-center">
<div class="text-2xl font-bold">${stats.want}</div>
<div class="text-xs">Хочу</div>
</div>
<div class="bg-yellow-600/80 backdrop-blur-sm rounded-lg p-3 text-white text-center">
<div class="text-2xl font-bold">${stats.in_progress}</div>
<div class="text-xs">В процессе</div>
</div>
<div class="bg-green-600/80 backdrop-blur-sm rounded-lg p-3 text-white text-center">
<div class="text-2xl font-bold">${stats.bought}</div>
<div class="text-xs">Куплено</div>
</div>
<div class="bg-gray-600/80 backdrop-blur-sm rounded-lg p-3 text-white text-center">
<div class="text-2xl font-bold">${stats.deferred}</div>
<div class="text-xs">Отложено</div>
</div>
</div>
${categories.categories.length > 0 ? `
<div class="mb-4">
<div class="text-white/80 text-sm mb-2">Категории:</div>
<div class="flex gap-2 flex-wrap">
${categories.categories.map(c => `<span class="bg-white/20 backdrop-blur-sm text-white px-3 py-1 rounded-full text-sm">${c}</span>`).join('')}
</div>
</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-[320px] max-w-[360px] 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 mb-2">
<input type="text" id="cat-${list.id}" 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')saveCategory('${list.id}',this)">
</div>
<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) {
const statusColors = {want:'bg-blue-100 text-blue-800', in_progress:'bg-yellow-100 text-yellow-800', bought:'bg-green-100 text-green-800', deferred:'bg-gray-100 text-gray-800'};
const statusLabels = {want:'Хочу', in_progress:'В процессе', bought:'Куплено', deferred:'Отложено'};
return `<div class="bg-white border border-gray-200 rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow ${'status-'+card.status}" 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}')">
${card.image_url ? `<img src="${card.image_url}" class="card-image" onerror="this.style.display='none'">` : ''}
${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="2" id="ed-${card.id}">${card.description}</textarea>
<input type="text" class="w-full px-2 py-1 border border-gray-300 rounded text-sm" value="${card.image_url}" placeholder="URL изображения" id="ei-${card.id}">
<input type="number" class="w-full px-2 py-1 border border-gray-300 rounded text-sm" value="${card.price}" placeholder="Цена" id="ep-${card.id}">
<input type="text" class="w-full px-2 py-1 border border-gray-300 rounded text-sm" value="${card.url}" placeholder="Ссылка на товар" id="eu-${card.id}">
<select class="w-full px-2 py-1 border border-gray-300 rounded text-sm" id="es-${card.id}">
<option value="want" ${card.status==='want'?'selected':''}>Хочу</option>
<option value="in_progress" ${card.status==='in_progress'?'selected':''}>В процессе</option>
<option value="bought" ${card.status==='bought'?'selected':''}>Куплено</option>
<option value="deferred" ${card.status==='deferred'?'selected':''}>Отложено</option>
</select>
<input type="text" class="w-full px-2 py-1 border border-gray-300 rounded text-sm" value="${card.category}" placeholder="Категория" id="ec-${card.id}">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">Рейтинг:</span>
<div class="flex gap-1" id="rating-${card.id}">
${[1,2,3,4,5].map(i => `<span class="star ${i<=card.rating?'text-yellow-500':'text-gray-300'}" onclick="setRating('${card.id}',${i})">${i<=card.rating?'★':'☆'}</span>`).join('')}
</div>
</div>
<textarea class="w-full px-2 py-1 border border-gray-300 rounded text-sm resize-none" rows="2" placeholder="Комментарий" id="em-${card.id}">${card.comments}</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">💾 Сохранить</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>
<div class="flex items-start justify-between mb-2">
<h4 class="text-sm font-semibold text-gray-800">${card.title}</h4>
<span class="text-xs px-2 py-1 rounded ${statusColors[card.status]}">${statusLabels[card.status]}</span>
</div>
${card.description ? `<p class="text-xs text-gray-600 mb-2 line-clamp-2">${card.description}</p>` : ''}
${card.price > 0 ? `<div class="text-lg font-bold text-gray-800 mb-2">${card.price.toLocaleString()} ₽</div>` : ''}
${card.url ? `<a href="${card.url}" target="_blank" class="text-xs text-blue-600 hover:text-blue-800 block mb-2 truncate">${card.url}</a>` : ''}
${card.category ? `<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">${card.category}</span>` : ''}
${card.rating > 0 ? `<div class="mt-1 text-yellow-500">${'★'.repeat(card.rating)}${'☆'.repeat(5-card.rating)}</div>` : ''}
${card.comments ? `<p class="text-xs text-gray-500 mt-2 italic">💬 ${card.comments}</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="toggleArchive('${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;
const data = {title:input.value.trim(), list_id:lid};
await fetch(API+'/lists/'+lid+'/cards',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
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 data = {
title: document.getElementById('et-'+cid).value,
description: document.getElementById('ed-'+cid).value,
image_url: document.getElementById('ei-'+cid).value,
price: parseFloat(document.getElementById('ep-'+cid).value) || 0,
url: document.getElementById('eu-'+cid).value,
status: document.getElementById('es-'+cid).value,
category: document.getElementById('ec-'+cid).value,
rating: parseInt(document.querySelector('#rating-'+cid+' .star').textContent === '★' ? 5 : 0) || 3,
comments: document.getElementById('em-'+cid).value
};
await fetch(API+'/cards/'+cid,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
editingCard=null;
refresh();
}
async function toggleArchive(cid) {
const card = await fetchJSON(API+'/cards/'+cid);
await fetch(API+'/cards/'+cid,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({archived:card.archived?0:1})});
refresh();
}
async function setRating(cid, rating) {
await fetch(API+'/cards/'+cid,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({rating})});
refresh();
}
async function saveCategory(lid,input) {
if(input.value.trim()) {
const cards = await fetchJSON(API+'/lists/'+lid+'/cards');
for(const card of cards) {
await fetch(API+'/cards/'+card.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({category:input.value.trim()})});
}
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>