tahamajs/IE / Projects /front /src /components /project /ProjectFiles.tsx
tahamajs's picture
download
raw
9.99 kB
// 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.