Improve a lot the tablo page

This commit is contained in:
Arthur Belleville 2025-06-28 17:27:51 +02:00
parent 4e0038d899
commit 44d837496f
No known key found for this signature in database
8 changed files with 921 additions and 656 deletions

View file

@ -0,0 +1,101 @@
# Click Outside Components
This module provides two ways to detect clicks outside of elements: a hook and a wrapper component.
## `useClickOutside` Hook
A React hook that detects clicks outside of a referenced element.
### Usage
```tsx
import { useClickOutside } from "../hooks/useClickOutside";
function MyComponent() {
const [isOpen, setIsOpen] = useState(false);
const ref = useClickOutside<HTMLDivElement>(() => setIsOpen(false));
return (
<div ref={ref}>{/* Content that will close when clicking outside */}</div>
);
}
```
### Parameters
- `callback: () => void` - Function to call when clicking outside
### Returns
- `ref` - React ref to attach to the element you want to monitor
## `ClickOutside` Component
A wrapper component that handles click outside detection for its children.
### Usage
```tsx
import { ClickOutside } from "./ClickOutside";
function MyComponent() {
const [isOpen, setIsOpen] = useState(false);
return (
<ClickOutside onClickOutside={() => setIsOpen(false)}>
<div>{/* Content that will close when clicking outside */}</div>
</ClickOutside>
);
}
```
### Props
- `children: React.ReactNode` - The content to wrap
- `onClickOutside: () => void` - Function to call when clicking outside
- `className?: string` - Optional className for the wrapper div
- `disabled?: boolean` - Disable click outside detection (default: false)
## Common Use Cases
### Modal Dialog
```tsx
<div className="fixed inset-0 bg-black/50 flex items-center justify-center">
<ClickOutside onClickOutside={closeModal}>
<div className="bg-white rounded-lg p-6">Modal content</div>
</ClickOutside>
</div>
```
### Dropdown Menu
```tsx
<div className="relative">
<button onClick={toggleMenu}>Menu</button>
{isOpen && (
<ClickOutside
onClickOutside={closeMenu}
className="absolute top-full left-0"
>
<div className="bg-white border rounded shadow">Menu items</div>
</ClickOutside>
)}
</div>
```
### Popover/Tooltip
```tsx
<ClickOutside onClickOutside={closePopover}>
<div className="bg-white border rounded shadow p-4">Popover content</div>
</ClickOutside>
```
## Notes
- The hook uses `mousedown` events for detection
- Event listeners are properly cleaned up when the component unmounts
- The wrapper component adds a single `div` element
- Use the `disabled` prop to temporarily disable click outside detection
- TypeScript generic support for proper ref typing

View file

@ -0,0 +1,33 @@
import React from "react";
import { useClickOutside } from "../hooks/useClickOutside";
interface ClickOutsideProps {
children: React.ReactNode;
onClickOutside: () => void;
className?: string;
disabled?: boolean;
}
/**
* Component that wraps children and detects clicks outside
* @param children - The content to wrap
* @param onClickOutside - Function to call when clicking outside
* @param className - Optional className for the wrapper
* @param disabled - Disable click outside detection
*/
export const ClickOutside: React.FC<ClickOutsideProps> = ({
children,
onClickOutside,
className,
disabled = false,
}) => {
const ref = useClickOutside<HTMLDivElement>(
disabled ? () => {} : onClickOutside
);
return (
<div ref={ref} className={className}>
{children}
</div>
);
};

View file

