diff --git a/README.md b/README.md
index 35e9553..3c5a6bb 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 🎯 Wish Board — Доска желаний
-Интерактивная доска желаний с drag & drop, real-time синхронизацией и AI-генерацией идей.
+Интерактивная доска желаний с drag & drop, real-time синхронизацией и расширенным функционалом для планирования покупок.
## 🚀 Быстрый старт
@@ -18,7 +18,7 @@ python server.py
# Открыть http://localhost:4000
```
-## 📋 MVP+ Функционал
+## ✨ MVP+ Функционал
### Базовый функционал
- ✅ Доски с цветами
@@ -28,16 +28,16 @@ python server.py
- ✅ SQLite для хранения данных
### MVP+ Функционал
-- 🖼️ Изображения товаров
-- 💰 Цена и ссылка на товар
-- 💬 Комментарии
-- 📊 Статус (хочу/в процессе/куплено/отложено)
-- 🏷️ Категории/теги
-- ⭐ Рейтинг желаемости (1-5 звёзд)
-- 🔍 Поиск и фильтры
-- 📁 Архив купленных товаров
-- 💵 Итого (сумма всех желаний)
-- 💾 Автосохранение
+- 🖼️ **Изображения товаров** — добавление фото в карточку
+- 💰 **Цена** — указание стоимости товара
+- 🔗 **Ссылка на товар** — прямая ссылка на страницу покупки
+- 💬 **Комментарии** — заметки и мысли по поводу товара
+- 📊 **Статусы** — хочу / в процессе / куплено / отложено
+- 🏷️ **Категории** — группировка по типам (электроника, одежда, книги и т.д.)
+- ⭐ **Рейтинг желаемости** — оценка от 1 до 5 звёзд
+- 📁 **Архив** — скрытие купленных товаров из основного вида
+- 📈 **Статистика** — общая сумма желаний, количество по статусам
+- 💾 **Автосохранение** — все изменения сохраняются в реальном времени
## 🛠️ Технологии
@@ -50,23 +50,57 @@ python server.py
```
wish-board/
-├── server.py # FastAPI сервер
+├── 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+ (текущий)
+### Phase 1: MVP+ ✅
- [x] Базовая структура
- [x] Drag & drop
- [x] Real-time синхронизация
-- [ ] Изображения и цены
-- [ ] Статусы и категории
-- [ ] Поиск и фильтры
-- [ ] Архив и итоги
+- [x] Изображения и цены
+- [x] Статусы и категории
+- [x] Поиск и фильтры
+- [x] Архив и итоги
### Phase 2: Улучшения
- [ ] Тёмная тема
diff --git a/index.html b/index.html
index 0306ae2..245c79c 100644
--- a/index.html
+++ b/index.html
@@ -6,6 +6,9 @@
@@ -22,9 +25,7 @@ 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();
+ if (m.type.startsWith('board:') || m.type.startsWith('list:') || m.type.startsWith('card:')) refresh();
};
ws.onclose = () => setTimeout(connect, 3000);
}
@@ -68,14 +69,52 @@ function render() {
} 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 = `
-
-
+
+
+
${currentBoard.name}
+
+
+
+ 💰 Итого: ${stats.total.toLocaleString()} ₽
+
+
+
+
+
+
+
+
${stats.in_progress}
+
В процессе
+
+
+
${stats.bought}
+
Куплено
+
+
+
${stats.deferred}
+
Отложено
+
+
+
+ ${categories.categories.length > 0 ? `
+
+
Категории:
+
+ ${categories.categories.map(c => `${c}`).join('')}
+
+
` : ''}
+
${lists.map(l=>renderList(l)).join('')}
`;
}
function renderCard(card) {
- return `
- ${editingCard===card.id?`
`
- :`
${card.title}
${card.description?`
${card.description}
`:''}
-
`}
`;
+ ${card.image_url ? `

` : ''}
+ ${editingCard===card.id?`
+
+
+
+
+
+
+
+
+
+
Рейтинг:
+
+ ${[1,2,3,4,5].map(i => `${i<=card.rating?'★':'☆'}`).join('')}
+
+
+
+
+
+
+
+
+ `:`
+
+
+
${card.title}
+ ${statusLabels[card.status]}
+
+ ${card.description ? `
${card.description}
` : ''}
+ ${card.price > 0 ? `
${card.price.toLocaleString()} ₽
` : ''}
+ ${card.url ? `
${card.url}` : ''}
+ ${card.category ? `
${card.category}` : ''}
+ ${card.rating > 0 ? `
${'★'.repeat(card.rating)}${'☆'.repeat(5-card.rating)}
` : ''}
+ ${card.comments ? `
💬 ${card.comments}
` : ''}
+
+
+
+
+
+
+ `}
`;
}
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 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 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 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;
function ds(e,cid) { dragged=cid; e.target.classList.add('dragging'); }
diff --git a/server.py b/server.py
index 2ceb992..f2df7eb 100644
--- a/server.py
+++ b/server.py
@@ -33,8 +33,21 @@ def init():
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
+ 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()
@@ -49,11 +62,29 @@ class ListIn(BaseModel):
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 = ""
-@app.get("/")
-def index():
- return FileResponse("index.html")
+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:
@@ -74,6 +105,7 @@ def delete_board(bid: str):
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:
@@ -108,6 +140,7 @@ def delete_list(lid: str):
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:
@@ -119,21 +152,25 @@ def create_card(lid: str, data: CardIn):
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 VALUES (?,?,?,?,?,?)", (cid, lid, bid, data.title, data.description, p+1))
- broadcast({"type": "card:create", "data": {"id": cid, "list_id": lid, "board_id": bid, "title": data.title, "description": data.description, "position": p+1}})
- return {"id": cid, "list_id": lid, "board_id": bid, "title": data.title, "description": data.description, "position": p+1}
+ c.execute("INSERT INTO cards VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,0)",
+ (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: dict):
+def update_card(cid: str, data: CardUpdate):
with db() as c:
u, ps = [], []
- for k in ("title","description","position","list_id"):
- if k in data: u.append(f"{k}=?"); ps.append(data[k])
+ 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()
- broadcast({"type": "card:update", "data": dict(row)})
+ row = dict(c.execute("SELECT * FROM cards WHERE id=?", (cid,)).fetchone())
+ broadcast({"type": "card:update", "data": row})
return {"ok": True}
@app.delete("/api/cards/{cid}")
@@ -143,6 +180,25 @@ def delete_card(cid: str):
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}
+
# WebSocket broadcast
clients = set()
diff --git a/wish.db-shm b/wish.db-shm
deleted file mode 100644
index 26d2a13..0000000
Binary files a/wish.db-shm and /dev/null differ
diff --git a/wish.db-wal b/wish.db-wal
deleted file mode 100644
index 6e576f8..0000000
Binary files a/wish.db-wal and /dev/null differ