# ТЗ: Портал приложений (apps.mva154.duckdns.org) ## Общее описание Лендинг-портал с карточками веб-приложений. Светлая тема, автогенерация аватарок, JSON-конфиг. **URL:** `apps.mva154.duckdns.org` **Стек:** Flask (порт 5560) + HTML/CSS/JS (один файл) + Pillow (аватарки) **Бизнес-требования:** `docs/BRD.md` --- ## Файлы ``` tasks/apps-portal/ ├── docs/ │ └── BRD.md — бизнес-требования ├── config/ │ └── apps.json — конфиг приложений ├── static/ │ └── avatars/ — сгенерированные аватарки (PNG 200×200) ├── templates/ │ └── index.html — главная страница ├── server.py — Flask сервер └── requirements.txt — flask, pillow ``` --- ## Конфиг: config/apps.json ```json [ { "id": "noisemap", "name": "Карта шума", "description": "Карта шумового загрязнения от авиации", "icon": "🛩️", "url": "https://openclaw.mva154.duckdns.org/noisemap/", "enabled": true, "order": 1 }, { "id": "snowbike-rag", "name": "Snowbike Поиск", "description": "Семантический поиск по 155K сообщений сноубайков", "icon": "🏔️", "url": "https://openclaw.mva154.duckdns.org/snowbike-rag/", "enabled": true, "order": 2 } ] ``` --- ## Аватарки: автогенерация При старте Flask проверяет `static/avatars/`. Для каждого приложения из конфига без файла аватарки — генерирует PNG 200×200. **Алгоритм:** 1. Хэш от `name` → два цвета для градиента 2. Градиентный фон (линейный, 135°) 3. По центру — emoji из поля `icon` (масштабируется через Pillow, если поддерживается) или первая буква `name` 4. Сохранить в `static/avatars/{id}.png` **Примеры градиентов:** - «Карта шума» → синий → тёмно-синий - «Snowbike Поиск» → зелёный → тёмно-зелёный - Разные названия → разные цвета (детерминированно) --- ## server.py ```python from flask import Flask, render_template, send_from_directory import json from pathlib import Path app = Flask(__name__) CONFIG_FILE = Path(__file__).parent / 'config' / 'apps.json' def load_apps(): with open(CONFIG_FILE, 'r', encoding='utf-8') as f: apps = json.load(f) return sorted([a for a in apps if a.get('enabled', True)], key=lambda x: x.get('order', 99)) @app.route('/') def index(): apps = load_apps() return render_template('index.html', apps=apps) @app.route('/api/apps') def api_apps(): return load_apps() @app.route('/static/avatars/') def avatar(filename): return send_from_directory('static/avatars', filename) if __name__ == '__main__': generate_avatars() # автогенерация при старте app.run(host='0.0.0.0', port=5560) ``` --- ## index.html ### Структура ``` ┌──────────────────────────────────────┐ │ Заголовок: «Мои приложения» │ │ Подзаголовок: «N активных» │ │ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ аватар │ │ аватар │ │ аватар │ │ │ │ Название│ │ Название│ │ Название│ │ │ │ Опис. │ │ Опис. │ │ Опис. │ │ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ └──────────────────────────────────────┘ ``` ### Дизайн • Светлая тема: фон #F8FAFC, карточки #FFFFFF • Шрифт: Inter (Google Fonts CDN) • Tailwind CSS через CDN • Карточка: 80×80 аватарка по центру, название, описание • Hover: border синий + lift-тень + scale 1.02 • Адаптивно: 4 → 2 → 1 колонка ### Зависимости (CDN) ```html ``` --- ## Nginx (добавить в основной конфиг) ```nginx server { server_name apps.mva154.duckdns.org; location / { proxy_pass http://172.19.0.2:5560/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } listen 443 ssl; ssl_certificate /etc/letsencrypt/live/mva154.duckdns.org/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mva154.duckdns.org/privkey.pem; } ``` --- ## Критерии приёмки - [ ] `http://localhost:5560/` — портал с карточками - [ ] Клик по карточке — переход на URL приложения - [ ] Аватарки сгенерированы в `static/avatars/` - [ ] Светлая тема, шрифт Inter - [ ] Адаптивно на мобильном - [ ] Добавил приложение в `apps.json` — портал показывает его с аватаркой - [ ] GET `/api/apps` — JSON-список приложений --- ## Важно • Всё в `tasks/apps-portal/` • HTML — один файл (inline CSS + JS) • Аватарки — Pillow, без внешних API • Порт 5560 (не пересекается с 5555, 5556, 5557)