Compare commits
7
Commits
aaeeefdb3e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edeb2a400 | ||
|
|
bf39bda24f | ||
|
|
e61a835de6 | ||
|
|
fbb306f8aa | ||
|
|
52f2d54768 | ||
|
|
f8609d7b56 | ||
|
|
cd945a6128 |
No files matched your search
+40
@@ -0,0 +1,40 @@
|
|||||||
|
# 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
|
||||||
@@ -1,3 +1,137 @@
|
|||||||
# wish-board
|
# 🎯 Wish Board — Доска желаний
|
||||||
|
|
||||||
AI-powered wish board with drag & drop
|
Интерактивная доска желаний с 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)
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
# Исследование: Доска желаний — функционал и лучшие практики
|
||||||
|
|
||||||
|
## Анализ конкурентов и лучших практик
|
||||||
|
|
||||||
|
### 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
@@ -0,0 +1,271 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn==0.24.0
|
||||||
|
python-multipart==0.0.6
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
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
Binary file not shown.
BIN
Binary file not shown.
Reference in new issue
Block a user