feat(feedback): implement ChipDip-style floating dev feedback & bug report widget with markdown sync

This commit is contained in:
Antigravity committed 2026-10-08 11:53:18 +03:00
1 parent e1cacde543
commit 8cffae46fa
11 files changed
+1104 -3

No files matched your search

+18
View File
@@ -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)
+1
View File
@@ -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
+3
View File
@@ -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",
]
+234
View File
@@ -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}
+396
View File
@@ -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
View File
@@ -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>
+2
View File
@@ -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');
+42
View File
@@ -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('Не удалось удалить');
}
}
};
+262
View File
@@ -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
View File
@@ -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',
];