263 lines
8.5 KiB
JavaScript
263 lines
8.5 KiB
JavaScript
// =======================================================================
|
||
// 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();
|
||
}
|