MVP+: add image, price, URL, comments, status, category, rating, archive, stats

This commit is contained in:
Nikita committed 2026-08-05 17:15:00 +05:00
1 parent f8609d7b56
commit 52f2d54768
5 files changed
+272 -48

No files matched your search

+52 -18
View File
@@ -1,6 +1,6 @@
# 🎯 Wish Board — Доска желаний # 🎯 Wish Board — Доска желаний
Интерактивная доска желаний с drag & drop, real-time синхронизацией и AI-генерацией идей. Интерактивная доска желаний с drag & drop, real-time синхронизацией и расширенным функционалом для планирования покупок.
## 🚀 Быстрый старт ## 🚀 Быстрый старт
@@ -18,7 +18,7 @@ python server.py
# Открыть http://localhost:4000 # Открыть http://localhost:4000
``` ```
## 📋 MVP+ Функционал ## ✨ MVP+ Функционал
### Базовый функционал ### Базовый функционал
- ✅ Доски с цветами - ✅ Доски с цветами
@@ -28,16 +28,16 @@ python server.py
- ✅ SQLite для хранения данных - ✅ SQLite для хранения данных
### MVP+ Функционал ### MVP+ Функционал
- 🖼️ Изображения товаров - 🖼️ **Изображения товаров** — добавление фото в карточку
- 💰 Цена и ссылка на товар - 💰 **Цена** — указание стоимости товара
- 💬 Комментарии - 🔗 **Ссылка на товар** — прямая ссылка на страницу покупки
- 📊 Статус (хочу/в процессе/куплено/отложено) - 💬 **Комментарии** — заметки и мысли по поводу товара
- 🏷️ Категории/теги - 📊 **Статусы** — хочу / в процессе / куплено / отложено
- ⭐ Рейтинг желаемости (1-5 звёзд) - 🏷️ **Категории** — группировка по типам (электроника, одежда, книги и т.д.)
- 🔍 Поиск и фильтры - ⭐ **Рейтинг желаемости** — оценка от 1 до 5 звёзд
- 📁 Архив купленных товаров - 📁 **Архив** — скрытие купленных товаров из основного вида
- 💵 Итого (сумма всех желаний) - 📈 **Статистика** — общая сумма желаний, количество по статусам
- 💾 Автосохранение - 💾 **Автосохранение** — все изменения сохраняются в реальном времени
## 🛠️ Технологии ## 🛠️ Технологии
@@ -50,23 +50,57 @@ python server.py
``` ```
wish-board/ wish-board/
├── server.py # FastAPI сервер ├── server.py # FastAPI сервер с API
├── index.html # Frontend (SPA) ├── index.html # Frontend (SPA)
├── requirements.txt # Зависимости Python ├── requirements.txt # Зависимости Python
├── README.md # Документация ├── README.md # Документация
└── wish.db # База данных (SQLite) └── wish.db # База данных (SQLite)
``` ```
## 🗄️ База данных
### Таблицы
**boards** — доски
- `id` — уникальный идентификатор
- `name` — название доски
- `color` — цвет доски (hex)
- `created_at` — дата создания
**lists** — списки на доске
- `id` — уникальный идентификатор
- `board_id` — связь с доской
- `title` — название списка
- `position` — порядок отображения
**cards** — карточки товаров
- `id` — уникальный идентификатор
- `list_id` — привязка к списку
- `board_id` — привязка к доске
- `title` — название товара
- `description` — описание
- `image_url` — URL изображения
- `price` — цена (float)
- `url` — ссылка на товар
- `status` — статус (want/in_progress/bought/deferred)
- `category` — категория
- `rating` — рейтинг желаемости (1-5)
- `comments` — комментарии
- `archived` — архив (0/1)
- `created_at` — дата добавления
- `bought_at` — дата покупки
- `position` — порядок в списке
## 🎯 Roadmap ## 🎯 Roadmap
### Phase 1: MVP+ (текущий) ### Phase 1: MVP+ ✅
- [x] Базовая структура - [x] Базовая структура
- [x] Drag & drop - [x] Drag & drop
- [x] Real-time синхронизация - [x] Real-time синхронизация
- [ ] Изображения и цены - [x] Изображения и цены
- [ ] Статусы и категории - [x] Статусы и категории
- [ ] Поиск и фильтры - [x] Поиск и фильтры
- [ ] Архив и итоги - [x] Архив и итоги
### Phase 2: Улучшения ### Phase 2: Улучшения
- [ ] Тёмная тема - [ ] Тёмная тема
+152 -18
View File
@@ -6,6 +6,9 @@
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style> <style>
.dragging{opacity:.5;transform:rotate(5deg)}.drag-over{background:#e0e7ff;border:2px dashed #6366f1} .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> </style>
</head> </head>
<body class="min-h-screen bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-500 p-6"> <body class="min-h-screen bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-500 p-6">
@@ -22,9 +25,7 @@ function connect() {
ws = new WebSocket(WS); ws = new WebSocket(WS);
ws.onmessage = e => { ws.onmessage = e => {
const m = JSON.parse(e.data); const m = JSON.parse(e.data);
if (m.type==='board:create'||m.type==='board:delete'||m.type==='board:update') refresh(); if (m.type.startsWith('board:') || m.type.startsWith('list:') || m.type.startsWith('card:')) 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); ws.onclose = () => setTimeout(connect, 3000);
} }
@@ -68,14 +69,52 @@ function render() {
} else refresh(); } else refresh();
} }
function renderBoard(lists) { async function renderBoard(lists) {
const stats = await fetchJSON(API+'/stats/'+currentBoard.id);
const categories = await fetchJSON(API+'/categories/'+currentBoard.id);
document.getElementById('app').innerHTML = ` document.getElementById('app').innerHTML = `
<div class="bg-white/10 backdrop-blur-md rounded-3xl shadow-2xl p-6"> <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 justify-between mb-6">
<div class="flex items-center gap-3"><div class="w-4 h-4 rounded-full" style="background:${currentBoard.color}"></div> <div class="flex items-center gap-3">
<h2 class="text-2xl font-bold text-white">${currentBoard.name}</h2></div> <div class="w-4 h-4 rounded-full" style="background:${currentBoard.color}"></div>
<button onclick="delBoard()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm">Удалить</button> <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>
<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="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"> <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()"> <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()">
@@ -85,7 +124,7 @@ function renderBoard(lists) {
} }
function renderList(list) { 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}" 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}')"> 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"> <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)">` ${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)">`
@@ -93,18 +132,68 @@ function renderList(list) {
<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> <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>
<div class="space-y-2 min-h-[100px]" id="cc-${list.id}"></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"> <div class="mt-3 pt-3 border-t border-gray-200">
<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)"> <div class="flex gap-1 mb-2">
<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>`; <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) { 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}" 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}')"> 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> ${card.image_url ? `<img src="${card.image_url}" class="card-image" onerror="this.style.display='none'">` : ''}
<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>` ${editingCard===card.id?`
:`<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="space-y-2">
<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>`; <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) { async function loadCards(lid) {
@@ -118,10 +207,55 @@ async function addList() { const v=document.getElementById('nl').value;if(!v.tri
async function delList(lid) { if(confirm('Удалить?')){await fetch(API+'/lists/'+lid,{method:'DELETE'});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 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 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 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 delCard(cid) { await fetch(API+'/cards/'+cid,{method:'DELETE'});refresh(); }
async function editCard(cid) { editingCard=cid; 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(); }
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; let dragged=null;
function ds(e,cid) { dragged=cid; e.target.classList.add('dragging'); } function ds(e,cid) { dragged=cid; e.target.classList.add('dragging'); }
+68 -12
View File
@@ -33,8 +33,21 @@ def init():
CREATE TABLE IF NOT EXISTS cards ( CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY, list_id TEXT REFERENCES lists(id) ON DELETE CASCADE, id TEXT PRIMARY KEY, list_id TEXT REFERENCES lists(id) ON DELETE CASCADE,
board_id TEXT REFERENCES boards(id) ON DELETE CASCADE, board_id TEXT REFERENCES boards(id) ON DELETE CASCADE,
title TEXT, description TEXT DEFAULT '', position INTEGER DEFAULT 0 title TEXT, description TEXT DEFAULT '', position INTEGER DEFAULT 0,
image_url TEXT DEFAULT '',
price REAL DEFAULT 0,
url TEXT DEFAULT '',
status TEXT DEFAULT 'want',
category TEXT DEFAULT '',
rating INTEGER DEFAULT 3,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
bought_at TIMESTAMP,
comments TEXT DEFAULT '',
archived INTEGER DEFAULT 0
); );
CREATE INDEX IF NOT EXISTS idx_cards_status ON cards(status);
CREATE INDEX IF NOT EXISTS idx_cards_category ON cards(category);
CREATE INDEX IF NOT EXISTS idx_cards_board ON cards(board_id);
""") """)
init() init()
@@ -49,11 +62,29 @@ class ListIn(BaseModel):
class CardIn(BaseModel): class CardIn(BaseModel):
title: str title: str
description: str = "" description: str = ""
image_url: str = ""
price: float = 0
url: str = ""
status: str = "want"
category: str = ""
rating: int = 3
comments: str = ""
@app.get("/") class CardUpdate(BaseModel):
def index(): title: Optional[str] = None
return FileResponse("index.html") description: Optional[str] = None
image_url: Optional[str] = None
price: Optional[float] = None
url: Optional[str] = None
status: Optional[str] = None
category: Optional[str] = None
rating: Optional[int] = None
comments: Optional[str] = None
archived: Optional[int] = None
position: Optional[int] = None
list_id: Optional[str] = None
# Boards
@app.get("/api/boards") @app.get("/api/boards")
def boards(): def boards():
with db() as c: with db() as c:
@@ -74,6 +105,7 @@ def delete_board(bid: str):
broadcast({"type": "board:delete", "data": {"id": bid}}) broadcast({"type": "board:delete", "data": {"id": bid}})
return {"ok": True} return {"ok": True}
# Lists
@app.get("/api/boards/{bid}/lists") @app.get("/api/boards/{bid}/lists")
def lists(bid: str): def lists(bid: str):
with db() as c: with db() as c:
@@ -108,6 +140,7 @@ def delete_list(lid: str):
broadcast({"type": "list:delete", "data": {"id": lid}}) broadcast({"type": "list:delete", "data": {"id": lid}})
return {"ok": True} return {"ok": True}
# Cards
@app.get("/api/lists/{lid}/cards") @app.get("/api/lists/{lid}/cards")
def cards(lid: str): def cards(lid: str):
with db() as c: with db() as c:
@@ -119,21 +152,25 @@ def create_card(lid: str, data: CardIn):
with db() as c: with db() as c:
bid = c.execute("SELECT board_id FROM lists WHERE id=?", (lid,)).fetchone()["board_id"] bid = c.execute("SELECT board_id FROM lists WHERE id=?", (lid,)).fetchone()["board_id"]
p = c.execute("SELECT COALESCE(MAX(position),-1) m FROM cards WHERE list_id=?", (lid,)).fetchone()["m"] p = c.execute("SELECT COALESCE(MAX(position),-1) m FROM cards WHERE list_id=?", (lid,)).fetchone()["m"]
c.execute("INSERT INTO cards VALUES (?,?,?,?,?,?)", (cid, lid, bid, data.title, data.description, p+1)) c.execute("INSERT INTO cards VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,0)",
broadcast({"type": "card:create", "data": {"id": cid, "list_id": lid, "board_id": bid, "title": data.title, "description": data.description, "position": p+1}}) (cid, lid, bid, data.title, data.description, p+1,
return {"id": cid, "list_id": lid, "board_id": bid, "title": data.title, "description": data.description, "position": p+1} data.image_url, data.price, data.url, data.status,
data.category, data.rating, data.comments))
broadcast({"type": "card:create", "data": dict(c.execute("SELECT * FROM cards WHERE id=?", (cid,)).fetchone())})
return dict(c.execute("SELECT * FROM cards WHERE id=?", (cid,)).fetchone())
@app.patch("/api/cards/{cid}") @app.patch("/api/cards/{cid}")
def update_card(cid: str, data: dict): def update_card(cid: str, data: CardUpdate):
with db() as c: with db() as c:
u, ps = [], [] u, ps = [], []
for k in ("title","description","position","list_id"): for k in ("title","description","image_url","price","url","status","category","rating","comments","archived","position","list_id"):
if k in data: u.append(f"{k}=?"); ps.append(data[k]) if getattr(data, k, None) is not None:
u.append(f"{k}=?"); ps.append(getattr(data, k))
if u: if u:
ps.append(cid) ps.append(cid)
c.execute(f"UPDATE cards SET {', '.join(u)} WHERE id=?", ps) c.execute(f"UPDATE cards SET {', '.join(u)} WHERE id=?", ps)
row = c.execute("SELECT * FROM cards WHERE id=?", (cid,)).fetchone() row = dict(c.execute("SELECT * FROM cards WHERE id=?", (cid,)).fetchone())
broadcast({"type": "card:update", "data": dict(row)}) broadcast({"type": "card:update", "data": row})
return {"ok": True} return {"ok": True}
@app.delete("/api/cards/{cid}") @app.delete("/api/cards/{cid}")
@@ -143,6 +180,25 @@ def delete_card(cid: str):
broadcast({"type": "card:delete", "data": {"id": cid}}) broadcast({"type": "card:delete", "data": {"id": cid}})
return {"ok": True} return {"ok": True}
# Stats
@app.get("/api/stats/{bid}")
def stats(bid: str):
with db() as c:
total = c.execute("SELECT COALESCE(SUM(price),0) s FROM cards WHERE board_id=? AND archived=0", (bid,)).fetchone()["s"]
bought = c.execute("SELECT COUNT(*) c FROM cards WHERE board_id=? AND status='bought'", (bid,)).fetchone()["c"]
want = c.execute("SELECT COUNT(*) c FROM cards WHERE board_id=? AND status='want'", (bid,)).fetchone()["c"]
deferred = c.execute("SELECT COUNT(*) c FROM cards WHERE board_id=? AND status='deferred'", (bid,)).fetchone()["c"]
in_progress = c.execute("SELECT COUNT(*) c FROM cards WHERE board_id=? AND status='in_progress'", (bid,)).fetchone()["c"]
return {"total": round(total, 2), "bought": bought, "want": want, "deferred": deferred, "in_progress": in_progress}
# Categories
@app.get("/api/categories/{bid}")
def categories(bid: str):
with db() as c:
rows = c.execute("SELECT DISTINCT category FROM cards WHERE board_id=? AND category != '' AND archived=0", (bid,)).fetchall()
cats = [r["category"] for r in rows]
return {"categories": cats}
# WebSocket broadcast # WebSocket broadcast
clients = set() clients = set()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.