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}

- +
+
+

${currentBoard.name}

+
+
+
+ 💰 Итого: ${stats.total.toLocaleString()} ₽ +
+ +
+ +
+
+
${stats.want}
+
Хочу
+
+
+
${stats.in_progress}
+
В процессе
+
+
+
${stats.bought}
+
Куплено
+
+
+
${stats.deferred}
+
Отложено
+
+
+ + ${categories.categories.length > 0 ? ` +
+
Категории:
+
+ ${categories.categories.map(c => `${c}`).join('')} +
+
` : ''} +
${lists.map(l=>renderList(l)).join('')}
@@ -85,7 +124,7 @@ function renderBoard(lists) { } function renderList(list) { - return `
${editingList===list.id?`` @@ -93,18 +132,68 @@ function renderList(list) {
-
- -
`; +
+
+ +
+
+ + +
`; } 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