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

+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',
];