diff --git a/backend/users/middleware.py b/backend/users/middleware.py index 6ae11df..58bbd46 100644 --- a/backend/users/middleware.py +++ b/backend/users/middleware.py @@ -40,7 +40,7 @@ def middleware(request: HttpRequest) -> HttpResponse: language=_extract_language(request), # Lazily computed properties: isAuthenticated=lambda: request.user.is_authenticated, - user=lambda: User.to_dict(request.user) if request.user.is_authenticated else None, + user=lambda: User.to_json(request.user) if request.user.is_authenticated else None, ) return get_response(request) diff --git a/backend/users/models.py b/backend/users/models.py index 0bcd538..1450f3b 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -10,6 +10,7 @@ from django.forms.models import model_to_dict from django.utils.translation import gettext as _ +from tools.display import format_dates from tools.utils.choices import CommonLabelValueChoices @@ -119,6 +120,20 @@ class Meta: def to_dict(self): return model_to_dict(self, exclude=("password", "is_superuser", "is_staff", "groups", "user_permissions")) + def to_json(self): + return { + "id": self.id, + "firstName": self.first_name, + "lastName": self.last_name, + "fullName": self.name, + "email": self.email, + "roleLabel": RoleChoices(self.main_role).label, + "roleValue": RoleChoices(self.main_role).value, + "addedSince": format_dates.short_date(self.date_joined), + "lastActivity": format_dates.short_datetime(self.last_login), + "ngohubId": self.ngohub_id, + } + def __str__(self): return self.email diff --git a/backend/users/views/team/listing.py b/backend/users/views/team/listing.py index 283486a..f817ffb 100644 --- a/backend/users/views/team/listing.py +++ b/backend/users/views/team/listing.py @@ -13,7 +13,6 @@ from django.views.decorators.cache import cache_control from tools.data_models.filtering import FilterField, FilterItem -from tools.display import format_dates as display_dates from tools.display.url_build import build_ngohub_url from tools.utils.filtering import build_filters_display, build_filters_mapping, filter_qs from tools.utils.pagination import paginate_queryset @@ -27,21 +26,14 @@ def _serialize_users(users_page: Page[User], user_id: int) -> List[Dict]: - return [ - { - "id": user.id, - "firstName": user.first_name, - "lastName": user.last_name, - "email": user.email, - "isCurrentUser": user.id == user_id, - "roleLabel": RoleChoices(user.main_role).label, - "roleValue": RoleChoices(user.main_role).value, - "addedSince": display_dates.short_date(user.date_joined), - "lastActivity": display_dates.short_datetime(user.last_login), - "ngohubId": user.ngohub_id, - } - for user in users_page - ] + users = [] + for user in users_page: + new_user = user.to_json() + new_user["isCurrentUser"] = bool(user.id == user_id) + + users.append(new_user) + + return users def _get_filter_options() -> List[FilterField]: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 60f19a6..426db2b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,7 +15,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", - "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", @@ -24,7 +24,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", - "@radix-ui/react-tooltip": "^1.2.7", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.10", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", diff --git a/frontend/package.json b/frontend/package.json index 195835f..66d2810 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "vite", "prod": "tsc -b && vite build", + "tsc": "tsc -b", "preview": "vite preview", "lint": "eslint .", "prettier:format": "prettier --config prettier.config.mjs --write src/**/*.{ts,tsx}", @@ -24,7 +25,7 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.11", - "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.14", @@ -33,7 +34,7 @@ "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", - "@radix-ui/react-tooltip": "^1.2.7", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.1.10", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", diff --git a/frontend/src/components/hooks/use-sidebar.ts b/frontend/src/components/hooks/use-sidebar.ts new file mode 100644 index 0000000..bea4718 --- /dev/null +++ b/frontend/src/components/hooks/use-sidebar.ts @@ -0,0 +1,15 @@ +import type { SidebarContextProps } from "@/components/types/sidebarContextProps"; +import * as React from "react"; + +export const SidebarContext = React.createContext(null); + +function useSidebar() { + const context = React.useContext(SidebarContext); + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider."); + } + + return context; +} + +export { useSidebar }; diff --git a/frontend/src/components/paul/app-sidebar.tsx b/frontend/src/components/paul/app-sidebar.tsx deleted file mode 100644 index 1d1b414..0000000 --- a/frontend/src/components/paul/app-sidebar.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { ExpandableNav } from "@/components/paul/expandable-nav"; -import { SingleNav } from "@/components/paul/single-nav"; -import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail } from "@/components/ui/sidebar"; -import { apiGetUrls } from "@/constants/api-urls"; -import { - CircleStackIcon as Database, - DocumentChartBarIcon as FileChartLine, - HomeIcon as House, - InformationCircleIcon as Info, - SparklesIcon as Zap, - TableCellsIcon as Grid2X2Plus, - UsersIcon as UsersRound, -} from "@heroicons/react/24/outline"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; - -export function AppSidebar({ ...props }: React.ComponentProps) { - const { t } = useTranslation(); - - const data = { - navHome: [ - { - title: t("navigation.home"), - url: "/", - icon: House, - }, - ], - navMain: [ - { - title: t("navigation.datasets"), - url: "#", - icon: Database, - items: [ - { - title: t("navigation.test1"), - url: "#", - }, - { - title: t("navigation.test2"), - url: "#", - }, - ], - }, - { - title: t("navigation.processedData"), - url: "#", - icon: FileChartLine, - items: [ - { - title: t("navigation.test1"), - url: "#", - }, - { - title: t("navigation.test2"), - url: "#", - }, - ], - }, - { - title: t("navigation.actions"), - url: "#", - icon: Zap, - items: [ - { - title: t("navigation.test1"), - url: "#", - }, - { - title: t("navigation.test2"), - url: "#", - }, - ], - }, - { - title: t("navigation.apps"), - url: "#", - icon: Grid2X2Plus, - items: [ - { - title: t("navigation.test1"), - url: "#", - }, - { - title: t("navigation.test2"), - url: "#", - }, - ], - }, - ], - navMore: [ - { - title: t("navigation.team"), - url: apiGetUrls.teamIndex, - icon: UsersRound, - }, - { - title: t("navigation.help"), - url: "#", - icon: Info, - }, - ], - }; - - return ( - - - - - - - - - - - - ); -} diff --git a/frontend/src/components/paul/app-topbar.tsx b/frontend/src/components/paul/app-topbar.tsx deleted file mode 100644 index 6356057..0000000 --- a/frontend/src/components/paul/app-topbar.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { - Disclosure, - DisclosureButton, - DisclosurePanel, - Menu, - MenuButton, - MenuItem, - MenuItems, -} from "@headlessui/react"; -import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; -import { Bars3Icon, BellIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { useTranslation } from "react-i18next"; -import { apiPostUrls } from "@/constants/api-urls"; -import { Link } from "@inertiajs/react"; - -import logo from "@/assets/paul-logo.svg"; - -export function AppTopbar() { - const { t } = useTranslation(); - - return ( - -
-
-
-
- PAUL -
-
-
-
- -
-
-
- {/* Mobile menu button */} - - - {t("topbar.openMainMenu")} - -
-
- - - {/* Profile dropdown */} - -
- - - {t("topbar.openUserMenu")} - - -
- - - - {t("topbar.yourProfile")} - - - - - {t("topbar.settings")} - - - - - {t("topbar.signOut")} - - - -
-
-
-
- - -
-
-
- -
-
-
user name
-
user@example.com
-
- -
-
- - {t("topbar.yourProfile")} - - - {t("topbar.settings")} - - - {t("topbar.signOut")} - -
-
-
-
- ); -} diff --git a/frontend/src/components/paul/expandable-nav.tsx b/frontend/src/components/paul/expandable-nav.tsx deleted file mode 100644 index 199c45d..0000000 --- a/frontend/src/components/paul/expandable-nav.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { - SidebarGroup, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, -} from "@/components/ui/sidebar"; -import { ChevronRightIcon as ChevronRight } from "@heroicons/react/24/outline"; -import { Link } from "@inertiajs/react"; -import { type LucideIcon } from "lucide-react"; - -export function ExpandableNav({ - items, -}: { - items: { - title: string; - url: string; - icon?: LucideIcon; - isActive?: boolean; - items?: { - title: string; - url: string; - }[]; - }[]; -}) { - return ( - - {/* */} - - {items.map((item) => ( - - - - - {item.icon && } - {item.title} - - - - - - {item.items?.map((subItem) => ( - - - - {subItem.title} - - - - ))} - - - - - ))} - - - ); -} diff --git a/frontend/src/components/paul/login-choice-form.tsx b/frontend/src/components/paul/login/login-choice-form.tsx similarity index 96% rename from frontend/src/components/paul/login-choice-form.tsx rename to frontend/src/components/paul/login/login-choice-form.tsx index 2d8f657..ad798d6 100644 --- a/frontend/src/components/paul/login-choice-form.tsx +++ b/frontend/src/components/paul/login/login-choice-form.tsx @@ -1,5 +1,5 @@ import logomark from "@/assets/paul-logomark.svg"; -import { LoginRegisterCta } from "@/components/paul/login-register-cta"; +import { LoginRegisterCta } from "@/components/paul/login/login-register-cta"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { cn } from "@/lib/utils"; diff --git a/frontend/src/components/paul/login-form.tsx b/frontend/src/components/paul/login/login-form.tsx similarity index 98% rename from frontend/src/components/paul/login-form.tsx rename to frontend/src/components/paul/login/login-form.tsx index 2a14172..8e44570 100644 --- a/frontend/src/components/paul/login-form.tsx +++ b/frontend/src/components/paul/login/login-form.tsx @@ -1,5 +1,5 @@ import logomark from "@/assets/paul-logomark.svg"; -import { LoginRegisterCta } from "@/components/paul/login-register-cta"; +import { LoginRegisterCta } from "@/components/paul/login/login-register-cta"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; diff --git a/frontend/src/components/paul/login-register-cta.tsx b/frontend/src/components/paul/login/login-register-cta.tsx similarity index 100% rename from frontend/src/components/paul/login-register-cta.tsx rename to frontend/src/components/paul/login/login-register-cta.tsx diff --git a/frontend/src/components/paul/login-text.tsx b/frontend/src/components/paul/login/login-text.tsx similarity index 100% rename from frontend/src/components/paul/login-text.tsx rename to frontend/src/components/paul/login/login-text.tsx diff --git a/frontend/src/components/paul/navigation/app-sidebar.tsx b/frontend/src/components/paul/navigation/app-sidebar.tsx new file mode 100644 index 0000000..fcf8a98 --- /dev/null +++ b/frontend/src/components/paul/navigation/app-sidebar.tsx @@ -0,0 +1,94 @@ +import { NavExpandable } from "@/components/paul/navigation/nav-expandable"; +import { NavLogo } from "@/components/paul/navigation/nav-logo"; +import { NavUser } from "@/components/paul/navigation/nav-user"; +import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarRail } from "@/components/ui/sidebar"; +import { apiGetUrls } from "@/constants/api-urls"; +import type { UserProps } from "@/types/user"; +import { + CircleStackIcon as Database, + DocumentChartBarIcon as FileChartLine, + SparklesIcon as Zap, + TableCellsIcon as Grid2X2Plus, +} from "@heroicons/react/24/outline"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; + +type AppSidebarProps = { + user: UserProps; +} & React.ComponentProps; + +export function AppSidebar({ user, ...props }: AppSidebarProps) { + const { t } = useTranslation(); + + const navMain = [ + { + title: t("navigation.datasets"), + url: apiGetUrls.datasetIndex, + icon: Database, + isActive: false, + items: [ + { + title: t("navigation.test1"), + url: "#", + }, + { + title: t("navigation.test2"), + url: "#", + }, + ], + }, + { + title: t("navigation.processedData"), + url: "#", + icon: FileChartLine, + isActive: false, + items: [ + { + title: t("navigation.test1"), + url: "#", + }, + { + title: t("navigation.test2"), + url: "#", + }, + ], + }, + { + title: t("navigation.actions"), + url: "#", + icon: Zap, + isActive: false, + items: [ + { + title: t("navigation.test1"), + url: "#", + }, + { + title: t("navigation.test2"), + url: "#", + }, + ], + }, + { + title: t("navigation.apps"), + url: "#", + icon: Grid2X2Plus, + isActive: false, + }, + ]; + + return ( + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/paul/navigation/app-topbar.tsx b/frontend/src/components/paul/navigation/app-topbar.tsx new file mode 100644 index 0000000..e13e63a --- /dev/null +++ b/frontend/src/components/paul/navigation/app-topbar.tsx @@ -0,0 +1,66 @@ +import logo from "@/assets/paul-logo.svg"; +import { cn } from "@/lib/utils"; +import { Link } from "@inertiajs/react"; +import * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +/** + * AppTopbar + * - Mobile-only top bar that hides on scroll down and reappears on scroll up. + * - Contains the Paul logo that links to the homepage. + */ +export function AppTopbar({ className, ...props }: React.ComponentProps<"div">) { + const { t } = useTranslation(); + const [visible, setVisible] = useState(true); + const lastScrollY = useRef(0); + const ticking = useRef(false); + const THRESHOLD = 10; // minimal delta to toggle + + useEffect(() => { + // Initialize last scroll position on mount + lastScrollY.current = window.scrollY || 0; + + const onScroll = () => { + if (ticking.current) return; + ticking.current = true; + + window.requestAnimationFrame(() => { + const currentY = window.scrollY || 0; + + if (currentY <= 0) { + setVisible(true); + } else if (currentY > lastScrollY.current + THRESHOLD) { + // Scrolling down + setVisible(false); + } else if (currentY < lastScrollY.current - THRESHOLD) { + // Scrolling up + setVisible(true); + } + + lastScrollY.current = currentY; + ticking.current = false; + }); + }; + + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, []); + + return ( +
+
+ + {t("navigation.home") + +
+
+ ); +} diff --git a/frontend/src/components/paul/navigation/nav-expandable.tsx b/frontend/src/components/paul/navigation/nav-expandable.tsx new file mode 100644 index 0000000..bc75af2 --- /dev/null +++ b/frontend/src/components/paul/navigation/nav-expandable.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useSidebar } from "@/components/hooks/use-sidebar"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + SidebarGroup, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "@/components/ui/sidebar"; +import type { NavigationItemExpandable } from "@/types/navigation-item"; +import { ChevronRightIcon as ChevronRight } from "@heroicons/react/24/outline"; +import { Link } from "@inertiajs/react"; +import * as React from "react"; + +type NavigationExpandableProps = { + items: NavigationItemExpandable[]; +}; + +function MainButton({ item }: { item: NavigationItemExpandable }) { + return ( + + + + {item.title} + + + ); +} +function CollapsedPopover({ item }: { item: NavigationItemExpandable }) { + const { isMobile } = useSidebar(); + const [openPopover, setOpenPopover] = React.useState(false); + + const handleEnterPopover = React.useCallback(() => { + if (!isMobile) { + setOpenPopover(true); + } + }, [isMobile]); + const handleLeavePopover = React.useCallback(() => { + if (!isMobile) { + setOpenPopover(false); + } + }, [isMobile]); + + return ( + + +
+ +
+
+ + + {item.items?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + +
+ ); +} + +function ExpandedDropdown({ item }: { item: NavigationItemExpandable }) { + return ( + <> + + + + + + + + {item.items?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + ); +} + +function NavExpandableItem({ item }: { item: NavigationItemExpandable }) { + const { state } = useSidebar(); + const collapsed = state === "collapsed"; + const hasSubItems = (item.items?.length ?? 0) > 0; + + if (!hasSubItems) { + return ( + + + + ); + } + + return ( + + + {collapsed ? : } + + + ); +} + +export function NavExpandable({ items }: NavigationExpandableProps) { + return ( + + + {items.map((item) => ( + + ))} + + + ); +} diff --git a/frontend/src/components/paul/navigation/nav-icon-link.tsx b/frontend/src/components/paul/navigation/nav-icon-link.tsx new file mode 100644 index 0000000..63c9502 --- /dev/null +++ b/frontend/src/components/paul/navigation/nav-icon-link.tsx @@ -0,0 +1,36 @@ +import type { Method } from "@inertiajs/core"; +import { Link } from "@inertiajs/react"; +import type { ComponentType, ReactElement, ReactNode } from "react"; +import * as React from "react"; +import { isValidElement } from "react"; + +export type IconComponent = ComponentType>; + +export interface IconLinkProps { + href: string; + method?: Method; + icon: IconComponent | ReactElement; + label: ReactNode; + className?: string; + iconClassName?: string; +} + +export function NavIconLink({ + href, + method, + icon, + label, + className = "flex items-center gap-2 text-left text-sm", + iconClassName = "size-4", + ...rest +}: IconLinkProps & Omit, "href" | "method" | "children" | "className">) { + const Icon = icon as IconComponent; + const iconNode = isValidElement(icon) ? icon :