Kumari Vaishnavi commited on
Commit
4ea829d
Β·
1 Parent(s): 2b2b242

fix(ui): mobile responsiveness for Chat Dashboard #130

Browse files

- Hide sidebar and PDF viewer on mobile using hidden md:block
- Add hamburger menu button (md:hidden) in Header
- Add slide-in mobile navigation sheet with backdrop
- Pass sidebar content via mobileSheetContent prop (no duplication)

frontend/src/app/dashboard/page.tsx CHANGED
@@ -4,17 +4,41 @@ import { useEffect, useState, useCallback } from "react";
4
  import dynamic from "next/dynamic";
5
  import { useRouter } from "next/navigation";
6
  import { useAuth } from "@/lib/auth";
7
- import {
8
- api,
9
- CONNECTION_ERROR_BANNER_MESSAGE,
10
- CONNECTION_ERROR_MESSAGE,
11
- } from "@/lib/api";
12
-
13
  import Header from "@/components/layout/Header";
14
  import DocumentSidebar from "@/components/document/DocumentSidebar";
15
  import ChatPanel from "@/components/chat/ChatPanel";
16
- import PDFViewer from "@/components/document/PDFViewer";
17
- import { Skeleton } from "@/components/ui/skeleton";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
  export interface DocInfo {
20
  summary: string;
@@ -28,23 +52,6 @@ export interface DocInfo {
28
  uploaded_at: string;
29
  }
30
 
31
- function DocumentSkeleton() {
32
- return (
33
- <div className="w-72 flex-shrink-0 border-r border-border/50 p-4 space-y-4">
34
- {[1, 2, 3, 4].map((item) => (
35
- <div
36
- key={item}
37
- className="rounded-lg border border-border/50 p-4 space-y-3"
38
- >
39
- <Skeleton className="h-4 w-[180px]" />
40
- <Skeleton className="h-3 w-[120px]" />
41
- <Skeleton className="h-3 w-[90px]" />
42
- </div>
43
- ))}
44
- </div>
45
- );
46
- }
47
-
48
  export default function DashboardPage() {
49
  const { user, loading } = useAuth();
50
  const router = useRouter();
@@ -55,7 +62,6 @@ export default function DashboardPage() {
55
  const [sidebarOpen, setSidebarOpen] = useState(true);
56
  const [viewerOpen, setViewerOpen] = useState(true);
57
  const [connectionError, setConnectionError] = useState("");
58
- const [documentsLoading, setDocumentsLoading] = useState(true);
59
 
60
  // Auth guard
61
  useEffect(() => {
@@ -65,31 +71,23 @@ export default function DashboardPage() {
65
  // Load documents
66
  const loadDocuments = useCallback(async () => {
67
  try {
68
- setDocumentsLoading(true);
69
-
70
  const data = await api.get<{ documents?: DocInfo[]; items?: DocInfo[] }>(
71
  "/api/v1/documents/"
72
  );
73
-
74
  setDocuments(data?.documents ?? data?.items ?? []);
75
  setConnectionError("");
76
  } catch (err) {
77
- const message =
78
- err instanceof Error ? err.message : CONNECTION_ERROR_MESSAGE;
79
-
80
  setConnectionError(
81
  message === CONNECTION_ERROR_MESSAGE
82
  ? CONNECTION_ERROR_BANNER_MESSAGE
83
  : `⚠️ ${message}`
84
  );
85
- } finally {
86
- setDocumentsLoading(false);
87
  }
88
  }, []);
89
 
90
  useEffect(() => {
91
  if (!user) return;
92
-
93
  void (async () => {
94
  await loadDocuments();
95
  })();
@@ -100,11 +98,9 @@ export default function DashboardPage() {
100
  const hasPending = (documents || []).some(
101
  (d) => d.status === "pending" || d.status === "processing"
102
  );
103
-
104
  if (!hasPending) return;
105
 
106
  const interval = setInterval(loadDocuments, 3000);
107
-
108
  return () => clearInterval(interval);
109
  }, [documents, loadDocuments]);
110
 
@@ -116,6 +112,19 @@ export default function DashboardPage() {
116
  );
117
  }
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  return (
120
  <div className="h-screen flex flex-col overflow-hidden">
121
  <Header
@@ -123,6 +132,7 @@ export default function DashboardPage() {
123
  onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
124
  viewerOpen={viewerOpen}
125
  onToggleViewer={() => setViewerOpen(!viewerOpen)}
 
126
  />
127
 
128
  {connectionError && (
@@ -135,49 +145,35 @@ export default function DashboardPage() {
135
  )}
136
 
137
  <div className="flex-1 flex overflow-hidden">
138
- {/* ── Left: Document Sidebar / Skeleton ──────────────── */}
139
- {sidebarOpen &&
140
- (documentsLoading ? (
141
- <DocumentSkeleton />
142
- ) : (
143
- <div className="w-72 flex-shrink-0 border-r border-border/50 overflow-hidden animate-fade-in-up">
144
- <DocumentSidebar
145
- documents={documents}
146
- activeDoc={activeDoc}
147
- onSelectDoc={(doc) => {
148
- setActiveDoc(doc);
149
- setPdfPage(1);
150
- }}
151
- onDocumentsChange={loadDocuments}
152
- />
153
- </div>
154
- ))}
155
-
156
- {/* ── Center: Chat Panel ─────────────────── */}
157
  <div className="flex-1 min-w-0 flex flex-col">
158
  <ChatPanel
159
  activeDoc={activeDoc}
160
  onCitationClick={(page) => {
161
  setPdfPage(page);
162
-
163
  if (!viewerOpen) setViewerOpen(true);
164
  }}
165
  />
166
  </div>
167
 
168
- {/* ── Right: PDF Viewer ──────────────────── */}
169
- {viewerOpen &&
170
- activeDoc &&
171
- activeDoc.original_name.endsWith(".pdf") && (
172
- <div className="w-[480px] flex-shrink-0 border-l border-border/50 overflow-hidden animate-fade-in-up">
173
- <PDFViewer
174
- documentId={activeDoc.id}
175
- currentPage={pdfPage}
176
- onPageChange={setPdfPage}
177
- totalPages={activeDoc.page_count}
178
- />
179
- </div>
180
- )}
181
  </div>
182
  </div>
183
  );
 
4
  import dynamic from "next/dynamic";
5
  import { useRouter } from "next/navigation";
6
  import { useAuth } from "@/lib/auth";
7
+ import { api, CONNECTION_ERROR_BANNER_MESSAGE, CONNECTION_ERROR_MESSAGE } from "@/lib/api";
 
 
 
 
 
8
  import Header from "@/components/layout/Header";
9
  import DocumentSidebar from "@/components/document/DocumentSidebar";
10
  import ChatPanel from "@/components/chat/ChatPanel";
11
+
12
+ function PDFViewerSkeleton() {
13
+ return (
14
+ <div
15
+ className="h-full flex flex-col bg-background"
16
+ aria-busy="true"
17
+ aria-label="Loading PDF viewer"
18
+ >
19
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border/50 bg-card/50 shrink-0">
20
+ <div className="flex items-center gap-2">
21
+ <div className="h-7 w-7 rounded-md bg-muted/70 animate-pulse" />
22
+ <div className="h-7 w-20 rounded-md bg-muted/70 animate-pulse" />
23
+ <div className="h-7 w-7 rounded-md bg-muted/70 animate-pulse" />
24
+ </div>
25
+ <div className="flex items-center gap-2">
26
+ <div className="h-7 w-7 rounded-md bg-muted/70 animate-pulse" />
27
+ <div className="h-4 w-10 rounded bg-muted/70 animate-pulse" />
28
+ <div className="h-7 w-7 rounded-md bg-muted/70 animate-pulse" />
29
+ </div>
30
+ </div>
31
+ <div className="flex-1 p-4">
32
+ <div className="h-full rounded-lg border border-border/50 bg-muted/40 animate-pulse" />
33
+ </div>
34
+ </div>
35
+ );
36
+ }
37
+
38
+ const PDFViewer = dynamic(() => import("@/components/document/PDFViewer"), {
39
+ ssr: false,
40
+ loading: () => <PDFViewerSkeleton />,
41
+ });
42
 
43
  export interface DocInfo {
44
  summary: string;
 
52
  uploaded_at: string;
53
  }
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  export default function DashboardPage() {
56
  const { user, loading } = useAuth();
57
  const router = useRouter();
 
62
  const [sidebarOpen, setSidebarOpen] = useState(true);
63
  const [viewerOpen, setViewerOpen] = useState(true);
64
  const [connectionError, setConnectionError] = useState("");
 
65
 
66
  // Auth guard
67
  useEffect(() => {
 
71
  // Load documents
72
  const loadDocuments = useCallback(async () => {
73
  try {
 
 
74
  const data = await api.get<{ documents?: DocInfo[]; items?: DocInfo[] }>(
75
  "/api/v1/documents/"
76
  );
 
77
  setDocuments(data?.documents ?? data?.items ?? []);
78
  setConnectionError("");
79
  } catch (err) {
80
+ const message = err instanceof Error ? err.message : CONNECTION_ERROR_MESSAGE;
 
 
81
  setConnectionError(
82
  message === CONNECTION_ERROR_MESSAGE
83
  ? CONNECTION_ERROR_BANNER_MESSAGE
84
  : `⚠️ ${message}`
85
  );
 
 
86
  }
87
  }, []);
88
 
89
  useEffect(() => {
90
  if (!user) return;
 
91
  void (async () => {
92
  await loadDocuments();
93
  })();
 
98
  const hasPending = (documents || []).some(
99
  (d) => d.status === "pending" || d.status === "processing"
100
  );
 
101
  if (!hasPending) return;
102
 
103
  const interval = setInterval(loadDocuments, 3000);
 
104
  return () => clearInterval(interval);
105
  }, [documents, loadDocuments]);
106
 
 
112
  );
113
  }
114
 
115
+ // Shared sidebar content β€” used by both desktop panel and mobile sheet
116
+ const sidebarContent = (
117
+ <DocumentSidebar
118
+ documents={documents}
119
+ activeDoc={activeDoc}
120
+ onSelectDoc={(doc) => {
121
+ setActiveDoc(doc);
122
+ setPdfPage(1);
123
+ }}
124
+ onDocumentsChange={loadDocuments}
125
+ />
126
+ );
127
+
128
  return (
129
  <div className="h-screen flex flex-col overflow-hidden">
130
  <Header
 
132
  onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
133
  viewerOpen={viewerOpen}
134
  onToggleViewer={() => setViewerOpen(!viewerOpen)}
135
+ mobileSheetContent={sidebarContent}
136
  />
137
 
138
  {connectionError && (
 
145
  )}
146
 
147
  <div className="flex-1 flex overflow-hidden">
148
+ {/* ── Left: Document Sidebar β€” desktop only (md+) ─────────── */}
149
+ {sidebarOpen && (
150
+ <div className="hidden md:block w-72 flex-shrink-0 border-r border-border/50 overflow-hidden animate-fade-in-up">
151
+ {sidebarContent}
152
+ </div>
153
+ )}
154
+
155
+ {/* ── Center: Chat Panel ──────────────────────────────────── */}
 
 
 
 
 
 
 
 
 
 
 
156
  <div className="flex-1 min-w-0 flex flex-col">
157
  <ChatPanel
158
  activeDoc={activeDoc}
159
  onCitationClick={(page) => {
160
  setPdfPage(page);
 
161
  if (!viewerOpen) setViewerOpen(true);
162
  }}
163
  />
164
  </div>
165
 
166
+ {/* ── Right: PDF Viewer β€” hidden on mobile ────────────────── */}
167
+ {viewerOpen && activeDoc && activeDoc.original_name.endsWith(".pdf") && (
168
+ <div className="hidden md:block w-[480px] flex-shrink-0 border-l border-border/50 overflow-hidden animate-fade-in-up">
169
+ <PDFViewer
170
+ documentId={activeDoc.id}
171
+ currentPage={pdfPage}
172
+ onPageChange={setPdfPage}
173
+ totalPages={activeDoc.page_count}
174
+ />
175
+ </div>
176
+ )}
 
 
177
  </div>
178
  </div>
179
  );
frontend/src/components/layout/Header.tsx CHANGED
@@ -1,5 +1,6 @@
1
  "use client";
2
 
 
3
  import { useAuth } from "@/lib/auth";
4
  import { useRouter } from "next/navigation";
5
  import { Button } from "@/components/ui/button";
@@ -20,32 +21,40 @@ import {
20
  LogOut,
21
  Moon,
22
  Sun,
 
 
23
  } from "lucide-react";
24
- import { useState } from "react";
 
25
 
26
  interface HeaderProps {
27
  sidebarOpen: boolean;
28
  onToggleSidebar: () => void;
29
  viewerOpen: boolean;
30
  onToggleViewer: () => void;
 
 
31
  }
32
 
33
- export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onToggleViewer }: HeaderProps) {
 
 
 
 
 
 
 
 
 
 
34
  const { user, logout } = useAuth();
35
  const router = useRouter();
36
- const [isDark, setIsDark] = useState(true);
 
 
37
 
38
- const toggleTheme = () => {
39
- const html = document.documentElement;
40
- if (isDark) {
41
- html.classList.remove("dark");
42
- html.classList.add("light");
43
- } else {
44
- html.classList.remove("light");
45
- html.classList.add("dark");
46
- }
47
- setIsDark(!isDark);
48
- };
49
 
50
  const handleLogout = () => {
51
  logout();
@@ -53,53 +62,147 @@ export default function Header({ sidebarOpen, onToggleSidebar, viewerOpen, onTog
53
  };
54
 
55
  return (
56
- <header className="h-14 flex items-center justify-between px-4 border-b border-border/50 bg-card/50 backdrop-blur-md flex-shrink-0 z-50">
57
- {/* Left */}
58
- <div className="flex items-center gap-3">
59
- <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleSidebar} title={sidebarOpen ? "Close sidebar" : "Open sidebar"}>
60
- {sidebarOpen ? <PanelLeftClose className="w-4 h-4" /> : <PanelLeftOpen className="w-4 h-4" />}
61
- </Button>
 
 
 
 
 
 
 
 
62
 
63
- <div className="flex items-center gap-2">
64
- <div className="w-7 h-7 rounded-lg bg-primary/15 flex items-center justify-center">
65
- <Brain className="w-4 h-4 text-primary" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  </div>
67
- <span className="font-semibold text-sm hidden sm:inline">Document AI Analyst</span>
68
  </div>
69
- </div>
70
 
71
- {/* Right */}
72
- <div className="flex items-center gap-2">
73
- <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onToggleViewer} title={viewerOpen ? "Close viewer" : "Open viewer"}>
74
- {viewerOpen ? <PanelRightClose className="w-4 h-4" /> : <PanelRightOpen className="w-4 h-4" />}
75
- </Button>
 
 
 
 
 
 
 
 
 
 
76
 
77
- <Button variant="ghost" size="icon" className="h-8 w-8" onClick={toggleTheme} title={isDark ? "Light mode" : "Dark mode"}>
78
- {isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
79
- </Button>
 
 
 
 
 
 
 
 
80
 
81
- <DropdownMenu>
82
- <DropdownMenuTrigger className="flex items-center h-8 gap-2 px-2 rounded-md hover:bg-accent transition-colors cursor-pointer">
83
- <Avatar className="w-6 h-6">
84
- <AvatarFallback className="text-[10px] bg-primary/20 text-primary">
85
- {user?.username?.slice(0, 2).toUpperCase() || "U"}
86
- </AvatarFallback>
87
- </Avatar>
88
- <span className="text-sm hidden sm:inline">{user?.username}</span>
89
- </DropdownMenuTrigger>
90
- <DropdownMenuContent align="end" className="w-48">
91
- <div className="px-3 py-2">
92
- <p className="text-sm font-medium">{user?.username}</p>
93
- <p className="text-xs text-muted-foreground truncate">{user?.email}</p>
94
- </div>
95
- <DropdownMenuSeparator />
96
- <DropdownMenuItem className="text-destructive cursor-pointer" onClick={handleLogout}>
97
- <LogOut className="w-4 h-4 mr-2" />
98
- Sign out
99
- </DropdownMenuItem>
100
- </DropdownMenuContent>
101
- </DropdownMenu>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  </div>
103
- </header>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  );
105
  }
 
1
  "use client";
2
 
3
+ import { useState } from "react";
4
  import { useAuth } from "@/lib/auth";
5
  import { useRouter } from "next/navigation";
6
  import { Button } from "@/components/ui/button";
 
21
  LogOut,
22
  Moon,
23
  Sun,
24
+ Menu,
25
+ X,
26
  } from "lucide-react";
27
+ import { useTheme } from "next-themes";
28
+ import { useSyncExternalStore } from "react";
29
 
30
  interface HeaderProps {
31
  sidebarOpen: boolean;
32
  onToggleSidebar: () => void;
33
  viewerOpen: boolean;
34
  onToggleViewer: () => void;
35
+ /** Pass DocumentSidebar JSX so the mobile sheet can render it */
36
+ mobileSheetContent?: React.ReactNode;
37
  }
38
 
39
+ const subscribe = () => () => {};
40
+ const getSnapshot = () => true;
41
+ const getServerSnapshot = () => false;
42
+
43
+ export default function Header({
44
+ sidebarOpen,
45
+ onToggleSidebar,
46
+ viewerOpen,
47
+ onToggleViewer,
48
+ mobileSheetContent,
49
+ }: HeaderProps) {
50
  const { user, logout } = useAuth();
51
  const router = useRouter();
52
+ const { theme, setTheme } = useTheme();
53
+ const mounted = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
54
+ const [sheetOpen, setSheetOpen] = useState(false);
55
 
56
+ const isDark = theme === "dark";
57
+ const toggleTheme = () => setTheme(isDark ? "light" : "dark");
 
 
 
 
 
 
 
 
 
58
 
59
  const handleLogout = () => {
60
  logout();
 
62
  };
63
 
64
  return (
65
+ <>
66
+ <header className="h-14 flex items-center justify-between px-4 border-b border-border/50 bg-card/50 backdrop-blur-md flex-shrink-0 z-50">
67
+ {/* Left */}
68
+ <div className="flex items-center gap-3">
69
+ {/* Hamburger β€” mobile only */}
70
+ <Button
71
+ variant="ghost"
72
+ size="icon"
73
+ className="h-8 w-8 md:hidden"
74
+ onClick={() => setSheetOpen(true)}
75
+ title="Open sidebar"
76
+ >
77
+ <Menu className="w-4 h-4" />
78
+ </Button>
79
 
80
+ {/* Desktop sidebar toggle β€” hidden on mobile */}
81
+ <Button
82
+ variant="ghost"
83
+ size="icon"
84
+ className="h-8 w-8 hidden md:inline-flex"
85
+ onClick={onToggleSidebar}
86
+ title={sidebarOpen ? "Close sidebar" : "Open sidebar"}
87
+ >
88
+ {sidebarOpen ? (
89
+ <PanelLeftClose className="w-4 h-4" />
90
+ ) : (
91
+ <PanelLeftOpen className="w-4 h-4" />
92
+ )}
93
+ </Button>
94
+
95
+ <div className="flex items-center gap-2">
96
+ <div className="w-7 h-7 rounded-lg bg-primary/15 flex items-center justify-center">
97
+ <Brain className="w-4 h-4 text-primary" />
98
+ </div>
99
+ <span className="font-semibold text-sm hidden sm:inline">
100
+ Document AI Analyst
101
+ </span>
102
  </div>
 
103
  </div>
 
104
 
105
+ {/* Right */}
106
+ <div className="flex items-center gap-2">
107
+ <Button
108
+ variant="ghost"
109
+ size="icon"
110
+ className="h-8 w-8"
111
+ onClick={onToggleViewer}
112
+ title={viewerOpen ? "Close viewer" : "Open viewer"}
113
+ >
114
+ {viewerOpen ? (
115
+ <PanelRightClose className="w-4 h-4" />
116
+ ) : (
117
+ <PanelRightOpen className="w-4 h-4" />
118
+ )}
119
+ </Button>
120
 
121
+ {mounted && (
122
+ <Button
123
+ variant="ghost"
124
+ size="icon"
125
+ className="h-8 w-8"
126
+ onClick={toggleTheme}
127
+ title={isDark ? "Light mode" : "Dark mode"}
128
+ >
129
+ {isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
130
+ </Button>
131
+ )}
132
 
133
+ <DropdownMenu>
134
+ <DropdownMenuTrigger className="flex items-center h-8 gap-2 px-2 rounded-md hover:bg-accent transition-colors cursor-pointer">
135
+ <Avatar className="w-6 h-6">
136
+ <AvatarFallback className="text-[10px] bg-primary/20 text-primary">
137
+ {user?.username?.slice(0, 2).toUpperCase() || "U"}
138
+ </AvatarFallback>
139
+ </Avatar>
140
+ <span className="text-sm hidden sm:inline">{user?.username}</span>
141
+ </DropdownMenuTrigger>
142
+ <DropdownMenuContent align="end" className="w-48">
143
+ <div className="px-3 py-2">
144
+ <p className="text-sm font-medium">{user?.username}</p>
145
+ <p className="text-xs text-muted-foreground truncate">{user?.email}</p>
146
+ </div>
147
+ <DropdownMenuSeparator />
148
+ <DropdownMenuItem
149
+ className="text-destructive cursor-pointer"
150
+ onClick={handleLogout}
151
+ >
152
+ <LogOut className="w-4 h-4 mr-2" />
153
+ Sign out
154
+ </DropdownMenuItem>
155
+ </DropdownMenuContent>
156
+ </DropdownMenu>
157
+ </div>
158
+ </header>
159
+
160
+ {/* ── Mobile Navigation Sheet ──────────────────────────────────── */}
161
+ {/* Backdrop */}
162
+ {sheetOpen && (
163
+ <div
164
+ className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm md:hidden"
165
+ onClick={() => setSheetOpen(false)}
166
+ aria-hidden="true"
167
+ />
168
+ )}
169
+
170
+ {/* Slide-in panel */}
171
+ <aside
172
+ className={[
173
+ "fixed inset-y-0 left-0 z-50 w-72 flex flex-col",
174
+ "bg-sidebar border-r border-sidebar-border",
175
+ "transform transition-transform duration-300 ease-in-out md:hidden",
176
+ sheetOpen ? "translate-x-0" : "-translate-x-full",
177
+ ].join(" ")}
178
+ aria-label="Mobile navigation"
179
+ aria-hidden={!sheetOpen}
180
+ inert={!sheetOpen ? true : undefined}
181
+ >
182
+ {/* Sheet header */}
183
+ <div className="h-14 flex items-center justify-between px-4 border-b border-sidebar-border flex-shrink-0">
184
+ <div className="flex items-center gap-2">
185
+ <div className="w-7 h-7 rounded-lg bg-primary/15 flex items-center justify-center">
186
+ <Brain className="w-4 h-4 text-primary" />
187
  </div>
188
+ <span className="font-semibold text-sm">Document AI Analyst</span>
189
+ </div>
190
+ <Button
191
+ variant="ghost"
192
+ size="icon"
193
+ className="h-8 w-8"
194
+ onClick={() => setSheetOpen(false)}
195
+ aria-label="Close navigation"
196
+ >
197
+ <X className="w-4 h-4" />
198
+ </Button>
199
+ </div>
200
+
201
+ {/* Sidebar content */}
202
+ <div className="flex-1 overflow-hidden">
203
+ {sheetOpen ? mobileSheetContent : null}
204
+ </div>
205
+ </aside>
206
+ </>
207
  );
208
  }