@ -0,0 +1,121 @@
import { useState } from "react";
import { ImageColorPicker } from "./ImageColorPicker";
import { ClickOutside } from "./ClickOutside";
import { StatusPicker } from "./StatusPicker";
interface Tablo {
id: number;
name: string;
image?: string;
color?: string;
status: "todo" | "in_progress" | "done";
}
interface CreateTabloModalProps {
onClose: () => void;
onCreate: (tabloData: Omit<Tablo, "id">) => void;
}
export const CreateTabloModal = ({
onClose,
onCreate,
}: CreateTabloModalProps) => {
const [newTabloName, setNewTabloName] = useState("");
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
const [selectedImage, setSelectedImage] = useState(
"https://images.unsplash.com/photo-1553877522-43269d4ea984?w=400&h=250&fit=crop&crop=center"
);
const [selectedColor, setSelectedColor] = useState("bg-blue-500");
const [selectedStatus, setSelectedStatus] = useState<
"todo" | "in_progress" | "done"
>("todo");
const resetForm = () => {
setNewTabloName("");
setCreationMode("color");
setSelectedImage(
"https://images.unsplash.com/photo-1553877522-43269d4ea984?w=400&h=250&fit=crop&crop=center"
);
setSelectedColor("bg-blue-500");
setSelectedStatus("todo");
};
const handleClose = () => {
resetForm();
onClose();
};
const handleCreate = () => {
if (newTabloName.trim()) {
const tabloData = {
name: newTabloName.trim(),
status: selectedStatus,
...(creationMode === "image"
? { image: selectedImage }
: { color: selectedColor }),
};
onCreate(tabloData);
resetForm();
}
};
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50">
<ClickOutside onClickOutside={handleClose}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 w-full max-w-4xl min-w-96 mx-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Créer un nouveau tablo
</h2>
<div className="space-y-4">
{/* Name Input */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Nom du tablo
</label>
<input
type="text"
value={newTabloName}
onChange={(e) => setNewTabloName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Entrez le nom du tablo"
autoFocus
/>
</div>
<StatusPicker
selectedStatus={selectedStatus}
setSelectedStatus={setSelectedStatus}
/>
<ImageColorPicker
creationMode={creationMode}
setCreationMode={setCreationMode}
selectedColor={selectedColor}
setSelectedColor={setSelectedColor}
/>
</div>
{/* Modal Actions */}
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md"
onClick={handleClose}
>
Annuler
</button>
<button
type="button"
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleCreate}
disabled={!newTabloName.trim() || creationMode === "image"}
>
Créer
</button>
</div>
</div>
</ClickOutside>
</div>
);
};

View file

