feat(feedback): implement ChipDip-style floating dev feedback & bug report widget with markdown sync
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+64
-2
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=6.8">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=6.9">
|
||||
|
||||
<!-- PWA meta and icons -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
@@ -2046,10 +2046,72 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ChipDip-style Floating Feedback & Bug Report Widget -->
|
||||
<div id="feedback-container" class="feedback-container">
|
||||
<!-- Floating Launcher Button -->
|
||||
<button id="feedback-trigger-btn" class="feedback-trigger-btn" title="Сообщить о баге или предложить идею (Dev Chat)" aria-label="Обратная связь">
|
||||
<span class="feedback-trigger-icon">💬</span>
|
||||
<span id="feedback-badge" class="feedback-badge hidden">0</span>
|
||||
</button>
|
||||
|
||||
<!-- Floating Chat Window -->
|
||||
<div id="feedback-widget" class="feedback-widget hidden" role="dialog" aria-labelledby="feedback-widget-title">
|
||||
<div class="feedback-widget-header">
|
||||
<div class="feedback-header-left">
|
||||
<span class="feedback-header-icon">💬</span>
|
||||
<div>
|
||||
<div id="feedback-widget-title" class="feedback-header-title">Dev Feedback & Баги</div>
|
||||
<div class="feedback-header-sub">Синхронизируется с AI-ассистентом</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="feedback-close-btn" class="feedback-close-btn" title="Закрыть (Esc)">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Category Selector Tabs -->
|
||||
<div class="feedback-category-bar">
|
||||
<button type="button" class="feedback-cat-chip active" data-category="bug">🐛 Баг</button>
|
||||
<button type="button" class="feedback-cat-chip" data-category="feature">💡 Фича</button>
|
||||
<button type="button" class="feedback-cat-chip" data-category="note">📝 Заметка</button>
|
||||
</div>
|
||||
|
||||
<!-- Auto Context Indicator -->
|
||||
<div class="feedback-context-indicator" id="feedback-context-indicator">
|
||||
<span class="context-icon">📍</span>
|
||||
<span id="feedback-context-text" class="context-text">Определение контекста...</span>
|
||||
</div>
|
||||
|
||||
<!-- Chat / Reports Feed -->
|
||||
<div id="feedback-messages-list" class="feedback-messages-list">
|
||||
<div class="feedback-empty-state" id="feedback-empty-state">
|
||||
<span class="empty-icon">📝</span>
|
||||
<p>Нашли баг или есть идея для фичи?</p>
|
||||
<small>Напишите сообщение ниже — оно сразу попадет в систему задач AI-разработчика.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<form id="feedback-form" class="feedback-input-form">
|
||||
<textarea
|
||||
id="feedback-input"
|
||||
class="feedback-textarea"
|
||||
placeholder="Опишите баг или идею... (Enter для отправки, Shift+Enter для новой строки)"
|
||||
rows="2"
|
||||
required
|
||||
></textarea>
|
||||
<div class="feedback-form-bottom">
|
||||
<span class="feedback-hint">Enter — отправить</span>
|
||||
<button type="submit" id="feedback-send-btn" class="feedback-send-btn" title="Отправить репорт">
|
||||
<span class="send-icon">➤</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio Element for native streaming -->
|
||||
<audio id="global-audio-player" preload="none"></audio>
|
||||
|
||||
<script type="module" src="/static/js/app.js?v=6.5"></script>
|
||||
<script type="module" src="/static/js/app.js?v=6.6"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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('Не удалось удалить');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = `
|
||||
<div class="feedback-msg-header">
|
||||
<span class="feedback-msg-badge ${cat.cls}">${cat.label}</span>
|
||||
<span class="feedback-msg-status ${st.cls}">${st.label}</span>
|
||||
</div>
|
||||
<div class="feedback-msg-text">${escapeHtml(r.message)}</div>
|
||||
<div class="feedback-msg-meta">
|
||||
<span>${timeStr}</span>
|
||||
${contextStr ? `<span>${contextStr}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
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 = `
|
||||
<div class="feedback-empty-state">
|
||||
<span class="empty-icon">📝</span>
|
||||
<p>Нашли баг или есть идея для фичи?</p>
|
||||
<small>Напишите сообщение ниже — оно сразу синхронизируется с AI-разработчиком.</small>
|
||||
</div>
|
||||
`;
|
||||
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();
|
||||
}
|
||||
+2
-1
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user