Compare commits

..
1 Commits
Author SHA1 Message Date
neo aaeeefdb3e Initial commit 2026-08-02 00:36:25 +05:00
8 changed files with 2 additions and 765 deletions

No files matched your search

-40
View File
@@ -1,40 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg-info/
dist/
build/
.eggs/
# Virtual environments
venv/
env/
.venv/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Database
*.db
*.sqlite
*.sqlite3
# Environment
.env
.env.local
# Logs
*.log
# Temporary files
*.tmp
*.temp
+2 -136
View File
@@ -1,137 +1,3 @@
# 🎯 Wish Board — Доска желаний
# wish-board
Интерактивная доска желаний с drag & drop, real-time синхронизацией и расширенным функционалом для планирования покупок.
## 🚀 Быстрый старт
```bash
# Клонировать
git clone <url>
cd wish-board
# Установить зависимости
pip install -r requirements.txt
# Запустить сервер
python server.py
# Открыть http://localhost:4000
```
## ✨ MVP+ Функционал
### Базовый функционал
- ✅ Доски с цветами
- ✅ Списки (TODO/Doing/Done)
- ✅ Карточки с drag & drop
- ✅ Real-time синхронизация через WebSocket
- ✅ SQLite для хранения данных
### MVP+ Функционал
- 🖼️ **Изображения товаров** — добавление фото в карточку
- 💰 **Цена** — указание стоимости товара
- 🔗 **Ссылка на товар** — прямая ссылка на страницу покупки
- 💬 **Комментарии** — заметки и мысли по поводу товара
- 📊 **Статусы** — хочу / в процессе / куплено / отложено
- 🏷️ **Категории** — группировка по типам (электроника, одежда, книги и т.д.)
- ⭐ **Рейтинг желаемости** — оценка от 1 до 5 звёзд
- 📁 **Архив** — скрытие купленных товаров из основного вида
- 📈 **Статистика** — общая сумма желаний, количество по статусам
- 💾 **Автосохранение** — все изменения сохраняются в реальном времени
## 🛠️ Технологии
- **Backend:** Python 3.11+, FastAPI, SQLite, WebSocket
- **Frontend:** HTML5, Tailwind CSS, Vanilla JS
- **Real-time:** WebSocket (FastAPI)
- **Storage:** SQLite (WAL mode)
## 📁 Структура проекта
```
wish-board/
├── server.py # FastAPI сервер с API
├── index.html # Frontend (SPA)
├── requirements.txt # Зависимости Python
├── README.md # Документация
└── 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
### Phase 1: MVP+ ✅
- [x] Базовая структура
- [x] Drag & drop
- [x] Real-time синхронизация
- [x] Изображения и цены
- [x] Статусы и категории
- [x] Поиск и фильтры
- [x] Архив и итоги
### Phase 2: Улучшения
- [ ] Тёмная тема
- [ ] PWA (установка как приложение)
- [ ] Экспорт/импорт (JSON backup)
- [ ] Горячие клавиши
- [ ] Контекстное меню
### Phase 3: AI-фичи
- [ ] AI-генерация идей (Ollama)
- [ ] Автозаполнение по ссылке
- [ ] Анализ цен (история)
### Phase 4: Коллаборация
- [ ] Регистрация/логин
- [ ] Общие доски
- [ ] Комментарии других пользователей
- [ ] Голосование
## 🤝 Вклад
1. Форкнуть проект
2. Создать ветку (`feature/your-feature`)
3. Запушить изменения
4. Создать Pull Request
## 📄 Лицензия
MIT License
---
**Создано:** 2024
**Автор:** Nikita (Ufa)
AI-powered wish board with drag & drop
-83
View File
@@ -1,83 +0,0 @@
# Исследование: Доска желаний — функционал и лучшие практики
## Анализ конкурентов и лучших практик
### 1. LMK.today (лидер рынка)
- **Универсальные списки** — добавление товаров из любого магазина
- **Трекинг цен** — автоматическое отслеживание 1000+ магазинов
- **Уведомления о снижении цен** — алерты при изменении цены
- **Браузерное расширение** — быстрое добавление товаров
- **Регистры** — свадебные, детские, подарочные
- **Сравнение цен** — между магазинами
- **Кроссплатформенность** — iOS, Android, Web
### 2. cmintey/wishlist (open source)
- **Claim items** — можно забрать товар из чужого списка
- **Пометка как купленное** — чек-лист покупок
- **Автоfetch данных** — парсинг товара по URL
- **Приглашение по email** — через SMTP
- **PWA** — установка как приложение
- **Группы** — друзья, семья (разные списки)
- **OAuth** — авторизация
- **Реестры** — свадебные/детские
### 3. Planka (аналог Trello)
- **Канбан-доски** — TODO/Doing/Done
- **Real-time** — синхронизация через WebSocket
- **Теги/метки** — цветные категории
- **Дедлайны** — даты выполнения
- **Комментарии** — обсуждение карточек
- **Архив** — скрытие завершённых задач
- **Drag & drop** — перетаскивание карточек
### 4. Trello (для дома)
- **Доски** — визуальное управление
- **Списки** — колонки с задачами
- **Карточки** — с чеклистами, датами, метками
- **Командная работа** — совместное использование
- **Интеграции** — Slack, Google Drive, и т.д.
## Рекомендации для проекта Wish Board
### Приоритет 1: MVP+ (реализовано)
✅ Изображение, цена, URL, комментарий
✅ Статус (хочу/в процессе/куплено/отложено)
✅ Категория/тег
✅ Рейтинг желаемости (1-5 звёзд)
✅ Дата добавления
✅ Поиск
✅ Архив
✅ Итого (сумма)
✅ Модальное окно для быстрого добавления
✅ Автосохранение
### Приоритет 2: Уникальные фичи (отличие от конкурентов)
1. **AI-генерация идей** — интеграция с Ollama/локальной моделью
2. **Трекинг цен** — парсинг актуальных цен с сайтов
3. **Уведомления о снижении цен** — когда цена падает
4. **PWA** — установка как приложение на телефон
5. **Экспорт/импорт JSON** — backup данных
6. **Тёмная тема** — переключатель
7. **Чеклисты** — подзадачи внутри карточки
8. **Комментарии** — обсуждение карточек
### Приоритет 3: Для командной работы (если нужно)
1. **Регистрация/логин** — несколько пользователей
2. **Общие доски** — совместное использование
3. **Claim items** — можно забрать товар из чужого списка
4. **Приглашение по email** — через SMTP
## Выводы
Для личного использования фокусируйся на:
- AI-генерация идей
- Трекинг цен + уведомления
- PWA
- Чеклисты
- Комментарии
Для командной работы добавь:
- Регистрацию/логин
- Общие доски
- Claim items
- Приглашение по email
-271
View File
@@ -1,271 +0,0 @@
<!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>
-3
View File
@@ -1,3 +0,0 @@
fastapi==0.104.1
uvicorn==0.24.0
python-multipart==0.0.6
-232
View File
@@ -1,232 +0,0 @@
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from pydantic import BaseModel
from typing import Optional
import sqlite3
import uuid
from datetime import datetime
import json
app = FastAPI()
app.mount("/static", StaticFiles(directory="."), name="static")
DB = "wish.db"
def db():
conn = sqlite3.connect(DB)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init():
with db() as c:
c.executescript("""
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY, name TEXT, color TEXT DEFAULT '#6366f1',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY, board_id TEXT REFERENCES boards(id) ON DELETE CASCADE,
title TEXT, position INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY, list_id TEXT REFERENCES lists(id) ON DELETE CASCADE,
board_id TEXT REFERENCES boards(id) ON DELETE CASCADE,
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()
class BoardIn(BaseModel):
name: str
color: str = "#6366f1"
class ListIn(BaseModel):
title: str
class CardIn(BaseModel):
title: str
description: str = ""
image_url: str = ""
price: float = 0
url: str = ""
status: str = "want"
category: str = ""
rating: int = 3
comments: str = ""
class CardUpdate(BaseModel):
title: Optional[str] = None
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")
def boards():
with db() as c:
return [dict(r) for r in c.execute("SELECT * FROM boards ORDER BY created_at DESC").fetchall()]
@app.post("/api/boards")
def create_board(data: BoardIn):
bid = str(uuid.uuid4())
with db() as c:
c.execute("INSERT INTO boards VALUES (?,?,?,NULL)", (bid, data.name, data.color))
broadcast({"type": "board:create", "data": {"id": bid, "name": data.name, "color": data.color}})
return {"id": bid, "name": data.name, "color": data.color}
@app.delete("/api/boards/{bid}")
def delete_board(bid: str):
with db() as c:
c.execute("DELETE FROM boards WHERE id=?", (bid,))
broadcast({"type": "board:delete", "data": {"id": bid}})
return {"ok": True}
# Lists
@app.get("/api/boards/{bid}/lists")
def lists(bid: str):
with db() as c:
return [dict(r) for r in c.execute("SELECT * FROM lists WHERE board_id=? ORDER BY position", (bid,)).fetchall()]
@app.post("/api/boards/{bid}/lists")
def create_list(bid: str, data: ListIn):
lid = str(uuid.uuid4())
with db() as c:
p = c.execute("SELECT COALESCE(MAX(position),-1) m FROM lists WHERE board_id=?", (bid,)).fetchone()["m"]
c.execute("INSERT INTO lists VALUES (?,?,?,?)", (lid, bid, data.title, p+1))
broadcast({"type": "list:create", "data": {"id": lid, "board_id": bid, "title": data.title, "position": p+1}})
return {"id": lid, "board_id": bid, "title": data.title, "position": p+1}
@app.patch("/api/lists/{lid}")
def update_list(lid: str, data: dict):
with db() as c:
u, ps = [], []
if "title" in data: u.append("title=?"); ps.append(data["title"])
if "position" in data: u.append("position=?"); ps.append(data["position"])
if u:
ps.append(lid)
c.execute(f"UPDATE lists SET {', '.join(u)} WHERE id=?", ps)
row = c.execute("SELECT * FROM lists WHERE id=?", (lid,)).fetchone()
broadcast({"type": "list:update", "data": dict(row)})
return {"ok": True}
@app.delete("/api/lists/{lid}")
def delete_list(lid: str):
with db() as c:
c.execute("DELETE FROM lists WHERE id=?", (lid,))
broadcast({"type": "list:delete", "data": {"id": lid}})
return {"ok": True}
# Cards
@app.get("/api/lists/{lid}/cards")
def cards(lid: str):
with db() as c:
return [dict(r) for r in c.execute("SELECT * FROM cards WHERE list_id=? ORDER BY position", (lid,)).fetchall()]
@app.post("/api/lists/{lid}/cards")
def create_card(lid: str, data: CardIn):
cid = str(uuid.uuid4())
with db() as c:
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"]
c.execute("INSERT INTO cards (id, list_id, board_id, title, description, position, image_url, price, url, status, category, rating, comments, bought_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,NULL)",
(cid, lid, bid, data.title, data.description, 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}")
def update_card(cid: str, data: CardUpdate):
with db() as c:
u, ps = [], []
for k in ("title","description","image_url","price","url","status","category","rating","comments","archived","position","list_id"):
if getattr(data, k, None) is not None:
u.append(f"{k}=?"); ps.append(getattr(data, k))
if u:
ps.append(cid)
c.execute(f"UPDATE cards SET {', '.join(u)} WHERE id=?", ps)
row = c.execute("SELECT * FROM cards WHERE id=?", (cid,)).fetchone()
if row:
broadcast({"type": "card:update", "data": dict(row)})
return {"ok": True}
@app.delete("/api/cards/{cid}")
def delete_card(cid: str):
with db() as c:
c.execute("DELETE FROM cards WHERE id=?", (cid,))
broadcast({"type": "card:delete", "data": {"id": cid}})
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}
# Serve frontend
@app.get("/")
def index():
return FileResponse("index.html")
# WebSocket broadcast
clients = set()
def broadcast(msg):
payload = json.dumps(msg, ensure_ascii=False)
for c in list(clients):
try:
import asyncio
asyncio.get_event_loop().run_until_complete(c.send(payload))
except:
clients.discard(c)
@app.websocket("/ws")
async def ws(websocket: WebSocket):
await websocket.accept()
clients.add(websocket)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
clients.discard(websocket)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=4000)
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.