From e12f2e1274acc57162f39e1d14f5b614ff5a56c1 Mon Sep 17 00:00:00 2001 From: CodeNinjaSarthak Date: Sat, 7 Mar 2026 22:02:31 +0530 Subject: [PATCH 01/41] feat: implement ErrorBoundary component and enhance error handling in Dashboard pages --- frontend/src/App.jsx | 9 ++- .../src/components/Dashboard/ActivityLog.jsx | 4 +- .../components/Dashboard/AnalyticsPanel.jsx | 60 +++++++++++-------- .../src/components/Dashboard/YouTubePanel.jsx | 12 +++- frontend/src/components/ErrorBoundary.jsx | 41 +++++++++++++ frontend/src/index.css | 36 +++++++++++ 6 files changed, 131 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/ErrorBoundary.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b853323..5184eb3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,7 @@ import { RegisterPage } from './pages/RegisterPage'; import { DashboardPage } from './pages/DashboardPage'; import LandingPage from './pages/LandingPage'; import { SettingsPage } from './pages/SettingsPage'; +import { ErrorBoundary } from './components/ErrorBoundary'; import { ToastContainer } from './components/Toast/Toast'; import { GlobalShortcutsHandler } from './components/GlobalShortcutsHandler'; @@ -23,7 +24,9 @@ export default function App() { path="/dashboard" element={ - + + + } /> @@ -31,7 +34,9 @@ export default function App() { path="/settings" element={ - + + + } /> diff --git a/frontend/src/components/Dashboard/ActivityLog.jsx b/frontend/src/components/Dashboard/ActivityLog.jsx index 973d97a..6fdb06a 100644 --- a/frontend/src/components/Dashboard/ActivityLog.jsx +++ b/frontend/src/components/Dashboard/ActivityLog.jsx @@ -30,10 +30,10 @@ export function ActivityLog({ sessionEvents }) { return (
- {events.map((msg, i) => { + {events.map((msg) => { const meta = EVENT_META[msg.type]; return ( -
+
{meta.icon} {meta.label(msg.data)} {relativeTime(msg.timestamp)} diff --git a/frontend/src/components/Dashboard/AnalyticsPanel.jsx b/frontend/src/components/Dashboard/AnalyticsPanel.jsx index 18e40ad..56b3cb9 100644 --- a/frontend/src/components/Dashboard/AnalyticsPanel.jsx +++ b/frontend/src/components/Dashboard/AnalyticsPanel.jsx @@ -3,7 +3,7 @@ import { BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts'; -import { getSessionAnalytics, getSessionClusters, fetchAllComments } from '../../services/api'; +import { getSessionAnalytics, getSessionClusters, getClusterComments } from '../../services/api'; import { ActivityLog } from './ActivityLog'; const ANALYTICS_EVENTS = new Set([ @@ -70,15 +70,18 @@ export function AnalyticsPanel({ sessionId, token, sessionEvents }) { async function handleExportCSV() { setExporting(true); try { - const [clusters, comments] = await Promise.all([ - getSessionClusters(sessionId, token), - fetchAllComments(sessionId, token), - ]); + const clusters = await getSessionClusters(sessionId, token); const rows = [['Question', 'Answer', 'Cluster', 'Timestamp', 'Is Posted']]; - (clusters || []).forEach(cluster => { - const clusterComments = (comments || []).filter(c => c.cluster_id === cluster.id); + for (const cluster of (clusters || [])) { + let comments; + try { + comments = await getClusterComments(cluster.id, token); + } catch (e) { + console.warn(`Skipping cluster ${cluster.id} in export:`, e.message); + continue; + } const latestAnswer = cluster.answers?.[cluster.answers.length - 1]; - clusterComments.forEach(comment => { + for (const comment of (comments || [])) { rows.push([ comment.text, latestAnswer?.text || '', @@ -86,8 +89,8 @@ export function AnalyticsPanel({ sessionId, token, sessionEvents }) { new Date(comment.created_at).toLocaleString(), latestAnswer?.is_posted ? 'Yes' : 'No', ]); - }); - }); + } + } const csv = rows .map(row => row.map(cell => `"${String(cell ?? '').replace(/"/g, '""')}"`).join(',')) .join('\n'); @@ -102,20 +105,29 @@ export function AnalyticsPanel({ sessionId, token, sessionEvents }) { async function handleExportJSON() { setExporting(true); try { - const [clusters, comments] = await Promise.all([ - getSessionClusters(sessionId, token), - fetchAllComments(sessionId, token), - ]); - const output = (clusters || []).map(cluster => ({ - cluster_id: cluster.id, - title: cluster.title, - comment_count: cluster.comment_count, - answer: cluster.answers?.[cluster.answers.length - 1]?.text || null, - is_posted: cluster.answers?.[cluster.answers.length - 1]?.is_posted ?? false, - questions: (comments || []) - .filter(c => c.cluster_id === cluster.id) - .map(c => ({ text: c.text, author: c.author_name, timestamp: c.created_at })), - })); + const clusters = await getSessionClusters(sessionId, token); + const output = []; + for (const cluster of (clusters || [])) { + let comments; + try { + comments = await getClusterComments(cluster.id, token); + } catch (e) { + console.warn(`Skipping cluster ${cluster.id} in export:`, e.message); + continue; + } + output.push({ + cluster_id: cluster.id, + title: cluster.title, + comment_count: cluster.comment_count, + answer: cluster.answers?.[cluster.answers.length - 1]?.text || null, + is_posted: cluster.answers?.[cluster.answers.length - 1]?.is_posted ?? false, + questions: (comments || []).map(c => ({ + text: c.text, + author: c.author_name, + timestamp: c.created_at, + })), + }); + } downloadBlob( JSON.stringify(output, null, 2), 'application/json', diff --git a/frontend/src/components/Dashboard/YouTubePanel.jsx b/frontend/src/components/Dashboard/YouTubePanel.jsx index abc0750..7f740da 100644 --- a/frontend/src/components/Dashboard/YouTubePanel.jsx +++ b/frontend/src/components/Dashboard/YouTubePanel.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { getYouTubeStatus, getYouTubeAuthURL, disconnectYouTube } from '../../services/api'; export function YouTubePanel({ token }) { @@ -6,6 +6,9 @@ export function YouTubePanel({ token }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [actionLoading, setActionLoading] = useState(false); + const pollIntervalRef = useRef(null); + + useEffect(() => () => clearInterval(pollIntervalRef.current), []); useEffect(() => { fetchStatus(); @@ -32,6 +35,8 @@ export function YouTubePanel({ token }) { window.addEventListener('message', function handler(e) { if (e.origin !== window.location.origin) return; if (e.data?.type === 'youtube_oauth_complete') { + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; popup?.close(); fetchStatus(); setActionLoading(false); @@ -39,9 +44,10 @@ export function YouTubePanel({ token }) { }, { once: true }); // Fallback: if popup is closed without completing - const pollClosed = setInterval(() => { + pollIntervalRef.current = setInterval(() => { if (popup && popup.closed) { - clearInterval(pollClosed); + clearInterval(pollIntervalRef.current); + pollIntervalRef.current = null; fetchStatus(); setActionLoading(false); } diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..637c088 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,41 @@ +import { Component } from 'react'; + +export class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, info) { + console.error('ErrorBoundary caught:', error, info); + } + + render() { + if (this.state.hasError) { + return ( +
+

Something went wrong

+
+ Error details +
{this.state.error?.message}
+
+ {/* + Reload instead of setState({ hasError: false }): clearing state would immediately + re-render the child, causing an infinite crash loop for structural errors + (bad prop, null data, etc.). Reload is the safer default. Trade-off: any unsaved + state (manual question drafts, in-progress edits) is lost, but this only triggers + on a hard component crash. + */} + +
+ ); + } + return this.props.children; + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 8fa774d..d67a8f9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -734,3 +734,39 @@ button:disabled { opacity: 0.5; pointer-events: none; } .activity-icon { flex-shrink: 0; } .activity-text { flex: 1; color: var(--color-text); } .activity-time { color: var(--color-muted); white-space: nowrap; flex-shrink: 0; } + +/* ============================================================ + Error Boundary + ============================================================ */ +.error-boundary { + padding: 2rem; + background: var(--color-surface); + color: var(--color-text); + border-radius: var(--radius); + margin: 2rem; + border: 1px solid var(--color-border); + box-shadow: var(--shadow); +} + +.error-boundary h2 { + font-size: 15px; + font-weight: 600; + margin-bottom: 12px; +} + +.error-boundary details { + margin-bottom: 16px; +} + +.error-boundary summary { + cursor: pointer; + color: var(--color-muted); + font-size: 13px; +} + +.error-boundary pre { + margin-top: 8px; + font-size: 12px; + white-space: pre-wrap; + color: var(--color-muted); +} From a276a14386e3c0db55765da62b345150f1512f27 Mon Sep 17 00:00:00 2001 From: CodeNinjaSarthak Date: Sat, 7 Mar 2026 22:16:36 +0530 Subject: [PATCH 02/41] feat: enhance layout and styling for ClustersPanel and QuestionsFeed, add responsive design adjustments --- .../components/Dashboard/ClustersPanel.jsx | 2 +- .../components/Dashboard/QuestionsFeed.jsx | 2 +- frontend/src/index.css | 84 +++++++++++++++++-- frontend/src/pages/DashboardPage.jsx | 16 ++-- 4 files changed, 88 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/Dashboard/ClustersPanel.jsx b/frontend/src/components/Dashboard/ClustersPanel.jsx index 2d5c856..9d7b352 100644 --- a/frontend/src/components/Dashboard/ClustersPanel.jsx +++ b/frontend/src/components/Dashboard/ClustersPanel.jsx @@ -145,7 +145,7 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) }); return ( -
+

Clusters & Answers

diff --git a/frontend/src/components/Dashboard/QuestionsFeed.jsx b/frontend/src/components/Dashboard/QuestionsFeed.jsx index 408ac50..2bb6730 100644 --- a/frontend/src/components/Dashboard/QuestionsFeed.jsx +++ b/frontend/src/components/Dashboard/QuestionsFeed.jsx @@ -85,7 +85,7 @@ export function QuestionsFeed({ sessionId, token, wsMessages }) { : comments; return ( -
+

Live Feed{' '} {comments.length} diff --git a/frontend/src/index.css b/frontend/src/index.css index d67a8f9..2c73b89 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -47,6 +47,7 @@ --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,0.4); + --header-height: 56px; } [data-theme='light'] { @@ -173,7 +174,7 @@ body { background: var(--color-surface); border-bottom: 1px solid var(--color-border); padding: 0 24px; - height: 56px; + height: var(--header-height); display: flex; align-items: center; justify-content: space-between; @@ -190,18 +191,35 @@ body { /* ============================================================ Main Layout ============================================================ */ -.app-main { padding: 20px 24px; max-width: 1400px; margin: 0 auto; } +.app-main { + padding: 20px 24px; + max-width: 1400px; + margin: 0 auto; + height: calc(100vh - var(--header-height)); + overflow: hidden; +} .panels-grid { display: grid; - grid-template-columns: 360px 1fr; + grid-template-columns: clamp(280px, 30%, 400px) 1fr; gap: 20px; + height: 100%; +} + +.left-column { + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; + min-height: 0; } -.left-column, .right-column { +.right-column { display: flex; flex-direction: column; gap: 16px; + min-height: 0; + overflow: hidden; } /* ============================================================ @@ -339,11 +357,40 @@ button:disabled { opacity: 0.5; pointer-events: none; } .badge-connected { background: var(--badge-connected-bg); color: var(--badge-connected-color); } .badge-pending { background: var(--badge-pending-bg); color: var(--badge-pending-color); } +/* Panels that grow to fill right-column height and scroll internally */ +.panel-scrollable { + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Flex weight: QuestionsFeed (minor) vs ClustersPanel (major) — 1:2 split */ +.panel-feed { flex: 1; } +.panel-clusters { flex: 2; } + +/* Main-view tab wrapper — explicit flex child of right-column, distributes to panels */ +.tab-view-panels { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Analytics tab wrapper — single scrollable child of right-column */ +.analytics-scroll-wrapper { + flex: 1; + min-height: 0; + overflow-y: auto; +} + /* ============================================================ Questions Feed ============================================================ */ .questions-feed { - height: 400px; + flex: 1; + min-height: 0; overflow-y: auto; scroll-behavior: smooth; display: flex; @@ -382,7 +429,8 @@ button:disabled { opacity: 0.5; pointer-events: none; } display: flex; flex-direction: column; gap: 12px; - max-height: 600px; + flex: 1; + min-height: 0; overflow-y: auto; } @@ -466,8 +514,30 @@ button:disabled { opacity: 0.5; pointer-events: none; } /* ============================================================ Responsive ============================================================ */ +/* Mid-range: 901px – 1100px */ +@media (max-width: 1100px) { + .panels-grid { + grid-template-columns: 260px 1fr; + } + .right-column .panel { + padding: 12px; + } +} + +/* Mobile: stack columns, restore normal page scroll */ @media (max-width: 900px) { - .panels-grid { grid-template-columns: 1fr; } + .app-main { + height: auto; + overflow: visible; + } + .panels-grid { + grid-template-columns: 1fr; + height: auto; + } + .left-column, + .right-column { + overflow-y: visible; + } } /* ============================================================ diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 0bedfd7..cad6e45 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -82,7 +82,7 @@ export function DashboardPage() {

{view === 'main' ? ( - <> +
- +
) : ( - +
+ +
)} ) : ( From 2e3b05539075b90118288bdff246b75574176de7 Mon Sep 17 00:00:00 2001 From: CodeNinjaSarthak Date: Sat, 7 Mar 2026 22:56:06 +0530 Subject: [PATCH 03/41] feat: add Skeleton component and implement loading states across various dashboard components --- .../components/Dashboard/AnalyticsPanel.jsx | 27 ++++++++++++++-- .../Dashboard/ClusterDetailsModal.jsx | 21 +++++++++++-- .../components/Dashboard/ClustersPanel.jsx | 29 +++++++++++------ .../components/Dashboard/DocumentUpload.jsx | 19 +++++++----- .../src/components/Dashboard/MetricsCards.jsx | 5 +-- .../components/Dashboard/QuestionsFeed.jsx | 23 +++++++++----- frontend/src/components/Skeleton.jsx | 3 ++ frontend/src/index.css | 31 +++++++++++++++++++ 8 files changed, 129 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/Skeleton.jsx diff --git a/frontend/src/components/Dashboard/AnalyticsPanel.jsx b/frontend/src/components/Dashboard/AnalyticsPanel.jsx index 56b3cb9..4e31c9c 100644 --- a/frontend/src/components/Dashboard/AnalyticsPanel.jsx +++ b/frontend/src/components/Dashboard/AnalyticsPanel.jsx @@ -5,6 +5,7 @@ import { } from 'recharts'; import { getSessionAnalytics, getSessionClusters, getClusterComments } from '../../services/api'; import { ActivityLog } from './ActivityLog'; +import { Skeleton } from '../Skeleton'; const ANALYTICS_EVENTS = new Set([ 'comment_created', 'comment_classified', @@ -140,7 +141,20 @@ export function AnalyticsPanel({ sessionId, token, sessionEvents }) { } } - if (loading) return

Loading analytics...

; + if (loading) return ( +
+

Session Analytics

+
+ {[1, 2, 3, 4].map(i => ( +
+ + +
+ ))} +
+ +
+ ); if (error) return

{error}

; // Derive cumulative line chart data @@ -215,7 +229,16 @@ export function AnalyticsPanel({ sessionId, token, sessionEvents }) {
) : ( -

No time data yet.

+
+ + + + + + +

No data yet

+

Analytics will appear once questions start coming in

+
)} {data.top_clusters.length > 0 && ( diff --git a/frontend/src/components/Dashboard/ClusterDetailsModal.jsx b/frontend/src/components/Dashboard/ClusterDetailsModal.jsx index d11baa7..9103bcd 100644 --- a/frontend/src/components/Dashboard/ClusterDetailsModal.jsx +++ b/frontend/src/components/Dashboard/ClusterDetailsModal.jsx @@ -1,3 +1,5 @@ +import { Skeleton } from '../Skeleton'; + export function ClusterDetailsModal({ cluster, comments, onClose }) { const answers = cluster.answers ?? []; const latestAnswer = answers.length > 0 ? answers[answers.length - 1] : null; @@ -10,9 +12,24 @@ export function ClusterDetailsModal({ cluster, comments, onClose }) {
{comments === null ? ( -

Loading questions...

+
+ {[1, 2, 3, 4].map(i => ( +
+ + +
+ ))} +
) : comments.length === 0 ? ( -

No questions assigned yet.

+
+ + + + + +

No questions assigned

+

Questions will appear here once grouped into this cluster

+
) : ( comments.map(c => (
diff --git a/frontend/src/components/Dashboard/ClustersPanel.jsx b/frontend/src/components/Dashboard/ClustersPanel.jsx index 9d7b352..75dabe3 100644 --- a/frontend/src/components/Dashboard/ClustersPanel.jsx +++ b/frontend/src/components/Dashboard/ClustersPanel.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { getSessionClusters, approveAnswer, editAnswer, getClusterComments } from '../../services/api'; import { showToast } from '../../hooks/useToast'; import { ClusterDetailsModal } from './ClusterDetailsModal'; +import { Skeleton } from '../Skeleton'; const REFETCH_EVENTS = new Set(['cluster_created', 'cluster_updated', 'answer_ready', 'answer_posted']); @@ -162,14 +163,14 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) {isLoadingInitial ? (
- {[1, 2].map(i => ( + {[1, 2, 3].map(i => (
-
-
-
+
+ +
-
-
+ +
))}
@@ -179,10 +180,20 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef })
{filteredClusters.length === 0 ? (
- 🤖 -

{clusters.length === 0 ? 'No clusters yet' : 'No clusters match this filter'}

+ + + + + + + +

+ {clusters.length === 0 ? 'No clusters yet' : 'No clusters match this filter'} +

{clusters.length === 0 && ( -

Questions cluster automatically after 5 similar ones arrive

+

+ Clusters form automatically once enough questions arrive +

)}
) : ( diff --git a/frontend/src/components/Dashboard/DocumentUpload.jsx b/frontend/src/components/Dashboard/DocumentUpload.jsx index 5725ef2..cd144ae 100644 --- a/frontend/src/components/Dashboard/DocumentUpload.jsx +++ b/frontend/src/components/Dashboard/DocumentUpload.jsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { uploadDocument, getDocuments, deleteDocument } from '../../services/api'; import { showToast } from '../../hooks/useToast'; +import { Skeleton } from '../Skeleton'; const MAX_SIZE = 10 * 1024 * 1024; const ALLOWED = ['.pdf', '.docx', '.txt']; @@ -127,16 +128,20 @@ export function DocumentUpload({ sessionId, token }) {
{isLoadingInitial ? ( -
- {[1, 2].map(i => ( -
- ))} +
+ {[1, 2].map(i => )}
) : docs.length === 0 ? (
- 📄 -

No documents uploaded yet

-

Upload PDFs to give the AI extra context when answering

+ + + + + + + +

No documents uploaded

+

Upload PDFs to give the AI context when answering

) : ( <> diff --git a/frontend/src/components/Dashboard/MetricsCards.jsx b/frontend/src/components/Dashboard/MetricsCards.jsx index 4a2cdb2..f60e623 100644 --- a/frontend/src/components/Dashboard/MetricsCards.jsx +++ b/frontend/src/components/Dashboard/MetricsCards.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { getSessionStats } from '../../services/api'; +import { Skeleton } from '../Skeleton'; const REFETCH_EVENTS = new Set(['comment_created', 'cluster_created', 'answer_ready', 'answer_posted', 'comment_classified']); @@ -49,8 +50,8 @@ export function MetricsCards({ sessionId, token, wsMessages }) {
{[1, 2, 3, 4].map(i => (
-
-
+ +
))}
diff --git a/frontend/src/components/Dashboard/QuestionsFeed.jsx b/frontend/src/components/Dashboard/QuestionsFeed.jsx index 2bb6730..1fb9c15 100644 --- a/frontend/src/components/Dashboard/QuestionsFeed.jsx +++ b/frontend/src/components/Dashboard/QuestionsFeed.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { getSessionComments } from '../../services/api'; +import { Skeleton } from '../Skeleton'; const PAGE_SIZE = 20; @@ -108,11 +109,11 @@ export function QuestionsFeed({ sessionId, token, wsMessages }) { {isLoadingInitial ? (
- {[1, 2, 3].map(i => ( + {[1, 2, 3, 4, 5].map(i => (
-
-
-
+ + +
))}
@@ -123,10 +124,18 @@ export function QuestionsFeed({ sessionId, token, wsMessages }) {
{filteredComments.length === 0 ? (
- 📝 -

{debouncedQuery ? 'No matching questions' : 'No questions yet'}

+ + + + + +

+ {debouncedQuery ? 'No matching questions' : 'No questions yet'} +

{!debouncedQuery && ( -

Connect YouTube or submit manual questions above to get started

+

+ Questions from your live stream will appear here +

)}
) : ( diff --git a/frontend/src/components/Skeleton.jsx b/frontend/src/components/Skeleton.jsx new file mode 100644 index 0000000..faf2283 --- /dev/null +++ b/frontend/src/components/Skeleton.jsx @@ -0,0 +1,3 @@ +export function Skeleton({ className = '' }) { + return
; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 2c73b89..bca32e6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -639,6 +639,37 @@ button:disabled { opacity: 0.5; pointer-events: none; } .empty-state p { font-size: 13px; margin-bottom: 4px; } .empty-hint { font-size: 12px; } +/* Skeleton layout utilities */ +.skeleton-list { display: flex; flex-direction: column; gap: 8px; } +.skeleton-row { display: flex; gap: 8px; align-items: center; } + +/* Per-component skeleton sizes */ +.sk-metric-value { height: 32px; margin-bottom: 6px; } +.sk-metric-label { height: 12px; width: 55%; margin: 0 auto; } + +.sk-feed-author { width: 60px; height: 14px; flex-shrink: 0; } +.sk-feed-text { flex: 1; height: 14px; } +.sk-feed-badge { width: 70px; height: 18px; border-radius: 12px; } + +.sk-cluster-title { width: 60%; height: 16px; } +.sk-cluster-count { width: 60px; height: 14px; } +.sk-cluster-body { height: 60px; margin-bottom: 8px; } +.sk-cluster-btn { width: 80px; height: 28px; } + +.sk-comment-author { width: 64px; height: 14px; flex-shrink: 0; } +.sk-comment-text { flex: 1; height: 14px; } + +.sk-doc-row { height: 32px; } + +.sk-analytics-value { height: 28px; margin-bottom: 6px; } +.sk-analytics-label { height: 12px; width: 60%; margin: 0 auto; } +.sk-analytics-chart { height: 160px; margin-top: 16px; } + +/* Structured empty state */ +.empty-state-icon { display: block; color: var(--color-muted); margin-bottom: 8px; } +.empty-state-title { font-size: 14px; font-weight: 600; color: var(--color-text); margin: 0 0 4px; } +.empty-state-description { font-size: 12px; color: var(--color-muted); margin: 0; } + /* ============================================================ Skeleton shimmer ============================================================ */ From 3c4e7f17d7da51d1497bc6e07b0bccb2ce74206e Mon Sep 17 00:00:00 2001 From: CodeNinjaSarthak Date: Sat, 7 Mar 2026 22:56:26 +0530 Subject: [PATCH 04/41] feat: add QuotaBanner component and integrate quota alert handling in DashboardPage --- .../src/components/Dashboard/QuotaBanner.jsx | 49 ++++++ frontend/src/index.css | 149 +++++++++++++++++- frontend/src/pages/DashboardPage.jsx | 19 ++- 3 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/Dashboard/QuotaBanner.jsx diff --git a/frontend/src/components/Dashboard/QuotaBanner.jsx b/frontend/src/components/Dashboard/QuotaBanner.jsx new file mode 100644 index 0000000..d61ad09 --- /dev/null +++ b/frontend/src/components/Dashboard/QuotaBanner.jsx @@ -0,0 +1,49 @@ +const DEFAULT_MESSAGES = { + warning: 'YouTube API quota is running low. New comments may stop being processed soon.', + critical: 'YouTube API quota exhausted. Comment processing and posting are paused until quota resets.', +}; + +const WarningIcon = () => ( + +); + +const DismissIcon = () => ( + +); + +export function QuotaBanner({ level, message, onDismiss }) { + const text = message || DEFAULT_MESSAGES[level]; + return ( +
+ + {text} + +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index bca32e6..25e9c89 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -26,6 +26,8 @@ /* Error/danger inline */ --color-error-bg: #450a0a; --color-error-border: #991b1b; + --color-warning-bg: #422006; + --color-warning-border: #713f12; --color-btn-hover: #0f172a; --color-danger-sm-bg: #1e293b; --color-danger-sm-hover: #0f172a; @@ -45,6 +47,8 @@ --status-reconnecting-bg: #422006; --status-reconnecting-color: #fde68a; --status-connecting-bg: #1e293b; --status-connecting-color: #94a3b8; + --color-surface-hover: #273548; + --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,0.4); --header-height: 56px; @@ -65,6 +69,8 @@ --color-input-focus-bg: #ffffff; --color-error-bg: #fef2f2; --color-error-border: #fecaca; + --color-warning-bg: #fef3c7; + --color-warning-border: #fcd34d; --color-btn-hover: #f1f5f9; --color-danger-sm-bg: #ffffff; --color-danger-sm-hover: #fef2f2; @@ -82,6 +88,8 @@ --status-reconnecting-bg: #fef3c7; --status-reconnecting-color: #92400e; --status-connecting-bg: #f1f5f9; --status-connecting-color: #64748b; + --color-surface-hover: #f1f5f9; + --shadow: 0 1px 3px rgba(0,0,0,0.1); } @@ -191,11 +199,18 @@ body { /* ============================================================ Main Layout ============================================================ */ +.app-shell { + display: flex; + flex-direction: column; + height: 100vh; +} + .app-main { padding: 20px 24px; max-width: 1400px; margin: 0 auto; - height: calc(100vh - var(--header-height)); + flex: 1; + min-height: 0; overflow: hidden; } @@ -234,9 +249,13 @@ body { } .panel h2 { - font-size: 14px; + font-size: 13px; font-weight: 600; + letter-spacing: 0.02em; + color: var(--color-muted); margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--color-border); display: flex; align-items: center; gap: 8px; @@ -275,6 +294,15 @@ input:focus, textarea:focus { background: var(--color-input-focus-bg); } +button:focus-visible, +a:focus-visible, +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + textarea { resize: vertical; min-height: 80px; } .hint { font-size: 12px; color: var(--color-muted); margin-bottom: 6px; } @@ -395,7 +423,7 @@ button:disabled { opacity: 0.5; pointer-events: none; } scroll-behavior: smooth; display: flex; flex-direction: column; - gap: 6px; + gap: 8px; } .feed-item { @@ -407,6 +435,11 @@ button:disabled { opacity: 0.5; pointer-events: none; } border-radius: 6px; background: var(--color-surface-alt); font-size: 13px; + transition: background 0.15s; + cursor: default; +} +.feed-item:hover { + background: var(--color-surface-hover); } .feed-item-author { @@ -428,7 +461,7 @@ button:disabled { opacity: 0.5; pointer-events: none; } .clusters-list { display: flex; flex-direction: column; - gap: 12px; + gap: 8px; flex: 1; min-height: 0; overflow-y: auto; @@ -436,9 +469,14 @@ button:disabled { opacity: 0.5; pointer-events: none; } .cluster-card { border: 1px solid var(--color-border); + border-left: 3px solid color-mix(in srgb, var(--color-primary) 40%, transparent); border-radius: 6px; padding: 12px; background: var(--color-surface-alt); + transition: border-left-color 0.15s; +} +.cluster-card:hover { + border-left-color: var(--color-primary); } .cluster-header { @@ -482,7 +520,7 @@ button:disabled { opacity: 0.5; pointer-events: none; } .metrics-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 10px; + gap: 16px; } .metric-card { @@ -491,6 +529,10 @@ button:disabled { opacity: 0.5; pointer-events: none; } border-radius: 6px; padding: 10px 12px; text-align: center; + transition: background 0.15s; +} +.metric-card:hover { + background: var(--color-surface-hover); } .metric-value { font-size: 24px; font-weight: 700; color: var(--color-primary); } @@ -529,6 +571,7 @@ button:disabled { opacity: 0.5; pointer-events: none; } .app-main { height: auto; overflow: visible; + flex: none; } .panels-grid { grid-template-columns: 1fr; @@ -696,7 +739,11 @@ button:disabled { opacity: 0.5; pointer-events: none; } ============================================================ */ .connection-status { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; padding: 3px 8px; border-radius: 12px; font-weight: 500; margin-right: 10px; } .connection-status.connected { background: var(--status-connected-bg); color: var(--status-connected-color); } -.connection-status.reconnecting { background: var(--status-reconnecting-bg); color: var(--status-reconnecting-color); } +.connection-status.reconnecting { background: var(--status-reconnecting-bg); color: var(--status-reconnecting-color); animation: status-pulse 1.5s ease-in-out infinite; } +@keyframes status-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.65; } +} .connection-status.connecting { background: var(--status-connecting-bg); color: var(--status-connecting-color); } /* ============================================================ @@ -798,7 +845,14 @@ button:disabled { opacity: 0.5; pointer-events: none; } color: var(--color-muted); cursor: pointer; font-size: 13px; font-weight: 500; transition: background 0.15s, color 0.15s; } -.tab-btn.active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); } +.tab-btn.active { + background: transparent; + color: var(--color-primary); + border-color: var(--color-border); + border-bottom: 2px solid var(--color-primary); + border-radius: var(--radius) var(--radius) 0 0; + padding-bottom: 5px; +} .tab-btn:hover:not(.active) { background: var(--color-btn-hover); color: var(--color-text); } /* ============================================================ @@ -871,3 +925,84 @@ button:disabled { opacity: 0.5; pointer-events: none; } white-space: pre-wrap; color: var(--color-muted); } + +/* ============================================================ + Quota Banner + ============================================================ */ +.quota-banner { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + padding: 10px 24px; + font-size: 13px; + font-weight: 500; + width: 100%; + box-sizing: border-box; +} + +.quota-banner-warning { + background: var(--color-warning-bg); + border-bottom: 1px solid var(--color-warning-border); + color: var(--color-text); +} + +.quota-banner-critical { + background: var(--color-error-bg); + border-bottom: 1px solid var(--color-error-border); + color: var(--color-text); +} + +.quota-banner-icon { + flex-shrink: 0; + width: 18px; + height: 18px; +} + +.quota-banner-text { + flex: 1; +} + +.quota-banner-dismiss { + margin-left: auto; + background: none; + border: none; + cursor: pointer; + color: inherit; + font-size: 18px; + line-height: 1; + padding: 0 4px; +} + +/* ============================================================ + Custom Scrollbars + ============================================================ */ +.left-column::-webkit-scrollbar, +.right-column::-webkit-scrollbar, +.questions-feed::-webkit-scrollbar, +.clusters-list::-webkit-scrollbar, +.analytics-scroll-wrapper::-webkit-scrollbar { + width: 4px; +} +.left-column::-webkit-scrollbar-track, +.right-column::-webkit-scrollbar-track, +.questions-feed::-webkit-scrollbar-track, +.clusters-list::-webkit-scrollbar-track, +.analytics-scroll-wrapper::-webkit-scrollbar-track { + background: transparent; +} +.left-column::-webkit-scrollbar-thumb, +.right-column::-webkit-scrollbar-thumb, +.questions-feed::-webkit-scrollbar-thumb, +.clusters-list::-webkit-scrollbar-thumb, +.analytics-scroll-wrapper::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 4px; +} +.left-column::-webkit-scrollbar-thumb:hover, +.right-column::-webkit-scrollbar-thumb:hover, +.questions-feed::-webkit-scrollbar-thumb:hover, +.clusters-list::-webkit-scrollbar-thumb:hover, +.analytics-scroll-wrapper::-webkit-scrollbar-thumb:hover { + background: var(--color-muted); +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index cad6e45..c9b10ef 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -11,6 +11,7 @@ import { QuestionsFeed } from '../components/Dashboard/QuestionsFeed'; import { ClustersPanel } from '../components/Dashboard/ClustersPanel'; import { DocumentUpload } from '../components/Dashboard/DocumentUpload'; import { AnalyticsPanel } from '../components/Dashboard/AnalyticsPanel'; +import { QuotaBanner } from '../components/Dashboard/QuotaBanner'; export function DashboardPage() { const { token } = useAuth(); @@ -37,16 +38,30 @@ export function DashboardPage() { // Session-scoped event accumulator — survives WS reconnects, resets on session change const [sessionEvents, setSessionEvents] = useState([]); + const [quotaAlert, setQuotaAlert] = useState(null); useEffect(() => { setSessionEvents([]); }, [activeSession?.id]); + useEffect(() => { setQuotaAlert(null); }, [activeSession?.id]); useEffect(() => { if (!wsMessages || wsMessages.length === 0) return; const last = wsMessages[wsMessages.length - 1]; - if (last) setSessionEvents(prev => [...prev.slice(-199), last]); + if (!last) return; + setSessionEvents(prev => [...prev.slice(-199), last]); + if (last.type === 'quota_alert') { + setQuotaAlert(prev => (prev === 'critical' ? 'critical' : 'warning')); + } else if (last.type === 'quota_exceeded') { + setQuotaAlert('critical'); + } }, [wsMessages]); return ( -
+
+ {quotaAlert && ( + setQuotaAlert(null)} + /> + )}
From 5d53da4a2f5c6820274f984704894c1b70545b5c Mon Sep 17 00:00:00 2001 From: CodeNinjaSarthak Date: Mon, 9 Mar 2026 21:13:32 +0530 Subject: [PATCH 05/41] =?UTF-8?q?feat:=20infrastructure=20hardening=20?= =?UTF-8?q?=E2=80=94=20WS=20subscriber=20refactor,=20DB=20pooling,=20HNSW?= =?UTF-8?q?=20indexes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor Redis pub/sub relay into WebSocketManager.start_subscriber() with exponential backoff (1→30s) and auto-reconnect on failure - Fix Redis channel prefix: ws:session:{id} → ws:{id} (manager + posting worker) - Add publish() helper on WebSocketManager for future use - Tune DB connection pool per worker: pool_size=2, max_overflow=3 (budget: 45 total) - Add Alembic migration c3d4e5f6a7b8: HNSW indexes on comments and rag_documents embeddings - Add scripts/truncate_embeddings.sql utility to clear embeddings before re-indexing - Set postgres max_connections=100 in docker-compose - Switch Redis maxmemory-policy from allkeys-lru to volatile-lru (preserve non-expiring keys) - Run uvicorn with --workers 2 in api.Dockerfile - Remove legacy vanilla-JS frontend files (replaced by React/Vite) --- .../versions/c3d4e5f6a7b8_add_hnsw_indexes.py | 35 + backend/app/main.py | 24 +- backend/app/services/websocket/manager.py | 56 +- docker-compose.yml | 1 + frontend/css/styles.css | 428 ------------ frontend/js/api.js | 155 ----- frontend/js/app.js | 616 ------------------ frontend/js/websocket.js | 83 --- infra/docker/api.Dockerfile | 2 +- infra/docker/redis.conf | 2 +- scripts/truncate_embeddings.sql | 14 + workers/common/db.py | 9 +- workers/youtube_posting/worker.py | 2 +- 13 files changed, 115 insertions(+), 1312 deletions(-) create mode 100644 backend/alembic/versions/c3d4e5f6a7b8_add_hnsw_indexes.py delete mode 100644 frontend/css/styles.css delete mode 100644 frontend/js/api.js delete mode 100644 frontend/js/app.js delete mode 100644 frontend/js/websocket.js create mode 100644 scripts/truncate_embeddings.sql diff --git a/backend/alembic/versions/c3d4e5f6a7b8_add_hnsw_indexes.py b/backend/alembic/versions/c3d4e5f6a7b8_add_hnsw_indexes.py new file mode 100644 index 0000000..3d93848 --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_add_hnsw_indexes.py @@ -0,0 +1,35 @@ +"""add_hnsw_indexes + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-03-09 00:00:00.000000 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'c3d4e5f6a7b8' +down_revision = 'b2c3d4e5f6a7' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(""" + CREATE INDEX IF NOT EXISTS idx_comments_embedding_hnsw + ON comments + USING hnsw (embedding vector_l2_ops) + WITH (m = 16, ef_construction = 64) + """) + op.execute(""" + CREATE INDEX IF NOT EXISTS idx_rag_documents_embedding_hnsw + ON rag_documents + USING hnsw (embedding vector_l2_ops) + WITH (m = 16, ef_construction = 64) + """) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_rag_documents_embedding_hnsw") + op.execute("DROP INDEX IF EXISTS idx_comments_embedding_hnsw") diff --git a/backend/app/main.py b/backend/app/main.py index e99f66a..454acf4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,11 +7,9 @@ """FastAPI application main entry point.""" import asyncio -import json import logging import os -import redis.asyncio as aioredis from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -75,26 +73,6 @@ logger.info(f"Serving frontend from {settings.frontend_dir} at /app") -async def _relay_redis_events() -> None: - """Subscribe to worker-published Redis pub/sub events and relay via WebSocket.""" - try: - r = aioredis.from_url(settings.redis_url, decode_responses=True) - pubsub = r.pubsub() - await pubsub.psubscribe("ws:session:*") - async for message in pubsub.listen(): - if message["type"] != "pmessage": - continue - channel = message["channel"] # "ws:session:{session_id}" - session_id = channel.split(":")[-1] - try: - event = json.loads(message["data"]) - await manager.broadcast_to_session(session_id, event) - except Exception as e: - logger.error(f"Failed to relay Redis event for session {session_id}: {e}") - except Exception as e: - logger.error(f"Redis relay error: {e}") - - @app.get("/") async def root() -> dict: """Root endpoint.""" @@ -135,7 +113,7 @@ async def startup_event(): f"Starting {settings.app_name} v{settings.app_version}", extra={"environment": settings.environment, "debug": settings.debug}, ) - _relay_task = asyncio.create_task(_relay_redis_events()) + _relay_task = asyncio.create_task(manager.start_subscriber()) @app.on_event("shutdown") diff --git a/backend/app/services/websocket/manager.py b/backend/app/services/websocket/manager.py index fa2a75c..278d085 100644 --- a/backend/app/services/websocket/manager.py +++ b/backend/app/services/websocket/manager.py @@ -1,11 +1,13 @@ """WebSocket connection manager.""" import asyncio +import json import logging +import uuid from datetime import datetime, timezone -from typing import Any, Dict, Optional, Set -from uuid import UUID +from typing import Any, Dict, Optional +import redis.asyncio as aioredis from fastapi import WebSocket, WebSocketDisconnect from app.core.config import settings @@ -37,6 +39,7 @@ def __init__(self): """Initialize WebSocket manager.""" self.active_connections: Dict[str, Dict[str, ConnectionInfo]] = {} self.heartbeat_task: Optional[asyncio.Task] = None + self._redis: Optional[aioredis.Redis] = None async def connect( self, @@ -57,7 +60,6 @@ async def connect( await websocket.accept() if connection_id is None: - import uuid connection_id = str(uuid.uuid4()) if session_id not in self.active_connections: @@ -98,6 +100,54 @@ def disconnect(self, session_id: str, connection_id: str) -> None: if not self.active_connections[session_id]: del self.active_connections[session_id] + async def publish(self, session_id: str, message: Dict[str, Any]) -> None: + """Publish a message to Redis channel ws:{session_id}. + + Any subscriber process that owns a connection for this session + will pick it up and deliver it locally. + """ + try: + if self._redis is None: + self._redis = aioredis.from_url(settings.redis_url, decode_responses=True) + await self._redis.publish(f"ws:{session_id}", json.dumps(message)) + except Exception as e: + logger.error(f"Redis publish failed, resetting connection: {e}") + self._redis = None + raise + + async def start_subscriber(self) -> None: + """Subscribe to ws:* and deliver messages to locally held connections. + + Reconnects with exponential backoff on Redis failure. + """ + backoff = 1 + while True: + r = None + try: + r = aioredis.from_url(settings.redis_url, decode_responses=True) + pubsub = r.pubsub() + await pubsub.psubscribe("ws:*") + backoff = 1 + async for message in pubsub.listen(): + if message["type"] != "pmessage": + continue + channel: str = message["channel"] # "ws:{session_id}" + session_id = channel[3:] # strip "ws:" prefix + try: + event = json.loads(message["data"]) + await self.broadcast_to_session(session_id, event) + except Exception as e: + logger.error( + f"Failed to deliver WS event for session {session_id}: {e}" + ) + except Exception as e: + logger.error(f"WS subscriber error, reconnecting in {backoff}s: {e}") + await asyncio.sleep(backoff) + backoff = min(backoff * 2, 30) + finally: + if r: + await r.aclose() + async def send_personal_message( self, session_id: str, diff --git a/docker-compose.yml b/docker-compose.yml index 0e672fd..f14e2b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data + command: postgres -c max_connections=100 redis: image: redis:7-alpine diff --git a/frontend/css/styles.css b/frontend/css/styles.css deleted file mode 100644 index c0c0129..0000000 --- a/frontend/css/styles.css +++ /dev/null @@ -1,428 +0,0 @@ -/* ============================================================ - Base Reset & Variables - ============================================================ */ -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -[hidden] { display: none !important; } - -:root { - --color-primary: #2563eb; - --color-primary-hover: #1d4ed8; - --color-danger: #dc2626; - --color-danger-hover: #b91c1c; - --color-success: #16a34a; - --color-bg: #f8fafc; - --color-surface: #ffffff; - --color-border: #e2e8f0; - --color-text: #1e293b; - --color-muted: #64748b; - --radius: 8px; - --shadow: 0 1px 3px rgba(0,0,0,0.1); -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: var(--color-bg); - color: var(--color-text); - font-size: 14px; - line-height: 1.5; -} - -/* ============================================================ - Auth View - ============================================================ */ -#auth-view { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; -} - -.auth-card { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 32px; - width: 100%; - max-width: 400px; -} - -.auth-card h1 { - font-size: 20px; - font-weight: 700; - color: var(--color-primary); - margin-bottom: 24px; - text-align: center; -} - -.auth-card h2 { - font-size: 16px; - font-weight: 600; - margin-bottom: 16px; -} - -.auth-card form { - display: flex; - flex-direction: column; - gap: 12px; -} - -.auth-card label { - display: flex; - flex-direction: column; - gap: 4px; - font-weight: 500; -} - -.error-msg { - background: #fef2f2; - border: 1px solid #fecaca; - color: var(--color-danger); - border-radius: 4px; - padding: 8px 12px; - font-size: 13px; - margin-bottom: 8px; -} - -/* ============================================================ - Header - ============================================================ */ -.app-header { - background: var(--color-surface); - border-bottom: 1px solid var(--color-border); - padding: 0 24px; - height: 56px; - display: flex; - align-items: center; - justify-content: space-between; - position: sticky; - top: 0; - z-index: 10; - box-shadow: var(--shadow); -} - -.logo { font-weight: 700; font-size: 16px; color: var(--color-primary); } -.user-name { color: var(--color-muted); margin-right: 12px; } -.header-right { display: flex; align-items: center; } - -/* ============================================================ - Main Layout - ============================================================ */ -.app-main { padding: 20px 24px; max-width: 1400px; margin: 0 auto; } - -.panels-grid { - display: grid; - grid-template-columns: 360px 1fr; - gap: 20px; -} - -.left-column, .right-column { - display: flex; - flex-direction: column; - gap: 16px; -} - -/* ============================================================ - Panel - ============================================================ */ -.panel { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius); - padding: 16px; - box-shadow: var(--shadow); -} - -.panel h2 { - font-size: 14px; - font-weight: 600; - margin-bottom: 12px; - display: flex; - align-items: center; - gap: 8px; -} - -/* ============================================================ - Forms - ============================================================ */ -label { - display: flex; - flex-direction: column; - gap: 4px; - font-weight: 500; - font-size: 13px; - margin-bottom: 8px; -} - -input[type="text"], -input[type="email"], -input[type="password"], -textarea { - width: 100%; - padding: 8px 10px; - border: 1px solid var(--color-border); - border-radius: 6px; - font-size: 13px; - font-family: inherit; - color: var(--color-text); - background: var(--color-bg); - transition: border-color 0.15s; -} - -input:focus, textarea:focus { - outline: none; - border-color: var(--color-primary); - background: #fff; -} - -textarea { resize: vertical; min-height: 80px; } - -.hint { font-size: 12px; color: var(--color-muted); margin-bottom: 6px; } - -/* ============================================================ - Buttons - ============================================================ */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - padding: 7px 14px; - border: 1px solid var(--color-border); - border-radius: 6px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - background: var(--color-surface); - color: var(--color-text); - transition: background 0.15s, border-color 0.15s; - white-space: nowrap; -} - -.btn:hover { background: #f1f5f9; } - -.btn-primary { - background: var(--color-primary); - color: #fff; - border-color: var(--color-primary); - width: 100%; - margin-top: 4px; -} - -.btn-primary:hover { background: var(--color-primary-hover); border-color: var(--color-primary-hover); } - -.btn-danger { - background: var(--color-danger); - color: #fff; - border-color: var(--color-danger); - width: 100%; - margin-top: 8px; -} - -.btn-danger:hover { background: var(--color-danger-hover); } - -.btn-danger-sm { - background: #fff; - color: var(--color-danger); - border-color: #fecaca; - font-size: 12px; - padding: 4px 10px; -} - -.btn-danger-sm:hover { background: #fef2f2; } - -.btn-sm { padding: 5px 10px; font-size: 12px; } - -.btn-loading { - opacity: 0.65; - pointer-events: none; -} - -button[disabled] { opacity: 0.5; pointer-events: none; } - -/* ============================================================ - Badges - ============================================================ */ -.badge { - display: inline-flex; - align-items: center; - padding: 2px 8px; - border-radius: 12px; - font-size: 11px; - font-weight: 600; - white-space: nowrap; -} - -.badge-question { background: #dbeafe; color: #1e40af; } -.badge-not-question { background: #f1f5f9; color: #64748b; } -.badge-classifying { background: #fef9c3; color: #92400e; } -.badge-posted { background: #dcfce7; color: #166534; } -.badge-active { background: #dcfce7; color: #166534; } -.badge-disconnected { background: #f1f5f9; color: #64748b; } -.badge-connected { background: #dbeafe; color: #1e40af; } -.badge-pending { background: #fef3c7; color: #92400e; } - -/* ============================================================ - Questions Feed - ============================================================ */ -.questions-feed { - height: 400px; - overflow-y: auto; - scroll-behavior: smooth; - display: flex; - flex-direction: column; - gap: 6px; -} - -.feed-item { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 8px 10px; - border: 1px solid var(--color-border); - border-radius: 6px; - background: var(--color-bg); - font-size: 13px; -} - -.feed-item-author { - font-weight: 600; - color: var(--color-primary); - white-space: nowrap; - flex-shrink: 0; - font-size: 12px; -} - -.feed-item-text { flex: 1; } -.feed-item-badge { flex-shrink: 0; } - -.empty-msg { color: var(--color-muted); font-size: 13px; text-align: center; padding: 24px 0; } - -/* ============================================================ - Clusters Panel - ============================================================ */ -#clusters-list { - display: flex; - flex-direction: column; - gap: 12px; - max-height: 600px; - overflow-y: auto; -} - -.cluster-card { - border: 1px solid var(--color-border); - border-radius: 6px; - padding: 12px; - background: var(--color-bg); -} - -.cluster-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -.cluster-title { font-weight: 600; font-size: 13px; } -.cluster-count { color: var(--color-muted); font-size: 12px; } - -.cluster-answer { - background: #fff; - border: 1px solid var(--color-border); - border-radius: 6px; - padding: 10px; - font-size: 13px; - margin-bottom: 8px; - white-space: pre-wrap; - max-height: 120px; - overflow-y: auto; -} - -.cluster-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -/* ============================================================ - YouTube Panel - ============================================================ */ -.yt-status-row { - margin-bottom: 10px; -} - -/* ============================================================ - Metrics Grid - ============================================================ */ -.metrics-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -.metric-card { - background: var(--color-bg); - border: 1px solid var(--color-border); - border-radius: 6px; - padding: 10px 12px; - text-align: center; -} - -.metric-value { font-size: 24px; font-weight: 700; color: var(--color-primary); } -.metric-label { font-size: 11px; color: var(--color-muted); margin-top: 2px; } - -/* ============================================================ - Session Info - ============================================================ */ -.session-info { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 6px; -} - -/* ============================================================ - Toast Notifications - ============================================================ */ -#toast-container { - position: fixed; - bottom: 24px; - right: 24px; - display: flex; - flex-direction: column; - gap: 8px; - z-index: 1000; -} - -.toast { - padding: 10px 16px; - border-radius: 8px; - font-size: 13px; - font-weight: 500; - box-shadow: 0 4px 12px rgba(0,0,0,0.15); - animation: slideIn 0.2s ease; - max-width: 320px; -} - -.toast-success { background: #166534; color: #fff; } -.toast-error { background: #991b1b; color: #fff; } -.toast-info { background: #1e40af; color: #fff; } -.toast-warning { background: #92400e; color: #fff; } - -@keyframes slideIn { - from { transform: translateX(100%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } -} - -/* ============================================================ - Responsive - ============================================================ */ -@media (max-width: 900px) { - .panels-grid { grid-template-columns: 1fr; } -} - -/* ============================================================ - Hidden utility - ============================================================ */ -.hidden { display: none !important; } diff --git a/frontend/js/api.js b/frontend/js/api.js deleted file mode 100644 index a185284..0000000 --- a/frontend/js/api.js +++ /dev/null @@ -1,155 +0,0 @@ -/** - * API client for AI Live Doubt Manager. - * Handles all fetch calls with auth, error handling, and 401 auto-logout. - */ - -class API { - constructor() { - this.token = localStorage.getItem('token'); - } - - async request(method, path, body = null) { - const opts = { - method, - headers: { - 'Content-Type': 'application/json', - ...(this.token ? { 'Authorization': `Bearer ${this.token}` } : {}), - }, - ...(body !== null ? { body: JSON.stringify(body) } : {}), - }; - - const resp = await fetch(path, opts); - - if (resp.status === 401) { - this.token = null; - localStorage.removeItem('token'); - // Emit custom event so app can react without causing reload loops - window.dispatchEvent(new CustomEvent('auth:expired')); - throw new Error('Unauthorized'); - } - - if (resp.status === 429) { - throw new Error('rate_limit'); - } - - if (!resp.ok) { - let message = `HTTP ${resp.status}`; - try { - const errBody = await resp.json(); - message = errBody.detail || errBody.message || message; - } catch { - // ignore parse error - } - throw new Error(message); - } - - if (resp.status === 204) return null; - return resp.json(); - } - - // ---------------------------------------------------------------- - // Auth - // ---------------------------------------------------------------- - - async login(email, password) { - const data = await this.request('POST', '/api/v1/auth/login', { - email, - password, - }); - this.token = data.access_token; - localStorage.setItem('token', this.token); - return data; - } - - async register(email, password, name) { - const data = await this.request('POST', '/api/v1/auth/register', { - email, password, name, - }); - return data; - } - - async logout() { - try { await this.request('POST', '/api/v1/auth/logout'); } catch {} - this.token = null; - localStorage.removeItem('token'); - window.location.reload(); - } - - async getMe() { - return this.request('GET', '/api/v1/auth/me'); - } - - // ---------------------------------------------------------------- - // Sessions - // ---------------------------------------------------------------- - - async getSessions() { - return this.request('GET', '/api/v1/sessions/'); - } - - async createSession(data) { - return this.request('POST', '/api/v1/sessions/', data); - } - - async endSession(id) { - return this.request('POST', `/api/v1/sessions/${id}/end`); - } - - async getSessionComments(id, limit = 100, offset = 0) { - return this.request('GET', `/api/v1/sessions/${id}/comments?limit=${limit}&offset=${offset}`); - } - - async getSessionClusters(id) { - return this.request('GET', `/api/v1/sessions/${id}/clusters`); - } - - async getSessionStats(id) { - return this.request('GET', `/api/v1/dashboard/sessions/${id}/stats`); - } - - // ---------------------------------------------------------------- - // YouTube - // ---------------------------------------------------------------- - - async getYouTubeAuthURL(returnUrl = '/app') { - return this.request('GET', `/api/v1/youtube/auth/url?return_url=${encodeURIComponent(returnUrl)}`); - } - - async getYouTubeStatus() { - return this.request('GET', '/api/v1/youtube/auth/status'); - } - - async disconnectYouTube() { - return this.request('DELETE', '/api/v1/youtube/auth/disconnect'); - } - - async validateVideo(videoId) { - return this.request('GET', `/api/v1/youtube/videos/${videoId}/validate`); - } - - // ---------------------------------------------------------------- - // Dashboard - // ---------------------------------------------------------------- - - async submitManualQuestion(sessionId, text) { - return this.request('POST', `/api/v1/dashboard/sessions/${sessionId}/manual-question`, { text }); - } - - async approveAnswer(answerId) { - return this.request('POST', `/api/v1/dashboard/answers/${answerId}/approve`); - } - - async editAnswer(answerId, text) { - return this.request('POST', `/api/v1/dashboard/answers/${answerId}/edit`, { text }); - } - - // ---------------------------------------------------------------- - // Metrics - // ---------------------------------------------------------------- - - async getMetrics() { - return this.request('GET', '/api/v1/metrics'); - } -} - -window.api = new API(); diff --git a/frontend/js/app.js b/frontend/js/app.js deleted file mode 100644 index a29fd7d..0000000 --- a/frontend/js/app.js +++ /dev/null @@ -1,616 +0,0 @@ -/** - * Main application logic for AI Live Doubt Manager dashboard. - */ - -const app = (() => { - // ---------------------------------------------------------------- - // State - // ---------------------------------------------------------------- - let currentUser = null; - let activeSession = null; - let feedCount = 0; - let statsRefreshTimer = null; - - // ---------------------------------------------------------------- - // Toast Notifications - // ---------------------------------------------------------------- - function showToast(message, type = 'info') { - const container = document.getElementById('toast-container'); - const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; - toast.textContent = message; - container.appendChild(toast); - setTimeout(() => toast.remove(), 4000); - } - - // ---------------------------------------------------------------- - // Loading state helpers - // ---------------------------------------------------------------- - function setLoading(btn, loading, loadingText = 'Loading...') { - if (!btn) return; - if (loading) { - btn._originalText = btn.textContent; - btn.textContent = loadingText; - btn.classList.add('btn-loading'); - btn.disabled = true; - } else { - btn.textContent = btn._originalText || btn.textContent; - btn.classList.remove('btn-loading'); - btn.disabled = false; - } - } - - // ---------------------------------------------------------------- - // View switching - // ---------------------------------------------------------------- - function showDashboard() { - document.getElementById('auth-view').hidden = true; - document.getElementById('dashboard-view').hidden = false; - } - - function showAuth() { - document.getElementById('auth-view').hidden = false; - document.getElementById('dashboard-view').hidden = true; - } - - function showLogin() { - document.getElementById('login-form').classList.remove('hidden'); - document.getElementById('register-form').classList.add('hidden'); - document.getElementById('login-error').classList.add('hidden'); - } - - function showRegister() { - document.getElementById('login-form').classList.add('hidden'); - document.getElementById('register-form').classList.remove('hidden'); - document.getElementById('register-error').classList.add('hidden'); - } - - // ---------------------------------------------------------------- - // Auth Handlers - // ---------------------------------------------------------------- - async function handleLogin(event) { - event.preventDefault(); - const email = document.getElementById('login-email').value; - const password = document.getElementById('login-password').value; - const btn = document.getElementById('login-btn'); - const errEl = document.getElementById('login-error'); - - errEl.classList.add('hidden'); - setLoading(btn, true, 'Signing in...'); - try { - await api.login(email, password); - await initDashboard(); - } catch (e) { - errEl.textContent = e.message || 'Login failed'; - errEl.classList.remove('hidden'); - } finally { - setLoading(btn, false); - } - } - - async function handleRegister(event) { - event.preventDefault(); - const name = document.getElementById('register-name').value; - const email = document.getElementById('register-email').value; - const password = document.getElementById('register-password').value; - const btn = document.getElementById('register-btn'); - const errEl = document.getElementById('register-error'); - - errEl.classList.add('hidden'); - setLoading(btn, true, 'Creating account...'); - try { - await api.register(email, password, name); - // Auto-login after registration - await api.login(email, password); - await initDashboard(); - } catch (e) { - errEl.textContent = e.message || 'Registration failed'; - errEl.classList.remove('hidden'); - } finally { - setLoading(btn, false); - } - } - - async function logout() { - await api.logout(); - } - - // ---------------------------------------------------------------- - // Dashboard Initialization - // ---------------------------------------------------------------- - async function initDashboard() { - try { - currentUser = await api.getMe(); - } catch (e) { - showAuth(); - return; - } - - document.getElementById('user-name').textContent = - currentUser.name || currentUser.email || ''; - - showDashboard(); - await loadYouTubeStatus(); - - // Load most recent active session if any - try { - const sessions = await api.getSessions(); - const active = sessions.find(s => s.is_active); - if (active) { - setActiveSession(active); - } - } catch (e) { - // ignore - } - } - - // ---------------------------------------------------------------- - // Session Management - // ---------------------------------------------------------------- - async function handleCreateSession(event) { - event.preventDefault(); - const title = document.getElementById('session-title').value.trim(); - const videoId = document.getElementById('session-video-id').value.trim(); - const btn = document.getElementById('create-session-btn'); - - setLoading(btn, true, 'Starting...'); - try { - const session = await api.createSession({ - title, - youtube_video_id: videoId || null, - }); - setActiveSession(session); - showToast('Session started!', 'success'); - } catch (e) { - showToast(e.message || 'Failed to create session', 'error'); - } finally { - setLoading(btn, false); - } - } - - function setActiveSession(session) { - activeSession = session; - - document.getElementById('no-session').hidden = true; - document.getElementById('active-session').hidden = false; - document.getElementById('active-session-title').textContent = session.title; - - const videoEl = document.getElementById('active-session-video'); - if (session.youtube_video_id) { - videoEl.textContent = `YouTube: ${session.youtube_video_id}`; - } else { - videoEl.textContent = 'Manual mode (no YouTube video)'; - } - - // Connect WebSocket - dashboardWS.disconnect(); - registerWebSocketHandlers(); - dashboardWS.connect(session.id); - - // Start stats refresh - refreshStats(); - if (statsRefreshTimer) clearInterval(statsRefreshTimer); - statsRefreshTimer = setInterval(refreshStats, 10000); - - // Load existing comments and clusters - loadComments(); - loadClusters(); - } - - async function endSession() { - if (!activeSession) return; - const btn = document.getElementById('end-session-btn'); - setLoading(btn, true, 'Ending...'); - try { - await api.endSession(activeSession.id); - activeSession = null; - dashboardWS.disconnect(); - if (statsRefreshTimer) clearInterval(statsRefreshTimer); - - document.getElementById('no-session').hidden = false; - document.getElementById('active-session').hidden = true; - document.getElementById('questions-feed').innerHTML = - '

Session ended.

'; - document.getElementById('clusters-list').innerHTML = - '

No clusters yet.

'; - feedCount = 0; - document.getElementById('feed-count').textContent = '0'; - showToast('Session ended', 'info'); - } catch (e) { - showToast(e.message || 'Failed to end session', 'error'); - } finally { - setLoading(btn, false); - } - } - - // ---------------------------------------------------------------- - // YouTube OAuth - // ---------------------------------------------------------------- - async function connectYouTube() { - const btn = document.getElementById('yt-connect-btn'); - setLoading(btn, true, 'Connecting...'); - try { - const data = await api.getYouTubeAuthURL('/app'); - const popup = window.open( - data.url, - 'youtube_oauth', - 'width=600,height=700,noopener' - ); - - // Listen for postMessage from OAuth result page - const onMessage = (event) => { - if (event.origin !== location.origin) return; - if (event.data && event.data.type === 'youtube_oauth_complete') { - window.removeEventListener('message', onMessage); - if (popup && !popup.closed) popup.close(); - loadYouTubeStatus(); - showToast('YouTube connected!', 'success'); - } - }; - window.addEventListener('message', onMessage); - - // Clean up if popup is closed without completing - const pollClosed = setInterval(() => { - if (popup && popup.closed) { - clearInterval(pollClosed); - window.removeEventListener('message', onMessage); - setLoading(btn, false); - loadYouTubeStatus(); - } - }, 500); - } catch (e) { - showToast(e.message || 'Failed to start YouTube OAuth', 'error'); - setLoading(btn, false); - } - } - - async function disconnectYouTube() { - const btn = document.getElementById('yt-disconnect-btn'); - setLoading(btn, true, 'Disconnecting...'); - try { - await api.disconnectYouTube(); - await loadYouTubeStatus(); - showToast('YouTube disconnected', 'info'); - } catch (e) { - showToast(e.message || 'Failed to disconnect', 'error'); - } finally { - setLoading(btn, false); - } - } - - async function loadYouTubeStatus() { - try { - const status = await api.getYouTubeStatus(); - const badge = document.getElementById('yt-status-badge'); - const connectRow = document.getElementById('yt-connect-row'); - const connectedRow = document.getElementById('yt-connected-row'); - const expiresEl = document.getElementById('yt-expires-at'); - - if (status.connected) { - badge.textContent = 'Connected'; - badge.className = 'badge badge-connected'; - connectRow.hidden = true; - connectedRow.hidden = false; - if (status.expires_at) { - expiresEl.textContent = `Token expires: ${new Date(status.expires_at).toLocaleString()}`; - } - } else { - badge.textContent = 'Disconnected'; - badge.className = 'badge badge-disconnected'; - connectRow.hidden = false; - connectedRow.hidden = true; - // Re-enable connect button in case it was loading - setLoading(document.getElementById('yt-connect-btn'), false); - } - } catch (e) { - // ignore — not critical - } - } - - // ---------------------------------------------------------------- - // Manual Questions - // ---------------------------------------------------------------- - async function submitManualQuestions() { - if (!activeSession) { - showToast('Start a session first', 'warning'); - return; - } - const textarea = document.getElementById('manual-textarea'); - const text = textarea.value.trim(); - if (!text) return; - - const btn = document.getElementById('manual-submit-btn'); - setLoading(btn, true, 'Submitting...'); - try { - const result = await api.submitManualQuestion(activeSession.id, text); - textarea.value = ''; - showToast(`${result.created} question(s) submitted`, 'success'); - } catch (e) { - if (e.message === 'rate_limit') { - showToast('Rate limit hit, try again in 60s', 'warning'); - } else { - showToast(e.message || 'Failed to submit questions', 'error'); - } - } finally { - setLoading(btn, false); - } - } - - // ---------------------------------------------------------------- - // Feed (Questions) - // ---------------------------------------------------------------- - async function loadComments() { - if (!activeSession) return; - try { - const comments = await api.getSessionComments(activeSession.id, 100, 0); - const feed = document.getElementById('questions-feed'); - feed.innerHTML = ''; - feedCount = 0; - comments.forEach(c => appendFeedItem(c)); - } catch (e) { - // ignore - } - } - - function appendFeedItem(comment) { - const feed = document.getElementById('questions-feed'); - - // Remove empty message if present - const empty = feed.querySelector('.empty-msg'); - if (empty) empty.remove(); - - const item = document.createElement('div'); - item.className = 'feed-item'; - item.dataset.commentId = comment.id; - - let badgeHtml = 'Classifying...'; - if (comment.is_question === true) { - badgeHtml = 'Question'; - } else if (comment.is_question === false) { - badgeHtml = 'Not a question'; - } - - item.innerHTML = ` - ${escHtml(comment.author_name || 'Unknown')} - ${escHtml(comment.text)} - ${badgeHtml} - `; - - feed.prepend(item); - feedCount++; - document.getElementById('feed-count').textContent = feedCount; - } - - function updateFeedItemBadge(commentId, isQuestion) { - const item = document.querySelector(`[data-comment-id="${commentId}"]`); - if (!item) return; - const badgeEl = item.querySelector('.feed-item-badge'); - if (!badgeEl) return; - if (isQuestion) { - badgeEl.innerHTML = 'Question'; - } else { - badgeEl.innerHTML = 'Not a question'; - } - } - - // ---------------------------------------------------------------- - // Clusters - // ---------------------------------------------------------------- - async function loadClusters() { - if (!activeSession) return; - try { - const clusters = await api.getSessionClusters(activeSession.id); - const list = document.getElementById('clusters-list'); - list.innerHTML = ''; - if (!clusters.length) { - list.innerHTML = '

No clusters yet.

'; - return; - } - clusters.forEach(cluster => upsertClusterCard(cluster)); - } catch (e) { - // ignore - } - } - - function upsertClusterCard(cluster) { - const list = document.getElementById('clusters-list'); - - // Remove empty message - const empty = list.querySelector('.empty-msg'); - if (empty) empty.remove(); - - let card = document.querySelector(`[data-cluster-id="${cluster.id}"]`); - if (!card) { - card = document.createElement('div'); - card.className = 'cluster-card'; - card.dataset.clusterId = cluster.id; - list.prepend(card); - } - - const answers = cluster.answers || []; - const latestAnswer = answers[answers.length - 1]; - - let answerHtml = '

Generating answer...

'; - let actionsHtml = ''; - - if (latestAnswer) { - const postedBadge = latestAnswer.is_posted - ? 'Posted' - : 'Pending'; - - answerHtml = ` -
${escHtml(latestAnswer.text)}
-
${postedBadge}
- `; - actionsHtml = ` - - `; - if (!latestAnswer.is_posted) { - actionsHtml += ` - - `; - } - } - - card.innerHTML = ` -
- ${escHtml(cluster.title || 'Untitled Cluster')} - ${cluster.comment_count || 0} questions -
- ${answerHtml} -
${actionsHtml}
- `; - } - - async function approveAnswer(answerId) { - const btn = document.getElementById(`approve-btn-${answerId}`); - setLoading(btn, true, 'Posting...'); - try { - await api.approveAnswer(answerId); - showToast('Answer approved for posting', 'success'); - loadClusters(); - } catch (e) { - if (e.message === 'rate_limit') { - showToast('Rate limit hit, try again in 60s', 'warning'); - } else { - showToast(e.message || 'Failed to approve answer', 'error'); - } - setLoading(btn, false); - } - } - - function copyAnswer(answerId) { - const el = document.getElementById(`answer-text-${answerId}`); - if (!el) return; - navigator.clipboard.writeText(el.textContent).then(() => { - showToast('Answer copied to clipboard', 'success'); - }).catch(() => { - showToast('Failed to copy', 'error'); - }); - } - - // ---------------------------------------------------------------- - // Stats - // ---------------------------------------------------------------- - async function refreshStats() { - if (!activeSession) return; - try { - const stats = await api.getSessionStats(activeSession.id); - document.getElementById('stat-total').textContent = stats.total_comments ?? '—'; - document.getElementById('stat-questions').textContent = stats.questions ?? '—'; - document.getElementById('stat-clusters').textContent = stats.clusters ?? '—'; - document.getElementById('stat-posted').textContent = stats.answers_posted ?? '—'; - } catch (e) { - // ignore - } - } - - // ---------------------------------------------------------------- - // WebSocket Event Handlers - // ---------------------------------------------------------------- - function registerWebSocketHandlers() { - dashboardWS.on('connected', () => { - console.log('WebSocket connected'); - }); - - dashboardWS.on('comment_created', (data) => { - appendFeedItem(data); - refreshStats(); - }); - - dashboardWS.on('comment_classified', (data) => { - updateFeedItemBadge(data.comment_id, data.is_question); - refreshStats(); - }); - - dashboardWS.on('cluster_created', (data) => { - upsertClusterCard(data); - refreshStats(); - }); - - dashboardWS.on('cluster_updated', (data) => { - upsertClusterCard(data); - refreshStats(); - }); - - dashboardWS.on('answer_ready', (data) => { - loadClusters(); - showToast('New answer generated — review in Clusters panel', 'info'); - }); - - dashboardWS.on('answer_posted', (data) => { - loadClusters(); - refreshStats(); - showToast('Answer posted to YouTube!', 'success'); - }); - - dashboardWS.on('error', (data) => { - showToast(data.msg || 'Connection error', 'error'); - }); - } - - // ---------------------------------------------------------------- - // Utility - // ---------------------------------------------------------------- - function escHtml(str) { - if (!str) return ''; - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - // ---------------------------------------------------------------- - // Bootstrap - // ---------------------------------------------------------------- - async function init() { - // Handle auth expiry globally - window.addEventListener('auth:expired', () => { - currentUser = null; - activeSession = null; - dashboardWS.disconnect(); - showAuth(); - showLogin(); - }); - - // Check for stored token - const token = localStorage.getItem('token'); - if (token) { - api.token = token; - try { - await initDashboard(); - } catch (e) { - // Token invalid — show login - localStorage.removeItem('token'); - api.token = null; - showAuth(); - showLogin(); - } - } else { - showAuth(); - showLogin(); - } - } - - // Start on DOMContentLoaded - document.addEventListener('DOMContentLoaded', init); - - // Public API - return { - handleLogin, - handleRegister, - logout, - showLogin, - showRegister, - handleCreateSession, - endSession, - connectYouTube, - disconnectYouTube, - submitManualQuestions, - approveAnswer, - copyAnswer, - }; -})(); diff --git a/frontend/js/websocket.js b/frontend/js/websocket.js deleted file mode 100644 index 12e12dc..0000000 --- a/frontend/js/websocket.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * WebSocket client with exponential backoff reconnection. - */ - -class DashboardWebSocket { - constructor() { - this.ws = null; - this.handlers = {}; - this.retryCount = 0; - this.maxRetries = 10; - this._sessionId = null; - } - - connect(sessionId) { - this._sessionId = sessionId; - const token = localStorage.getItem('token'); - const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; - const tokenParam = token ? `&token=${encodeURIComponent(token)}` : ''; - const url = `${protocol}://${location.host}/ws/${sessionId}?connection_id=${Date.now()}${tokenParam}`; - - this.ws = new WebSocket(url); - - this.ws.onopen = () => { - this.retryCount = 0; - this._emit('connected', {}); - }; - - this.ws.onmessage = (e) => { - try { - const msg = JSON.parse(e.data); - this._emit(msg.type, msg.data || msg); - } catch (err) { - console.error('WS parse error', err); - } - }; - - this.ws.onclose = (e) => { - // Auth/forbidden errors — do not retry - if (e.code === 4001 || e.code === 4003) { - this._emit('error', { msg: 'WebSocket auth error' }); - return; - } - if (this.retryCount >= this.maxRetries) { - this._emit('error', { msg: 'Connection lost after maximum retries' }); - return; - } - const delay = Math.min(1000 * Math.pow(2, this.retryCount), 30000); - this.retryCount++; - setTimeout(() => { - if (this._sessionId) this.connect(this._sessionId); - }, delay); - }; - - this.ws.onerror = () => { - this._emit('error', { msg: 'WebSocket error' }); - }; - } - - on(type, cb) { - this.handlers[type] = cb; - } - - _emit(type, data) { - if (this.handlers[type]) this.handlers[type](data); - } - - send(obj) { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(obj)); - } - } - - disconnect() { - this._sessionId = null; - if (this.ws) { - this.ws.onclose = null; // prevent reconnect - this.ws.close(); - this.ws = null; - } - } -} - -window.dashboardWS = new DashboardWebSocket(); diff --git a/infra/docker/api.Dockerfile b/infra/docker/api.Dockerfile index e88c791..148348c 100644 --- a/infra/docker/api.Dockerfile +++ b/infra/docker/api.Dockerfile @@ -9,5 +9,5 @@ COPY backend/ ./backend/ ENV PYTHONPATH=/app -CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/infra/docker/redis.conf b/infra/docker/redis.conf index 6107cc3..ebde656 100644 --- a/infra/docker/redis.conf +++ b/infra/docker/redis.conf @@ -1,7 +1,7 @@ # Redis configuration for AI Live Doubt Manager maxmemory 256mb -maxmemory-policy allkeys-lru +maxmemory-policy volatile-lru save 900 1 save 300 10 save 60 10000 diff --git a/scripts/truncate_embeddings.sql b/scripts/truncate_embeddings.sql new file mode 100644 index 0000000..02bbe19 --- /dev/null +++ b/scripts/truncate_embeddings.sql @@ -0,0 +1,14 @@ +-- Run with: psql $DATABASE_URL -f scripts/truncate_embeddings.sql + +BEGIN; + +UPDATE comments SET embedding = NULL; +-- comments.embedding cleared + +UPDATE clusters SET centroid_embedding = NULL; +-- clusters.centroid_embedding cleared + +UPDATE rag_documents SET embedding = NULL; +-- rag_documents.embedding cleared + +COMMIT; diff --git a/workers/common/db.py b/workers/common/db.py index ec987d4..e99c679 100644 --- a/workers/common/db.py +++ b/workers/common/db.py @@ -5,7 +5,14 @@ from sqlalchemy.orm import sessionmaker _database_url = os.getenv("DATABASE_URL", "postgresql://user:pass@localhost/dbname") -_engine = create_engine(_database_url, pool_pre_ping=True) +# Connection budget: 15 (API) + 30 (6 workers × 5) = 45 total. +# PostgreSQL max_connections should be set to >= 60 (adds headroom). +_engine = create_engine( + _database_url, + pool_pre_ping=True, + pool_size=2, + max_overflow=3, +) _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine) diff --git a/workers/youtube_posting/worker.py b/workers/youtube_posting/worker.py index 76f1df7..90d4524 100644 --- a/workers/youtube_posting/worker.py +++ b/workers/youtube_posting/worker.py @@ -140,7 +140,7 @@ def main() -> None: str(answer.id), str(answer.cluster_id) ) redis_client.publish( - f"ws:session:{session_id}", json.dumps(event) + f"ws:{session_id}", json.dumps(event) ) logger.info( f"Posted answer {answer_id} to YouTube chat (msg_id={msg_id})" From 0a5db93434308fe0b76a4f2c7f2fb13bcc1e767c Mon Sep 17 00:00:00 2001 From: CodeNinjaSarthak Date: Wed, 11 Mar 2026 13:56:45 +0530 Subject: [PATCH 06/41] frontend updation --- frontend/index.html | 3 + .../components/Dashboard/ClustersPanel.jsx | 162 +- .../components/Dashboard/DocumentUpload.jsx | 111 +- .../src/components/Dashboard/ManualInput.jsx | 19 +- .../src/components/Dashboard/SessionList.jsx | 76 +- .../src/components/Dashboard/YouTubePanel.jsx | 15 +- frontend/src/components/Layout/Header.jsx | 27 +- frontend/src/index.css | 1424 ++++++++++------- frontend/src/pages/DashboardPage.jsx | 110 +- 9 files changed, 1109 insertions(+), 838 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 1c366fa..81ee976 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,9 @@ AI Live Doubt Manager + + +
diff --git a/frontend/src/components/Dashboard/ClustersPanel.jsx b/frontend/src/components/Dashboard/ClustersPanel.jsx index 75dabe3..41e3cdb 100644 --- a/frontend/src/components/Dashboard/ClustersPanel.jsx +++ b/frontend/src/components/Dashboard/ClustersPanel.jsx @@ -17,6 +17,7 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) const [clusterFilter, setClusterFilter] = useState('all'); const [selectedCluster, setSelectedCluster] = useState(null); const [modalComments, setModalComments] = useState(null); + const [expandedIds, setExpandedIds] = useState(new Set()); const commentCache = useRef(new Map()); async function fetchClusters() { @@ -55,10 +56,10 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) handleApprove(latest.id); } }; - return () => { approveFirstRef.current = null; }; // cleanup on unmount — prevents stale calls + return () => { approveFirstRef.current = null; }; }, [clusters, approveFirstRef]); - // WS-triggered refetch — targeted cache invalidation + // WS-triggered refetch useEffect(() => { if (!wsMessages || wsMessages.length === 0) return; const last = wsMessages[wsMessages.length - 1]; @@ -73,6 +74,15 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) } }, [wsMessages]); + function toggleExpand(id) { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + async function openClusterModal(cluster) { setSelectedCluster(cluster); if (commentCache.current.has(cluster.id)) { @@ -147,7 +157,10 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) return (
-

Clusters & Answers

+

+ Clusters & Answers + {clusters.length} +

{['all', 'pending', 'approved'].map(tab => ( @@ -169,8 +182,6 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef })
- -
))}
@@ -201,87 +212,98 @@ export function ClustersPanel({ sessionId, token, wsMessages, approveFirstRef }) const answers = cluster.answers || []; const latestAnswer = answers[answers.length - 1]; const isEditing = latestAnswer && editingAnswerId === latestAnswer.id; + const isExpanded = expandedIds.has(cluster.id); + const isApproved = latestAnswer?.is_posted === true; + return ( -
-
- openClusterModal(cluster)} - > +
+
toggleExpand(cluster.id)}> + {cluster.title || 'Untitled Cluster'} + {cluster.comment_count || 0}q + {isApproved && ( + ✓ POSTED + )} openClusterModal(cluster)} + className="cluster-expand-icon" + onClick={e => { e.stopPropagation(); openClusterModal(cluster); }} + title="View details" > - {cluster.comment_count || 0} questions + ▼
- {latestAnswer ? ( - <> - {isEditing ? ( -