| // src/components/project/ProjectFiles.tsx | |
| import { useState, useEffect, useCallback, useRef } from "react"; | |
| import { Upload, Trash2, Download, FileText, FileImage, FileCode, File, Loader2, FolderOpen, Plus } from "lucide-react"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { formatDistanceToNow } from "@/lib/date"; | |
| import api from "@/lib/api"; | |
| import { toast } from "react-toastify"; | |
| interface ProjectOutput { | |
| id: number; | |
| projectId: number; | |
| title: string; | |
| outputType?: string; | |
| link?: string; | |
| description?: string; | |
| createdAt: string; | |
| addedByUsername?: string; | |
| } | |
| interface ProjectFilesProps { | |
| projectId: number; | |
| isOwner: boolean; | |
| onFileUpdate: () => void; | |
| } | |
| const getFileIcon = (title: string) => { | |
| const ext = title.split(".").pop()?.toLowerCase() ?? ""; | |
| if (["jpg", "jpeg", "png", "gif", "svg", "webp"].includes(ext)) return { Icon: FileImage, color: "text-pink-500 bg-pink-50 dark:bg-pink-900/20" }; | |
| if (["js", "ts", "tsx", "jsx", "py", "java", "go", "rs"].includes(ext)) return { Icon: FileCode, color: "text-blue-500 bg-blue-50 dark:bg-blue-900/20" }; | |
| if (["pdf", "doc", "docx", "txt", "md"].includes(ext)) return { Icon: FileText, color: "text-orange-500 bg-orange-50 dark:bg-orange-900/20" }; | |
| return { Icon: File, color: "text-gray-500 bg-gray-100 dark:bg-gray-800" }; | |
| }; | |
| export function ProjectFiles({ projectId, isOwner, onFileUpdate }: ProjectFilesProps) { | |
| const [outputs, setOutputs] = useState<ProjectOutput[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [showAddForm, setShowAddForm] = useState(false); | |
| const [title, setTitle] = useState(""); | |
| const [link, setLink] = useState(""); | |
| const [description, setDescription] = useState(""); | |
| const [saving, setSaving] = useState(false); | |
| const fetchOutputs = useCallback(async () => { | |
| setLoading(true); | |
| try { | |
| const res = await api.get(`/projects/${projectId}/outputs`); | |
| setOutputs(res.data?.outputs ?? res.data?.items ?? res.data ?? []); | |
| } catch { | |
| toast.error("Failed to load files"); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [projectId]); | |
| useEffect(() => { fetchOutputs(); }, [fetchOutputs]); | |
| const handleAdd = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!title.trim()) { | |
| toast.error("Title is required"); | |
| return; | |
| } | |
| setSaving(true); | |
| try { | |
| await api.post(`/projects/${projectId}/outputs`, { title: title.trim(), link: link.trim(), description: description.trim() }); | |
| toast.success("Output added!"); | |
| setTitle(""); setLink(""); setDescription(""); | |
| setShowAddForm(false); | |
| await fetchOutputs(); | |
| onFileUpdate(); | |
| } catch { | |
| toast.error("Failed to add output"); | |
| } finally { | |
| setSaving(false); | |
| } | |
| }; | |
| const handleDelete = async (outputId: number) => { | |
| if (!confirm("Remove this output?")) return; | |
| try { | |
| await api.delete(`/projects/${projectId}/outputs/${outputId}`); | |
| await fetchOutputs(); | |
| onFileUpdate(); | |
| toast.success("Output removed"); | |
| } catch { | |
| toast.error("Failed to remove output"); | |
| } | |
| }; | |
| if (loading) { | |
| return ( | |
| <div className="flex items-center justify-center py-16"> | |
| <Loader2 className="w-8 h-8 animate-spin text-primary" /> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="space-y-5"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h3 className="text-base font-bold text-gray-900 dark:text-white">Files & Outputs</h3> | |
| <p className="text-sm text-gray-500 dark:text-gray-400">{outputs.length} item{outputs.length !== 1 ? "s" : ""}</p> | |
| </div> | |
| {isOwner && ( | |
| <motion.button | |
| whileHover={{ scale: 1.03 }} | |
| whileTap={{ scale: 0.97 }} | |
| onClick={() => setShowAddForm(!showAddForm)} | |
| className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-primary to-indigo-600 text-white rounded-xl text-sm font-semibold shadow-sm shadow-primary/20 hover:shadow-primary/40 transition-all" | |
| > | |
| <Plus className="w-4 h-4" /> | |
| Add Output | |
| </motion.button> | |
| )} | |
| </div> | |
| {/* Add Form */} | |
| <AnimatePresence> | |
| {showAddForm && ( | |
| <motion.form | |
| key="add-form" | |
| initial={{ opacity: 0, y: -10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -10 }} | |
| onSubmit={handleAdd} | |
| className="bg-primary/5 dark:bg-primary/10 border border-primary/20 rounded-2xl p-4 space-y-3" | |
| > | |
| <input | |
| value={title} | |
| onChange={e => setTitle(e.target.value)} | |
| placeholder="Output title / filename *" | |
| className="w-full px-3 py-2.5 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm focus:ring-2 focus:ring-primary focus:border-transparent" | |
| autoFocus | |
| /> | |
| <input | |
| value={link} | |
| onChange={e => setLink(e.target.value)} | |
| placeholder="Link (optional)" | |
| type="url" | |
| className="w-full px-3 py-2.5 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm focus:ring-2 focus:ring-primary focus:border-transparent" | |
| /> | |
| <textarea | |
| value={description} | |
| onChange={e => setDescription(e.target.value)} | |
| placeholder="Description (optional)" | |
| rows={2} | |
| className="w-full px-3 py-2.5 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm focus:ring-2 focus:ring-primary focus:border-transparent resize-none" | |
| /> | |
| <div className="flex gap-2"> | |
| <button type="submit" disabled={saving || !title.trim()} | |
| className="px-4 py-2 bg-primary text-white rounded-xl text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition flex items-center gap-2" | |
| > | |
| {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />} | |
| {saving ? "Saving..." : "Add"} | |
| </button> | |
| <button type="button" onClick={() => setShowAddForm(false)} | |
| className="px-4 py-2 border border-gray-200 dark:border-gray-700 rounded-xl text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition" | |
| > | |
| Cancel | |
| </button> | |
| </div> | |
| </motion.form> | |
| )} | |
| </AnimatePresence> | |
| {/* Files List */} | |
| <div className="space-y-2.5"> | |
| <AnimatePresence> | |
| {outputs.length === 0 ? ( | |
| <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center py-16 text-gray-500"> | |
| <FolderOpen className="w-12 h-12 mx-auto mb-3 opacity-30" /> | |
| <p>No files or outputs yet</p> | |
| {isOwner && ( | |
| <button onClick={() => setShowAddForm(true)} className="mt-3 text-sm text-primary hover:underline font-medium"> | |
| + Add the first output | |
| </button> | |
| )} | |
| </motion.div> | |
| ) : ( | |
| outputs.map((output, i) => { | |
| const { Icon, color } = getFileIcon(output.title); | |
| return ( | |
| <motion.div | |
| key={output.id} | |
| layout | |
| initial={{ opacity: 0, x: -20 }} | |
| animate={{ opacity: 1, x: 0 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ delay: i * 0.04 }} | |
| className="group bg-white/70 dark:bg-gray-900/70 backdrop-blur-md rounded-2xl border border-white/50 dark:border-gray-700/50 p-4 hover:border-primary/20 hover:shadow-sm transition-all" | |
| > | |
| <div className="flex items-center gap-4"> | |
| <div className={`w-10 h-10 rounded-xl ${color} flex items-center justify-center flex-shrink-0`}> | |
| <Icon className="w-5 h-5" /> | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <p className="font-semibold text-gray-900 dark:text-white text-sm truncate">{output.title}</p> | |
| {output.description && <p className="text-xs text-gray-500 mt-0.5 truncate">{output.description}</p>} | |
| <p className="text-xs text-gray-400 mt-0.5"> | |
| {output.addedByUsername && <span>by {output.addedByUsername} • </span>} | |
| {formatDistanceToNow(new Date(output.createdAt), { addSuffix: true })} | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-all"> | |
| {output.link && ( | |
| <a | |
| href={output.link} | |
| target="_blank" | |
| rel="noreferrer" | |
| className="p-1.5 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 text-gray-400 hover:text-blue-500 transition" | |
| > | |
| <Download className="w-4 h-4" /> | |
| </a> | |
| )} | |
| {isOwner && ( | |
| <button | |
| onClick={() => handleDelete(output.id)} | |
| className="p-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 text-gray-300 hover:text-red-500 transition" | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </motion.div> | |
| ); | |
| }) | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| </div> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 9.99 kB
- Xet hash:
- 3a327fb27fa9709fc1949c86d62dde3a99d6418d5181b8dc0c4e4f86cb6a1351
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.