Spaces:
Sleeping
Sleeping
| import { router } from '@inertiajs/react'; | |
| import { | |
| ChevronDown, | |
| Clock3, | |
| LogOut, | |
| MessageSquare, | |
| Trash2, | |
| } from 'lucide-react'; | |
| import type { CSSProperties, ReactNode } from 'react'; | |
| import { useState, useSyncExternalStore } from 'react'; | |
| import AppearanceToggle from '@/components/appearance-toggle'; | |
| import { Avatar, AvatarFallback } from '@/components/ui/avatar'; | |
| import { Button } from '@/components/ui/button'; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| } from '@/components/ui/dialog'; | |
| import { LoadingIndicator } from '@/components/ui/loading-indicator'; | |
| import { | |
| Sidebar, | |
| SidebarContent, | |
| SidebarFooter, | |
| SidebarGroup, | |
| SidebarGroupContent, | |
| SidebarGroupLabel, | |
| SidebarHeader, | |
| SidebarInset, | |
| SidebarMenu, | |
| SidebarMenuButton, | |
| SidebarMenuItem, | |
| SidebarProvider, | |
| SidebarRail, | |
| SidebarTrigger, | |
| useSidebar, | |
| } from '@/components/ui/sidebar'; | |
| import { clearAuthSession, getStoredAuthUser } from '@/lib/auth'; | |
| import type { ChatSessionResponse } from '@/lib/rag'; | |
| import { login, mahasiswa } from '@/routes'; | |
| import { show as showMahasiswaSession } from '@/routes/mahasiswa'; | |
| const sidebarStyle = { | |
| '--sidebar-width': '260px', | |
| '--sidebar-width-icon': '64px', | |
| } as CSSProperties; | |
| export type DeleteSessionResult = { | |
| ok: boolean; | |
| message?: string; | |
| }; | |
| export function getStoredStudentName(): string { | |
| return getStoredAuthUser()?.name ?? 'Mahasiswa'; | |
| } | |
| function subscribeToAuthStorage(onStoreChange: () => void): () => void { | |
| if (typeof window === 'undefined') { | |
| return () => {}; | |
| } | |
| window.addEventListener('storage', onStoreChange); | |
| return () => window.removeEventListener('storage', onStoreChange); | |
| } | |
| export function useStoredStudentName(): string { | |
| return useSyncExternalStore( | |
| subscribeToAuthStorage, | |
| getStoredStudentName, | |
| () => 'Mahasiswa', | |
| ); | |
| } | |
| export function isAbortError(error: unknown): boolean { | |
| return error instanceof DOMException && error.name === 'AbortError'; | |
| } | |
| export function formatSessionTime(value?: string): string { | |
| if (!value) { | |
| return ''; | |
| } | |
| const normalized = /[Zz]|[+-]\d{2}:?\d{2}$/.test(value) ? value : value + 'Z'; | |
| const date = new Date(normalized); | |
| if (Number.isNaN(date.getTime())) { | |
| return value; | |
| } | |
| return new Intl.DateTimeFormat('en-US', { | |
| day: 'numeric', | |
| hour: 'numeric', | |
| minute: '2-digit', | |
| month: 'short', | |
| }).format(date); | |
| } | |
| function getInitials(name: string): string { | |
| return name | |
| .split(' ') | |
| .filter(Boolean) | |
| .slice(0, 2) | |
| .map((part) => part[0]?.toUpperCase()) | |
| .join(''); | |
| } | |
| function confirmLogout(): void { | |
| clearAuthSession(); | |
| router.visit(login()); | |
| } | |
| function StudentSidebarLogo() { | |
| const { state, toggleSidebar } = useSidebar(); | |
| const handleLogoClick = (): void => { | |
| if (state === 'collapsed') { | |
| toggleSidebar(); | |
| } | |
| }; | |
| return ( | |
| <button | |
| aria-label="Buka sidebar" | |
| className="student-logo-action" | |
| onClick={handleLogoClick} | |
| type="button" | |
| > | |
| <span className="student-logo-mark"> | |
| <MessageSquare className="size-5" /> | |
| </span> | |
| </button> | |
| ); | |
| } | |
| function StudentSidebar({ | |
| activeSessionId, | |
| chatSessions, | |
| isLoading, | |
| onDeleteSession, | |
| studentName, | |
| }: { | |
| activeSessionId?: string; | |
| chatSessions: ChatSessionResponse[]; | |
| isLoading: boolean; | |
| onDeleteSession?: ( | |
| sessionId: string, | |
| ) => DeleteSessionResult | Promise<DeleteSessionResult>; | |
| studentName: string; | |
| }) { | |
| const [isHistoryOpen, setIsHistoryOpen] = useState(true); | |
| const [deleteDialogMessage, setDeleteDialogMessage] = useState<string>(); | |
| const [isDeletingSession, setIsDeletingSession] = useState(false); | |
| const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false); | |
| const [sessionToDelete, setSessionToDelete] = | |
| useState<ChatSessionResponse>(); | |
| const { state, toggleSidebar } = useSidebar(); | |
| const isSidebarCollapsed = state === 'collapsed'; | |
| function handleDeleteDialogOpenChange(open: boolean): void { | |
| if (isDeletingSession) { | |
| return; | |
| } | |
| if (!open) { | |
| setSessionToDelete(undefined); | |
| setDeleteDialogMessage(undefined); | |
| } | |
| } | |
| async function handleConfirmDeleteSession(): Promise<void> { | |
| if (!sessionToDelete || !onDeleteSession) { | |
| return; | |
| } | |
| setIsDeletingSession(true); | |
| setDeleteDialogMessage(undefined); | |
| try { | |
| const result = await onDeleteSession(sessionToDelete.uuid_id); | |
| if (result.ok) { | |
| setSessionToDelete(undefined); | |
| return; | |
| } | |
| setDeleteDialogMessage(result.message); | |
| } finally { | |
| setIsDeletingSession(false); | |
| } | |
| } | |
| return ( | |
| <> | |
| <Sidebar className="student-sidebar" collapsible="icon"> | |
| <SidebarHeader className="student-sidebar-top"> | |
| <StudentSidebarLogo /> | |
| <SidebarTrigger className="student-sidebar-trigger" /> | |
| </SidebarHeader> | |
| <SidebarContent className="student-sidebar-content"> | |
| <SidebarGroup className="student-history-group"> | |
| {isSidebarCollapsed ? ( | |
| <SidebarGroupContent> | |
| <SidebarMenu className="student-history-list"> | |
| <SidebarMenuItem> | |
| <SidebarMenuButton | |
| className="student-history-menu-button" | |
| tooltip="Riwayat Chat" | |
| onClick={() => { | |
| toggleSidebar(); | |
| setIsHistoryOpen(true); | |
| }} | |
| > | |
| <Clock3 className="student-history-menu-icon size-4" /> | |
| <span className="student-history-copy"> | |
| <span className="student-history-title"> | |
| Riwayat Chat | |
| </span> | |
| </span> | |
| </SidebarMenuButton> | |
| </SidebarMenuItem> | |
| </SidebarMenu> | |
| </SidebarGroupContent> | |
| ) : ( | |
| <> | |
| <SidebarGroupLabel asChild> | |
| <button | |
| aria-expanded={isHistoryOpen} | |
| className="student-history-label" | |
| onClick={() => | |
| setIsHistoryOpen( | |
| (currentState) => !currentState, | |
| ) | |
| } | |
| type="button" | |
| > | |
| <Clock3 className="size-4" /> | |
| <span>Riwayat Chat</span> | |
| <ChevronDown className="student-history-chevron" /> | |
| </button> | |
| </SidebarGroupLabel> | |
| {isHistoryOpen && ( | |
| <SidebarGroupContent> | |
| <SidebarMenu className="student-history-list"> | |
| {isLoading && ( | |
| <SidebarMenuItem> | |
| <SidebarMenuButton | |
| className="student-history-menu-button" | |
| disabled | |
| > | |
| <Clock3 className="student-history-menu-icon size-4" /> | |
| <span className="student-history-copy"> | |
| <span className="student-history-title"> | |
| <LoadingIndicator | |
| label="Memuat riwayat" | |
| spinnerClassName="size-3" | |
| /> | |
| </span> | |
| </span> | |
| </SidebarMenuButton> | |
| </SidebarMenuItem> | |
| )} | |
| {chatSessions.map((session) => { | |
| const sessionTime = | |
| formatSessionTime( | |
| session.last_message_at ?? | |
| session.updated_at ?? | |
| session.created_at, | |
| ); | |
| return ( | |
| <SidebarMenuItem | |
| className="student-history-menu-item" | |
| key={session.uuid_id} | |
| > | |
| <SidebarMenuButton | |
| className="student-history-menu-button" | |
| isActive={ | |
| session.uuid_id === | |
| activeSessionId | |
| } | |
| onClick={() => | |
| router.visit( | |
| showMahasiswaSession( | |
| session.uuid_id, | |
| ), | |
| ) | |
| } | |
| tooltip={ | |
| session.title | |
| } | |
| type="button" | |
| > | |
| <Clock3 className="student-history-menu-icon size-4" /> | |
| <span className="student-history-copy"> | |
| <span className="student-history-title"> | |
| { | |
| session.title | |
| } | |
| </span> | |
| {sessionTime && ( | |
| <span className="student-history-meta"> | |
| { | |
| sessionTime | |
| } | |
| </span> | |
| )} | |
| </span> | |
| </SidebarMenuButton> | |
| {onDeleteSession && ( | |
| <button | |
| aria-label={`Hapus sesi ${session.title}`} | |
| className="student-history-delete" | |
| onClick={( | |
| event, | |
| ) => { | |
| event.stopPropagation(); | |
| setSessionToDelete( | |
| session, | |
| ); | |
| setDeleteDialogMessage( | |
| undefined, | |
| ); | |
| }} | |
| type="button" | |
| > | |
| <Trash2 className="size-3" /> | |
| </button> | |
| )} | |
| </SidebarMenuItem> | |
| ); | |
| })} | |
| </SidebarMenu> | |
| </SidebarGroupContent> | |
| )} | |
| </> | |
| )} | |
| </SidebarGroup> | |
| </SidebarContent> | |
| <SidebarFooter className="student-sidebar-footer"> | |
| <SidebarMenu> | |
| <SidebarMenuItem> | |
| <SidebarMenuButton | |
| className="student-user-menu-button" | |
| tooltip={studentName} | |
| > | |
| <Avatar className="student-user-avatar"> | |
| <AvatarFallback> | |
| {getInitials(studentName) || 'AK'} | |
| </AvatarFallback> | |
| </Avatar> | |
| <span className="student-user-copy"> | |
| <span className="student-user-name"> | |
| {studentName} | |
| </span> | |
| <span className="student-user-role"> | |
| Mahasiswa | |
| </span> | |
| </span> | |
| </SidebarMenuButton> | |
| </SidebarMenuItem> | |
| <SidebarMenuItem> | |
| <SidebarMenuButton | |
| className="student-logout-button" | |
| onClick={() => setIsLogoutDialogOpen(true)} | |
| tooltip="Logout" | |
| > | |
| <LogOut className="size-4" /> | |
| <span>Logout</span> | |
| </SidebarMenuButton> | |
| </SidebarMenuItem> | |
| </SidebarMenu> | |
| </SidebarFooter> | |
| <SidebarRail /> | |
| </Sidebar> | |
| <Dialog | |
| onOpenChange={handleDeleteDialogOpenChange} | |
| open={Boolean(sessionToDelete)} | |
| > | |
| <DialogContent className="student-delete-dialog"> | |
| <DialogHeader> | |
| <DialogTitle>Hapus Riwayat Chat</DialogTitle> | |
| <DialogDescription> | |
| Sesi chat ini akan dihapus dari riwayat. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <div className="student-delete-dialog-body"> | |
| <p className="student-delete-copy"> | |
| Hapus sesi{' '} | |
| <strong> | |
| {sessionToDelete?.title ?? 'chat ini'} | |
| </strong> | |
| ? | |
| </p> | |
| {deleteDialogMessage && ( | |
| <p className="student-delete-error" role="alert"> | |
| {deleteDialogMessage} | |
| </p> | |
| )} | |
| </div> | |
| <DialogFooter className="student-delete-dialog-footer"> | |
| <Button | |
| className="student-delete-cancel" | |
| disabled={isDeletingSession} | |
| onClick={() => handleDeleteDialogOpenChange(false)} | |
| type="button" | |
| variant="outline" | |
| > | |
| Batal | |
| </Button> | |
| <Button | |
| className="student-delete-confirm" | |
| disabled={isDeletingSession} | |
| onClick={() => { | |
| void handleConfirmDeleteSession(); | |
| }} | |
| type="button" | |
| > | |
| {isDeletingSession ? ( | |
| <LoadingIndicator | |
| label="Menghapus" | |
| showSpinner={false} | |
| /> | |
| ) : ( | |
| 'Hapus' | |
| )} | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| <Dialog | |
| onOpenChange={setIsLogoutDialogOpen} | |
| open={isLogoutDialogOpen} | |
| > | |
| <DialogContent className="student-delete-dialog"> | |
| <DialogHeader> | |
| <DialogTitle>Keluar dari akun?</DialogTitle> | |
| <DialogDescription> | |
| Anda perlu login kembali untuk mengakses sesi chat. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <DialogFooter className="student-delete-dialog-footer"> | |
| <Button | |
| className="student-delete-cancel" | |
| onClick={() => setIsLogoutDialogOpen(false)} | |
| type="button" | |
| variant="outline" | |
| > | |
| Batal | |
| </Button> | |
| <Button | |
| className="student-delete-confirm" | |
| onClick={confirmLogout} | |
| type="button" | |
| > | |
| Logout | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| </> | |
| ); | |
| } | |
| export function StudentShell({ | |
| activeSessionId, | |
| chatSessions, | |
| children, | |
| isLoadingSessions, | |
| onDeleteSession, | |
| studentName, | |
| }: { | |
| activeSessionId?: string; | |
| chatSessions: ChatSessionResponse[]; | |
| children: ReactNode; | |
| isLoadingSessions: boolean; | |
| onDeleteSession?: ( | |
| sessionId: string, | |
| ) => DeleteSessionResult | Promise<DeleteSessionResult>; | |
| studentName: string; | |
| }) { | |
| return ( | |
| <SidebarProvider | |
| className="student-dashboard-shell" | |
| defaultOpen | |
| style={sidebarStyle} | |
| > | |
| <StudentSidebar | |
| activeSessionId={activeSessionId} | |
| chatSessions={chatSessions} | |
| isLoading={isLoadingSessions} | |
| onDeleteSession={onDeleteSession} | |
| studentName={studentName} | |
| /> | |
| <SidebarInset className="student-workspace"> | |
| <header className="student-topbar"> | |
| <SidebarTrigger className="md:hidden" /> | |
| <h1 className="student-brand-title">RAG Hub</h1> | |
| <div className="student-topbar-actions"> | |
| <AppearanceToggle className="student-theme-toggle" /> | |
| <Button | |
| className="student-new-session-button" | |
| onClick={() => router.visit(mahasiswa())} | |
| type="button" | |
| > | |
| + Sesi Baru | |
| </Button> | |
| </div> | |
| </header> | |
| {children} | |
| </SidebarInset> | |
| </SidebarProvider> | |
| ); | |
| } | |