@ -0,0 +1,150 @@
interface ImageColorPickerProps {
creationMode: "image" | "color";
setCreationMode: (mode: "image" | "color") => void;
selectedColor: string;
setSelectedColor: (color: string) => void;
}
const AVAILABLE_COLORS = [
"bg-blue-500",
"bg-green-500",
"bg-purple-500",
"bg-red-500",
"bg-yellow-500",
"bg-indigo-500",
"bg-pink-500",
"bg-teal-500",
"bg-orange-500",
"bg-cyan-500",
];
export const ImageColorPicker = ({
creationMode,
setCreationMode,
selectedColor,
setSelectedColor,
}: ImageColorPickerProps) => {
return (
<div className="my-4 space-y-4">
{/* Mode Toggle */}
<div>
<label className="block text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
Style
</label>
<div className="flex rounded-md border border-gray-300 dark:border-gray-600 overflow-hidden">
<button
type="button"
className={`flex-1 px-4 py-2 text-sm font-medium ${
creationMode === "image"
? "bg-blue-600 text-white"
: "bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
} transition-colors`}
onClick={() => setCreationMode("image")}
>
Image (Bientôt disponible)
</button>
<button
type="button"
className={`flex-1 px-4 py-2 text-sm font-medium ${
creationMode === "color"
? "bg-blue-600 text-white"
: "bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600"
} transition-colors`}
onClick={() => setCreationMode("color")}
>
Couleur
</button>
</div>
</div>
{/* Image Mode */}
{creationMode === "image" && (
<div className="space-y-4">
{/* File Upload - Coming Soon */}
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 border border-dashed border-gray-300 dark:border-gray-600">
<div className="text-center">
<svg
className="mx-auto h-8 w-8 text-gray-400"
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>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
<span className="font-medium">Import d&apos;images</span>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
Bientôt disponible
</p>
</div>
</div>
{/* Commented out image selection */}
{/* <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Choisir une image
</label>
<div className="grid grid-cols-3 gap-2 max-h-40 overflow-y-auto">
{availableImages.map((image) => (
<button
key={image}
type="button"
className={`relative w-full h-16 rounded border-2 ${
selectedImage === image
? "border-blue-500"
: "border-gray-300 dark:border-gray-600"
} hover:border-blue-400 transition-colors overflow-hidden`}
onClick={() => setSelectedImage(image)}
>
<img
src={image}
alt="Option"
className="w-full h-full object-cover"
/>
{selectedImage === image && (
<div className="absolute inset-0 bg-blue-500/20 flex items-center justify-center">
<span className="text-blue-600 text-lg"></span>
</div>
)}
</button>
))}
</div>
</div> */}
</div>
)}
{/* Color Mode */}
{creationMode === "color" && (
<div>
<label className="block text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
Couleur
</label>
<div className="grid grid-cols-5 gap-2">
{AVAILABLE_COLORS.map((color) => (
<button
key={color}
type="button"
className={`w-12 h-12 ${color} rounded-lg border-2 ${
selectedColor === color
? "border-gray-800 dark:border-white scale-110"
: "border-gray-300 dark:border-gray-600"
} hover:scale-105 transition-all duration-200`}
onClick={() => setSelectedColor(color)}
>
{selectedColor === color && (
<span className="text-white text-lg"></span>
)}
</button>
))}
</div>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,52 @@
interface StatusPickerProps {
selectedStatus: "todo" | "in_progress" | "done";
setSelectedStatus: (status: "todo" | "in_progress" | "done") => void;
}
export const StatusPicker = ({
selectedStatus,
setSelectedStatus,
}: StatusPickerProps) => {
return (
<div>
<label className="block text-base font-semibold text-gray-700 dark:text-gray-300 mb-2">
Statut
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
selectedStatus === "todo"
? "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 ring-2 ring-gray-300 dark:ring-gray-600"
: "bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
}`}
onClick={() => setSelectedStatus("todo")}
>
À faire
</button>
<button
type="button"
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
selectedStatus === "in_progress"
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300 ring-2 ring-blue-300 dark:ring-blue-600"
: "bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
}`}
onClick={() => setSelectedStatus("in_progress")}
>
En cours
</button>
<button
type="button"
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
selectedStatus === "done"
? "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 ring-2 ring-green-300 dark:ring-green-600"
: "bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
}`}
onClick={() => setSelectedStatus("done")}
>
Terminé
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,128 @@
import { ClickOutside } from "./ClickOutside";
import { useState } from "react";
import { ImageColorPicker } from "./ImageColorPicker";
import { StatusPicker } from "./StatusPicker";
interface Tablo {
id: number;
name: string;
image?: string;
color?: string;
status: "todo" | "in_progress" | "done";
}
interface TabloModalProps {
tablo: Tablo | null;
onClose: () => void;
onSave?: (updatedTablo: Tablo) => void;
}
export const TabloModal = ({ tablo, onClose, onSave }: TabloModalProps) => {
const [editData, setEditData] = useState<Tablo | null>(tablo);
const [isEditingName, setIsEditingName] = useState(false);
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
const [selectedColor, setSelectedColor] = useState("bg-blue-500");
const handleCancelEdit = () => {
setEditData(null);
};
const handleSaveEdit = () => {
if (editData && onSave) {
// Clear the unused field based on selection
const updatedTablo = {
...editData,
image: creationMode === "image" ? editData.image : undefined,
color: creationMode === "color" ? editData.color : undefined,
};
onSave(updatedTablo);
}
setEditData(null);
};
if (!tablo) return null;
const currentData = editData || tablo;
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<ClickOutside onClickOutside={onClose}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-4 w-full max-w-xl min-w-[28rem] max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
{isEditingName ? (
<ClickOutside onClickOutside={() => setIsEditingName(false)}>
<input
type="text"
value={editData?.name}
onChange={(e) =>
setEditData((prev) =>
prev ? { ...prev, name: e.target.value } : null
)
}
className="text-2xl font-bold text-gray-900 dark:text-white bg-transparent border-b-2 border-blue-500 focus:outline-none focus:border-blue-600"
/>
</ClickOutside>
) : (
<h2
className="text-2xl font-bold text-gray-900 dark:text-white cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
onClick={() => setIsEditingName(true)}
>
{tablo.name}
</h2>
)}
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 p-2"
>
</button>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto p-2">
<ImageColorPicker
creationMode={creationMode}
setCreationMode={setCreationMode}
selectedColor={selectedColor}
setSelectedColor={setSelectedColor}
/>
{/* Details */}
<div className="space-y-4 mb-4">
<div>
<StatusPicker
selectedStatus={currentData.status}
setSelectedStatus={(status) =>
setEditData((prev) => (prev ? { ...prev, status } : null))
}
/>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-end space-x-4 pt-4 pb-1 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
<>
<button
type="button"
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md"
onClick={handleCancelEdit}
>
Annuler
</button>
<button
type="button"
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md"
onClick={handleSaveEdit}
>
Sauvegarder
</button>
</>
</div>
</div>
</ClickOutside>
</div>
);
};

View file

@ -0,0 +1,47 @@
import React, { useEffect, useRef } from "react";
/**
* Hook that detects clicks outside of a referenced element
* @param callback - Function to call when clicking outside
* @returns ref - Ref to attach to the element you want to detect clicks outside of
*/
export function useClickOutside<T extends HTMLElement = HTMLElement>(
callback: () => void
) {
const ref = useRef<T>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
callback();
}
};
// Add event listener
document.addEventListener("mousedown", handleClickOutside);
// Cleanup
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [callback]);
return ref;
}
export const ClickOutside = ({
children,
callback,
}: {
children: React.ReactNode;
callback: () => void;
}) => {
const ref = useClickOutside(callback);
return React.createElement(
"div",
{
ref,
},
children
);
};

File diff suppressed because it is too large Load diff