feat(feedback): implement ChipDip-style floating dev feedback & bug report widget with markdown sync
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user