| // src/components/authoring/FigureManagerPanel.tsx | |
| import { useRef, useState } from "react"; | |
| import { Upload, Trash2, Image, GripVertical, ChevronUp, ChevronDown, Plus, Loader2, Check, ExternalLink } from "lucide-react"; | |
| interface FigureManagerPanelProps { | |
| figures: any[]; | |
| loading: boolean; | |
| uploadingIds: Set<string>; | |
| uploadProgress: Record<string, number>; | |
| addFigure: (file: File, caption?: string) => void; | |
| removeFigure: (id: string) => void; | |
| updateCaption: (id: string, caption: string) => void; | |
| updatePlacement: (id: string, placement: string) => void; | |
| reorderFigures: (ids: string[]) => void; | |
| insertFigureAtCursor: (figure: any, editor: any) => void; | |
| editor: any; | |
| } | |
| export function FigureManagerPanel({ figures, uploadingIds, uploadProgress, addFigure, removeFigure, updateCaption, updatePlacement, reorderFigures, insertFigureAtCursor, editor }: FigureManagerPanelProps) { | |
| const fileRef = useRef<HTMLInputElement>(null); | |
| const [editingCaptionId, setEditingCaptionId] = useState<string | null>(null); | |
| const [captionInput, setCaptionInput] = useState(""); | |
| const handleFiles = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const files = e.target.files; | |
| if (files) { | |
| for (const file of Array.from(files)) { | |
| addFigure(file); | |
| } | |
| } | |
| if (fileRef.current) fileRef.current.value = ""; | |
| }; | |
| const handleDrop = (e: React.DragEvent) => { | |
| e.preventDefault(); | |
| const files = e.dataTransfer.files; | |
| for (const file of Array.from(files)) { | |
| if (file.type.startsWith("image/")) { | |
| addFigure(file); | |
| } | |
| } | |
| }; | |
| return ( | |
| <div className="grid grid-cols-1 lg:grid-cols-4 gap-4 h-full" onDragOver={e => e.preventDefault()} onDrop={handleDrop}> | |
| {/* Upload zone */} | |
| <div className="space-y-3"> | |
| <div className="border-2 border-dashed border-white/30 rounded-xl p-4 text-center cursor-pointer hover:border-primary/50 transition" | |
| onClick={() => fileRef.current?.click()}> | |
| <input ref={fileRef} type="file" multiple accept="image/*" className="hidden" onChange={handleFiles}/> | |
| <Upload className="w-6 h-6 text-gray-400 mx-auto mb-1.5"/> | |
| <p className="text-xs text-gray-500">Drop images or click</p> | |
| </div> | |
| <p className="text-[10px] text-gray-400 text-center">Supported: PNG, JPG, PDF, EPS</p> | |
| </div> | |
| {/* Figure grid */} | |
| <div className="lg:col-span-3 overflow-y-auto"> | |
| {figures.length === 0 ? ( | |
| <div className="text-center py-8 text-gray-400"> | |
| <Image className="w-8 h-8 mx-auto mb-2 opacity-30"/> | |
| <p className="text-sm">No figures yet. Drag & drop to add.</p> | |
| </div> | |
| ) : ( | |
| <div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> | |
| {figures.map((fig, idx) => ( | |
| <div key={fig.id} className={`relative group rounded-xl border overflow-hidden ${ | |
| fig.status === "error" ? "border-red-300 dark:border-red-800" : | |
| uploadingIds.has(fig.id) ? "border-amber-300 dark:border-amber-800" : | |
| "border-white/20" | |
| }`}> | |
| {/* Thumbnail */} | |
| <div className="aspect-video bg-gray-100 dark:bg-gray-700 relative"> | |
| {fig.thumbnailUrl ? ( | |
| <img src={fig.thumbnailUrl} alt={fig.altText} className="w-full h-full object-cover"/> | |
| ) : ( | |
| <div className="flex items-center justify-center h-full"><Image className="w-8 h-8 text-gray-400"/></div> | |
| )} | |
| {/* Upload progress */} | |
| {uploadingIds.has(fig.id) && ( | |
| <div className="absolute inset-0 bg-black/50 flex flex-col items-center justify-center"> | |
| <Loader2 className="w-6 h-6 animate-spin text-white mb-1"/> | |
| <span className="text-xs text-white">{uploadProgress[fig.id] ?? 0}%</span> | |
| </div> | |
| )} | |
| {/* Hover actions */} | |
| <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition flex items-center justify-center gap-2"> | |
| <button onClick={() => insertFigureAtCursor(fig, editor)} className="p-1.5 bg-primary text-white rounded-lg text-xs" title="Insert at cursor"> | |
| <Plus className="w-4 h-4"/> | |
| </button> | |
| <button onClick={() => removeFigure(fig.id)} className="p-1.5 bg-red-500 text-white rounded-lg text-xs" title="Remove"> | |
| <Trash2 className="w-4 h-4"/> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Info */} | |
| <div className="p-2"> | |
| {editingCaptionId === fig.id ? ( | |
| <input type="text" value={captionInput} onChange={e => setCaptionInput(e.target.value)} | |
| onKeyDown={e => { if (e.key === "Enter") { updateCaption(fig.id, captionInput); setEditingCaptionId(null); } }} | |
| onBlur={() => { updateCaption(fig.id, captionInput); setEditingCaptionId(null); }} | |
| className="w-full text-xs px-2 py-1 bg-white/10 rounded" autoFocus/> | |
| ) : ( | |
| <p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-2 cursor-pointer" | |
| onClick={() => { setEditingCaptionId(fig.id); setCaptionInput(fig.caption); }}> | |
| {fig.caption || "Click to add caption"} | |
| </p> | |
| )} | |
| <div className="flex items-center justify-between mt-1.5"> | |
| <span className="text-[9px] text-gray-500">Fig. {fig.order}</span> | |
| <select aria-label="Select option" value={fig.placement} onChange={e => updatePlacement(fig.id, e.target.value)} | |
| className="text-[9px] bg-white/10 rounded px-1.5 py-0.5"> | |
| <option value="htbp">htbp</option> | |
| <option value="ht">ht</option> | |
| <option value="t">t</option> | |
| <option value="b">b</option> | |
| <option value="p">p</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } |
Xet Storage Details
- Size:
- 6.4 kB
- Xet hash:
- ef6c4a3b9d0ea458f994d684625234fe6e73110a7fc6c7591448e6451920a6b2
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.