Restyle navbar/topbar with light/dark themes and restore invite functionality
- NavigationBar: add light/dark theme support with adaptive text colors - TopBar: match navbar background color in both themes - main.css: make navbar-background/darker CSS variables theme-aware - tablo-details: restore working invite button with full share dialog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
080d3a98f3
commit
d126fe84dc
4 changed files with 175 additions and 36 deletions
|
|
@ -69,8 +69,8 @@ function NavLink({ isActive, children }: NavLinkProps) {
|
|||
"*:data-[ui=notification-badge]:text-xs/6",
|
||||
"*:data-[ui=notification-badge]:font-semibold",
|
||||
isActive
|
||||
? "bg-navbar-darker font-semibold text-white *:data-[ui=notification-badge]:bg-transparent"
|
||||
: ["font-medium", "text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"]
|
||||
? "bg-navbar-darker font-semibold text-gray-900 dark:text-white *:data-[ui=notification-badge]:bg-transparent"
|
||||
: ["font-medium", "text-gray-500 dark:text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"]
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -84,13 +84,13 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
const { t } = useTranslation("navigation");
|
||||
|
||||
const MenuSeparator = () => {
|
||||
return <DropdownMenuSeparator className="bg-gray-500!" />;
|
||||
return <DropdownMenuSeparator className="bg-gray-300 dark:bg-gray-500!" />;
|
||||
};
|
||||
|
||||
const itemVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "text-gray-200/90 focus:bg-gray-500/80 focus:text-white",
|
||||
default: "text-gray-600 dark:text-gray-200/90 focus:bg-gray-200/80 dark:focus:bg-gray-500/80 focus:text-gray-900 dark:focus:text-white",
|
||||
destructive: "text-red-500/80 focus:bg-red-500/80 focus:text-white",
|
||||
},
|
||||
},
|
||||
|
|
@ -136,10 +136,10 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
</Avatar>
|
||||
{!isCollapsed && (
|
||||
<div className="flex flex-col items-start">
|
||||
<TypographyMuted className="text-gray-300/90 transition-all duration-300 ml-1 truncate font-medium overflow-hidden text-ellipsis">
|
||||
<TypographyMuted className="text-gray-700 dark:text-gray-300/90 transition-all duration-300 ml-1 truncate font-medium overflow-hidden text-ellipsis">
|
||||
{user.first_name} {user.last_name}
|
||||
</TypographyMuted>
|
||||
<TypographyMuted className="text-gray-400/90 transition-all duration-300 ml-1 text-xs truncate overflow-hidden text-ellipsis">
|
||||
<TypographyMuted className="text-gray-500 dark:text-gray-400/90 transition-all duration-300 ml-1 text-xs truncate overflow-hidden text-ellipsis">
|
||||
{user.email}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
|
|
@ -147,7 +147,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-56 bg-navbar-background border-gray-600/50 p-1 rounded-lg text-white"
|
||||
className="min-w-56 bg-navbar-background border-gray-300 dark:border-gray-600/50 p-1 rounded-lg text-gray-900 dark:text-white"
|
||||
side="right"
|
||||
align="end"
|
||||
sideOffset={-8}
|
||||
|
|
@ -155,7 +155,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
<div className="flex gap-2 p-1">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={user.avatar_url ?? undefined} alt={user.name ?? "User avatar"} />
|
||||
<AvatarFallback className="bg-gray-700 text-white">
|
||||
<AvatarFallback className="bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-white">
|
||||
{user.name?.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
<AvatarBadge className="size-3">
|
||||
|
|
@ -163,10 +163,10 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
</AvatarBadge>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<TypographyMuted className="font-bold text-gray-100 text-sm truncate">
|
||||
<TypographyMuted className="font-bold text-gray-800 dark:text-gray-100 text-sm truncate">
|
||||
{user.name}
|
||||
</TypographyMuted>
|
||||
<TypographyMuted className="text-gray-300 text-xs truncate">
|
||||
<TypographyMuted className="text-gray-500 dark:text-gray-300 text-xs truncate">
|
||||
{user.email}
|
||||
</TypographyMuted>
|
||||
</div>
|
||||
|
|
@ -248,7 +248,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
|
|||
/>
|
||||
<h1
|
||||
className={twMerge(
|
||||
"text-lg font-bold transition-all duration-300 text-white whitespace-nowrap",
|
||||
"text-lg font-bold transition-all duration-300 text-gray-900 dark:text-white whitespace-nowrap",
|
||||
isCollapsed ? "w-0 h-0 opacity-0" : "w-auto opacity-100"
|
||||
)}
|
||||
>
|
||||
|
|
@ -265,7 +265,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
|
|||
className={twMerge(
|
||||
isCollapsed ? "relative" : "absolute top-2 right-2",
|
||||
"size-5 p-1",
|
||||
"text-gray-300 hover:text-white",
|
||||
"text-gray-500 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white",
|
||||
"transition-all duration-300",
|
||||
"bg-navbar-background",
|
||||
"rounded-full shadow-md",
|
||||
|
|
@ -300,9 +300,9 @@ function RecentProjectsSection() {
|
|||
|
||||
return (
|
||||
<div className="px-2 pb-2">
|
||||
<Separator className="border-gray-300/20 mb-3" />
|
||||
<Separator className="border-gray-300 dark:border-gray-300/20 mb-3" />
|
||||
<div className="px-2 mb-2">
|
||||
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
|
||||
<span className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
{t("projects", "Projects")}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -316,12 +316,12 @@ function RecentProjectsSection() {
|
|||
className={twMerge(
|
||||
"flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-navbar-darker text-white font-semibold"
|
||||
: "text-gray-300/90 hover:bg-navbar-darker hover:text-white"
|
||||
? "bg-navbar-darker text-gray-900 dark:text-white font-semibold"
|
||||
: "text-gray-500 dark:text-gray-300/90 hover:bg-navbar-darker hover:text-gray-900 dark:hover:text-white"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="w-5 h-5 rounded-md shrink-0 flex items-center justify-center text-xs font-bold text-white"
|
||||
className="w-6 h-6 rounded-full shrink-0 flex items-center justify-center text-xs font-bold text-gray-700 dark:text-white border border-gray-400 dark:border-gray-500/50"
|
||||
style={{ backgroundColor: tablo.color ?? "#6b7280" }}
|
||||
>
|
||||
{tablo.name.charAt(0).toUpperCase()}
|
||||
|
|
@ -437,7 +437,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
if ("isHorizontalBar" in item) {
|
||||
return (
|
||||
<li key={`horizontal-bar-${index}`} className="my-2">
|
||||
<Separator className="border-gray-300/20" />
|
||||
<Separator className="border-gray-300 dark:border-gray-300/20" />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
|
@ -458,7 +458,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
<TypographyLarge
|
||||
className={twMerge(
|
||||
"text-base transition-all duration-300 font-normal",
|
||||
isActive ? "text-white" : "text-gray-300/90",
|
||||
isActive ? "text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-300/90",
|
||||
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
|
||||
)}
|
||||
>
|
||||
|
|
@ -621,7 +621,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
<TypographyLarge
|
||||
className={twMerge(
|
||||
"text-base transition-all duration-300 font-normal",
|
||||
location.pathname === "/feedback" ? "text-white" : "text-gray-300/90",
|
||||
location.pathname === "/feedback" ? "text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-300/90",
|
||||
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
|
||||
)}
|
||||
>
|
||||
|
|
@ -632,7 +632,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
|
|||
</NavLink>
|
||||
</li>
|
||||
<li className="my-2">
|
||||
<Separator className="border-gray-300/20" />
|
||||
<Separator className="border-gray-300 dark:border-gray-300/20" />
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -341,7 +341,7 @@ export function TopBar() {
|
|||
};
|
||||
|
||||
return (
|
||||
<header className="h-[75px] flex items-center justify-between px-6 gap-4 border-b border-[#EAECF0] dark:border-gray-700 bg-white dark:bg-navbar-background shrink-0">
|
||||
<header className="h-[75px] flex items-center justify-between px-6 gap-4 border-b border-[#EAECF0] dark:border-gray-700 bg-navbar-background shrink-0">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500 pointer-events-none" />
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@
|
|||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--navbar-background: rgb(249, 250, 251);
|
||||
--navbar-darker: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -72,6 +74,8 @@
|
|||
--sidebar-accent-foreground: oklch(0.985 0.005 290);
|
||||
--sidebar-border: oklch(0.26 0.02 290);
|
||||
--sidebar-ring: oklch(0.45 0.03 290);
|
||||
--navbar-background: #1e1b2e;
|
||||
--navbar-darker: #141121;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
|
@ -111,8 +115,8 @@
|
|||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-navbar-background: #1e1b2e;
|
||||
--color-navbar-darker: #141121;
|
||||
--color-navbar-background: var(--navbar-background);
|
||||
--color-navbar-darker: var(--navbar-darker);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@ import { cn, toast } from "@xtablo/shared";
|
|||
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
|
||||
import type { Etape, KanbanTask } from "@xtablo/shared-types";
|
||||
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
|
||||
import { Button } from "@xtablo/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@xtablo/ui/components/dialog";
|
||||
import { Input } from "@xtablo/ui/components/input";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
|
|
@ -30,9 +40,12 @@ import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
|
|||
import { TabloEventsSection } from "../components/TabloEventsSection";
|
||||
import { TabloFilesSection } from "../components/TabloFilesSection";
|
||||
import { TabloTasksSection } from "../components/TabloTasksSection";
|
||||
import { useInviteUser } from "../hooks/invite";
|
||||
import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
|
||||
import { useAllTasks, useCreateTask, useTabloEtapes } from "../hooks/tasks";
|
||||
import { useTabloFileNames } from "../hooks/tablo_data";
|
||||
import { useTablosList } from "../hooks/tablos";
|
||||
import { useTablosList, useTabloMembers } from "../hooks/tablos";
|
||||
import { useUser } from "../providers/UserStoreProvider";
|
||||
|
||||
// ─── Status helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -100,6 +113,29 @@ export const TabloDetailsPage = () => {
|
|||
const { data: tablos, isLoading } = useTablosList();
|
||||
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
|
||||
const currentUser = useUser();
|
||||
const { data: members } = useTabloMembers(tabloId ?? "");
|
||||
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? "");
|
||||
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
|
||||
|
||||
const isEmailValid = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const handleSendInvite = () => {
|
||||
if (inviteEmail.trim() && tabloId) {
|
||||
inviteUser({ email: inviteEmail, tablo_id: tabloId });
|
||||
setInviteEmail("");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredMembers = members?.filter(
|
||||
(member) => !pendingInvites?.some((invite) => invite.invited_email === member.email),
|
||||
);
|
||||
|
||||
const sectionParam = searchParams.get("section") as TabSection | null;
|
||||
const activeSection: TabSection =
|
||||
|
|
@ -196,17 +232,16 @@ export const TabloDetailsPage = () => {
|
|||
<MessageCircleIcon className="w-5 h-5" />
|
||||
Discussion
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="border border-border text-muted-foreground font-medium py-2 px-4 rounded-lg flex items-center gap-2 opacity-60 cursor-not-allowed"
|
||||
>
|
||||
<UserPlusIcon className="w-5 h-5" />
|
||||
Inviter
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 leading-none">
|
||||
Bientôt
|
||||
</span>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsShareDialogOpen(true)}
|
||||
className="border border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10 font-medium py-2 px-4 rounded-lg flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<UserPlusIcon className="w-5 h-5" />
|
||||
Inviter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -479,6 +514,106 @@ export const TabloDetailsPage = () => {
|
|||
initialStatus="todo"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Share / Invite Dialog */}
|
||||
<Dialog open={isShareDialogOpen} onOpenChange={setIsShareDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Partager le projet</DialogTitle>
|
||||
<DialogDescription>Invitez des personnes à collaborer sur ce projet</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Invite Input */}
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="Email de l'utilisateur"
|
||||
className="flex-1"
|
||||
/>
|
||||
{isInvitingUser ? (
|
||||
<div className="flex justify-center items-center px-4">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSendInvite}
|
||||
disabled={!isEmailValid(inviteEmail)}
|
||||
>
|
||||
Inviter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pending Invites */}
|
||||
{pendingInvites && pendingInvites.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground mb-2">
|
||||
Invitations en attente ({pendingInvites.length})
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{pendingInvites.map((invite) => (
|
||||
<div
|
||||
key={invite.id}
|
||||
className="flex items-center space-x-2 p-2 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-dashed border-orange-200 dark:border-orange-900/50"
|
||||
>
|
||||
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center text-orange-600 dark:text-orange-400 text-xs">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-medium text-foreground truncate block">
|
||||
{invite.invited_email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members List */}
|
||||
{filteredMembers && filteredMembers.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground mb-2">
|
||||
Membres ({filteredMembers.length})
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{filteredMembers.map((member) => {
|
||||
const isCurrentUser = member.id === currentUser.id;
|
||||
const avatarUrl = isCurrentUser ? currentUser.avatar_url : null;
|
||||
return (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center space-x-2 p-2 bg-muted rounded-lg"
|
||||
>
|
||||
<Avatar className="w-8 h-8">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={member.name} />}
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs font-medium">
|
||||
{member.name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-medium text-foreground truncate block">
|
||||
{member.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{member.is_admin ? "Admin" : "Invité"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue