Compare commits

..
5 Commits
22 changed files with 357 additions and 4829 deletions

No files matched your search

-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+52 -18
View File
@@ -1,6 +1,6 @@
# 🎯 Wish Board — Доска желаний # 🎯 Wish Board — Доска желаний
Интерактивная доска желаний с drag & drop, real-time синхронизацией и AI-генерацией идей. Интерактивная доска желаний с drag & drop, real-time синхронизацией и расширенным функционалом для планирования покупок.
## 🚀 Быстрый старт ## 🚀 Быстрый старт
@@ -18,7 +18,7 @@ python server.py
# Открыть http://localhost:4000 # Открыть http://localhost:4000
``` ```
## 📋 MVP+ Функционал ## ✨ MVP+ Функционал
### Базовый функционал ### Базовый функционал
- ✅ Доски с цветами - ✅ Доски с цветами
@@ -28,16 +28,16 @@ python server.py
- ✅ SQLite для хранения данных - ✅ SQLite для хранения данных
### MVP+ Функционал ### MVP+ Функционал
- 🖼️ Изображения товаров - 🖼️ **Изображения товаров** — добавление фото в карточку
- 💰 Цена и ссылка на товар - 💰 **Цена** — указание стоимости товара
- 💬 Комментарии - 🔗 **Ссылка на товар** — прямая ссылка на страницу покупки
- 📊 Статус (хочу/в процессе/куплено/отложено) - 💬 **Комментарии** — заметки и мысли по поводу товара
- 🏷️ Категории/теги - 📊 **Статусы** — хочу / в процессе / куплено / отложено
- ⭐ Рейтинг желаемости (1-5 звёзд) - 🏷️ **Категории** — группировка по типам (электроника, одежда, книги и т.д.)
- 🔍 Поиск и фильтры - ⭐ **Рейтинг желаемости** — оценка от 1 до 5 звёзд
- 📁 Архив купленных товаров - 📁 **Архив** — скрытие купленных товаров из основного вида
- 💵 Итого (сумма всех желаний) - 📈 **Статистика** — общая сумма желаний, количество по статусам
- 💾 Автосохранение - 💾 **Автосохранение** — все изменения сохраняются в реальном времени
## 🛠️ Технологии ## 🛠️ Технологии
@@ -50,23 +50,57 @@ python server.py
``` ```
wish-board/ wish-board/
├── server.py # FastAPI сервер ├── server.py # FastAPI сервер с API
├── index.html # Frontend (SPA) ├── index.html # Frontend (SPA)
├── requirements.txt # Зависимости Python ├── requirements.txt # Зависимости Python
├── README.md # Документация ├── README.md # Документация
└── wish.db # База данных (SQLite) └── 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 ## 🎯 Roadmap
### Phase 1: MVP+ (текущий) ### Phase 1: MVP+ ✅
- [x] Базовая структура - [x] Базовая структура
- [x] Drag & drop - [x] Drag & drop
- [x] Real-time синхронизация - [x] Real-time синхронизация
- [ ] Изображения и цены - [x] Изображения и цены
- [ ] Статусы и категории - [x] Статусы и категории
- [ ] Поиск и фильтры - [x] Поиск и фильтры
- [ ] Архив и итоги - [x] Архив и итоги
### Phase 2: Улучшения ### Phase 2: Улучшения
- [ ] Тёмная тема - [ ] Тёмная тема
+83
View File
@@ -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
-160
View File
@@ -1,160 +0,0 @@
#!/usr/bin/env python3
"""Generate random cards from a predefined list."""
import random
import json
from datetime import datetime
# Predefined list of card options
CARD_OPTIONS = [
"Изучить React и TypeScript",
"Написать мобильное приложение",
"Запустить свой SaaS-продукт",
"Собрать первый миллион на крипто",
"Путешествие в Японию",
"Купить дом в деревне",
"Научиться играть на гитаре",
"Прочитать 50 книг за год",
"Запустить YouTube канал",
"Создать AI-бота для бизнеса",
"Собрать свой первый ПК",
"Выучить английский на C1",
"Написать книгу",
"Открыть кофейню",
"Сделать татуировку",
"Прыгнуть с парашютом",
"Пройти марафон",
"Выучить Python с нуля",
"Собрать сервер для дома",
"Запустить Telegram-бот",
"Создать свой дизайн-систему",
"Написать игру на Unity",
"Собрать умный дом",
"Выучить три языка",
"Построить баню",
"Купить машину мечты",
"Собрать коллекцию винила",
"Пройти курс по UX/UI",
"Создать подкаст",
"Написать приложение для заметок",
"Собрать свой первый drone",
"Выучить JavaScript",
"Создать свой сайт-портфолио",
"Научиться фотографировать",
"Собрать свой первый сервер",
"Запустить свой блог",
"Создать свой мессенджер",
"Выучить Rust",
"Собрать свой первый VR-шлем",
"Написать свой фреймворк",
"Создать свою операционную систему",
"Выучить машинное обучение",
"Собрать свой первый робот",
"Написать свой компилятор",
"Создать свою базу данных",
"Выучить ассемблер",
"Собрать свой первый суперкомпьютер",
"Написать свой процессор",
"Создать свою криптовалюту",
"Выучить квантовые вычисления",
"Собрать свой первый квантовый компьютер",
"Написать свой AI-модель",
"Создать свою нейросеть",
"Выучить нейронные сети",
"Собрать свой первый GPU-кластер",
"Написать свой GPU-драйвер",
"Создать свою операционную систему для GPU",
"Выучить FPGA-программирование",
"Собрать свой первый FPGA-ускоритель",
"Написать свой ASIC",
"Создать свою квантовую программу",
"Выучить квантовую физику",
"Собрать свой первый квантовый датчик",
"Написать свой квантовый алгоритм",
"Создать свою квантовую сеть",
"Выучить квантовую криптографию",
"Собрать свой первый квантовый ретранслятор",
"Написать свой квантовый протокол",
"Создать свою квантовую облачную платформу",
"Выучить квантовое машинное обучение",
"Собрать свой первый квантовый симулятор",
"Написать свой квантовый компилятор",
"Создать свою квантовую IDE",
"Выучить квантовое программирование",
"Собрать свой первый квантовый отладчик",
"Написать свой квантовый тестер",
"Создать свою квантовую документацию",
"Выучить квантовую математику",
"Собрать свой первый квантовый калькулятор",
"Написать свой квантовый solver",
"Создать свою квантовую оптимизацию",
"Выучить квантовую статистику",
"Собрать свой первый квантовый генератор случайных чисел",
"Написать свой квантовый шифр",
"Создать свою квантовую стеганографию",
"Выучить квантовую теорию игр",
"Собрать свой первый квантовый аукцион",
"Написать свой квантовый консенсус",
"Создать свою квантовую блокчейн",
"Выучить квантовую криптографию",
"Собрать свой первый квантовый ретранслятор",
"Написать свой квантовый протокол",
"Создать свою квантовую облачную платформу",
"Выучить квантовое машинное обучение",
"Собрать свой первый квантовый симулятор",
"Написать свой квантовый компилятор",
"Создать свою квантовую IDE",
"Выучить квантовое программирование",
"Собрать свой первый квантовый отладчик",
"Написать свой квантовый тестер",
"Создать свою квантовую документацию",
"Выучить квантовую математику",
"Собрать свой первый квантовый калькулятор",
"Написать свой квантовый solver",
"Создать свою квантовую оптимизацию",
"Выучить квантовую статистику",
"Собрать свой первый квантовый генератор случайных чисел",
"Написать свой квантовый шифр",
"Создать свою квантовую стеганографию",
"Выучить квантовую теорию игр",
"Собрать свой первый квантовый аукцион",
"Написать свой квантовый консенсус",
"Создать свою квантовую блокчейн",
]
def generate_random_cards(count=None):
"""Generate random cards from the predefined list."""
if count is None:
count = random.randint(1, 10)
selected_cards = random.sample(CARD_OPTIONS, min(count, len(CARD_OPTIONS)))
result = {
"generated_at": datetime.now().isoformat(),
"count": len(selected_cards),
"cards": selected_cards
}
return result
def print_cards(cards_data):
"""Print cards in a formatted way."""
print(f"\n📋 Сгенерировано карточек: {cards_data['count']}")
print(f"🕐 Время генерации: {cards_data['generated_at']}")
print("-" * 50)
for i, card in enumerate(cards_data['cards'], 1):
print(f"{i}. {card}")
print("-" * 50)
print(f"💡 Всего в списке: {len(CARD_OPTIONS)} вариантов")
if __name__ == "__main__":
# Generate random number of cards
cards_data = generate_random_cards()
print_cards(cards_data)
# Save to JSON
with open('/tmp/wish_cards.json', 'w') as f:
json.dump(cards_data, f, indent=2, ensure_ascii=False)
print(f"\n💾 Сохранено в /tmp/wish_cards.json")
+150 -16
View File
@@ -6,6 +6,9 @@
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<style> <style>
.dragging{opacity:.5;transform:rotate(5deg)}.drag-over{background:#e0e7ff;border:2px dashed #6366f1} .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> </style>
</head> </head>
<body class="min-h-screen bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-500 p-6"> <body class="min-h-screen bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-500 p-6">
@@ -22,9 +25,7 @@ function connect() {
ws = new WebSocket(WS); ws = new WebSocket(WS);
ws.onmessage = e => { ws.onmessage = e => {
const m = JSON.parse(e.data); const m = JSON.parse(e.data);
if (m.type==='board:create'||m.type==='board:delete'||m.type==='board:update') refresh(); if (m.type.startsWith('board:') || m.type.startsWith('list:') || m.type.startsWith('card:')) 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();
}; };
ws.onclose = () => setTimeout(connect, 3000); ws.onclose = () => setTimeout(connect, 3000);
} }
@@ -68,14 +69,52 @@ function render() {
} else refresh(); } 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 = ` document.getElementById('app').innerHTML = `
<div class="bg-white/10 backdrop-blur-md rounded-3xl shadow-2xl p-6"> <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 justify-between mb-6">
<div class="flex items-center gap-3"><div class="w-4 h-4 rounded-full" style="background:${currentBoard.color}"></div> <div class="flex items-center gap-3">
<h2 class="text-2xl font-bold text-white">${currentBoard.name}</h2></div> <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> <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>
<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="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"> <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()"> <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()">
@@ -85,7 +124,7 @@ function renderBoard(lists) {
} }
function renderList(list) { function renderList(list) {
return `<div class="bg-white rounded-2xl shadow-lg p-4 min-w-[280px] max-w-[320px] flex-shrink-0" data-lid="${list.id}" 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}')"> 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"> <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)">` ${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)">`
@@ -93,18 +132,68 @@ function renderList(list) {
<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> <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>
<div class="space-y-2 min-h-[100px]" id="cc-${list.id}"></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"> <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)"> <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>`; <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) { function renderCard(card) {
return `<div class="bg-white border border-gray-200 rounded-lg p-3 shadow-sm hover:shadow-md transition-shadow cursor-move" draggable="true" data-cid="${card.id}" 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}')"> ondragstart="ds(event,'${card.id}')" ondragover="event.preventDefault();this.classList.add('drag-over')" ondragleave="this.classList.remove('drag-over')" ondrop="dc(event,'${card.id}')">
${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="3" id="ed-${card.id}">${card.description}</textarea> ${card.image_url ? `<img src="${card.image_url}" class="card-image" onerror="this.style.display='none'">` : ''}
<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">OK</button><button onclick="render()" class="text-xs px-2 py-1 bg-gray-600 text-white rounded hover:bg-gray-700">Отмена</button></div></div>` ${editingCard===card.id?`
:`<div><p class="text-sm font-medium">${card.title}</p>${card.description?`<p class="text-xs text-gray-600 mt-1 line-clamp-2">${card.description}</p>`:''} <div class="space-y-2">
<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="delCard('${card.id}')" class="text-xs text-gray-400 hover:text-red-600">🗑️</button></div></div>`}</div>`; <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) { 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 delList(lid) { if(confirm('Удалить?')){await fetch(API+'/lists/'+lid,{method:'DELETE'});refresh();} }
async function editTitle(lid,t) { editingList=lid; render(); } 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 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 delCard(cid) { await fetch(API+'/cards/'+cid,{method:'DELETE'});refresh(); }
async function editCard(cid) { editingCard=cid; 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; let dragged=null;
function ds(e,cid) { dragged=cid; e.target.classList.add('dragging'); } function ds(e,cid) { dragged=cid; e.target.classList.add('dragging'); }
-3031
View File
File diff suppressed because it is too large. Load diff
-31
View File
@@ -1,31 +0,0 @@
{
"name": "wish-board",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"better-sqlite3": "^12.11.1",
"cors": "^2.8.6",
"express": "^5.2.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"uuid": "^14.0.1"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"vite": "^8.2.0"
}
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

Before

Width:  |  Height:  |  Size: 9.3 KiB

-24
View File
@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

+72 -10
View File
@@ -33,8 +33,21 @@ def init():
CREATE TABLE IF NOT EXISTS cards ( CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY, list_id TEXT REFERENCES lists(id) ON DELETE CASCADE, id TEXT PRIMARY KEY, list_id TEXT REFERENCES lists(id) ON DELETE CASCADE,
board_id TEXT REFERENCES boards(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() init()
@@ -49,11 +62,29 @@ class ListIn(BaseModel):
class CardIn(BaseModel): class CardIn(BaseModel):
title: str title: str
description: str = "" description: str = ""
image_url: str = ""
price: float = 0
url: str = ""
status: str = "want"
category: str = ""
rating: int = 3
comments: str = ""
@app.get("/") class CardUpdate(BaseModel):
def index(): title: Optional[str] = None
return FileResponse("index.html") 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") @app.get("/api/boards")
def boards(): def boards():
with db() as c: with db() as c:
@@ -74,6 +105,7 @@ def delete_board(bid: str):
broadcast({"type": "board:delete", "data": {"id": bid}}) broadcast({"type": "board:delete", "data": {"id": bid}})
return {"ok": True} return {"ok": True}
# Lists
@app.get("/api/boards/{bid}/lists") @app.get("/api/boards/{bid}/lists")
def lists(bid: str): def lists(bid: str):
with db() as c: with db() as c:
@@ -108,6 +140,7 @@ def delete_list(lid: str):
broadcast({"type": "list:delete", "data": {"id": lid}}) broadcast({"type": "list:delete", "data": {"id": lid}})
return {"ok": True} return {"ok": True}
# Cards
@app.get("/api/lists/{lid}/cards") @app.get("/api/lists/{lid}/cards")
def cards(lid: str): def cards(lid: str):
with db() as c: with db() as c:
@@ -119,20 +152,25 @@ def create_card(lid: str, data: CardIn):
with db() as c: with db() as c:
bid = c.execute("SELECT board_id FROM lists WHERE id=?", (lid,)).fetchone()["board_id"] 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"] 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)) 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)",
broadcast({"type": "card:create", "data": {"id": cid, "list_id": lid, "board_id": bid, "title": data.title, "description": data.description, "position": p+1}}) (cid, lid, bid, data.title, data.description, p+1,
return {"id": cid, "list_id": lid, "board_id": bid, "title": data.title, "description": data.description, "position": 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}") @app.patch("/api/cards/{cid}")
def update_card(cid: str, data: dict): def update_card(cid: str, data: CardUpdate):
with db() as c: with db() as c:
u, ps = [], [] u, ps = [], []
for k in ("title","description","position","list_id"): for k in ("title","description","image_url","price","url","status","category","rating","comments","archived","position","list_id"):
if k in data: u.append(f"{k}=?"); ps.append(data[k]) if getattr(data, k, None) is not None:
u.append(f"{k}=?"); ps.append(getattr(data, k))
if u: if u:
ps.append(cid) ps.append(cid)
c.execute(f"UPDATE cards SET {', '.join(u)} WHERE id=?", ps) c.execute(f"UPDATE cards SET {', '.join(u)} WHERE id=?", ps)
row = c.execute("SELECT * FROM cards WHERE id=?", (cid,)).fetchone() row = c.execute("SELECT * FROM cards WHERE id=?", (cid,)).fetchone()
if row:
broadcast({"type": "card:update", "data": dict(row)}) broadcast({"type": "card:update", "data": dict(row)})
return {"ok": True} return {"ok": True}
@@ -143,6 +181,30 @@ def delete_card(cid: str):
broadcast({"type": "card:delete", "data": {"id": cid}}) broadcast({"type": "card:delete", "data": {"id": cid}})
return {"ok": True} 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 # WebSocket broadcast
clients = set() clients = set()
-333
View File
@@ -1,333 +0,0 @@
from fastapi import FastAPI, HTTPException
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 os
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
DB_PATH = "wish_board.db"
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
conn.execute("""
CREATE TABLE IF NOT EXISTS boards (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
title TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS cards (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
board_id TEXT NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT DEFAULT '',
position INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (list_id) REFERENCES lists(id) ON DELETE CASCADE,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
)
""")
conn.commit()
conn.close()
init_db()
class BoardCreate(BaseModel):
name: str
color: str = "#6366f1"
class ListCreate(BaseModel):
title: str
class CardCreate(BaseModel):
title: str
description: str = ""
class BoardUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
class ListUpdate(BaseModel):
title: Optional[str] = None
position: Optional[int] = None
class CardUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
position: Optional[int] = None
list_id: Optional[str] = None
# Boards
@app.get("/api/boards")
def get_boards():
conn = get_db()
boards = conn.execute("SELECT * FROM boards ORDER BY created_at DESC").fetchall()
conn.close()
return [dict(b) for b in boards]
@app.post("/api/boards")
def create_board(data: BoardCreate):
board_id = str(uuid.uuid4())
conn = get_db()
conn.execute("INSERT INTO boards (id, name, color) VALUES (?, ?, ?)",
(board_id, data.name, data.color))
conn.commit()
conn.close()
return {"id": board_id, "name": data.name, "color": data.color}
@app.delete("/api/boards/{board_id}")
def delete_board(board_id: str):
conn = get_db()
conn.execute("DELETE FROM boards WHERE id = ?", (board_id,))
conn.commit()
conn.close()
return {"ok": True}
@app.patch("/api/boards/{board_id}")
def update_board(board_id: str, data: BoardUpdate):
conn = get_db()
updates = []
params = []
if data.name is not None:
updates.append("name = ?")
params.append(data.name)
if data.color is not None:
updates.append("color = ?")
params.append(data.color)
if updates:
params.append(board_id)
conn.execute(f"UPDATE boards SET {', '.join(updates)} WHERE id = ?", params)
conn.commit()
conn.close()
return {"ok": True}
# Lists
@app.get("/api/boards/{board_id}/lists")
def get_lists(board_id: str):
conn = get_db()
lists = conn.execute("SELECT * FROM lists WHERE board_id = ? ORDER BY position", (board_id,)).fetchall()
conn.close()
return [dict(l) for l in lists]
@app.post("/api/boards/{board_id}/lists")
def create_list(board_id: str, data: ListCreate):
list_id = str(uuid.uuid4())
conn = get_db()
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) as max FROM lists WHERE board_id = ?", (board_id,)).fetchone()["max"]
conn.execute("INSERT INTO lists (id, board_id, title, position) VALUES (?, ?, ?, ?)",
(list_id, board_id, data.title, max_pos + 1))
conn.commit()
conn.close()
return {"id": list_id, "board_id": board_id, "title": data.title, "position": max_pos + 1}
@app.patch("/api/lists/{list_id}")
def update_list(list_id: str, data: ListUpdate):
conn = get_db()
updates = []
params = []
if data.title is not None:
updates.append("title = ?")
params.append(data.title)
if data.position is not None:
updates.append("position = ?")
params.append(data.position)
if updates:
params.append(list_id)
conn.execute(f"UPDATE lists SET {', '.join(updates)} WHERE id = ?", params)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/lists/{list_id}")
def delete_list(list_id: str):
conn = get_db()
conn.execute("DELETE FROM lists WHERE id = ?", (list_id,))
conn.commit()
conn.close()
return {"ok": True}
# Cards
@app.get("/api/lists/{list_id}/cards")
def get_cards(list_id: str):
conn = get_db()
cards = conn.execute("SELECT * FROM cards WHERE list_id = ? ORDER BY position", (list_id,)).fetchall()
conn.close()
return [dict(c) for c in cards]
@app.post("/api/lists/{list_id}/cards")
def create_card(list_id: str, data: CardCreate):
card_id = str(uuid.uuid4())
conn = get_db()
list_row = conn.execute("SELECT board_id FROM lists WHERE id = ?", (list_id,)).fetchone()
board_id = list_row["board_id"]
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) as max FROM cards WHERE list_id = ?", (list_id,)).fetchone()["max"]
conn.execute("INSERT INTO cards (id, list_id, board_id, title, description, position) VALUES (?, ?, ?, ?, ?, ?)",
(card_id, list_id, board_id, data.title, data.description, max_pos + 1))
conn.commit()
conn.close()
return {"id": card_id, "list_id": list_id, "board_id": board_id, "title": data.title, "description": data.description, "position": max_pos + 1}
@app.patch("/api/cards/{card_id}")
def update_card(card_id: str, data: CardUpdate):
conn = get_db()
updates = []
params = []
if data.title is not None:
updates.append("title = ?")
params.append(data.title)
if data.description is not None:
updates.append("description = ?")
params.append(data.description)
if data.position is not None:
updates.append("position = ?")
params.append(data.position)
if data.list_id is not None:
updates.append("list_id = ?")
params.append(data.list_id)
if updates:
params.append(card_id)
conn.execute(f"UPDATE cards SET {', '.join(updates)} WHERE id = ?", params)
conn.commit()
conn.close()
return {"ok": True}
@app.delete("/api/cards/{card_id}")
def delete_card(card_id: str):
conn = get_db()
conn.execute("DELETE FROM cards WHERE id = ?", (card_id,))
conn.commit()
conn.close()
return {"ok": True}
# Serve frontend
@app.get("/")
def serve_index():
return FileResponse("static/index.html")
@app.get("/api/generate-cards")
def generate_random_cards(count: int = 5):
"""Generate random cards from predefined list."""
CARD_OPTIONS = [
"Изучить React и TypeScript",
"Написать мобильное приложение",
"Запустить свой SaaS-продукт",
"Собрать первый миллион на крипто",
"Путешествие в Японию",
"Купить дом в деревне",
"Научиться играть на гитаре",
"Прочитать 50 книг за год",
"Запустить YouTube канал",
"Создать AI-бота для бизнеса",
"Собрать свой первый ПК",
"Выучить английский на C1",
"Написать книгу",
"Открыть кофейню",
"Сделать татуировку",
"Прыгнуть с парашютом",
"Пройти марафон",
"Выучить Python с нуля",
"Собрать сервер для дома",
"Запустить Telegram-бот",
"Создать свой дизайн-систему",
"Написать игру на Unity",
"Собрать умный дом",
"Выучить три языка",
"Построить баню",
"Купить машину мечты",
"Собрать коллекцию винила",
"Пройти курс по UX/UI",
"Создать подкаст",
"Написать приложение для заметок",
"Собрать свой первый drone",
"Выучить JavaScript",
"Создать свой сайт-портфолио",
"Научиться фотографировать",
"Собрать свой первый сервер",
"Запустить свой блог",
"Создать свой мессенджер",
"Выучить Rust",
"Собрать свой первый VR-шлем",
"Написать свой фреймворк",
"Создать свою операционную систему",
"Выучить машинное обучение",
"Собрать свой первый робот",
"Написать свой компилятор",
"Создать свою базу данных",
"Выучить ассемблер",
"Собрать свой первый суперкомпьютер",
"Написать свой процессор",
"Создать свою криптовалюту",
"Выучить квантовые вычисления",
"Собрать свой первый квантовый компьютер",
"Написать свой AI-модель",
"Создать свою нейросеть",
"Выучить нейронные сети",
"Собрать свой первый GPU-кластер",
"Написать свой GPU-драйвер",
"Создать свою операционную систему для GPU",
"Выучить FPGA-программирование",
"Собрать свой первый FPGA-ускоритель",
"Написать свой ASIC",
"Создать свою квантовую программу",
"Выучить квантовую физику",
"Собрать свой первый квантовый датчик",
"Написать свой квантовый алгоритм",
"Создать свою квантовую сеть",
"Выучить квантовую криптографию",
"Собрать свой первый квантовый ретранслятор",
"Написать свой квантовый протокол",
"Создать свою квантовую облачную платформу",
"Выучить квантовое машинное обучение",
"Собрать свой первый квантовый симулятор",
"Написать свой квантовый компилятор",
"Создать свою квантовую IDE",
"Выучить квантовое программирование",
"Собрать свой первый квантовый отладчик",
"Написать свой квантовый тестер",
"Создать свою квантовую документацию",
"Выучить квантовую математику",
"Собрать свой первый квантовый калькулятор",
"Написать свой квантовый solver",
"Создать свою квантовую оптимизацию",
"Выучить квантовую статистику",
"Собрать свой первый квантовый генератор случайных чисел",
"Написать свой квантовый шифр",
"Создать свою квантовую стеганографию",
"Выучить квантовую теорию игр",
"Собрать свой первый квантовый аукцион",
"Написать свой квантовый консенсус",
"Создать свою квантовую блокчейн",
]
import random
selected = random.sample(CARD_OPTIONS, min(count, len(CARD_OPTIONS)))
return {
"cards": selected,
"total": len(CARD_OPTIONS)
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=4000)
-184
View File
@@ -1,184 +0,0 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
-122
View File
@@ -1,122 +0,0 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<section id="center">
<div className="hero">
<img src={heroImg} className="base" width="170" height="179" alt="" />
<img src={reactLogo} className="framework" alt="React logo" />
<img src={viteLogo} className="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>
Edit <code>src/App.jsx</code> and save to test <code>HMR</code>
</p>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
)
}
export default App
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated

Before

Width:  |  Height:  |  Size: 8.5 KiB

-111
View File
@@ -1,111 +0,0 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
-761
View File
@@ -1,761 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Доска желаний</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 2rem;
}
.container {
max-width: 1400px;
margin: 0 auto;
}
h1 {
color: white;
font-size: 3rem;
text-align: center;
margin-bottom: 2rem;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.board {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 1.5rem;
padding: 2rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.board-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
}
.board-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.board-color {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
}
.board-name {
font-size: 1.75rem;
font-weight: bold;
color: white;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: white;
color: #1f2937;
}
.btn-primary:hover {
background: #f3f4f6;
transform: translateY(-2px);
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.btn-small {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.lists-container {
display: flex;
gap: 1.5rem;
overflow-x: auto;
padding-bottom: 1rem;
min-height: 400px;
}
.list {
background: white;
border-radius: 1rem;
padding: 1.25rem;
min-width: 300px;
max-width: 320px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
flex-shrink: 0;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.list-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
cursor: pointer;
flex: 1;
}
.list-title:hover {
color: #4b5563;
}
.list-title-input {
flex: 1;
padding: 0.5rem;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.list-title-input:focus {
outline: none;
border-color: #6366f1;
}
.cards-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 100px;
}
.card {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1rem;
cursor: grab;
transition: all 0.2s;
}
.card:hover {
background: #f3f4f6;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.card:active {
cursor: grabbing;
}
.card-title {
font-size: 0.9375rem;
font-weight: 500;
color: #1f2937;
margin-bottom: 0.5rem;
}
.card-description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.5;
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.75rem;
}
.card-action-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: #9ca3af;
border-radius: 0.25rem;
}
.card-action-btn:hover {
background: #e5e7eb;
color: #4b5563;
}
.card-action-btn.delete:hover {
background: #fee2e2;
color: #dc2626;
}
.add-card-form {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.add-card-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.add-card-input:focus {
outline: none;
border-color: #6366f1;
}
.add-list-form {
margin-top: 2rem;
}
.add-list-container {
display: flex;
gap: 0.75rem;
}
.add-list-input {
flex: 1;
padding: 1rem;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 1rem;
background: rgba(255,255,255,0.2);
color: white;
font-size: 1rem;
}
.add-list-input::placeholder {
color: rgba(255,255,255,0.7);
}
.add-list-input:focus {
outline: none;
border-color: white;
background: rgba(255,255,255,0.3);
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: white;
}
.empty-state h2 {
font-size: 2rem;
margin-bottom: 1rem;
}
.create-board-form {
background: white;
border-radius: 1.5rem;
padding: 3rem;
max-width: 500px;
margin: 0 auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.create-board-form h2 {
font-size: 1.75rem;
color: #1f2937;
margin-bottom: 2rem;
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.75rem;
font-size: 1rem;
}
.form-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.color-picker {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
}
.color-option {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
border: 3px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.color-option:hover {
transform: scale(1.1);
}
.color-option.selected {
border-color: #1f2937;
transform: scale(1.15);
}
.btn-submit {
width: 100%;
padding: 1rem;
background: #6366f1;
color: white;
border: none;
border-radius: 0.75rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-submit:hover {
background: #4f46e5;
transform: translateY(-2px);
}
.edit-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.edit-input {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
}
.edit-textarea {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
resize: vertical;
min-height: 60px;
}
.edit-actions {
display: flex;
gap: 0.5rem;
}
.btn-save {
background: #10b981;
color: white;
}
.btn-save:hover {
background: #059669;
}
.btn-cancel {
background: #6b7280;
color: white;
}
.btn-cancel:hover {
background: #4b5563;
}
.dragging {
opacity: 0.5;
transform: rotate(5deg);
}
.drag-over {
background: #e0e7ff;
border: 2px dashed #6366f1;
}
</style>
</head>
<body>
<div class="container">
<h1>✨ Доска желаний</h1>
<div id="app"></div>
</div>
<script>
const API_BASE = '/api';
let currentBoard = null;
let boards = [];
async function fetchBoards() {
const res = await fetch(`${API_BASE}/boards`);
boards = await res.json();
if (boards.length > 0 && !currentBoard) {
currentBoard = boards[0];
}
render();
}
async function createBoard(name, color) {
const res = await fetch(`${API_BASE}/boards`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, color})
});
const board = await res.json();
boards.unshift(board);
currentBoard = board;
render();
}
async function deleteBoard(id) {
if (confirm('Удалить доску?')) {
await fetch(`${API_BASE}/boards/${id}`, {method: 'DELETE'});
boards = boards.filter(b => b.id !== id);
currentBoard = boards.length > 0 ? boards[0] : null;
render();
}
}
async function updateBoard(id, data) {
await fetch(`${API_BASE}/boards/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
fetchBoards();
}
async function fetchLists(boardId) {
const res = await fetch(`${API_BASE}/boards/${boardId}/lists`);
return await res.json();
}
async function createList(boardId, title) {
const res = await fetch(`${API_BASE}/boards/${boardId}/lists`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({title})
});
return await res.json();
}
async function updateList(listId, data) {
await fetch(`${API_BASE}/lists/${listId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
}
async function deleteList(listId) {
if (confirm('Удалить список и все карточки?')) {
await fetch(`${API_BASE}/lists/${listId}`, {method: 'DELETE'});
}
}
async function fetchCards(listId) {
const res = await fetch(`${API_BASE}/lists/${listId}/cards`);
return await res.json();
}
async function createCard(listId, title, description = '') {
const res = await fetch(`${API_BASE}/lists/${listId}/cards`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({title, description})
});
return await res.json();
}
async function updateCard(cardId, data) {
await fetch(`${API_BASE}/cards/${cardId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
}
async function deleteCard(cardId) {
await fetch(`${API_BASE}/cards/${cardId}`, {method: 'DELETE'});
}
async function generateRandomCards(count = 5) {
const res = await fetch(`${API_BASE}/generate-cards?count=${count}`);
const data = await res.json();
return data.cards;
}
function render() {
const app = document.getElementById('app');
if (!currentBoard) {
app.innerHTML = `
<div class="create-board-form">
<h2>Создать новую доску</h2>
<form id="createBoardForm">
<div class="form-group">
<label class="form-label">Название доски</label>
<input type="text" class="form-input" id="boardName" placeholder="Например: Цели на 2024" required>
</div>
<div class="form-group">
<label class="form-label">Цвет доски</label>
<div class="color-picker" id="colorPicker"></div>
</div>
<button type="submit" class="btn-submit">Создать доску</button>
</form>
</div>
`;
const colors = ['#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', '#f43f5e', '#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6', '#2563eb', '#1d4ed8', '#1e3a8a'];
const colorPicker = document.getElementById('colorPicker');
colors.forEach((color, index) => {
const div = document.createElement('div');
div.className = `color-option ${index === 0 ? 'selected' : ''}`;
div.style.backgroundColor = color;
div.dataset.color = color;
colorPicker.appendChild(div);
});
let selectedColor = colors[0];
colorPicker.addEventListener('click', (e) => {
if (e.target.classList.contains('color-option')) {
document.querySelectorAll('.color-option').forEach(el => el.classList.remove('selected'));
e.target.classList.add('selected');
selectedColor = e.target.dataset.color;
}
});
document.getElementById('createBoardForm').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('boardName').value;
if (name.trim()) {
await createBoard(name.trim(), selectedColor);
}
});
return;
}
fetchLists(currentBoard.id).then(lists => {
renderBoard(lists);
});
}
async function renderBoard(lists) {
const app = document.getElementById('app');
app.innerHTML = `
<div class="board">
<div class="board-header">
<div class="board-title">
<div class="board-color" style="background-color: ${currentBoard.color}"></div>
<h2 class="board-name">${currentBoard.name}</h2>
</div>
<div class="flex gap-2">
<button class="btn btn-danger btn-small" onclick="deleteBoard('${currentBoard.id}')">Удалить доску</button>
</div>
</div>
<div class="lists-container" id="listsContainer">
${lists.map(list => renderList(list)).join('')}
</div>
<div class="add-list-form">
<div class="add-list-container">
<input type="text" class="add-list-input" id="newListInput" placeholder="Добавить список..." onkeypress="if(event.key==='Enter') addList()">
<button class="btn btn-primary" onclick="addList()">Добавить</button>
</div>
</div>
</div>
`;
// Load cards for each list
lists.forEach(list => loadCards(list.id));
}
function renderList(list) {
return `
<div class="list" data-list-id="${list.id}">
<div class="list-header">
<h3 class="list-title" ondblclick="editListTitle('${list.id}', '${list.title}')">${list.title}</h3>
<button class="btn btn-danger btn-small" onclick="deleteList('${list.id}')">×</button>
</div>
<div class="cards-container" id="cards-${list.id}">
<!-- Cards will be loaded here -->
</div>
<div class="add-card-form">
<input type="text" class="add-card-input" placeholder="Добавить карточку..." onkeypress="if(event.key==='Enter') addCard('${list.id}', this)">
</div>
</div>
`;
}
async function loadCards(listId) {
const cards = await fetchCards(listId);
const container = document.getElementById(`cards-${listId}`);
container.innerHTML = cards.map(card => renderCard(card)).join('');
}
function renderCard(card) {
return `
<div class="card" draggable="true" data-card-id="${card.id}">
<div class="card-title">${card.title}</div>
${card.description ? `<div class="card-description">${card.description}</div>` : ''}
<div class="card-actions">
<button class="card-action-btn" onclick="editCard('${card.id}')">Изменить</button>
<button class="card-action-btn delete" onclick="deleteCard('${card.id}')">Удалить</button>
</div>
</div>
`;
}
async function addCard(listId, input) {
if (input.value.trim()) {
await createCard(listId, input.value.trim());
input.value = '';
loadCards(listId);
}
}
async function addList() {
const input = document.getElementById('newListInput');
if (input.value.trim()) {
const list = await createList(currentBoard.id, input.value.trim());
const container = document.getElementById('listsContainer');
container.insertAdjacentHTML('beforeend', renderList(list));
loadCards(list.id);
input.value = '';
}
}
async function editListTitle(listId, currentTitle) {
const titleEl = document.querySelector(`[data-list-id="${listId}"] .list-title`);
titleEl.innerHTML = `<input type="text" class="list-title-input" value="${currentTitle}" onblur="saveListTitle('${listId}', this.value)" onkeypress="if(event.key==='Enter') saveListTitle('${listId}', this.value)">`;
titleEl.querySelector('input').focus();
}
async function saveListTitle(listId, newTitle) {
if (newTitle.trim()) {
await updateList(listId, {title: newTitle.trim()});
render();
}
}
async function editCard(cardId) {
const cards = await fetchCards(document.querySelector(`[data-list-id]`).dataset.listId);
const card = cards.find(c => c.id === cardId);
if (!card) return;
const cardEl = document.querySelector(`[data-card-id="${cardId}"]`);
cardEl.innerHTML = `
<div class="edit-form">
<input type="text" class="edit-input" id="edit-title-${cardId}" value="${card.title}">
<textarea class="edit-textarea" id="edit-desc-${cardId}">${card.description}</textarea>
<div class="edit-actions">
<button class="btn btn-save btn-small" onclick="saveCard('${cardId}')">Сохранить</button>
<button class="btn btn-cancel btn-small" onclick="loadCards('${cardEl.closest('.list').dataset.listId}')">Отмена</button>
</div>
</div>
`;
}
async function saveCard(cardId) {
const title = document.getElementById(`edit-title-${cardId}`).value;
const description = document.getElementById(`edit-desc-${cardId}`).value;
await updateCard(cardId, {title, description});
render();
}
// Drag and drop
let draggedCard = null;
document.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('card')) {
draggedCard = e.target;
e.target.classList.add('dragging');
}
});
document.addEventListener('dragend', (e) => {
if (e.target.classList.contains('card')) {
e.target.classList.remove('dragging');
}
});
document.addEventListener('dragover', (e) => {
e.preventDefault();
const list = e.target.closest('.list');
if (list) {
list.classList.add('drag-over');
}
});
document.addEventListener('dragleave', (e) => {
const list = e.target.closest('.list');
if (list) {
list.classList.remove('drag-over');
}
});
document.addEventListener('drop', async (e) => {
e.preventDefault();
const list = e.target.closest('.list');
if (list && draggedCard) {
list.classList.remove('drag-over');
const listId = list.dataset.listId;
const cardId = draggedCard.dataset.cardId;
// Update position
const cards = await fetchCards(listId);
const cardIndex = cards.findIndex(c => c.id === cardId);
await updateCard(cardId, {position: cardIndex, list_id: listId});
// Reload all lists
fetchBoards();
draggedCard = null;
}
});
// Initial load
fetchBoards();
</script>
</body>
</html>
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.