@dbosoft/nextjs-uicore 1.0.0
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/.eslintrc.js +4 -0
- package/CHANGELOG.md +12 -0
- package/package.json +40 -0
- package/src/global.d.ts +1 -0
- package/src/head/global.d.ts +1 -0
- package/src/head/index.tsx +119 -0
- package/src/subnav/helpers/useStuckRef.ts +50 -0
- package/src/subnav/index.tsx +133 -0
- package/src/subnav/partials/CtaLinks/github-stars-link/formatStarCount/index.test.js +25 -0
- package/src/subnav/partials/CtaLinks/github-stars-link/formatStarCount/index.ts +17 -0
- package/src/subnav/partials/CtaLinks/github-stars-link/index.tsx +84 -0
- package/src/subnav/partials/CtaLinks/github-stars-link/parseGithubUrl/index.test.js +25 -0
- package/src/subnav/partials/CtaLinks/github-stars-link/parseGithubUrl/index.ts +25 -0
- package/src/subnav/partials/CtaLinks/icons/github.svg +4 -0
- package/src/subnav/partials/CtaLinks/index.tsx +46 -0
- package/src/subnav/partials/MenuItemsDefault/index.tsx +63 -0
- package/src/subnav/partials/MenuItemsDefault/style.module.scss +71 -0
- package/src/subnav/partials/MenuItemsOverflow/index.tsx +50 -0
- package/src/subnav/partials/MenuItemsOverflow/style.module.scss +51 -0
- package/src/subnav/partials/TitleLink/index.tsx +25 -0
- package/src/subnav/partials/nav-item-text/index.tsx +29 -0
- package/src/subnav/partials/nav-item-text/style.module.scss +19 -0
- package/src/subnav/style.module.scss +13 -0
- package/src/tabs/hooks/use-scroll-left.ts +27 -0
- package/src/tabs/hooks/use-window-size.js +33 -0
- package/src/tabs/icons/chevron-right.svg +1 -0
- package/src/tabs/icons/tooltip.svg +1 -0
- package/src/tabs/index.tsx +102 -0
- package/src/tabs/partials/tab-trigger/index.tsx +66 -0
- package/src/tabs/partials/tab-trigger/style.module.scss +69 -0
- package/src/tabs/partials/tab-triggers/index.tsx +241 -0
- package/src/tabs/partials/tab-triggers/style.module.scss +193 -0
- package/src/tabs/partials/tooltip/index.tsx +112 -0
- package/src/tabs/partials/tooltip/style.module.scss +38 -0
- package/src/tabs/provider.js +18 -0
- package/src/tabs/style.module.css +4 -0
- package/src/tabs/utils/smooth-scroll.js +88 -0
- package/tsconfig.json +8 -0
package/.eslintrc.js
ADDED
package/CHANGELOG.md
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dbosoft/nextjs-uicore",
|
|
3
|
+
"description": "Core UI components for Next.js",
|
|
4
|
+
"author": "dbosoft",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src",
|
|
10
|
+
"./head": "./src/head/index.tsx",
|
|
11
|
+
"./tabs": "./src/tabs/index.tsx",
|
|
12
|
+
"./subnav": "./src/subnav/index.tsx"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/react": "^18.2.46",
|
|
16
|
+
"@types/react-dom": "^18.2.18",
|
|
17
|
+
"eslint": "^8.56.0",
|
|
18
|
+
"next": "^14.0.4",
|
|
19
|
+
"react": "^18.2.0",
|
|
20
|
+
"typescript": "^5.3.3",
|
|
21
|
+
"@dbosoft/eslint-config": "1.0.0",
|
|
22
|
+
"@dbosoft/web-types": "1.0.0",
|
|
23
|
+
"@dbosoft/typescript-config": "1.0.0"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@headlessui/react": "^1.4.3",
|
|
30
|
+
"@heroicons/react": "^1.0.5",
|
|
31
|
+
"classnames": "^2.3.1",
|
|
32
|
+
"isomorphic-unfetch": "^4.0.2",
|
|
33
|
+
"unfetch": "^4.2.0",
|
|
34
|
+
"@dbosoft/react-uicore": "1.0.0"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"lint": "eslint . --max-warnings 0",
|
|
38
|
+
"clean": "rimraf .turbo && rimraf node_modules && rimraf dist"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="@dbosoft/web-types" />
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="@dbosoft/web-types" />
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import Head from 'next/head'
|
|
2
|
+
|
|
3
|
+
export default function DBOHead(props: DBOHeadProps): React.ReactElement {
|
|
4
|
+
return (
|
|
5
|
+
<Head>
|
|
6
|
+
{whenString(props.title, <title>{props.title}</title>)}
|
|
7
|
+
<meta httpEquiv="x-ua-compatible" content="ie=edge" />
|
|
8
|
+
<meta property="og:locale" content="en_US" key="og:locale" />
|
|
9
|
+
<meta property="og:type" content="website" key="og:type" />
|
|
10
|
+
<meta
|
|
11
|
+
property="article:publisher"
|
|
12
|
+
content="https://www.facebook.com/dbosoft/"
|
|
13
|
+
key="article:publisher"
|
|
14
|
+
/>
|
|
15
|
+
<meta name="twitter:site" content="@dbosoft" key="twitter:site" />
|
|
16
|
+
<meta
|
|
17
|
+
name="twitter:card"
|
|
18
|
+
content={props.twitterCard || 'summary_large_image'}
|
|
19
|
+
key="twitter:card"
|
|
20
|
+
/>
|
|
21
|
+
<meta name="theme-color" content="#000" key="themeColor" />
|
|
22
|
+
{whenString(
|
|
23
|
+
props.description,
|
|
24
|
+
<meta
|
|
25
|
+
name="description"
|
|
26
|
+
property="og:description"
|
|
27
|
+
content={props.description}
|
|
28
|
+
key="description"
|
|
29
|
+
/>
|
|
30
|
+
)}
|
|
31
|
+
{whenString(
|
|
32
|
+
props.siteName,
|
|
33
|
+
<meta
|
|
34
|
+
property="og:site_name"
|
|
35
|
+
content={props.siteName}
|
|
36
|
+
key="og:site_name"
|
|
37
|
+
/>
|
|
38
|
+
)}
|
|
39
|
+
{whenString(
|
|
40
|
+
props.pageName,
|
|
41
|
+
<meta property="og:title" content={props.pageName} key="og:title" />
|
|
42
|
+
)}
|
|
43
|
+
{whenString(
|
|
44
|
+
props.image,
|
|
45
|
+
<meta property="og:image" content={props.image} key="og:image" />
|
|
46
|
+
)}
|
|
47
|
+
{whenString(
|
|
48
|
+
props.canonicalUrl,
|
|
49
|
+
<link rel="canonical" key="canonical" href={props.canonicalUrl} />
|
|
50
|
+
)}
|
|
51
|
+
{whenArray(props.preload, ({ href, ...linkProps }: {href:string}) => (
|
|
52
|
+
<link href={href} {...linkProps} rel="preload" key={href} />
|
|
53
|
+
))}
|
|
54
|
+
{whenArray(props.icon, ({ href, ...linkProps }: {href:string}) => (
|
|
55
|
+
<link href={href} {...linkProps} rel="icon" key={href} />
|
|
56
|
+
))}
|
|
57
|
+
{whenArray(props.stylesheet, ({ href, ...linkProps }: {href:string}) => (
|
|
58
|
+
<link href={href} {...linkProps} rel="stylesheet" key={href} />
|
|
59
|
+
))}
|
|
60
|
+
{props.children}
|
|
61
|
+
</Head>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Return a map of the value using the callback if it is an Array. */
|
|
66
|
+
const whenArray = (value : any, mapFn: $TSFixMe) =>
|
|
67
|
+
Array.isArray(value) ? value.map(mapFn) : null
|
|
68
|
+
|
|
69
|
+
/** Return the value if it is a String */
|
|
70
|
+
const whenString = (value : any, returnValue: any) =>
|
|
71
|
+
typeof value === 'string' ? returnValue : null
|
|
72
|
+
|
|
73
|
+
// -----
|
|
74
|
+
// Types
|
|
75
|
+
// -----
|
|
76
|
+
|
|
77
|
+
interface DBOHeadProps {
|
|
78
|
+
canonicalUrl?: string
|
|
79
|
+
children?: React.ReactNode
|
|
80
|
+
description?: string
|
|
81
|
+
icon?: {
|
|
82
|
+
[key: string]: unknown
|
|
83
|
+
href: string
|
|
84
|
+
sizes?: string
|
|
85
|
+
type?: string
|
|
86
|
+
}[]
|
|
87
|
+
image?: string
|
|
88
|
+
pageName?: string
|
|
89
|
+
preload?: {
|
|
90
|
+
[key: string]: unknown
|
|
91
|
+
as: asProp
|
|
92
|
+
href: string
|
|
93
|
+
type?: string
|
|
94
|
+
}[]
|
|
95
|
+
siteName?: string
|
|
96
|
+
stylesheet?: {
|
|
97
|
+
[key: string]: unknown
|
|
98
|
+
href: string
|
|
99
|
+
media?: string
|
|
100
|
+
}[]
|
|
101
|
+
title?: string
|
|
102
|
+
twitterCard?: TwitterCardProp
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type TwitterCardProp = 'summary' | 'summary_large_image'
|
|
106
|
+
|
|
107
|
+
type asProp =
|
|
108
|
+
| 'audio'
|
|
109
|
+
| 'document'
|
|
110
|
+
| 'embed'
|
|
111
|
+
| 'fetch'
|
|
112
|
+
| 'font'
|
|
113
|
+
| 'image'
|
|
114
|
+
| 'object'
|
|
115
|
+
| 'script'
|
|
116
|
+
| 'style'
|
|
117
|
+
| 'track'
|
|
118
|
+
| 'video'
|
|
119
|
+
| 'worker'
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { DependencyList} from 'react';
|
|
4
|
+
import { useCallback, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
const IntersectionObserver =
|
|
7
|
+
(typeof window !== 'undefined' && window.IntersectionObserver) || null
|
|
8
|
+
const intersectionOpts = { threshold: [1] }
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
|
|
12
|
+
Stuck-ness is determined by whether a sticky target element has intersected the
|
|
13
|
+
top boundary of its viewport.
|
|
14
|
+
|
|
15
|
+
Stuck-ness is checked on intersection observation.
|
|
16
|
+
|
|
17
|
+
IntersectionObserver Compatibility (https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver#Browser_compatibility):
|
|
18
|
+
|
|
19
|
+
- Chrome 51+
|
|
20
|
+
- Edge 15+
|
|
21
|
+
- Firefox 55+
|
|
22
|
+
- Safari 12.1+
|
|
23
|
+
|
|
24
|
+
Internet Explorer 11 gracefully skips over this functionality, as it does not
|
|
25
|
+
support `IntersectionObserver` or CSS `position: sticky`.
|
|
26
|
+
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export default function useStuckRef(deps: DependencyList) {
|
|
30
|
+
const [isStuck, setStuck] = useState(false)
|
|
31
|
+
|
|
32
|
+
const stuckRef = useCallback((target: Element | null) => {
|
|
33
|
+
if (target && IntersectionObserver) {
|
|
34
|
+
const intersectionObserver = new IntersectionObserver(([entry]) => {
|
|
35
|
+
const nowIsStuck = entry.intersectionRatio < 1
|
|
36
|
+
setStuck(nowIsStuck);
|
|
37
|
+
|
|
38
|
+
}, intersectionOpts)
|
|
39
|
+
|
|
40
|
+
intersectionObserver.observe(target)
|
|
41
|
+
|
|
42
|
+
return intersectionObserver.disconnect.bind(intersectionObserver)
|
|
43
|
+
}
|
|
44
|
+
return () => { };
|
|
45
|
+
}, deps);
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
return { isStuck, stuckRef }
|
|
50
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Disclosure } from '@headlessui/react'
|
|
2
|
+
import { MenuIcon, XIcon } from '@heroicons/react/outline'
|
|
3
|
+
import classNames from 'classnames'
|
|
4
|
+
import type { LinkType, Variant } from '@dbosoft/react-uicore/linkbutton'
|
|
5
|
+
import MenuItemsOverflow from './partials/MenuItemsOverflow'
|
|
6
|
+
import MenuItemsDefault from './partials/MenuItemsDefault'
|
|
7
|
+
import CtaLinks from './partials/CtaLinks'
|
|
8
|
+
import useStuckRef from './helpers/useStuckRef'
|
|
9
|
+
import TitleLink from './partials/TitleLink'
|
|
10
|
+
import style from './style.module.scss'
|
|
11
|
+
|
|
12
|
+
export type MenuItem = 'divider' | ILinkMenuItem;
|
|
13
|
+
|
|
14
|
+
export interface IDropDownMenuItem {
|
|
15
|
+
text: string,
|
|
16
|
+
submenu: ILinkMenuItem[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ILinkMenuItem {
|
|
20
|
+
text: string,
|
|
21
|
+
url: string,
|
|
22
|
+
type: 'inbound' | 'outbound' | 'anchor'
|
|
23
|
+
active?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
export interface ICtaItem {
|
|
28
|
+
text: string,
|
|
29
|
+
url: string,
|
|
30
|
+
variant?: Variant
|
|
31
|
+
linktype?: LinkType
|
|
32
|
+
className?: string,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ISubNavProps {
|
|
36
|
+
titleLink?: ILinkMenuItem,
|
|
37
|
+
titleContent?: JSX.Element,
|
|
38
|
+
ctaLinks: ICtaItem[],
|
|
39
|
+
hideGithubStars: boolean,
|
|
40
|
+
menuItems: MenuItem[],
|
|
41
|
+
menuItemsAlign: 'left' | 'right',
|
|
42
|
+
className?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function Subnav({
|
|
46
|
+
className,
|
|
47
|
+
titleLink,
|
|
48
|
+
ctaLinks = [],
|
|
49
|
+
hideGithubStars,
|
|
50
|
+
menuItems,
|
|
51
|
+
menuItemsAlign = `right`,
|
|
52
|
+
titleContent
|
|
53
|
+
}: ISubNavProps) {
|
|
54
|
+
|
|
55
|
+
const { isStuck, stuckRef } = useStuckRef([])
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div className={classNames(style.root, `bg-white`, className, {
|
|
59
|
+
[style.isSticky]: isStuck,
|
|
60
|
+
})} ref={stuckRef}>
|
|
61
|
+
<Disclosure as="nav" >
|
|
62
|
+
{({ open }) => (
|
|
63
|
+
<>
|
|
64
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
65
|
+
<div className="flex justify-between h-16">
|
|
66
|
+
<div className={classNames(`flex grow`, menuItemsAlign == `right` ? ` justify-between` : ``)}>
|
|
67
|
+
|
|
68
|
+
<div className="flex-shrink-0 flex items-center ">
|
|
69
|
+
{titleLink &&<TitleLink
|
|
70
|
+
text={titleLink.text}
|
|
71
|
+
|
|
72
|
+
url={titleLink.url} >{titleContent}</TitleLink>}
|
|
73
|
+
{titleContent}
|
|
74
|
+
</div>
|
|
75
|
+
<div className="hidden sm:-my-px sm:ml-6 sm:flex">
|
|
76
|
+
<MenuItemsDefault
|
|
77
|
+
menuItems={menuItems}
|
|
78
|
+
/>
|
|
79
|
+
<CtaLinks
|
|
80
|
+
hideGithubStars={hideGithubStars}
|
|
81
|
+
isInDropdown={false}
|
|
82
|
+
links={ctaLinks}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="-mr-2 flex items-center sm:hidden ">
|
|
87
|
+
{/* Mobile menu button */}
|
|
88
|
+
<Disclosure.Button className="bg-white inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary">
|
|
89
|
+
<span className="sr-only">Open main menu</span>
|
|
90
|
+
{open ? (
|
|
91
|
+
<XIcon aria-hidden="true" className="block h-6 w-6" />
|
|
92
|
+
) : (
|
|
93
|
+
<MenuIcon aria-hidden="true" className="block h-6 w-6" />
|
|
94
|
+
)}
|
|
95
|
+
</Disclosure.Button>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<Disclosure.Panel className="sm:hidden">
|
|
101
|
+
<div className="pt-2 pb-3 space-y-1">
|
|
102
|
+
<MenuItemsOverflow
|
|
103
|
+
ctaLinks={ctaLinks}
|
|
104
|
+
hideGithubStars={hideGithubStars}
|
|
105
|
+
menuItems={menuItems}
|
|
106
|
+
/>
|
|
107
|
+
{/* {navigation.map((item) => (
|
|
108
|
+
<Disclosure.Button
|
|
109
|
+
key={item.name}
|
|
110
|
+
as="a"
|
|
111
|
+
href={item.href}
|
|
112
|
+
className={classNames(
|
|
113
|
+
item.current
|
|
114
|
+
? 'bg-indigo-50 border-indigo-500 text-indigo-700'
|
|
115
|
+
: 'border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800',
|
|
116
|
+
'block pl-3 pr-4 py-2 border-l-4 text-base font-medium'
|
|
117
|
+
)}
|
|
118
|
+
aria-current={item.current ? 'page' : undefined}
|
|
119
|
+
>
|
|
120
|
+
{item.name}
|
|
121
|
+
</Disclosure.Button>
|
|
122
|
+
))}
|
|
123
|
+
*/} </div>
|
|
124
|
+
|
|
125
|
+
</Disclosure.Panel>
|
|
126
|
+
</>
|
|
127
|
+
)}
|
|
128
|
+
</Disclosure>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export default Subnav
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import formatStarCount from './index.js'
|
|
2
|
+
|
|
3
|
+
describe('<Subnav /> - formatStarCount', () => {
|
|
4
|
+
it('should return zero for an undefined, null, or zero count', () => {
|
|
5
|
+
expect(formatStarCount(undefined)).toBe(false)
|
|
6
|
+
expect(formatStarCount(null)).toBe(false)
|
|
7
|
+
expect(formatStarCount(0)).toBe(false)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('should return numbers 1 to 999 without formatting', () => {
|
|
11
|
+
expect(formatStarCount(1)).toBe('1')
|
|
12
|
+
expect(formatStarCount(999)).toBe('999')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('should abbreviate numbers in the tens of thousands with "k" and a single decimal point', () => {
|
|
16
|
+
expect(formatStarCount(1003)).toBe('1.0k')
|
|
17
|
+
expect(formatStarCount(1280)).toBe('1.2k')
|
|
18
|
+
expect(formatStarCount(15630)).toBe('15.6k')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should abbreviate numbers in the hundreds of thousands with "k" and no decimal point', () => {
|
|
22
|
+
expect(formatStarCount(100000)).toBe('100k')
|
|
23
|
+
expect(formatStarCount(156783)).toBe('156k')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Given:
|
|
3
|
+
* starCount (int)
|
|
4
|
+
* Return:
|
|
5
|
+
* Formatted string, to match GitHub's typical display of star counts,
|
|
6
|
+
* that is, expressed as thousands of stars
|
|
7
|
+
* Or returns false for falsy starCount values
|
|
8
|
+
*/
|
|
9
|
+
function formatStarCount(starCount: number) {
|
|
10
|
+
if (!starCount || starCount <= 0) return false
|
|
11
|
+
if (starCount < 1000) return `${starCount}`
|
|
12
|
+
const thousands = Math.floor(starCount / 100.0) / 10.0
|
|
13
|
+
if (starCount < 100000) return `${thousands.toFixed(1)}k`
|
|
14
|
+
return `${Math.floor(thousands)}k`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default formatStarCount
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from 'react'
|
|
4
|
+
import fetch from 'isomorphic-unfetch'
|
|
5
|
+
import type { IconObject } from '@dbosoft/react-uicore/linkbutton';
|
|
6
|
+
import LinkButton from '@dbosoft/react-uicore/linkbutton';
|
|
7
|
+
import { StarIcon } from '@heroicons/react/solid'
|
|
8
|
+
import GithubIcon from '../icons/github.svg'
|
|
9
|
+
import formatStarCount from './formatStarCount'
|
|
10
|
+
import parseGithubUrl from './parseGithubUrl'
|
|
11
|
+
|
|
12
|
+
function GithubStarsButton({ url, hideGithubStars }: { url: string, hideGithubStars: boolean }) {
|
|
13
|
+
const [starCount, setStarCount] = useState(-1)
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (hideGithubStars) { setStarCount(0); return; }
|
|
17
|
+
const parseResult = parseGithubUrl(url);
|
|
18
|
+
const { org, repo } = parseResult !== false ? parseResult : { org: undefined, repo: undefined };
|
|
19
|
+
|
|
20
|
+
if (!org || !repo) { setStarCount(0); return; }
|
|
21
|
+
const githubApiUrl = `https://api.github.com/repos/${org}/${repo}`
|
|
22
|
+
fetch(githubApiUrl)
|
|
23
|
+
.then((response) => {
|
|
24
|
+
response.json().then((data) => {
|
|
25
|
+
// Github's rate limit for unauthenticated requests is 60 per hour
|
|
26
|
+
// When the limit is hit, data.stargazers_count is undefined,
|
|
27
|
+
// and setStarCount falls back to not showing the star count
|
|
28
|
+
setStarCount(data.stargazers_count)
|
|
29
|
+
// Warn if this limit is hit, to avoid otherwise confusing behavior
|
|
30
|
+
// We're still using the response to provide a documentation link
|
|
31
|
+
if (!data.stargazers_count) {
|
|
32
|
+
const { headers } = response
|
|
33
|
+
if (headers.get('x-ratelimit-remaining') === '0') {
|
|
34
|
+
const resetAtSeconds = parseInt(headers.get('x-ratelimit-reset') || '')
|
|
35
|
+
const resetDate = new Date(resetAtSeconds * 1000)
|
|
36
|
+
const rateLimit = headers.get('x-ratelimit-limit')
|
|
37
|
+
console.warn(
|
|
38
|
+
`⭐ Stargazers count could not be fetched. Rate limit exceeded for unauthenticated GitHub API. Limit will be reset to ${rateLimit} at ${resetDate}. See ${data.documentation_url} for more details.`
|
|
39
|
+
)
|
|
40
|
+
} else {
|
|
41
|
+
console.warn(
|
|
42
|
+
`Request for stargazers was successful, but the returned value was undefined or falsy. This might be because the repo has no stars, or it might be a different issue.`
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
.catch((err) => {
|
|
49
|
+
setStarCount(0)
|
|
50
|
+
console.warn(JSON.stringify(err, null, 2))
|
|
51
|
+
})
|
|
52
|
+
}, [hideGithubStars, url])
|
|
53
|
+
|
|
54
|
+
const isLoadingStarCount = starCount === -1
|
|
55
|
+
const isFailedStarCount = formatStarCount(starCount) === false
|
|
56
|
+
|
|
57
|
+
const showStarCount = !hideGithubStars && (isLoadingStarCount || !isFailedStarCount);
|
|
58
|
+
|
|
59
|
+
const icon: IconObject = {
|
|
60
|
+
position: 'right',
|
|
61
|
+
svg: GithubIcon,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<LinkButton icon={!showStarCount ? icon : undefined} label='Github' text={!showStarCount ? "Github" : ""}
|
|
66
|
+
variant='neutral' >
|
|
67
|
+
{showStarCount ? <div className="inline-flex">
|
|
68
|
+
<GithubIcon className="w-5 mr-2" />
|
|
69
|
+
<div className='-my-2 mx-2 h-9 block bg-gray-300' style={{ width: "1px" }} />
|
|
70
|
+
|
|
71
|
+
<span className="inline-flex gap-1 items-center">
|
|
72
|
+
<StarIcon className='w-5 fill-yellow-500' />
|
|
73
|
+
<span className="g-type-body-small-strong" data-testid="github-stars">
|
|
74
|
+
{formatStarCount(starCount) || <span>—</span>}
|
|
75
|
+
</span>
|
|
76
|
+
</span>
|
|
77
|
+
|
|
78
|
+
</div> : null}
|
|
79
|
+
</LinkButton>
|
|
80
|
+
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export default GithubStarsButton
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import parseGithubUrl from './index.js'
|
|
2
|
+
|
|
3
|
+
describe('<Subnav /> - parseGithubUrl', () => {
|
|
4
|
+
it('should gracefully handle an invalid URL', () => {
|
|
5
|
+
// Suppress console.warn for this test, we expect warnings
|
|
6
|
+
jest.spyOn(console, 'warn')
|
|
7
|
+
global.console.warn.mockImplementation(() => {})
|
|
8
|
+
expect(parseGithubUrl(undefined)).toBe(false)
|
|
9
|
+
expect(parseGithubUrl('blah-blah')).toBe(false)
|
|
10
|
+
// Restore console.warn for further tests
|
|
11
|
+
global.console.warn.mockRestore()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('should handle a non-GitHub URL', () => {
|
|
15
|
+
expect(parseGithubUrl('https://www.example.com/dbosoft/maxback')).toBe(
|
|
16
|
+
false
|
|
17
|
+
)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should parse the org and repo from a GitHub URL', () => {
|
|
21
|
+
const testInput = 'https://www.github.com/dbosoft/maxback'
|
|
22
|
+
expect(parseGithubUrl(testInput).repo).toBe('maxback')
|
|
23
|
+
expect(parseGithubUrl(testInput).org).toBe('dbosoft')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Given:
|
|
3
|
+
* urlString (string) valid URL string
|
|
4
|
+
* Return:
|
|
5
|
+
* { org, repo }, where the url is ~= www.github.com/{org}/{repo}
|
|
6
|
+
* or false otherwise (eg if not a valid URL, or if not a GitHub url)
|
|
7
|
+
*/
|
|
8
|
+
function parseGithubUrl(urlString: string) {
|
|
9
|
+
try {
|
|
10
|
+
const urlObj = new URL(urlString)
|
|
11
|
+
if (urlObj.hostname !== 'www.github.com') return false
|
|
12
|
+
const parts = urlObj.pathname.split('/').filter(Boolean)
|
|
13
|
+
const org = parts[0]
|
|
14
|
+
const repo = parts[1]
|
|
15
|
+
return { org, repo }
|
|
16
|
+
} catch (err) {
|
|
17
|
+
console.warn(
|
|
18
|
+
'Warning! An invalid URL has probably been supplied to the GitHub ctaLink in <Subnav />. The corresponding error:'
|
|
19
|
+
)
|
|
20
|
+
console.warn(err)
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default parseGithubUrl
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<title>GitHub</title>
|
|
3
|
+
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import classNames from 'classnames'
|
|
2
|
+
import LinkButton from '@dbosoft/react-uicore/linkbutton'
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import type { ICtaItem } from '../..'
|
|
5
|
+
import GithubStarsLink from './github-stars-link'
|
|
6
|
+
|
|
7
|
+
function CtaLinks(props: {
|
|
8
|
+
links: ICtaItem[],
|
|
9
|
+
hideGithubStars: boolean,
|
|
10
|
+
isInDropdown: boolean
|
|
11
|
+
}) {
|
|
12
|
+
const { links, hideGithubStars, isInDropdown } = props
|
|
13
|
+
return (
|
|
14
|
+
<div className={classNames("flex gap-2", isInDropdown ? "pt-4 flex-col" : "items-center ml-4 space-x-1 ")}>
|
|
15
|
+
{links.map((link, stableIdx) => {
|
|
16
|
+
|
|
17
|
+
const textKey = link.text.toLowerCase()
|
|
18
|
+
const isGithub = textKey === 'github'
|
|
19
|
+
|
|
20
|
+
if (isGithub)
|
|
21
|
+
return (
|
|
22
|
+
<GithubStarsLink
|
|
23
|
+
|
|
24
|
+
hideGithubStars={hideGithubStars}
|
|
25
|
+
key={stableIdx}
|
|
26
|
+
url={link.url}
|
|
27
|
+
/>
|
|
28
|
+
)
|
|
29
|
+
return (
|
|
30
|
+
<LinkButton
|
|
31
|
+
|
|
32
|
+
Link={Link}
|
|
33
|
+
className={link.className}
|
|
34
|
+
key={stableIdx}
|
|
35
|
+
linktype={link.linktype}
|
|
36
|
+
text={link.text}
|
|
37
|
+
url={link.url}
|
|
38
|
+
variant={link.variant}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
})}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default CtaLinks
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import LinkWrap from '@dbosoft/react-uicore/link-wrap'
|
|
4
|
+
import NavItemText from '../nav-item-text'
|
|
5
|
+
import type { MenuItem } from '../..'
|
|
6
|
+
import Link from 'next/link';
|
|
7
|
+
|
|
8
|
+
function MenuItemsDefault(props: {
|
|
9
|
+
menuItems: MenuItem[]
|
|
10
|
+
}) {
|
|
11
|
+
const { menuItems } = props
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<ul
|
|
15
|
+
className="inline-flex items-center">
|
|
16
|
+
{/* <a
|
|
17
|
+
key={item.name}
|
|
18
|
+
href={item.href}
|
|
19
|
+
className={classNames(
|
|
20
|
+
item.current
|
|
21
|
+
? 'border-indigo-500 text-gray-900'
|
|
22
|
+
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
|
|
23
|
+
'inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium'
|
|
24
|
+
)}
|
|
25
|
+
aria-current={item.current ? 'page' : undefined}
|
|
26
|
+
></a> */}
|
|
27
|
+
{menuItems.map((menuItem, stableIdx) => {
|
|
28
|
+
if (menuItem === 'divider') {
|
|
29
|
+
return <VerticalDivider key={stableIdx} />
|
|
30
|
+
}
|
|
31
|
+
const { text, url } = menuItem
|
|
32
|
+
return <NavLink
|
|
33
|
+
isActive={menuItem.active || false}
|
|
34
|
+
key={stableIdx}
|
|
35
|
+
text={text}
|
|
36
|
+
url={url}
|
|
37
|
+
/>
|
|
38
|
+
})}
|
|
39
|
+
</ul>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
function VerticalDivider() {
|
|
43
|
+
return (
|
|
44
|
+
<li>
|
|
45
|
+
<span className="bg-gray-400 w-0.5 h-6 mx-1 block" />
|
|
46
|
+
</li>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
function NavLink(props: { text: string; url: string; isActive: boolean }) {
|
|
52
|
+
const { text, url, isActive } = props
|
|
53
|
+
return (
|
|
54
|
+
<li>
|
|
55
|
+
<LinkWrap Link={Link} className="px-2" href={url}>
|
|
56
|
+
<NavItemText isActive={isActive} text={text} />
|
|
57
|
+
</LinkWrap>
|
|
58
|
+
</li>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
export default MenuItemsDefault
|