create-react-adam 0.2.2 → 0.4.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/README.md +75 -20
- package/bin/index.js +263 -100
- package/package.json +8 -3
- package/template/.github/workflows/check.yml +1 -1
- package/template/.github/workflows/e2e.yml +3 -3
- package/template/.github/workflows/lighthouse.yml +33 -0
- package/template/.nvmrc +1 -1
- package/template/README.md +174 -31
- package/template/e2e/gitignore +13 -0
- package/template/e2e/package.json +2 -2
- package/template/eslint-rules/prefer-webp-images.js +66 -0
- package/template/eslint.config.js +3 -0
- package/template/eslint.config.no-webp.js +54 -0
- package/template/index.html +19 -0
- package/template/lighthouserc.json +17 -0
- package/template/npmrc +2 -0
- package/template/package.json +18 -18
- package/template/public/fonts/InterVariable.woff2 +0 -0
- package/template/public/robots.txt +5 -0
- package/template/src/App.tsx +48 -14
- package/template/src/app.css +27 -9
- package/template/src/components/Button.tsx +27 -0
- package/template/src/components/PreloadLink.tsx +49 -0
- package/template/src/pages/About/index.no-utils.tsx +8 -3
- package/template/src/pages/About/index.tsx +25 -15
- package/template/src/pages/Home/index.no-utils.tsx +40 -4
- package/template/src/pages/Home/index.tsx +25 -15
- package/template/src/pages/NotFound/index.tsx +26 -19
- package/template/src/routes.ts +94 -0
- package/template/src/types/ui.ts +1 -0
- package/template/src/utils/classNames.ts +23 -2
- package/template/src/utils/helpers.ts +22 -0
- package/template/src/utils/useUrlState.ts +17 -4
package/template/src/app.css
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
@import "tailwindcss";
|
|
2
2
|
|
|
3
|
+
@font-face {
|
|
4
|
+
font-family: "Inter";
|
|
5
|
+
font-style: normal;
|
|
6
|
+
font-weight: 100 900;
|
|
7
|
+
font-display: swap;
|
|
8
|
+
src: url("/fonts/InterVariable.woff2") format("woff2");
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
@theme {
|
|
4
|
-
--font-sans:
|
|
12
|
+
--font-sans:
|
|
13
|
+
"Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
5
14
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
--color-brand-
|
|
15
|
+
/* Brand colors are chosen to meet WCAG AA contrast (>= 4.5:1) with white
|
|
16
|
+
text; keep that in mind when changing them. */
|
|
17
|
+
--color-brand-primary: #2563eb;
|
|
18
|
+
--color-brand-primaryHover: #1d4ed8;
|
|
19
|
+
--color-brand-primaryActive: #1e40af;
|
|
9
20
|
|
|
10
|
-
--color-brand-secondary: #
|
|
11
|
-
--color-brand-secondaryHover: #
|
|
12
|
-
--color-brand-secondaryActive: #
|
|
21
|
+
--color-brand-secondary: #7c3aed;
|
|
22
|
+
--color-brand-secondaryHover: #6d28d9;
|
|
23
|
+
--color-brand-secondaryActive: #5b21b6;
|
|
13
24
|
|
|
14
|
-
--color-brand-success: #
|
|
25
|
+
--color-brand-success: #15803d;
|
|
15
26
|
--color-brand-warning: #f59e0b;
|
|
16
|
-
--color-brand-danger: #
|
|
27
|
+
--color-brand-danger: #dc2626;
|
|
17
28
|
|
|
18
29
|
--color-brand-black: #0f172a;
|
|
19
30
|
--color-brand-white: #ffffff;
|
|
@@ -27,3 +38,10 @@
|
|
|
27
38
|
|
|
28
39
|
--color-brand-disabled: #94a3b8;
|
|
29
40
|
}
|
|
41
|
+
|
|
42
|
+
@layer base {
|
|
43
|
+
:focus-visible {
|
|
44
|
+
outline: 2px solid var(--color-brand-primary);
|
|
45
|
+
outline-offset: 2px;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ComponentPropsWithoutRef } from "react";
|
|
2
|
+
import { ButtonVariant } from "../types/ui";
|
|
3
|
+
|
|
4
|
+
interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
|
|
5
|
+
variant?: ButtonVariant;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const variantClasses: Record<ButtonVariant, string> = {
|
|
9
|
+
primary: "bg-brand-primary hover:bg-brand-primaryHover",
|
|
10
|
+
success: "bg-brand-success hover:bg-brand-success/90",
|
|
11
|
+
danger: "bg-brand-danger hover:bg-brand-danger/90",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const Button = ({ variant = "primary", className, ...props }: ButtonProps) => (
|
|
15
|
+
<button
|
|
16
|
+
className={[
|
|
17
|
+
"text-brand-white rounded-lg px-4 py-2 transition-colors",
|
|
18
|
+
variantClasses[variant],
|
|
19
|
+
className,
|
|
20
|
+
]
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.join(" ")}
|
|
23
|
+
{...props}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export default Button;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ComponentProps, FocusEvent, MouseEvent, TouchEvent } from "react";
|
|
2
|
+
import { Link } from "wouter";
|
|
3
|
+
import { preloadRoute } from "../routes";
|
|
4
|
+
|
|
5
|
+
// Drop-in replacement for wouter's <Link> that preloads the target page's
|
|
6
|
+
// chunk as soon as the user shows intent to navigate (hover, keyboard focus,
|
|
7
|
+
// or touch). By the time the click lands, the chunk is usually in flight or
|
|
8
|
+
// already cached, so navigation feels instant. See src/routes.ts for the
|
|
9
|
+
// full preloading picture.
|
|
10
|
+
//
|
|
11
|
+
// HOW TO REMOVE: swap <PreloadLink> back to wouter's <Link> wherever it is
|
|
12
|
+
// used (the props are identical), delete this file, and follow the removal
|
|
13
|
+
// steps in src/routes.ts.
|
|
14
|
+
|
|
15
|
+
// Mirrors wouter's <Link> props in its plain-anchor form. wouter's asChild
|
|
16
|
+
// variant and `to` alias are not supported here — use wouter's <Link>
|
|
17
|
+
// directly if you need those.
|
|
18
|
+
type PreloadLinkProps = Omit<ComponentProps<"a">, "className" | "href"> & {
|
|
19
|
+
href: string;
|
|
20
|
+
className?: string | ((isActive: boolean) => string | undefined);
|
|
21
|
+
replace?: boolean;
|
|
22
|
+
state?: unknown;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const PreloadLink = (props: PreloadLinkProps) => {
|
|
26
|
+
const handleIntent = () => {
|
|
27
|
+
preloadRoute(props.href);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Link
|
|
32
|
+
{...props}
|
|
33
|
+
onMouseEnter={(event: MouseEvent<HTMLAnchorElement>) => {
|
|
34
|
+
handleIntent();
|
|
35
|
+
props.onMouseEnter?.(event);
|
|
36
|
+
}}
|
|
37
|
+
onFocus={(event: FocusEvent<HTMLAnchorElement>) => {
|
|
38
|
+
handleIntent();
|
|
39
|
+
props.onFocus?.(event);
|
|
40
|
+
}}
|
|
41
|
+
onTouchStart={(event: TouchEvent<HTMLAnchorElement>) => {
|
|
42
|
+
handleIntent();
|
|
43
|
+
props.onTouchStart?.(event);
|
|
44
|
+
}}
|
|
45
|
+
/>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default PreloadLink;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import PreloadLink from "../../components/PreloadLink";
|
|
2
3
|
|
|
3
4
|
const About = () => {
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
document.title = "About | __PROJECT_NAME__";
|
|
7
|
+
}, []);
|
|
8
|
+
|
|
4
9
|
return (
|
|
5
10
|
<div className="bg-brand-background flex min-h-screen flex-col items-center justify-center">
|
|
6
11
|
<div className="mx-auto max-w-2xl px-4 text-center">
|
|
@@ -25,12 +30,12 @@ const About = () => {
|
|
|
25
30
|
</ul>
|
|
26
31
|
</div>
|
|
27
32
|
|
|
28
|
-
<
|
|
33
|
+
<PreloadLink
|
|
29
34
|
href="/"
|
|
30
35
|
className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
|
|
31
36
|
>
|
|
32
37
|
Back to Home
|
|
33
|
-
</
|
|
38
|
+
</PreloadLink>
|
|
34
39
|
</div>
|
|
35
40
|
</div>
|
|
36
41
|
);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import Button from "../../components/Button";
|
|
3
|
+
import PreloadLink from "../../components/PreloadLink";
|
|
2
4
|
import { useReactPersist } from "../../utils/Storage";
|
|
3
5
|
import { useUrlState } from "../../utils/useUrlState";
|
|
4
6
|
|
|
@@ -6,6 +8,10 @@ const About = () => {
|
|
|
6
8
|
const [sharedCount, setSharedCount] = useReactPersist("sharedCounter", 0);
|
|
7
9
|
const [urlCount, setUrlCount] = useUrlState("counter", 0);
|
|
8
10
|
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
document.title = "About | __PROJECT_NAME__";
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
9
15
|
return (
|
|
10
16
|
<div className="bg-brand-background flex min-h-screen flex-col items-center justify-center">
|
|
11
17
|
<div className="mx-auto max-w-2xl px-4 text-center">
|
|
@@ -26,21 +32,23 @@ const About = () => {
|
|
|
26
32
|
This counter is shared between Home and About pages
|
|
27
33
|
</p>
|
|
28
34
|
<div className="flex items-center justify-center gap-4">
|
|
29
|
-
<
|
|
35
|
+
<Button
|
|
36
|
+
variant="danger"
|
|
37
|
+
aria-label="Decrease shared counter"
|
|
30
38
|
onClick={() => setSharedCount(sharedCount - 1)}
|
|
31
|
-
className="bg-brand-danger text-brand-white hover:bg-brand-danger/90 rounded-lg px-4 py-2 transition-colors"
|
|
32
39
|
>
|
|
33
40
|
-
|
|
34
|
-
</
|
|
41
|
+
</Button>
|
|
35
42
|
<span className="text-brand-black text-3xl font-bold">
|
|
36
43
|
{sharedCount}
|
|
37
44
|
</span>
|
|
38
|
-
<
|
|
45
|
+
<Button
|
|
46
|
+
variant="success"
|
|
47
|
+
aria-label="Increase shared counter"
|
|
39
48
|
onClick={() => setSharedCount(sharedCount + 1)}
|
|
40
|
-
className="bg-brand-success text-brand-white hover:bg-brand-success/90 rounded-lg px-4 py-2 transition-colors"
|
|
41
49
|
>
|
|
42
50
|
+
|
|
43
|
-
</
|
|
51
|
+
</Button>
|
|
44
52
|
</div>
|
|
45
53
|
</div>
|
|
46
54
|
|
|
@@ -52,21 +60,23 @@ const About = () => {
|
|
|
52
60
|
This counter syncs with URL parameters
|
|
53
61
|
</p>
|
|
54
62
|
<div className="flex items-center justify-center gap-4">
|
|
55
|
-
<
|
|
63
|
+
<Button
|
|
64
|
+
variant="danger"
|
|
65
|
+
aria-label="Decrease URL counter"
|
|
56
66
|
onClick={() => setUrlCount(urlCount - 1)}
|
|
57
|
-
className="bg-brand-danger text-brand-white hover:bg-brand-danger/90 rounded-lg px-4 py-2 transition-colors"
|
|
58
67
|
>
|
|
59
68
|
-
|
|
60
|
-
</
|
|
69
|
+
</Button>
|
|
61
70
|
<span className="text-brand-black text-3xl font-bold">
|
|
62
71
|
{urlCount}
|
|
63
72
|
</span>
|
|
64
|
-
<
|
|
73
|
+
<Button
|
|
74
|
+
variant="success"
|
|
75
|
+
aria-label="Increase URL counter"
|
|
65
76
|
onClick={() => setUrlCount(urlCount + 1)}
|
|
66
|
-
className="bg-brand-success text-brand-white hover:bg-brand-success/90 rounded-lg px-4 py-2 transition-colors"
|
|
67
77
|
>
|
|
68
78
|
+
|
|
69
|
-
</
|
|
79
|
+
</Button>
|
|
70
80
|
</div>
|
|
71
81
|
</div>
|
|
72
82
|
</div>
|
|
@@ -83,12 +93,12 @@ const About = () => {
|
|
|
83
93
|
<li>✨ ESLint & Prettier configured</li>
|
|
84
94
|
</ul>
|
|
85
95
|
</div>
|
|
86
|
-
<
|
|
96
|
+
<PreloadLink
|
|
87
97
|
href="/"
|
|
88
98
|
className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
|
|
89
99
|
>
|
|
90
100
|
Back to Home
|
|
91
|
-
</
|
|
101
|
+
</PreloadLink>
|
|
92
102
|
</div>
|
|
93
103
|
</div>
|
|
94
104
|
);
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import Button from "../../components/Button";
|
|
3
|
+
import PreloadLink from "../../components/PreloadLink";
|
|
2
4
|
|
|
3
5
|
const Home = () => {
|
|
6
|
+
const [count, setCount] = useState(0);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
document.title = "__PROJECT_NAME__";
|
|
10
|
+
}, []);
|
|
11
|
+
|
|
4
12
|
return (
|
|
5
13
|
<div className="bg-brand-background flex min-h-screen flex-col items-center justify-center">
|
|
6
14
|
<div className="mx-auto max-w-2xl px-4 text-center">
|
|
@@ -11,7 +19,7 @@ const Home = () => {
|
|
|
11
19
|
Built with React, TypeScript, Vite, Wouter, and Tailwind CSS
|
|
12
20
|
</p>
|
|
13
21
|
|
|
14
|
-
<div className="mb-8">
|
|
22
|
+
<div className="mb-8 space-y-6">
|
|
15
23
|
<div className="bg-brand-white rounded-lg p-6 shadow-md">
|
|
16
24
|
<h2 className="text-brand-black mb-4 text-2xl font-semibold">
|
|
17
25
|
Get Started
|
|
@@ -27,15 +35,43 @@ const Home = () => {
|
|
|
27
35
|
This is a clean starting point for your React application.
|
|
28
36
|
</p>
|
|
29
37
|
</div>
|
|
38
|
+
|
|
39
|
+
<div className="bg-brand-white rounded-lg p-6 shadow-md">
|
|
40
|
+
<h2 className="text-brand-black mb-4 text-2xl font-semibold">
|
|
41
|
+
Counter
|
|
42
|
+
</h2>
|
|
43
|
+
<p className="text-brand-gray mb-4">
|
|
44
|
+
A simple counter using the shared Button component
|
|
45
|
+
</p>
|
|
46
|
+
<div className="flex items-center justify-center gap-4">
|
|
47
|
+
<Button
|
|
48
|
+
variant="danger"
|
|
49
|
+
aria-label="Decrease counter"
|
|
50
|
+
onClick={() => setCount(count - 1)}
|
|
51
|
+
>
|
|
52
|
+
-
|
|
53
|
+
</Button>
|
|
54
|
+
<span className="text-brand-black text-3xl font-bold">
|
|
55
|
+
{count}
|
|
56
|
+
</span>
|
|
57
|
+
<Button
|
|
58
|
+
variant="success"
|
|
59
|
+
aria-label="Increase counter"
|
|
60
|
+
onClick={() => setCount(count + 1)}
|
|
61
|
+
>
|
|
62
|
+
+
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
30
66
|
</div>
|
|
31
67
|
|
|
32
68
|
<div className="flex justify-center gap-4">
|
|
33
|
-
<
|
|
69
|
+
<PreloadLink
|
|
34
70
|
href="/about"
|
|
35
71
|
className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
|
|
36
72
|
>
|
|
37
73
|
About Page
|
|
38
|
-
</
|
|
74
|
+
</PreloadLink>
|
|
39
75
|
<a
|
|
40
76
|
href="https://react.dev"
|
|
41
77
|
target="_blank"
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import Button from "../../components/Button";
|
|
3
|
+
import PreloadLink from "../../components/PreloadLink";
|
|
2
4
|
import { useReactPersist } from "../../utils/Storage";
|
|
3
5
|
import { useUrlState } from "../../utils/useUrlState";
|
|
4
6
|
|
|
@@ -6,6 +8,10 @@ const Home = () => {
|
|
|
6
8
|
const [sharedCount, setSharedCount] = useReactPersist("sharedCounter", 0);
|
|
7
9
|
const [urlCount, setUrlCount] = useUrlState("counter", 0);
|
|
8
10
|
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
document.title = "__PROJECT_NAME__";
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
9
15
|
return (
|
|
10
16
|
<div className="bg-brand-background flex min-h-screen flex-col items-center justify-center">
|
|
11
17
|
<div className="mx-auto max-w-2xl px-4 text-center">
|
|
@@ -25,21 +31,23 @@ const Home = () => {
|
|
|
25
31
|
This counter is shared between Home and About pages
|
|
26
32
|
</p>
|
|
27
33
|
<div className="flex items-center justify-center gap-4">
|
|
28
|
-
<
|
|
34
|
+
<Button
|
|
35
|
+
variant="danger"
|
|
36
|
+
aria-label="Decrease shared counter"
|
|
29
37
|
onClick={() => setSharedCount(sharedCount - 1)}
|
|
30
|
-
className="bg-brand-danger text-brand-white hover:bg-brand-danger/90 rounded-lg px-4 py-2 transition-colors"
|
|
31
38
|
>
|
|
32
39
|
-
|
|
33
|
-
</
|
|
40
|
+
</Button>
|
|
34
41
|
<span className="text-brand-black text-3xl font-bold">
|
|
35
42
|
{sharedCount}
|
|
36
43
|
</span>
|
|
37
|
-
<
|
|
44
|
+
<Button
|
|
45
|
+
variant="success"
|
|
46
|
+
aria-label="Increase shared counter"
|
|
38
47
|
onClick={() => setSharedCount(sharedCount + 1)}
|
|
39
|
-
className="bg-brand-success text-brand-white hover:bg-brand-success/90 rounded-lg px-4 py-2 transition-colors"
|
|
40
48
|
>
|
|
41
49
|
+
|
|
42
|
-
</
|
|
50
|
+
</Button>
|
|
43
51
|
</div>
|
|
44
52
|
</div>
|
|
45
53
|
|
|
@@ -51,32 +59,34 @@ const Home = () => {
|
|
|
51
59
|
This counter syncs with URL parameters
|
|
52
60
|
</p>
|
|
53
61
|
<div className="flex items-center justify-center gap-4">
|
|
54
|
-
<
|
|
62
|
+
<Button
|
|
63
|
+
variant="danger"
|
|
64
|
+
aria-label="Decrease URL counter"
|
|
55
65
|
onClick={() => setUrlCount(urlCount - 1)}
|
|
56
|
-
className="bg-brand-danger text-brand-white hover:bg-brand-danger/90 rounded-lg px-4 py-2 transition-colors"
|
|
57
66
|
>
|
|
58
67
|
-
|
|
59
|
-
</
|
|
68
|
+
</Button>
|
|
60
69
|
<span className="text-brand-black text-3xl font-bold">
|
|
61
70
|
{urlCount}
|
|
62
71
|
</span>
|
|
63
|
-
<
|
|
72
|
+
<Button
|
|
73
|
+
variant="success"
|
|
74
|
+
aria-label="Increase URL counter"
|
|
64
75
|
onClick={() => setUrlCount(urlCount + 1)}
|
|
65
|
-
className="bg-brand-success text-brand-white hover:bg-brand-success/90 rounded-lg px-4 py-2 transition-colors"
|
|
66
76
|
>
|
|
67
77
|
+
|
|
68
|
-
</
|
|
78
|
+
</Button>
|
|
69
79
|
</div>
|
|
70
80
|
</div>
|
|
71
81
|
</div>
|
|
72
82
|
|
|
73
83
|
<div className="flex justify-center gap-4">
|
|
74
|
-
<
|
|
84
|
+
<PreloadLink
|
|
75
85
|
href="/about"
|
|
76
86
|
className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover rounded-lg px-6 py-3 transition-colors"
|
|
77
87
|
>
|
|
78
88
|
About Page
|
|
79
|
-
</
|
|
89
|
+
</PreloadLink>
|
|
80
90
|
<a
|
|
81
91
|
href="https://react.dev"
|
|
82
92
|
target="_blank"
|
|
@@ -1,23 +1,30 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import PreloadLink from "../../components/PreloadLink";
|
|
2
3
|
|
|
3
|
-
const NotFound = () =>
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
className="
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
4
|
+
const NotFound = () => {
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
document.title = "Page Not Found | __PROJECT_NAME__";
|
|
7
|
+
}, []);
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="bg-brand-background flex min-h-screen flex-col items-center justify-center">
|
|
11
|
+
<div className="text-center">
|
|
12
|
+
<h1 className="text-brand-black text-9xl font-bold">404</h1>
|
|
13
|
+
<p className="text-brand-black mt-4 text-2xl font-semibold">
|
|
14
|
+
Page Not Found
|
|
15
|
+
</p>
|
|
16
|
+
<p className="text-brand-gray mt-2">
|
|
17
|
+
The page you're looking for doesn't exist.
|
|
18
|
+
</p>
|
|
19
|
+
<PreloadLink
|
|
20
|
+
href="/"
|
|
21
|
+
className="bg-brand-primary text-brand-white hover:bg-brand-primaryHover mt-6 inline-block rounded-lg px-6 py-3 transition-colors"
|
|
22
|
+
>
|
|
23
|
+
Go Home
|
|
24
|
+
</PreloadLink>
|
|
25
|
+
</div>
|
|
19
26
|
</div>
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
);
|
|
28
|
+
};
|
|
22
29
|
|
|
23
30
|
export default NotFound;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
|
|
3
|
+
// Route-level code splitting + preloading.
|
|
4
|
+
//
|
|
5
|
+
// This file is the single source of truth for every page's dynamic import.
|
|
6
|
+
// App.tsx feeds these thunks to React.lazy() for routing, and the preload
|
|
7
|
+
// helpers below fire the same thunks ahead of navigation. Because dynamic
|
|
8
|
+
// imports are deduped by module, a preloaded chunk is never downloaded
|
|
9
|
+
// twice — navigating just picks up the in-flight or already-cached module.
|
|
10
|
+
//
|
|
11
|
+
// Preloading happens in two layers:
|
|
12
|
+
// 1. Intent: <PreloadLink> (src/components/PreloadLink.tsx) calls
|
|
13
|
+
// preloadRoute() on hover/focus/touch. Hover-to-click is typically
|
|
14
|
+
// 200-400ms — enough for a small page chunk to arrive before the click.
|
|
15
|
+
// 2. Idle backstop: preloadAllRoutesWhenIdle() (called once from App.tsx)
|
|
16
|
+
// fetches every remaining chunk after the window `load` event, one at a
|
|
17
|
+
// time during idle periods, so it never competes with first-paint
|
|
18
|
+
// resources (or your Lighthouse score).
|
|
19
|
+
//
|
|
20
|
+
// HOW TO REMOVE PRELOADING:
|
|
21
|
+
// 1. In App.tsx: delete the preloadAllRoutesWhenIdle() effect and the
|
|
22
|
+
// useDeferredValue lines, and inline the imports back into lazy(),
|
|
23
|
+
// e.g. const Home = lazy(() => import("./pages/Home"));
|
|
24
|
+
// 2. In src/pages/: replace <PreloadLink> with wouter's <Link>.
|
|
25
|
+
// 3. Delete this file and src/components/PreloadLink.tsx.
|
|
26
|
+
|
|
27
|
+
type PageLoader = () => Promise<{ default: ComponentType }>;
|
|
28
|
+
|
|
29
|
+
export const routeImports: Record<string, PageLoader> = {
|
|
30
|
+
"/": () => import("./pages/Home"),
|
|
31
|
+
"/about": () => import("./pages/About"),
|
|
32
|
+
// Not a real link target — keyed by a pseudo-path so the idle prefetcher
|
|
33
|
+
// warms the 404 page too.
|
|
34
|
+
"/404": () => import("./pages/NotFound"),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Fires a route's import ahead of navigation. Safe to call repeatedly and
|
|
38
|
+
// with paths that are not in the map (external links, dynamic segments) —
|
|
39
|
+
// those are a no-op.
|
|
40
|
+
export function preloadRoute(path: string): void {
|
|
41
|
+
const load = routeImports[path];
|
|
42
|
+
if (!load) return;
|
|
43
|
+
load().catch(() => {
|
|
44
|
+
// Preloading is opportunistic: if it fails (offline, a deploy replaced
|
|
45
|
+
// the chunk), the real import on navigation will surface the error.
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let hasStartedIdlePreload = false;
|
|
50
|
+
|
|
51
|
+
// Fetches every route chunk after the page has fully loaded, one chunk at a
|
|
52
|
+
// time during browser idle periods. Skips entirely for users on data-saver
|
|
53
|
+
// or 2G connections.
|
|
54
|
+
export function preloadAllRoutesWhenIdle(): void {
|
|
55
|
+
// React StrictMode runs effects twice in development — only start once.
|
|
56
|
+
if (hasStartedIdlePreload) return;
|
|
57
|
+
hasStartedIdlePreload = true;
|
|
58
|
+
|
|
59
|
+
const { connection } = navigator as Navigator & {
|
|
60
|
+
connection?: { saveData?: boolean; effectiveType?: string };
|
|
61
|
+
};
|
|
62
|
+
if (connection?.saveData === true) return;
|
|
63
|
+
if (connection?.effectiveType?.endsWith("2g") === true) return;
|
|
64
|
+
|
|
65
|
+
// Safari has no requestIdleCallback; a timeout is a close-enough stand-in.
|
|
66
|
+
const scheduleIdle = (callback: () => void): void => {
|
|
67
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
68
|
+
window.requestIdleCallback(callback);
|
|
69
|
+
} else {
|
|
70
|
+
window.setTimeout(callback, 1500);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const queue = Object.values(routeImports);
|
|
75
|
+
const loadNext = async (): Promise<void> => {
|
|
76
|
+
const load = queue.shift();
|
|
77
|
+
if (!load) return;
|
|
78
|
+
try {
|
|
79
|
+
await load();
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore failures here for the same reason as preloadRoute.
|
|
82
|
+
}
|
|
83
|
+
scheduleIdle(() => void loadNext());
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Waiting for `load` keeps prefetching out of the critical path: every
|
|
87
|
+
// first-paint resource is done before the first idle callback runs.
|
|
88
|
+
const start = (): void => scheduleIdle(() => void loadNext());
|
|
89
|
+
if (document.readyState === "complete") {
|
|
90
|
+
start();
|
|
91
|
+
} else {
|
|
92
|
+
window.addEventListener("load", start, { once: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type ButtonVariant = "primary" | "success" | "danger";
|
|
@@ -1,3 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
type ClassValue = string | false | null | undefined | Record<string, boolean>;
|
|
2
|
+
|
|
3
|
+
export default function classNames(...classes: ClassValue[]): string {
|
|
4
|
+
const result: string[] = [];
|
|
5
|
+
|
|
6
|
+
for (const entry of classes) {
|
|
7
|
+
if (!entry) {
|
|
8
|
+
continue;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (typeof entry === "string") {
|
|
12
|
+
result.push(entry);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
for (const [name, isEnabled] of Object.entries(entry)) {
|
|
17
|
+
if (isEnabled) {
|
|
18
|
+
result.push(name);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return result.join(" ");
|
|
3
24
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Browsers store setTimeout delays as a 32-bit signed integer, so any delay
|
|
2
|
+
// above 2^31 - 1 ms (~24.8 days) overflows and fires immediately.
|
|
3
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Drop-in replacement for setTimeout that clamps delays larger than the
|
|
7
|
+
* maximum 32-bit signed integer (2,147,483,647 ms / ~24.8 days) instead of
|
|
8
|
+
* letting them overflow and fire immediately.
|
|
9
|
+
*/
|
|
10
|
+
export function safeTimeout<TArgs extends unknown[]>(
|
|
11
|
+
callback: (...callbackArgs: TArgs) => void,
|
|
12
|
+
delay: number,
|
|
13
|
+
...args: TArgs
|
|
14
|
+
): ReturnType<typeof globalThis.setTimeout> {
|
|
15
|
+
if (delay > MAX_TIMEOUT_MS) {
|
|
16
|
+
console.error(
|
|
17
|
+
`safeTimeout: delay ${delay} exceeds the maximum of ${MAX_TIMEOUT_MS} ms (~24.8 days). Using the maximum value.`,
|
|
18
|
+
);
|
|
19
|
+
delay = MAX_TIMEOUT_MS;
|
|
20
|
+
}
|
|
21
|
+
return globalThis.setTimeout(callback, delay, ...args);
|
|
22
|
+
}
|
|
@@ -4,16 +4,28 @@ import { useLocation, useSearch } from "wouter";
|
|
|
4
4
|
export function useUrlState(
|
|
5
5
|
key: string,
|
|
6
6
|
defaultValue: number,
|
|
7
|
-
): [number, (value: number) => void]
|
|
7
|
+
): [number, (value: number) => void];
|
|
8
|
+
export function useUrlState(
|
|
9
|
+
key: string,
|
|
10
|
+
defaultValue: string,
|
|
11
|
+
): [string, (value: string) => void];
|
|
12
|
+
export function useUrlState(
|
|
13
|
+
key: string,
|
|
14
|
+
defaultValue: string | number,
|
|
15
|
+
): [string, (value: string) => void] | [number, (value: number) => void] {
|
|
8
16
|
const [, setLocation] = useLocation();
|
|
9
17
|
const searchParams = useSearch();
|
|
10
18
|
|
|
11
19
|
const params = new URLSearchParams(searchParams);
|
|
12
20
|
const urlValue = params.get(key);
|
|
13
|
-
const value = urlValue
|
|
21
|
+
const value = urlValue
|
|
22
|
+
? typeof defaultValue === "number"
|
|
23
|
+
? Number.parseInt(urlValue, 10)
|
|
24
|
+
: urlValue
|
|
25
|
+
: defaultValue;
|
|
14
26
|
|
|
15
27
|
const setValue = useCallback(
|
|
16
|
-
(newValue: number) => {
|
|
28
|
+
(newValue: string | number) => {
|
|
17
29
|
const currentParams = new URLSearchParams(globalThis.location.search);
|
|
18
30
|
currentParams.set(key, newValue.toString());
|
|
19
31
|
setLocation(
|
|
@@ -23,5 +35,6 @@ export function useUrlState(
|
|
|
23
35
|
[key, setLocation],
|
|
24
36
|
);
|
|
25
37
|
|
|
26
|
-
|
|
38
|
+
// The overloads guarantee value/setValue agree with the default's type.
|
|
39
|
+
return [value, setValue] as [string, (value: string) => void];
|
|
27
40
|
}
|