Add tablo details page
This commit is contained in:
parent
c88394c650
commit
b31a40415a
7 changed files with 1007 additions and 26 deletions
|
|
@ -6,18 +6,20 @@ interface CustomChannelHeaderProps {
|
|||
tablos: UserTablo[];
|
||||
onToggleChannelList?: () => void;
|
||||
isChannelListExpanded?: boolean;
|
||||
showToggleButton?: boolean;
|
||||
}
|
||||
|
||||
export const CustomChannelHeader = ({
|
||||
tablos,
|
||||
onToggleChannelList,
|
||||
isChannelListExpanded = false,
|
||||
showToggleButton = true,
|
||||
}: CustomChannelHeaderProps) => {
|
||||
const { channel } = useChannelStateContext();
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{onToggleChannelList && (
|
||||
{showToggleButton && onToggleChannelList && (
|
||||
<button
|
||||
onClick={onToggleChannelList}
|
||||
className="mr-2 p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
|
|
|
|||
65
apps/main/src/components/TabloDiscussionSection.tsx
Normal file
65
apps/main/src/components/TabloDiscussionSection.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { CustomChannelHeader } from "@ui/components/CustomChannelHeader";
|
||||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Channel as StreamChannel } from "stream-chat";
|
||||
import { Channel, MessageInput, MessageList, useChatContext, Window } from "stream-chat-react";
|
||||
import ChatProvider from "../providers/ChatProvider";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
|
||||
interface TabloDiscussionSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const TabloChat = ({ tablo }: { tablo: UserTablo }) => {
|
||||
const { client, setActiveChannel } = useChatContext();
|
||||
const [channel, setChannel] = useState<StreamChannel | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initChannel = async () => {
|
||||
if (client && tablo.id) {
|
||||
const newChannel = client.channel("messaging", tablo.id);
|
||||
await newChannel.watch();
|
||||
setChannel(newChannel);
|
||||
setActiveChannel(newChannel);
|
||||
}
|
||||
};
|
||||
|
||||
initChannel();
|
||||
}, [client, tablo.id, setActiveChannel]);
|
||||
|
||||
if (!channel) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Channel channel={channel}>
|
||||
<Window>
|
||||
<CustomChannelHeader tablos={[tablo]} showToggleButton={false} />
|
||||
<MessageList />
|
||||
<MessageInput />
|
||||
</Window>
|
||||
</Channel>
|
||||
);
|
||||
};
|
||||
|
||||
export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground">Discussion</h1>
|
||||
<p className="text-muted-foreground mt-1">Conversations liées à ce tablo</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-card rounded-lg border border-border overflow-hidden min-h-0">
|
||||
<ChatProvider>
|
||||
<TabloChat tablo={tablo} />
|
||||
</ChatProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
498
apps/main/src/components/TabloFilesSection.tsx
Normal file
498
apps/main/src/components/TabloFilesSection.tsx
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { DownloadIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { FileTrigger } from "react-aria-components";
|
||||
import {
|
||||
useCreateTabloFile,
|
||||
useDeleteTabloFile,
|
||||
useDownloadTabloFile,
|
||||
useTabloFileNames,
|
||||
} from "../hooks/tablo_data";
|
||||
|
||||
interface TabloFilesSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => {
|
||||
const {
|
||||
data: fileData,
|
||||
isLoading: filesLoading,
|
||||
error: filesError,
|
||||
} = useTabloFileNames(tablo.id);
|
||||
|
||||
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 [error, setError] = useState("");
|
||||
|
||||
const createFile = useCreateTabloFile();
|
||||
const deleteFile = useDeleteTabloFile();
|
||||
const downloadFile = useDownloadTabloFile();
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Fichiers</h1>
|
||||
<p className="text-muted-foreground mt-1">Gérez les fichiers attachés à ce tablo</p>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="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 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>
|
||||
)}
|
||||
|
||||
{/* File Upload Section - Only for Admins */}
|
||||
{isAdmin && (
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<svg
|
||||
className="w-5 h-5 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>
|
||||
<h3 className="text-lg font-semibold text-foreground">Ajouter un fichier</h3>
|
||||
</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: 20MB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File List */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
Liste des fichiers
|
||||
{fileData?.fileNames && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
({fileData.fileNames.length})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
{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-muted rounded-lg hover:bg-muted/80 transition-colors 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-foreground truncate" title={fileName}>
|
||||
{fileName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground 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>
|
||||
);
|
||||
};
|
||||
254
apps/main/src/components/TabloSettingsSection.tsx
Normal file
254
apps/main/src/components/TabloSettingsSection.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { useTabloMembers } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
import { ClickOutside } from "./ClickOutside";
|
||||
import { ImageColorPicker } from "./ImageColorPicker";
|
||||
import { StatusPicker } from "./StatusPicker";
|
||||
|
||||
type StatusType = "todo" | "in_progress" | "done";
|
||||
|
||||
interface TabloSettingsSectionProps {
|
||||
tablo: UserTablo;
|
||||
isAdmin: boolean;
|
||||
onEdit: (updatedTablo: TabloUpdate & { id: string }) => void;
|
||||
}
|
||||
|
||||
export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSectionProps) => {
|
||||
const currentUser = useUser();
|
||||
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 { data: members } = useTabloMembers(tablo.id);
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setEditData(tablo);
|
||||
setSelectedColor(tablo.color || "bg-blue-500");
|
||||
}, [tablo]);
|
||||
|
||||
// Auto-focus name input when editing
|
||||
useEffect(() => {
|
||||
if (isEditingName && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
nameInputRef.current.select();
|
||||
}
|
||||
}, [isEditingName]);
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (editData && onEdit) {
|
||||
const updatedTablo: TabloUpdate & { id: string } = {
|
||||
id: editData.id,
|
||||
name: editData.name,
|
||||
color: creationMode === "color" ? selectedColor : null,
|
||||
status: editData.status,
|
||||
};
|
||||
onEdit(updatedTablo);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendInvite = () => {
|
||||
if (inviteEmail.trim()) {
|
||||
inviteUser({ email: inviteEmail, tablo_id: tablo.id });
|
||||
setInviteEmail("");
|
||||
}
|
||||
};
|
||||
|
||||
const isEmailValid = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const currentData = editData || tablo;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Paramètres</h1>
|
||||
<p className="text-muted-foreground mt-1">Configurez votre tablo et gérez les accès</p>
|
||||
</div>
|
||||
|
||||
{!isAdmin ? (
|
||||
/* Read-only view for non-admins */
|
||||
<div className="space-y-6">
|
||||
{/* Tablo Preview */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Aperçu</h3>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Status Display */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Statut</h3>
|
||||
<div className="text-foreground">
|
||||
{currentData.status === "todo" && "À faire"}
|
||||
{currentData.status === "in_progress" && "En cours"}
|
||||
{currentData.status === "done" && "Terminé"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Level */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Votre rôle</h3>
|
||||
<div className="text-foreground">{tablo.is_admin ? "Administrateur" : "Invité"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Editable view for admins */
|
||||
<div className="space-y-6">
|
||||
{/* Name Edit */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Nom du tablo</h3>
|
||||
{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="w-full px-3 py-2 text-lg font-medium text-foreground bg-transparent border-b-2 border-primary focus:outline-none focus:border-primary"
|
||||
placeholder="Nom du tablo"
|
||||
/>
|
||||
</ClickOutside>
|
||||
) : (
|
||||
<div
|
||||
className="text-lg font-medium text-foreground cursor-text hover:text-primary transition-colors"
|
||||
onClick={() => setIsEditingName(true)}
|
||||
>
|
||||
{tablo.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color/Image Picker */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Apparence</h3>
|
||||
<ImageColorPicker
|
||||
creationMode={creationMode}
|
||||
setCreationMode={setCreationMode}
|
||||
selectedColor={selectedColor}
|
||||
setSelectedColor={setSelectedColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Picker */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">Statut</h3>
|
||||
<StatusPicker
|
||||
selectedStatus={currentData.status as StatusType}
|
||||
setSelectedStatus={(status) =>
|
||||
setEditData((prev) => (prev ? { ...prev, status } : null))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Invite User Section */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">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-input rounded-md shadow-sm placeholder-muted-foreground focus:outline-none focus:ring-primary focus:border-primary bg-background text-foreground"
|
||||
/>
|
||||
{isInvitingUser ? (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSendInvite}
|
||||
disabled={!isEmailValid(inviteEmail)}
|
||||
>
|
||||
Inviter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveEdit} className="px-6">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Sauvegarder les modifications
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members List */}
|
||||
<div className="bg-card rounded-lg border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">
|
||||
Membres
|
||||
{members && (
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
({members.length})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{members && members.length > 0 ? (
|
||||
members.map((member, index) => (
|
||||
<div key={index} className="flex items-center space-x-3 p-3 bg-muted rounded-lg">
|
||||
<div className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-primary-foreground text-sm font-medium">
|
||||
{member.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-foreground">{member.name}</span>
|
||||
{member.is_admin ? (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{member.id === currentUser?.id ? "(Vous, Admin)" : "(Admin)"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{member.id === currentUser?.id ? "(Vous, Invité)" : "(Invité)"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Aucun membre trouvé</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -20,6 +20,7 @@ import { ResetPasswordPage } from "../pages/reset-password";
|
|||
import SettingsPage from "../pages/settings";
|
||||
import { SignUpPage } from "../pages/signup";
|
||||
import { TabloPage } from "../pages/tablo";
|
||||
import { TabloDetailsPage } from "../pages/tablo-details";
|
||||
import ChatProvider from "../providers/ChatProvider";
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
|
|
@ -28,6 +29,10 @@ export const routes: RouteObject[] = [
|
|||
path: "/",
|
||||
element: <ProtectedRoute fallback="/login" />,
|
||||
children: [
|
||||
{
|
||||
path: "tablos/:tabloId",
|
||||
element: <TabloDetailsPage />,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
element: <Layout />,
|
||||
|
|
|
|||
180
apps/main/src/pages/tablo-details.tsx
Normal file
180
apps/main/src/pages/tablo-details.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { toast } from "@xtablo/shared";
|
||||
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import { ArrowLeft, FileText, MessageSquare, Settings } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { LoadingSpinner } from "../components/LoadingSpinner";
|
||||
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
|
||||
import { TabloFilesSection } from "../components/TabloFilesSection";
|
||||
import { TabloSettingsSection } from "../components/TabloSettingsSection";
|
||||
import { useTablosList, useUpdateTablo } from "../hooks/tablos";
|
||||
|
||||
type TabSection = "files" | "discussion" | "settings";
|
||||
|
||||
export const TabloDetailsPage = () => {
|
||||
const { tabloId } = useParams<{ tabloId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: tablos, isLoading } = useTablosList();
|
||||
const { mutateAsync: updateTablo } = useUpdateTablo();
|
||||
|
||||
const [activeSection, setActiveSection] = useState<TabSection>("files");
|
||||
const [tablo, setTablo] = useState<UserTablo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tablos && tabloId) {
|
||||
const foundTablo = tablos.find((t) => t.id === tabloId);
|
||||
if (foundTablo) {
|
||||
setTablo(foundTablo);
|
||||
} else {
|
||||
// Tablo not found, redirect back
|
||||
toast.add(
|
||||
{
|
||||
title: "Tablo introuvable",
|
||||
description: "Le tablo demandé n'existe pas ou vous n'y avez pas accès",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
navigate("/tablo");
|
||||
}
|
||||
}
|
||||
}, [tablos, tabloId, navigate]);
|
||||
|
||||
const handleEdit = async (updatedTablo: TabloUpdate & { id: string }) => {
|
||||
try {
|
||||
await updateTablo(updatedTablo);
|
||||
toast.add(
|
||||
{
|
||||
title: "Tablo mis à jour",
|
||||
description: "Les modifications ont été enregistrées",
|
||||
type: "success",
|
||||
},
|
||||
{ timeout: 3000 }
|
||||
);
|
||||
} catch (error) {
|
||||
toast.add(
|
||||
{
|
||||
title: "Erreur",
|
||||
description: "Impossible de mettre à jour le tablo",
|
||||
type: "error",
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tablo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAdmin = tablo.is_admin;
|
||||
|
||||
const navigationItems: Array<{
|
||||
id: TabSection;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}> = [
|
||||
{
|
||||
id: "files",
|
||||
label: "Fichiers",
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
id: "discussion",
|
||||
label: "Discussion",
|
||||
icon: <MessageSquare className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Paramètres",
|
||||
icon: <Settings className="w-5 h-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Left Sidebar Navigation */}
|
||||
<aside className="w-64 border-r border-border bg-card flex flex-col">
|
||||
{/* Header with back button */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/tablo")}
|
||||
className="mb-4 w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Retour aux tablos
|
||||
</Button>
|
||||
|
||||
{/* Tablo preview */}
|
||||
<div className="flex items-center space-x-3">
|
||||
{tablo.image ? (
|
||||
<img
|
||||
src={tablo.image}
|
||||
alt={tablo.name}
|
||||
className="w-12 h-12 rounded-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`w-12 h-12 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 className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-bold text-foreground truncate">{tablo.name}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isAdmin ? "Administrateur" : "Invité"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation items */}
|
||||
<nav className="flex-1 p-4 space-y-1">
|
||||
{navigationItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveSection(item.id)}
|
||||
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
|
||||
activeSection === item.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="font-medium">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="max-w-5xl mx-auto p-6 h-full">
|
||||
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
|
||||
{activeSection === "discussion" && (
|
||||
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
|
||||
)}
|
||||
{activeSection === "settings" && (
|
||||
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import { CreateTabloModal } from "@ui/components/CreateTabloModal";
|
||||
import { DeleteTabloModal } from "@ui/components/DeleteTabloModal";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { TabloModal } from "@ui/components/TabloModal";
|
||||
import { TabloTutorial } from "@ui/components/TabloTutorial";
|
||||
import { TabloInsert, TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { TabloInsert, UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
Empty,
|
||||
|
|
@ -57,7 +56,6 @@ export const TabloPage = () => {
|
|||
y: number;
|
||||
} | null>(null);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [viewingTablo, setViewingTablo] = useState<UserTablo | null>(null);
|
||||
const [deletingTablo, setDeletingTablo] = useState<UserTablo | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [filterType, setFilterType] = useState<"all" | "todo" | "in_progress" | "done">("all");
|
||||
|
|
@ -143,15 +141,7 @@ export const TabloPage = () => {
|
|||
};
|
||||
|
||||
const openTablo = (tabloId: string) => {
|
||||
if (!tablos) return;
|
||||
const tablo = tablos.find((t) => t.id === tabloId);
|
||||
if (tablo) {
|
||||
setViewingTablo(tablo);
|
||||
}
|
||||
};
|
||||
|
||||
const closeTabloModal = () => {
|
||||
setViewingTablo(null);
|
||||
navigate(`/tablos/${tabloId}`);
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
|
|
@ -193,14 +183,6 @@ export const TabloPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const onEditTablo = (tablo: TabloUpdate & { id: string }) => {
|
||||
updateTablo(tablo, {
|
||||
onSuccess: () => {
|
||||
closeTabloModal();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteTablo = (tabloId: string) => {
|
||||
if (!tablos) return;
|
||||
const tablo = tablos.find((t) => t.id === tabloId);
|
||||
|
|
@ -921,11 +903,6 @@ export const TabloPage = () => {
|
|||
<CreateTabloModal onClose={closeCreateModal} onCreate={createNewTablo} />
|
||||
)}
|
||||
|
||||
{/* Tablo Details Modal */}
|
||||
{!!viewingTablo && (
|
||||
<TabloModal tablo={viewingTablo} onEdit={onEditTablo} onClose={closeTabloModal} />
|
||||
)}
|
||||
|
||||
{/* Delete Tablo Modal */}
|
||||
{!!deletingTablo && (
|
||||
<DeleteTabloModal
|
||||
|
|
|
|||
Loading…
Reference in a new issue