From 52f2d547683b282259ab5065bd9875c7276d2455 Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 5 Aug 2026 17:15:00 +0500 Subject: [PATCH] MVP+: add image, price, URL, comments, status, category, rating, archive, stats --- README.md | 70 ++++++++++++++++------ index.html | 170 ++++++++++++++++++++++++++++++++++++++++++++++------ server.py | 80 +++++++++++++++++++++---- wish.db-shm | Bin 32768 -> 0 bytes wish.db-wal | Bin 70072 -> 0 bytes 5 files changed, 272 insertions(+), 48 deletions(-) delete mode 100644 wish.db-shm delete mode 100644 wish.db-wal 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 26d2a13f1f242e4072354ed8d2ea59bb4ef6ad7e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI)Jx)SF5CGue&mRQAjvG)(Lc>WcO*{&eRgQ>*p8fHDK2=3| z_xCK0$93uEs+ZZ%9AsW)mVcCbIy>FDRP(AJK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!89a z1UfN{QQXJ05x@0G0{V*i=wnD>GJR+yYesYJ!Jp*Xi)n4ug?$4T@B~&KXY{X@RHWe zFSW>>x%`+SZ!8EPfB*srAbys;pF00Iag zfB*srAb z5I_I{1Q0*~0R#|00D<`{P+go`P%KtknmsEF6VRq1uu0UA5!2k@0Hng#7#ELha+)hf@z6E_81wZYbPWE_IhHg-x!bt`>K% zU)}w{y7h$z*WR;MQZ>o-TwdTz^|QC5rc*n}3(Vg)5w8pZ1Q0*~0R#|0009ILKmdV@ z38Z;}EVT!DoCWEzX7U2>y?(a0@2%0}(|LgoCEszeGx0bA2q1s}0tg_000IagfB*t@ zFHqf>Yc9$!`tNUPj!o6Gy+9LzXQ;wVTvG&AXbVq%-5+bF6S=1RE&tr40k%{s{R6jm zbqx*?&Dj1R=%?q5X3%u(@$Nv6)?>EzVfp#U|UdKob4FLoY zKmY**5I_I{1Q0*~f&UYzZvHnVaBH2V1T3xKd0t_1XhaVGg1&>V?H*Y%@yW1})&&|C zpOt+F<%R_T1Q0*~0R#|0009ILKmdVy64=quR4j&zjL5JYFL8va8=l;Rq3|6mO7A&w zki_!)b~97&zc*ypztQm*WcR1`XS;`p= zm(X{xAr+bFFVJ>y=kMAVH|tY*f&6|YKPERU2q1s}0tg_000IagfB*srAaErGG?{3~ zw<)S0C4nA0!c9U$n5t&Vd!F2cu^HKV`R9+xU zi9nw9g1o@VReL^q|H!W!r~3|$kr&7`Ej$SU1Q0*~0R#|0009ILKmdWOEigBEfqQ?> zt!cTd^}GMBQ%HY-t39)v9{~gqKmY**5I_I{1Q0*~flC!g`wPq_FL1}6-R(C8r+=94 zFR(Ao3+%hpbMt%z5I_I{1Q0*~0R#|0009ILn7x20n+wQJ0Y+?Ss_RF>FycU%zO9KM z4q}m*ek3oIphbq^O?3*e4ckt%xycKB)zR}#&(=SR^cR@@{PCX%Ab