@tpzdsp/next-toolkit 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +594 -0
- package/package.json +133 -0
- package/src/assets/fonts/gds-bold-w-v2.woff +0 -0
- package/src/assets/fonts/gds-bold-w2-v2.woff2 +0 -0
- package/src/assets/fonts/gds-light-w-v2.woff +0 -0
- package/src/assets/fonts/gds-light-w2-v2.woff2 +0 -0
- package/src/assets/images/defra-logo.svg +51 -0
- package/src/assets/images/ea-logo.svg +58 -0
- package/src/assets/images/ogl.svg +1 -0
- package/src/assets/styles/globals.css +68 -0
- package/src/assets/styles/index.ts +7 -0
- package/src/components/Button/Button.stories.tsx +41 -0
- package/src/components/Button/Button.test.tsx +55 -0
- package/src/components/Button/Button.tsx +44 -0
- package/src/components/Card/Card.stories.tsx +35 -0
- package/src/components/Card/Card.test.tsx +12 -0
- package/src/components/Card/Card.tsx +19 -0
- package/src/components/ErrorText/ErrorText.stories.tsx +34 -0
- package/src/components/ErrorText/ErrorText.test.tsx +35 -0
- package/src/components/ErrorText/ErrorText.tsx +17 -0
- package/src/components/Heading/Heading.stories.tsx +21 -0
- package/src/components/Heading/Heading.test.tsx +24 -0
- package/src/components/Heading/Heading.tsx +21 -0
- package/src/components/Hint/Hint.stories.tsx +40 -0
- package/src/components/Hint/Hint.test.tsx +35 -0
- package/src/components/Hint/Hint.tsx +13 -0
- package/src/components/Paragraph/Paragraph.stories.tsx +30 -0
- package/src/components/Paragraph/Paragraph.test.tsx +12 -0
- package/src/components/Paragraph/Paragraph.tsx +7 -0
- package/src/components/container/Container.tsx +38 -0
- package/src/components/dropdown/DropdownMenu.test.tsx +213 -0
- package/src/components/dropdown/DropdownMenu.tsx +106 -0
- package/src/components/dropdown/useDropdownMenu.ts +245 -0
- package/src/components/images/DefraLogo.tsx +64 -0
- package/src/components/images/EaLogo.tsx +81 -0
- package/src/components/images/OglLogo.tsx +18 -0
- package/src/components/index.ts +36 -0
- package/src/components/layout/footer/Copyright.tsx +14 -0
- package/src/components/layout/footer/Footer.tsx +26 -0
- package/src/components/layout/footer/Licence.tsx +19 -0
- package/src/components/layout/footer/MetaLinks.tsx +36 -0
- package/src/components/layout/header/Header.tsx +89 -0
- package/src/components/layout/header/HeaderAuthClient.tsx +32 -0
- package/src/components/layout/header/HeaderNavClient.tsx +64 -0
- package/src/components/link/ExternalLink.tsx +29 -0
- package/src/components/link/Link.tsx +26 -0
- package/src/components/link/NextLinkWrapper.tsx +66 -0
- package/src/components/theme/ThemeProvider.tsx +30 -0
- package/src/contexts/ThemeContext.tsx +72 -0
- package/src/contexts/index.ts +5 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useDebounce.ts +18 -0
- package/src/hooks/useLocalStorage.ts +57 -0
- package/src/index.ts +8 -0
- package/src/types.ts +99 -0
- package/src/utils/auth.ts +19 -0
- package/src/utils/constants.ts +3 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/renderers.tsx +68 -0
- package/src/utils/utils.ts +63 -0
- package/src/vite-env.d.ts +17 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
|
|
3
|
+
import { FaHouse } from 'react-icons/fa6';
|
|
4
|
+
|
|
5
|
+
import { HeaderAuthClient } from './HeaderAuthClient';
|
|
6
|
+
import { HeaderNavClient } from './HeaderNavClient';
|
|
7
|
+
import type { Credentials, NavLink } from '../../../types';
|
|
8
|
+
import { DefraLogo } from '../../images/DefraLogo';
|
|
9
|
+
import { EaLogo } from '../../images/EaLogo';
|
|
10
|
+
import { ExternalLink } from '../../link/ExternalLink';
|
|
11
|
+
import { Link } from '../../link/Link';
|
|
12
|
+
|
|
13
|
+
type HeaderProps = {
|
|
14
|
+
credentials: Credentials | null;
|
|
15
|
+
dspUrl: string;
|
|
16
|
+
appName: string;
|
|
17
|
+
navLinks: NavLink[];
|
|
18
|
+
// headerAuthClientComponent: ComponentType<{ credentials: Credentials | null; hostname: string }>;
|
|
19
|
+
headerNavClientComponent?: ComponentType<{ navLinks: NavLink[] }>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const Header = ({
|
|
23
|
+
credentials,
|
|
24
|
+
dspUrl,
|
|
25
|
+
appName,
|
|
26
|
+
navLinks,
|
|
27
|
+
// headerAuthClientComponent,
|
|
28
|
+
headerNavClientComponent = HeaderNavClient,
|
|
29
|
+
}: HeaderProps) => {
|
|
30
|
+
const HeaderNav = headerNavClientComponent;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<header className="bg-black w-full text-white border-b-2 border-brand">
|
|
34
|
+
<div
|
|
35
|
+
className="grid grid-rows-[min-content_min-content] grid-cols-2 md:grid-cols-3 gap-x-4
|
|
36
|
+
gap-y-2 md:gap-y-0 py-2 px-3 border-b border-gray-500"
|
|
37
|
+
>
|
|
38
|
+
<ExternalLink
|
|
39
|
+
href="https://www.gov.uk/government/organisations/department-for-environment-food-rural-affairs"
|
|
40
|
+
className="justify-center text-white visited:text-white active:text-black hover:text-white
|
|
41
|
+
text-base"
|
|
42
|
+
>
|
|
43
|
+
<span className="flex flex-col sm:flex-row sm:items-center gap-1 flex-1">
|
|
44
|
+
<DefraLogo className="w-[50px] h-[50px]" />
|
|
45
|
+
|
|
46
|
+
<span>Department for Environment Food & Rural Affairs</span>
|
|
47
|
+
</span>
|
|
48
|
+
</ExternalLink>
|
|
49
|
+
|
|
50
|
+
<ExternalLink
|
|
51
|
+
href="/"
|
|
52
|
+
className="flex gap-1 text-base items-center text-white md:justify-self-center
|
|
53
|
+
justify-self-end h-min self-center visited:text-white active:text-black
|
|
54
|
+
hover:text-white"
|
|
55
|
+
>
|
|
56
|
+
<FaHouse className="shrink-0" />
|
|
57
|
+
|
|
58
|
+
<span>Data Services Platform</span>
|
|
59
|
+
</ExternalLink>
|
|
60
|
+
|
|
61
|
+
{dspUrl ? <HeaderAuthClient hostname={dspUrl} credentials={credentials} /> : <></>}
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div
|
|
65
|
+
className="w-full bg-white grid grid-cols-[1fr_min-content]
|
|
66
|
+
2xs:grid-cols-[min-content_minmax(0,_1fr)_min-content] grid-rows-1 sm:grid-rows-none py-2
|
|
67
|
+
px-1 sm:px-3 items-center gap-y-4 gap-2"
|
|
68
|
+
>
|
|
69
|
+
<ExternalLink
|
|
70
|
+
href="https://www.gov.uk/government/organisations/environment-agency"
|
|
71
|
+
className="justify-self-start text-black hidden 2xs:block min-w-32"
|
|
72
|
+
>
|
|
73
|
+
<EaLogo className="text-nowrap overflow-hidden pl-2" />
|
|
74
|
+
</ExternalLink>
|
|
75
|
+
|
|
76
|
+
<Link
|
|
77
|
+
href="/"
|
|
78
|
+
className="text-center font-bold text-base sm:text-lg text-black visited:text-black
|
|
79
|
+
active:text-black hover:text-black"
|
|
80
|
+
>
|
|
81
|
+
{appName}
|
|
82
|
+
</Link>
|
|
83
|
+
|
|
84
|
+
{/* <HeaderNavClient navLinks={navLinks} /> */}
|
|
85
|
+
<HeaderNav navLinks={navLinks} />
|
|
86
|
+
</div>
|
|
87
|
+
</header>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Credentials } from '../../../types';
|
|
4
|
+
import { Link } from '../../link/Link';
|
|
5
|
+
|
|
6
|
+
type HeaderAuthClientProps = {
|
|
7
|
+
credentials: Credentials | null;
|
|
8
|
+
hostname: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const HeaderAuthClient = ({ hostname, credentials }: HeaderAuthClientProps) => {
|
|
12
|
+
console.log('HeaderAuthClient: ', { hostname, credentials });
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<span
|
|
16
|
+
className="flex gap-2 justify-self-start text-base col-span-2 row-start-2 col-start-1
|
|
17
|
+
md:row-start-1 md:col-start-3 h-min md:justify-self-end self-center items-center"
|
|
18
|
+
>
|
|
19
|
+
<span className="text-base border-r border-white pr-2 wrap break-anywhere sm:break-word">
|
|
20
|
+
<span>Welcome,</span> {credentials ? credentials.user.email : 'Guest'}
|
|
21
|
+
</span>
|
|
22
|
+
|
|
23
|
+
<Link
|
|
24
|
+
href={`${hostname}/${credentials ? 'api/logout' : 'login'}?redirect-uri=${encodeURIComponent(window?.location?.href)}`}
|
|
25
|
+
className="text-white visited:text-white active:text-black hover:text-white"
|
|
26
|
+
prefetch={false}
|
|
27
|
+
>
|
|
28
|
+
<span className="text-base">{credentials ? 'Logout' : 'Login'}</span>
|
|
29
|
+
</Link>
|
|
30
|
+
</span>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { NavLink } from '../../../types';
|
|
4
|
+
import { DropdownMenu, type DropdownMenuItem } from '../../dropdown/DropdownMenu';
|
|
5
|
+
import { ExternalLink } from '../../link/ExternalLink';
|
|
6
|
+
import { Link } from '../../link/Link';
|
|
7
|
+
|
|
8
|
+
type HeaderNavClientProps = {
|
|
9
|
+
navLinks: NavLink[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const ExternalNavItem = ({ label, url, icon, ...props }: DropdownMenuItem<NavLink>) => (
|
|
13
|
+
<ExternalLink href={url} {...props}>
|
|
14
|
+
{icon ?? null}
|
|
15
|
+
<span>{label}</span>
|
|
16
|
+
</ExternalLink>
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const InternalNavItem = ({ label, url, icon, ...props }: DropdownMenuItem<NavLink>) => (
|
|
20
|
+
<Link {...props} href={url}>
|
|
21
|
+
{icon ?? null}
|
|
22
|
+
<span>{label}</span>
|
|
23
|
+
</Link>
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const NavItem = (props: DropdownMenuItem<NavLink>) => {
|
|
27
|
+
if (props.isExternal) {
|
|
28
|
+
return <ExternalNavItem {...props} />;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return <InternalNavItem {...props} />;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const HeaderNavClient = ({ navLinks }: HeaderNavClientProps) => {
|
|
35
|
+
return (
|
|
36
|
+
<>
|
|
37
|
+
<nav className="block sm:hidden" aria-label="Small Screen Navigation">
|
|
38
|
+
<DropdownMenu
|
|
39
|
+
items={navLinks}
|
|
40
|
+
itemRenderer={NavItem}
|
|
41
|
+
itemClassName="text-black cursor-pointer px-2 py-1 gap-1 text-base flex items-center shrink-0"
|
|
42
|
+
/>
|
|
43
|
+
</nav>
|
|
44
|
+
|
|
45
|
+
<nav
|
|
46
|
+
className="hidden sm:block col-span-full sm:col-span-1 sm:justify-self-end sm:gap-4 min-w-0
|
|
47
|
+
px-2 gap-2"
|
|
48
|
+
aria-label="Large Screen Navigation"
|
|
49
|
+
>
|
|
50
|
+
<ul className="flex flex-row gap-2 order-1 shrink-0">
|
|
51
|
+
{navLinks.map((link) => (
|
|
52
|
+
<li key={link.label} className="w-full py-1">
|
|
53
|
+
<NavItem
|
|
54
|
+
{...link}
|
|
55
|
+
className="text-black visited:text-black active:text-black hover:text-black gap-1
|
|
56
|
+
text-base flex items-center justify-center shrink-0 w-max"
|
|
57
|
+
/>
|
|
58
|
+
</li>
|
|
59
|
+
))}
|
|
60
|
+
</ul>
|
|
61
|
+
</nav>
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { twMerge } from 'tailwind-merge';
|
|
4
|
+
|
|
5
|
+
import type { ExtendProps } from '../../types';
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ExternalLinkProps = ExtendProps<'a', Props>;
|
|
12
|
+
|
|
13
|
+
export const ExternalLink = ({ href, className, children, ...props }: ExternalLinkProps) => (
|
|
14
|
+
<a
|
|
15
|
+
{...props}
|
|
16
|
+
className={twMerge(
|
|
17
|
+
`cursor-pointer text-link hover:decoration-[max(3px,_.1875rem,_.12em)] hover:text-link-hover
|
|
18
|
+
visited:text-link-visited focus:decoration-[max(3px,_.1875rem,_.12em)]
|
|
19
|
+
decoration-[max(1px,_.0625rem)] underline-offset-[0.1578em] underline outline-none
|
|
20
|
+
focus:text-focus-text focus:bg-focus inline-block`,
|
|
21
|
+
className,
|
|
22
|
+
)}
|
|
23
|
+
href={href}
|
|
24
|
+
rel="noopener noreferrer"
|
|
25
|
+
target="_blank"
|
|
26
|
+
>
|
|
27
|
+
{children}
|
|
28
|
+
</a>
|
|
29
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import NextLink from 'next/link';
|
|
2
|
+
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
|
|
4
|
+
import type { ExtendProps } from '../../types';
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
href: string | object;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type LinkProps = ExtendProps<typeof NextLink, Props>;
|
|
11
|
+
|
|
12
|
+
export const Link = ({ href, className, children, ...props }: LinkProps) => (
|
|
13
|
+
<NextLink
|
|
14
|
+
{...props}
|
|
15
|
+
className={twMerge(
|
|
16
|
+
`cursor-pointer text-link hover:decoration-[max(3px,_.1875rem,_.12em)] hover:text-link-hover
|
|
17
|
+
visited:text-link-visited active:text-black focus:decoration-[max(3px,_.1875rem,_.12em)]
|
|
18
|
+
decoration-[max(1px,_.0625rem)] underline-offset-[0.1578em] underline outline-none
|
|
19
|
+
focus:text-focus-text focus:bg-focus inline-block`,
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
href={href}
|
|
23
|
+
>
|
|
24
|
+
<>{children}</>
|
|
25
|
+
</NextLink>
|
|
26
|
+
);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import Link from 'next/link';
|
|
6
|
+
|
|
7
|
+
import { cn } from '../../utils';
|
|
8
|
+
|
|
9
|
+
export type NextLinkWrapperProps = {
|
|
10
|
+
href: string;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
target?: '_blank' | '_self' | '_parent' | '_top';
|
|
14
|
+
rel?: string;
|
|
15
|
+
prefetch?: boolean;
|
|
16
|
+
replace?: boolean;
|
|
17
|
+
scroll?: boolean;
|
|
18
|
+
shallow?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const NextLinkWrapper = ({
|
|
22
|
+
href,
|
|
23
|
+
children,
|
|
24
|
+
className,
|
|
25
|
+
target,
|
|
26
|
+
rel,
|
|
27
|
+
prefetch,
|
|
28
|
+
replace,
|
|
29
|
+
scroll,
|
|
30
|
+
shallow,
|
|
31
|
+
...props
|
|
32
|
+
}: NextLinkWrapperProps) => {
|
|
33
|
+
// Handle external links
|
|
34
|
+
const isExternal =
|
|
35
|
+
href.startsWith('http') || href.startsWith('mailto:') || href.startsWith('tel:');
|
|
36
|
+
|
|
37
|
+
if (isExternal) {
|
|
38
|
+
return (
|
|
39
|
+
<a
|
|
40
|
+
href={href}
|
|
41
|
+
className={cn('text-blue-600 hover:text-blue-800 transition-colors', className)}
|
|
42
|
+
target={target ?? '_blank'}
|
|
43
|
+
rel={rel ?? 'noopener noreferrer'}
|
|
44
|
+
{...props}
|
|
45
|
+
>
|
|
46
|
+
{children}
|
|
47
|
+
</a>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<Link
|
|
53
|
+
href={href}
|
|
54
|
+
className={cn('text-blue-600 hover:text-blue-800 transition-colors', className)}
|
|
55
|
+
prefetch={prefetch}
|
|
56
|
+
replace={replace}
|
|
57
|
+
scroll={scroll}
|
|
58
|
+
shallow={shallow}
|
|
59
|
+
target={target}
|
|
60
|
+
rel={rel}
|
|
61
|
+
{...props}
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
</Link>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { ThemeContext } from '../../contexts';
|
|
6
|
+
import { useLocalStorage } from '../../hooks';
|
|
7
|
+
|
|
8
|
+
export type ThemeProviderProps = {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
defaultTheme?: 'light' | 'dark';
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const ThemeProvider = ({ children, defaultTheme = 'light' }: ThemeProviderProps) => {
|
|
14
|
+
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', defaultTheme);
|
|
15
|
+
|
|
16
|
+
const toggleTheme = () => {
|
|
17
|
+
setTheme(theme === 'light' ? 'dark' : 'light');
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const value = {
|
|
21
|
+
theme,
|
|
22
|
+
toggleTheme,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<ThemeContext.Provider value={value}>
|
|
27
|
+
<div className={theme === 'dark' ? 'dark' : ''}>{children}</div>
|
|
28
|
+
</ThemeContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use client'; // This is necessary for Next.js to treat this file as a client-side component
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useEffect } from 'react';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { ThemeContextValue } from '../types';
|
|
7
|
+
|
|
8
|
+
export const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
|
9
|
+
|
|
10
|
+
type ThemeProviderProps = {
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
defaultTheme?: 'light' | 'dark';
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const ThemeProvider = ({ children, defaultTheme = 'light' }: ThemeProviderProps) => {
|
|
16
|
+
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
|
17
|
+
if (typeof window === 'undefined') {
|
|
18
|
+
return defaultTheme;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Check localStorage first
|
|
22
|
+
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark';
|
|
23
|
+
|
|
24
|
+
if (savedTheme) {
|
|
25
|
+
return savedTheme;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check system preference
|
|
29
|
+
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
30
|
+
return 'dark';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return defaultTheme;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const toggleTheme = () => {
|
|
37
|
+
const newTheme = theme === 'light' ? 'dark' : 'light';
|
|
38
|
+
|
|
39
|
+
setTheme(newTheme);
|
|
40
|
+
|
|
41
|
+
if (typeof window !== 'undefined') {
|
|
42
|
+
localStorage.setItem('theme', newTheme);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
// Add theme class to document element
|
|
48
|
+
if (typeof window !== 'undefined') {
|
|
49
|
+
const root = window.document.documentElement;
|
|
50
|
+
|
|
51
|
+
root.classList.remove('light', 'dark');
|
|
52
|
+
root.classList.add(theme);
|
|
53
|
+
}
|
|
54
|
+
}, [theme]);
|
|
55
|
+
|
|
56
|
+
const value: ThemeContextValue = {
|
|
57
|
+
theme,
|
|
58
|
+
toggleTheme,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const useTheme = (): ThemeContextValue => {
|
|
65
|
+
const context = useContext(ThemeContext);
|
|
66
|
+
|
|
67
|
+
if (context === undefined) {
|
|
68
|
+
throw new Error('useTheme must be used within a ThemeProvider');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return context;
|
|
72
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
export const useDebounce = <T>(value: T, delay: number): T => {
|
|
4
|
+
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
|
5
|
+
|
|
6
|
+
useEffect(() => {
|
|
7
|
+
const handler = setTimeout(() => {
|
|
8
|
+
setDebouncedValue(value);
|
|
9
|
+
}, delay);
|
|
10
|
+
|
|
11
|
+
// Cleanup function to clear the timeout if value changes
|
|
12
|
+
return () => {
|
|
13
|
+
clearTimeout(handler);
|
|
14
|
+
};
|
|
15
|
+
}, [value, delay]); // Re-run effect when value or delay changes
|
|
16
|
+
|
|
17
|
+
return debouncedValue;
|
|
18
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
export type UseLocalStorageReturn<T> = [T, (value: T | ((val: T) => T)) => void, () => void];
|
|
4
|
+
|
|
5
|
+
export const useLocalStorage = <T>(key: string, initialValue: T): UseLocalStorageReturn<T> => {
|
|
6
|
+
// State to store our value
|
|
7
|
+
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
8
|
+
if (typeof window === 'undefined') {
|
|
9
|
+
return initialValue;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const item = window.localStorage.getItem(key);
|
|
14
|
+
|
|
15
|
+
return item ? JSON.parse(item) : initialValue;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error(`Error reading localStorage key "${key}":`, error);
|
|
18
|
+
|
|
19
|
+
return initialValue;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Return a wrapped version of useState's setter function that persists to localStorage
|
|
24
|
+
const setValue = useCallback(
|
|
25
|
+
(value: T | ((val: T) => T)) => {
|
|
26
|
+
try {
|
|
27
|
+
// Allow value to be a function so we have the same API as useState
|
|
28
|
+
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
|
29
|
+
|
|
30
|
+
setStoredValue(valueToStore);
|
|
31
|
+
|
|
32
|
+
// Save to localStorage
|
|
33
|
+
if (typeof window !== 'undefined') {
|
|
34
|
+
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error(`Error setting localStorage key "${key}":`, error);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
[key, storedValue],
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Function to remove the item from localStorage
|
|
44
|
+
const removeValue = useCallback(() => {
|
|
45
|
+
try {
|
|
46
|
+
setStoredValue(initialValue);
|
|
47
|
+
|
|
48
|
+
if (typeof window !== 'undefined') {
|
|
49
|
+
window.localStorage.removeItem(key);
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(`Error removing localStorage key "${key}":`, error);
|
|
53
|
+
}
|
|
54
|
+
}, [key, initialValue]);
|
|
55
|
+
|
|
56
|
+
return [storedValue, setValue, removeValue];
|
|
57
|
+
};
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Common types used throughout the library and consuming applications
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
export type BaseProps = {
|
|
5
|
+
className?: string;
|
|
6
|
+
children?: ReactNode;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type NextLinkProps = {
|
|
10
|
+
href: string;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
target?: '_blank' | '_self' | '_parent' | '_top';
|
|
14
|
+
rel?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ThemeContextValue = {
|
|
18
|
+
theme: 'light' | 'dark';
|
|
19
|
+
toggleTheme: () => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Component variant types
|
|
23
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
|
24
|
+
export type ButtonSize = 'sm' | 'md' | 'lg';
|
|
25
|
+
export type CardVariant = 'default' | 'elevated' | 'outlined';
|
|
26
|
+
export type ContainerSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
27
|
+
|
|
28
|
+
// Common utility types that apps might need
|
|
29
|
+
export type Variant = 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info';
|
|
30
|
+
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
|
31
|
+
export type Status = 'idle' | 'loading' | 'success' | 'error';
|
|
32
|
+
export type Theme = 'light' | 'dark' | 'system';
|
|
33
|
+
|
|
34
|
+
// Form related types
|
|
35
|
+
export type FormFieldProps = {
|
|
36
|
+
name: string;
|
|
37
|
+
label?: string;
|
|
38
|
+
error?: string;
|
|
39
|
+
required?: boolean;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
className?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// API related types
|
|
45
|
+
export type ApiResponse<T = unknown> = {
|
|
46
|
+
data: T;
|
|
47
|
+
success: boolean;
|
|
48
|
+
message?: string;
|
|
49
|
+
errors?: Record<string, string[]>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type PaginationMeta = {
|
|
53
|
+
page: number;
|
|
54
|
+
perPage: number;
|
|
55
|
+
total: number;
|
|
56
|
+
totalPages: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type PaginatedResponse<T = unknown> = {
|
|
60
|
+
meta: PaginationMeta;
|
|
61
|
+
} & ApiResponse<T[]>;
|
|
62
|
+
|
|
63
|
+
// Event handler types
|
|
64
|
+
export type ClickHandler = (event: React.MouseEvent<HTMLElement>) => void;
|
|
65
|
+
export type ChangeHandler<T = HTMLInputElement> = (event: React.ChangeEvent<T>) => void;
|
|
66
|
+
export type SubmitHandler = (event: React.FormEvent<HTMLFormElement>) => void;
|
|
67
|
+
|
|
68
|
+
// Utility types for better developer experience
|
|
69
|
+
export type PropsWithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
|
70
|
+
export type PropsWithOptional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
|
71
|
+
|
|
72
|
+
export type ExtendProps<
|
|
73
|
+
// `ComponentProps` internally constrains `Comp` to be `JSXElementConstructor<any>`,
|
|
74
|
+
// and since our type must have the same constraints to avoid errors, `any` is required here
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
|
+
Comp extends keyof React.JSX.IntrinsicElements | React.JSXElementConstructor<any>,
|
|
77
|
+
Props = object,
|
|
78
|
+
> = Props & Omit<ComponentProps<Comp>, keyof Props>;
|
|
79
|
+
|
|
80
|
+
// Define an interface for the decoded JWT payload
|
|
81
|
+
export type DecodedJWT = {
|
|
82
|
+
name: string;
|
|
83
|
+
email: string;
|
|
84
|
+
groupInfoIds: string[];
|
|
85
|
+
exp?: number; // Optional: JWT expiration timestamp
|
|
86
|
+
[key: string]: unknown; // Additional claims
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type Credentials = {
|
|
90
|
+
token: string;
|
|
91
|
+
user: DecodedJWT;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export type NavLink = {
|
|
95
|
+
label: string;
|
|
96
|
+
url: string;
|
|
97
|
+
isExternal: boolean;
|
|
98
|
+
icon?: ReactNode;
|
|
99
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { cookies } from 'next/headers';
|
|
4
|
+
import type { NextRequest } from 'next/server';
|
|
5
|
+
|
|
6
|
+
import { COOKIE_NAME } from './constants';
|
|
7
|
+
import { decodeAuthToken } from './utils';
|
|
8
|
+
import type { Credentials } from '../types';
|
|
9
|
+
|
|
10
|
+
export const getCredentials = async (source?: NextRequest | null): Promise<Credentials | null> => {
|
|
11
|
+
const cookieStore = source ? source?.cookies : await cookies();
|
|
12
|
+
const token = cookieStore.get(COOKIE_NAME)?.value;
|
|
13
|
+
|
|
14
|
+
if (!token) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return decodeAuthToken(token);
|
|
19
|
+
};
|