@featurevisor/site 0.8.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +15 -0
  4. package/dist/index.css +2183 -0
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.LICENSE.txt +68 -0
  7. package/dist/index.js.map +1 -0
  8. package/lib/components/Alert.d.ts +7 -0
  9. package/lib/components/App.d.ts +2 -0
  10. package/lib/components/EditLink.d.ts +2 -0
  11. package/lib/components/EnvironmentDot.d.ts +8 -0
  12. package/lib/components/ExpandConditions.d.ts +6 -0
  13. package/lib/components/ExpandRuleSegments.d.ts +6 -0
  14. package/lib/components/Footer.d.ts +2 -0
  15. package/lib/components/Header.d.ts +2 -0
  16. package/lib/components/HistoryTimeline.d.ts +10 -0
  17. package/lib/components/LastModified.d.ts +2 -0
  18. package/lib/components/ListAttributes.d.ts +2 -0
  19. package/lib/components/ListFeatures.d.ts +2 -0
  20. package/lib/components/ListHistory.d.ts +2 -0
  21. package/lib/components/ListSegments.d.ts +2 -0
  22. package/lib/components/Markdown.d.ts +2 -0
  23. package/lib/components/PageContent.d.ts +4 -0
  24. package/lib/components/PageTitle.d.ts +5 -0
  25. package/lib/components/PrettyDate.d.ts +7 -0
  26. package/lib/components/SearchInput.d.ts +7 -0
  27. package/lib/components/ShowAttribute.d.ts +5 -0
  28. package/lib/components/ShowFeature.d.ts +10 -0
  29. package/lib/components/ShowSegment.d.ts +5 -0
  30. package/lib/components/Tabs.d.ts +10 -0
  31. package/lib/components/Tag.d.ts +6 -0
  32. package/lib/contexts/SearchIndexContext.d.ts +7 -0
  33. package/lib/hooks/searchIndexHook.d.ts +1 -0
  34. package/lib/index.d.ts +1 -0
  35. package/lib/utils/index.d.ts +23 -0
  36. package/package.json +62 -0
  37. package/public/favicon-128.png +0 -0
  38. package/public/index.html +15 -0
  39. package/src/components/Alert.tsx +22 -0
  40. package/src/components/App.tsx +129 -0
  41. package/src/components/EditLink.tsx +18 -0
  42. package/src/components/EnvironmentDot.tsx +48 -0
  43. package/src/components/ExpandConditions.tsx +84 -0
  44. package/src/components/ExpandRuleSegments.tsx +59 -0
  45. package/src/components/Footer.tsx +14 -0
  46. package/src/components/Header.tsx +62 -0
  47. package/src/components/HistoryTimeline.tsx +179 -0
  48. package/src/components/LastModified.tsx +29 -0
  49. package/src/components/ListAttributes.tsx +87 -0
  50. package/src/components/ListFeatures.tsx +95 -0
  51. package/src/components/ListHistory.tsx +17 -0
  52. package/src/components/ListSegments.tsx +81 -0
  53. package/src/components/Markdown.tsx +11 -0
  54. package/src/components/PageContent.tsx +9 -0
  55. package/src/components/PageTitle.tsx +23 -0
  56. package/src/components/PrettyDate.tsx +56 -0
  57. package/src/components/SearchInput.tsx +34 -0
  58. package/src/components/ShowAttribute.tsx +145 -0
  59. package/src/components/ShowFeature.tsx +490 -0
  60. package/src/components/ShowSegment.tsx +125 -0
  61. package/src/components/Tabs.tsx +46 -0
  62. package/src/components/Tag.tsx +13 -0
  63. package/src/contexts/SearchIndexContext.tsx +13 -0
  64. package/src/hooks/searchIndexHook.ts +9 -0
  65. package/src/index.css +8 -0
  66. package/src/index.tsx +12 -0
  67. package/src/utils/index.ts +203 -0
  68. package/tailwind.config.js +8 -0
  69. package/tsconfig.cjs.json +7 -0
  70. package/webpack.config.js +14 -0
