diff --git a/src/app/account/sections/AccountDetailsSection.tsx b/src/app/account/sections/AccountDetailsSection.tsx index 39e6ee75..7503e6c7 100644 --- a/src/app/account/sections/AccountDetailsSection.tsx +++ b/src/app/account/sections/AccountDetailsSection.tsx @@ -336,7 +336,7 @@ export function AccountDetailsSection({ {canAccessAdminPanel ? ( ) : isInServerResult.data?.isInServer === false ? ( diff --git a/src/app/admin/admin.module.css b/src/app/admin/admin.module.css index 33b77670..f635e4c9 100644 --- a/src/app/admin/admin.module.css +++ b/src/app/admin/admin.module.css @@ -25,31 +25,240 @@ linear-gradient(180deg, #f8fafc 0%, #f3f4f6 100%); } -.main { +.navigationStack { + --navigation-stack-height: calc(100dvh - 100px); +} + +.sidebar.sidebar { + --navigation-stack-sidebar-bg: rgba(255, 255, 255, 0.72); + /* --navigation-stack-sidebar-width: 20rem; */ +} + +.unselectedView { display: flex; - flex: 1; - background: transparent; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 0.55rem; + min-height: 100%; + text-align: center; + padding-inline: 4rem; + padding-bottom: 70rem; +} + +@keyframes profileHeaderIn { + from { + opacity: 0; + transform: translateY(18px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes cardIn { + from { + opacity: 0; + transform: translateY(14px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes welcomeIn { + from { + opacity: 0; + transform: translateX(-6px); + filter: blur(1.5px); + } + to { + opacity: 1; + transform: translateX(0); + filter: blur(0); + } +} + +@keyframes welcomeOut { + from { + opacity: 1; + transform: translateX(0); + filter: blur(0); + } + to { + opacity: 0; + transform: translateX(6px); + filter: blur(1.5px); + } +} + +@keyframes nameIn { + from { + opacity: 0; + transform: translateY(5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} - /* gap: 0.75rem; */ +.unselectedProfileHeader { + display: grid; + justify-items: center; + gap: 0.3rem; + margin-top: 8rem; + margin-bottom: 2rem; + animation: profileHeaderIn 1.1s cubic-bezier(0.25, 0.46, 0.45, 0.94) 0.5s + both; } -.content { +.unselectedWelcome { + position: absolute; + inset: 0; display: flex; - flex: 1; + align-items: center; + justify-content: center; + font-size: clamp(1.65rem, 2.6vw, 2.3rem); + font-weight: 750; + letter-spacing: -0.03em; + line-height: 1.08; + color: rgba(9, 34, 58, 0.96); + animation: + welcomeIn 1.875s cubic-bezier(0.2, 0.8, 0.2, 1) 0.875s both, + welcomeOut 0.8s cubic-bezier(0.4, 0, 0.2, 1) 2.575s forwards; + pointer-events: none; +} - height: calc(100dvh - 7.75rem); - margin: 0.75rem; +.unselectedNameSlot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.1rem; + padding-top: 0.5rem; +} - border-radius: 1.25rem; +.unselectedProfileName { + margin: 0; + font-size: clamp(1.65rem, 2.6vw, 2.3rem); + font-weight: 750; + letter-spacing: -0.03em; + line-height: 1.08; + color: rgba(9, 34, 58, 0.96); + animation: nameIn 0.79s cubic-bezier(0.25, 0.46, 0.45, 0.94) 3.175s both; +} - background: rgba(255, 255, 255, 0.4); - backdrop-filter: blur(16px) saturate(150%); - -webkit-backdrop-filter: blur(16px) saturate(150%); +.unselectedNameSlotImmediate .unselectedProfileName { + animation-delay: 0.75s; +} - border: 1px solid rgba(17, 24, 39, 0.08); +.unselectedAvatar { + width: 132px; + height: 132px; + border-radius: 999px; + border: 6px solid rgba(255, 255, 255, 0.8); + outline-offset: 0; box-shadow: - 0 1px 0 rgba(255, 255, 255, 0.75) inset, - 0 24px 60px rgba(15, 23, 42, 0.08); + 0 1px 0 rgba(255, 255, 255, 0.85) inset, + 0 16px 34px rgba(15, 23, 42, 0.15); + object-fit: cover; +} + +.unselectedProfileHandle { + margin: 0; + font-size: 0.98rem; + font-weight: 540; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.55); + animation: nameIn 0.79s cubic-bezier(0.25, 0.46, 0.45, 0.94) 3.275s both; +} + +.unselectedNameSlotImmediate .unselectedProfileHandle { + animation-delay: 0.9s; +} + +.unselectedGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.1rem; + width: 100%; + padding: 1.05rem 1.25rem 1.5rem; + text-align: left; +} + +.unselectedGridHeader { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 0.7rem; + margin: 0 0 0.1rem; +} + +.unselectedGridHeaderText { + flex-shrink: 0; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: rgba(9, 34, 58, 0.7); +} + +.unselectedGridHeaderLine { + display: block; + width: 100%; + height: 1px; + border-radius: 999px; + background: linear-gradient( + 90deg, + rgba(15, 23, 42, 0.2) 0%, + rgba(15, 23, 42, 0.06) 70%, + rgba(15, 23, 42, 0) 100% + ); +} + +.unselectedGrid > * { + animation: cardIn 0.55s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; +} + +.unselectedGrid > *:nth-child(1) { + animation-delay: 0.1s; +} +.unselectedGrid > *:nth-child(2) { + animation-delay: 0.15s; +} +.unselectedGrid > *:nth-child(3) { + animation-delay: 0.2s; +} +.unselectedGrid > *:nth-child(4) { + animation-delay: 0.25s; +} +.unselectedGrid > *:nth-child(5) { + animation-delay: 0.3s; +} +.unselectedGrid > *:nth-child(6) { + animation-delay: 0.35s; +} +.unselectedGrid > *:nth-child(7) { + animation-delay: 0.4s; +} +.unselectedGrid > *:nth-child(8) { + animation-delay: 0.45s; +} + +.unselectedTitle { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #0f172a; +} - overflow: hidden; +.unselectedDescription { + margin: 0; + max-width: 44ch; + font-size: 0.92rem; + color: rgba(15, 23, 42, 0.62); } diff --git a/src/app/admin/admin.tsx b/src/app/admin/admin.tsx new file mode 100644 index 00000000..26ec08fc --- /dev/null +++ b/src/app/admin/admin.tsx @@ -0,0 +1,157 @@ +import styles from './admin.module.css' +import { DiscordAvatar } from '@/components/common' +import { Detail } from '@/components/common/navigation_stack/detail/Detail' +import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' +import type { ReactElement } from 'react' +import { FaDonate, FaUserShield, FaUserTag, FaUsers } from 'react-icons/fa' +import { FaClipboardUser, FaDollarSign } from 'react-icons/fa6' + +function renderUnselectedGridHeader(label: string): ReactElement { + return ( +
+ {label} +
+ ) +} + +export function renderAdminUnselectedDetail({ + showWelcome, + currentUserName, + currentUserHandle, + currentUserDiscordId, + currentUserDiscordImage, + userCount, + donorCount, + contributionCount, + roleCount, + permissionCount, + positionCount, +}: { + showWelcome?: boolean + currentUserName?: string + currentUserHandle?: string + currentUserDiscordId?: string + currentUserDiscordImage?: string + userCount?: number + donorCount?: number + contributionCount?: number + roleCount?: number + permissionCount?: number + positionCount?: number +}): ReactElement { + return ( + +
+ +
+ {showWelcome && ( +
+ Welcome Back +
+ )} +

+ {(currentUserName?.trim() + ? currentUserName + : undefined) ?? + (currentUserHandle + ? `@${currentUserHandle}` + : 'Admin User')} +

+ {currentUserHandle ? ( +
+ @{currentUserHandle} +
+ ) : null} +
+
+
+ {renderUnselectedGridHeader('Organization')} + + + +
+
+ {renderUnselectedGridHeader('Fundraising')} + + + +
+
+ {renderUnselectedGridHeader('Roles & Permissions')} + + +
+ + } + /> + ) +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 55cda7de..1657147e 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,8 +1,15 @@ 'use client' +import { renderAdminUnselectedDetail } from './admin' import styles from './admin.module.css' -import Sidebar from './layout/Sidebar' import { ProtectedPage } from '@/components/ProtectedPage' +import { NavigationStack } from '@/components/common/navigation_stack/NavigationStack' +import { Detail } from '@/components/common/navigation_stack/detail/Detail' +import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' +import { + Sidebar, + SidebarFeatured, +} from '@/components/common/navigation_stack/sidebar/Sidebar' import { Header } from '@/components/layout/Header' import { zActBlueDonationPacket, @@ -12,12 +19,43 @@ import { zUser, } from '@/contracts/data' import { usePositionQueries } from '@/queries' -import { usePaginatedSearch } from '@/util/hooks' +import { usePaginatedSearch, useCurrentUser } from '@/util/hooks' import { useQuery } from '@tanstack/react-query' +import { usePathname } from 'next/navigation' +import { useEffect, useRef } from 'react' +import type { ReactNode } from 'react' +import { FaDonate, FaUserShield, FaUserTag, FaUsers } from 'react-icons/fa' +import { FaClipboardUser, FaDollarSign } from 'react-icons/fa6' +import type { IconType } from 'react-icons/lib' -export default function Layout({ children }: { children: React.ReactNode }) { - const positionQueries = usePositionQueries() +interface AdminGroupChildConfigItem { + key: string + label: string + href: `/admin/panels/${string}` + icon?: IconType + count?: number +} + +interface AdminPanelConfigItem { + href: `/admin/panels/${string}` + key: string + label: string + icon: IconType + count?: number + buttonType?: 'default' | 'group' + groupChildren?: AdminGroupChildConfigItem[] +} +export default function Layout({ children }: { children: ReactNode }) { + const pathname = usePathname() + const previousPathnameRef = useRef(pathname) + const positionQueries = usePositionQueries() + const showWelcomeRef = useRef( + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).get('from') === + 'welcome' + ) + const previousPathname = previousPathnameRef.current const users = usePaginatedSearch('/users', zUser, { search: { limit: 0 } }) const roles = usePaginatedSearch('/roles', zRole, { search: { limit: 0 } }) const permissions = usePaginatedSearch('/permissions', zPermission, { @@ -37,25 +75,181 @@ export default function Layout({ children }: { children: React.ReactNode }) { enabled: positionQueries.ready, }) + const positionCount = positionHierarchy.data?.positions?.length + + const currentUser = useCurrentUser() + + const adminPanelConfig: AdminPanelConfigItem[] = [ + { + key: 'members', + label: 'Members', + href: '/admin/panels/members', + icon: FaUsers, + count: users.query.data?.count, + }, + { + key: 'fundraising', + label: 'Fundraising', + href: '/admin/panels/fundraising', + icon: FaDonate, + buttonType: 'group', + groupChildren: [ + { + key: 'donors', + label: 'Donors', + href: '/admin/panels/donors', + icon: FaDonate, + count: donors.query.data?.count, + }, + { + key: 'contributions', + label: 'Contributions', + href: '/admin/panels/contributions', + icon: FaDollarSign, + count: contributions.query.data?.count, + }, + ], + }, + { + key: 'positions', + label: 'Positions', + href: '/admin/panels/positions', + icon: FaClipboardUser, + count: positionCount, + }, + { + key: 'roles', + label: 'Roles', + href: '/admin/panels/roles', + icon: FaUserTag, + count: roles.query.data?.count, + }, + { + key: 'permissions', + label: 'Permissions', + href: '/admin/panels/permissions', + icon: FaUserShield, + count: permissions.query.data?.count, + }, + ] + + useEffect(() => { + previousPathnameRef.current = pathname + }, [pathname]) + + const currentTopLevelIndex = adminPanelConfig.findIndex( + (panel) => panel.href === pathname + ) + const previousTopLevelIndex = adminPanelConfig.findIndex( + (panel) => panel.href === previousPathname + ) + + const activePanelLabel = adminPanelConfig + .flatMap((panel) => [ + { href: panel.href, label: panel.label }, + ...(panel.groupChildren ?? []).map((groupChild) => ({ + href: groupChild.href, + label: groupChild.label, + })), + ]) + .find((panel) => panel.href === pathname)?.label + + const isPanelSelected = pathname.startsWith('/admin/panels/') + return (
-
- - -
{children}
-
+ + + + + + + {adminPanelConfig.map((panel) => ( + + previousTopLevelIndex + ? 'down' + : 'up' + : 'none' + } + hasActiveGroupChild={Boolean( + panel.groupChildren?.some( + (groupChild) => + pathname === groupChild.href + ) + )} + groupContent={panel.groupChildren?.map( + (groupChild) => ( + + ) + )} + resetPanelHistoryOnClick + /> + ))} + + + } + detail={ + + } + unSelected={renderAdminUnselectedDetail({ + showWelcome: showWelcomeRef.current, + currentUserName: + `${currentUser.data?.firstName ?? ''} ${currentUser.data?.lastName ?? ''}`.trim(), + currentUserHandle: + currentUser.data?.discordUsers?.[0]?.username, + currentUserDiscordId: + currentUser.data?.discordUsers?.[0]?.id, + currentUserDiscordImage: + currentUser.data?.discordUsers?.[0]?.image, + userCount: users.query.data?.count, + donorCount: donors.query.data?.count, + contributionCount: contributions.query.data?.count, + roleCount: roles.query.data?.count, + permissionCount: permissions.query.data?.count, + positionCount, + })} + />
) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 0dd1d948..1736bf30 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -14,5 +14,6 @@ export const metadata: Metadata = { } export default function Page() { - redirect('/admin/panels/members', RedirectType.replace) + redirect('/admin', RedirectType.replace) + // redirect('/admin/panels/members', RedirectType.replace) } diff --git a/src/app/admin/panels/contributions/page.module.css b/src/app/admin/panels/contributions/page.module.css index 4df33866..e41bfffb 100644 --- a/src/app/admin/panels/contributions/page.module.css +++ b/src/app/admin/panels/contributions/page.module.css @@ -1,3 +1,9 @@ +.sidebarBg { + --navigation-stack-sidebar-bg: #f9fafb; + --navigation-stack-sidebar-padding: 0rem; + --navigation-stack-sidebar-gap: 0rem; +} + .detailsPane { height: calc(100dvh - 100px); flex: 1; @@ -25,6 +31,85 @@ height: 100%; align-items: center; justify-content: center; + color: rgba(15, 23, 42, 0.5); + font-weight: 500; +} + +.mobileBackButton { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0 1rem 1rem; + border: 0; + background: transparent; + border-radius: 0.5rem; + padding: 0.6rem 0.65rem; + color: #2986cc; + font: inherit; + font-weight: 500; + line-height: 1; + cursor: pointer; +} + +.mobileBackButton::before { + content: ''; + display: inline-block; + width: 0.58rem; + height: 0.58rem; + border-left: 0.14rem solid currentColor; + border-bottom: 0.14rem solid currentColor; + transform: rotate(45deg); + position: relative; + top: -0.5px; + border-bottom-left-radius: 0.04rem; +} + +.mobileBackButton:hover { + color: #549ed6; + background: rgba(15, 23, 42, 0.05); +} + +.detailsNavigationButton { + width: auto; + display: inline-block; + margin-top: 0.35rem; +} + +.detailsNavigationLink { + display: inline-flex; + width: auto; + align-items: center; + justify-content: center; + padding: 0.28rem 0.75rem; + border-radius: 9999px; + border: 1px solid rgba(17, 24, 39, 0.12); + background: rgba(14, 165, 233, 0.12); + color: rgba(2, 132, 199, 1); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.78) inset; +} + +.detailsNavigationLink:hover { + background: rgba(14, 165, 233, 0.18); + border-color: rgba(14, 165, 233, 0.2); +} + +.detailsNavigationLink:active { + transform: translateY(0) scale(0.99); +} + +.detailsNavigationLabel { + flex: 0 0 auto; + width: auto; + max-width: none; + color: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; + white-space: nowrap; } .loading { diff --git a/src/app/admin/panels/contributions/page.tsx b/src/app/admin/panels/contributions/page.tsx index 1d80da28..23d700dc 100644 --- a/src/app/admin/panels/contributions/page.tsx +++ b/src/app/admin/panels/contributions/page.tsx @@ -1,22 +1,39 @@ 'use client' import styles from './page.module.css' -import { ListElement, List } from '@/app/admin/layout/List' import { FormGroup, - FormState, TextField, Form, DateField, } from '@/components/common/forms' +import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' +import Panel from '@/components/common/panel/Panel' +import { SidebarBody } from '@/components/common/panel/sidebar_list/SidebarBody' import { ActBlueDonationPacket, zActBlueDonationPacket } from '@/contracts/data' import { SortDirection } from '@/contracts/requests' import { dateService } from '@/services' import { useFetch, usePaginatedSearch } from '@/util/hooks' import { keepPreviousData, skipToken, useQuery } from '@tanstack/react-query' -import Link from 'next/link' import { useSearchParams } from 'next/navigation' -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { useMediaQuery } from 'usehooks-ts' + +const CONTRIBUTION_FIELD_OPTIONS = [ + { value: 'lineitem_id', label: 'Lineitem ID' }, + { value: 'order_number', label: 'Order Number' }, + { value: 'paid_at', label: 'Paid At' }, + { value: 'firstname', label: 'First Name' }, + { value: 'lastname', label: 'Last Name' }, + { value: 'email', label: 'Email' }, + { value: 'state', label: 'State' }, +] + +const CONTRIBUTION_SORT_FIELD_OPTIONS = [ + { value: 'firstname', label: 'First Name' }, + { value: 'lastname', label: 'Last Name' }, + { value: 'paid_at', label: 'Paid At' }, +] export default function Page() { const { ready, onGet } = useFetch() @@ -26,8 +43,8 @@ export default function Page() { const [selectedLineitemId, setSelectedLineitemId] = useState( navVal ? +navVal : null ) - const [formState, setFormState] = - useState | null>(null) + const [sidebarMobileVisible, setSidebarMobileVisible] = useState(true) + const isDesktop = useMediaQuery('(min-width: 64rem)') const { query: searchQuery, @@ -51,106 +68,149 @@ export default function Page() { placeholderData: keepPreviousData, }) - const format = (value: Date, format?: Intl.DateTimeFormatOptions) => { - if (!dateService.isValid(value)) return undefined - return Intl.DateTimeFormat( - 'en-US', - format ?? { - dateStyle: 'long', - timeStyle: 'medium', - } - ).format(value) - } + useEffect(() => { + if (navVal == null) { + return + } + + const nextSelectedLineitemId = Number(navVal) - const handleSelectItem = (value: ActBlueDonationPacket) => { - if (value.lineitemId === selectedLineitemId) return + setSelectedLineitemId( + Number.isFinite(nextSelectedLineitemId) + ? nextSelectedLineitemId + : null + ) + }, [navVal]) - if (formState?.dirty) { - const proceed = confirm( - 'You have unsaved changes! Selecting a new list element will discard them.' - ) - if (!proceed) return + const formatDate = (value: Date) => { + if (!dateService.isValid(value)) { + return 'Invalid date' } - setSelectedLineitemId(value.lineitemId) + return Intl.DateTimeFormat('en-US', { + dateStyle: 'long', + timeStyle: 'medium', + }).format(value) } const makeTitle = (donation: ActBlueDonationPacket) => { return `${donation.firstName} ${donation.lastName}` } - const renderItem = (item: ActBlueDonationPacket) => { - return ( - handleSelectItem(item)} - > -
- {makeTitle(item)} - - {format(item.paidAt)} - -
-
- ) - } + const contributions = searchQuery.data?.data ?? [] + const resultCount = searchQuery.data?.count return ( - <> - - {searchQuery.data?.data?.map((item) => renderItem(item))} - - + + onSearch({ ...search, page: nextPage }), + }, + filters: { + search, + onSearch, + searchFieldOptions: CONTRIBUTION_FIELD_OPTIONS, + sortFieldOptions: CONTRIBUTION_SORT_FIELD_OPTIONS, + showSort: true, + showLimit: true, + }, + }} + sidebarBody={ + + items={contributions} + isLoading={searchQuery.isPending} + error={searchQuery.error} + selectedKey={selectedLineitemId} + renderItem={(contribution) => ({ + key: contribution.lineitemId, + label: makeTitle(contribution), + subtitle: formatDate(contribution.paidAt), + tagLabel: contribution.isRecurring + ? 'Recurring' + : undefined, + href: `/admin/panels/contributions?lineitemId=${contribution.lineitemId}`, + onClick: (event) => { + event.preventDefault() + setSelectedLineitemId(contribution.lineitemId) + if (!isDesktop) { + setSidebarMobileVisible(false) + } + }, + })} + /> + } + >
+ {!isDesktop && !sidebarMobileVisible ? ( + + ) : null} + {selectedLineitemId == null && (
- No Contribution Selected + No contribution selected +
+ )} + + {selectedLineitemId != null && contributionQuery.isPending && ( +
+ Loading contribution details... +
+ )} + + {selectedLineitemId != null && contributionQuery.error && ( +
+ Error:{' '} + {contributionQuery.error instanceof Error + ? contributionQuery.error.message + : 'Unknown error'}
)} - {selectedLineitemId && contributionQuery.data && ( + + {selectedLineitemId != null && contributionQuery.data && ( key={selectedLineitemId} form={contributionQuery.data} - title={`${makeTitle(contributionQuery.data)}`} + title={makeTitle(contributionQuery.data)} readonly={true} saving={false} isInvalid={false} - onUpdate={setFormState} - onSave={() => { - return - }} + onUpdate={() => undefined} + onSave={() => undefined} > - label="Recurring Amount" - field="recurringAmount" + getter={(form) => + form.recurringAmount == null + ? 'N/A' + : `$${form.recurringAmount}` + } /> - - - - + label="Is Paypal" + getter={(form) => `${form.isPaypal}`} + /> + + label="Is Mobile" + getter={(form) => `${form.isMobile}`} + /> + + label="Is Express" + getter={(form) => `${form.isExpress}`} + /> + label="Is Recurring" - field="isRecurring" + getter={(form) => `${form.isRecurring}`} /> -
- - Full Details - +
( field.answer} /> @@ -214,6 +278,6 @@ export default function Page() { )}
- +
) } diff --git a/src/app/admin/panels/donors/page.module.css b/src/app/admin/panels/donors/page.module.css index b48c3b13..c81bb380 100644 --- a/src/app/admin/panels/donors/page.module.css +++ b/src/app/admin/panels/donors/page.module.css @@ -1,76 +1,371 @@ -.detailsPane { +.sidebarBg { + --navigation-stack-sidebar-bg: #f9fafb; + --navigation-stack-sidebar-padding: 0rem; + --navigation-stack-sidebar-gap: 0rem; +} + +.sidebarSearchInput { + width: 100%; + border-radius: 0.6rem; + border: 1px solid #d1d5db; + padding: 0.35rem 0.75rem; + color: rgba(15, 23, 42, 0.85); + background: rgba(255, 255, 255, 0.9); +} + +.sidebarSearchInput:focus { + border-color: rgba(14, 165, 233, 0.45); + box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.12); + outline: none; +} + +.searchInput { + display: flex; + width: 100%; + align-items: center; + gap: 0.5rem; + padding-inline: 0.5rem; +} + +.searchInput > input { + width: 100%; + border-radius: 0.6rem; + border-width: 1px; + border-style: solid; + border-color: #d1d5db; + padding: 0.25rem 0.75rem; +} + +.searchInput > button { + display: inline-flex; + align-items: center; + justify-content: center; + + border: 0; + background: transparent; + border-radius: 0.85rem; + + padding: 0.5rem; + + cursor: pointer; + color: rgba(15, 23, 42, 0.62); + + gap: 0.5rem; +} + +.searchInput > button:hover { + background: rgba(15, 23, 42, 0.06); +} + +.iconToggleButton { + display: inline-flex; + align-items: center; + justify-content: center; + + border: 0; + background: transparent; + border-radius: 0.85rem; + + padding: 0.5rem; + + cursor: pointer; + color: rgba(15, 23, 42, 0.62); +} + +.iconToggleButton:hover { + background: rgba(15, 23, 42, 0.06); +} + +.permissionNavigationButton { + --navigation-button-item-margin: 0.1rem 0.25rem; + --navigation-button-item-width: auto; + --navigation-button-item-radius: 1rem; + --navigation-button-link-gap: 1.25rem; + --navigation-button-link-padding: 1rem; + --navigation-button-link-radius: 1rem; + --navigation-button-link-color: #000; + --navigation-button-label-font-size: 1rem; + --navigation-button-label-font-weight: 500; + --navigation-button-label-letter-spacing: 0; + --navigation-button-label-color: #000; + --navigation-button-link-hover-bg: #e5e7eb; + --navigation-button-link-active-bg: #e5e7eb; + --navigation-button-link-active-color: #000; + --navigation-button-link-active-hover-bg: #d1d5db; +} + +.sidebarList { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + gap: 0.25rem; + padding: 0.35rem; +} + +.sidebarItem { + display: flex; + width: 100%; + align-items: center; + gap: 0.75rem; + border: 0; + border-radius: 1rem; + background: transparent; + margin: 0; + padding: 0.85rem 0.9rem; + text-align: left; + cursor: pointer; +} + +.sidebarItem:hover { + background-color: #e5e7eb; +} + +.sidebarItem[data-selected='true'] { + background-color: #e5e7eb; +} + +.sidebarItem[data-selected='true']:hover { + background-color: #d1d5db; +} + +.sidebarState { + display: flex; + align-items: center; + justify-content: center; + min-height: 7rem; + color: rgba(15, 23, 42, 0.64); + text-align: center; +} + +.detailPane { height: calc(100dvh - 100px); - flex: 1; + flex: 1 1 auto; overflow-y: auto; + min-height: 0; padding-block: 1rem; } -.userMeta { - display: flex; - flex-direction: column; +.mobileBackButton { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0 1rem 1rem; + border: 0; + background: transparent; + border-radius: 0.5rem; + padding: 0.6rem 0.65rem; + color: #2986cc; + font: inherit; + font-weight: 500; + line-height: 1; + cursor: pointer; +} + +.mobileBackButton::before { + content: ''; + display: inline-block; + width: 0.58rem; + height: 0.58rem; + border-left: 0.14rem solid currentColor; + border-bottom: 0.14rem solid currentColor; + transform: rotate(45deg); + position: relative; + top: -0.5px; + border-bottom-left-radius: 0.04rem; +} + +.mobileBackButton:hover { + color: #549ed6; + background: rgba(15, 23, 42, 0.05); } -.userName { +.element { + display: flex; + + .detailsNavigationButton { + width: auto; + display: inline-block; + margin-top: 0.35rem; + } + + .detailsNavigationLink { + display: inline-flex; + width: auto; + align-items: center; + justify-content: center; + padding: 0.28rem 0.75rem; + border-radius: 9999px; + border: 1px solid rgba(17, 24, 39, 0.12); + background: rgba(14, 165, 233, 0.12); + color: rgba(2, 132, 199, 1); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.78) inset; + } + + .detailsNavigationLink:hover { + background: rgba(14, 165, 233, 0.18); + border-color: rgba(14, 165, 233, 0.2); + } + + .detailsNavigationLink:active { + transform: translateY(0) scale(0.99); + } + + .detailsNavigationLabel { + flex: 0 0 auto; + width: auto; + max-width: none; + color: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; + white-space: nowrap; + } + + .detailsNavigationTagSection { + display: none; + } + cursor: pointer; + align-items: center; + gap: 1.25rem; + padding: 1rem; + border-radius: 1rem; + margin: 0.25rem; font-weight: 500; color: #000; + background-color: #e5e7eb; } -.userUsername { - color: #6b7280; +.element:hover { + background-color: #e5e7eb; +} + +.element.selected { + background-color: #e5e7eb; +} + +.element.selected:hover { + background-color: #d1d5db; } .emptyState { - display: flex; height: 100%; + display: flex; align-items: center; justify-content: center; + color: rgba(15, 23, 42, 0.5); + font-weight: 500; +} + +.filterOverlay { + min-width: 16rem; +} + +.filterOverlayBody { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; +} + +.filterSelect { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.filterSelect > strong { + font-weight: 500; + color: rgba(15, 23, 42, 0.75); } -.loading { +.select { display: flex; align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.select > span { + font-weight: 500; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.7); +} + +.select > select { + border-radius: 0.85rem; + border: 1px solid rgba(17, 24, 39, 0.12); + background: rgba(255, 255, 255, 0.75); + padding: 0.35rem 0.55rem; + color: rgba(15, 23, 42, 0.8); + outline: none; } -.historyContainer { +.pageSelectButtons { + margin-left: auto; + margin-right: auto; display: flex; - flex-direction: column; - width: 100%; - border: 2px solid #e5e7eb; - width: 92%; - margin-left: 1.5rem; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.9rem; } -.historyEntry { +.pageSelectForm { + display: flex; + align-items: center; + gap: 0.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + color: rgba(15, 23, 42, 0.72); +} + +.pageSelectInput { + width: 6ch; + max-width: min-content; + border-radius: 0.65rem; + border-width: 1px; + border-color: #d1d5db; padding: 0.125rem 0.5rem; - text-align: left; - border-bottom-width: 1px; - width: 100%; + text-align: center; + color: rgba(15, 23, 42, 0.62); } -.historyEntry > code { - font-family: - ui-monospace, - SFMono-Regular, - Menlo, - Monaco, - Consolas, - Liberation Mono, - Courier New, - monospace; +.pageSelectInput:focus { + border-color: rgba(14, 165, 233, 0.35); + box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.12); } -.historyEntryDate { - font-weight: 500; +.arrow { + display: flex; + user-select: none; + align-items: center; + justify-content: center; + border-radius: 0.85rem; + padding: 0.45rem; + + font-weight: 600; } -.historyEntry:hover { - background-color: #e5e7eb; +.arrow.disabled { + cursor: not-allowed; + color: rgba(15, 23, 42, 0.28); } -.historyEntry.selected { - background-color: #e5e7eb; +.arrow.enabled { + cursor: pointer; + color: rgba(15, 23, 42, 0.62); } -.historyEntry.selected:hover { - background-color: #d1d5db; +.arrow.enabled:hover { + background: rgba(15, 23, 42, 0.06); +} + +.arrow.enabled:active { + background-color: #f3f4f6; } diff --git a/src/app/admin/panels/donors/page.tsx b/src/app/admin/panels/donors/page.tsx index 53033abb..d4a8384c 100644 --- a/src/app/admin/panels/donors/page.tsx +++ b/src/app/admin/panels/donors/page.tsx @@ -1,7 +1,6 @@ 'use client' import styles from './page.module.css' -import { ListElement, List } from '@/app/admin/layout/List' import { FormState, Form, @@ -9,6 +8,9 @@ import { FormGroup, DateField, } from '@/components/common/forms' +import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' +import Panel from '@/components/common/panel/Panel' +import { SidebarBody } from '@/components/common/panel/sidebar_list/SidebarBody' import { ActBlueDonor, zActBlueDonor, @@ -19,9 +21,9 @@ import { import { SortDirection } from '@/contracts/requests' import { useFetch, usePaginatedSearch } from '@/util/hooks' import { keepPreviousData, skipToken, useQuery } from '@tanstack/react-query' -import Link from 'next/link' import { useSearchParams } from 'next/navigation' -import { useState, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useMediaQuery } from 'usehooks-ts' interface contributionData { total: number @@ -30,6 +32,17 @@ interface contributionData { lineitems: ActBlueLineitem[] } +const DONOR_FIELD_OPTIONS = [ + { value: 'firstname', label: 'First Name' }, + { value: 'lastname', label: 'Last Name' }, + { value: 'email', label: 'Email' }, + { value: 'state', label: 'State' }, +] + +const DONOR_SORT_FIELD_OPTIONS = DONOR_FIELD_OPTIONS.filter( + (option) => option.value !== 'state' +) + export default function Page() { const { ready, onGet } = useFetch() const navParams = useSearchParams() @@ -39,6 +52,8 @@ export default function Page() { const [formState, setFormState] = useState | null>( null ) + const [sidebarMobileVisible, setSidebarMobileVisible] = useState(true) + const isDesktop = useMediaQuery('(min-width: 64rem)') const { query: searchQuery, @@ -61,6 +76,10 @@ export default function Page() { placeholderData: keepPreviousData, }) + useEffect(() => { + setSelectedEmail(navEmail) + }, [navEmail]) + const calcFutureDate = ( initialTime: Date, period: 'weekly' | 'monthly', @@ -132,48 +151,89 @@ export default function Page() { return `${donor.firstname} ${donor.lastname}` } - const renderItem = (item: ActBlueDonor) => { - return ( - handleSelectItem(item)} - > -
- {makeTitle(item)} -
-
- ) - } + const formatLineitemDate = (value: Date) => + Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(value) - return ( - <> - - {searchQuery.data?.data?.map((item) => renderItem(item))} - + const donors = searchQuery.data?.data ?? [] + const resultCount = searchQuery.data?.count -
+ return ( + + onSearch({ ...search, page: nextPage }), + }, + filters: { + search, + onSearch, + searchFieldOptions: DONOR_FIELD_OPTIONS, + sortFieldOptions: DONOR_SORT_FIELD_OPTIONS, + showSort: true, + showLimit: true, + }, + }} + sidebarBody={ + + items={donors} + isLoading={searchQuery.isPending} + error={searchQuery.error} + selectedKey={selectedEmail} + renderItem={(donor) => ({ + key: donor.email, + label: makeTitle(donor), + href: `/admin/panels/donors?email=${donor.email}`, + onClick: (event) => { + event.preventDefault() + handleSelectItem(donor) + if (!isDesktop) { + setSidebarMobileVisible(false) + } + }, + })} + /> + } + > +
+ {!isDesktop && !sidebarMobileVisible ? ( + + ) : null} {selectedEmail == null && ( -
No user selected
+
No donor selected
+ )} + {selectedEmail && donorQuery.isPending && ( +
+ Loading donor details... +
+ )} + {selectedEmail && donorQuery.error && ( +
+ Error: {donorQuery.error.message} +
)} {selectedEmail && donorQuery.data && ( @@ -212,40 +272,44 @@ export default function Page() { label="Employer Name" - getter={(form) => form.employerData?.employer} + getter={(form) => + form.employerData?.employer ?? 'N/A' + } /> label="Occupation" - getter={(form) => form.employerData?.occupation} + getter={(form) => + form.employerData?.occupation ?? 'N/A' + } /> label="Employer Street Address" getter={(form) => - form.employerData?.employerAddr1 + form.employerData?.employerAddr1 ?? 'N/A' } /> label="Employer City" getter={(form) => - form.employerData?.employerCity + form.employerData?.employerCity ?? 'N/A' } /> label="Employer State" getter={(form) => - form.employerData?.employerState + form.employerData?.employerState ?? 'N/A' } /> label="Employer Zip Code" getter={(form) => - `${form.employerData?.employerZip ?? ''}` + `${form.employerData?.employerZip ?? 'N/A'}` } /> label="Employer Country" getter={(form) => - form.employerData?.employerCountry + form.employerData?.employerCountry ?? 'N/A' } /> @@ -271,7 +335,10 @@ export default function Page() { {(contributionData.lineitems ?? []).map( (lineitem) => ( ) )} -
- - Full Details - +
) )} @@ -332,6 +404,6 @@ export default function Page() { )}
- +
) } diff --git a/src/app/admin/panels/fundraising/page.module.css b/src/app/admin/panels/fundraising/page.module.css index 19e3a763..0b36a40a 100644 --- a/src/app/admin/panels/fundraising/page.module.css +++ b/src/app/admin/panels/fundraising/page.module.css @@ -241,95 +241,11 @@ z-index: 1; } -.card { - display: flex; - flex-direction: column; - gap: 0.7rem; - padding: 1rem; - border-radius: 1rem; - text-decoration: none; - background: rgba(255, 255, 255, 0.74); - backdrop-filter: blur(18px) saturate(160%); - -webkit-backdrop-filter: blur(18px) saturate(160%); - border: 1px solid rgba(17, 24, 39, 0.08); - box-shadow: - 0 1px 0 rgba(255, 255, 255, 0.75) inset, - 0 22px 55px rgba(15, 23, 42, 0.12); - transition: - transform 160ms ease, - box-shadow 160ms ease, - background-color 160ms ease; -} - -.card:hover { - transform: translateY(-1px); - box-shadow: - 0 1px 0 rgba(255, 255, 255, 0.78) inset, - 0 26px 70px rgba(15, 23, 42, 0.16); -} - -.card:active { - transform: translateY(0) scale(0.995); -} - -.cardTop { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; -} - -.cardLeft { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.iconPill { - display: inline-flex; - align-items: center; - justify-content: center; - width: 2.5rem; - height: 2.5rem; - border-radius: 0.85rem; - background: rgba(15, 23, 42, 0.06); - color: rgba(15, 23, 42, 0.72); - box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8) inset; -} - -.cardTitle { - margin-left: auto; - font-size: 1.2rem; - font-weight: 800; - color: rgba(9, 34, 58, 0.9); - text-align: right; -} - -.cardCount { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.25rem 1rem; - border-radius: 1rem; - background: rgba(15, 23, 42, 0.06); - color: rgba(15, 23, 42, 0.7); - font-size: 0.82rem; - font-weight: 800; -} - -.cardDescription { - font-size: 0.93rem; - font-weight: 600; - letter-spacing: -0.01em; - color: rgba(15, 23, 42, 0.52); - line-height: 1.35; -} - .dashboard { + position: relative; margin: 0.5rem 1.3rem 0; padding: 1rem; border-radius: 1.1rem; - position: relative; z-index: 20; border: 1px solid rgba(15, 23, 42, 0.12); backdrop-filter: blur(16px) saturate(145%); diff --git a/src/app/admin/panels/fundraising/page.tsx b/src/app/admin/panels/fundraising/page.tsx index abc47ac0..cf621171 100644 --- a/src/app/admin/panels/fundraising/page.tsx +++ b/src/app/admin/panels/fundraising/page.tsx @@ -24,46 +24,13 @@ import { } from '@/components/common' import { Chart } from '@/components/common/charts/DualAxisBarLineChart' import { type ChartGranularityMode } from '@/components/common/charts/timeBuckets' -import Link from 'next/link' +import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' +import Panel from '@/components/common/panel/Panel' import { useEffect, useRef, useState } from 'react' import { FaDonate } from 'react-icons/fa' import { FaDollarSign } from 'react-icons/fa6' import { FiCheck } from 'react-icons/fi' -interface FundraisingCardProps { - title: string - description: string - href: string - icon: React.ComponentType<{ size?: number }> - count?: number -} - -function FundraisingCard({ - title, - description, - href, - icon: Icon, - count, -}: FundraisingCardProps) { - return ( - -
-
- - -
{title}
-
- -
{formatCount(count)}
-
- -
{description}
- - ) -} - export default function Page() { const [dateRangeOverlayMaxHeight, setDateRangeOverlayMaxHeight] = useState() @@ -200,462 +167,415 @@ export default function Page() { }, [draftStartDate, draftEndDate, committedPreset]) return ( -
-
-
- Admin - / - Fundraising -
- - {/* logic will eventually need to be reworked to show last api fetch and not most recent contribution date */} -
Last Updated: N/A
-
-
-
-

Fundraising

-

- Manage ActBlue donors and contribution records. -

-
+ +
+
+
+

Fundraising

+

+ Manage ActBlue donors and contribution records. +

+
-
-
-
-
- {raisedKickerLabel} -
-
- {formatCurrency( - statsQuery.data?.totalDollarsRaised - )} +
+
+
+
+ {raisedKickerLabel} +
+
+ {formatCurrency( + statsQuery.data?.totalDollarsRaised + )} +
-
- -
-
-
- - { - setDraftStartDate(startDate) - setDraftEndDate(endDate) - setDraftPreset(committedPreset) - }} - menu={({ closeDropdown }) => ( - { - closeDropdown() - }} - body={ - <> -
- {PRESETS.map( - (preset) => { - const isCommitted = - committedPreset === - preset - const isDraft = - draftPreset === +
+
+
+ + + { + setDraftStartDate(startDate) + setDraftEndDate(endDate) + setDraftPreset(committedPreset) + }} + menu={({ closeDropdown }) => ( + { + closeDropdown() + }} + body={ + <> +
+ {PRESETS.map( + ( preset - const classes = - [ - styles.dateRangePresetButton, - ] - if ( - isCommitted - ) - classes.push( - styles.dateRangePresetButtonCommitted + ) => { + const isCommitted = + committedPreset === + preset + const isDraft = + draftPreset === + preset + const classes = + [ + styles.dateRangePresetButton, + ] + if ( + isCommitted ) - if (isDraft) - classes.push( - styles.dateRangePresetButtonDraft + classes.push( + styles.dateRangePresetButtonCommitted + ) + if ( + isDraft ) - return ( - - ) - } - )} -
+ + { + preset + } + + + + ) + } + )} +
- { - setDraftStartDate( - nextStartDate - ) - setDraftEndDate( - nextEndDate - ) - setDraftPreset( - inferPresetFromRange( - nextStartDate, - nextEndDate, - allTimeFirst + { + setDraftStartDate( + nextStartDate ) - ) - }} - /> - - } - bodyClassName={ - styles.dateRangePopBody - } - footerButtonLabel={ - isAwaitingDraftEndDate - ? 'Select End Date' - : 'Select' - } - footerButtonDisabled={ - !canApplyCustomRange - } - footerButtonOnClick={() => { - if (!canApplyCustomRange) - return - setStartDate(draftStartDate) - setEndDate(draftEndDate) - setCommittedPreset( - draftPreset - ) - closeDropdown() - }} - /> - )} - /> + setDraftEndDate( + nextEndDate + ) + setDraftPreset( + inferPresetFromRange( + nextStartDate, + nextEndDate, + allTimeFirst + ) + ) + }} + /> + + } + bodyClassName={ + styles.dateRangePopBody + } + footerButtonLabel={ + isAwaitingDraftEndDate + ? 'Select End Date' + : 'Select' + } + footerButtonDisabled={ + !canApplyCustomRange + } + footerButtonOnClick={() => { + if ( + !canApplyCustomRange + ) + return + setStartDate( + draftStartDate + ) + setEndDate(draftEndDate) + setCommittedPreset( + draftPreset + ) + closeDropdown() + }} + /> + )} + /> +
-
-
- - {recurringChangeLabel} - - ) : null - } - stat1={ - recurringPct != null && - statsQuery.data?.recurringContributionCount != - null - ? `${recurringPct}% of total` - : '—' - } - stat2={ - statsQuery.data?.recurringContributionCount != - null - ? formatDonationCountLabel( - statsQuery.data - .recurringContributionCount - ) - : '—' - } - /> - - + + {recurringChangeLabel} + + ) : null + } + stat1={ + recurringPct != null && + statsQuery.data + ?.recurringContributionCount != null + ? `${recurringPct}% of total` + : '—' + } + stat2={ + statsQuery.data + ?.recurringContributionCount != null + ? formatDonationCountLabel( + statsQuery.data + .recurringContributionCount + ) + : '—' + } + /> + + - - - - -
- - formatCurrency(value), - secondaryBar: (value) => formatCurrency(value), - line: (value) => formatCount(value), - }} - axisFormatters={{ - left: formatCurrencyAxis, - right: formatCountAxis, - }} - ariaLabel="Fundraising volume by period: one-time and recurring dollars with total donations" - chartAriaLabel={`Fundraising volume across ${chartPoints.length} periods`} - tableConfig={{ - caption: 'Fundraising volume per period', - periodHeader: 'Period', - primaryBarHeader: 'One-Time', - secondaryBarHeader: 'Recurring', - lineHeader: 'Donations', - }} - onRangeSelect={ - zoomEnabled - ? ({ - start, - end, - }: { - start: Date - end: Date - }) => { - const oneDayMs = 24 * 60 * 60 * 1000 - const inclusiveRangeMs = - end.getTime() - start.getTime() + 1000 - - if ( - Number.isNaN(start.getTime()) || - Number.isNaN(end.getTime()) - ) { - return - } - - if (inclusiveRangeMs < oneDayMs) { - return - } + ? formatDonationCountLabel( + statsQuery.data + .oneTimeContributionCount + ) + : '—' + } + /> + + + + +
- applyChartViewOverrideRange({ + formatCurrency(value), + secondaryBar: (value) => formatCurrency(value), + line: (value) => formatCount(value), + }} + axisFormatters={{ + left: formatCurrencyAxis, + right: formatCountAxis, + }} + ariaLabel="Fundraising volume by period: one-time and recurring dollars with total donations" + chartAriaLabel={`Fundraising volume across ${chartPoints.length} periods`} + tableConfig={{ + caption: 'Fundraising volume per period', + periodHeader: 'Period', + primaryBarHeader: 'One-Time', + secondaryBarHeader: 'Recurring', + lineHeader: 'Donations', + }} + onRangeSelect={ + zoomEnabled + ? ({ start, end, - }) - } - : undefined - } - cornerTopRight={ - chartViewOverrideActive ? ( - - ) : null - } - headerRight={ -
- ( - { - closeDropdown() - }} - bodyClassName={ - styles.chartOptionsBody - } - body={ - <> -
- - Time scale - - - ariaLabel="Time scale" - orientation="horizontal" - value={ - granularityMode - } - options={( - [ - 'auto', - 'year', - 'quarter', - 'month', - 'week', - 'day', - 'hour', - ] as ChartGranularityMode[] - ) - .filter( - (mode) => - validGranularityModes.includes( - mode - ) - ) - .map( - (mode) => ({ - value: mode, - label: CHART_GRANULARITY_LABELS[ - mode - ], - }) - )} - onChange={ - setGranularityMode - } - /> -
- - {isXToDateSpanOptionRelevant && ( + }: { + start: Date + end: Date + }) => { + const oneDayMs = 24 * 60 * 60 * 1000 + const inclusiveRangeMs = + end.getTime() - + start.getTime() + + 1000 + + if ( + Number.isNaN(start.getTime()) || + Number.isNaN(end.getTime()) + ) { + return + } + + if (inclusiveRangeMs < oneDayMs) { + return + } + + applyChartViewOverrideRange({ + start, + end, + }) + } + : undefined + } + cornerTopRight={ + chartViewOverrideActive ? ( + + ) : null + } + headerRight={ +
+ ( + { + closeDropdown() + }} + bodyClassName={ + styles.chartOptionsBody + } + body={ + <>
- Show{' '} - {getXToDatePeriodName( - committedPreset, - granularityMode - )}{' '} - To Date + Time scale - - ariaLabel="X-to-date span" + + ariaLabel="Time scale" orientation="horizontal" value={ - showFullXToDateSpan + granularityMode } - options={[ - { - value: true, - label: 'Hide', - }, - { - value: false, - label: 'Show', - }, - ]} + options={( + [ + 'auto', + 'year', + 'quarter', + 'month', + 'week', + 'day', + 'hour', + ] as ChartGranularityMode[] + ) + .filter( + ( + mode + ) => + validGranularityModes.includes( + mode + ) + ) + .map( + ( + mode + ) => ({ + value: mode, + label: CHART_GRANULARITY_LABELS[ + mode + ], + }) + )} onChange={ - setShowFullXToDateSpan + setGranularityMode } />
- )} - -
- - Contributions line - - - ariaLabel="Contributions line" - orientation="horizontal" - value={ - showDonationsLine - } - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={ - setShowDonationsLine - } - /> -
- {showDonationsLine && ( - <> + {isXToDateSpanOptionRelevant && (
- Line style + Show{' '} + {getXToDatePeriodName( + committedPreset, + granularityMode + )}{' '} + To Date - ariaLabel="Line style" + ariaLabel="X-to-date span" orientation="horizontal" value={ - smoothLine + showFullXToDateSpan } options={[ { value: true, - label: 'Curved', + label: 'Hide', }, { value: false, - label: 'Straight', + label: 'Show', }, ]} onChange={ - setSmoothLine + setShowFullXToDateSpan } />
+ )} -
+ - + + ariaLabel="Contributions line" + orientation="horizontal" + value={ + showDonationsLine + } + options={[ + { + value: true, + label: 'Show', + }, + { + value: false, + label: 'Hide', + }, + ]} + onChange={ + setShowDonationsLine + } + /> +
+ + {showDonationsLine && ( + <> +
- Area fill - - - ariaLabel="Area fill" - orientation="horizontal" - value={ - showAreaFill - } - options={[ - { - value: true, - label: 'Show', - }, - { - value: false, - label: 'Hide', - }, - ]} - onChange={ - setShowAreaFill + + Line + style + + + ariaLabel="Line style" + orientation="horizontal" + value={ + smoothLine + } + options={[ + { + value: true, + label: 'Curved', + }, + { + value: false, + label: 'Straight', + }, + ]} + onChange={ + setSmoothLine + } + /> +
+ +
-
- - )} - -
- + + Area + fill + + + ariaLabel="Area fill" + orientation="horizontal" + value={ + showAreaFill + } + options={[ + { + value: true, + label: 'Show', + }, + { + value: false, + label: 'Hide', + }, + ]} + onChange={ + setShowAreaFill + } + /> +
+ + )} + +
- Drag To Zoom - - - ariaLabel="Chart zoom" - orientation="horizontal" - value={zoomEnabled} - options={[ - { - value: true, - label: 'Enabled', - }, - { - value: false, - label: 'Disabled', - }, - ]} - onChange={ - setZoomEnabled - } - /> -
- -
- + Drag To Zoom + + + ariaLabel="Chart zoom" + orientation="horizontal" + value={ + zoomEnabled + } + options={[ + { + value: true, + label: 'Enabled', + }, + { + value: false, + label: 'Disabled', + }, + ]} + onChange={ + setZoomEnabled + } + /> +
+ +
- Bar layout - - - ariaLabel="Bar layout" - orientation="horizontal" - value={ - chartBarDisplayMode - } - options={[ - { - value: 'stacked', - label: 'Stacked', - }, - { - value: 'grouped', - label: 'Seperate', - }, - ]} - onChange={ - setChartBarDisplayMode - } - /> -
- - } - /> - )} - /> -
- } - /> - - - -
+ + Bar layout + + + ariaLabel="Bar layout" + orientation="horizontal" + value={ + chartBarDisplayMode + } + options={[ + { + value: 'stacked', + label: 'Stacked', + }, + { + value: 'grouped', + label: 'Seperate', + }, + ]} + onChange={ + setChartBarDisplayMode + } + /> +
+ + } + /> + )} + /> +
+ } + /> -
- - - + + +
+ +
+ + + +
-
+ ) } diff --git a/src/app/admin/panels/members/page.module.css b/src/app/admin/panels/members/page.module.css index 48e05dd8..639c8131 100644 --- a/src/app/admin/panels/members/page.module.css +++ b/src/app/admin/panels/members/page.module.css @@ -1,5 +1,41 @@ -.listWidth { - --list-width: 25.5rem; +.sidebarBg { + --navigation-stack-sidebar-bg: #f9fafb; + --navigation-stack-sidebar-padding: 0rem; + --navigation-stack-sidebar-gap: 0rem; +} + +.mobileBackButton { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0 1rem 1rem; + border: 0; + background: transparent; + border-radius: 0.5rem; + padding: 0.6rem 0.65rem; + color: #2986cc; + font: inherit; + font-weight: 500; + line-height: 1; + cursor: pointer; +} + +.mobileBackButton::before { + content: ''; + display: inline-block; + width: 0.58rem; + height: 0.58rem; + border-left: 0.14rem solid currentColor; + border-bottom: 0.14rem solid currentColor; + transform: rotate(45deg); + position: relative; + top: -0.5px; + border-bottom-left-radius: 0.04rem; +} + +.mobileBackButton:hover { + color: #549ed6; + background: rgba(15, 23, 42, 0.05); } .detailsHeader { @@ -65,42 +101,20 @@ rgba(241, 245, 249, 0.98) 38%, rgba(14, 165, 233, 0.04) 100% ); + z-index: 0; } .detailsContent { margin: 1rem; } -.userMeta { - display: flex; - flex-direction: column; -} - -.rolePill { - font-size: 0.85rem; - font-weight: 600; - padding-inline: 1rem; - padding-block: 0.45rem; - border-radius: 9999px; - white-space: nowrap; - color: rgba(15, 23, 42, 0.72); - background: rgba(15, 23, 42, 0.06); -} - -.userName { - font-weight: 500; - color: #000; -} - -.userUsername { - color: #6b7280; -} - .emptyState { display: flex; height: 100%; align-items: center; justify-content: center; + color: rgba(15, 23, 42, 0.5); + font-weight: 500; } .loading { @@ -108,13 +122,10 @@ align-items: center; } -@media (max-width: 720px) { - .detailsHeader { - --banner-cover-height: 4rem; - padding: 0.45rem 0.45rem 0.35rem; - } +.filterTagsWrapper { + border-bottom: 1px solid rgba(17, 24, 39, 0.08); +} - .bannerCover { - height: var(--banner-cover-height); - } +:global([data-sidebar-collapsed='true']) .filterTagsWrapper { + display: none; } diff --git a/src/app/admin/panels/members/page.tsx b/src/app/admin/panels/members/page.tsx index b165ade2..5eac3d0e 100644 --- a/src/app/admin/panels/members/page.tsx +++ b/src/app/admin/panels/members/page.tsx @@ -6,9 +6,11 @@ import { DonorView } from './panel_views/DonorView' import { HistoryView } from './panel_views/HistoryView' import { MemberView } from './panel_views/MemberView' import { FilterTags, FilterTag } from '@/app/admin/layout/FilterTags' -import { ListElement, List } from '@/app/admin/layout/List' +import { ListElement } from '@/app/admin/layout/List' import { DiscordAvatar } from '@/components/common' import { FormState } from '@/components/common/forms' +import Panel from '@/components/common/panel/Panel' +import { SidebarBody } from '@/components/common/panel/sidebar_list/SidebarBody' import { TabSpec } from '@/components/common/tab_bar/TabBar' import { ActBlueDonor, @@ -39,10 +41,11 @@ import { useQuery, useQueryClient, } from '@tanstack/react-query' -import { useCallback, useMemo, useState } from 'react' +import { useSearchParams } from 'next/navigation' +import { useCallback, useEffect, useMemo, useState } from 'react' import { FaUsers, FaUserTag } from 'react-icons/fa' import { FaClipboardUser, FaDollarSign, FaAddressCard } from 'react-icons/fa6' -import { PulseLoader } from 'react-spinners' +import { useMediaQuery } from 'usehooks-ts' import z from 'zod' type MemberTabKey = 'overview' | 'donorMatching' | 'history' @@ -53,11 +56,44 @@ const tabs: TabSpec[] = [ { key: 'history', label: 'History' }, ] +const MEMBER_FIELD_OPTIONS = [ + { value: 'email', label: 'Email' }, + { value: 'phone', label: 'Phone Number' }, + { value: 'zip', label: 'Zip Code' }, + { value: 'county', label: 'County' }, + { value: 'city', label: 'City' }, + { value: 'state', label: 'State' }, + { value: 'preferred_name', label: 'Preferred Name' }, + { value: 'first_name', label: 'First Name' }, + { value: 'last_name', label: 'Last Name' }, + { value: 'birthdate', label: 'Birthdate' }, + { value: 'accepted_alerts', label: 'Accepted Notifications' }, + { value: 'onboarding_stage', label: 'Onboarding Stage' }, + { value: 'created_at_utc', label: 'Date Created' }, + { value: 'joined_at_utc', label: 'Date Joined Server' }, + { value: 'completed_intake_utc', label: 'Date Intake Done' }, + { value: 'aliases', label: 'Aliases' }, + { value: 'discord_usernames', label: 'Discord Usernames' }, +] + +const MEMBER_SORT_FIELD_OPTIONS = [ + { value: 'email', label: 'Email' }, + { value: 'first_name', label: 'First Name' }, + { value: 'last_name', label: 'Last Name' }, + { value: 'created_at_utc', label: 'Created At' }, + { value: 'updated_at_utc', label: 'Recently Edited' }, +] + export default function Page() { const queryClient = useQueryClient() const { ready, onGet, onPatch, onPost } = useFetch() + const navParams = useSearchParams() + const navUserId = navParams.get('userId') - const [selectedId, setSelectedId] = useState(null) + const initialUserId = navUserId ? Number(navUserId) : null + const [selectedId, setSelectedId] = useState( + Number.isFinite(initialUserId) ? initialUserId : null + ) const [selectedHistory, setSelectedHistory] = useState | null>(null) @@ -142,6 +178,9 @@ export default function Page() { onSearch({ ...rest, page: 0, ...tagFilters }) } + const [sidebarMobileVisible, setSidebarMobileVisible] = useState(true) + const isDesktop = useMediaQuery('(min-width: 64rem)') + const loggedInUser = useCurrentUser() const positionQueries = usePositionQueries() @@ -450,27 +489,29 @@ export default function Page() { [handleSelectDonorItem] ) - const renderItem = (item: User | UserProfile) => { - return ( - handleSelectItem(item)} - > - -
- {makeTitle(item)} - - {item.discordUsers?.[0]?.username ?? 'NOT FOUND'} - -
-
- ) - } + const pinnedUsers = useMemo(() => { + const currentUser = loggedInUser.data + return currentUser ? [currentUser] : [] + }, [loggedInUser.data]) + + const users = useMemo(() => { + const fetchedUsers = searchQuery.data?.data ?? [] + const currentUser = loggedInUser.data + + if (!currentUser) return fetchedUsers + + return fetchedUsers.filter((user) => user.id !== currentUser.id) + }, [searchQuery.data?.data, loggedInUser.data]) + + useEffect(() => { + if (navUserId == null) { + return + } + + const nextSelectedId = Number(navUserId) + + setSelectedId(Number.isFinite(nextSelectedId) ? nextSelectedId : null) + }, [navUserId]) const renderPage = () => { if (!selectedId || !userQuery.data) return null @@ -538,70 +579,38 @@ export default function Page() { } return ( - <> -
- - -
- } - searchFields={[ - { value: 'first_name', label: 'First Name' }, - { value: 'last_name', label: 'Last Name' }, - { - value: 'discord_usernames', - label: 'Discord Username', - }, - { value: 'email', label: 'Email' }, - { value: 'phone', label: 'Phone Number' }, - { value: 'zip', label: 'Zip Code' }, - { value: 'birthdate', label: 'Birthdate' }, - { value: 'created_at_utc', label: 'Join Date' }, - // { value: 'county', label: 'County' }, - // { value: 'city', label: 'City' }, - // { value: 'state', label: 'State' }, - // { value: 'preferred_name', label: 'Preferred Name' }, - - // { - // value: 'accepted_alerts', - // label: 'Accepted Notifications', - // }, - // { value: 'onboarding_stage', label: 'Onboarding Stage' }, - // { value: 'joined_at_utc', label: 'Date Joined Server' }, - // { - // value: 'completed_intake_utc', - // label: 'Date Intake Done', - // }, - // { value: 'aliases', label: 'Aliases' }, - ]} - sortFields={[ - { value: 'email', label: 'Email' }, - { value: 'first_name', label: 'First Name' }, - { value: 'last_name', label: 'Last Name' }, - { value: 'created_at_utc', label: 'Created At' }, - { value: 'updated_at_utc', label: 'Date Modified' }, - ]} - filters={[ + + onSearch({ ...search, page: nextPage }), + }, + filters: { + search, + onSearch, + searchFieldOptions: MEMBER_FIELD_OPTIONS, + sortFieldOptions: MEMBER_SORT_FIELD_OPTIONS, + showSort: true, + showLimit: true, + options: [ { label: 'Role', value: 'roleIds', @@ -610,35 +619,87 @@ export default function Page() { value: role.id, })), }, - ]} - pinnedContent={ - loggedInUser.data ? ( - renderItem(loggedInUser.data) - ) : ( -
    - - -
    - -
    -
    -
- ) - } - onSearch={onSearch} - > - {searchQuery.data?.data?.map((item) => renderItem(item))} - -
- + ], + }, + }} + sidebarBody={ + <> +
+ +
+ + items={users} + pinnedItems={pinnedUsers} + isLoading={searchQuery.isPending} + error={searchQuery.error} + selectedKey={selectedId} + renderItem={(user) => ({ + key: user.id, + label: makeTitle(user), + subtitle: user.discordUsers?.[0]?.username + ? `@${user.discordUsers[0].username}` + : 'NOT FOUND', + tagLabel: + user.id === loggedInUser.data?.id + ? 'You' + : undefined, + iconNode: ( + + ), + href: `/admin/panels/members?userId=${user.id}`, + onClick: (event) => { + event.preventDefault() + handleSelectItem(user) + if (!isDesktop) { + setSidebarMobileVisible(false) + } + }, + })} + /> + + } + >
+ {!isDesktop && !sidebarMobileVisible ? ( + + ) : null} + {selectedId == null && (
No user selected
)} + + {selectedId != null && userQuery.isPending && ( +
+ Loading user details... +
+ )} + + {selectedId != null && userQuery.error && ( +
+ Error:{' '} + {userQuery.error instanceof Error + ? userQuery.error.message + : 'Unknown error'} +
+ )} + {selectedId && userQuery.data && ( <>
@@ -660,6 +721,6 @@ export default function Page() { )}
- + ) } diff --git a/src/app/admin/panels/members/panel_views/DonorView.module.css b/src/app/admin/panels/members/panel_views/DonorView.module.css index cc99eeea..362c4709 100644 --- a/src/app/admin/panels/members/panel_views/DonorView.module.css +++ b/src/app/admin/panels/members/panel_views/DonorView.module.css @@ -173,3 +173,199 @@ color: #475569; padding-right: 1rem; } + +.modalBackdrop { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + padding: 1.25rem; + background: rgba(2, 6, 23, 0.38); + backdrop-filter: blur(10px) saturate(140%); + -webkit-backdrop-filter: blur(10px) saturate(140%); + will-change: opacity, backdrop-filter; +} + +.modal { + position: relative; + width: min(56rem, 100%); + height: min(80dvh, 48rem); + min-height: 18rem; + display: flex; + flex-direction: column; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.86); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + border: 1px solid rgba(17, 24, 39, 0.12); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.75) inset, + 0 30px 80px rgba(15, 23, 42, 0.35); + overflow: hidden; + transform-origin: 50% 40%; + will-change: transform, opacity; +} + +.modal::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient( + to bottom, + rgba(255, 255, 255, 0.55), + rgba(255, 255, 255, 0) 22% + ); + opacity: 0.35; +} + +.modalHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.95rem 1rem; + border-bottom: 1px solid rgba(17, 24, 39, 0.1); + background: rgba(255, 255, 255, 0.6); +} + +.modalHeaderLeft { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.modalTitle { + font-size: 1.05rem; + font-weight: 650; + letter-spacing: -0.01em; + color: #0f172a; +} + +.modalSubtitle { + font-size: 0.92rem; + color: #475569; + line-height: 1.35; +} + +.modalHeaderRight { + display: flex; + align-items: center; + gap: 0.6rem; + flex-shrink: 0; +} + +.modalBody { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: auto; + padding: 0; + background: rgba(255, 255, 255, 0.62); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + font-weight: 500; + padding-inline: 1rem; +} + +.modalBody :global(.pinned), +.modalBody :global(.elementList), +.modalBody :global(.listStatus) { + padding: 0.9rem 1rem 1rem; +} + +.modalBody :global(.listStatus) { + flex: 1; + min-height: 0; +} + +.modalFooter { + padding: 0.5rem 1rem; + border-top: 1px solid rgba(17, 24, 39, 0.1); + background: rgba(255, 255, 255, 0.6); + display: flex; + justify-content: flex-end; + gap: 0.6rem; +} + +.modalFooterButton { + min-width: 7rem; +} + +.modalSearch { + width: min(22rem, 52vw); +} + +.searchInputBare { + display: flex; + width: 100%; + align-items: center; +} + +.searchInputBare > input { + width: 100%; + height: 2.35rem; + line-height: 2.35rem; + border-radius: 0.6rem; + border: 1px solid #d1d5db; + padding: 0 0.75rem; + background: rgba(255, 255, 255, 0.75); +} + +.detailsNavigationButton { + width: auto; + display: inline-block; + margin-top: 0.35rem; +} + +.detailsNavigationLink { + display: inline-flex; + width: auto; + align-items: center; + justify-content: center; + padding: 0.28rem 0.75rem; + border-radius: 9999px; + background: rgba(14, 165, 233, 0.12); + color: rgba(2, 132, 199, 1); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.detailsNavigationLink:hover { + background: rgba(14, 165, 233, 0.18); +} + +.detailsNavigationLink:active { + transform: translateY(0) scale(0.99); +} + +.detailsNavigationLabel { + flex: 0 0 auto; + width: auto; + max-width: none; + color: inherit; + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; + white-space: nowrap; +} + +.detailsNavigationTagSection { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + .modalBackdrop { + will-change: auto; + } + + .modal { + will-change: auto; + } +} diff --git a/src/app/admin/panels/members/panel_views/DonorView.tsx b/src/app/admin/panels/members/panel_views/DonorView.tsx index d30b484f..f52d9bae 100644 --- a/src/app/admin/panels/members/panel_views/DonorView.tsx +++ b/src/app/admin/panels/members/panel_views/DonorView.tsx @@ -9,6 +9,7 @@ import { FormGroup, TextField, } from '@/components/common/forms' +import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' import { ActBlueContribution, ActBlueContributionCustomField, @@ -99,6 +100,12 @@ const calcContributionData = (donor: ActBlueDonor): ContributionData => { } } +const formatLineitemDate = (value: Date) => + Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(value) + export function DonorView({ selectedId, user, @@ -366,17 +373,112 @@ export function DonorView({ /> - - Open in Donors Panel - + {( + contributionData.lineitems ?? + [] + ).map((lineitem) => ( + + + label="Paid At" + getter={() => + lineitem.paidAt + } + /> + + label="Sequence" + getter={() => + `${lineitem.sequence}` + } + /> + + label="Amount" + getter={() => + `$${lineitem.amount}` + } + /> + + label="Recurring Amount" + getter={() => + `$${lineitem.recurringAmount}` + } + /> + + label="Amount Less AB Fees" + getter={() => + `$${lineitem.amountLessAbFees}` + } + /> + + donor + .contributions?.[0] + ?.contributionForm + } + /> + {contributionData.customFields?.map( + (field) => ( + + field.answer + } + /> + ) + )} + + + + ))} +
diff --git a/src/app/admin/panels/permissions/page.module.css b/src/app/admin/panels/permissions/page.module.css index 3a109c84..53a348c1 100644 --- a/src/app/admin/panels/permissions/page.module.css +++ b/src/app/admin/panels/permissions/page.module.css @@ -1,3 +1,143 @@ +.sidebarBg { + --navigation-stack-sidebar-bg: #f9fafb; + --navigation-stack-sidebar-padding: 0rem; + --navigation-stack-sidebar-gap: 0rem; +} + +.sidebarSearchInput { + width: 100%; + border-radius: 0.6rem; + border: 1px solid #d1d5db; + padding: 0.35rem 0.75rem; + color: rgba(15, 23, 42, 0.85); + background: rgba(255, 255, 255, 0.9); +} + +.sidebarSearchInput:focus { + border-color: rgba(14, 165, 233, 0.45); + box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.12); + outline: none; +} + +.searchInput { + display: flex; + width: 100%; + align-items: center; + gap: 0.5rem; + padding-inline: 0.5rem; +} + +.searchInput > input { + width: 100%; + border-radius: 0.6rem; + border-width: 1px; + border-style: solid; + border-color: #d1d5db; + padding: 0.25rem 0.75rem; +} + +.searchInput > button { + display: inline-flex; + align-items: center; + justify-content: center; + + border: 0; + background: transparent; + border-radius: 0.85rem; + + padding: 0.5rem; + + cursor: pointer; + color: rgba(15, 23, 42, 0.62); + + gap: 0.5rem; +} + +.searchInput > button:hover { + background: rgba(15, 23, 42, 0.06); +} + +.iconToggleButton { + display: inline-flex; + align-items: center; + justify-content: center; + + border: 0; + background: transparent; + border-radius: 0.85rem; + + padding: 0.5rem; + + cursor: pointer; + color: rgba(15, 23, 42, 0.62); +} + +.iconToggleButton:hover { + background: rgba(15, 23, 42, 0.06); +} + +.permissionNavigationButton { + --navigation-button-item-margin: 0.1rem 0.25rem; + --navigation-button-item-width: auto; + --navigation-button-item-radius: 1rem; + --navigation-button-link-gap: 1.25rem; + --navigation-button-link-padding: 1rem; + --navigation-button-link-radius: 1rem; + --navigation-button-link-color: #000; + --navigation-button-label-font-size: 1rem; + --navigation-button-label-font-weight: 500; + --navigation-button-label-letter-spacing: 0; + --navigation-button-label-color: #000; + --navigation-button-link-hover-bg: #e5e7eb; + --navigation-button-link-active-bg: #e5e7eb; + --navigation-button-link-active-color: #000; + --navigation-button-link-active-hover-bg: #d1d5db; +} + +.sidebarList { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + gap: 0.25rem; + padding: 0.35rem; +} + +.sidebarItem { + display: flex; + width: 100%; + align-items: center; + gap: 0.75rem; + border: 0; + border-radius: 1rem; + background: transparent; + margin: 0; + padding: 0.85rem 0.9rem; + text-align: left; + cursor: pointer; +} + +.sidebarItem:hover { + background-color: #e5e7eb; +} + +.sidebarItem[data-selected='true'] { + background-color: #e5e7eb; +} + +.sidebarItem[data-selected='true']:hover { + background-color: #d1d5db; +} + +.sidebarState { + display: flex; + align-items: center; + justify-content: center; + min-height: 7rem; + color: rgba(15, 23, 42, 0.64); + text-align: center; +} + .detailPane { height: calc(100dvh - 100px); flex: 1 1 auto; @@ -6,9 +146,63 @@ padding-block: 1rem; } -.listItemText { +.mobileBackButton { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0 1rem 1rem; + border: 0; + background: transparent; + border-radius: 0.5rem; + padding: 0.6rem 0.65rem; + color: #2986cc; + font: inherit; + font-weight: 500; + line-height: 1; + cursor: pointer; +} + +.mobileBackButton::before { + content: ''; + display: inline-block; + width: 0.58rem; + height: 0.58rem; + border-left: 0.14rem solid currentColor; + border-bottom: 0.14rem solid currentColor; + transform: rotate(45deg); + position: relative; + top: -0.5px; + border-bottom-left-radius: 0.04rem; +} + +.mobileBackButton:hover { + color: #549ed6; + background: rgba(15, 23, 42, 0.05); +} + +.element { + display: flex; + cursor: pointer; + align-items: center; + gap: 1.25rem; + padding: 1rem; + border-radius: 1rem; + margin: 0.25rem; font-weight: 500; color: #000; + background-color: #e5e7eb; +} + +.element:hover { + background-color: #e5e7eb; +} + +.element.selected { + background-color: #e5e7eb; +} + +.element.selected:hover { + background-color: #d1d5db; } .emptyState { @@ -17,3 +211,112 @@ align-items: center; justify-content: center; } + +.filterOverlay { + min-width: 16rem; +} + +.filterOverlayBody { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; +} + +.filterSelect { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.filterSelect > strong { + font-weight: 500; + color: rgba(15, 23, 42, 0.75); +} + +.select { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.select > span { + font-weight: 500; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.7); +} + +.select > select { + border-radius: 0.85rem; + border: 1px solid rgba(17, 24, 39, 0.12); + background: rgba(255, 255, 255, 0.75); + padding: 0.35rem 0.55rem; + color: rgba(15, 23, 42, 0.8); + outline: none; +} + +.pageSelectButtons { + margin-left: auto; + margin-right: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.9rem; +} + +.pageSelectForm { + display: flex; + align-items: center; + gap: 0.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + color: rgba(15, 23, 42, 0.72); +} + +.pageSelectInput { + width: 6ch; + max-width: min-content; + border-radius: 0.65rem; + border-width: 1px; + border-color: #d1d5db; + padding: 0.125rem 0.5rem; + text-align: center; + color: rgba(15, 23, 42, 0.62); +} + +.pageSelectInput:focus { + border-color: rgba(14, 165, 233, 0.35); + box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.12); +} + +.arrow { + display: flex; + user-select: none; + align-items: center; + justify-content: center; + border-radius: 0.85rem; + padding: 0.45rem; + + font-weight: 600; +} + +.arrow.disabled { + cursor: not-allowed; + color: rgba(15, 23, 42, 0.28); +} + +.arrow.enabled { + cursor: pointer; + color: rgba(15, 23, 42, 0.62); +} + +.arrow.enabled:hover { + background: rgba(15, 23, 42, 0.06); +} + +.arrow.enabled:active { + background-color: #f3f4f6; +} diff --git a/src/app/admin/panels/permissions/page.tsx b/src/app/admin/panels/permissions/page.tsx index 89e9d1cd..9e72f25f 100644 --- a/src/app/admin/panels/permissions/page.tsx +++ b/src/app/admin/panels/permissions/page.tsx @@ -1,13 +1,14 @@ 'use client' import styles from './page.module.css' -import { ListElement, List } from '@/app/admin/layout/List' import { Form, FormGroup, FormState, TextField, } from '@/components/common/forms' +import Panel from '@/components/common/panel/Panel' +import { SidebarBody } from '@/components/common/panel/sidebar_list/SidebarBody' import { Permission, zPermission } from '@/contracts/data' import { SortDirection, UpdatePermissionRequest } from '@/contracts/requests' import { PaginatedResponse } from '@/contracts/responses' @@ -15,6 +16,7 @@ import { FetchError } from '@/models' import { useFetch, usePaginatedSearch } from '@/util/hooks' import { useMutation, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' +import { useMediaQuery } from 'usehooks-ts' export default function Page() { const queryClient = useQueryClient() @@ -22,6 +24,8 @@ export default function Page() { const [selectedPermission, setSelectedPermission] = useState(null) + const [sidebarMobileVisible, setSidebarMobileVisible] = useState(true) + const isDesktop = useMediaQuery('(min-width: 64rem)') const [formState, setFormState] = useState | null>( null @@ -92,17 +96,18 @@ export default function Page() { queryClient.invalidateQueries({ queryKey: ['/roles', search] }), }) - const handleSelectItem = (value: Permission) => { - if (value.id === selectedPermission?.id) return + const handleSelectItem = (value: Permission): boolean => { + if (value.id === selectedPermission?.id) return false if (formState?.dirty) { const proceed = confirm( 'You have unsaved changes! Selecting a new list element will discard them.' ) - if (!proceed) return + if (!proceed) return false } setSelectedPermission(value) + return true } const handleSave = (permission: Permission) => { @@ -113,27 +118,66 @@ export default function Page() { }) } - return ( - <> - - {searchQuery.data?.data?.map((item) => ( - handleSelectItem(item)} - > - {item.name} - - ))} - + const permissions = searchQuery.data?.data ?? [] + const resultCount = searchQuery.data?.count + return ( + + onSearch({ ...search, page: nextPage }), + }, + filters: { + search, + onSearch, + showSort: true, + showLimit: true, + }, + }} + sidebarBody={ + + items={permissions} + isLoading={searchQuery.isPending} + error={searchQuery.error} + selectedKey={selectedPermission?.id} + renderItem={(permission) => ({ + key: permission.id, + label: permission.name, + href: `/admin/panels/permissions?permissionId=${permission.id}`, + onClick: (event) => { + event.preventDefault() + const selected = handleSelectItem(permission) + if (selected && !isDesktop) { + setSidebarMobileVisible(false) + } + }, + })} + /> + } + >
+ {!isDesktop && !sidebarMobileVisible ? ( + + ) : null} {selectedPermission ? ( key={selectedPermission.id} @@ -153,6 +197,6 @@ export default function Page() {
)}
- +
) } diff --git a/src/app/admin/panels/positions/page.module.css b/src/app/admin/panels/positions/page.module.css index 1ef3a7ee..5d5be033 100644 --- a/src/app/admin/panels/positions/page.module.css +++ b/src/app/admin/panels/positions/page.module.css @@ -1,3 +1,9 @@ +.sidebarBg { + --navigation-stack-sidebar-bg: #f9fafb; + --navigation-stack-sidebar-padding: 0rem; + --navigation-stack-sidebar-gap: 0rem; +} + .detailPane { height: calc(100dvh - 100px); flex: 1 1 auto; @@ -6,9 +12,38 @@ padding-block: 1rem; } -.listItemText { +.mobileBackButton { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0 1rem 1rem; + border: 0; + background: transparent; + border-radius: 0.5rem; + padding: 0.6rem 0.65rem; + color: #2986cc; + font: inherit; font-weight: 500; - color: #000; + line-height: 1; + cursor: pointer; +} + +.mobileBackButton::before { + content: ''; + display: inline-block; + width: 0.58rem; + height: 0.58rem; + border-left: 0.14rem solid currentColor; + border-bottom: 0.14rem solid currentColor; + transform: rotate(45deg); + position: relative; + top: -0.5px; + border-bottom-left-radius: 0.04rem; +} + +.mobileBackButton:hover { + color: #549ed6; + background: rgba(15, 23, 42, 0.05); } .emptyState { diff --git a/src/app/admin/panels/positions/page.tsx b/src/app/admin/panels/positions/page.tsx index 5679dd9e..8570f3c5 100644 --- a/src/app/admin/panels/positions/page.tsx +++ b/src/app/admin/panels/positions/page.tsx @@ -1,7 +1,7 @@ 'use client' import styles from './page.module.css' -import { ListElement, List } from '@/app/admin/layout/List' +import { ListElement } from '@/app/admin/layout/List' import { SearchModal } from '@/app/admin/layout/SearchModal' import { Form, @@ -13,6 +13,8 @@ import { FormFieldProps, useConfigure, } from '@/components/common/forms/FormField' +import Panel from '@/components/common/panel/Panel' +import { SidebarBody } from '@/components/common/panel/sidebar_list/SidebarBody' import { Position, UserProfile, zUserProfile } from '@/contracts/data' import { SearchRequest, SortDirection } from '@/contracts/requests' import { @@ -29,6 +31,7 @@ import { } from '@/util/hooks' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ChangeEvent, useCallback, useState } from 'react' +import { useMediaQuery } from 'usehooks-ts' function getUserDisplayName(user: UserProfile | undefined): string { if (!user) return 'Unknown' @@ -38,7 +41,7 @@ function getUserDisplayName(user: UserProfile | undefined): string { if (discord) return `@${discord}` if (user.preferredName) return user.preferredName if (user.email) return user.email - return `User #${user.id}` + return 'Unknown' } const blankPosition: Position = { @@ -56,6 +59,8 @@ export default function Page() { null ) const [formState, setFormState] = useState | null>(null) + const [sidebarMobileVisible, setSidebarMobileVisible] = useState(true) + const isDesktop = useMediaQuery('(min-width: 64rem)') const positionHierarchy = useQuery({ queryKey: ['positionHierarchy'], @@ -73,7 +78,6 @@ export default function Page() { const { items: positions, - count: positionCount, search, onSearch, } = useUnpaginatedSearch({ @@ -99,17 +103,18 @@ export default function Page() { pageUserMap.set(u.id, u) } - const handleSelectItem = (value: Position) => { - if (value.id === selectedPosition?.id) return + const handleSelectItem = (value: Position): boolean => { + if (value.id === selectedPosition?.id) return false if (formState?.dirty) { const proceed = confirm( 'You have unsaved changes! Selecting a new list element will discard them.' ) - if (!proceed) return + if (!proceed) return false } setSelectedPosition(value) + return true } const updatePositionInHierarchyCache = ( @@ -203,26 +208,54 @@ export default function Page() { } return ( - <> - - {positions.map((item) => ( - handleSelectItem(item)} - > - {item.name} - - ))} - - + + items={positions} + isLoading={positionHierarchy.isPending} + error={positionHierarchy.error} + selectedKey={selectedPosition?.id} + renderItem={(position) => ({ + key: position.id, + label: position.name, + href: `/admin/panels/positions?positionId=${position.id}`, + onClick: (event) => { + event.preventDefault() + const selected = handleSelectItem(position) + if (selected && !isDesktop) { + setSidebarMobileVisible(false) + } + }, + })} + /> + } + >
+ {!isDesktop && !sidebarMobileVisible ? ( + + ) : null} key={selectedPosition?.id} form={selectedPosition} @@ -231,7 +264,11 @@ export default function Page() { ? 'New Position' : (selectedPosition?.name ?? 'Position') } - saving={updateMutation.isPending} + saving={ + createMutation.isPending || + updateMutation.isPending || + deleteMutation.isPending + } onUpdate={setFormState} onSave={handleSave} onCreate={handleCreate} @@ -270,7 +307,7 @@ export default function Page() { )}
- +
) } diff --git a/src/app/admin/panels/roles/page.module.css b/src/app/admin/panels/roles/page.module.css index 7b1d4b31..3fbbea1f 100644 --- a/src/app/admin/panels/roles/page.module.css +++ b/src/app/admin/panels/roles/page.module.css @@ -1,13 +1,208 @@ -.rightPane { +.sidebarBg { + --navigation-stack-sidebar-bg: #f9fafb; + --navigation-stack-sidebar-padding: 0rem; + --navigation-stack-sidebar-gap: 0rem; +} + +.sidebarSearchInput { + width: 100%; + border-radius: 0.6rem; + border: 1px solid #d1d5db; + padding: 0.35rem 0.75rem; + color: rgba(15, 23, 42, 0.85); + background: rgba(255, 255, 255, 0.9); +} + +.sidebarSearchInput:focus { + border-color: rgba(14, 165, 233, 0.45); + box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.12); + outline: none; +} + +.searchInput { + display: flex; + width: 100%; + align-items: center; + gap: 0.5rem; + padding-inline: 0.5rem; +} + +.searchInput > input { + width: 100%; + border-radius: 0.6rem; + border-width: 1px; + border-style: solid; + border-color: #d1d5db; + padding: 0.25rem 0.75rem; +} + +.searchInput > button { + display: inline-flex; + align-items: center; + justify-content: center; + + border: 0; + background: transparent; + border-radius: 0.85rem; + + padding: 0.5rem; + + cursor: pointer; + color: rgba(15, 23, 42, 0.62); + + gap: 0.5rem; +} + +.searchInput > button:hover { + background: rgba(15, 23, 42, 0.06); +} + +.iconToggleButton { + display: inline-flex; + align-items: center; + justify-content: center; + + border: 0; + background: transparent; + border-radius: 0.85rem; + + padding: 0.5rem; + + cursor: pointer; + color: rgba(15, 23, 42, 0.62); +} + +.iconToggleButton:hover { + background: rgba(15, 23, 42, 0.06); +} + +.permissionNavigationButton { + --navigation-button-item-margin: 0.1rem 0.25rem; + --navigation-button-item-width: auto; + --navigation-button-item-radius: 1rem; + --navigation-button-link-gap: 1.25rem; + --navigation-button-link-padding: 1rem; + --navigation-button-link-radius: 1rem; + --navigation-button-link-color: #000; + --navigation-button-label-font-size: 1rem; + --navigation-button-label-font-weight: 500; + --navigation-button-label-letter-spacing: 0; + --navigation-button-label-color: #000; + --navigation-button-link-hover-bg: #e5e7eb; + --navigation-button-link-active-bg: #e5e7eb; + --navigation-button-link-active-color: #000; + --navigation-button-link-active-hover-bg: #d1d5db; +} + +.sidebarList { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + gap: 0.25rem; + padding: 0.35rem; +} + +.sidebarItem { + display: flex; + width: 100%; + align-items: center; + gap: 0.75rem; + border: 0; + border-radius: 1rem; + background: transparent; + margin: 0; + padding: 0.85rem 0.9rem; + text-align: left; + cursor: pointer; +} + +.sidebarItem:hover { + background-color: #e5e7eb; +} + +.sidebarItem[data-selected='true'] { + background-color: #e5e7eb; +} + +.sidebarItem[data-selected='true']:hover { + background-color: #d1d5db; +} + +.sidebarState { + display: flex; + align-items: center; + justify-content: center; + min-height: 7rem; + color: rgba(15, 23, 42, 0.64); + text-align: center; +} + +.detailPane { height: calc(100dvh - 100px); - flex: 1; + flex: 1 1 auto; overflow-y: auto; + min-height: 0; padding-block: 1rem; } -.roleListItemName { +.mobileBackButton { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0 1rem 1rem; + border: 0; + background: transparent; + border-radius: 0.5rem; + padding: 0.6rem 0.65rem; + color: #2986cc; + font: inherit; + font-weight: 500; + line-height: 1; + cursor: pointer; +} + +.mobileBackButton::before { + content: ''; + display: inline-block; + width: 0.58rem; + height: 0.58rem; + border-left: 0.14rem solid currentColor; + border-bottom: 0.14rem solid currentColor; + transform: rotate(45deg); + position: relative; + top: -0.5px; + border-bottom-left-radius: 0.04rem; +} + +.mobileBackButton:hover { + color: #549ed6; + background: rgba(15, 23, 42, 0.05); +} + +.element { + display: flex; + cursor: pointer; + align-items: center; + gap: 1.25rem; + padding: 1rem; + border-radius: 1rem; + margin: 0.25rem; font-weight: 500; color: #000; + background-color: #e5e7eb; +} + +.element:hover { + background-color: #e5e7eb; +} + +.element.selected { + background-color: #e5e7eb; +} + +.element.selected:hover { + background-color: #d1d5db; } .emptyState { @@ -15,4 +210,128 @@ display: flex; align-items: center; justify-content: center; + color: rgba(15, 23, 42, 0.5); + font-weight: 500; +} + +.filterOverlay { + min-width: 16rem; +} + +.filterOverlayBody { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; +} + +.filterSelect { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.filterSelect > strong { + font-weight: 500; + color: rgba(15, 23, 42, 0.75); +} + +.select { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.select > span { + font-weight: 500; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.7); +} + +.select > select { + border-radius: 0.85rem; + border: 1px solid rgba(17, 24, 39, 0.12); + background: rgba(255, 255, 255, 0.75); + padding: 0.35rem 0.55rem; + color: rgba(15, 23, 42, 0.8); + outline: none; +} + +/* .pageSelectContainer { + margin-top: auto; + display: flex; + gap: 1rem; +} */ +/* +.pageSelect { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; +} */ + +.pageSelectButtons { + margin-left: auto; + margin-right: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.9rem; +} + +.pageSelectForm { + display: flex; + align-items: center; + gap: 0.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + color: rgba(15, 23, 42, 0.72); +} + +.pageSelectInput { + width: 6ch; + max-width: min-content; + border-radius: 0.65rem; + border-width: 1px; + border-color: #d1d5db; + padding: 0.125rem 0.5rem; + text-align: center; + color: rgba(15, 23, 42, 0.62); +} + +.pageSelectInput:focus { + border-color: rgba(14, 165, 233, 0.35); + box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.12); +} + +.arrow { + display: flex; + user-select: none; + align-items: center; + justify-content: center; + border-radius: 0.85rem; + padding: 0.45rem; + + font-weight: 600; +} + +.arrow.disabled { + cursor: not-allowed; + color: rgba(15, 23, 42, 0.28); +} + +.arrow.enabled { + cursor: pointer; + color: rgba(15, 23, 42, 0.62); +} + +.arrow.enabled:hover { + background: rgba(15, 23, 42, 0.06); +} + +.arrow.enabled:active { + background-color: #f3f4f6; } diff --git a/src/app/admin/panels/roles/page.tsx b/src/app/admin/panels/roles/page.tsx index 36720e07..4bdfacbe 100644 --- a/src/app/admin/panels/roles/page.tsx +++ b/src/app/admin/panels/roles/page.tsx @@ -1,7 +1,7 @@ 'use client' import styles from './page.module.css' -import { ListElement, List } from '@/app/admin/layout/List' +import { MultiSelectOption } from '@/components/common' import { Form, FormGroup, @@ -9,6 +9,8 @@ import { SelectManyField, TextField, } from '@/components/common/forms' +import Panel from '@/components/common/panel/Panel' +import { SidebarBody } from '@/components/common/panel/sidebar_list/SidebarBody' import { Role, zPermission, zRole } from '@/contracts/data' import { SortDirection, UpdateRoleRequest } from '@/contracts/requests' import { PaginatedResponse } from '@/contracts/responses' @@ -22,6 +24,7 @@ import { useQueryClient, } from '@tanstack/react-query' import { useMemo, useState } from 'react' +import { useMediaQuery } from 'usehooks-ts' export default function Page() { const queryClient = useQueryClient() @@ -29,6 +32,8 @@ export default function Page() { const [selectedId, setSelectedId] = useState(null) const [formState, setFormState] = useState | null>(null) + const [sidebarMobileVisible, setSidebarMobileVisible] = useState(true) + const isDesktop = useMediaQuery('(min-width: 64rem)') const { query: searchQuery, @@ -157,43 +162,93 @@ export default function Page() { }) } + const roles = searchQuery.data?.data ?? [] + const resultCount = searchQuery.data?.count + + const filterOptions = useMemo( + () => [ + { + value: 'permissionIds', + label: 'Permissions', + options: permissionOptions, + }, + ], + [permissionOptions] + ) + return ( - <> - ({ - value: permission.id, - label: permission.name, - })), - }, - ]} - onSearch={onSearch} - > - {searchQuery.data?.data?.map((item) => ( - handleSelectItem(item)} + + onSearch({ ...search, page: nextPage }), + }, + filters: { + search, + onSearch, + options: filterOptions, + }, + }} + sidebarBody={ + + items={roles} + isLoading={searchQuery.isPending} + error={searchQuery.error} + selectedKey={selectedId} + renderItem={(role) => ({ + key: role.id, + label: role.name, + href: `/admin/panels/roles?roleId=${role.id}`, + onClick: (event) => { + event.preventDefault() + handleSelectItem(role) + if (!isDesktop) { + setSidebarMobileVisible(false) + } + }, + })} + /> + } + > +
+ {!isDesktop && !sidebarMobileVisible ? ( + + ) : null} {selectedId == null && (
No role selected
)} - {selectedId != null && roleQuery.data && ( + {selectedId != null && roleQuery.isPending && ( +
+ Loading role details... +
+ )} + {selectedId != null && roleQuery.error && ( +
+ Error: {roleQuery.error.message} +
+ )} + {selectedId != null && roleQuery.data ? ( key={selectedId} form={roleQuery.data} @@ -224,8 +279,16 @@ export default function Page() { /> + ) : ( +
No role selected
)}
- +
) } + +interface FilterOption { + value: string + label: string + options: MultiSelectOption[] +} diff --git a/src/components/common/dropdown/DropdownButton.module.css b/src/components/common/dropdown/DropdownButton.module.css index 681572bc..cf27efe4 100644 --- a/src/components/common/dropdown/DropdownButton.module.css +++ b/src/components/common/dropdown/DropdownButton.module.css @@ -71,6 +71,38 @@ justify-content: center; } +.buttonIcon { + display: inline-flex; + align-items: center; + justify-content: center; + + border: 0; + background: transparent; + border-radius: 0.85rem; + + padding: 0.5rem; + + cursor: pointer; + color: rgba(15, 23, 42, 0.62); +} + +.buttonIcon:hover { + background: rgba(15, 23, 42, 0.06); +} + +.container { + position: relative; + display: inline-flex; +} + +.menuAnchor { + pointer-events: none; +} + +.menuAnchor > * { + pointer-events: auto; +} + .shortIcon { width: 1rem; height: 1rem; diff --git a/src/components/common/dropdown/DropdownButton.tsx b/src/components/common/dropdown/DropdownButton.tsx index af11f5d4..792056b7 100644 --- a/src/components/common/dropdown/DropdownButton.tsx +++ b/src/components/common/dropdown/DropdownButton.tsx @@ -4,54 +4,18 @@ import styles from './DropdownButton.module.css' import { EllipsisVerticalIcon } from '@heroicons/react/24/outline' import { forwardRef, + useCallback, useEffect, + useLayoutEffect, useRef, useState, + type CSSProperties, type MouseEvent as ReactMouseEvent, } from 'react' +import { createPortal } from 'react-dom' import { FiChevronDown } from 'react-icons/fi' import { IoClose } from 'react-icons/io5' -/* - * How To Use: - * - * This component is a standardized button trigger for our dropdown menus. - * It has predefined styling and functionality options built in as defaults - * as well as avenues to customize said style and function. - * - * For best results use DropdownButton in conjunction with the DropdownOverlay. - * - * - * - * DropdownButtonProps: - * - label: The text label for the button. - * - buttonVariant: Predefined style variant for button. - * - className: Additional CSS class applied to the - {isOpen && renderedMenu} + {isOpen && portal && anchorStyle + ? createPortal( +
+ {renderedMenu} +
, + document.body + ) + : null} + {isOpen && !portal ? renderedMenu : null}
) }) diff --git a/src/components/common/forms/FormGroup.tsx b/src/components/common/forms/FormGroup.tsx index 02a1ef4b..fb72e8f7 100644 --- a/src/components/common/forms/FormGroup.tsx +++ b/src/components/common/forms/FormGroup.tsx @@ -16,7 +16,7 @@ export interface FormGroupProps { /** The group's title, displayed as a heading above the fields. */ title: string - /** Optional subtitle displayed under the group's title. */ + /** An optional subtitle displayed below the heading. */ subtitle?: string /** Whether the group is displayed as collapsed by default. */ diff --git a/src/components/common/navigation_stack/NavigationStack.module.css b/src/components/common/navigation_stack/NavigationStack.module.css new file mode 100644 index 00000000..caad46fa --- /dev/null +++ b/src/components/common/navigation_stack/NavigationStack.module.css @@ -0,0 +1,47 @@ +.root { + position: relative; + display: flex; + width: 100%; + height: var(--navigation-stack-height, 100dvh); + background: var(--navigation-stack-bg, transparent); + overflow: hidden; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + z-index: 4; + display: flex; + align-items: center; + height: var(--navigation-stack-sidebar-header-min-height, 4.05rem); + padding: 0 0 0 1.25rem; + pointer-events: none; +} + +.overlay > * { + pointer-events: auto; +} + +.noContentView { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + height: 100%; + text-align: center; +} + +.noContentTitle { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #0f172a; +} + +.noContentDescription { + margin: 0; + font-size: 0.9rem; + color: rgba(15, 23, 42, 0.55); +} diff --git a/src/components/common/navigation_stack/NavigationStack.tsx b/src/components/common/navigation_stack/NavigationStack.tsx new file mode 100644 index 00000000..04ce5942 --- /dev/null +++ b/src/components/common/navigation_stack/NavigationStack.tsx @@ -0,0 +1,44 @@ +'use client' + +import styles from './NavigationStack.module.css' +import { cn } from '@/util' +import type { ReactElement, ReactNode } from 'react' + +export interface NavigationStackProps { + sidebar?: ReactNode + detail?: ReactNode + unSelected?: ReactNode + isSelected?: boolean + className?: string + overlay?: ReactNode +} + +/** + * Layout container for split-view navigation UI. + * Manages sidebar + detail pane with optional overlay. + * + * Props: + * - sidebar: ReactNode rendered in left pane (usually Sidebar component) + * - detail: ReactNode rendered in right pane (usually Detail component) + * - className: Optional CSS classes for root element + * - overlay: Optional ReactNode for fullscreen overlay (e.g., dropdowns) + */ +export function NavigationStack({ + sidebar, + detail, + unSelected, + isSelected, + className, + overlay, +}: NavigationStackProps): ReactElement { + const hasSelectedDetail = Boolean(isSelected ?? detail) + const activeDetailContent = hasSelectedDetail ? detail : unSelected + + return ( +
+ {sidebar} + {activeDetailContent} + {overlay &&
{overlay}
} +
+ ) +} diff --git a/src/components/common/navigation_stack/detail/Detail.module.css b/src/components/common/navigation_stack/detail/Detail.module.css new file mode 100644 index 00000000..a097fcfc --- /dev/null +++ b/src/components/common/navigation_stack/detail/Detail.module.css @@ -0,0 +1,138 @@ +.detail { + display: none; + flex-direction: column; + flex: 1; + min-width: 0; + background: var(--navigation-stack-detail-bg, transparent); + overflow-y: auto; +} + +.backButton, +.panelBackButton { + display: flex; + align-items: center; + gap: 0.25rem; + border: 0; + margin: 0 0 0 0rem; + padding: 0.6rem 0.5rem 0.6rem 0.25rem; + background: transparent; + color: #2986cc; + font: inherit; + font-weight: 500; + font-size: 0.92rem; + line-height: 1; + border-radius: 0.7rem; + cursor: pointer; + text-decoration: none; + -webkit-tap-highlight-color: transparent; + transition: + background-color 140ms ease, + color 140ms ease, + transform 140ms ease; + + &:hover { + color: #549ed6; + background: rgba(15, 23, 42, 0.05); + } + + &:active { + color: #1b4568; + } + + &:focus-visible { + outline: 2px solid rgba(41, 134, 204, 0.28); + outline-offset: 0.2rem; + border-radius: 999px; + } +} + +.backButton { + font-size: 1.06rem; +} + +.label { + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgba(15, 23, 42, 0.86); + padding: 0 0 0.25rem; + user-select: none; +} + +.header { + display: contents; +} + +.body { + display: contents; +} + +.panelHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.35rem 1.25rem; + background-color: #f9fafb; + backdrop-filter: blur(14px) saturate(150%); + -webkit-backdrop-filter: blur(14px) saturate(150%); + border-bottom: 2px solid #e5e7eb; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.75) inset; +} + +.panelTimestamp { + font-size: 0.78rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.45); +} + +.panelHeaderLeft { + display: inline-flex; + align-items: center; + gap: 0.75rem; +} + +.breadcrumbs { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.86rem; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.72); + user-select: none; +} + +.prominentBreadcrumb { + font-weight: 700; + color: rgba(15, 23, 42, 0.72); +} + +.breadcrumbSeperator { + color: rgba(15, 23, 42, 0.35); + font-weight: 700; +} + +.panelBreadcrumb { + font-weight: 650; + color: rgba(15, 23, 42, 0.55); +} + +.footer { + display: contents; +} + +@media (max-width: 63.999rem) { + .detail[data-mobile-visible='true'] { + display: flex; + } +} + +@media (min-width: 64rem) { + .backButton { + display: none; + } + + .detail { + display: flex; + } +} diff --git a/src/components/common/navigation_stack/detail/Detail.tsx b/src/components/common/navigation_stack/detail/Detail.tsx new file mode 100644 index 00000000..fbffc2e7 --- /dev/null +++ b/src/components/common/navigation_stack/detail/Detail.tsx @@ -0,0 +1,169 @@ +'use client' + +import { readPanelHistory, writePanelHistory } from '../panelHistory' +import styles from './Detail.module.css' +import { cn } from '@/util' +import { usePathname, useRouter } from 'next/navigation' +import { useEffect, useState, type ReactElement, type ReactNode } from 'react' +import { FiChevronLeft } from 'react-icons/fi' +import { useMediaQuery } from 'usehooks-ts' + +function formatPanelLabelFromPath(path: string): string { + if (!path.startsWith('/admin/panels/')) { + return 'Back' + } + + const panelSlug = path.slice('/admin/panels/'.length).split('/')[0] + + if (!panelSlug) { + return 'Back' + } + + return panelSlug + .split('-') + .filter(Boolean) + .map((segment) => segment[0].toUpperCase() + segment.slice(1)) + .join(' ') +} + +export interface DetailProps { + body?: ReactNode + label?: string + className?: string + bodyType?: 'blank' | 'panel' +} + +interface PanelBackButtonProps { + className?: string + showOnDesktop?: boolean + showOnMobile?: boolean +} + +function usePanelBackNavigation() { + const pathname = usePathname() + const router = useRouter() + const isDesktop = useMediaQuery('(min-width: 64rem)') + const [hasPanelHistory, setHasPanelHistory] = useState(false) + const [backTargetPanelLabel, setBackTargetPanelLabel] = useState('Back') + const isPanelRoute = pathname.startsWith('/admin/panels/') + + useEffect(() => { + if (typeof window === 'undefined') { + return + } + + if (!isPanelRoute) { + setHasPanelHistory(false) + setBackTargetPanelLabel('Back') + return + } + + const panelHistory = readPanelHistory() + + const previousPanelPath = panelHistory[panelHistory.length - 1] + + setHasPanelHistory(panelHistory.length > 0) + setBackTargetPanelLabel( + previousPanelPath + ? formatPanelLabelFromPath(previousPanelPath) + : 'Back' + ) + }, [isPanelRoute, pathname]) + + function handleBackNavigation() { + if (typeof window === 'undefined') { + return + } + + const panelHistory = readPanelHistory() + + if (panelHistory.length > 0) { + const nextHistory = panelHistory.slice(0, -1) + const previousPanelPath = panelHistory[panelHistory.length - 1] + + writePanelHistory(nextHistory) + + router.push(previousPanelPath) + return + } + + router.push('/admin') + } + + const backButtonText = + !isDesktop && !hasPanelHistory ? 'Back' : backTargetPanelLabel + + return { + backButtonText, + handleBackNavigation, + isDesktop, + isPanelRoute, + } +} + +export function PanelBackButton({ + className, + showOnDesktop = false, + showOnMobile = true, +}: PanelBackButtonProps): ReactElement | null { + const { backButtonText, handleBackNavigation, isDesktop, isPanelRoute } = + usePanelBackNavigation() + + const shouldShowForViewport = + (isDesktop && showOnDesktop) || (!isDesktop && showOnMobile) + + if (!isPanelRoute || !shouldShowForViewport) { + return null + } + + return ( + + ) +} + +export function Detail({ + body, + label, + className, + bodyType = 'blank', +}: DetailProps): ReactElement { + const pathname = usePathname() + const { backButtonText, handleBackNavigation, isPanelRoute } = + usePanelBackNavigation() + const isAdminRootRoute = pathname === '/admin' + const mobileVisible = isPanelRoute || isAdminRootRoute + const showDetailHeader = bodyType === 'blank' + + return ( +
+ {showDetailHeader ? ( +
+ {isPanelRoute ? ( + + ) : null} + {label &&
{label}
} +
+ ) : null} +
{body}
+
+
+ ) +} diff --git a/src/components/common/navigation_stack/index.ts b/src/components/common/navigation_stack/index.ts new file mode 100644 index 00000000..9bb677cf --- /dev/null +++ b/src/components/common/navigation_stack/index.ts @@ -0,0 +1,2 @@ +export * from './NavigationStack' +export * from './sidebar/Sidebar' diff --git a/src/components/common/navigation_stack/navigation_button/NavigationButton.module.css b/src/components/common/navigation_stack/navigation_button/NavigationButton.module.css new file mode 100644 index 00000000..193b8efa --- /dev/null +++ b/src/components/common/navigation_stack/navigation_button/NavigationButton.module.css @@ -0,0 +1,512 @@ +.item { + position: relative; + width: var(--navigation-button-item-width, 100%); + cursor: pointer; + margin: var(--navigation-button-item-margin, 0); + border-radius: var(--navigation-button-item-radius, 0.85rem); + --group-scaffold-offset: 0.8rem; +} + +.cardItem { + width: auto; + border-radius: 1rem; +} + +.itemHeader { + position: relative; + display: block; +} + +.itemHeader > .link:hover { + background: var(--navigation-button-link-hover-bg, rgba(15, 23, 42, 0.05)); +} + +.itemActive > .itemHeader > .link { + color: var(--navigation-button-link-active-color, rgba(2, 132, 199, 1)); + background: var( + --navigation-button-link-active-bg, + rgba(14, 165, 233, 0.14) + ); +} + +.itemActive > .itemHeader > .link:hover { + background: var( + --navigation-button-link-active-hover-bg, + rgba(15, 23, 42, 0.05) + ); +} + +.itemActive > .itemHeader > .link:active { + transform: scale(0.99); +} + +.itemInactive { + font-weight: 500; +} + +.accountItem { + margin-top: 0; + flex-shrink: 0; + overflow: hidden; + border-radius: 1rem; + transition: + opacity 160ms ease, + margin-top 220ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.link { + display: flex; + width: 100%; + align-items: center; + gap: var(--navigation-button-link-gap, 0.65rem); + padding: var(--navigation-button-link-padding, 0.6rem 0.65rem); + background-color: transparent; + color: var(--navigation-button-link-color, rgba(15, 23, 42, 0.78)); + text-decoration: none; + border-radius: var(--navigation-button-link-radius, 0.85rem); + transition: + gap 220ms cubic-bezier(0.22, 1, 0.36, 1), + padding 220ms cubic-bezier(0.22, 1, 0.36, 1), + background-color 220ms cubic-bezier(0.22, 0.61, 0.36, 1), + transform 140ms ease, + color 140ms ease, + box-shadow 140ms ease; +} + +.link:active { + transform: scale(0.99); +} + +.accountLink { + gap: 0.65rem; + padding: 0.55rem 0.65rem; + border-radius: 1rem; + background-color: transparent; + border: 1px solid rgba(15, 23, 42, 0.1); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset; + text-decoration: none; + cursor: pointer; + transition: + gap 220ms cubic-bezier(0.22, 1, 0.36, 1), + padding 220ms cubic-bezier(0.22, 1, 0.36, 1), + background-color 180ms ease, + border-color 220ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 220ms cubic-bezier(0.22, 1, 0.36, 1), + transform 140ms ease; +} + +.accountLink:hover { + background: rgba(15, 23, 42, 0.05); + border-color: transparent; + box-shadow: none; +} + +.accountLink:active { + transform: translateY(0) scale(0.99); +} + +.cardLink { + display: flex; + flex-direction: column; + gap: 0.7rem; + padding: 1rem; + border-radius: 1rem; + text-decoration: none; + text-align: left; + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + border: 1px solid rgba(17, 24, 39, 0.08); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.75) inset, + 0 22px 55px rgba(15, 23, 42, 0.12); + transition: + transform 160ms ease, + box-shadow 160ms ease, + background-color 160ms ease; +} + +.cardLink:hover { + transform: translateY(-1px); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.78) inset, + 0 26px 70px rgba(15, 23, 42, 0.16); +} + +.cardLink:active { + transform: translateY(0) scale(0.995); +} + +.cardTop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.cardLeft { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.cardIconPill { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 0.85rem; + background: rgba(15, 23, 42, 0.06); + color: rgba(15, 23, 42, 0.72); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8) inset; +} + +.cardTitle { + margin-left: auto; + font-size: 1.2rem; + font-weight: 800; + color: rgba(9, 34, 58, 0.9); + text-align: right; +} + +.cardCount { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.25rem 1rem; + border-radius: 1rem; + background: rgba(15, 23, 42, 0.06); + color: rgba(15, 23, 42, 0.7); + font-size: 0.82rem; + font-weight: 800; +} + +.cardDescription { + font-size: 0.93rem; + font-weight: 600; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.52); + line-height: 1.35; +} + +.iconSection { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; +} + +.accountIconSection { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.accountIconSection > * { + flex-shrink: 0; + box-sizing: border-box; + width: 39px; + height: 39px; + min-width: 39px; + min-height: 39px; + aspect-ratio: 1 / 1; + display: block; + border-radius: 999px; + border: 1.5px solid rgba(15, 23, 42, 0.1); + object-fit: cover; + transition: + border-color 180ms ease, + transform 220ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.labelSection { + flex: 1; + min-width: 0; + max-width: 100%; + font-size: var(--navigation-button-label-font-size, 0.95rem); + font-weight: var(--navigation-button-label-font-weight, 600); + letter-spacing: var(--navigation-button-label-letter-spacing, -0.01em); + color: var(--navigation-button-label-color, inherit); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: + max-width 200ms cubic-bezier(0.32, 0.72, 0, 1), + opacity 160ms ease; +} + +.labelSectionWithSubtitle { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.1rem; + white-space: normal; + line-height: 1.15; +} + +.subtitleSection { + display: block; + font-size: 0.75rem; + font-weight: 500; + color: rgba(15, 23, 42, 0.48); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.accountText { + display: flex; + flex-direction: column; + gap: 0.03rem; + min-width: 0; + max-height: 2.2rem; + overflow: hidden; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: -0.01em; + color: rgba(9, 34, 58, 0.92); + transition: + max-height 200ms cubic-bezier(0.32, 0.72, 0, 1), + opacity 160ms ease; +} + +.accountSubtitle { + display: block; + font-size: 0.75rem; + font-weight: 500; + color: rgba(15, 23, 42, 0.48); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.tagSection { + display: inline-flex; + align-items: center; + justify-content: flex-end; + flex: 0 0 auto; + max-width: 5rem; + overflow: hidden; + transition: + max-width 200ms cubic-bezier(0.32, 0.72, 0, 1), + opacity 160ms ease; +} + +.tagSectionWithLabel { + max-width: none; + overflow: visible; + margin-left: auto; +} + +.accountTagSection { + display: none; +} + +[data-sidebar-collapsed='true'] .accountItem { + margin-top: 0.35rem; +} + +[data-sidebar-collapsed='true'] .accountLink { + justify-content: center; + gap: 0; + padding: 0.05rem; + border-color: transparent; + box-shadow: none; +} + +[data-sidebar-collapsed='true'] .accountText { + max-height: 0; + opacity: 0; + pointer-events: none; +} + +[data-sidebar-collapsed='true'] .accountLink:hover { + background: transparent; + border-color: transparent; + box-shadow: none; +} + +[data-sidebar-collapsed='true'] .accountLink:hover .accountIconSection > * { + border-color: #1b4568; +} + +:global([data-sidebar-collapsed='true']) .labelSection, +:global([data-sidebar-collapsed='true']) .tagSection { + max-width: 0; + opacity: 0; + pointer-events: none; +} + +:global([data-sidebar-collapsed='true']) .link { + justify-content: flex-start; + gap: 0; +} + +.count { + font-size: 0.75rem; + font-weight: 600; + padding-inline: 0.75rem; + border-radius: 9999px; + padding-block: 0.15rem; + color: rgba(15, 23, 42, 0.72); + background: rgba(15, 23, 42, 0.06); +} + +.tagLabel { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + padding-inline: 0.7rem; + padding-block: 0.18rem; + border-radius: 9999px; + color: rgba(2, 132, 199, 1); + background: rgba(14, 165, 233, 0.14); +} + +.groupTagButton { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + border-radius: 9999px; + padding-inline: 0.75rem; + padding-block: 0.55rem; + color: rgba(15, 23, 42, 0.72); + background: rgba(15, 23, 42, 0.06); + cursor: pointer; + transition: + background-color 140ms ease, + color 140ms ease; +} + +.groupTagButton:hover { + background: rgba(15, 23, 42, 0.1); +} + +.groupTagButton[data-collapsed-active-child='true'] { + color: rgba(2, 132, 199, 1); + background: rgba(14, 165, 233, 0.14); +} + +.groupTagChevron { + width: 0.45rem; + height: 0.45rem; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(135deg); + transition: transform 200ms ease; +} + +.groupTagButton[data-open='true'] .groupTagChevron { + transform: rotate(45deg); +} + +.groupChildrenClip { + overflow: hidden; + max-height: 0; + margin-right: -1rem; + padding-right: 1rem; + transition: max-height 220ms ease; +} + +.groupChildrenClip[data-open='true'] { + max-height: var(--group-children-open-height, 0px); +} + +.groupChildren { + position: relative; + display: flex; + flex-direction: column; + gap: 0.2rem; + padding-top: 0.3rem; + margin-left: var(--group-scaffold-offset); + padding-left: 1.05rem; + overflow: visible; + opacity: 0; + transform: translate3d(0, -4px, 0); + will-change: opacity, transform; + pointer-events: none; + transition: + opacity 200ms ease, + transform 220ms ease; +} + +.groupChildrenClip[data-open='true'] .groupChildren { + opacity: 1; + transform: translate3d(0, 0, 0); + pointer-events: auto; +} + +.groupChildrenClip[data-open='true'] .groupChildren > .item { + opacity: 1; + transform: translate3d(0, 0, 0); +} + +.groupChildrenClip[data-open='true'] .groupChildren > .item:nth-child(1) { + transition-delay: 30ms; +} + +.groupChildrenClip[data-open='true'] .groupChildren > .item:nth-child(2) { + transition-delay: 55ms; +} + +.groupChildrenClip[data-open='true'] .groupChildren > .item:nth-child(3) { + transition-delay: 80ms; +} + +.groupChildrenClip[data-open='true'] .groupChildren > .item:nth-child(4) { + transition-delay: 105ms; +} + +.groupChildrenClip[data-open='true'] .groupChildren > .item:nth-child(5) { + transition-delay: 130ms; +} + +.groupChildren > .item { + position: relative; + opacity: 0; + transform: translate3d(0, -3px, 0); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + transition: + opacity 160ms ease, + transform 220ms ease; +} + +.groupChildren > .item::before { + content: ''; + position: absolute; + left: -1.05rem; + top: -0.2rem; + width: 1.05rem; + height: calc(50% + 0.2rem); + transform: translateY(1px); + border-left: 2px solid rgba(15, 23, 42, 0.18); + border-bottom: 2px solid rgba(15, 23, 42, 0.18); + border-bottom-left-radius: 0.45rem; + box-sizing: border-box; +} + +.groupChildren > .item:first-child::before { + top: 0; + height: 50%; +} + +.groupChildren > .item .link { + margin-left: 0.2rem; + padding: 0.6rem 0.65rem; + border-radius: 0.85rem; +} + +.groupChildren > .item .labelSection { + font-size: 0.95rem; +} + +:global([data-sidebar-collapsed='true']) .groupChildrenClip { + display: none; +} diff --git a/src/components/common/navigation_stack/navigation_button/NavigationButton.tsx b/src/components/common/navigation_stack/navigation_button/NavigationButton.tsx new file mode 100644 index 00000000..3265e05a --- /dev/null +++ b/src/components/common/navigation_stack/navigation_button/NavigationButton.tsx @@ -0,0 +1,382 @@ +'use client' + +import { + readPanelHistory, + writePanelHistory, + clearPanelHistory, +} from '../panelHistory' +import styles from './NavigationButton.module.css' +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type ReactElement, + type ReactNode, +} from 'react' +import type { MouseEvent } from 'react' +import type { IconType } from 'react-icons/lib' + +const GROUP_CHILDREN_ANIMATION_MS = 220 + +export type NavigationButtonType = 'default' | 'group' | 'card' | 'account' +export type IndicatorDirection = 'up' | 'down' | 'none' + +export interface NavigationButtonProps { + label: string + subtitle?: string + tagLabel?: string + href: string + icon?: IconType + iconNode?: ReactNode + count?: number + description?: string + buttonType?: NavigationButtonType + groupContent?: ReactNode + hasActiveGroupChild?: boolean + indicatorDirection?: IndicatorDirection + trackPanelHistory?: boolean + resetPanelHistoryOnClick?: boolean + onClick?: (event: MouseEvent) => void + showIndicator?: boolean + active?: boolean + style?: CSSProperties + className?: string + linkClassName?: string + iconSectionClassName?: string + labelClassName?: string + subtitleClassName?: string + tagSectionClassName?: string +} + +export function NavigationButton({ + label, + subtitle, + tagLabel, + href, + icon, + iconNode, + count, + description, + buttonType = 'default', + groupContent, + hasActiveGroupChild = false, + indicatorDirection = 'none', + trackPanelHistory = false, + resetPanelHistoryOnClick = false, + onClick, + showIndicator = true, + active = false, + style, + className, + linkClassName, + iconSectionClassName, + labelClassName, + subtitleClassName, + tagSectionClassName, +}: NavigationButtonProps): ReactElement { + const Icon = icon + const hasIcon = Boolean(iconNode ?? Icon) + const isAccountButton = buttonType === 'account' + const hasSubtitle = subtitle != null && subtitle !== '' + const pathname = usePathname() + const formattedCount = (count ?? 0).toLocaleString() + const [isGroupOpen, setIsGroupOpen] = useState(false) + const [shouldRenderGroupChildren, setShouldRenderGroupChildren] = + useState(false) + const [groupChildrenHeight, setGroupChildrenHeight] = useState(0) + const groupChildrenCloseTimeoutRef = useRef | null>(null) + const groupChildrenRef = useRef(null) + + useLayoutEffect(() => { + if (!shouldRenderGroupChildren) { + setGroupChildrenHeight(0) + return + } + + function updateGroupChildrenHeight() { + setGroupChildrenHeight(groupChildrenRef.current?.scrollHeight ?? 0) + } + + updateGroupChildrenHeight() + + const groupChildrenElement = groupChildrenRef.current + + const resizeObserver = + typeof ResizeObserver === 'undefined' || + groupChildrenElement === null + ? null + : new ResizeObserver(() => { + updateGroupChildrenHeight() + }) + + if (resizeObserver && groupChildrenElement) { + resizeObserver.observe(groupChildrenElement) + } + + return () => { + resizeObserver?.disconnect() + } + }, [groupContent, shouldRenderGroupChildren]) + + useEffect(() => { + return () => { + if (groupChildrenCloseTimeoutRef.current !== null) { + clearTimeout(groupChildrenCloseTimeoutRef.current) + } + } + }, []) + + function handleGroupTagClick(event: MouseEvent) { + event.preventDefault() + event.stopPropagation() + + if (groupChildrenCloseTimeoutRef.current !== null) { + clearTimeout(groupChildrenCloseTimeoutRef.current) + groupChildrenCloseTimeoutRef.current = null + } + + if (isGroupOpen) { + setIsGroupOpen(false) + groupChildrenCloseTimeoutRef.current = setTimeout(() => { + setShouldRenderGroupChildren(false) + groupChildrenCloseTimeoutRef.current = null + }, GROUP_CHILDREN_ANIMATION_MS) + return + } + + setShouldRenderGroupChildren(true) + requestAnimationFrame(() => { + setIsGroupOpen(true) + }) + } + + function handleNavigationClick() { + if (typeof window === 'undefined') { + return + } + + if (resetPanelHistoryOnClick) { + clearPanelHistory() + return + } + + if (!trackPanelHistory) { + return + } + + if (!pathname.startsWith('/admin/panels/')) { + return + } + + const panelHistory = readPanelHistory() + + const nextHistory = + panelHistory[panelHistory.length - 1] === pathname + ? panelHistory + : [...panelHistory, pathname] + + writePanelHistory(nextHistory) + } + + if (buttonType === 'card') { + return ( +
+
+ { + handleNavigationClick() + onClick?.(event) + }} + title={label} + > +
+
+ {Icon ? ( + + ) : null} + +
{label}
+
+ + {count !== undefined ? ( +
+ {formattedCount} +
+ ) : null} +
+ + {description ? ( +
+ {description} +
+ ) : null} + +
+
+ ) + } + + return ( +
+
+ { + handleNavigationClick() + onClick?.(event) + }} + title={label} + > + {hasIcon ? ( + + {iconNode ?? (Icon ? : null)} + + ) : null} + + + {label} + {subtitle ? ( + + {subtitle} + + ) : null} + + + + {buttonType === 'group' ? ( + + +
+ + {buttonType === 'group' && shouldRenderGroupChildren ? ( +
+
+ {groupContent} +
+
+ ) : null} +
+ ) +} diff --git a/src/components/common/navigation_stack/panelHistory.ts b/src/components/common/navigation_stack/panelHistory.ts new file mode 100644 index 00000000..dfa58982 --- /dev/null +++ b/src/components/common/navigation_stack/panelHistory.ts @@ -0,0 +1,42 @@ +const PANEL_HISTORY_STORAGE_KEY = 'pv.admin.panel.history' + +export function readPanelHistory(): string[] { + if (typeof window === 'undefined') { + return [] + } + + const rawHistory = window.sessionStorage.getItem(PANEL_HISTORY_STORAGE_KEY) + if (!rawHistory) { + return [] + } + + try { + const parsed: unknown = JSON.parse(rawHistory) + return Array.isArray(parsed) + ? parsed.filter( + (value): value is string => typeof value === 'string' + ) + : [] + } catch { + return [] + } +} + +export function writePanelHistory(history: string[]): void { + if (typeof window === 'undefined') { + return + } + + window.sessionStorage.setItem( + PANEL_HISTORY_STORAGE_KEY, + JSON.stringify(history.slice(-50)) + ) +} + +export function clearPanelHistory(): void { + if (typeof window === 'undefined') { + return + } + + window.sessionStorage.removeItem(PANEL_HISTORY_STORAGE_KEY) +} diff --git a/src/components/common/navigation_stack/sidebar/Sidebar.module.css b/src/components/common/navigation_stack/sidebar/Sidebar.module.css new file mode 100644 index 00000000..675282b2 --- /dev/null +++ b/src/components/common/navigation_stack/sidebar/Sidebar.module.css @@ -0,0 +1,662 @@ +.sidebar { + --sidebar-collapse-duration: 280ms; + --sidebar-collapse-ease: cubic-bezier(0.22, 1, 0.36, 1); + --sidebar-content-slide-duration: 240ms; + --sidebar-content-slide-ease: cubic-bezier(0.32, 0.72, 0, 1); + overflow: hidden; + box-shadow: 0 18px 45px rgba(15, 23, 42, 0.08); + transition: + width var(--sidebar-collapse-duration) var(--sidebar-collapse-ease), + flex-basis var(--sidebar-collapse-duration) var(--sidebar-collapse-ease), + padding var(--sidebar-collapse-duration) var(--sidebar-collapse-ease), + border-right-color var(--sidebar-collapse-duration) + var(--sidebar-collapse-ease), + box-shadow var(--sidebar-collapse-duration) var(--sidebar-collapse-ease); + will-change: width, flex-basis; +} + +.sidebarProminent, +.sidebarMinimal { + position: relative; + display: flex; + flex-direction: column; + gap: var(--navigation-stack-sidebar-gap, 1rem); + padding: var(--navigation-stack-sidebar-padding, 0.9rem); + width: 100%; + height: 100dvh; + background: var(--navigation-stack-sidebar-bg, transparent); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + border-right: 1px solid rgba(17, 24, 39, 0.08); +} + +.sidebar[data-sidebar-visual-mode='prominent-bare'] { + box-shadow: none; + border-right-width: 2px; +} + +.sidebar[data-sidebar-visual-mode='prominent-bare'][data-sidebar-collapsed='true'], +.sidebar[data-sidebar-visual-mode='prominent-bare'].sidebarHiddenCollapsed { + border-right-width: 0; + border-right-color: transparent; +} + +.sidebar[data-keep-border-when-collapsed='true'][data-sidebar-visual-mode='prominent-bare'][data-sidebar-collapsed='true'] { + border-right-width: 2px; + border-right-color: rgba(17, 24, 39, 0.08); +} + +.sidebarWithProminentHeader { + padding-top: 0; +} + +.sidebarWithProminentHeader > .panelHeader { + margin-top: 0; + margin-right: calc(-1 * var(--navigation-stack-sidebar-padding, 0.9rem)); + margin-left: calc(-1 * var(--navigation-stack-sidebar-padding, 0.9rem)); +} + +.sidebarOpen { + --navigation-stack-sidebar-width: var( + --navigation-stack-sidebar-open-width, + 15.25rem + ); +} + +@media (min-width: 96rem) { + .sidebarOpen { + --navigation-stack-sidebar-width: var( + --navigation-stack-sidebar-open-width, + 17rem + ); + } +} + +.sidebarClosed { + --navigation-stack-sidebar-width: var( + --navigation-stack-sidebar-collapsed-width, + 4.5rem + ); +} + +.sidebarHiddenCollapsed { + --navigation-stack-sidebar-width: 0rem; + width: 0; + min-width: 0; + padding-left: 0; + padding-right: 0; + border-right-width: 0; + box-shadow: none; + overflow: hidden; +} + +.sidebar > * { + transition: transform var(--sidebar-content-slide-duration) + var(--sidebar-content-slide-ease); + will-change: transform; +} + +.sidebarHiddenCollapsed > * { + transform: translateX(-0.4rem); + pointer-events: none; +} + +@media (max-width: 63.999rem) { + .sidebar { + width: 100%; + min-width: 0; + flex: 1 1 100%; + } + + .sidebarMinimal, + .sidebarProminent { + border-right-width: 0; + } + + .sidebar[data-mobile-visible='false'] { + display: none; + } +} + +.label { + display: block; + max-width: 100%; + max-height: 2rem; + margin: 0; + + padding: 0.25rem 0.35rem 0.35rem; + + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.01em; + + color: rgba(15, 23, 42, 0.86); + + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + transition: + max-width var(--sidebar-content-slide-duration) + var(--sidebar-content-slide-ease), + max-height var(--sidebar-content-slide-duration) + var(--sidebar-content-slide-ease), + padding-top var(--sidebar-content-slide-duration) + var(--sidebar-content-slide-ease), + padding-bottom var(--sidebar-content-slide-duration) + var(--sidebar-content-slide-ease), + opacity 170ms ease, + transform var(--sidebar-content-slide-duration) + var(--sidebar-content-slide-ease); + transform-origin: left center; +} + +.labelCollapsed { + max-width: 0; + max-height: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + transform: translateY(-0.2rem); + pointer-events: none; +} + +.prominentLabel { + margin: 0; + + padding: 0.75rem 0.5rem; + + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.02em; + + color: rgba(15, 23, 42, 0.95); + + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.header { + display: block; + overflow: hidden; + /* display: flex; + min-height: var(--navigation-stack-sidebar-header-min-height, 10rem); + align-items: center; + overflow: hidden; */ +} + +.footer { + display: contents; +} + +@media (min-width: 64rem) { + .sidebar { + flex-grow: 0; + flex-shrink: 0; + flex-basis: var(--navigation-stack-sidebar-width, 17rem); + width: var(--navigation-stack-sidebar-width, 17rem); + height: 100%; + } +} +.body { + position: relative; + list-style: none; + margin: 0; + padding: 0; + + display: flex; + flex-direction: column; + gap: calc(var(--navigation-stack-sidebar-gap, 1rem) * 0.25); +} + +.sidebarBodyWrapper { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: clip; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + margin-right: calc(-1 * var(--navigation-stack-sidebar-padding, 0.9rem)); + padding-right: var(--navigation-stack-sidebar-padding, 0.9rem); +} + +.sidebarBodyWrapperHideScrollbar { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.sidebarBodyWrapperHideScrollbar::-webkit-scrollbar { + display: none; +} + +.prominentFooter { + margin-top: auto; + margin-right: calc(-1 * var(--navigation-stack-sidebar-padding, 0.9rem)); + margin-left: calc(-1 * var(--navigation-stack-sidebar-padding, 0.9rem)); + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 1rem; + border-top: 2px solid #e5e7eb; + background-color: #f9fafb; + overflow: visible; + white-space: nowrap; + min-width: max-content; + max-height: 10rem; + transition: + max-height 280ms cubic-bezier(0.22, 1, 0.36, 1), + padding 280ms cubic-bezier(0.22, 1, 0.36, 1), + gap 280ms cubic-bezier(0.22, 1, 0.36, 1), + border-top-color 280ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.sidebar[data-hide-footer-when-collapsed='true'][data-sidebar-collapsed='true'] + .prominentFooter { + overflow: hidden; + padding: 0; + gap: 0; + max-height: 0; + border-top-color: transparent; + transition: + max-height 280ms cubic-bezier(0.22, 1, 0.36, 1), + padding 280ms cubic-bezier(0.22, 1, 0.36, 1), + gap 280ms cubic-bezier(0.22, 1, 0.36, 1), + border-top-color 280ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.sidebar[data-hide-footer-when-collapsed='true'][data-sidebar-collapsed='true'] + .prominentFooter + > * { + visibility: hidden; + pointer-events: none; +} + +.selectionIndicator { + position: absolute; + right: calc( + -1 * var(--navigation-stack-sidebar-padding, 0.9rem) - + var(--navigation-stack-indicator-overhang, 0.1rem) + ); + width: 0.25rem; + background: #0ea5e9; + border-top-left-radius: 0.5rem; + border-bottom-left-radius: 0.5rem; + opacity: 0; + transition: + top 220ms ease, + height 220ms ease, + opacity 160ms ease; + pointer-events: none; +} + +.selectionIndicator[data-visible='true'] { + opacity: 1; +} + +.selectionIndicator[data-layout-syncing='true'] { + transition: + top 90ms ease-out, + height 90ms ease-out, + opacity 160ms ease; +} + +.toggleButton { + position: absolute; + right: 0.75rem; + bottom: 0.75rem; + + display: flex; + width: 2.25rem; + height: 2.25rem; + align-items: center; + justify-content: center; + + cursor: pointer; + border-radius: 9999px; + + background: rgba(255, 255, 255, 0.65); + backdrop-filter: blur(14px) saturate(160%); + -webkit-backdrop-filter: blur(14px) saturate(160%); + + border: 1px solid rgba(17, 24, 39, 0.12); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.7) inset, + 0 10px 24px rgba(15, 23, 42, 0.14); + + color: rgba(15, 23, 42, 0.6); + + transition: + transform 140ms ease, + box-shadow 140ms ease, + background-color 140ms ease, + color 140ms ease; +} + +.toggleButton:hover { + transform: translateY(-1px); + color: rgba(15, 23, 42, 0.8); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.75) inset, + 0 14px 32px rgba(15, 23, 42, 0.18); +} + +.toggleButton:active { + transform: translateY(0px) scale(0.98); +} + +.toggleIcon { + transition: transform 220ms cubic-bezier(0.2, 0.9, 0.2, 1); + transform-origin: center; +} + +.toggleIconClosed { + transform: rotate(180deg); +} + +@media (min-width: 64rem) { + .toggleButton { + display: inline-flex; + } +} + +.content { + display: flex; + flex-direction: column; + flex: 1; + + height: calc(100dvh - 7.75rem); + margin: 0.75rem; + border-radius: 1.25rem; + + background: rgba(255, 255, 255, 0.4); + backdrop-filter: blur(16px) saturate(150%); + -webkit-backdrop-filter: blur(16px) saturate(150%); + + border: 1px solid rgba(17, 24, 39, 0.08); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.75) inset, + 0 24px 60px rgba(15, 23, 42, 0.08); + + overflow: hidden; +} + +.panelHeader { + box-sizing: border-box; + position: relative; + display: flex; + flex-wrap: nowrap; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + column-gap: 0.75rem; + min-width: 0; + min-height: var(--navigation-stack-sidebar-header-min-height, 4.05rem); + padding: 0.8rem 1.25rem; + background-color: #f9fafb; + backdrop-filter: blur(14px) saturate(150%); + -webkit-backdrop-filter: blur(14px) saturate(150%); + border-bottom: 2px solid #e5e7eb; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.75) inset; + overflow: visible; +} + +.panelTimestamp { + font-size: 0.78rem; + font-weight: 700; + line-height: 1; + white-space: nowrap; + color: rgba(15, 23, 42, 0.45); +} + +.panelHeaderRight { + display: inline-flex; + flex: 0 0 auto; + flex-shrink: 0; + align-items: center; + justify-content: flex-end; + justify-self: end; + margin-left: auto; + max-width: 100%; +} + +.panelHeaderRightFilterToggle { + width: 2.25rem; + flex-basis: 2.25rem; + line-height: 0; +} + +.filterToggleSlot { + position: absolute; + top: 50%; + right: 1.25rem; + transform: translateY(-50%); +} + +.filterToggleButton { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + + border: 0; + background: transparent; + border-radius: 0.85rem; + padding: 0.5rem; + + font: inherit; + line-height: 1; + cursor: pointer; + color: rgba(15, 23, 42, 0.62); + -webkit-tap-highlight-color: transparent; +} + +.filterToggleButton:hover { + background: rgba(15, 23, 42, 0.06); +} + +.panelToggleButton { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + + border: 0; + background: transparent; + border-radius: 0.85rem; + padding: 0.5rem; + + font: inherit; + line-height: 1; + cursor: pointer; + color: #696d79; + -webkit-tap-highlight-color: transparent; + + transition: + background-color 140ms ease, + color 140ms ease; +} + +.panelToggleButton:hover { + background: rgba(15, 23, 42, 0.06); + color: #696d79; +} + +.panelToggleButton:active { + background: rgba(15, 23, 42, 0.1); +} + +.sidebarIcon { + transition: opacity 200ms ease; + transform: translateY(-0.5px); +} + +.panelHeaderLeft { + display: inline-flex; + flex: 1 1 auto; + align-items: center; + gap: 0.75rem; + justify-self: start; + min-width: 0; + overflow: clip; + overflow-clip-margin: 0.6rem; +} + +.panelHeaderLeftShifted { + padding-left: 3.5rem; + transition: padding-left var(--sidebar-collapse-duration) + var(--sidebar-collapse-ease); +} + +.breadcrumbs { + display: inline-flex; + min-width: 0; + overflow: hidden; + align-items: center; + gap: 0.5rem; + font-size: 0.86rem; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.72); + user-select: none; + white-space: nowrap; + text-overflow: ellipsis; +} + +.prominentBreadcrumb { + font-weight: 700; + color: rgba(15, 23, 42, 0.72); + opacity: 1; + transform: translateX(0); + transition: + opacity var(--sidebar-content-slide-duration) + var(--sidebar-content-slide-ease), + transform var(--sidebar-content-slide-duration) + var(--sidebar-content-slide-ease); +} + +.sidebarWithProminentHeader[data-sidebar-collapsed='true'] + .prominentBreadcrumb { + opacity: 0; + transform: translateX(-0.2rem); +} + +.breadcrumbSeperator { + color: rgba(15, 23, 42, 0.35); + font-weight: 700; +} + +.panelBreadcrumb { + font-weight: 650; + color: rgba(15, 23, 42, 0.55); +} + +.largeTitleBlock { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 0.25rem 0 0.5rem; + max-height: 8rem; + overflow: hidden; + transition: + max-height 320ms cubic-bezier(0.32, 0.72, 0, 1), + padding 320ms cubic-bezier(0.32, 0.72, 0, 1), + opacity 240ms ease; +} + +.largeTitleBlock[data-collapsed='true'] { + max-height: 0; + padding: 0; + opacity: 0; + pointer-events: none; + transition: + max-height 280ms cubic-bezier(0.22, 1, 0.36, 1), + padding 280ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 180ms ease; +} + +.largeTitle { + margin: 0; + padding: 1rem 1rem 0rem; + + font-size: 1.85rem; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.1; + color: rgba(15, 23, 42, 0.86); + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; + transform-origin: left center; + + opacity: 1; + transform: translateY(0); + transition: + opacity 240ms cubic-bezier(0.32, 0.72, 0, 1), + transform 320ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.sidebar[data-sidebar-large-title-collapsed='true'] .largeTitle { + opacity: 0; + transform: translateY(-0.6rem); +} + +.largeTitleSearch { + padding: 0 0.2rem; + max-height: 4rem; + opacity: 1; + overflow: hidden; + transition: + max-height 280ms cubic-bezier(0.32, 0.72, 0, 1), + opacity 200ms ease, + transform 320ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.sidebar[data-sidebar-large-title-collapsed='true'] .largeTitleSearch { + max-height: 0; + opacity: 0; + transform: translateY(-0.35rem); + pointer-events: none; +} + +.prominentBreadcrumb[data-large-title='true'] { + opacity: 0; + transform: translateY(0.35rem); + transition: + opacity 240ms cubic-bezier(0.32, 0.72, 0, 1), + transform 320ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.sidebar[data-sidebar-large-title-collapsed='true'] + .prominentBreadcrumb[data-large-title='true'] { + opacity: 1; + transform: translateY(0); +} + +.sidebar[data-sidebar-large-title-collapsed='true'] + .panelHeaderLeft + [data-back-label] { + display: none; +} + +.sidebar[data-sidebar-large-title-collapsed='true'] + .panelHeaderLeft + button:has([data-back-label]) { + padding-right: 0.4rem; +} + +.sidebarWithProminentHeader[data-sidebar-collapsed='true'] + .prominentBreadcrumb[data-large-title='true'] { + opacity: 0; +} + +@media (prefers-reduced-motion: reduce) { + .largeTitle, + .largeTitleSearch, + .prominentBreadcrumb[data-large-title='true'] { + transition: none; + } +} diff --git a/src/components/common/navigation_stack/sidebar/Sidebar.tsx b/src/components/common/navigation_stack/sidebar/Sidebar.tsx new file mode 100644 index 00000000..298574b8 --- /dev/null +++ b/src/components/common/navigation_stack/sidebar/Sidebar.tsx @@ -0,0 +1,1107 @@ +'use client' + +import styles from './Sidebar.module.css' +import { DiscordAvatar } from '@/components/common/DiscordAvatar' +import { DropdownButton } from '@/components/common/dropdown/DropdownButton' +import { DropdownOverlay } from '@/components/common/dropdown/DropdownOverlay' +import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' +import { useCurrentUser } from '@/util/hooks' +import { usePathname } from 'next/navigation' +import { Children, isValidElement, useEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactElement, ReactNode } from 'react' +import { FiChevronLeft } from 'react-icons/fi' +import { IoMdOptions } from 'react-icons/io' +import { IoClose } from 'react-icons/io5' +import { useMediaQuery } from 'usehooks-ts' + +type SidebarVariant = 'minimal' | 'prominent' +type SidebarVisualMode = 'minimal' | 'prominent' | 'prominent-bare' +type SidebarHeaderMode = 'shown' | 'hidden' + +interface IndicatorStyle { + top: number + height: number + visible: boolean +} + +interface SidebarBodyProps { + bodyRef: React.RefObject + indicatorLayoutSyncing: boolean + indicatorStyle: IndicatorStyle + showSelectionIndicator: boolean + children?: ReactNode +} + +interface MinimalSidebarHeaderProps { + label?: string + isOpen: boolean +} + +interface ProminentSidebarHeaderProps { + label?: string + prominentHeader?: ReactNode + prominentHeaderLeft?: ReactNode + prominentHeaderRight?: ReactNode + filterOpen?: boolean + onFilterOpenChange?: (open: boolean) => void + filterContent?: ReactNode + reserveToggleSpace: boolean + largeTitle?: boolean +} + +interface SidebarFooterProps { + isOpen: boolean + hiddenCollapsed: boolean + onToggle: () => void +} + +export interface SidebarRootProps { + label?: string + variant?: SidebarVariant + width?: string + collapsedWidth?: string + collapsedMode?: 'compact' | 'hidden' + open?: boolean + onOpenChange?: (open: boolean) => void + mobileVisible?: boolean + showScrollbar?: boolean + showFooterToggle?: boolean + hideFooterWhenCollapsed?: boolean + keepBorderWhenCollapsed?: boolean + reserveHeaderToggleSpace?: boolean + showSelectionIndicator?: boolean + className?: string + children?: ReactNode +} + +export type NavigationStackSlotProps = SidebarRootProps + +export interface SidebarHeaderSlotProps { + mode?: SidebarHeaderMode + label?: string + largeTitle?: boolean + children?: ReactNode +} + +export interface SidebarHeaderFiltersSlotProps { + open?: boolean + onOpenChange?: (open: boolean) => void + content?: ReactNode + children?: ReactNode +} + +interface SidebarNamedSlotProps { + children?: ReactNode +} + +interface ParsedHeaderSlots { + left?: ReactNode + right?: ReactNode + content?: ReactNode + search?: ReactNode + filters?: SidebarHeaderFiltersSlotProps +} + +interface ResolvedHeaderProps { + mode: SidebarHeaderMode + label?: string + largeTitle?: boolean + search?: ReactNode + filterOpen?: boolean + onFilterOpenChange?: (open: boolean) => void + filterContent?: ReactNode + prominentHeader?: ReactNode + prominentHeaderLeft?: ReactNode + prominentHeaderRight?: ReactNode +} + +interface ResolvedSidebarProps { + variant: SidebarVariant + width?: string + collapsedWidth?: string + collapsedMode: 'compact' | 'hidden' + open?: boolean + onOpenChange?: (open: boolean) => void + mobileVisible?: boolean + showScrollbar: boolean + showFooterToggle: boolean + hideFooterWhenCollapsed: boolean + keepBorderWhenCollapsed: boolean + reserveHeaderToggleSpace: boolean + showSelectionIndicator: boolean + className?: string + header: ResolvedHeaderProps + featured?: ReactNode + body?: ReactNode + footer?: ReactNode +} + +interface SidebarComponent { + (props: SidebarRootProps): ReactElement + Header: (props: SidebarHeaderSlotProps) => ReactElement | null + HeaderLeft: (props: SidebarNamedSlotProps) => ReactElement | null + HeaderRight: (props: SidebarNamedSlotProps) => ReactElement | null + HeaderContent: (props: SidebarNamedSlotProps) => ReactElement | null + HeaderSearch: (props: SidebarNamedSlotProps) => ReactElement | null + HeaderFilters: (props: SidebarHeaderFiltersSlotProps) => ReactElement | null + Featured: (props: SidebarNamedSlotProps) => ReactElement | null + Body: (props: SidebarNamedSlotProps) => ReactElement | null + Footer: (props: SidebarNamedSlotProps) => ReactElement | null +} + +interface SidebarSlotElementProps { + children?: ReactNode +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function SidebarHeaderSlot(_: SidebarHeaderSlotProps): ReactElement | null { + return null +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function SidebarHeaderLeftSlot(_: SidebarNamedSlotProps): ReactElement | null { + return null +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function SidebarHeaderRightSlot(_: SidebarNamedSlotProps): ReactElement | null { + return null +} + +function SidebarHeaderContentSlot( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _: SidebarNamedSlotProps +): ReactElement | null { + return null +} + +function SidebarHeaderSearchSlot( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _: SidebarNamedSlotProps +): ReactElement | null { + return null +} + +function SidebarHeaderFiltersSlot( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _: SidebarHeaderFiltersSlotProps +): ReactElement | null { + return null +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function SidebarFeaturedSlot(_: SidebarNamedSlotProps): ReactElement | null { + return null +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function SidebarBodySlot(_: SidebarNamedSlotProps): ReactElement | null { + return null +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function SidebarFooterSlot(_: SidebarNamedSlotProps): ReactElement | null { + return null +} + +function parseHeaderSlots(children: ReactNode): ParsedHeaderSlots { + const contentNodes: ReactNode[] = [] + const parsed: ParsedHeaderSlots = {} + + for (const child of Children.toArray(children)) { + if (!isValidElement(child)) { + contentNodes.push(child) + continue + } + + if (child.type === SidebarHeaderLeftSlot) { + parsed.left = (child.props as SidebarSlotElementProps).children + continue + } + + if (child.type === SidebarHeaderRightSlot) { + parsed.right = (child.props as SidebarSlotElementProps).children + continue + } + + if (child.type === SidebarHeaderContentSlot) { + parsed.content = (child.props as SidebarSlotElementProps).children + continue + } + + if (child.type === SidebarHeaderSearchSlot) { + parsed.search = (child.props as SidebarSlotElementProps).children + continue + } + + if (child.type === SidebarHeaderFiltersSlot) { + const filtersProps = child.props as SidebarHeaderFiltersSlotProps + parsed.filters = { + open: filtersProps.open, + onOpenChange: filtersProps.onOpenChange, + content: filtersProps.content ?? filtersProps.children, + } + continue + } + + contentNodes.push(child) + } + + if (contentNodes.length > 0) { + parsed.content = <>{contentNodes} + } + + return parsed +} + +function resolveHeaderProps( + rootLabel: string | undefined, + headerSlot: SidebarHeaderSlotProps | undefined, + variant: SidebarVariant +): ResolvedHeaderProps { + const nestedSlots = parseHeaderSlots(headerSlot?.children) + + return { + mode: + headerSlot?.mode ?? (variant === 'prominent' ? 'hidden' : 'shown'), + label: headerSlot?.label ?? rootLabel, + largeTitle: headerSlot?.largeTitle ?? false, + search: nestedSlots.search, + filterOpen: nestedSlots.filters?.open, + onFilterOpenChange: nestedSlots.filters?.onOpenChange, + filterContent: nestedSlots.filters?.content, + prominentHeader: nestedSlots.content, + prominentHeaderLeft: nestedSlots.left, + prominentHeaderRight: nestedSlots.right, + } +} + +function resolveSidebarProps(props: SidebarRootProps): ResolvedSidebarProps { + const variant = props.variant ?? 'minimal' + let headerSlot: SidebarHeaderSlotProps | undefined + let featured: ReactNode + let footer: ReactNode + const bodyNodes: ReactNode[] = [] + + for (const child of Children.toArray(props.children)) { + if (!isValidElement(child)) { + bodyNodes.push(child) + continue + } + + if (child.type === SidebarHeaderSlot) { + headerSlot = child.props as SidebarHeaderSlotProps + continue + } + + if (child.type === SidebarFeaturedSlot) { + featured = (child.props as SidebarSlotElementProps).children + continue + } + + if (child.type === SidebarBodySlot) { + bodyNodes.push((child.props as SidebarSlotElementProps).children) + continue + } + + if (child.type === SidebarFooterSlot) { + footer = (child.props as SidebarSlotElementProps).children + continue + } + + bodyNodes.push(child) + } + + const body = bodyNodes.length > 0 ? <>{bodyNodes} : undefined + + return { + variant, + width: props.width, + collapsedWidth: props.collapsedWidth, + collapsedMode: props.collapsedMode ?? 'compact', + open: props.open, + onOpenChange: props.onOpenChange, + mobileVisible: props.mobileVisible, + showScrollbar: props.showScrollbar ?? true, + showFooterToggle: props.showFooterToggle ?? true, + hideFooterWhenCollapsed: props.hideFooterWhenCollapsed ?? false, + keepBorderWhenCollapsed: props.keepBorderWhenCollapsed ?? false, + reserveHeaderToggleSpace: props.reserveHeaderToggleSpace ?? false, + showSelectionIndicator: props.showSelectionIndicator ?? false, + className: props.className, + header: resolveHeaderProps(props.label, headerSlot, variant), + featured, + body, + footer, + } +} + +function cx(...parts: (string | false | undefined)[]): string { + return parts.filter(Boolean).join(' ') +} + +function getSidebarInlineStyle( + sidebarWidth?: string, + collapsedWidth?: string +): CSSProperties | undefined { + if (!sidebarWidth && !collapsedWidth) return undefined + const style: Record = {} + if (sidebarWidth) + style['--navigation-stack-sidebar-open-width'] = sidebarWidth + if (collapsedWidth) + style['--navigation-stack-sidebar-collapsed-width'] = collapsedWidth + return style as CSSProperties +} + +function getProminentSidebarVisualMode( + includeHeader: boolean +): SidebarVisualMode { + return includeHeader ? 'prominent' : 'prominent-bare' +} + +function useSidebarOpenState( + controlledOpen: boolean | undefined, + onOpenChange?: (open: boolean) => void +): { + isDesktop: boolean + isOpen: boolean + desktopOpen: boolean + toggle: () => void +} { + const [uncontrolledOpen, setUncontrolledOpen] = useState(true) + const isDesktop = useMediaQuery('(min-width: 64rem)') + const desktopOpen = controlledOpen ?? uncontrolledOpen + const isOpen = !isDesktop || desktopOpen + + function toggle() { + const nextOpen = !desktopOpen + onOpenChange?.(nextOpen) + if (controlledOpen === undefined) { + setUncontrolledOpen(nextOpen) + } + } + + return { isDesktop, isOpen, desktopOpen, toggle } +} + +function useSidebarIndicator( + pathname: string, + isDesktop: boolean, + isOpen: boolean, + enabled: boolean +): { + bodyRef: React.RefObject + indicatorLayoutSyncing: boolean + indicatorStyle: IndicatorStyle +} { + const [indicatorLayoutSyncing, setIndicatorLayoutSyncing] = useState(false) + const [indicatorStyle, setIndicatorStyle] = useState({ + top: 0, + height: 0, + visible: false, + }) + const bodyRef = useRef(null) + const indicatorLayoutSyncTimeoutRef = useRef | null>(null) + + useEffect(() => { + if (!enabled) { + setIndicatorLayoutSyncing(false) + setIndicatorStyle((previous) => + previous.visible ? { ...previous, visible: false } : previous + ) + return + } + + let frameId: number | null = null + + function syncIndicatorToLayout() { + if (indicatorLayoutSyncTimeoutRef.current !== null) { + clearTimeout(indicatorLayoutSyncTimeoutRef.current) + } + + setIndicatorLayoutSyncing(true) + indicatorLayoutSyncTimeoutRef.current = setTimeout(() => { + setIndicatorLayoutSyncing(false) + indicatorLayoutSyncTimeoutRef.current = null + }, 140) + } + + function getActiveLink(bodyElement: HTMLDivElement) { + return ( + bodyElement.querySelector( + '[data-indicator-target="true"]:not([data-show-indicator="false"])' + ) ?? + bodyElement.querySelector( + '[aria-current="page"]:not([data-show-indicator="false"])' + ) + ) + } + + function updateIndicator() { + const bodyElement = bodyRef.current + + if (!bodyElement) { + return + } + + const activeLink = getActiveLink(bodyElement) + + if (!activeLink) { + setIndicatorStyle((previous) => + previous.visible + ? { ...previous, visible: false } + : previous + ) + return + } + + let nextTop = 0 + let node: HTMLElement | null = activeLink + + while (node && node !== bodyElement) { + nextTop += node.offsetTop + node = node.offsetParent as HTMLElement | null + } + + const nextHeight = activeLink.offsetHeight + + setIndicatorStyle((previous) => { + const topChanged = Math.abs(previous.top - nextTop) > 0.5 + const heightChanged = + Math.abs(previous.height - nextHeight) > 0.5 + + if (!topChanged && !heightChanged && previous.visible) { + return previous + } + + return { + top: nextTop, + height: nextHeight, + visible: true, + } + }) + } + + function scheduleIndicatorSync() { + if (frameId !== null) { + return + } + + frameId = requestAnimationFrame(() => { + frameId = null + updateIndicator() + }) + } + + updateIndicator() + window.addEventListener('resize', updateIndicator) + + const bodyElement = bodyRef.current + const resizeObserver = + typeof ResizeObserver === 'undefined' || !bodyElement + ? null + : new ResizeObserver(() => { + syncIndicatorToLayout() + scheduleIndicatorSync() + }) + const mutationObserver = bodyElement + ? new MutationObserver(scheduleIndicatorSync) + : null + + if (bodyElement) { + resizeObserver?.observe(bodyElement) + } + + if (bodyElement && mutationObserver) { + mutationObserver.observe(bodyElement, { + attributes: true, + attributeFilter: [ + 'data-open', + 'aria-current', + 'data-indicator-target', + 'class', + ], + childList: true, + subtree: true, + }) + } + + return () => { + window.removeEventListener('resize', updateIndicator) + resizeObserver?.disconnect() + mutationObserver?.disconnect() + + if (indicatorLayoutSyncTimeoutRef.current !== null) { + clearTimeout(indicatorLayoutSyncTimeoutRef.current) + } + + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } + }, [pathname, isDesktop, isOpen, enabled]) + + return { + bodyRef, + indicatorLayoutSyncing, + indicatorStyle, + } +} + +function useLargeTitleScroll( + scrollRef: React.RefObject, + titleRef: React.RefObject, + enabled: boolean +): boolean { + const [collapsed, setCollapsed] = useState(false) + + useEffect(() => { + if (!enabled) { + setCollapsed((previous) => (previous ? false : previous)) + return + } + + const scrollElement = scrollRef.current + + if (!scrollElement) { + return + } + + let frameId: number | null = null + + function evaluate() { + frameId = null + + const element = scrollRef.current + + if (!element) { + return + } + + const collapseTrigger = 12 + const expandTrigger = 4 + + setCollapsed((previous) => { + if (!previous && element.scrollTop > collapseTrigger) { + return true + } + + if (previous && element.scrollTop <= expandTrigger) { + return false + } + + return previous + }) + } + + function handleScroll() { + if (frameId !== null) { + return + } + + frameId = requestAnimationFrame(evaluate) + } + + scrollElement.addEventListener('scroll', handleScroll, { + passive: true, + }) + evaluate() + + return () => { + scrollElement.removeEventListener('scroll', handleScroll) + + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } + }, [enabled, scrollRef, titleRef]) + + return enabled ? collapsed : false +} + +function SidebarRoot(sidebarProps: SidebarRootProps): ReactElement { + const props = resolveSidebarProps(sidebarProps) + + if (props.variant === 'prominent') { + return + } + + return +} + +export const Sidebar = Object.assign(SidebarRoot, { + Header: SidebarHeaderSlot, + HeaderLeft: SidebarHeaderLeftSlot, + HeaderRight: SidebarHeaderRightSlot, + HeaderContent: SidebarHeaderContentSlot, + HeaderSearch: SidebarHeaderSearchSlot, + HeaderFilters: SidebarHeaderFiltersSlot, + Featured: SidebarFeaturedSlot, + Body: SidebarBodySlot, + Footer: SidebarFooterSlot, +}) as SidebarComponent + +function MinimalSidebar({ + body, + featured, + width, + collapsedWidth, + collapsedMode, + open: controlledOpen, + onOpenChange, + mobileVisible, + showScrollbar, + showFooterToggle, + hideFooterWhenCollapsed, + keepBorderWhenCollapsed, + showSelectionIndicator, + className, + header, +}: ResolvedSidebarProps): ReactElement { + const pathname = usePathname() + const visualMode: SidebarVisualMode = 'minimal' + const { isDesktop, isOpen, toggle } = useSidebarOpenState( + controlledOpen, + onOpenChange + ) + const { bodyRef, indicatorLayoutSyncing, indicatorStyle } = + useSidebarIndicator(pathname, isDesktop, isOpen, showSelectionIndicator) + const hiddenCollapsed = isDesktop && !isOpen && collapsedMode === 'hidden' + const resolvedMobileVisible = + mobileVisible ?? !pathname.startsWith('/admin/panels/') + const sidebarInlineStyle = getSidebarInlineStyle(width, collapsedWidth) + + return ( + + ) +} + +function ProminentSidebar({ + body, + featured, + footer, + width, + collapsedWidth, + collapsedMode, + open: controlledOpen, + onOpenChange, + mobileVisible, + showScrollbar, + showFooterToggle, + hideFooterWhenCollapsed, + keepBorderWhenCollapsed, + reserveHeaderToggleSpace, + showSelectionIndicator, + className, + header, +}: ResolvedSidebarProps): ReactElement { + const pathname = usePathname() + const includeHeader = header.mode !== 'hidden' + const visualMode = getProminentSidebarVisualMode(includeHeader) + const { isDesktop, isOpen, toggle } = useSidebarOpenState( + controlledOpen, + onOpenChange + ) + const { bodyRef, indicatorLayoutSyncing, indicatorStyle } = + useSidebarIndicator(pathname, isDesktop, isOpen, showSelectionIndicator) + const scrollRef = useRef(null) + const largeTitleRef = useRef(null) + const collapsed = isDesktop && !isOpen + const largeTitleActive = header.largeTitle ?? false + const largeTitleEnabled = largeTitleActive && !collapsed + const titleCollapsed = useLargeTitleScroll( + scrollRef, + largeTitleRef, + largeTitleEnabled + ) + const hiddenCollapsed = isDesktop && !isOpen && collapsedMode === 'hidden' + const reserveProminentHeaderToggleSpace = + isDesktop && reserveHeaderToggleSpace + const shellClassName = + visualMode === 'prominent-bare' ? styles.sidebarMinimal : undefined + const surfaceClassName = + visualMode === 'prominent' ? styles.sidebarProminent : undefined + const sidebarInlineStyle = getSidebarInlineStyle(width, collapsedWidth) + const resolvedMobileVisible = mobileVisible ?? true + + return ( + + ) +} + +function MinimalSidebarHeader({ + label, + isOpen, +}: MinimalSidebarHeaderProps): ReactElement { + return ( +
+ {label ? ( +
+ {label} +
+ ) : null} +
+ ) +} + +function ProminentSidebarHeader({ + label, + prominentHeader, + prominentHeaderLeft, + prominentHeaderRight, + filterOpen, + onFilterOpenChange, + filterContent, + reserveToggleSpace, + largeTitle, +}: ProminentSidebarHeaderProps): ReactElement { + if (prominentHeader) { + return <>{prominentHeader} + } + + const usesGeneratedFilterDropdown = + prominentHeaderRight == null && + filterContent !== undefined && + filterContent !== null + const usesGeneratedLegacyFilterToggle = + prominentHeaderRight == null && + !usesGeneratedFilterDropdown && + typeof filterOpen === 'boolean' && + !!onFilterOpenChange + const usesGeneratedFilterToggle = + usesGeneratedFilterDropdown || usesGeneratedLegacyFilterToggle + + const resolvedHeaderRight = + prominentHeaderRight ?? + (usesGeneratedFilterDropdown ? ( + } + menu={({ closeDropdown }) => ( + + )} + /> + ) : usesGeneratedLegacyFilterToggle ? ( +
+ +
+ ) : null) + + return ( +
+
+ {prominentHeaderLeft} +
+ + {label} + +
+
+ +
+ {resolvedHeaderRight} +
+
+ ) +} + +export function SidebarFeatured(): ReactElement { + const isDesktop = useMediaQuery('(min-width: 64rem)') + const featuredHref = isDesktop ? '/admin' : '/admin/panels/members' + const currentUser = useCurrentUser() + const displayName = + `${currentUser.data?.firstName ?? ''} ${currentUser.data?.lastName ?? ''}`.trim() || + (currentUser.data?.discordUsers?.[0]?.username + ? `@${currentUser.data.discordUsers[0].username}` + : 'Admin User') + const discordUser = currentUser.data?.discordUsers?.[0] + + return ( + + } + label={displayName} + resetPanelHistoryOnClick={!isDesktop} + subtitle={ + discordUser?.username ? `@${discordUser.username}` : undefined + } + /> + ) +} + +function SidebarBody({ + bodyRef, + indicatorLayoutSyncing, + indicatorStyle, + showSelectionIndicator, + children, +}: SidebarBodyProps): ReactElement { + return ( +
+ {showSelectionIndicator ? ( + + ) +} + +function SidebarFooter({ + isOpen, + hiddenCollapsed, + onToggle, +}: SidebarFooterProps): ReactElement { + if (hiddenCollapsed) { + return
+ } + + return ( +
+ +
+ ) +} + +interface SidebarToggleButtonProps { + isOpen: boolean + onToggle: () => void + className?: string + size?: number +} + +export function SidebarToggleButton({ + isOpen, + onToggle, + className, + size = 22, +}: SidebarToggleButtonProps): ReactElement { + return ( + + ) +} diff --git a/src/components/common/panel/Panel.module.css b/src/components/common/panel/Panel.module.css new file mode 100644 index 00000000..f428333f --- /dev/null +++ b/src/components/common/panel/Panel.module.css @@ -0,0 +1,145 @@ +.content { + display: flex; + flex-direction: column; + flex: 1; + + height: calc(100dvh - 7.75rem); + margin: 0.75rem; + border-radius: 1.25rem; + + background: rgba(255, 255, 255, 0.4); + backdrop-filter: blur(16px) saturate(150%); + -webkit-backdrop-filter: blur(16px) saturate(150%); + + border: 1px solid rgba(17, 24, 39, 0.08); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.75) inset, + 0 24px 60px rgba(15, 23, 42, 0.08); + + overflow: hidden; +} + +.panelHeader { + display: flex; + justify-content: space-between; + align-items: center; + column-gap: 0.75rem; + padding: 1.35rem 1.25rem; + background-color: #f9fafb; + backdrop-filter: blur(14px) saturate(150%); + -webkit-backdrop-filter: blur(14px) saturate(150%); + border-bottom: 2px solid #e5e7eb; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.75) inset; +} + +.panelTimestamp { + font-size: 0.78rem; + font-weight: 700; + color: rgba(15, 23, 42, 0.45); +} + +.panelFooter { + margin-top: auto; + display: flex; + gap: 1rem; + border-top-width: 2px; +} + +.panelHeaderLeft { + display: inline-flex; + flex: 1 1 auto; + align-items: center; + gap: 0.75rem; + justify-self: start; + min-width: 0; + overflow: hidden; +} + +.panelHeaderLeftShifted { + padding-left: 2.5rem; + transition: padding-left 280ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.panelHeaderRight { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: flex-end; + justify-self: end; + min-width: 0; + margin-left: auto; + max-width: 100%; +} + +.breadcrumbs { + display: inline-flex; + min-width: 0; + overflow: hidden; + align-items: center; + gap: 0.5rem; + font-size: 0.86rem; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.72); + user-select: none; + white-space: nowrap; + text-overflow: ellipsis; +} + +.prominentBreadcrumb { + font-weight: 700; + color: rgba(15, 23, 42, 0.72); +} + +.breadcrumbSeperator { + color: rgba(15, 23, 42, 0.35); + font-weight: 700; +} + +.panelBreadcrumb { + font-weight: 650; + color: rgba(15, 23, 42, 0.55); +} + +.sidebarBodyShell { + display: flex; + flex: 1 1 auto; + min-height: 0; + flex-direction: column; + overflow-y: auto; + overflow-x: hidden; + -webkit-overflow-scrolling: touch; +} + +.sidebarBodyHeader { + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 2px solid rgba(17, 24, 39, 0.1); + padding: 0.85rem 0.85rem 0.75rem 0.6rem; + overflow: hidden; + max-height: 5rem; + transition: + max-height 240ms cubic-bezier(0.2, 0.8, 0.2, 1), + padding-top 220ms ease, + padding-bottom 220ms ease, + border-bottom-color 200ms ease, + opacity 180ms ease, + transform 220ms ease; +} + +.sidebarBodyShell[data-scrolled='true'] .sidebarBodyHeader { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + border-bottom-color: transparent; + opacity: 0; + transform: translateY(-0.35rem); + pointer-events: none; +} + +.sidebarBodyContent { + display: flex; + flex: 1 0 auto; + min-height: 100%; + flex-direction: column; +} diff --git a/src/components/common/panel/Panel.tsx b/src/components/common/panel/Panel.tsx new file mode 100644 index 00000000..2d675337 --- /dev/null +++ b/src/components/common/panel/Panel.tsx @@ -0,0 +1,319 @@ +import styles from './Panel.module.css' +import { + SidebarListFilters, + type SidebarListFiltersConfig, +} from './sidebar_list/SidebarList' +import { + SidebarListFooter, + type SidebarListFooterProps, +} from './sidebar_list/SidebarList' +import { + SidebarListSearch, + type SidebarListSearchProps, +} from './sidebar_list/SidebarList' +import { + NavigationStack, + SidebarToggleButton, +} from '@/components/common/navigation_stack' +import { + Detail, + PanelBackButton, +} from '@/components/common/navigation_stack/detail/Detail' +import { Sidebar } from '@/components/common/navigation_stack/sidebar/Sidebar' +import type { ReactNode } from 'react' +import { useState } from 'react' +import { useMediaQuery } from 'usehooks-ts' + +export interface SidebarListConfig { + search?: SidebarListSearchProps + filters?: SidebarListFiltersConfig + footer?: SidebarListFooterProps +} + +export interface PanelProps { + children: ReactNode + label?: string + includeHeader?: boolean + includeSidebar?: boolean + largeTitle?: boolean + sidebarList?: SidebarListConfig + sidebarSearch?: ReactNode + sidebarFooter?: ReactNode + hideSidebarFooterWhenCollapsed?: boolean + keepSidebarBorderWhenCollapsed?: boolean + sidebarWidth?: string + collapsedSidebarWidth?: string + showScrollbar?: boolean + sidebarClassName?: string + sidebarBody?: ReactNode + sidebarMobileVisible?: boolean + sidebarFilterOpen?: boolean + onSidebarFilterOpenChange?: (open: boolean) => void + sidebarFilterContent?: ReactNode + collapsedSidebarMode?: 'compact' | 'hidden' + sidebarTogglePlacement?: 'header' | 'footer' + prominentHeader?: ReactNode + prominentHeaderLeft?: ReactNode + prominentHeaderRight?: ReactNode + headerLead?: ReactNode + headerLeft?: ReactNode + headerRight?: ReactNode + footer?: ReactNode +} + +export function Panel({ + children, + label, + includeHeader = false, + includeSidebar = false, + largeTitle = false, + sidebarList, + sidebarSearch, + sidebarFooter, + hideSidebarFooterWhenCollapsed = false, + keepSidebarBorderWhenCollapsed = false, + sidebarWidth, + collapsedSidebarWidth, + showScrollbar = true, + sidebarClassName, + sidebarBody, + sidebarMobileVisible, + sidebarFilterOpen, + onSidebarFilterOpenChange, + sidebarFilterContent, + collapsedSidebarMode = 'hidden', + sidebarTogglePlacement, + prominentHeader, + prominentHeaderLeft, + prominentHeaderRight, + headerLead, + headerLeft, + headerRight, + footer, +}: PanelProps) { + const panelLabel = label ?? 'Panel' + const [isSidebarOpen, setIsSidebarOpen] = useState(true) + const isDesktop = useMediaQuery('(min-width: 64rem)') + const showHeader = includeHeader + const resolvedSidebarTogglePlacement = + sidebarTogglePlacement ?? + (collapsedSidebarMode === 'hidden' ? 'header' : 'footer') + const showOverlaySidebarToggle = + includeSidebar && + resolvedSidebarTogglePlacement === 'header' && + isDesktop + const shiftHeaderLeftForToggle = showOverlaySidebarToggle && !isSidebarOpen + const resolvedHeaderLead = + headerLead ?? + (includeSidebar ? ( + isDesktop ? ( + + ) : null + ) : ( + + )) + const resolvedProminentHeaderLeft = + prominentHeaderLeft ?? + (includeSidebar && !isDesktop ? : undefined) + const resolvedSidebarFooter = + sidebarFooter ?? + (sidebarList?.footer ? ( + + ) : null) + const resolvedSidebarSearch = + sidebarSearch ?? + (sidebarList?.search ? ( + + ) : null) + const resolvedSidebarFilterContent = + sidebarFilterContent ?? + (sidebarList?.filters ? ( + + ) : undefined) + + return ( +
+ {includeSidebar ? ( + + setIsSidebarOpen((previous) => !previous) + } + /> + ) : null + } + sidebar={ + + + {resolvedSidebarSearch ? ( + + {resolvedSidebarSearch} + + ) : null} + {resolvedSidebarFilterContent ? ( + + ) : null} + {prominentHeader ? ( + + {prominentHeader} + + ) : null} + {resolvedProminentHeaderLeft ? ( + + {resolvedProminentHeaderLeft} + + ) : null} + {prominentHeaderRight ? ( + + {prominentHeaderRight} + + ) : null} + + + {resolvedSidebarFooter ? ( + + {resolvedSidebarFooter} + + ) : null} + + {sidebarBody} + + } + detail={ + + {showHeader ? ( + + ) : null} + {children} + {footer ? ( + {footer} + ) : null} + + } + /> + } + /> + ) : null} + {!includeSidebar ? ( + <> + {showHeader ? ( + + ) : null} + {children} + {footer ? {footer} : null} + + ) : null} +
+ ) +} + +export default Panel + +interface PanelHeaderProps { + panelLabel: string + shiftForOverlayToggle?: boolean + headerLead?: ReactNode + headerLeft?: ReactNode + headerRight?: ReactNode +} + +function PanelHeader({ + panelLabel, + shiftForOverlayToggle = false, + headerLead, + headerLeft, + headerRight, +}: PanelHeaderProps): ReactNode { + return ( +
+
+ {headerLead} + {headerLeft ?? ( +
+ + Admin + + / + + {panelLabel} + +
+ )} +
+
+ {headerRight ?? ( +
+ Last Updated: N/A +
+ )} +
+
+ ) +} + +interface PanelFooterProps { + children?: ReactNode +} + +function PanelFooter({ children }: PanelFooterProps): ReactNode { + return
{children}
+} diff --git a/src/components/common/panel/index.ts b/src/components/common/panel/index.ts new file mode 100644 index 00000000..7b80156d --- /dev/null +++ b/src/components/common/panel/index.ts @@ -0,0 +1,3 @@ +export { default } from './Panel' +export * from './Panel' +export * from './sidebar_list/SidebarList' diff --git a/src/components/common/panel/sidebar_list/SidebarBody.tsx b/src/components/common/panel/sidebar_list/SidebarBody.tsx new file mode 100644 index 00000000..ab9a1ab5 --- /dev/null +++ b/src/components/common/panel/sidebar_list/SidebarBody.tsx @@ -0,0 +1,89 @@ +'use client' + +import styles from './SidebarList.module.css' +import { NavigationButton } from '@/components/common/navigation_stack/navigation_button/NavigationButton' +import React from 'react' +import type { ReactNode } from 'react' + +export interface SidebarBodyItemConfig { + key: string | number + label: string + subtitle?: string + tagLabel?: string + iconNode?: ReactNode + href: string + onClick: (event: React.MouseEvent) => void +} + +export interface SidebarBodyProps { + items: T[] + pinnedItems?: T[] + isLoading?: boolean + error?: unknown + selectedKey?: string | number | null + renderItem: (item: T) => SidebarBodyItemConfig +} + +export function SidebarBody({ + items, + pinnedItems, + isLoading = false, + error = null, + selectedKey = null, + renderItem, +}: SidebarBodyProps) { + if (isLoading) { + return
Loading...
+ } + + if (error) { + const message = + error instanceof Error + ? error.message + : typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as { message?: unknown }).message === 'string' + ? (error as { message: string }).message + : 'Unknown error' + + return ( +
+ Error: {message} +
+ ) + } + + if (items.length === 0 && (!pinnedItems || pinnedItems.length === 0)) { + return
No items found
+ } + + const renderItemButton = (item: T) => { + const config = renderItem(item) + return ( + + ) + } + + return ( + <> + {pinnedItems && pinnedItems.length > 0 ? ( +
+ {pinnedItems.map((item) => renderItemButton(item))} +
+ ) : null} + {items.map((item) => renderItemButton(item))} + + ) +} diff --git a/src/components/common/panel/sidebar_list/SidebarList.module.css b/src/components/common/panel/sidebar_list/SidebarList.module.css new file mode 100644 index 00000000..e42cbde6 --- /dev/null +++ b/src/components/common/panel/sidebar_list/SidebarList.module.css @@ -0,0 +1,246 @@ +.controls { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.select { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.select > span { + font-weight: 500; + letter-spacing: -0.01em; + color: rgba(15, 23, 42, 0.7); +} + +.select > select { + border-radius: 0.85rem; + border: 1px solid rgba(17, 24, 39, 0.12); + background: rgba(255, 255, 255, 0.75); + padding: 0.35rem 0.55rem; + color: rgba(15, 23, 42, 0.8); + outline: none; +} + +.filterSelect { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.filterSelect > strong { + font-weight: 500; + color: rgba(15, 23, 42, 0.75); +} + +.searchInput { + display: flex; + width: 100%; + align-items: center; + padding-inline: 0.75rem; + gap: 0.5rem; +} + +.searchInput > input { + width: 100%; + border-radius: 0.6rem; + border-width: 1px; + border-color: #d1d5db; + padding: 0.25rem 0.75rem; + outline: none; + + &:focus { + border-color: #0ea5e9; + } +} + +.sidebarNavigationButton { + --navigation-button-item-margin: 0.1rem 0.25rem; + --navigation-button-item-width: auto; + --navigation-button-item-radius: 1rem; + --navigation-button-link-gap: 1.25rem; + --navigation-button-link-padding: 1rem; + --navigation-button-link-radius: 1rem; + --navigation-button-link-color: #000; + --navigation-button-label-font-size: 1rem; + --navigation-button-label-font-weight: 500; + --navigation-button-label-letter-spacing: 0; + --navigation-button-label-color: #000; + --navigation-button-link-hover-bg: #e5e7eb; + --navigation-button-link-active-bg: #e5e7eb; + --navigation-button-link-active-color: #000; + --navigation-button-link-active-hover-bg: #d1d5db; + overflow: hidden; +} + +.pageSelectContainer { + position: relative; + display: flex; + flex-direction: column; + align-items: center; +} + +.resultCount { + position: absolute; + top: 0; + left: calc(50% - 1px); + transform: translate(-50%, -50%); + font-size: 0.6rem; + font-weight: 600; + color: rgba(15, 23, 42, 0.55); + background-color: #f9fafb; + border: 0.12rem solid rgba(17, 24, 39, 0.1); + padding: 0.15rem 0.6rem; + border-radius: 9999px; + white-space: nowrap; + pointer-events: none; + z-index: 1; +} + +.pageSelect { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; +} + +.pageSelectButtons { + margin-left: auto; + margin-right: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + padding: 0.9rem; +} + +.pageSelectForm { + display: flex; + align-items: center; + gap: 0.5rem; + padding-left: 0.5rem; + padding-right: 0.5rem; + color: rgba(15, 23, 42, 0.72); +} + +.pageSelectInput { + width: 6ch; + max-width: min-content; + border-radius: 0.65rem; + border-width: 1px; + border-color: #d1d5db; + padding: 0.125rem 0.5rem; + text-align: center; + color: rgba(15, 23, 42, 0.62); +} + +.pageSelectInput:focus { + border-color: rgba(14, 165, 233, 0.35); + box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.12); +} + +.arrow { + display: flex; + user-select: none; + align-items: center; + justify-content: center; + border-radius: 0.85rem; + padding: 0.45rem; + + font-weight: 600; +} + +.arrow.disabled { + cursor: not-allowed; + color: rgba(15, 23, 42, 0.28); +} + +.arrow.enabled { + cursor: pointer; + color: rgba(15, 23, 42, 0.62); +} + +.arrow.enabled:hover { + background: rgba(15, 23, 42, 0.06); +} + +.arrow.enabled:active { + background-color: #f3f4f6; +} + +.pinnedSection { + position: sticky; + top: -2px; + z-index: 20; + background: rgba(249, 250, 251, 0.5); + backdrop-filter: blur(16px) saturate(145%); + border-bottom: 1px solid transparent; + transition: border-color 200ms ease; +} + +:global([data-sidebar-large-title-collapsed='true']) .pinnedSection, +:global([data-sidebar-collapsed='true']) .pinnedSection { + border-bottom-color: rgba(17, 24, 39, 0.1); +} + +.pinnedSection .sidebarNavigationButton { + margin-top: 3px; + margin-bottom: 2px; + transition: margin 280ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.sidebarNavigationButton a { + white-space: nowrap; + min-width: max-content; + transition: + padding 280ms cubic-bezier(0.22, 1, 0.36, 1), + background-color 220ms cubic-bezier(0.22, 0.61, 0.36, 1), + transform 140ms ease, + color 140ms ease, + box-shadow 140ms ease; +} + +:global([data-sidebar-collapsed='true']) .sidebarNavigationButton a { + gap: var(--navigation-button-link-gap, 1.25rem); +} + +:global([data-sidebar-collapsed='true']) .sidebarNavigationButton a > span { + max-width: none; + opacity: 1; +} + +:global([data-sidebar-collapsed='true']) + .sidebarNavigationButton + a + > span:nth-child(2) { + opacity: 0; +} + +:global([data-sidebar-collapsed='true']) + .sidebarNavigationButton + a + > span:last-child { + opacity: 0; +} + +.sidebarNavigationButton a > span:nth-child(2) { + transition: opacity 280ms ease; +} + +.sidebarNavigationButton a > span:last-child { + transition: opacity 280ms 100ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.sidebarState { + display: flex; + align-items: center; + justify-content: center; + padding: 2rem 1rem; + color: rgba(15, 23, 42, 0.5); + font-weight: 500; +} diff --git a/src/components/common/panel/sidebar_list/SidebarList.tsx b/src/components/common/panel/sidebar_list/SidebarList.tsx new file mode 100644 index 00000000..2d201be2 --- /dev/null +++ b/src/components/common/panel/sidebar_list/SidebarList.tsx @@ -0,0 +1,379 @@ +import styles from './SidebarList.module.css' +import { MultiSelect, type MultiSelectOption } from '@/components/common' +import { type SearchRequest, SortDirection } from '@/contracts/requests' +import { useEffect, useState } from 'react' +import { + FiChevronLeft, + FiChevronRight, + FiChevronsLeft, + FiChevronsRight, +} from 'react-icons/fi' +import type { IconType } from 'react-icons/lib' + +export { + SidebarBody, + type SidebarBodyProps, + type SidebarBodyItemConfig, +} from './SidebarBody' + +export interface SidebarListFilterOption { + value: string + label: string + options: MultiSelectOption[] +} + +export interface SidebarListFieldOption { + value: string + label: string +} + +export interface SidebarListFiltersProps { + search: SearchRequest + onSearch: (search: SearchRequest) => void + options?: SidebarListFilterOption[] + searchFieldOptions?: SidebarListFieldOption[] + sortFieldOptions?: SidebarListFieldOption[] + showLimit?: boolean + showSort?: boolean + limitOptions?: number[] +} + +export type SidebarListFiltersConfig = SidebarListFiltersProps + +export function SidebarListFilters({ + search, + onSearch, + options = [], + searchFieldOptions, + sortFieldOptions, + showLimit = true, + showSort = true, + limitOptions = [5, 10, 25, 50, 100], +}: SidebarListFiltersProps) { + const { + query, + searchField, + sortField, + limit, + sort, + page, + ...activeFilters + } = search as SearchRequest & Record + + const handleChangeLimit = (nextLimit: number) => { + onSearch({ ...search, limit: nextLimit, page: 0 }) + } + + const handleChangeSearchField = (nextSearchField: string | undefined) => { + onSearch({ ...search, searchField: nextSearchField, page: 0 }) + } + + const handleChangeSortField = (nextSortField: string | undefined) => { + onSearch({ ...search, sortField: nextSortField, page: 0 }) + } + + const handleChangeSort = (nextSort: SortDirection) => { + onSearch({ ...search, sort: nextSort, page: 0 }) + } + + const handleChangeFilter = ( + value: string, + selected: (string | number)[] + ) => { + const nextFilters = { + ...(activeFilters as Record), + } + + if (selected.length) nextFilters[value] = selected + else delete nextFilters[value] + + onSearch({ + query, + searchField, + sortField, + limit, + sort, + page, + ...nextFilters, + }) + } + + return ( +
+ {searchFieldOptions?.length ? ( + + ) : null} + + {sortFieldOptions?.length ? ( + + ) : null} + + {showLimit ? ( + + ) : null} + + {showSort ? ( + + ) : null} + + {options.map((option) => ( +
+ {option.label}: + )[ + option.value + ] as (string | number)[]) ?? [] + } + onUpdate={(selected) => + handleChangeFilter(option.value, selected) + } + /> +
+ ))} +
+ ) +} + +export interface SidebarListSearchProps { + search: SearchRequest + onSearch: (search: SearchRequest) => void + placeholder?: string + inputId?: string +} + +export function SidebarListSearch({ + search, + onSearch, + placeholder = 'Search...', + inputId = 'search', +}: SidebarListSearchProps) { + return ( +
+ + onSearch({ + ...search, + query: event.currentTarget.value, + page: 0, + }) + } + /> +
+ ) +} + +export interface SidebarListFooterProps { + page?: number + pageSize?: number + count?: number + isPending?: boolean + onPageChange: (page: number) => void +} + +export function SidebarListFooter({ + page = 0, + pageSize = 25, + count, + isPending = false, + onPageChange, +}: SidebarListFooterProps) { + const [value, setValue] = useState('') + + const totalCount = count ?? 0 + const pageCount = Math.ceil(totalCount / pageSize) + const canNavigate = pageCount > 1 + const maxPage = pageCount - 1 + + const handleChangeValue = (nextValue: string) => { + if (!nextValue || (/^\d+$/.test(nextValue) && nextValue.length < 10)) { + setValue(nextValue) + } + } + + const handleSubmit = () => { + const nextPage = +value - 1 + if (0 <= nextPage && nextPage <= maxPage) { + onPageChange(nextPage) + } else { + setValue((page + 1).toString()) + } + } + + useEffect(() => { + setValue((page + 1).toString()) + }, [page]) + + useEffect(() => { + if (page < 0) onPageChange(0) + else if (maxPage >= 0 && page > maxPage) onPageChange(maxPage) + }, [page, maxPage, onPageChange]) + + if (count == null) return null + + const startItem = page * pageSize + 1 + const endItem = Math.min((page + 1) * pageSize, totalCount) + + return ( +
+ + {totalCount === 0 + ? 'No results' + : `Showing ${startItem}–${endItem} of ${totalCount.toLocaleString()} Results`} + +
+
+ onPageChange(0)} + icon={FiChevronsLeft} + title="First" + enabled={canNavigate && page > 0} + /> + onPageChange(page - 1)} + icon={FiChevronLeft} + title="Previous" + enabled={canNavigate && page > 0} + /> +
{ + event.preventDefault() + handleSubmit() + }} + > + + handleChangeValue(event.target.value) + } + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.currentTarget.blur() + } + }} + className={styles.pageSelectInput} + /> + + + of{' '} + + {pageCount} + + +
+ onPageChange(page + 1)} + icon={FiChevronRight} + title="Next" + enabled={canNavigate && page < maxPage} + /> + onPageChange(maxPage)} + icon={FiChevronsRight} + title="Last" + enabled={canNavigate && page < maxPage} + /> +
+
+
+ ) +} + +interface PaginationArrowProps { + onClick: () => void + icon: IconType + title: string + enabled: boolean +} + +function PaginationArrow({ + onClick, + icon: Icon, + title, + enabled, +}: PaginationArrowProps) { + return ( + enabled && onClick()} + title={title} + > + + + ) +} diff --git a/src/components/common/panel/sidebar_list/index.ts b/src/components/common/panel/sidebar_list/index.ts new file mode 100644 index 00000000..6cbf0993 --- /dev/null +++ b/src/components/common/panel/sidebar_list/index.ts @@ -0,0 +1,6 @@ +export { + SidebarBody, + type SidebarBodyItemConfig, + type SidebarBodyProps, +} from './SidebarBody' +export * from './SidebarList'