252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
'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<HTMLInputElement>(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 (
|
|
<div className="flex items-center gap-1 flex-wrap">
|
|
<div className="flex min-w-0 items-center gap-1">
|
|
{/* ── Course material (extractor + upload) combined Popover ── */}
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
{courseMaterials.length > 0 ? (
|
|
<button className={pillActive}>
|
|
<Paperclip className="size-3.5" />
|
|
<span className="max-w-[140px] truncate">
|
|
{courseMaterials.length === 1
|
|
? courseMaterials[0].name
|
|
: t('toolbar.courseMaterialsSelected', { n: courseMaterials.length })}
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<button className={pillMuted}>
|
|
<Paperclip className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-72 p-0">
|
|
{/* Upload area / file info */}
|
|
<div className="px-3 pb-3 pt-3">
|
|
<input
|
|
type="file"
|
|
ref={fileInputRef}
|
|
className="hidden"
|
|
accept={acceptForCurrentProvider}
|
|
multiple
|
|
onChange={(e) => {
|
|
const files = Array.from(e.target.files ?? []);
|
|
if (files.length > 0) handleFilesSelect(files);
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
<div className="space-y-3">
|
|
<div
|
|
className={cn(
|
|
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-4 transition-colors cursor-pointer',
|
|
isDragging
|
|
? 'border-violet-400 bg-violet-50 dark:bg-violet-950/20'
|
|
: 'border-muted-foreground/20 hover:border-violet-300',
|
|
)}
|
|
onClick={() => 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);
|
|
}}
|
|
>
|
|
<Paperclip className="size-5 text-muted-foreground/50 mb-1.5" />
|
|
<p className="text-xs font-medium">{t('toolbar.courseMaterialUpload')}</p>
|
|
<p className="text-[10px] text-muted-foreground/60 mt-0.5 text-center">
|
|
{t('upload.courseMaterialSizeLimit')}
|
|
</p>
|
|
<p className="text-[10px] text-muted-foreground/60 text-center">
|
|
{t('upload.courseMaterialCountLimit', { n: MAX_DOCUMENT_BUNDLE_FILES })}
|
|
</p>
|
|
</div>
|
|
|
|
{courseMaterials.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-[10px] text-muted-foreground/70">
|
|
{t('toolbar.courseMaterialMergeOrder')}
|
|
</p>
|
|
<div className="max-h-44 space-y-2 overflow-y-auto pr-1">
|
|
{[...courseMaterials]
|
|
.sort((a, b) => a.order - b.order)
|
|
.map((file) => (
|
|
<div
|
|
key={file.id}
|
|
className="flex items-center gap-2 rounded-lg border border-border/50 px-2 py-2"
|
|
>
|
|
<div className="size-8 rounded-lg bg-violet-100 dark:bg-violet-900/30 flex items-center justify-center shrink-0">
|
|
<FileText className="size-4 text-violet-600 dark:text-violet-400" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium truncate">
|
|
{file.order}. {file.name}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{(file.size / 1024 / 1024).toFixed(2)} MB
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => onCourseMaterialRemove(file.id)}
|
|
className="size-6 rounded-full inline-flex items-center justify-center text-muted-foreground hover:bg-muted transition-colors"
|
|
aria-label={t('toolbar.removeCourseMaterial')}
|
|
>
|
|
<X className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* ── Media popover ── */}
|
|
<MediaPopover
|
|
onSettingsOpen={onSettingsOpen}
|
|
webSearch={webSearch}
|
|
onWebSearchChange={onWebSearchChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|