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 @@