'use client'; import { useState, useRef, useMemo, useEffect } from 'react'; import { Paperclip, FileText, X } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useSettingsStore } from '@/lib/store/settings'; import type { SettingsSection } from '@/lib/types/settings'; import { MediaPopover } from '@/components/generation/media-popover'; import { getAcceptStringForProviders, isMimeSupportedByProviders } from '@/lib/document/mime'; import { MAX_DOCUMENT_BUNDLE_FILES, MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES, } from '@/lib/document/bundle'; import { dedupeCourseMaterialFiles } from '@/lib/document/course-materials'; import type { SelectedCourseMaterial } from '@/lib/types/generation'; // ─── Constants ─────────────────────────────────────────────── const MAX_COURSE_MATERIAL_SIZE_MB = 50; const MAX_COURSE_MATERIAL_SIZE_BYTES = MAX_COURSE_MATERIAL_SIZE_MB * 1024 * 1024; // ─── Types ─────────────────────────────────────────────────── export interface GenerationToolbarProps { webSearch: boolean; onWebSearchChange: (v: boolean) => void; onSettingsOpen: (section?: SettingsSection) => void; // PDF courseMaterials: SelectedCourseMaterial[]; onCourseMaterialsAdd: (files: File[]) => void; onCourseMaterialRemove: (id: string) => void; onPdfError: (error: string | null) => void; } // ─── Component ─────────────────────────────────────────────── export function GenerationToolbar({ webSearch, onWebSearchChange, onSettingsOpen, courseMaterials, onCourseMaterialsAdd, onCourseMaterialRemove, onPdfError, }: GenerationToolbarProps) { const { t } = useI18n(); const pdfProviderId = useSettingsStore((s) => s.pdfProviderId); const fileInputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); // Course material handler. `plain-text` is always active alongside the // configured extractor so txt/md files remain uploadable without an // additional service choice in the generation form. const activeDocumentProviderIds = useMemo( () => [pdfProviderId, 'plain-text'] as const, [pdfProviderId], ); const acceptForCurrentProvider = useMemo( () => getAcceptStringForProviders(activeDocumentProviderIds), [activeDocumentProviderIds], ); // If the configured extractor changes and no longer supports already // attached materials, drop only the incompatible files so the eventual // extraction request matches its capability. useEffect(() => { const unsupportedMaterials = courseMaterials.filter( (file) => !isMimeSupportedByProviders( { mimeType: file.type, fileName: file.name }, activeDocumentProviderIds, ), ); if (unsupportedMaterials.length === 0) return; for (const file of unsupportedMaterials) { onCourseMaterialRemove(file.id); } onPdfError(t('upload.unsupportedCourseMaterial')); // Intentionally omit callbacks/t from deps: adding them would re-run this // provider capability cleanup on unrelated parent re-renders. // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeDocumentProviderIds, courseMaterials]); const handleFilesSelect = (incomingFiles: File[]) => { const supportedFiles = incomingFiles.filter((file) => isMimeSupportedByProviders( { mimeType: file.type, fileName: file.name }, activeDocumentProviderIds, ), ); if (supportedFiles.length === 0) { onPdfError(t('upload.unsupportedCourseMaterial')); return; } if (supportedFiles.length !== incomingFiles.length) { onPdfError(t('upload.unsupportedCourseMaterial')); return; } if (supportedFiles.some((file) => file.size > MAX_COURSE_MATERIAL_SIZE_BYTES)) { onPdfError(t('upload.fileTooLarge')); return; } const dedupedFiles = dedupeCourseMaterialFiles(courseMaterials, supportedFiles); if (dedupedFiles.length === 0) return; if (courseMaterials.length + dedupedFiles.length > MAX_DOCUMENT_BUNDLE_FILES) { onPdfError(t('upload.courseMaterialCountLimit', { n: MAX_DOCUMENT_BUNDLE_FILES })); return; } const totalSize = courseMaterials.reduce((sum, file) => sum + file.size, 0) + dedupedFiles.reduce((sum, file) => sum + file.size, 0); if (totalSize > MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES) { onPdfError( t('upload.courseMaterialTotalSizeLimit', { n: Math.floor(MAX_DOCUMENT_BUNDLE_TOTAL_SIZE_BYTES / 1024 / 1024), }), ); return; } onPdfError(null); onCourseMaterialsAdd(dedupedFiles); }; // ─── Pill button helper ───────────────────────────── const pillCls = 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none whitespace-nowrap border'; const pillMuted = `${pillCls} border-border/50 text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`; const pillActive = `${pillCls} border-violet-200/60 dark:border-violet-700/50 bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300`; return (
{/* ── Course material (extractor + upload) combined Popover ── */} {courseMaterials.length > 0 ? ( ) : ( )} {/* Upload area / file info */}
{ const files = Array.from(e.target.files ?? []); if (files.length > 0) handleFilesSelect(files); e.target.value = ''; }} />
fileInputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={(e) => { e.preventDefault(); setIsDragging(false); const files = Array.from(e.dataTransfer.files ?? []); if (files.length > 0) handleFilesSelect(files); }} >

{t('toolbar.courseMaterialUpload')}

{t('upload.courseMaterialSizeLimit')}

{t('upload.courseMaterialCountLimit', { n: MAX_DOCUMENT_BUNDLE_FILES })}

{courseMaterials.length > 0 && (

{t('toolbar.courseMaterialMergeOrder')}

{[...courseMaterials] .sort((a, b) => a.order - b.order) .map((file) => (

{file.order}. {file.name}

{(file.size / 1024 / 1024).toFixed(2)} MB

))}
)}
{/* ── Media popover ── */}
); }