wepee's picture
fix: priority 4,3,2
f98535e
Raw
History Blame Contribute Delete
22.4 kB
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>
);
}