@@ -0,0 +1,14 @@
1
+ import * as React from "react";
2
+
3
+ export function Footer() {
4
+ return (
5
+ <footer>
6
+ <p className="pb-5 text-center text-xs leading-5 text-gray-500">
7
+ Site built using{" "}
8
+ <a target="_blank" href="https://featurevisor.com">
9
+ Featurevisor
10
+ </a>
11
+ </p>
12
+ </footer>
13
+ );
14
+ }
@@ -0,0 +1,62 @@
1
+ import * as React from "react";
2
+ import { Link, NavLink } from "react-router-dom";
3
+
4
+ export function Header() {
5
+ const navItems = [
6
+ {
7
+ title: "Features",
8
+ to: "features",
9
+ active: true,
10
+ },
11
+ {
12
+ title: "Segments",
13
+ to: "segments",
14
+ },
15
+ {
16
+ title: "Attributes",
17
+ to: "attributes",
18
+ },
19
+ {
20
+ title: "History",
21
+ to: "history",
22
+ },
23
+ ];
24
+
25
+ return (
26
+ <div className="bg-gray-800">
27
+ <nav className="mx-auto flex max-w-4xl items-center justify-between px-8 pb-4 pt-3">
28
+ <Link to="/" className="text-gray-50">
29
+ <img
30
+ alt="Featurevisor"
31
+ src="/favicon-128.png"
32
+ className="absolute top-4 -ml-2 w-[36px]"
33
+ />
34
+ </Link>
35
+
36
+ <div className="relative flex gap-x-4">
37
+ {navItems.map((item) => (
38
+ <NavLink
39
+ key={item.title}
40
+ to={item.to}
41
+ className={({ isActive }) => {
42
+ return [
43
+ "relative",
44
+ "rounded-lg",
45
+ isActive ? "bg-gray-700" : "",
46
+ "px-3",
47
+ "py-2",
48
+ "text-sm",
49
+ "font-semibold",
50
+ "leading-6",
51
+ "text-gray-50",
52
+ ].join(" ");
53
+ }}
54
+ >
55
+ {item.title}
56
+ </NavLink>
57
+ ))}
58
+ </div>
59
+ </nav>
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,179 @@
1
+ import * as React from "react";
2
+
3
+ import { UserCircleIcon } from "@heroicons/react/20/solid";
4
+
5
+ import { Alert } from "./Alert";
6
+ import { PrettyDate } from "./PrettyDate";
7
+ import { useSearchIndex } from "../hooks/searchIndexHook";
8
+
9
+ const entriesPerPage = 50;
10
+ const initialMaxEntitiesCount = 10;
11
+
12
+ function Activity(props) {
13
+ const { entry, links } = props;
14
+ const [showAll, setShowAll] = React.useState(false);
15
+
16
+ const entitiesToRender = showAll
17
+ ? entry.entities
18
+ : entry.entities.slice(0, initialMaxEntitiesCount);
19
+
20
+ return (
21
+ <>
22
+ <span className="font-semibold text-gray-600">{entry.author}</span>{" "}
23
+ updated{" "}
24
+ {entry.entities.length === 1 ? (
25
+ <>
26
+ {entry.entities[0].type}{" "}
27
+ <a href="#" className="font-semibold text-gray-600">
28
+ {entry.entities[0].key}
29
+ </a>
30
+ </>
31
+ ) : (
32
+ ""
33
+ )}{" "}
34
+ on{" "}
35
+ <a
36
+ href={
37
+ links
38
+ ? links.commit.replace("{{hash}}", entry.commit)
39
+ : `#${entry.commit}`
40
+ }
41
+ target="_blank"
42
+ className="font-semibold text-gray-600"
43
+ >
44
+ <PrettyDate date={entry.timestamp} showTime={props.showTime} />
45
+ </a>
46
+ {entry.entities.length > 1 && (
47
+ <>
48
+ <span>:</span>
49
+ <ul className="list-disc pl-3.5 pt-2 text-gray-400 marker:text-gray-400">
50
+ {entitiesToRender.map((entity, index) => {
51
+ return (
52
+ <li key={index}>
53
+ <span className="text-gray-400">{entity.type}</span>{" "}
54
+ <a href="#" className="font-semibold text-gray-500">
55
+ {entity.key}
56
+ </a>
57
+ </li>
58
+ );
59
+ })}
60
+
61
+ {!showAll && entry.entities.length > initialMaxEntitiesCount && (
62
+ <li key="show-all">
63
+ <a
64
+ href="#"
65
+ className="font-bold underline"
66
+ onClick={() => setShowAll(true)}
67
+ >
68
+ Show all
69
+ </a>
70
+ </li>
71
+ )}
72
+ </ul>
73
+ </>
74
+ )}
75
+ </>
76
+ );
77
+ }
78
+
79
+ interface HistoryTimelineProps {
80
+ className?: string;
81
+ filter?: (entry: any) => boolean;
82
+ entityType?: string;
83
+ entityKey?: string;
84
+ showTime?: boolean;
85
+ }
86
+
87
+ export function HistoryTimeline(props: HistoryTimelineProps) {
88
+ const { data } = useSearchIndex();
89
+ const links = data?.links;
90
+ const [historyEntries, setHistoryEntries] = React.useState([]);
91
+
92
+ const [page, setPage] = React.useState(1);
93
+
94
+ React.useEffect(() => {
95
+ fetch("/history-full.json")
96
+ .then((res) => res.json())
97
+ .then((data) => {
98
+ const filteredHistoryEntries = data.filter((entry: any) => {
99
+ if (props.filter) {
100
+ return props.filter(entry);
101
+ }
102
+
103
+ if (props.entityType && props.entityKey) {
104
+ return entry.entities.some(
105
+ (entity: any) =>
106
+ entity.type === props.entityType &&
107
+ entity.key === props.entityKey
108
+ );
109
+ }
110
+
111
+ return true;
112
+ });
113
+
114
+ setHistoryEntries(filteredHistoryEntries);
115
+ });
116
+ }, []);
117
+
118
+ const entriesToRender = historyEntries.slice(0, page * entriesPerPage);
119
+
120
+ return (
121
+ <div className={props.className || ""}>
122
+ {entriesToRender.length === 0 && (
123
+ <Alert type="warning">No history found.</Alert>
124
+ )}
125
+
126
+ {entriesToRender.length > 0 && (
127
+ <div>
128
+ <ul className="mt-8">
129
+ <li>
130
+ {entriesToRender
131
+ .slice(0, page * entriesPerPage)
132
+ .map((entry: any, index) => {
133
+ const isNotLast = entriesToRender.length !== index + 1;
134
+
135
+ return (
136
+ <div className="relative pb-8">
137
+ {isNotLast ? (
138
+ <span className="absolute left-4 top-5 -ml-px h-full w-0.5 bg-gray-200" />
139
+ ) : (
140
+ ""
141
+ )}
142
+ <div className="relative flex space-x-3">
143
+ <div>
144
+ <div className="relative">
145
+ <div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 ring-4 ring-white">
146
+ <UserCircleIcon className="h-5 w-5 text-gray-500" />
147
+ </div>
148
+ </div>
149
+ </div>
150
+ <div className="min-w-0 flex-1 py-1.5">
151
+ <div className="text-sm text-gray-500">
152
+ <Activity
153
+ entry={entry}
154
+ showTime={props.showTime}
155
+ links={links}
156
+ />
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ );
162
+ })}
163
+ </li>
164
+ </ul>
165
+
166
+ {historyEntries.length > entriesToRender.length && (
167
+ <a
168
+ href="#"
169
+ className="text-md block rounded-md border border-gray-300 bg-gray-50 py-2 pl-6 text-center font-bold text-gray-500 shadow-sm hover:bg-gray-100"
170
+ onClick={() => setPage(page + 1)}
171
+ >
172
+ Load more
173
+ </a>
174
+ )}
175
+ </div>
176
+ )}
177
+ </div>
178
+ );
179
+ }
@@ -0,0 +1,29 @@
1
+ import * as React from "react";
2
+
3
+ import { PrettyDate } from "./PrettyDate";
4
+
5
+ export function LastModified(props: any) {
6
+ const { lastModified } = props;
7
+
8
+ if (!lastModified) {
9
+ return (
10
+ <p className="inline-flex rounded-full px-2 text-xs leading-5 text-gray-500">
11
+ Last modified
12
+ <span className="pl-1 font-semibold text-gray-600">n/a</span>
13
+ </p>
14
+ );
15
+ }
16
+
17
+ return (
18
+ <p className="inline-flex rounded-full px-2 text-xs leading-5 text-gray-500">
19
+ Last modified by
20
+ <span className="pl-1 pr-1 font-semibold text-gray-600">
21
+ {lastModified.author}
22
+ </span>{" "}
23
+ on
24
+ <span className="pl-1 font-semibold text-gray-600">
25
+ <PrettyDate date={lastModified.timestamp} />
26
+ </span>
27
+ </p>
28
+ );
29
+ }
@@ -0,0 +1,87 @@
1
+ import * as React from "react";
2
+ import { Link } from "react-router-dom";
3
+
4
+ import { SearchIndex } from "@featurevisor/types";
5
+ import { useSearchIndex } from "../hooks/searchIndexHook";
6
+ import { getQueryFromString, getAttributesByQuery } from "../utils";
7
+ import { Tag } from "./Tag";
8
+ import { Alert } from "./Alert";
9
+ import { SearchInput } from "./SearchInput";
10
+ import { PageTitle } from "./PageTitle";
11
+ import { PageContent } from "./PageContent";
12
+ import { LastModified } from "./LastModified";
13
+
14
+ export function ListAttributes() {
15
+ const [q, setQ] = React.useState("");
16
+
17
+ const contextValue = useSearchIndex();
18
+ const data = contextValue.data as SearchIndex;
19
+
20
+ const query = getQueryFromString(q);
21
+ const attributes = getAttributesByQuery(query, data);
22
+
23
+ return (
24
+ <PageContent>
25
+ <PageTitle>Attributes</PageTitle>
26
+
27
+ <SearchInput value={q} onChange={(e: any) => setQ(e.target.value)} />
28
+
29
+ {attributes.length === 0 && <Alert type="warning">No results found</Alert>}
30
+
31
+ {attributes.length > 0 && (
32
+ <div>
33
+ <ul className="diving-gray-200 divide-y">
34
+ {attributes.map((attribute: any) => (
35
+ <li key={attribute.key}>
36
+ <Link to={`/attributes/${attribute.key}`} className="block hover:bg-gray-50">
37
+ <div className="px-6 py-4">
38
+ <div className="flex items-center justify-between">
39
+ <p className="text-md relative font-bold text-slate-600">
40
+ {attribute.key}{" "}
41
+ {attribute.capture && (
42
+ <span className="ml-1 rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
43
+ capture
44
+ </span>
45
+ )}
46
+ {attribute.archived && (
47
+ <span className="ml-1 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800">
48
+ archived
49
+ </span>
50
+ )}
51
+ </p>
52
+
53
+ <div className="ml-2 flex flex-shrink-0 text-xs text-gray-500">
54
+ <div>
55
+ Used in: <Tag tag={`${attribute.usedInSegments.length} segments`} />{" "}
56
+ <Tag tag={`${attribute.usedInFeatures.length} features`} />
57
+ </div>
58
+ </div>
59
+ </div>
60
+
61
+ <div className="mt-2 flex justify-between">
62
+ <div className="flex">
63
+ <p className="line-clamp-3 max-w-md items-center text-sm text-gray-500">
64
+ {attribute.description && attribute.description.trim().length > 0
65
+ ? attribute.description
66
+ : "n/a"}
67
+ </p>
68
+ </div>
69
+
70
+ <div className="items-top mt-2 flex text-xs text-gray-500 sm:mt-0">
71
+ <LastModified lastModified={attribute.lastModified} />
72
+ </div>
73
+ </div>
74
+ </div>
75
+ </Link>
76
+ </li>
77
+ ))}
78
+ </ul>
79
+
80
+ <p className="mt-6 text-center text-sm text-gray-500">
81
+ A total of <span className="font-bold">{attributes.length}</span> results found.
82
+ </p>
83
+ </div>
84
+ )}
85
+ </PageContent>
86
+ );
87
+ }
@@ -0,0 +1,95 @@
1
+ import * as React from "react";
2
+ import { Link } from "react-router-dom";
3
+
4
+ import { TagIcon } from "@heroicons/react/20/solid";
5
+
6
+ import { SearchIndex } from "@featurevisor/types";
7
+ import { useSearchIndex } from "../hooks/searchIndexHook";
8
+ import { getQueryFromString, getFeaturesByQuery } from "../utils";
9
+ import { EnvironmentDot } from "./EnvironmentDot";
10
+ import { Tag } from "./Tag";
11
+ import { Alert } from "./Alert";
12
+ import { SearchInput } from "./SearchInput";
13
+ import { PageTitle } from "./PageTitle";
14
+ import { PageContent } from "./PageContent";
15
+ import { LastModified } from "./LastModified";
16
+
17
+ export function ListFeatures() {
18
+ const [q, setQ] = React.useState("");
19
+
20
+ const contextValue = useSearchIndex();
21
+ const data = contextValue.data as SearchIndex;
22
+
23
+ const query = getQueryFromString(q);
24
+ const features = getFeaturesByQuery(query, data);
25
+
26
+ return (
27
+ <PageContent>
28
+ <PageTitle>Features</PageTitle>
29
+
30
+ <SearchInput value={q} onChange={(e: any) => setQ(e.target.value)} />
31
+
32
+ {features.length === 0 && <Alert type="warning">No results found</Alert>}
33
+
34
+ {features.length > 0 && (
35
+ <div>
36
+ <ul className="diving-gray-200 divide-y">
37
+ {features.map((feature: any) => (
38
+ <li key={feature.key}>
39
+ <Link to={`/features/${feature.key}`}>
40
+ <div className="block hover:bg-gray-50">
41
+ <div className="px-6 py-4">
42
+ <div className="flex items-center justify-between">
43
+ <p className="text-md relative font-bold text-slate-600">
44
+ <EnvironmentDot
45
+ feature={feature}
46
+ className="relative top-[0.5px] inline-block pr-2"
47
+ />{" "}
48
+ <a href="#" className="font-bold">
49
+ {feature.key}
50
+ </a>{" "}
51
+ {feature.archived && (
52
+ <span className="ml-1 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800">
53
+ archived
54
+ </span>
55
+ )}
56
+ </p>
57
+
58
+ <div className="ml-2 flex flex-shrink-0">
59
+ <div>
60
+ <TagIcon className="inline-block h-6 w-6 pr-1 text-xs text-gray-400" />
61
+ {feature.tags.map((tag: string) => (
62
+ <Tag tag={tag} key={tag} />
63
+ ))}
64
+ </div>
65
+ </div>
66
+ </div>
67
+
68
+ <div className="mt-2 flex justify-between">
69
+ <div className="flex">
70
+ <p className="line-clamp-3 max-w-md items-center pl-6 text-sm text-gray-500">
71
+ {feature.description && feature.description.trim().length > 0
72
+ ? feature.description
73
+ : "n/a"}
74
+ </p>
75
+ </div>
76
+
77
+ <div className="items-top mt-2 flex text-xs text-gray-500 sm:mt-0">
78
+ <LastModified lastModified={feature.lastModified} />
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </Link>
84
+ </li>
85
+ ))}
86
+ </ul>
87
+
88
+ <p className="mt-6 text-center text-sm text-gray-500">
89
+ A total of <span className="font-bold">{features.length}</span> results found.
90
+ </p>
91
+ </div>
92
+ )}
93
+ </PageContent>
94
+ );
95
+ }
@@ -0,0 +1,17 @@
1
+ import * as React from "react";
2
+
3
+ import { PageContent } from "./PageContent";
4
+ import { PageTitle } from "./PageTitle";
5
+ import { HistoryTimeline } from "./HistoryTimeline";
6
+
7
+ export function ListHistory() {
8
+ return (
9
+ <PageContent>
10
+ <PageTitle>History</PageTitle>
11
+
12
+ <div className="px-8">
13
+ <HistoryTimeline showTime />
14
+ </div>
15
+ </PageContent>
16
+ );
17
+ }
@@ -0,0 +1,81 @@
1
+ import * as React from "react";
2
+ import { Link } from "react-router-dom";
3
+
4
+ import { SearchIndex } from "@featurevisor/types";
5
+ import { useSearchIndex } from "../hooks/searchIndexHook";
6
+ import { getQueryFromString, getSegmentsByQuery } from "../utils";
7
+ import { Tag } from "./Tag";
8
+ import { Alert } from "./Alert";
9
+ import { SearchInput } from "./SearchInput";
10
+ import { PageTitle } from "./PageTitle";
11
+ import { PageContent } from "./PageContent";
12
+ import { LastModified } from "./LastModified";
13
+
14
+ export function ListSegments() {
15
+ const [q, setQ] = React.useState("");
16
+
17
+ const contextValue = useSearchIndex();
18
+ const data = contextValue.data as SearchIndex;
19
+
20
+ const query = getQueryFromString(q);
21
+ const segments = getSegmentsByQuery(query, data);
22
+
23
+ return (
24
+ <PageContent>
25
+ <PageTitle>Segments</PageTitle>
26
+
27
+ <SearchInput value={q} onChange={(e: any) => setQ(e.target.value)} />
28
+
29
+ {segments.length === 0 && <Alert type="warning">No results found</Alert>}
30
+
31
+ {segments.length > 0 && (
32
+ <div>
33
+ <ul className="diving-gray-200 divide-y">
34
+ {segments.map((segment: any) => (
35
+ <li key={segment.key}>
36
+ <Link to={`/segments/${segment.key}`} className="block hover:bg-gray-50">
37
+ <div className="px-6 py-4">
38
+ <div className="flex items-center justify-between">
39
+ <p className="text-md relative font-bold text-slate-600">
40
+ {segment.key}{" "}
41
+ {segment.archived && (
42
+ <span className="ml-1 rounded-full bg-red-100 px-2 py-1 text-xs font-medium text-red-800">
43
+ archived
44
+ </span>
45
+ )}
46
+ </p>
47
+
48
+ <div className="ml-2 flex flex-shrink-0 text-xs text-gray-500">
49
+ <div>
50
+ Used in: <Tag tag={`${segment.usedInFeatures.length} features`} />
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <div className="mt-2 flex justify-between">
56
+ <div className="flex">
57
+ <p className="line-clamp-3 max-w-md items-center text-sm text-gray-500">
58
+ {segment.description && segment.description.trim().length > 0
59
+ ? segment.description
60
+ : "n/a"}
61
+ </p>
62
+ </div>
63
+
64
+ <div className="items-top mt-2 flex text-xs text-gray-500 sm:mt-0">
65
+ <LastModified lastModified={segment.lastModified} />
66
+ </div>
67
+ </div>
68
+ </div>
69
+ </Link>
70
+ </li>
71
+ ))}
72
+ </ul>
73
+
74
+ <p className="mt-6 text-center text-sm text-gray-500">
75
+ A total of <span className="font-bold">{segments.length}</span> results found.
76
+ </p>
77
+ </div>
78
+ )}
79
+ </PageContent>
80
+ );
81
+ }
@@ -0,0 +1,11 @@
1
+ import * as React from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+
4
+ export function Markdown(props) {
5
+ return (
6
+ <ReactMarkdown
7
+ className={`prose prose-sm text-gray-700 ${props.className || ""}`}
8
+ children={props.children}
9
+ />
10
+ );
11
+ }
@@ -0,0 +1,9 @@
1
+ import * as React from "react";
2
+
3
+ export function PageContent(props: { children: React.ReactNode }) {
4
+ return (
5
+ <div className="m-8 mx-auto max-w-4xl rounded-lg bg-white px-0 py-0 pb-8 shadow">
6
+ {props.children}
7
+ </div>
8
+ );
9
+ }
@@ -0,0 +1,23 @@
1
+ import * as React from "react";
2
+
3
+ export function PageTitle(props: {
4
+ children: React.ReactNode;
5
+ className?: string;
6
+ }) {
7
+ return (
8
+ <h1
9
+ className={[
10
+ "mx-6",
11
+ "border-b",
12
+ "pb-4",
13
+ "pt-8",
14
+ "text-3xl",
15
+ "font-black",
16
+ "text-gray-700",
17
+ props.className ? props.className : "",
18
+ ].join(" ")}
19
+ >
20
+ {props.children}
21
+ </h1>
22
+ );
23
+ }
@@ -0,0 +1,56 @@
1
+ import * as React from "react";
2
+
3
+ function getPrettyDate(props) {
4
+ const { showTime } = props;
5
+ const date = new Date(props.date);
6
+
7
+ const day = date.getDate();
8
+ const month = new Intl.DateTimeFormat("en-US", { month: "short" }).format(
9
+ date
10
+ );
11
+ const year = date.getFullYear();
12
+
13
+ const ordinalSuffix = (day: number) => {
14
+ switch (day % 10) {
15
+ case 1:
16
+ return "st";
17
+ case 2:
18
+ return "nd";
19
+ case 3:
20
+ return "rd";
21
+ default:
22
+ return "th";
23
+ }
24
+ };
25
+
26
+ const prettyDate = `${day}${ordinalSuffix(day)} ${month} ${year}`;
27
+
28
+ if (showTime) {
29
+ const hours = date.getHours();
30
+ const minutes = date.getMinutes();
31
+ const ampm = hours >= 12 ? "PM" : "AM";
32
+ const hour12 = hours % 12 || 12;
33
+ const prettyTime = `${hour12}:${minutes
34
+ .toString()
35
+ .padStart(2, "0")}${ampm}`;
36
+
37
+ return (
38
+ <>
39
+ <span className="font-normal text-gray-500">{prettyDate}</span>{" "}
40
+ <span className="font-normal text-gray-500">at</span>{" "}
41
+ <span className="font-normal text-gray-500">{prettyTime}</span>
42
+ </>
43
+ );
44
+ }
45
+
46
+ return prettyDate;
47
+ }
48
+
49
+ interface PrettyDateProps {
50
+ date: string;
51
+ showTime?: boolean;
52
+ }
53
+
54
+ export function PrettyDate(props: PrettyDateProps) {
55
+ return <>{getPrettyDate(props)}</>;
56
+ }