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:
Arthur Belleville 2026-02-23 19:06:46 +01:00
parent 080d3a98f3
commit d126fe84dc
No known key found for this signature in database
4 changed files with 175 additions and 36 deletions

View file

@ -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>

View file

@ -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

View file

@ -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 {

View file

@ -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>
);
};