911 lines
39 KiB
TypeScript
911 lines
39 KiB
TypeScript
import { Button } from "@ui/components/ui/button";
|
|
import { useInviteUser } from "@ui/hooks/invite";
|
|
import {
|
|
useCreateTabloFile,
|
|
useDeleteTabloFile,
|
|
useDownloadTabloFile,
|
|
useTabloFileNames,
|
|
} from "@ui/hooks/tablo_data";
|
|
import { useTabloMembers } from "@ui/hooks/tablos";
|
|
import { toast } from "@ui/lib/toast";
|
|
import { useUser } from "@ui/providers/UserStoreProvider";
|
|
import { TabloUpdate, UserTablo } from "@ui/types/tablos.types";
|
|
import { FileTrigger } from "@ui/ui-library/file-trigger";
|
|
import { DownloadIcon, Trash2Icon } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { ClickOutside } from "./ClickOutside";
|
|
import { ImageColorPicker } from "./ImageColorPicker";
|
|
import { StatusPicker } from "./StatusPicker";
|
|
|
|
type StatusType = "todo" | "in_progress" | "done";
|
|
|
|
interface TabloModalProps {
|
|
tablo: UserTablo | null;
|
|
onEdit: (updatedTablo: TabloUpdate & { id: string }) => void;
|
|
onClose: () => void;
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
|
|
const currentUser = useUser();
|
|
const isAdmin = tablo?.is_admin ?? false;
|
|
|
|
const [editData, setEditData] = useState<UserTablo | null>(tablo);
|
|
const [isEditingName, setIsEditingName] = useState(false);
|
|
|
|
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
|
|
const [selectedColor, setSelectedColor] = useState(tablo?.color || "bg-blue-500");
|
|
const [error, setError] = useState("");
|
|
const { data: members } = useTabloMembers(tablo?.id ?? "");
|
|
const [showMembers, setShowMembers] = useState(false);
|
|
|
|
const [inviteEmail, setInviteEmail] = useState("");
|
|
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
|
|
|
const {
|
|
data: fileData,
|
|
isLoading: filesLoading,
|
|
error: filesError,
|
|
} = useTabloFileNames(tablo?.id ?? "");
|
|
const [showFiles, setShowFiles] = useState(false);
|
|
|
|
// File upload state
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [deletingFile, setDeletingFile] = useState<string | null>(null);
|
|
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
|
const createFile = useCreateTabloFile();
|
|
const deleteFile = useDeleteTabloFile();
|
|
const downloadFile = useDownloadTabloFile();
|
|
|
|
const handleSaveEdit = () => {
|
|
if (editData && onEdit) {
|
|
// Clear the unused field based on selection
|
|
const updatedTablo: TabloUpdate & { id: string } = {
|
|
id: editData.id,
|
|
name: editData.name,
|
|
//TODO: image: creationMode === "image" ? editData.image : null,
|
|
color: creationMode === "color" ? selectedColor : null,
|
|
status: editData.status,
|
|
};
|
|
onEdit(updatedTablo);
|
|
}
|
|
};
|
|
|
|
const handleSendInvite = () => {
|
|
if (inviteEmail.trim()) {
|
|
inviteUser({ email: inviteEmail, tablo_id: tablo?.id ?? "" });
|
|
}
|
|
};
|
|
|
|
const isEmailValid = (email: string): boolean => {
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
return emailRegex.test(email);
|
|
};
|
|
|
|
const handleFileSelect = (files: FileList | null) => {
|
|
const file = files?.[0];
|
|
if (!file) return;
|
|
|
|
// Validate file size (20MB limit)
|
|
const maxSize = 20 * 1024 * 1024; // 20MB in bytes
|
|
if (file.size > maxSize) {
|
|
setError("Le fichier ne peut pas dépasser 20MB");
|
|
return;
|
|
}
|
|
|
|
setError("");
|
|
setSelectedFile(file);
|
|
};
|
|
|
|
const handleFileUpload = async () => {
|
|
if (!selectedFile || !tablo?.id) return;
|
|
|
|
setIsUploading(true);
|
|
try {
|
|
const reader = new FileReader();
|
|
reader.onload = async (e) => {
|
|
try {
|
|
const content = e.target?.result as string;
|
|
|
|
await createFile.mutateAsync({
|
|
tabloId: tablo.id,
|
|
fileName: selectedFile.name,
|
|
data: {
|
|
content,
|
|
contentType: selectedFile.type || "application/octet-stream",
|
|
},
|
|
});
|
|
|
|
// Reset upload state
|
|
setSelectedFile(null);
|
|
setIsUploading(false);
|
|
} catch (uploadError) {
|
|
setIsUploading(false);
|
|
console.error("Upload error:", uploadError);
|
|
}
|
|
};
|
|
|
|
reader.onerror = () => {
|
|
setIsUploading(false);
|
|
toast.add(
|
|
{
|
|
title: "Erreur de lecture",
|
|
description: "Impossible de lire le fichier sélectionné",
|
|
type: "error",
|
|
},
|
|
{
|
|
timeout: 5000,
|
|
}
|
|
);
|
|
};
|
|
|
|
// Read file as base64 data URL for binary files, or as text for text files
|
|
if (selectedFile.type.startsWith("text/") || selectedFile.type === "application/json") {
|
|
reader.readAsText(selectedFile);
|
|
} else {
|
|
reader.readAsDataURL(selectedFile);
|
|
}
|
|
} catch (error) {
|
|
setIsUploading(false);
|
|
console.error("Upload error:", error);
|
|
}
|
|
};
|
|
|
|
const cancelFileUpload = () => {
|
|
setSelectedFile(null);
|
|
};
|
|
|
|
const handleDeleteFile = async (fileName: string) => {
|
|
if (!tablo?.id) return;
|
|
|
|
// Simple confirmation
|
|
if (!window.confirm(`Êtes-vous sûr de vouloir supprimer le fichier "${fileName}" ?`)) {
|
|
return;
|
|
}
|
|
|
|
setDeletingFile(fileName);
|
|
try {
|
|
await deleteFile.mutateAsync({
|
|
tabloId: tablo.id,
|
|
fileName,
|
|
});
|
|
} catch (error) {
|
|
console.error("Delete error:", error);
|
|
} finally {
|
|
setDeletingFile(null);
|
|
}
|
|
};
|
|
|
|
const handleDownloadFile = async (fileName: string) => {
|
|
if (!tablo?.id) return;
|
|
|
|
setDownloadingFile(fileName);
|
|
try {
|
|
await downloadFile.mutateAsync({
|
|
tabloId: tablo.id,
|
|
fileName,
|
|
});
|
|
} catch (error) {
|
|
console.error("Download error:", error);
|
|
} finally {
|
|
setDownloadingFile(null);
|
|
}
|
|
};
|
|
|
|
if (!tablo) return null;
|
|
|
|
const currentData = editData || tablo;
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
onClose();
|
|
}
|
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && isAdmin) {
|
|
handleSaveEdit();
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [onClose, handleSaveEdit, isAdmin]);
|
|
|
|
// Auto-focus name input when editing
|
|
const nameInputRef = useRef<HTMLInputElement>(null);
|
|
useEffect(() => {
|
|
if (isEditingName && nameInputRef.current) {
|
|
nameInputRef.current.focus();
|
|
nameInputRef.current.select();
|
|
}
|
|
}, [isEditingName]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
|
<ClickOutside onClickOutside={onClose}>
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full min-w-[32rem] max-w-2xl max-h-[95vh] flex flex-col overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
<div className="flex items-center space-x-3 flex-1">
|
|
{/* Tablo Color/Image Preview */}
|
|
<div className="flex-shrink-0">
|
|
{tablo.image ? (
|
|
<img
|
|
src={tablo.image}
|
|
alt={tablo.name}
|
|
className="w-10 h-10 rounded-lg object-cover"
|
|
/>
|
|
) : (
|
|
<div
|
|
className={`w-10 h-10 rounded-lg ${
|
|
tablo.color || "bg-blue-500"
|
|
} flex items-center justify-center`}
|
|
>
|
|
<span className="text-white font-bold text-sm">
|
|
{tablo.name.charAt(0).toUpperCase()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<div className="flex-1 min-w-0">
|
|
{isAdmin && isEditingName ? (
|
|
<ClickOutside onClickOutside={() => setIsEditingName(false)}>
|
|
<input
|
|
ref={nameInputRef}
|
|
type="text"
|
|
value={editData?.name}
|
|
onChange={(e) =>
|
|
setEditData((prev) => (prev ? { ...prev, name: e.target.value } : null))
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
setIsEditingName(false);
|
|
}
|
|
}}
|
|
className="text-xl font-bold text-gray-900 dark:text-white bg-transparent border-b-2 border-blue-500 focus:outline-none focus:border-blue-600 w-full"
|
|
placeholder="Nom du tablo"
|
|
/>
|
|
</ClickOutside>
|
|
) : (
|
|
<div>
|
|
<h2
|
|
className={`text-xl font-bold text-gray-900 dark:text-white truncate ${
|
|
isAdmin
|
|
? "cursor-text hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
|
: ""
|
|
}`}
|
|
onClick={isAdmin ? () => setIsEditingName(true) : undefined}
|
|
title={tablo.name}
|
|
>
|
|
{tablo.name}
|
|
</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
{isAdmin ? "Administrateur" : "Invité"} •{" "}
|
|
{currentData.status === "todo" && "À faire"}
|
|
{currentData.status === "in_progress" && "En cours"}
|
|
{currentData.status === "done" && "Terminé"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Close Button */}
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
|
title="Fermer (Échap)"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error Banner */}
|
|
{error && (
|
|
<div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center space-x-2">
|
|
<svg
|
|
className="w-5 h-5 text-red-500 flex-shrink-0"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<span className="text-red-700 dark:text-red-300 text-sm">{error}</span>
|
|
<button
|
|
onClick={() => setError("")}
|
|
className="ml-auto text-red-500 hover:text-red-700 dark:hover:text-red-300"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M6 18L18 6M6 6l12 12"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content - Expandable */}
|
|
<div className="flex-grow px-6 py-4 overflow-y-auto space-y-6">
|
|
{!isAdmin ? (
|
|
/* Read-only content */
|
|
<div className="space-y-4 mb-4">
|
|
{/* Tablo Preview */}
|
|
<div className="relative h-48 rounded-lg overflow-hidden">
|
|
{tablo.image ? (
|
|
<img
|
|
src={tablo.image}
|
|
alt={tablo.name}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
<div
|
|
className={`w-full h-full ${
|
|
tablo.color || "bg-gray-400"
|
|
} flex items-center justify-center`}
|
|
>
|
|
<h3 className="text-white font-bold text-2xl text-center px-4">
|
|
{tablo.name}
|
|
</h3>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status Display */}
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Statut
|
|
</label>
|
|
<div className="text-gray-900 dark:text-white">
|
|
{currentData.status === "todo" && "À faire"}
|
|
{currentData.status === "in_progress" && "En cours"}
|
|
{currentData.status === "done" && "Terminé"}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Access Level */}
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Votre rôle
|
|
</label>
|
|
<div className="text-gray-900 dark:text-white">
|
|
{tablo.is_admin ? "Administrateur" : "Invité"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Editable content */
|
|
<>
|
|
<ImageColorPicker
|
|
creationMode={creationMode}
|
|
setCreationMode={setCreationMode}
|
|
selectedColor={selectedColor}
|
|
setSelectedColor={setSelectedColor}
|
|
/>
|
|
|
|
{/* Details */}
|
|
<div className="space-y-4 mb-4">
|
|
<div>
|
|
<StatusPicker
|
|
selectedStatus={currentData.status as StatusType}
|
|
setSelectedStatus={(status) =>
|
|
setEditData((prev) => (prev ? { ...prev, status } : null))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{/* Invite User Section */}
|
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3">
|
|
Inviter un utilisateur
|
|
</h3>
|
|
<div className="flex space-x-2">
|
|
<input
|
|
type="email"
|
|
value={inviteEmail}
|
|
onChange={(e) => setInviteEmail(e.target.value)}
|
|
placeholder="Email de l'utilisateur à inviter"
|
|
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
|
/>
|
|
{isInvitingUser ? (
|
|
<div className="flex justify-center items-center">
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={handleSendInvite}
|
|
disabled={!isEmailValid(inviteEmail)}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-md transition-colors"
|
|
>
|
|
Inviter
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Files Section */}
|
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center space-x-2">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Fichiers</h3>
|
|
{fileData?.fileNames && (
|
|
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium px-2 py-1 rounded-full">
|
|
{fileData.fileNames.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowFiles(!showFiles)}
|
|
className="flex items-center space-x-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-colors"
|
|
>
|
|
<span>{showFiles ? "Masquer" : "Afficher"}</span>
|
|
<svg
|
|
className={`w-4 h-4 transition-transform ${showFiles ? "rotate-180" : ""}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{showFiles && (
|
|
<div className="space-y-4">
|
|
{/* File Upload Section - Only for Admins */}
|
|
{isAdmin && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-4">
|
|
<div className="flex items-center space-x-2 mb-3">
|
|
<svg
|
|
className="w-4 h-4 text-green-600 dark:text-green-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
|
/>
|
|
</svg>
|
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
|
|
Ajouter un fichier
|
|
</h4>
|
|
</div>
|
|
|
|
{!selectedFile ? (
|
|
<div className="space-y-3">
|
|
<FileTrigger allowsMultiple={false} onSelect={handleFileSelect}>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-center py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 bg-gray-50 dark:bg-gray-800/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
|
>
|
|
<div className="flex flex-col items-center space-y-2">
|
|
<svg
|
|
className="w-8 h-8 text-gray-400 dark:text-gray-500"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
/>
|
|
</svg>
|
|
<div className="text-center">
|
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Cliquez pour sélectionner un fichier
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</Button>
|
|
</FileTrigger>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center space-x-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
|
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
|
{selectedFile.name}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex space-x-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleFileUpload}
|
|
disabled={isUploading}
|
|
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center justify-center space-x-2 shadow-sm"
|
|
>
|
|
{isUploading ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
<span>Ajout en cours...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
/>
|
|
</svg>
|
|
<span>Ajouter le fichier</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={cancelFileUpload}
|
|
disabled={isUploading}
|
|
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
|
|
>
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
|
Taille maximale: 2MB
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* File List */}
|
|
<div>
|
|
{filesLoading ? (
|
|
<div className="flex items-center justify-center p-8">
|
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
|
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
|
|
Chargement des fichiers...
|
|
</span>
|
|
</div>
|
|
) : filesError ? (
|
|
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
|
<div className="flex items-center space-x-2">
|
|
<svg
|
|
className="w-5 h-5 text-red-500"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
</svg>
|
|
<span className="text-sm text-red-700 dark:text-red-300">
|
|
Erreur lors du chargement des fichiers
|
|
</span>
|
|
</div>
|
|
</div>
|
|
) : fileData && fileData.fileNames && fileData.fileNames.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{fileData.fileNames.map((fileName, index) => {
|
|
const fileExtension = fileName.split(".").pop()?.toLowerCase() || "";
|
|
const isImage = ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(
|
|
fileExtension
|
|
);
|
|
const isPdf = fileExtension === "pdf";
|
|
const isText = ["txt", "md", "json", "csv"].includes(fileExtension);
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className="flex items-center space-x-3 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-sm transition-shadow group"
|
|
>
|
|
<button
|
|
onClick={() => handleDownloadFile(fileName)}
|
|
disabled={downloadingFile === fileName}
|
|
className={`w-10 h-10 rounded-lg flex items-center justify-center text-white text-sm font-medium transition-all hover:scale-105 ${
|
|
isImage
|
|
? "bg-purple-500 hover:bg-purple-600"
|
|
: isPdf
|
|
? "bg-red-500 hover:bg-red-600"
|
|
: isText
|
|
? "bg-blue-500 hover:bg-blue-600"
|
|
: "bg-gray-500 hover:bg-gray-600"
|
|
} ${
|
|
downloadingFile === fileName
|
|
? "opacity-50 cursor-not-allowed"
|
|
: "cursor-pointer"
|
|
}`}
|
|
title={`Télécharger ${fileName}`}
|
|
>
|
|
{downloadingFile === fileName ? (
|
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
|
) : isImage ? (
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
) : isPdf ? (
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
className="w-5 h-5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<p
|
|
className="text-sm font-medium text-gray-900 dark:text-white truncate"
|
|
title={fileName}
|
|
>
|
|
{fileName}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase">
|
|
{fileExtension || "Fichier"}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center space-x-1">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleDownloadFile(fileName)}
|
|
disabled={downloadingFile === fileName}
|
|
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20 transition-colors"
|
|
aria-label={`Télécharger ${fileName}`}
|
|
>
|
|
{downloadingFile === fileName ? (
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
|
|
) : (
|
|
<DownloadIcon className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
{isAdmin && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleDeleteFile(fileName)}
|
|
disabled={deletingFile === fileName}
|
|
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20 transition-colors"
|
|
aria-label={`Supprimer ${fileName}`}
|
|
>
|
|
{deletingFile === fileName ? (
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-500"></div>
|
|
) : (
|
|
<Trash2Icon className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8">
|
|
<svg
|
|
className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"
|
|
/>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M8 5a2 2 0 012-2h2a2 2 0 012 2v0H8v0z"
|
|
/>
|
|
</svg>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
Aucun fichier dans ce tablo
|
|
</p>
|
|
{isAdmin && (
|
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
|
Ajoutez votre premier fichier ci-dessus
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Members Section */}
|
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center space-x-2">
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Membres</h3>
|
|
{members && (
|
|
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium px-2 py-1 rounded-full">
|
|
{members.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowMembers(!showMembers)}
|
|
className="flex items-center space-x-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-colors"
|
|
>
|
|
<span>{showMembers ? "Masquer" : "Afficher"}</span>
|
|
<svg
|
|
className={`w-4 h-4 transition-transform ${showMembers ? "rotate-180" : ""}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{showMembers && (
|
|
<div className="space-y-2">
|
|
{members && members.length > 0 ? (
|
|
members.map((member, index) => (
|
|
<div
|
|
key={index}
|
|
className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded-md"
|
|
>
|
|
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
|
|
{member.name.charAt(0).toUpperCase()}
|
|
</div>
|
|
<span className="text-sm text-gray-900 dark:text-white">{member.name}</span>
|
|
{member.is_admin ? (
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
{member.id === currentUser?.id ? "(Vous, Admin)" : "(Admin)"}
|
|
</span>
|
|
) : (
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
{member.id === currentUser?.id ? "(Vous, Invité)" : "(Invité)"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">Aucun membre trouvé</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 bg-gray-50 dark:bg-gray-900/50">
|
|
<div className="flex space-x-3 ml-auto">
|
|
{!isAdmin ? (
|
|
<button
|
|
type="button"
|
|
className="px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
|
onClick={onClose}
|
|
>
|
|
Fermer
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
|
onClick={onClose}
|
|
>
|
|
Annuler
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="px-6 py-2.5 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg shadow-sm transition-colors flex items-center space-x-2"
|
|
onClick={handleSaveEdit}
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M5 13l4 4L19 7"
|
|
/>
|
|
</svg>
|
|
<span>Sauvegarder</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ClickOutside>
|
|
</div>
|
|
);
|
|
};
|