From 8cffae46faa9aec475f238b21b3f740e83b6496b Mon Sep 17 00:00:00 2001 From: Antigravity Date: Thu, 8 Oct 2026 11:53:18 +0300 Subject: [PATCH] feat(feedback): implement ChipDip-style floating dev feedback & bug report widget with markdown sync --- app/database.py | 18 ++ app/main.py | 1 + app/routers/__init__.py | 3 + app/routers/feedback.py | 234 ++++++++++++++++++ app/static/css/components.css | 396 ++++++++++++++++++++++++++++++ app/static/index.html | 66 ++++- app/static/js/app.js | 2 + app/static/js/modules/api.js | 42 ++++ app/static/js/modules/feedback.js | 262 ++++++++++++++++++++ app/static/sw.js | 3 +- tests/test_api.py | 80 ++++++ 11 files changed, 1104 insertions(+), 3 deletions(-) create mode 100644 app/routers/feedback.py create mode 100644 app/static/js/modules/feedback.js diff --git a/app/database.py b/app/database.py index 2bbad9d..916f8b4 100644 --- a/app/database.py +++ b/app/database.py @@ -433,6 +433,24 @@ def _migrate(conn: sqlite3.Connection): """) conn.execute("CREATE INDEX IF NOT EXISTS idx_lesson_sess_user_date ON lesson_sessions(user_id, completed_at DESC);") + # Ensure feedback_reports table exists (User In-App Bug & Feature Tracker) + conn.execute(""" + CREATE TABLE IF NOT EXISTS feedback_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + category TEXT NOT NULL DEFAULT 'bug', + message TEXT NOT NULL, + context_url TEXT DEFAULT '', + context_data_json TEXT DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'new', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_feedback_user ON feedback_reports(user_id);") + conn.execute("CREATE INDEX IF NOT EXISTS idx_feedback_status ON feedback_reports(status);") + # Repair legacy cards with placeholder blanks or mismatched context repair_mismatched_cefr_contexts(conn) diff --git a/app/main.py b/app/main.py index 2177ddb..cdc0978 100644 --- a/app/main.py +++ b/app/main.py @@ -27,6 +27,7 @@ from app.routers.grammar import GrammarAnswerRequest, GrammarCompleteRequest, Gr from app.routers.tasks import MineBooksRequest from app.routers.listening import ListeningCheckRequest from app.routers.lessons import SavePresetRequest, LessonDebriefRequest +from app.routers.feedback import FeedbackCreateRequest, FeedbackStatusUpdateRequest @asynccontextmanager diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 0d54d6d..d8ea0a8 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -9,6 +9,7 @@ from app.routers.cefr import router as cefr_router from app.routers.grammar import router as grammar_router from app.routers.tasks import router as tasks_router from app.routers.lessons import router as lessons_router +from app.routers.feedback import router as feedback_router all_routers = [ auth_router, @@ -22,6 +23,7 @@ all_routers = [ grammar_router, tasks_router, lessons_router, + feedback_router, ] __all__ = [ @@ -37,4 +39,5 @@ __all__ = [ "grammar_router", "tasks_router", "lessons_router", + "feedback_router", ] diff --git a/app/routers/feedback.py b/app/routers/feedback.py new file mode 100644 index 0000000..8789931 --- /dev/null +++ b/app/routers/feedback.py @@ -0,0 +1,234 @@ +import json +import logging +from pathlib import Path +from typing import Optional, Dict, Any, List +from fastapi import APIRouter, Depends, HTTPException, Query, status +from pydantic import BaseModel, Field + +from app.auth import require_user +from app.database import get_db + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/feedback", tags=["Feedback & Bug Tracker"]) + +# Path to workspace memory file for seamless Antigravity AI reading +MEMORY_REPORTS_FILE = Path("/home/ai-projects/memory/USER_REPORTS.md") + + +class FeedbackCreateRequest(BaseModel): + category: str = Field(default="bug", description="Category: 'bug', 'feature', 'note'") + message: str = Field(..., min_length=1, max_length=5000) + context_url: Optional[str] = Field(default="", max_length=500) + context_data: Optional[Dict[str, Any]] = None + + +class FeedbackStatusUpdateRequest(BaseModel): + status: str = Field(..., pattern="^(new|in_progress|resolved|closed)$") + + +def sync_reports_to_markdown(conn): + """ + Synchronizes feedback_reports from SQLite to /home/ai-projects/memory/USER_REPORTS.md. + This enables Antigravity and AI agents to instantly inspect user-reported bugs and feature requests. + """ + try: + rows = conn.execute(""" + SELECT f.id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username + FROM feedback_reports f + JOIN users u ON f.user_id = u.id + ORDER BY f.id DESC + """).fetchall() + + lines = [ + "# 📋 Пользовательские репорты & Запросы на доработку (study.qpid.pro)", + "", + "> 🔄 Данный файл автоматически синхронизируется с виджетом обратной связи на сайте.", + "> AI-ассистент (Antigravity) считывает этот файл для анализа найденных багов и планирования задач.", + "", + f"**Всего репортов:** {len(rows)} | **Последняя синхронизация:** сейчас", + "", + "## 🔴 Активные репорты (Требуют внимания / В работе)", + "", + ] + + active_rows = [r for r in rows if r["status"] in ("new", "in_progress")] + resolved_rows = [r for r in rows if r["status"] in ("resolved", "closed")] + + if not active_rows: + lines.append("🎉 *Активных багов или задач нет! Все известные репорты закрыты.*") + lines.append("") + else: + for r in active_rows: + cat_emoji = "🐛 БАГ" if r["category"] == "bug" else ("💡 ФИЧА" if r["category"] == "feature" else "📝 ЗАМЕТКА") + status_icon = "🆕 Новый" if r["status"] == "new" else "⏳ В работе" + + ctx_extra = "" + if r["context_data_json"]: + try: + c_dict = json.loads(r["context_data_json"]) + parts = [] + if "screen" in c_dict: parts.append(f"Экран: {c_dict['screen']}") + if "device" in c_dict: parts.append(f"Устройство: {c_dict['device']}") + if parts: ctx_extra = f" ({', '.join(parts)})" + except Exception: + pass + + lines.append(f"### #{r['id']} [{cat_emoji}] — {status_icon} (`{r['created_at']}`)") + lines.append(f"- **Пользователь**: `{r['username']}`") + if r["context_url"]: + lines.append(f"- **Раздел/Страница**: `{r['context_url']}`{ctx_extra}") + lines.append(f"- **Сообщение**:\n> {r['message'].strip()}") + lines.append("") + + lines.extend([ + "---", + "", + "## ✅ Решенные / Закрытые задачи", + "", + ]) + + if not resolved_rows: + lines.append("*Пока нет закрытых репортов.*") + lines.append("") + else: + for r in resolved_rows: + cat_emoji = "🐛" if r["category"] == "bug" else ("💡" if r["category"] == "feature" else "📝") + lines.append(f"- [x] **#{r['id']} {cat_emoji} ({r['created_at']})**: {r['message'].strip()} *(Страница: {r['context_url'] or 'общая'})*") + lines.append("") + + MEMORY_REPORTS_FILE.parent.mkdir(parents=True, exist_ok=True) + MEMORY_REPORTS_FILE.write_text("\n".join(lines), encoding="utf-8") + except Exception as e: + logger.warning(f"Could not sync reports to markdown: {e}") + + +@router.get("", response_model=List[Dict[str, Any]]) +def list_feedback( + category: Optional[str] = Query(None), + status_filter: Optional[str] = Query(None, alias="status"), + user: dict = Depends(require_user), +): + """Lists feedback reports. Admins see all reports; regular users see their own.""" + is_admin = bool(user.get("is_admin")) + with get_db() as conn: + query = """ + SELECT f.id, f.user_id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username + FROM feedback_reports f + JOIN users u ON f.user_id = u.id + WHERE 1=1 + """ + params = [] + if not is_admin: + query += " AND f.user_id = ?" + params.append(user["id"]) + + if category: + query += " AND f.category = ?" + params.append(category) + + if status_filter: + query += " AND f.status = ?" + params.append(status_filter) + + query += " ORDER BY f.id DESC LIMIT 100" + rows = conn.execute(query, params).fetchall() + + result = [] + for r in rows: + ctx_data = {} + if r["context_data_json"]: + try: + ctx_data = json.loads(r["context_data_json"]) + except Exception: + pass + + result.append({ + "id": r["id"], + "user_id": r["user_id"], + "username": r["username"], + "category": r["category"], + "message": r["message"], + "context_url": r["context_url"], + "context_data": ctx_data, + "status": r["status"], + "created_at": r["created_at"], + "updated_at": r["updated_at"], + }) + return result + + +@router.post("", status_code=status.HTTP_201_CREATED) +def create_feedback( + payload: FeedbackCreateRequest, + user: dict = Depends(require_user), +): + """Submits a new bug, feature request, or note from the website chat widget.""" + allowed_categories = {"bug", "feature", "note"} + category = payload.category if payload.category in allowed_categories else "bug" + ctx_json = json.dumps(payload.context_data or {}, ensure_ascii=False) + + with get_db() as conn: + cursor = conn.execute( + """ + INSERT INTO feedback_reports (user_id, category, message, context_url, context_data_json, status) + VALUES (?, ?, ?, ?, ?, 'new') + """, + (user["id"], category, payload.message.strip(), payload.context_url or "", ctx_json), + ) + report_id = cursor.lastrowid + sync_reports_to_markdown(conn) + + return { + "ok": True, + "id": report_id, + "category": category, + "status": "new", + "message": "Репорт успешно отправлен и передан в систему задач", + } + + +@router.patch("/{report_id}") +def update_feedback_status( + report_id: int, + payload: FeedbackStatusUpdateRequest, + user: dict = Depends(require_user), +): + """Updates the status of a report (e.g. 'in_progress', 'resolved').""" + with get_db() as conn: + existing = conn.execute("SELECT id, user_id FROM feedback_reports WHERE id = ?", (report_id,)).fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Репорт не найден") + + is_admin = bool(user.get("is_admin")) + if not is_admin and existing["user_id"] != user["id"]: + raise HTTPException(status_code=403, detail="Недостаточно прав") + + conn.execute( + "UPDATE feedback_reports SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (payload.status, report_id), + ) + sync_reports_to_markdown(conn) + + return {"ok": True, "id": report_id, "status": payload.status} + + +@router.delete("/{report_id}") +def delete_feedback( + report_id: int, + user: dict = Depends(require_user), +): + """Deletes a report.""" + with get_db() as conn: + existing = conn.execute("SELECT id, user_id FROM feedback_reports WHERE id = ?", (report_id,)).fetchone() + if not existing: + raise HTTPException(status_code=404, detail="Репорт не найден") + + is_admin = bool(user.get("is_admin")) + if not is_admin and existing["user_id"] != user["id"]: + raise HTTPException(status_code=403, detail="Недостаточно прав") + + conn.execute("DELETE FROM feedback_reports WHERE id = ?", (report_id,)) + sync_reports_to_markdown(conn) + + return {"ok": True, "id": report_id} diff --git a/app/static/css/components.css b/app/static/css/components.css index e28d0a0..7453cba 100644 --- a/app/static/css/components.css +++ b/app/static/css/components.css @@ -1156,3 +1156,399 @@ body.drawer-open { } } + +/* ========================================================================== + ChipDip-Style Floating Feedback & Dev Bug Report Widget + ========================================================================== */ + +.feedback-container { + position: relative; + z-index: 9995; +} + +/* Floating Launcher Button */ +.feedback-trigger-btn { + position: fixed; + bottom: 24px; + right: 24px; + width: 48px; + height: 48px; + border-radius: var(--radius-full); + background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%); + color: #ffffff; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 18px rgba(99, 102, 241, 0.42), 0 2px 6px rgba(0, 0, 0, 0.2); + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, opacity 0.2s ease; + z-index: 9995; + user-select: none; +} + +.feedback-trigger-btn:hover { + transform: scale(1.08) translateY(-2px); + box-shadow: 0 6px 24px rgba(99, 102, 241, 0.55), 0 3px 8px rgba(0, 0, 0, 0.3); +} + +.feedback-trigger-btn:active { + transform: scale(0.96); +} + +.feedback-trigger-icon { + font-size: 22px; + line-height: 1; +} + +.feedback-badge { + position: absolute; + top: -4px; + right: -4px; + background: #ef4444; + color: #ffffff; + font-size: 11px; + font-weight: 700; + min-width: 18px; + height: 18px; + border-radius: 9999px; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid var(--bg-main); +} + +/* Floating Chat Window */ +.feedback-widget { + position: fixed; + bottom: 84px; + right: 24px; + width: 380px; + max-width: calc(100vw - 32px); + height: 520px; + max-height: calc(100dvh - 120px); + background: var(--bg-surface); + border: 1px solid var(--border-focus); + border-radius: 18px; + box-shadow: 0 16px 40px -8px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.05); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 9998; + animation: fadeInScale 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} + +.feedback-widget-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: linear-gradient(135deg, rgba(59, 130, 246, 0.12), rgba(99, 102, 241, 0.08)); + border-bottom: 1px solid var(--border-color); +} + +.feedback-header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.feedback-header-icon { + font-size: 20px; +} + +.feedback-header-title { + font-size: 14px; + font-weight: 600; + color: var(--text-main); + line-height: 1.2; +} + +.feedback-header-sub { + font-size: 11px; + color: var(--text-muted); +} + +.feedback-close-btn { + background: none; + border: none; + color: var(--text-muted); + font-size: 16px; + cursor: pointer; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + transition: background 0.15s ease, color 0.15s ease; +} + +.feedback-close-btn:hover { + background: var(--bg-hover); + color: var(--text-main); +} + +/* Category Switcher Tabs */ +.feedback-category-bar { + display: flex; + gap: 6px; + padding: 8px 14px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-color); +} + +.feedback-cat-chip { + flex: 1; + padding: 5px 8px; + font-size: 12px; + font-weight: 500; + border-radius: var(--radius-full); + border: 1px solid var(--border-color); + background: rgba(255, 255, 255, 0.04); + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s ease; + text-align: center; +} + +.feedback-cat-chip:hover { + color: var(--text-main); + border-color: var(--border-focus); +} + +.feedback-cat-chip.active { + background: rgba(99, 102, 241, 0.16); + border-color: var(--primary); + color: var(--primary); + font-weight: 600; +} + +/* Context Indicator */ +.feedback-context-indicator { + padding: 5px 14px; + background: rgba(0, 0, 0, 0.15); + font-size: 11px; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-bottom: 1px solid var(--border-color); +} + +/* Chat Messages List */ +.feedback-messages-list { + flex: 1; + overflow-y: auto; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; + background: var(--bg-main); +} + +.feedback-empty-state { + margin: auto; + text-align: center; + padding: 24px 12px; + color: var(--text-muted); +} + +.feedback-empty-state .empty-icon { + font-size: 32px; + display: block; + margin-bottom: 8px; +} + +.feedback-empty-state p { + font-size: 13px; + font-weight: 500; + color: var(--text-main); + margin-bottom: 4px; +} + +.feedback-empty-state small { + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.4; + display: block; +} + +/* Message Card in Feed */ +.feedback-msg-card { + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 10px 12px; + font-size: 13px; + line-height: 1.45; + animation: fadeIn 0.15s ease-out; +} + +.feedback-msg-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; + gap: 8px; +} + +.feedback-msg-badge { + font-size: 10px; + font-weight: 600; + padding: 2px 7px; + border-radius: var(--radius-full); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.feedback-msg-badge.bug { + background: rgba(239, 68, 68, 0.14); + color: #f87171; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.feedback-msg-badge.feature { + background: rgba(168, 85, 247, 0.14); + color: #c084fc; + border: 1px solid rgba(168, 85, 247, 0.3); +} + +.feedback-msg-badge.note { + background: rgba(59, 130, 246, 0.14); + color: #60a5fa; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.feedback-msg-status { + font-size: 10.5px; + font-weight: 500; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.feedback-msg-status.new { + color: #facc15; +} + +.feedback-msg-status.in_progress { + color: #38bdf8; +} + +.feedback-msg-status.resolved { + color: #4ade80; +} + +.feedback-msg-text { + color: var(--text-main); + white-space: pre-wrap; + word-break: break-word; +} + +.feedback-msg-meta { + margin-top: 6px; + font-size: 10.5px; + color: var(--text-dim); + display: flex; + align-items: center; + justify-content: space-between; +} + +/* Input Form */ +.feedback-input-form { + padding: 10px 14px 12px; + background: var(--bg-surface); + border-top: 1px solid var(--border-color); +} + +.feedback-textarea { + width: 100%; + box-sizing: border-box; + background: var(--bg-input); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-main); + padding: 8px 12px; + font-size: 13px; + font-family: inherit; + line-height: 1.4; + resize: none; + outline: none; + transition: border-color 0.15s ease; +} + +.feedback-textarea:focus { + border-color: var(--border-focus); +} + +.feedback-form-bottom { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 6px; +} + +.feedback-hint { + font-size: 10.5px; + color: var(--text-dim); +} + +.feedback-send-btn { + width: 32px; + height: 32px; + border-radius: var(--radius-full); + background: var(--primary); + color: #ffffff; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s ease, transform 0.15s ease; +} + +.feedback-send-btn:hover { + background: var(--primary-hover); + transform: scale(1.05); +} + +.feedback-send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Zen mode override: completely hide feedback button & widget */ +body.zen-mode-active #feedback-container, +body.zen-mode-active .feedback-trigger-btn, +body.zen-mode-active .feedback-widget { + display: none !important; +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .feedback-trigger-btn { + bottom: calc(68px + env(safe-area-inset-bottom, 0px)); + right: 16px; + width: 44px; + height: 44px; + } + + .feedback-trigger-icon { + font-size: 20px; + } + + .feedback-widget { + bottom: calc(64px + env(safe-area-inset-bottom, 0px)); + right: 10px; + left: 10px; + width: auto; + max-width: none; + height: calc(85dvh - 70px); + border-radius: 18px; + } +} diff --git a/app/static/index.html b/app/static/index.html index 51aee9d..1fbacc2 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -5,7 +5,7 @@ Notes — Персональный тренажер и словарь английского языка - + @@ -2046,10 +2046,72 @@ + +
+ + + + + +
+ - + diff --git a/app/static/js/app.js b/app/static/js/app.js index 86e1d2e..2062e7f 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -11,6 +11,7 @@ import { initReader, loadBookshelf, openBook } from './modules/reader.js'; import { initCefr, loadCefrOverview, startCefrQuiz } from './modules/cefr.js'; import { initGrammar, loadGrammarUnits, openGrammarUnit } from './modules/grammar.js'; import { initLessonModule, openLessonConfigurator } from './modules/lesson.js'; +import { initFeedbackWidget } from './modules/feedback.js'; let currentUser = null; let isRouting = false; @@ -686,6 +687,7 @@ document.addEventListener('DOMContentLoaded', () => { initCefr(); initGrammar(); initLessonModule(); + initFeedbackWidget(); // 5. Global Navigation & Header Actions const navBrandHome = document.getElementById('nav-brand-home'); diff --git a/app/static/js/modules/api.js b/app/static/js/modules/api.js index 66a0007..8f31ba5 100644 --- a/app/static/js/modules/api.js +++ b/app/static/js/modules/api.js @@ -514,6 +514,48 @@ export const api = { if (resp.ok) return await resp.json(); throw new Error(`Failed to get lesson history: ${resp.status}`); } + }, + // In-app Feedback & Bug Tracker + feedback: { + async getAll(params = {}) { + let url = '/api/feedback'; + const qs = new URLSearchParams(); + if (params.category) qs.set('category', params.category); + if (params.status) qs.set('status', params.status); + if (qs.toString()) url += `?${qs.toString()}`; + const resp = await fetch(url); + if (resp.ok) return await resp.json(); + return []; + }, + async create(category, message, contextUrl = '', contextData = {}) { + const resp = await fetch('/api/feedback', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + category, + message, + context_url: contextUrl, + context_data: contextData + }) + }); + if (resp.ok) return await resp.json(); + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || 'Не удалось отправить репорт'); + }, + async updateStatus(id, status) { + const resp = await fetch(`/api/feedback/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }) + }); + if (resp.ok) return await resp.json(); + throw new Error('Не удалось обновить статус'); + }, + async delete(id) { + const resp = await fetch(`/api/feedback/${id}`, { method: 'DELETE' }); + if (resp.ok) return await resp.json(); + throw new Error('Не удалось удалить'); + } } }; diff --git a/app/static/js/modules/feedback.js b/app/static/js/modules/feedback.js new file mode 100644 index 0000000..df2c119 --- /dev/null +++ b/app/static/js/modules/feedback.js @@ -0,0 +1,262 @@ +// ======================================================================= +// Notes Application - Feedback & Dev Bug Tracker Widget (Native ES Module) +// ChipDip-style floating chat for instant bug reporting & feature requests +// ======================================================================= + +import { api } from './api.js'; +import { escapeHtml, showToast } from './ui.js'; + +let activeCategory = 'bug'; +let isWidgetOpen = false; + +function getCurrentContext() { + const hash = window.location.hash || '#dictionary'; + const width = window.innerWidth; + const height = window.innerHeight; + const isMobile = width <= 768; + + let friendlyName = 'Главная'; + if (hash.startsWith('#reader/book/')) { + const titleEl = document.getElementById('reader-book-title'); + const bookTitle = titleEl ? titleEl.textContent : 'Книга'; + friendlyName = `Читалка: ${bookTitle}`; + } else if (hash.startsWith('#reader')) { + friendlyName = 'Полка книг'; + } else if (hash.startsWith('#srs')) { + friendlyName = 'SRS Тренировка'; + } else if (hash.startsWith('#grammar')) { + friendlyName = 'Грамматика'; + } else if (hash.startsWith('#cefr')) { + friendlyName = 'CEFR Тестирование'; + } else if (hash.startsWith('#lesson')) { + friendlyName = 'Урок'; + } else if (hash.startsWith('#dictionary')) { + friendlyName = 'Словарь & Поиск'; + } + + return { + url: hash, + friendlyName: friendlyName, + data: { + screen: `${width}×${height}`, + device: isMobile ? 'Mobile' : 'Desktop', + userAgent: navigator.userAgent.slice(0, 150), + timestamp: new Date().toISOString() + } + }; +} + +function updateContextDisplay() { + const indicator = document.getElementById('feedback-context-text'); + if (!indicator) return; + const ctx = getCurrentContext(); + indicator.textContent = `${ctx.friendlyName} • ${ctx.data.device} (${ctx.data.screen})`; +} + +function formatRelativeTime(isoString) { + if (!isoString) return ''; + try { + const date = new Date(isoString); + return date.toLocaleDateString('ru-RU', { + day: 'numeric', + month: 'short', + hour: '2-digit', + minute: '2-digit' + }); + } catch { + return isoString; + } +} + +function createReportCard(r) { + const card = document.createElement('div'); + card.className = 'feedback-msg-card'; + card.dataset.reportId = r.id; + + const catLabels = { + bug: { label: '🐛 Баг', cls: 'bug' }, + feature: { label: '💡 Фича', cls: 'feature' }, + note: { label: '📝 Заметка', cls: 'note' } + }; + const cat = catLabels[r.category] || catLabels.bug; + + const statusLabels = { + new: { label: '🆕 Новый', cls: 'new' }, + in_progress: { label: '⏳ В работе', cls: 'in_progress' }, + resolved: { label: '✅ Исправлено', cls: 'resolved' }, + closed: { label: '✕ Закрыт', cls: 'resolved' } + }; + const st = statusLabels[r.status] || statusLabels.new; + + const timeStr = formatRelativeTime(r.created_at); + const contextStr = r.context_url ? escapeHtml(r.context_url) : ''; + + card.innerHTML = ` +
+ + +
+
${escapeHtml(r.message)}
+
+ ${timeStr} + ${contextStr ? `${contextStr}` : ''} +
+ `; + return card; +} + +export async function loadFeedbackList() { + const listEl = document.getElementById('feedback-messages-list'); + const badgeEl = document.getElementById('feedback-badge'); + if (!listEl) return; + + try { + const reports = await api.feedback.getAll(); + const activeCount = reports.filter(r => r.status === 'new' || r.status === 'in_progress').length; + + if (badgeEl) { + if (activeCount > 0) { + badgeEl.textContent = activeCount; + badgeEl.classList.remove('hidden'); + } else { + badgeEl.classList.add('hidden'); + } + } + + listEl.innerHTML = ''; + if (reports.length === 0) { + listEl.innerHTML = ` +
+ 📝 +

Нашли баг или есть идея для фичи?

+ Напишите сообщение ниже — оно сразу синхронизируется с AI-разработчиком. +
+ `; + return; + } + + reports.forEach(r => { + listEl.appendChild(createReportCard(r)); + }); + + // Scroll to bottom + listEl.scrollTop = listEl.scrollHeight; + } catch (err) { + console.error('Failed to load feedback reports:', err); + } +} + +export function openFeedbackWidget() { + const widget = document.getElementById('feedback-widget'); + const input = document.getElementById('feedback-input'); + if (!widget) return; + + isWidgetOpen = true; + widget.classList.remove('hidden'); + updateContextDisplay(); + loadFeedbackList(); + + setTimeout(() => { + if (input) input.focus(); + }, 100); +} + +export function closeFeedbackWidget() { + const widget = document.getElementById('feedback-widget'); + if (!widget) return; + isWidgetOpen = false; + widget.classList.add('hidden'); +} + +export function toggleFeedbackWidget() { + if (isWidgetOpen) { + closeFeedbackWidget(); + } else { + openFeedbackWidget(); + } +} + +export function initFeedbackWidget() { + const triggerBtn = document.getElementById('feedback-trigger-btn'); + const closeBtn = document.getElementById('feedback-close-btn'); + const widget = document.getElementById('feedback-widget'); + const form = document.getElementById('feedback-form'); + const input = document.getElementById('feedback-input'); + const sendBtn = document.getElementById('feedback-send-btn'); + const catChips = document.querySelectorAll('.feedback-cat-chip'); + + if (triggerBtn) { + triggerBtn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleFeedbackWidget(); + }); + } + + if (closeBtn) { + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + closeFeedbackWidget(); + }); + } + + // Category chips selection + catChips.forEach(chip => { + chip.addEventListener('click', () => { + catChips.forEach(c => c.classList.remove('active')); + chip.classList.add('active'); + activeCategory = chip.dataset.category || 'bug'; + if (input) input.focus(); + }); + }); + + // Close on Escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && isWidgetOpen) { + closeFeedbackWidget(); + } + }); + + // Enter to send, Shift+Enter for new line + if (input) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (form) form.requestSubmit(); + } + }); + } + + // Form submit + if (form) { + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const text = input ? input.value.trim() : ''; + if (!text) return; + + const ctx = getCurrentContext(); + + if (sendBtn) sendBtn.disabled = true; + + try { + await api.feedback.create(activeCategory, text, ctx.url, ctx.data); + showToast('✅ Репорт отправлен в бэклог AI-разработчику!', 'success'); + if (input) input.value = ''; + await loadFeedbackList(); + } catch (err) { + console.error('Feedback submit error:', err); + showToast(err.message || 'Ошибка отправки репорта', 'error'); + } finally { + if (sendBtn) sendBtn.disabled = false; + if (input) input.focus(); + } + }); + } + + // Hash change updates context indicator if open + window.addEventListener('hashchange', () => { + if (isWidgetOpen) updateContextDisplay(); + }); + + // Initial check of badge count + loadFeedbackList(); +} diff --git a/app/static/sw.js b/app/static/sw.js index 29c18b8..1b7ae63 100644 --- a/app/static/sw.js +++ b/app/static/sw.js @@ -1,7 +1,7 @@ // Service Worker for Notes English PWA (Stage 2) // Strategy: Cache-first for static assets, Network-first for API calls. -const CACHE_NAME = 'notes-v6.8'; +const CACHE_NAME = 'notes-v6.9'; const OFFLINE_URL = '/'; const PRECACHE_URLS = [ @@ -22,6 +22,7 @@ const PRECACHE_URLS = [ '/static/js/modules/reader.js', '/static/js/modules/cefr.js', '/static/js/modules/grammar.js', + '/static/js/modules/feedback.js', '/manifest.json', ]; diff --git a/tests/test_api.py b/tests/test_api.py index eeb4958..84d4a59 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2556,6 +2556,86 @@ def test_wikipedia_parsoid_html_cleaning(): assert "Clean Cell" in content +def test_feedback_endpoints_workflow(): + from fastapi.testclient import TestClient + from pathlib import Path + + # 1. Unauthenticated access blocked + unauth_client = TestClient(app) + resp = unauth_client.get("/api/feedback") + assert resp.status_code == 401 + + resp = unauth_client.post("/api/feedback", json={"message": "Test bug"}) + assert resp.status_code == 401 + + # 2. Authenticated login + login_resp = client.post("/api/login", json={"username": "testadmin", "password": "testpass12345"}) + assert login_resp.status_code == 200 + + # 3. Create a bug report + bug_payload = { + "category": "bug", + "message": "Кнопка в читалке не нажимается на мобилке", + "context_url": "#reader/book/1/ch/2", + "context_data": {"screen": "390x844", "device": "Mobile"} + } + create_resp = client.post("/api/feedback", json=bug_payload) + assert create_resp.status_code == 201 + c_data = create_resp.json() + assert c_data["ok"] is True + report_id = c_data["id"] + assert c_data["category"] == "bug" + assert c_data["status"] == "new" + + # 4. Create a feature request + feat_payload = { + "category": "feature", + "message": "Добавить темную тему OLED", + "context_url": "#reader", + "context_data": {"screen": "1920x1080", "device": "Desktop"} + } + create_resp2 = client.post("/api/feedback", json=feat_payload) + assert create_resp2.status_code == 201 + report_id2 = create_resp2.json()["id"] + + # 5. List reports + list_resp = client.get("/api/feedback") + assert list_resp.status_code == 200 + reports = list_resp.json() + assert len(reports) >= 2 + r_ids = [r["id"] for r in reports] + assert report_id in r_ids + assert report_id2 in r_ids + + # 6. Filter by category + bugs_resp = client.get("/api/feedback?category=bug") + assert bugs_resp.status_code == 200 + assert all(r["category"] == "bug" for r in bugs_resp.json()) + + # 7. Update status to resolved + patch_resp = client.patch(f"/api/feedback/{report_id}", json={"status": "resolved"}) + assert patch_resp.status_code == 200 + assert patch_resp.json()["status"] == "resolved" + + # Verify updated + get_updated = client.get("/api/feedback?status=resolved") + assert get_updated.status_code == 200 + assert any(r["id"] == report_id and r["status"] == "resolved" for r in get_updated.json()) + + # 8. Delete report + del_resp = client.delete(f"/api/feedback/{report_id2}") + assert del_resp.status_code == 200 + assert del_resp.json()["ok"] is True + + # 9. Verify markdown sync file exists and has content + reports_md = Path("/home/ai-projects/memory/USER_REPORTS.md") + assert reports_md.exists() + content = reports_md.read_text(encoding="utf-8") + assert "Пользовательские репорты" in content + assert "Кнопка в читалке не нажимается" in content + + +