Show members of a tablo

This commit is contained in:
Arthur Belleville 2025-07-06 17:23:44 +02:00
parent 99e73a582d
commit aca3d32498
No known key found for this signature in database
5 changed files with 277 additions and 72 deletions

View file

@ -230,3 +230,35 @@ tabloRouter.post("/join", async (c) => {
return c.json({ message: "Tablo joined successfully" });
});
tabloRouter.get("/members/:tablo_id", async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const { tablo_id } = c.req.param();
const { data: tabloData, error: tabloError } = await supabase
.from("user_tablos")
.select("*")
.eq("id", tablo_id)
.eq("user_id", user.id);
if (!tabloData || tabloData.length === 0) {
return c.json({ error: "You are not a member of this tablo" }, 403);
}
if (tabloError) {
return c.json({ error: "Internal server error" }, 500);
}
const { data, error } = await supabase
.from("tablo_access")
.select("profiles(id, name)")
.eq("tablo_id", tablo_id)
.eq("is_active", true);
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({ members: data.map((member) => member.profiles) });
});

View file

@ -19,6 +19,10 @@ CREATE TABLE IF NOT EXISTS tablo_access (
-- Foreign key constraint to users table (auth.users)
CONSTRAINT fk_tablo_access_user_id
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE,
-- Foreign key constraint to profiles table
CONSTRAINT fk_tablo_access_user_id_from_profiles
FOREIGN KEY (user_id) REFERENCES profiles(id)
);
-- Create indexes for performance

View file

@ -4,6 +4,8 @@ import { ImageColorPicker } from "./ImageColorPicker";
import { StatusPicker } from "./StatusPicker";
import { useInviteUser } from "@ui/hooks/invite";
import { TabloUpdate, UserTablo } from "@ui/types/tablos.types";
import { useTabloMembers } from "@ui/hooks/tablos";
import { useUser } from "@ui/providers/UserStoreProvider";
type StatusType = "todo" | "in_progress" | "done";
@ -11,9 +13,17 @@ interface TabloModalProps {
tablo: UserTablo | null;
onEdit: (updatedTablo: TabloUpdate & { id: string }) => void;
onClose: () => void;
readOnly?: boolean;
}
export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
export const TabloModal = ({
tablo,
onClose,
onEdit,
readOnly = false,
}: TabloModalProps) => {
const currentUser = useUser();
const [editData, setEditData] = useState<UserTablo | null>(tablo);
const [isEditingName, setIsEditingName] = useState(false);
@ -22,6 +32,9 @@ export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
tablo?.color || "bg-blue-500"
);
const { data: members } = useTabloMembers(tablo?.id ?? "");
const [showMembers, setShowMembers] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const inviteUser = useInviteUser();
@ -60,7 +73,7 @@ export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
<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 ? (
{!readOnly && isEditingName ? (
<ClickOutside onClickOutside={() => setIsEditingName(false)}>
<input
type="text"
@ -75,8 +88,12 @@ export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
</ClickOutside>
) : (
<h2
className="text-2xl font-bold text-gray-900 dark:text-white cursor-text hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
onClick={() => setIsEditingName(true)}
className={`text-2xl font-bold text-gray-900 dark:text-white ${
!readOnly
? "cursor-text hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
: ""
}`}
onClick={!readOnly ? () => setIsEditingName(true) : undefined}
>
{tablo.name}
</h2>
@ -91,68 +108,201 @@ export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto p-2">
<ImageColorPicker
creationMode={creationMode}
setCreationMode={setCreationMode}
selectedColor={selectedColor}
setSelectedColor={setSelectedColor}
/>
{readOnly ? (
/* 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>
{/* Details */}
<div className="space-y-4 mb-4">
<div>
<StatusPicker
selectedStatus={currentData.status as StatusType}
setSelectedStatus={(status) =>
setEditData((prev) => (prev ? { ...prev, status } : null))
}
/>
</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>
{/* 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"
/>
<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>
{/* 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>
{/* Members placeholder - can be expanded later */}
<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">
Membres
</h3>
<div className="text-gray-500 dark:text-gray-400 text-sm">
Vous avez accès à ce tablo en tant qu&apos;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"
/>
<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>
{/* Members Section */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
Membres
</h3>
<button
type="button"
onClick={() => setShowMembers(!showMembers)}
className="flex items-center space-x-1 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white 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.id === currentUser?.id && (
<span className="text-xs text-gray-500 dark:text-gray-400">
(Vous)
</span>
)}
</div>
))
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">
Aucun membre trouvé
</p>
)}
</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">
<>
{readOnly ? (
<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={onClose}
>
Annuler
Fermer
</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>
</>
) : (
<>
<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={onClose}
>
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>

View file

@ -49,6 +49,25 @@ export const useTablo = (id: string) => {
});
};
// Fetch tablo members
export const useTabloMembers = (tabloId: string) => {
const { session } = useSession();
const { data, isLoading, error } = useQuery({
queryKey: ["tablo-members", tabloId],
queryFn: async () => {
const { data } = await api.get<{
members: { id: string; name: string }[];
}>(`/api/v1/tablos/members/${tabloId}`, {
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
});
return data.members;
},
});
return { data, isLoading, error };
};
// Create new tablo
export const useCreateTablo = () => {
const { session } = useSession();

View file

@ -73,6 +73,7 @@ export const TabloPage = () => {
{
name: "Notes",
action: (tabloId: string) => navigate(`/tablo/${tabloId}/notes`),
isDisabled: true,
},
];
@ -300,16 +301,12 @@ export const TabloPage = () => {
className={`bg-white dark:bg-gray-800 rounded-lg shadow-lg transition-all duration-300 w-64 overflow-hidden border border-gray-200 dark:border-gray-700 ${
isAdmin
? "hover:shadow-xl cursor-pointer"
: "cursor-default opacity-75"
: "hover:shadow-xl cursor-pointer opacity-75"
}`}
onClick={
isAdmin
? (e) => {
e.stopPropagation();
openTablo(tablo.id);
}
: undefined
}
onClick={(e) => {
e.stopPropagation();
openTablo(tablo.id);
}}
>
{/* Image or Color */}
<div className="relative h-56 group">
@ -431,18 +428,20 @@ export const TabloPage = () => {
onClick={(e) => e.stopPropagation()}
>
{/* Regular menu items - always show */}
{menuItems.map((item, index) => (
<button
key={index}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={(e) => {
e.stopPropagation();
item.action(tablo.id);
}}
>
<span>{item.name}</span>
</button>
))}
{menuItems
.filter((item) => !item.isDisabled)
.map((item, index) => (
<button
key={index}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={(e) => {
e.stopPropagation();
item.action(tablo.id);
}}
>
<span>{item.name}</span>
</button>
))}
{/* <div className="border-t border-gray-200 dark:border-gray-600 my-1"></div> */}
@ -650,6 +649,7 @@ export const TabloPage = () => {
tablo={viewingTablo}
onEdit={onEditTablo}
onClose={closeTabloModal}
readOnly={!viewingTablo.is_admin}
/>
)}