Show members of a tablo
This commit is contained in:
parent
99e73a582d
commit
aca3d32498
5 changed files with 277 additions and 72 deletions
|
|
@ -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) });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue