[packages][m]: mv @flowershow/core package here
This commit is contained in:
parent
0b8c56bcac
commit
9ef5d52497
12
packages/core/.babelrc
Normal file
12
packages/core/.babelrc
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@nrwl/react/babel",
|
||||
{
|
||||
"runtime": "automatic",
|
||||
"useBuiltIns": "usage"
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
}
|
||||
17
packages/core/.eslintrc.json
Normal file
17
packages/core/.eslintrc.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
171
packages/core/CHANGELOG.md
Normal file
171
packages/core/CHANGELOG.md
Normal file
@ -0,0 +1,171 @@
|
||||
# @flowershow/core
|
||||
|
||||
## 0.4.13
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix url to Flowershow url in the footer.
|
||||
|
||||
## 0.4.12
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Remove next-contentlayer from peer dependencies.
|
||||
|
||||
## 0.4.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix author logo and add some spacing around it.
|
||||
- f3d3a60: Fix path to JSON file with kbar search actions.
|
||||
|
||||
## 0.4.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e0f21bd: SiteToc adjustments to display infinitely nested page directories.
|
||||
- 714d580: Fix tooltips for empty pages causing page render errors
|
||||
|
||||
## 0.4.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Minor fix of CustomLink.
|
||||
|
||||
## 0.4.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: wiki links with tooltips not showing.
|
||||
|
||||
## 0.4.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0db40da: Fix logic in CustomLink component for testing if link is internal.
|
||||
|
||||
## 0.4.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6e04357: Add BEM classes to BlogItem.
|
||||
|
||||
## 0.4.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0167dda: Minor style adjustements to Card, TableOfContents and nav logo.
|
||||
|
||||
## 0.4.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6bc7200: Make date frontmatter the default field being used, created can still be used in frontmatter
|
||||
|
||||
## 0.4.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 19842b6: Extend public API of the core package.
|
||||
|
||||
## 0.4.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e5c6e08: Update all dependencies and refactor search components
|
||||
|
||||
## 0.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e811ddc: Create mermaid components and update dependencies
|
||||
|
||||
## 0.4.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 3588a8e: Fix: broken theme switch (use Themeprovider from @flowershow/core)
|
||||
|
||||
## 0.3.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e83bc87: Remove support for nav dropdown
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0520975: Fix: remove twitter URLs displayed after embeds
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- ffd2b6c: Feature: Twitter urls turn into embeds
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 124e24e: Site-wide ToC: grouping
|
||||
|
||||
## 0.2.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e629afc: Fix: add missing dependency for disqus-react
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 0ff6c06: Add: Page comments feature with support for three providers - giscus, utterances and disqus.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 411995f: Feature: sidebar with site-wide table of contents.
|
||||
|
||||
## 0.0.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1a2fd9f: Fix: ToC not showing up on some pages, and not rendering all the headings.
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix: `SearchProvider` return type.
|
||||
|
||||
## 0.0.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d8c918c: Fix import for `useDocSearchKeyboardEvents` in Algolia search component.
|
||||
- e12b558: Fix: prop types in CustomLink
|
||||
|
||||
## 0.0.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3a005dc: Fix: remove reference to "document" from Tooltip
|
||||
|
||||
## 0.0.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Revert: 1cf9bdf
|
||||
|
||||
## 0.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 51a1b31: Fix: make Avatar keys unique
|
||||
- 1cf9bdf: Fix: import of `useDocSearchKeyboardEvents` from `@docsearch/react`;
|
||||
|
||||
## 0.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- bd4c5b0: Fix: add missing file extensions to import statements.
|
||||
3
packages/core/README.md
Normal file
3
packages/core/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# @flowershow/core
|
||||
|
||||
Core Flowershow package containing components, styles, utils etc. used by Flowershow templates.
|
||||
10
packages/core/jest.config.ts
Normal file
10
packages/core/jest.config.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: "core",
|
||||
preset: "../../jest.preset.js",
|
||||
transform: {
|
||||
"^.+\\.[tj]sx?$": "babel-jest",
|
||||
},
|
||||
moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
|
||||
coverageDirectory: "../../coverage/packages/core",
|
||||
};
|
||||
41
packages/core/package.json
Normal file
41
packages/core/package.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@flowershow/core",
|
||||
"version": "0.4.13",
|
||||
"description": "Core Flowershow components, configs and utils.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/flowershow/flowershow.git",
|
||||
"directory": "packages/core"
|
||||
},
|
||||
"author": "Rufus Pollock",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/flowershow/flowershow/issues"
|
||||
},
|
||||
"homepage": "https://github.com/flowershow/flowershow#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"dependencies": {
|
||||
"@docsearch/react": "^3.3.3",
|
||||
"@floating-ui/react-dom": "^1.3.0",
|
||||
"@floating-ui/react-dom-interactions": "^0.13.3",
|
||||
"@giscus/react": "^2.2.6",
|
||||
"@headlessui/react": "^1.7.12",
|
||||
"clsx": "^1.2.1",
|
||||
"disqus-react": "^1.1.5",
|
||||
"framer-motion": "^10.0.1",
|
||||
"kbar": "0.1.0-beta.40",
|
||||
"mdx-mermaid": "2.0.0-rc7",
|
||||
"mermaid": "10.0.1-rc.2",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^13.2.1",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
}
|
||||
73
packages/core/project.json
Normal file
73
packages/core/project.json
Normal file
@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "core",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/core/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/web:rollup",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "packages/core/dist",
|
||||
"tsConfig": "packages/core/tsconfig.lib.json",
|
||||
"project": "packages/core/package.json",
|
||||
"entryFile": "packages/core/src/index.ts",
|
||||
"format": ["esm"],
|
||||
"external": [
|
||||
"react/jsx-runtime",
|
||||
"react",
|
||||
"next/link",
|
||||
"next/head",
|
||||
"next/router",
|
||||
"next/dynamic",
|
||||
"next-contentlayer",
|
||||
"next-themes"
|
||||
],
|
||||
"generateExportsField": true,
|
||||
"rollupConfig": "@nrwl/react/plugins/bundle-rollup",
|
||||
"compiler": "babel",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "packages/core/README.md",
|
||||
"input": ".",
|
||||
"output": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["packages/core/**/*.{ts,tsx,js,jsx}"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nrwl/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "packages/core/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
}
|
||||
},
|
||||
"publish:dry": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"commands": ["npm publish --dry-run"],
|
||||
"parallel": false,
|
||||
"cwd": "packages/core/dist"
|
||||
},
|
||||
"dependsOn": ["build"]
|
||||
},
|
||||
"publish": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"commands": ["npm publish"],
|
||||
"parallel": false,
|
||||
"cwd": "packages/core/dist"
|
||||
},
|
||||
"dependsOn": ["build"]
|
||||
}
|
||||
}
|
||||
}
|
||||
31
packages/core/src/config/default.ts
Normal file
31
packages/core/src/config/default.ts
Normal file
@ -0,0 +1,31 @@
|
||||
export const defaultConfig = {
|
||||
title: "Flowershow",
|
||||
description: "",
|
||||
showEditLink: false,
|
||||
showToc: true,
|
||||
showSidebar: false,
|
||||
showLinkPreviews: true,
|
||||
author: "",
|
||||
authorLogo: "",
|
||||
domain: "",
|
||||
// Google analytics key e.g. G-XXXX
|
||||
analytics: "",
|
||||
// content source directory for markdown files
|
||||
// DO NOT CHANGE THIS VALUE
|
||||
// if you have your notes in another (external) directory,
|
||||
// /content dir should be a symlink to that directory
|
||||
content: "content",
|
||||
avatarPlaceholder: "/_flowershow/avatarplaceholder.png",
|
||||
contentExclude: [],
|
||||
contentInclude: [],
|
||||
blogDir: "blog",
|
||||
peopleDir: "people",
|
||||
// Theme
|
||||
theme: {
|
||||
default: "dark",
|
||||
toggleIcon: "/_flowershow/theme-button.svg",
|
||||
},
|
||||
navLinks: [
|
||||
// { href: '/about', name: 'About' },
|
||||
],
|
||||
};
|
||||
1
packages/core/src/config/index.ts
Normal file
1
packages/core/src/config/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { defaultConfig } from "./default";
|
||||
3
packages/core/src/index.ts
Normal file
3
packages/core/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./ui";
|
||||
export * from "./utils";
|
||||
export * from "./config";
|
||||
7
packages/core/src/types/index.d.ts
vendored
Normal file
7
packages/core/src/types/index.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag: any; // TODO
|
||||
}
|
||||
}
|
||||
24
packages/core/src/ui/Avatar/Avatar.tsx
Normal file
24
packages/core/src/ui/Avatar/Avatar.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
// TODO
|
||||
type Props = any;
|
||||
|
||||
export const Avatar: React.FC<Props> = ({ name, img, href }) => {
|
||||
const Component = href ? "a" : "div";
|
||||
return (
|
||||
<Component href={href} className="group block flex-shrink-0 mt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<img
|
||||
className="inline-block h-9 w-9 rounded-full"
|
||||
src={img}
|
||||
alt={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-primary dark:text-primary-dark">
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Avatar/index.ts
Normal file
1
packages/core/src/ui/Avatar/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Avatar } from "./Avatar";
|
||||
15
packages/core/src/ui/Base/BaseLink.tsx
Normal file
15
packages/core/src/ui/Base/BaseLink.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import Link from "next/link.js";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const BaseLink = forwardRef((props: any, ref) => {
|
||||
const { href, children, ...rest } = props;
|
||||
return (
|
||||
<Link href={href} ref={ref} {...rest}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
BaseLink.displayName = "BaseLink";
|
||||
|
||||
export { BaseLink };
|
||||
57
packages/core/src/ui/Base/CustomLink.tsx
Normal file
57
packages/core/src/ui/Base/CustomLink.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import Link from "next/link.js";
|
||||
import { Tooltip } from "../Tooltip";
|
||||
import TwitterEmbed from "./TwitterEmbed";
|
||||
|
||||
// TODO it's a mess, move twitter embeds support to remark-embed
|
||||
const TWITTER_REGEX =
|
||||
/^https?:\/\/twitter\.com\/(?:#!\/)?(\w+)\/status(es)?\/(\d+)/;
|
||||
|
||||
interface Props {
|
||||
href: string;
|
||||
data: any;
|
||||
usehook: any;
|
||||
preview: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
[x: string]: unknown;
|
||||
}
|
||||
|
||||
export const CustomLink: React.FC<Props> = ({
|
||||
data,
|
||||
usehook,
|
||||
preview,
|
||||
...props
|
||||
}) => {
|
||||
const { href } = props;
|
||||
const isInternalLink = !href.startsWith("http");
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
const isHeadingLink = href.startsWith("#");
|
||||
const isTwitterLink = TWITTER_REGEX.test(href);
|
||||
|
||||
// Use next link for pages within app and <a> for external links.
|
||||
// https://nextjs.org/learn/basics/navigate-between-pages/client-side
|
||||
if (isInternalLink) {
|
||||
if (preview && !isHeadingLink) {
|
||||
return (
|
||||
<Tooltip
|
||||
{...props}
|
||||
data={data} // TODO again, why do we pass all documents here?!
|
||||
usehook={usehook}
|
||||
render={(tooltipTriggerProps) => <Link {...tooltipTriggerProps} />}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <Link {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
if (isTwitterLink) {
|
||||
return <TwitterEmbed url={href} {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<a target="_blank" rel="noopener noreferrer" {...props}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
44
packages/core/src/ui/Base/ThemeSelector.tsx
Normal file
44
packages/core/src/ui/Base/ThemeSelector.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
defaultTheme: "dark" | "light";
|
||||
toggleIcon: string;
|
||||
}
|
||||
|
||||
export const ThemeSelector: React.FC<Props> = ({
|
||||
defaultTheme,
|
||||
toggleIcon,
|
||||
}) => {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
/** Avoid Hydration Mismatch
|
||||
* https://github.com/pacocoursey/next-themes#avoid-hydration-mismatch
|
||||
*/
|
||||
if (!mounted) return null;
|
||||
|
||||
// TODO why?
|
||||
if (!defaultTheme) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
min-w-fit transition duration-500
|
||||
${theme === "dark" ? "grayscale opacity-70" : ""}
|
||||
`}
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
<img
|
||||
src={toggleIcon}
|
||||
alt="toggle theme"
|
||||
width={24}
|
||||
height={24}
|
||||
className="max-w-24 max-h-24"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
105
packages/core/src/ui/Base/TwitterEmbed.tsx
Normal file
105
packages/core/src/ui/Base/TwitterEmbed.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
// TODO dark and light theme
|
||||
|
||||
import { useEffect, useState, useRef, RefObject } from "react";
|
||||
|
||||
const twitterWidgetJs = "https://platform.twitter.com/widgets.js";
|
||||
enum TweetState {
|
||||
LOADING,
|
||||
LOADED,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
interface TweetConfig {
|
||||
theme: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
twttr: {
|
||||
widgets: {
|
||||
createTweet: (
|
||||
id: string,
|
||||
ref: RefObject<HTMLDivElement>,
|
||||
options: TweetConfig
|
||||
) => Promise<any>; // TODO type
|
||||
load: (ref: RefObject<HTMLDivElement>) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function TwitterEmbed({ url, ...props }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [tweetState, setTweetState] = useState<TweetState>(TweetState.LOADING);
|
||||
|
||||
const tweetId = url.split("status/").pop();
|
||||
|
||||
useEffect(() => {
|
||||
const renderTweet = () => {
|
||||
window.twttr.widgets
|
||||
.createTweet(tweetId, ref.current as any, {
|
||||
theme: "dark",
|
||||
})
|
||||
.then((el) => {
|
||||
if (el) {
|
||||
setTweetState(TweetState.LOADED);
|
||||
} else {
|
||||
setTweetState(TweetState.FAILED);
|
||||
}
|
||||
});
|
||||
return window.twttr.widgets.load(ref.current as any);
|
||||
};
|
||||
|
||||
if (!window.twttr) {
|
||||
const script = document.createElement("script");
|
||||
script.src = twitterWidgetJs;
|
||||
script.async = true;
|
||||
script.onload = () => renderTweet();
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
renderTweet();
|
||||
}
|
||||
}, [tweetId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{tweetState === TweetState.LOADING && (
|
||||
<div className="relative my-4 w-full sm:max-w-xl bg-neutral-900 drop-shadow-md rounded-lg">
|
||||
<div className="absolute flex flex-col flex-wrap break-all items-center justify-center bg-slate-700/60 w-full h-full px-4 py-2 rounded-lg top-0 left-0 z-10">
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-6 absolute right-4 top-4"
|
||||
>
|
||||
<title>Twitter</title>
|
||||
<path
|
||||
fill="#1DA1F2"
|
||||
d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="text-gray-300 font-bold my-2 italic">
|
||||
{"Loading tweet..."}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 space-y-4 animate-pulse">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-2 h-10 w-10 rounded-full bg-slate-700" />
|
||||
<div className="w-1/3 h-4 bg-slate-700"></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="w-2/3 h-3 bg-slate-700"></div>
|
||||
<div className="w-2/3 h-3 bg-slate-700"></div>
|
||||
</div>
|
||||
<div className="flex space-x-4">
|
||||
<div className="w-1/4 h-3 bg-slate-700"></div>
|
||||
<div className="w-1/4 h-3 bg-slate-700"></div>
|
||||
<div className="w-1/4 h-3 bg-slate-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="twitter-tweet" ref={ref} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
packages/core/src/ui/Base/index.ts
Normal file
3
packages/core/src/ui/Base/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { BaseLink } from "./BaseLink";
|
||||
export { ThemeSelector } from "./ThemeSelector";
|
||||
export { CustomLink } from "./CustomLink";
|
||||
21
packages/core/src/ui/Blog/Avatar.jsx
Normal file
21
packages/core/src/ui/Blog/Avatar.jsx
Normal file
@ -0,0 +1,21 @@
|
||||
export function Avatar({ name, img, href }) {
|
||||
const Component = href ? "a" : "div";
|
||||
return (
|
||||
<Component href={href} className="group block flex-shrink-0 mt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<img
|
||||
className="inline-block h-9 w-9 rounded-full"
|
||||
src={img}
|
||||
alt={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-primary dark:text-primary-dark">
|
||||
{name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
40
packages/core/src/ui/Blog/BlogItem.tsx
Normal file
40
packages/core/src/ui/Blog/BlogItem.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { Card } from "../Card";
|
||||
import { formatDate } from "../../utils/formatDate";
|
||||
import { Blog } from "../types";
|
||||
|
||||
interface Props {
|
||||
blog: Blog;
|
||||
}
|
||||
|
||||
export const BlogItem: React.FC<Props> = ({ blog }) => {
|
||||
return (
|
||||
<article className="blogitem md:grid md:grid-cols-4 md:items-baseline">
|
||||
<Card className="blogitem-card md:col-span-3">
|
||||
<Card.Title className="blogitem-title" href={`${blog.urlPath}`}>
|
||||
{blog.title}
|
||||
</Card.Title>
|
||||
<Card.Eyebrow
|
||||
as="time"
|
||||
dateTime={blog.date}
|
||||
className="blogitem-date md:hidden"
|
||||
decorate
|
||||
>
|
||||
{formatDate(blog.date)}
|
||||
</Card.Eyebrow>
|
||||
{blog.description && (
|
||||
<Card.Description className="blogitem-descr">
|
||||
{blog.description}
|
||||
</Card.Description>
|
||||
)}
|
||||
<Card.Cta className="blogitem-cta">Read article</Card.Cta>
|
||||
</Card>
|
||||
<Card.Eyebrow
|
||||
as="time"
|
||||
dateTime={blog.date}
|
||||
className="blogitem-date mt-1 hidden md:block"
|
||||
>
|
||||
{formatDate(blog.date)}
|
||||
</Card.Eyebrow>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
36
packages/core/src/ui/Blog/BlogsList.tsx
Normal file
36
packages/core/src/ui/Blog/BlogsList.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import { BlogItem } from "./BlogItem";
|
||||
|
||||
const BLOGS_LOAD_COUNT = 10;
|
||||
|
||||
// TODO types
|
||||
export const BlogsList: React.FC<any> = ({ blogs }) => {
|
||||
const [blogsCount, setBlogsCount] = useState(BLOGS_LOAD_COUNT);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setBlogsCount((prevCount) => prevCount + BLOGS_LOAD_COUNT);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40">
|
||||
<div className="flex flex-col space-y-16">
|
||||
{blogs.slice(0, blogsCount).map((blog) => {
|
||||
return <BlogItem key={blog.urlPath} blog={blog} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{blogs.length > blogsCount && (
|
||||
<div className="text-center pt-20">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
type="button"
|
||||
className="inline-flex items-center rounded border border-gray-300 px-2.5 py-1.5 text-xs font-medium text-gray-200 shadow-sm hover:bg-gray-50/10 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Blog/index.ts
Normal file
1
packages/core/src/ui/Blog/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { BlogsList } from "./BlogsList";
|
||||
38
packages/core/src/ui/BlogLayout/BlogLayout.tsx
Normal file
38
packages/core/src/ui/BlogLayout/BlogLayout.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
/* eslint import/no-default-export: off */
|
||||
import { formatDate } from "../../utils/formatDate";
|
||||
import { Avatar } from "../Avatar";
|
||||
|
||||
// TODO
|
||||
type Props = any;
|
||||
|
||||
export const BlogLayout: React.FC<Props> = ({ children, ...frontMatter }) => {
|
||||
const { title, date, authors } = frontMatter;
|
||||
|
||||
return (
|
||||
<article className="docs prose prose-a:text-primary dark:prose-a:text-primary-dark prose-strong:text-primary dark:prose-strong:text-primary-dark prose-code:text-primary dark:prose-code:text-primary-dark prose-headings:text-primary dark:prose-headings:text-primary-dark prose text-primary dark:text-primary-dark prose-headings:font-headings dark:prose-invert prose-a:break-words mx-auto p-6">
|
||||
<header>
|
||||
<div className="mb-4 flex-col items-center">
|
||||
{title && <h1 className="flex justify-center">{title}</h1>}
|
||||
{date && (
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-500 flex justify-center">
|
||||
<time dateTime={date}>{formatDate(date)}</time>
|
||||
</p>
|
||||
)}
|
||||
{authors && (
|
||||
<div className="flex flex-wrap not-prose items-center space-x-6 space-y-3 justify-center">
|
||||
{authors.map(({ name, avatar, urlPath }) => (
|
||||
<Avatar
|
||||
key={urlPath || name}
|
||||
name={name}
|
||||
img={avatar}
|
||||
href={urlPath ? `/${urlPath}` : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<section>{children}</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/BlogLayout/index.ts
Normal file
1
packages/core/src/ui/BlogLayout/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { BlogLayout } from "./BlogLayout";
|
||||
170
packages/core/src/ui/Card/Card.tsx
Normal file
170
packages/core/src/ui/Card/Card.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
// import Link from 'next/link'
|
||||
import clsx from "clsx";
|
||||
import { ChevronRightIcon } from "../Icons";
|
||||
|
||||
interface CardProps extends React.PropsWithChildren {
|
||||
as?: React.ElementType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CardLinkProps extends React.PropsWithChildren {
|
||||
href?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CardTitleProps extends React.PropsWithChildren {
|
||||
as?: React.ElementType;
|
||||
href?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CardDescriptionProps extends React.PropsWithChildren {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CardCtaProps extends React.PropsWithChildren {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface CardEyebrowProps extends React.PropsWithChildren {
|
||||
as?: React.ElementType;
|
||||
decorate?: boolean;
|
||||
className?: string;
|
||||
[x: string]: unknown;
|
||||
}
|
||||
|
||||
type Card = React.FC<CardProps> & { Link: React.FC<CardLinkProps> } & {
|
||||
Title: React.FC<CardTitleProps>;
|
||||
} & { Description: React.FC<CardDescriptionProps> } & {
|
||||
Cta: React.FC<CardCtaProps>;
|
||||
} & { Eyebrow: React.FC<CardEyebrowProps> };
|
||||
|
||||
export const Card: Card = ({ children, as: Component = "div", className }) => {
|
||||
return (
|
||||
<Component
|
||||
className={clsx(className, "group relative flex flex-col items-start")}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
Card.Link = function CardLink({ children, href, className, ...props }) {
|
||||
// <Link {...props}>
|
||||
// <span className="absolute -inset-y-6 -inset-x-4 z-20 sm:-inset-x-6 sm:rounded-2xl" />
|
||||
// <span className="relative z-10">{children}</span>
|
||||
// </Link>
|
||||
return (
|
||||
<>
|
||||
<div className="absolute -inset-y-6 -inset-x-4 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-slate-800/75 sm:-inset-x-6 sm:rounded-2xl" />
|
||||
<a href={href} className={className} {...props}>
|
||||
<span className="absolute -inset-y-6 -inset-x-4 z-20 sm:-inset-x-6 sm:rounded-2xl" />
|
||||
<span className="relative z-10">{children}</span>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Card.Title = function CardTitle({
|
||||
as: Component = "h2",
|
||||
href,
|
||||
children,
|
||||
className,
|
||||
}) {
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
className,
|
||||
"text-base font-semibold tracking-tight text-zinc-800 dark:text-zinc-100"
|
||||
)}
|
||||
>
|
||||
{href ? <Card.Link href={href}>{children}</Card.Link> : children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
Card.Description = function CardDescription({ children, className }) {
|
||||
return (
|
||||
<p
|
||||
className={clsx(
|
||||
className,
|
||||
"relative z-10 mt-2 text-sm text-zinc-600 dark:text-zinc-400"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
Card.Cta = function CardCta({ children, className }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
className,
|
||||
"relative z-10 mt-4 flex items-center text-sm font-medium text-secondary dark:text-secondary-dark"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-1 h-4 w-4 stroke-current" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* Card.Avatar = function CardAvatar({ name, src, href }) {
|
||||
* return (
|
||||
* <a href={href} className="group block flex-shrink-0 mt-2">
|
||||
* <div className="flex items-center">
|
||||
* <div>
|
||||
* {src ? (
|
||||
* <img
|
||||
* className="inline-block h-9 w-9 rounded-full"
|
||||
* src={src}
|
||||
* alt={name}
|
||||
* />
|
||||
* ) : (
|
||||
* <span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-gray-500">
|
||||
* <span className="text-xs font-medium leading-none text-white">
|
||||
* {initialsFromName(name)}
|
||||
* </span>
|
||||
* </span>
|
||||
* )}
|
||||
* </div>
|
||||
* <div className="ml-3">
|
||||
* <p className="text-sm font-medium text-gray-700 group-hover:text-gray-900">
|
||||
* {name}
|
||||
* </p>
|
||||
* </div>
|
||||
* </div>
|
||||
* </a>
|
||||
* );
|
||||
* }; */
|
||||
|
||||
Card.Eyebrow = function CardEyebrow({
|
||||
as: Component = "p",
|
||||
decorate = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
className,
|
||||
"relative z-10 order-first mb-3 flex items-center text-sm text-zinc-400 dark:text-zinc-500",
|
||||
decorate && "pl-3.5"
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{decorate && (
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 flex items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Card/index.ts
Normal file
1
packages/core/src/ui/Card/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Card } from "./Card";
|
||||
25
packages/core/src/ui/Comments/Disqus.tsx
Normal file
25
packages/core/src/ui/Comments/Disqus.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { DiscussionEmbed } from "disqus-react";
|
||||
|
||||
export interface DisqusConfig {
|
||||
provider: "disqus";
|
||||
pages?: Array<string>;
|
||||
config: {
|
||||
shortname: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type DisqusProps = DisqusConfig["config"] & {
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export const Disqus: React.FC<DisqusProps> = ({ shortname, slug }) => {
|
||||
return (
|
||||
<DiscussionEmbed
|
||||
shortname={shortname}
|
||||
config={{
|
||||
url: window?.location?.href,
|
||||
identifier: slug,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
53
packages/core/src/ui/Comments/Giscus.tsx
Normal file
53
packages/core/src/ui/Comments/Giscus.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import Giscus, { BooleanString, Mapping, Repo } from "@giscus/react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
export interface GiscusConfig {
|
||||
provider: "giscus";
|
||||
pages?: Array<string>;
|
||||
config: {
|
||||
theme?: string;
|
||||
mapping: Mapping;
|
||||
repo: Repo;
|
||||
repositoryId: string;
|
||||
category: string;
|
||||
categoryId: string;
|
||||
reactions: BooleanString;
|
||||
metadata: BooleanString;
|
||||
inputPosition?: string;
|
||||
lang?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type GiscusProps = GiscusConfig["config"];
|
||||
|
||||
export const GiscusReactComponent: React.FC<GiscusProps> = ({
|
||||
repo,
|
||||
repositoryId,
|
||||
category,
|
||||
categoryId,
|
||||
reactions = "0",
|
||||
metadata = "0",
|
||||
mapping = "pathname",
|
||||
theme = "light",
|
||||
}) => {
|
||||
const { theme: nextTheme, resolvedTheme } = useTheme();
|
||||
const commentsTheme =
|
||||
nextTheme === "dark" || resolvedTheme === "dark"
|
||||
? "transparent_dark"
|
||||
: theme;
|
||||
|
||||
return (
|
||||
<Giscus
|
||||
repo={repo}
|
||||
repoId={repositoryId}
|
||||
category={category}
|
||||
categoryId={categoryId}
|
||||
mapping={mapping}
|
||||
inputPosition="top"
|
||||
reactionsEnabled={reactions}
|
||||
emitMetadata={metadata}
|
||||
// TODO: remove transparent_dark after theme toggle fix
|
||||
theme={nextTheme ? commentsTheme : "transparent_dark"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
59
packages/core/src/ui/Comments/Utterances.tsx
Normal file
59
packages/core/src/ui/Comments/Utterances.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
export interface UtterancesConfig {
|
||||
provider: "utterances";
|
||||
pages?: Array<string>;
|
||||
config: {
|
||||
theme?: string;
|
||||
repo: string;
|
||||
label: string;
|
||||
issueTerm: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type UtterancesProps = UtterancesConfig["config"];
|
||||
|
||||
export const Utterances: React.FC<UtterancesProps> = ({
|
||||
repo,
|
||||
label = "comments",
|
||||
issueTerm = "pathname",
|
||||
theme = "github-light",
|
||||
}) => {
|
||||
const { theme: nextTheme, resolvedTheme } = useTheme();
|
||||
// TODO: remove preferred-color-scheme after theme toggle fix
|
||||
const commentsTheme = nextTheme
|
||||
? nextTheme === "dark" || resolvedTheme === "dark"
|
||||
? "github-dark"
|
||||
: theme
|
||||
: "preferred-color-scheme";
|
||||
|
||||
const COMMENTS_ID = "comments-container";
|
||||
|
||||
const LoadComments = useCallback(() => {
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://utteranc.es/client.js";
|
||||
script.setAttribute("repo", repo);
|
||||
script.setAttribute("issue-term", issueTerm);
|
||||
script.setAttribute("label", label);
|
||||
script.setAttribute("theme", commentsTheme);
|
||||
script.setAttribute("crossorigin", "anonymous");
|
||||
script.async = true;
|
||||
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.appendChild(script);
|
||||
|
||||
return () => {
|
||||
const comments = document.getElementById(COMMENTS_ID);
|
||||
if (comments) comments.innerHTML = "";
|
||||
};
|
||||
}, [commentsTheme, issueTerm]);
|
||||
|
||||
// Reload on theme change
|
||||
useEffect(() => {
|
||||
LoadComments();
|
||||
}, [LoadComments]);
|
||||
|
||||
// Added `relative` to fix a weird bug with `utterances-frame` position
|
||||
return <div className="utterances-frame relative" id={COMMENTS_ID} />;
|
||||
};
|
||||
53
packages/core/src/ui/Comments/index.tsx
Normal file
53
packages/core/src/ui/Comments/index.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import dynamic from "next/dynamic.js";
|
||||
import { GiscusReactComponent, GiscusConfig, GiscusProps } from "./Giscus";
|
||||
import { Utterances, UtterancesConfig, UtterancesProps } from "./Utterances";
|
||||
import { Disqus, DisqusConfig, DisqusProps } from "./Disqus";
|
||||
|
||||
export type CommentsConfig = GiscusConfig | UtterancesConfig | DisqusConfig;
|
||||
|
||||
export interface CommentsProps {
|
||||
commentsConfig: CommentsConfig;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
const GiscusComponent = dynamic<GiscusProps>(
|
||||
() => {
|
||||
return import("./Giscus").then((mod) => mod.GiscusReactComponent);
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const UtterancesComponent = dynamic<UtterancesProps>(
|
||||
() => {
|
||||
return import("./Utterances").then((mod) => mod.Utterances);
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const DisqusComponent = dynamic<DisqusProps>(
|
||||
() => {
|
||||
return import("./Disqus").then((mod) => mod.Disqus);
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export const Comments = ({ commentsConfig, slug }: CommentsProps) => {
|
||||
switch (commentsConfig.provider) {
|
||||
case "giscus":
|
||||
return <GiscusComponent {...commentsConfig.config} />;
|
||||
case "utterances":
|
||||
return <UtterancesComponent {...commentsConfig.config} />;
|
||||
case "disqus":
|
||||
return <DisqusComponent slug={slug} {...commentsConfig.config} />;
|
||||
}
|
||||
};
|
||||
|
||||
export { GiscusReactComponent, Utterances, Disqus };
|
||||
export type {
|
||||
GiscusConfig,
|
||||
GiscusProps,
|
||||
UtterancesConfig,
|
||||
UtterancesProps,
|
||||
DisqusConfig,
|
||||
DisqusProps,
|
||||
};
|
||||
22
packages/core/src/ui/DocsLayout/Docs.tsx
Normal file
22
packages/core/src/ui/DocsLayout/Docs.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
/* eslint import/no-default-export: off */
|
||||
import { formatDate } from "../../utils/formatDate";
|
||||
|
||||
// TODO types
|
||||
export const DocsLayout: React.FC<any> = ({ children, ...frontMatter }) => {
|
||||
const { title, created } = frontMatter;
|
||||
return (
|
||||
<article className="docs prose prose-a:text-primary dark:prose-a:text-primary-dark prose-strong:text-primary dark:prose-strong:text-primary-dark prose-code:text-primary dark:prose-code:text-primary-dark prose-headings:text-primary dark:prose-headings:text-primary-dark prose text-primary dark:text-primary-dark dark:prose-invert prose-headings:font-headings prose-a:break-words mx-auto">
|
||||
<header>
|
||||
<div className="mb-6">
|
||||
{created && (
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-500">
|
||||
<time dateTime={created}>{formatDate(created)}</time>
|
||||
</p>
|
||||
)}
|
||||
{title && <h1>{title}</h1>}
|
||||
</div>
|
||||
</header>
|
||||
<section>{children}</section>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/DocsLayout/index.ts
Normal file
1
packages/core/src/ui/DocsLayout/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { DocsLayout } from "./Docs";
|
||||
12
packages/core/src/ui/Icons/ChevronRightIcon.tsx
Normal file
12
packages/core/src/ui/Icons/ChevronRightIcon.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
export const ChevronRightIcon: React.FC<{ [x: string]: unknown }> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" {...props}>
|
||||
<path
|
||||
d="M6.75 5.75 9.25 8l-2.5 2.25"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
14
packages/core/src/ui/Icons/CloseIcon.tsx
Normal file
14
packages/core/src/ui/Icons/CloseIcon.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
export const CloseIcon: React.FC<{ [x: string]: unknown }> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M5 5l14 14M19 5l-14 14" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
14
packages/core/src/ui/Icons/DiscordIcon.tsx
Normal file
14
packages/core/src/ui/Icons/DiscordIcon.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
export const DiscordIcon: React.FC<{ [x: string]: unknown }> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
{...props}
|
||||
>
|
||||
<path d="M13.545 2.907a13.227 13.227 0 0 0-3.257-1.011.05.05 0 0 0-.052.025c-.141.25-.297.577-.406.833a12.19 12.19 0 0 0-3.658 0 8.258 8.258 0 0 0-.412-.833.051.051 0 0 0-.052-.025c-1.125.194-2.22.534-3.257 1.011a.041.041 0 0 0-.021.018C.356 6.024-.213 9.047.066 12.032c.001.014.01.028.021.037a13.276 13.276 0 0 0 3.995 2.02.05.05 0 0 0 .056-.019c.308-.42.582-.863.818-1.329a.05.05 0 0 0-.01-.059.051.051 0 0 0-.018-.011 8.875 8.875 0 0 1-1.248-.595.05.05 0 0 1-.02-.066.051.051 0 0 1 .015-.019c.084-.063.168-.129.248-.195a.05.05 0 0 1 .051-.007c2.619 1.196 5.454 1.196 8.041 0a.052.052 0 0 1 .053.007c.08.066.164.132.248.195a.051.051 0 0 1-.004.085 8.254 8.254 0 0 1-1.249.594.05.05 0 0 0-.03.03.052.052 0 0 0 .003.041c.24.465.515.909.817 1.329a.05.05 0 0 0 .056.019 13.235 13.235 0 0 0 4.001-2.02.049.049 0 0 0 .021-.037c.334-3.451-.559-6.449-2.366-9.106a.034.034 0 0 0-.02-.019Zm-8.198 7.307c-.789 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.45.73 1.438 1.613 0 .888-.637 1.612-1.438 1.612Zm5.316 0c-.788 0-1.438-.724-1.438-1.612 0-.889.637-1.613 1.438-1.613.807 0 1.451.73 1.438 1.613 0 .888-.631 1.612-1.438 1.612Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
7
packages/core/src/ui/Icons/GitHubIcon.tsx
Normal file
7
packages/core/src/ui/Icons/GitHubIcon.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export const GitHubIcon: React.FC<{ [x: string]: unknown }> = (props) => {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16" {...props}>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
14
packages/core/src/ui/Icons/MenuIcon.tsx
Normal file
14
packages/core/src/ui/Icons/MenuIcon.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
export const MenuIcon: React.FC<{ [x: string]: unknown }> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
7
packages/core/src/ui/Icons/SearchIcon.tsx
Normal file
7
packages/core/src/ui/Icons/SearchIcon.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export const SearchIcon: React.FC<{ [x: string]: unknown }> = (props) => {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 20 20" {...props}>
|
||||
<path d="M16.293 17.707a1 1 0 0 0 1.414-1.414l-1.414 1.414ZM9 14a5 5 0 0 1-5-5H2a7 7 0 0 0 7 7v-2ZM4 9a5 5 0 0 1 5-5V2a7 7 0 0 0-7 7h2Zm5-5a5 5 0 0 1 5 5h2a7 7 0 0 0-7-7v2Zm8.707 12.293-3.757-3.757-1.414 1.414 3.757 3.757 1.414-1.414ZM14 9a4.98 4.98 0 0 1-1.464 3.536l1.414 1.414A6.98 6.98 0 0 0 16 9h-2Zm-1.464 3.536A4.98 4.98 0 0 1 9 14v2a6.98 6.98 0 0 0 4.95-2.05l-1.414-1.414Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
6
packages/core/src/ui/Icons/index.tsx
Normal file
6
packages/core/src/ui/Icons/index.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
export { GitHubIcon } from "./GitHubIcon";
|
||||
export { DiscordIcon } from "./DiscordIcon";
|
||||
export { MenuIcon } from "./MenuIcon";
|
||||
export { CloseIcon } from "./CloseIcon";
|
||||
export { SearchIcon } from "./SearchIcon";
|
||||
export { ChevronRightIcon } from "./ChevronRightIcon";
|
||||
30
packages/core/src/ui/Layout/EditThisPage.tsx
Normal file
30
packages/core/src/ui/Layout/EditThisPage.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
export const EditThisPage = ({ url }: { url: string }) => {
|
||||
return (
|
||||
<div className="mb-10 prose dark:prose-invert p-6 mx-auto">
|
||||
<a
|
||||
className="flex no-underline font-semibold justify-center"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Edit this page
|
||||
<span className="mx-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
packages/core/src/ui/Layout/Footer.tsx
Normal file
61
packages/core/src/ui/Layout/Footer.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import Link from "next/link.js";
|
||||
|
||||
import { AuthorConfig, NavLink } from "../types";
|
||||
|
||||
interface Props {
|
||||
links: Array<NavLink>;
|
||||
author: AuthorConfig;
|
||||
}
|
||||
|
||||
export const Footer: React.FC<Props> = ({ links, author }) => {
|
||||
return (
|
||||
<footer className="bg-background dark:bg-background-dark prose dark:prose-invert max-w-none flex flex-col items-center justify-center w-full h-auto pt-10 pb-20">
|
||||
<div className="flex w-full flex-wrap justify-center">
|
||||
{links.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="inline-flex items-center mx-4 px-1 pt-1 font-regular hover:text-slate-300 no-underline"
|
||||
>
|
||||
{/* TODO aria-current={item.current ? "page" : undefined} */}
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<p className="flex items-center justify-center">
|
||||
Created by
|
||||
<a
|
||||
href={author.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center no-underline"
|
||||
>
|
||||
{author.logo && (
|
||||
<img
|
||||
src={author.logo}
|
||||
alt={author.name}
|
||||
className="my-0 mx-1 h-6 block"
|
||||
/>
|
||||
)}
|
||||
{author.name}
|
||||
</a>
|
||||
</p>
|
||||
<p className="flex items-center justify-center">
|
||||
Made with
|
||||
<a
|
||||
href="https://flowershow.app/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center no-underline"
|
||||
>
|
||||
<img
|
||||
src="https://flowershow.app/images/logo.svg"
|
||||
alt="Flowershow"
|
||||
className="my-0 mx-1 h-6 block"
|
||||
/>
|
||||
Flowershow
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
147
packages/core/src/ui/Layout/Layout.tsx
Normal file
147
packages/core/src/ui/Layout/Layout.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Head from "next/head.js";
|
||||
import { NextRouter, useRouter } from "next/router.js";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useTableOfContents } from "./useTableOfContents";
|
||||
import { collectHeadings } from "../../utils";
|
||||
|
||||
import { Nav } from "../Nav";
|
||||
import { SiteToc, NavItem, NavGroup } from "../SiteToc";
|
||||
import { Comments, CommentsConfig } from "../Comments";
|
||||
import { Footer } from "./Footer";
|
||||
import { EditThisPage } from "./EditThisPage";
|
||||
import { TableOfContents, TocSection } from "./TableOfContents";
|
||||
import { NavConfig, ThemeConfig } from "../Nav";
|
||||
import { AuthorConfig } from "../types";
|
||||
|
||||
interface Props extends React.PropsWithChildren {
|
||||
showComments: boolean;
|
||||
showEditLink: boolean;
|
||||
showSidebar: boolean;
|
||||
showToc: boolean;
|
||||
nav: NavConfig;
|
||||
author: AuthorConfig;
|
||||
theme: ThemeConfig;
|
||||
urlPath: string;
|
||||
commentsConfig: CommentsConfig;
|
||||
siteMap: Array<NavItem | NavGroup>;
|
||||
editUrl?: string;
|
||||
}
|
||||
|
||||
export const Layout: React.FC<Props> = ({
|
||||
children,
|
||||
nav,
|
||||
author,
|
||||
theme,
|
||||
showEditLink,
|
||||
showToc,
|
||||
showSidebar,
|
||||
urlPath,
|
||||
showComments,
|
||||
commentsConfig,
|
||||
editUrl,
|
||||
siteMap,
|
||||
}) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [tableOfContents, setTableOfContents] = useState<TocSection[]>([]);
|
||||
const currentSection = useTableOfContents(tableOfContents);
|
||||
const router: NextRouter = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!showToc) return;
|
||||
const headingNodes: NodeListOf<HTMLHeadingElement> =
|
||||
document.querySelectorAll("h1,h2,h3");
|
||||
const toc = collectHeadings(headingNodes);
|
||||
setTableOfContents(toc ?? []);
|
||||
}, [router.asPath, showToc]); // update table of contents on route change with next/link
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setIsScrolled(window.scrollY > 0);
|
||||
}
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💐</text></svg>"
|
||||
/>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
|
||||
</Head>
|
||||
<div className="min-h-screen bg-background dark:bg-background-dark">
|
||||
{/* NAVBAR */}
|
||||
<div
|
||||
className={clsx(
|
||||
"sticky top-0 z-50 w-full",
|
||||
isScrolled
|
||||
? "dark:bg-background-dark/95 bg-background/95 backdrop-blur [@supports(backdrop-filter:blur(0))]:dark:bg-background-dark/75"
|
||||
: "dark:bg-background-dark bg-background"
|
||||
)}
|
||||
>
|
||||
<div className="max-w-8xl mx-auto p-4 md:px-8">
|
||||
<Nav
|
||||
title={nav.title}
|
||||
logo={nav.logo}
|
||||
links={nav.links}
|
||||
search={nav.search}
|
||||
social={nav.social}
|
||||
defaultTheme={theme.defaultTheme}
|
||||
themeToggleIcon={theme.themeToggleIcon}
|
||||
>
|
||||
{showSidebar && <SiteToc currentPath={urlPath} nav={siteMap} />}
|
||||
</Nav>
|
||||
</div>
|
||||
</div>
|
||||
{/* wrapper for sidebar, main content and ToC */}
|
||||
<div
|
||||
className={clsx(
|
||||
"max-w-8xl mx-auto px-4 md:px-8",
|
||||
showSidebar && "lg:ml-[18rem]",
|
||||
showToc && "xl:mr-[18rem]"
|
||||
)}
|
||||
>
|
||||
{/* SIDEBAR */}
|
||||
{showSidebar && (
|
||||
<div className="hidden lg:block fixed z-20 w-[18rem] top-[4.6rem] right-auto bottom-0 left-[max(0px,calc(50%-44rem))] pt-8 pl-8 overflow-y-auto">
|
||||
<SiteToc currentPath={urlPath} nav={siteMap} />
|
||||
</div>
|
||||
)}
|
||||
{/* MAIN CONTENT & FOOTER */}
|
||||
<main className="mx-auto pt-8">
|
||||
{children}
|
||||
{/* EDIT THIS PAGE LINK */}
|
||||
{showEditLink && editUrl && <EditThisPage url={editUrl} />}
|
||||
{/* PAGE COMMENTS */}
|
||||
{showComments && (
|
||||
<div
|
||||
className="prose mx-auto pt-6 pb-6 text-center text-gray-700 dark:text-gray-300"
|
||||
id="comment"
|
||||
>
|
||||
{<Comments commentsConfig={commentsConfig} slug={urlPath} />}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
<Footer links={nav.links} author={author} />
|
||||
{/** TABLE OF CONTENTS */}
|
||||
{showToc && tableOfContents.length > 0 && (
|
||||
<div className="hidden xl:block fixed z-20 w-[18rem] top-[4.6rem] bottom-0 right-[max(0px,calc(50%-44rem))] left-auto pt-8 pr-8 overflow-y-auto">
|
||||
<TableOfContents
|
||||
tableOfContents={tableOfContents}
|
||||
currentSection={currentSection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
90
packages/core/src/ui/Layout/TableOfContents.tsx
Normal file
90
packages/core/src/ui/Layout/TableOfContents.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import Link from "next/link.js";
|
||||
|
||||
export interface TocSection {
|
||||
id: string;
|
||||
title: string;
|
||||
level: string;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tableOfContents: TocSection[];
|
||||
currentSection: string;
|
||||
}
|
||||
|
||||
export const TableOfContents: React.FC<Props> = ({
|
||||
tableOfContents,
|
||||
currentSection,
|
||||
}) => {
|
||||
function isActiveSection(section) {
|
||||
if (section.id === currentSection) {
|
||||
return true;
|
||||
}
|
||||
if (!section.children) {
|
||||
return false;
|
||||
}
|
||||
return section.children.findIndex(isActiveSection) > -1;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-labelledby="on-this-page-title">
|
||||
<h2 className="font-display text-md font-medium text-slate-900 dark:text-white">
|
||||
On this page
|
||||
</h2>
|
||||
<ol className="mt-4 space-y-3 text-sm">
|
||||
{tableOfContents.map((section) => (
|
||||
<li key={section.id}>
|
||||
<h3>
|
||||
<Link
|
||||
href={`#${section.id}`}
|
||||
className={
|
||||
isActiveSection(section)
|
||||
? "text-secondary dark:text-secondary-dark"
|
||||
: "font-normal text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300"
|
||||
}
|
||||
>
|
||||
{section.title}
|
||||
</Link>
|
||||
</h3>
|
||||
{section.children && section.children.length > 0 && (
|
||||
<ol className="mt-2 space-y-3 pl-5 text-slate-500 dark:text-slate-400">
|
||||
{section.children.map((subSection) => (
|
||||
<li key={subSection.id}>
|
||||
<Link
|
||||
href={`#${subSection.id}`}
|
||||
className={
|
||||
isActiveSection(subSection)
|
||||
? "text-secondary dark:text-secondary-dark"
|
||||
: "hover:text-slate-600 dark:hover:text-slate-300"
|
||||
}
|
||||
>
|
||||
{subSection.title}
|
||||
</Link>
|
||||
{subSection.children && subSection.children.length > 0 && (
|
||||
<ol className="mt-2 space-y-3 pl-5 text-slate-500 dark:text-slate-400">
|
||||
{subSection.children.map((thirdSection) => (
|
||||
<li key={thirdSection.id}>
|
||||
<Link
|
||||
href={`#${thirdSection.id}`}
|
||||
className={
|
||||
isActiveSection(thirdSection)
|
||||
? "text-secondary dark:text-secondary-dark"
|
||||
: "hover:text-slate-600 dark:hover:text-slate-300"
|
||||
}
|
||||
>
|
||||
{thirdSection.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
5
packages/core/src/ui/Layout/index.ts
Normal file
5
packages/core/src/ui/Layout/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { EditThisPage } from "./EditThisPage";
|
||||
export { Layout } from "./Layout";
|
||||
export { useTableOfContents } from "./useTableOfContents";
|
||||
export { TableOfContents, TocSection } from "./TableOfContents";
|
||||
export { Footer } from "./Footer";
|
||||
51
packages/core/src/ui/Layout/useTableOfContents.ts
Normal file
51
packages/core/src/ui/Layout/useTableOfContents.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
// TODO types
|
||||
export const useTableOfContents = (tableOfContents) => {
|
||||
const [currentSection, setCurrentSection] = useState(tableOfContents[0]?.id);
|
||||
|
||||
const getHeadings = useCallback((toc) => {
|
||||
return toc
|
||||
.flatMap((node) => [
|
||||
node.id,
|
||||
...node.children.flatMap((child) => [
|
||||
child.id,
|
||||
...child.children.map((subChild) => subChild.id),
|
||||
]),
|
||||
])
|
||||
.map((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
|
||||
const style = window.getComputedStyle(el);
|
||||
const scrollMt = parseFloat(style.scrollMarginTop);
|
||||
|
||||
const top = window.scrollY + el.getBoundingClientRect().top - scrollMt;
|
||||
return { id, top };
|
||||
})
|
||||
.filter((el) => !!el);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tableOfContents.length === 0) return;
|
||||
const headings = getHeadings(tableOfContents);
|
||||
function onScroll() {
|
||||
const top = window.scrollY + 4.5;
|
||||
let current = headings[0].id;
|
||||
headings.forEach((heading) => {
|
||||
if (top >= heading.top) {
|
||||
current = heading.id;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
setCurrentSection(current);
|
||||
}
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [getHeadings, tableOfContents]);
|
||||
|
||||
return currentSection;
|
||||
};
|
||||
25
packages/core/src/ui/Mermaid/Mermaid.tsx
Normal file
25
packages/core/src/ui/Mermaid/Mermaid.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import dynamic from "next/dynamic.js";
|
||||
// import { useTheme } from "next-themes";
|
||||
|
||||
import type { MermaidProps } from "mdx-mermaid/lib/Mermaid";
|
||||
// import type { Config } from "mdx-mermaid/lib/config.model";
|
||||
|
||||
const MdxMermaid = dynamic(
|
||||
() => import("mdx-mermaid/lib/Mermaid").then((res) => res.Mermaid),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export const Mermaid: React.FC<MermaidProps> = ({ ...props }) => {
|
||||
// TODO: add light and dark theme configs
|
||||
// currently Mermaid component doesn't render if configs are passed as props.
|
||||
|
||||
// const { theme } = useTheme()
|
||||
// const config: Config = {
|
||||
// mermaid: {
|
||||
// fontFamily: "inherit",
|
||||
// theme: theme
|
||||
// }
|
||||
// }
|
||||
|
||||
return <MdxMermaid {...props} />;
|
||||
};
|
||||
1
packages/core/src/ui/Mermaid/index.ts
Normal file
1
packages/core/src/ui/Mermaid/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Mermaid } from "./Mermaid";
|
||||
86
packages/core/src/ui/Nav/Nav.tsx
Normal file
86
packages/core/src/ui/Nav/Nav.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ThemeSelector } from "../Base";
|
||||
import { SearchContext, SearchField } from "../Search";
|
||||
import { NavMobile } from "./NavMobile";
|
||||
import { NavItem } from "./NavItem";
|
||||
import { NavTitle } from "./NavTitle";
|
||||
import { NavSocial } from "./NavSocial";
|
||||
import { NavLink, SocialLink, SearchProviderConfig } from "../types";
|
||||
|
||||
export interface ThemeConfig {
|
||||
defaultTheme: "dark" | "light";
|
||||
themeToggleIcon: string;
|
||||
}
|
||||
|
||||
export interface NavConfig {
|
||||
title: string;
|
||||
logo?: string;
|
||||
version?: string;
|
||||
links: Array<NavLink>;
|
||||
search?: SearchProviderConfig;
|
||||
social?: Array<SocialLink>;
|
||||
}
|
||||
|
||||
interface Props extends NavConfig, ThemeConfig, React.PropsWithChildren {}
|
||||
|
||||
export const Nav: React.FC<Props> = ({
|
||||
children,
|
||||
title,
|
||||
logo,
|
||||
version,
|
||||
links,
|
||||
search,
|
||||
social,
|
||||
defaultTheme,
|
||||
themeToggleIcon,
|
||||
}) => {
|
||||
const [modifierKey, setModifierKey] = useState<string>();
|
||||
const [Search, setSearch] = useState<any>(); // TODO types
|
||||
|
||||
useEffect(() => {
|
||||
const isMac = /(Mac|iPhone|iPod|iPad)/i.test(navigator.userAgent);
|
||||
setModifierKey(isMac ? "⌘" : "Ctrl ");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
setSearch(SearchContext(search.provider));
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<nav className="flex justify-between">
|
||||
{/* Mobile navigation */}
|
||||
<div className="mr-2 sm:mr-4 flex lg:hidden">
|
||||
<NavMobile links={links}>{children}</NavMobile>
|
||||
</div>
|
||||
{/* Non-mobile navigation */}
|
||||
<div className="flex flex-none items-center">
|
||||
<NavTitle title={title} logo={logo} version={version} />
|
||||
{links && (
|
||||
<div className="hidden lg:flex ml-8 mr-6 sm:mr-8 md:mr-0">
|
||||
{links.map((link) => (
|
||||
<NavItem link={link} key={link.name} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Search field and social links */}
|
||||
<div className="relative flex items-center basis-auto justify-end gap-6 xl:gap-8 md:shrink w-full">
|
||||
{Search && (
|
||||
<Search>
|
||||
{({ query }: any) => (
|
||||
<SearchField modifierKey={modifierKey} onOpen={query?.toggle} />
|
||||
)}
|
||||
</Search>
|
||||
)}
|
||||
<ThemeSelector
|
||||
defaultTheme={defaultTheme}
|
||||
toggleIcon={themeToggleIcon}
|
||||
/>
|
||||
{social && <NavSocial links={social} />}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
21
packages/core/src/ui/Nav/NavItem.tsx
Normal file
21
packages/core/src/ui/Nav/NavItem.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { Menu } from "@headlessui/react";
|
||||
import Link from "next/link.js";
|
||||
import { BaseLink } from "../Base";
|
||||
import { NavLink } from "../types";
|
||||
|
||||
interface Props {
|
||||
link: NavLink;
|
||||
}
|
||||
|
||||
export const NavItem: React.FC<Props> = ({ link }) => {
|
||||
return (
|
||||
<Menu as="div" className="relative">
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-slate-500 inline-flex items-center mr-2 px-1 pt-1 text-sm font-medium hover:text-slate-600"
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
116
packages/core/src/ui/Nav/NavMobile.tsx
Normal file
116
packages/core/src/ui/Nav/NavMobile.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { Dialog, Menu } from "@headlessui/react";
|
||||
import Link from "next/link.js";
|
||||
import { useRouter } from "next/router.js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { SearchContext, SearchField } from "../Search";
|
||||
import { MenuIcon, CloseIcon } from "../Icons";
|
||||
import { NavLink, SearchProviderConfig } from "../types";
|
||||
|
||||
interface Props extends React.PropsWithChildren {
|
||||
author?: string;
|
||||
links?: Array<NavLink>;
|
||||
search?: SearchProviderConfig;
|
||||
}
|
||||
|
||||
// TODO why mobile navigation only accepts author and regular nav accepts different things like title, logo, version
|
||||
export const NavMobile: React.FC<Props> = ({
|
||||
children,
|
||||
links,
|
||||
search,
|
||||
author,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [Search, setSearch] = useState<any>(); // TODO types
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
function onRouteChange() {
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
router.events.on("routeChangeComplete", onRouteChange);
|
||||
router.events.on("routeChangeError", onRouteChange);
|
||||
|
||||
return () => {
|
||||
router.events.off("routeChangeComplete", onRouteChange);
|
||||
router.events.off("routeChangeError", onRouteChange);
|
||||
};
|
||||
}, [router, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (search) {
|
||||
setSearch(SearchContext(search.provider));
|
||||
}
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="relative"
|
||||
aria-label="Open navigation"
|
||||
>
|
||||
<MenuIcon className="h-6 w-6 stroke-slate-500" />
|
||||
</button>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={setIsOpen}
|
||||
className="fixed inset-0 z-50 flex items-start overflow-y-auto bg-background-dark/50 pr-10 backdrop-blur lg:hidden"
|
||||
aria-label="Navigation"
|
||||
>
|
||||
<Dialog.Panel className="relative min-h-full w-full max-w-xs bg-background px-4 pt-5 pb-12 dark:bg-background-dark sm:px-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<CloseIcon className="h-6 w-6 stroke-slate-500" />
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="ml-6"
|
||||
aria-label="Home page"
|
||||
legacyBehavior
|
||||
>
|
||||
{/* <Logomark className="h-9 w-9" /> */}
|
||||
<div className="font-extrabold text-primary dark:text-primary-dark text-2xl ml-6">
|
||||
{author}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
{Search && (
|
||||
<Search>
|
||||
{({ query }: any) => <SearchField mobile onOpen={query.toggle} />}
|
||||
</Search>
|
||||
)}
|
||||
{links && (
|
||||
<ul className="mt-2 space-y-2 border-l-2 border-slate-100 dark:border-slate-800 lg:mt-4 lg:space-y-4 lg:border-slate-200">
|
||||
{links.map((link) => (
|
||||
<Menu as="div" key={link.name} className="relative">
|
||||
<Menu.Button>
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className={`
|
||||
block w-full pl-3.5 before:pointer-events-none before:absolute before:-left-1 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-y-1/2 before:rounded-full text-slate-500 before:hidden before:bg-slate-300 hover:text-slate-600 hover:before:block dark:text-slate-400 dark:before:bg-slate-700 dark:hover:text-slate-300`}
|
||||
>
|
||||
{link.name}
|
||||
</Link>
|
||||
</li>
|
||||
</Menu.Button>
|
||||
</Menu>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* <div className="pt-6 border border-t-2">
|
||||
{children}
|
||||
</div> */}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
27
packages/core/src/ui/Nav/NavSocial.tsx
Normal file
27
packages/core/src/ui/Nav/NavSocial.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import Link from "next/link.js";
|
||||
import { GitHubIcon, DiscordIcon } from "../Icons";
|
||||
import { SocialLink, SocialPlatform } from "../types";
|
||||
|
||||
interface Props {
|
||||
links: Array<SocialLink>;
|
||||
}
|
||||
|
||||
const icons: { [K in SocialPlatform]: React.FC<any> } = {
|
||||
github: GitHubIcon,
|
||||
discord: DiscordIcon,
|
||||
};
|
||||
|
||||
export const NavSocial: React.FC<Props> = ({ links }) => {
|
||||
return (
|
||||
<>
|
||||
{links.map(({ label, href }) => {
|
||||
const Icon = icons[label];
|
||||
return (
|
||||
<Link key={label} href={href} aria-label={label} className="group">
|
||||
<Icon className="h-6 w-6 dark:fill-slate-400 group-hover:fill-slate-500 dark:group-hover:fill-slate-300" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
27
packages/core/src/ui/Nav/NavTitle.tsx
Normal file
27
packages/core/src/ui/Nav/NavTitle.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import Link from "next/link.js";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
logo?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export const NavTitle: React.FC<Props> = ({ title, logo, version }) => {
|
||||
return (
|
||||
<Link
|
||||
href="/"
|
||||
aria-label="Home page"
|
||||
className="flex items-center font-extrabold text-xl sm:text-2xl text-slate-900 dark:text-white"
|
||||
>
|
||||
{logo && (
|
||||
<img src={logo} alt={title} className="nav-logo mr-1 fill-white" />
|
||||
)}
|
||||
{title && <span>{title}</span>}
|
||||
{version && (
|
||||
<div className="mx-2 rounded-full border border-slate-500 py-1 px-3 text-xs text-slate-500">
|
||||
{version}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Nav/index.ts
Normal file
1
packages/core/src/ui/Nav/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Nav, NavConfig, ThemeConfig } from "./Nav";
|
||||
74
packages/core/src/ui/Pre/Pre.tsx
Normal file
74
packages/core/src/ui/Pre/Pre.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
interface Props extends React.PropsWithChildren {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Pre: React.FC<Props> = ({ children, ...props }) => {
|
||||
const ref = useRef<any>(); // TODO type
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onEnter = () => {
|
||||
setHovered(true);
|
||||
};
|
||||
const onExit = () => {
|
||||
setHovered(false);
|
||||
setCopied(false);
|
||||
};
|
||||
const onCopy = () => {
|
||||
setCopied(true);
|
||||
navigator.clipboard.writeText(ref.current.textContent);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={onExit}
|
||||
className="relative"
|
||||
>
|
||||
{hovered && (
|
||||
<button
|
||||
aria-label="Copy code"
|
||||
type="button"
|
||||
className={`absolute right-2 top-2 h-6 w-6 rounded border bg-gray-700 p-1 ease-in-out duration-100 ${
|
||||
copied
|
||||
? "border-green-400 focus:border-green-400 focus:outline-none"
|
||||
: "border-slate-300"
|
||||
}`}
|
||||
onClick={onCopy}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="-2 -2 20 20"
|
||||
fill="currentColor"
|
||||
className={copied ? "text-green-400" : "text-slate-300"}
|
||||
>
|
||||
{copied ? (
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<pre>{children}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Pre/index.ts
Normal file
1
packages/core/src/ui/Pre/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Pre } from "./Pre";
|
||||
132
packages/core/src/ui/Search/Algolia.tsx
Normal file
132
packages/core/src/ui/Search/Algolia.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import * as docsearch from "@docsearch/react";
|
||||
import Head from "next/head.js";
|
||||
import Link from "next/link.js";
|
||||
import { useRouter } from "next/router.js";
|
||||
import { createContext, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const { useDocSearchKeyboardEvents } = docsearch;
|
||||
|
||||
let DocSearchModal: any = null;
|
||||
|
||||
function Hit({ hit, children }) {
|
||||
return <Link href={hit.url}>{children}</Link>;
|
||||
}
|
||||
|
||||
export const AlgoliaSearchContext = createContext({});
|
||||
|
||||
export function AlgoliaSearchProvider({ children, config }) {
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialQuery, setInitialQuery] = useState(undefined);
|
||||
|
||||
const importDocSearchModalIfNeeded = useCallback(async () => {
|
||||
if (DocSearchModal) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const [{ DocSearchModal: Modal }] = await Promise.all([docsearch]);
|
||||
// eslint-disable-next-line
|
||||
DocSearchModal = Modal;
|
||||
}, [DocSearchModal]);
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
importDocSearchModalIfNeeded().then(() => {
|
||||
setIsOpen(true);
|
||||
});
|
||||
}, [importDocSearchModalIfNeeded, setIsOpen]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
}, [setIsOpen]);
|
||||
|
||||
const onInput = useCallback(
|
||||
(event) => {
|
||||
importDocSearchModalIfNeeded().then(() => {
|
||||
setIsOpen(true);
|
||||
setInitialQuery(event.key);
|
||||
});
|
||||
},
|
||||
[importDocSearchModalIfNeeded, setIsOpen, setInitialQuery]
|
||||
);
|
||||
|
||||
// web accessibility
|
||||
// https://www.algolia.com/doc/ui-libraries/autocomplete/core-concepts/keyboard-navigation/
|
||||
const navigator = useRef({
|
||||
navigate({ itemUrl }) {
|
||||
// Algolia results could contain URL's from other domains which cannot
|
||||
// be served through history and should navigate with window.location
|
||||
const isInternalLink = itemUrl.startsWith("/");
|
||||
const isAnchorLink = itemUrl.startsWith("#");
|
||||
if (!isInternalLink && !isAnchorLink) {
|
||||
window.location.href = itemUrl;
|
||||
} else {
|
||||
router.push(itemUrl);
|
||||
}
|
||||
},
|
||||
}).current;
|
||||
|
||||
// https://docsearch.algolia.com/docs/api#transformitems
|
||||
const transformItems = (items) =>
|
||||
items.map((item) => {
|
||||
// If Algolia contains a external domain, we should navigate without
|
||||
// relative URL
|
||||
const isInternalLink = item.url.startsWith("/");
|
||||
const isAnchorLink = item.url.startsWith("#");
|
||||
if (!isInternalLink && !isAnchorLink) {
|
||||
return item;
|
||||
}
|
||||
|
||||
// We transform the absolute URL into a relative URL.
|
||||
const url = new URL(item.url);
|
||||
return {
|
||||
...item,
|
||||
// url: withBaseUrl(`${url.pathname}${url.hash}`),
|
||||
url: `${url.pathname}${url.hash}`,
|
||||
};
|
||||
});
|
||||
// ).current;
|
||||
|
||||
useDocSearchKeyboardEvents({
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onInput,
|
||||
});
|
||||
|
||||
const providerValue = useMemo(
|
||||
() => ({ query: { setSearch: setInitialQuery, toggle: onOpen } }),
|
||||
[setInitialQuery, onOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<AlgoliaSearchContext.Provider value={providerValue}>
|
||||
<Head>
|
||||
{/* This hints the browser that the website will load data from Algolia,
|
||||
and allows it to preconnect to the DocSearch cluster. It makes the first
|
||||
query faster, especially on mobile. */}
|
||||
<link
|
||||
rel="preconnect"
|
||||
href={`https://${config.appId}-dsn.algolia.net`}
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</Head>
|
||||
{children}
|
||||
{isOpen &&
|
||||
DocSearchModal &&
|
||||
createPortal(
|
||||
<DocSearchModal
|
||||
onClose={onClose}
|
||||
initialScrollY={window.scrollY}
|
||||
initialQuery={initialQuery}
|
||||
navigator={navigator}
|
||||
transformItems={transformItems}
|
||||
hitComponent={Hit}
|
||||
placeholder={config.placeholder ?? "Search"}
|
||||
{...config}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</AlgoliaSearchContext.Provider>
|
||||
);
|
||||
}
|
||||
33
packages/core/src/ui/Search/KBar.tsx
Normal file
33
packages/core/src/ui/Search/KBar.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import router from "next/router.js";
|
||||
import { Action } from "kbar";
|
||||
|
||||
import { KBarModal } from "./KBarModal";
|
||||
|
||||
export const KBarSearchProvider = ({ config, children }) => {
|
||||
const defaultActions = config?.defaultActions;
|
||||
const searchDocumentsPath = "/search.json";
|
||||
|
||||
let startingActions: Action[] = [
|
||||
{
|
||||
id: "homepage",
|
||||
name: "Homepage",
|
||||
keywords: "",
|
||||
section: "Home",
|
||||
perform: () => router.push("/"),
|
||||
},
|
||||
];
|
||||
|
||||
if (defaultActions && Array.isArray(defaultActions))
|
||||
startingActions = [...startingActions, ...defaultActions];
|
||||
|
||||
return KBarModal ? (
|
||||
<KBarModal
|
||||
startingActions={startingActions}
|
||||
searchDocumentsPath={searchDocumentsPath}
|
||||
>
|
||||
{children}
|
||||
</KBarModal>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
};
|
||||
21
packages/core/src/ui/Search/KBarModal.tsx
Normal file
21
packages/core/src/ui/Search/KBarModal.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { KBarProvider, Action } from "kbar";
|
||||
|
||||
import { Portal } from "./KBarPortal";
|
||||
|
||||
interface Props extends React.PropsWithChildren {
|
||||
searchDocumentsPath: string;
|
||||
startingActions?: Action[];
|
||||
}
|
||||
|
||||
export const KBarModal: React.FC<Props> = ({
|
||||
searchDocumentsPath,
|
||||
startingActions,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<KBarProvider actions={startingActions}>
|
||||
<Portal searchDocumentsPath={searchDocumentsPath} />
|
||||
{children}
|
||||
</KBarProvider>
|
||||
);
|
||||
};
|
||||
120
packages/core/src/ui/Search/KBarPortal.tsx
Normal file
120
packages/core/src/ui/Search/KBarPortal.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Action,
|
||||
KBarAnimator,
|
||||
KBarPortal,
|
||||
KBarPositioner,
|
||||
KBarResults,
|
||||
KBarSearch,
|
||||
useMatches,
|
||||
useRegisterActions,
|
||||
} from "kbar";
|
||||
import { kbarActionsFromDocuments } from "./kbarActionsFromDocuments";
|
||||
|
||||
interface Props {
|
||||
searchDocumentsPath: string;
|
||||
}
|
||||
|
||||
export const Portal: React.FC<Props> = ({ searchDocumentsPath }) => {
|
||||
const [searchActions, setSearchActions] = useState<Action[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const res = await fetch(searchDocumentsPath);
|
||||
const json = await res.json();
|
||||
const actions = kbarActionsFromDocuments(json);
|
||||
setSearchActions(actions);
|
||||
};
|
||||
fetchData();
|
||||
}, [searchDocumentsPath]);
|
||||
|
||||
useRegisterActions(searchActions, [searchActions]);
|
||||
|
||||
return (
|
||||
<KBarPortal>
|
||||
<KBarPositioner className="bg-gray-300/50 p-4 backdrop-blur backdrop-filter dark:bg-black/50">
|
||||
<KBarAnimator className="w-full max-w-xl">
|
||||
<div className="overflow-hidden rounded-2xl border border-gray-100 bg-gray-50 dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex items-center space-x-4 p-4">
|
||||
<span className="block w-5">
|
||||
<svg
|
||||
className="text-gray-400 dark:text-gray-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<KBarSearch
|
||||
defaultPlaceholder="Search"
|
||||
className="h-8 w-full bg-transparent text-slate-600 placeholder-slate-400 focus:outline-none dark:text-slate-200 dark:placeholder-slate-500"
|
||||
/>
|
||||
<span className="inline-block whitespace-nowrap rounded border border-slate-400/70 px-1.5 align-middle font-medium leading-4 tracking-wide text-slate-500 [font-size:10px] dark:border-slate-600 dark:text-slate-400">
|
||||
ESC
|
||||
</span>
|
||||
</div>
|
||||
<RenderResults />
|
||||
</div>
|
||||
</KBarAnimator>
|
||||
</KBarPositioner>
|
||||
</KBarPortal>
|
||||
);
|
||||
};
|
||||
|
||||
function RenderItem(props) {
|
||||
const { item, active } = props;
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
typeof item === "string"
|
||||
? ""
|
||||
: "hover:bg-gray-200 hover:dark:bg-gray-800"
|
||||
}
|
||||
>
|
||||
{typeof item === "string" ? (
|
||||
<div className="pt-3">
|
||||
<div className="text-primary-600 block border-t border-gray-100 px-4 pt-6 pb-2 text-xs font-semibold uppercase dark:border-gray-800">
|
||||
{item}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`block cursor-pointer px-4 py-2 text-gray-600 dark:text-gray-200 ${
|
||||
active ? "bg-primary-600" : "bg-transparent"
|
||||
}`}
|
||||
>
|
||||
{item?.subtitle && (
|
||||
<div
|
||||
className={`${
|
||||
active ? "text-gray-200" : "text-gray-400 dark:text-gray-500"
|
||||
} text-xs`}
|
||||
>
|
||||
{item.subtitle}
|
||||
</div>
|
||||
)}
|
||||
<div>{item?.name}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderResults() {
|
||||
const { results } = useMatches();
|
||||
|
||||
if (results.length) {
|
||||
return <KBarResults items={results} onRender={RenderItem} />;
|
||||
}
|
||||
return (
|
||||
<div className="block border-t border-gray-100 px-4 py-8 text-center text-gray-400 dark:border-gray-800 dark:text-gray-600">
|
||||
No results for your search...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
packages/core/src/ui/Search/SearchField.tsx
Normal file
48
packages/core/src/ui/Search/SearchField.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { SearchIcon } from "../Icons";
|
||||
|
||||
// TODO types
|
||||
export const SearchField: React.FC<any> = (props) => {
|
||||
const { modifierKey, onOpen, mobile } = props;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`
|
||||
group flex h-6 w-6 items-center justify-center
|
||||
${
|
||||
mobile
|
||||
? "sm:hidden justify-start min-w-full flex-none rounded-lg px-4 py-5 my-6 text-sm ring-1 ring-slate-200 dark:bg-slate-800/75 dark:ring-inset dark:ring-white/5"
|
||||
: "hidden sm:flex sm:justify-start md:h-auto md:w-auto xl:w-full max-w-[380px] shrink xl:rounded-lg xl:py-2.5 xl:pl-4 xl:pr-3.5 md:text-sm xl:ring-1 xl:ring-slate-200 xl:hover:ring-slate-300 dark:xl:bg-slate-800/75 dark:xl:ring-inset dark:xl:ring-white/5 dark:xl:hover:bg-slate-700/40 dark:xl:hover:ring-slate-500"
|
||||
}
|
||||
`}
|
||||
onClick={onOpen}
|
||||
>
|
||||
<SearchIcon className="h-5 w-5 flex-none fill-slate-400 group-hover:fill-slate-500 dark:fill-slate-500 md:group-hover:fill-slate-400" />
|
||||
<span
|
||||
className={`
|
||||
text-slate-500 dark:text-slate-400
|
||||
${
|
||||
mobile
|
||||
? "w-full not-sr-only text-left ml-2"
|
||||
: "hidden xl:block sr-only md:not-sr-only md:ml-2"
|
||||
}
|
||||
`}
|
||||
>
|
||||
Search
|
||||
</span>
|
||||
{modifierKey && (
|
||||
<kbd
|
||||
className={`
|
||||
${
|
||||
mobile
|
||||
? "hidden"
|
||||
: "ml-auto font-medium text-slate-400 dark:text-slate-500 hidden xl:block"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<kbd className="font-sans">{modifierKey}</kbd>
|
||||
<kbd className="font-sans">K</kbd>
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
69
packages/core/src/ui/Search/SearchProvider.tsx
Normal file
69
packages/core/src/ui/Search/SearchProvider.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import dynamic from "next/dynamic.js";
|
||||
import {
|
||||
SearchProvider as SearchProviderType,
|
||||
SearchProviderConfig,
|
||||
} from "../types";
|
||||
|
||||
const AlgoliaSearchProvider = dynamic(
|
||||
async () => {
|
||||
return await import("./Algolia").then((mod) => mod.AlgoliaSearchProvider);
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const AlgoliaSearchContext = dynamic(
|
||||
async () => {
|
||||
return await import("./Algolia").then(
|
||||
(mod) => mod.AlgoliaSearchContext.Consumer
|
||||
);
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const KBarProvider = dynamic(
|
||||
async () => {
|
||||
return await import("./KBar").then((mod) => mod.KBarSearchProvider);
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const KBarSearchContext = dynamic(
|
||||
async () => {
|
||||
return await import("kbar").then((mod) => mod.KBarContext.Consumer);
|
||||
},
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
export const SearchProvider = ({
|
||||
searchConfig,
|
||||
children,
|
||||
}: {
|
||||
searchConfig: SearchProviderConfig;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
switch (searchConfig?.provider) {
|
||||
case "algolia":
|
||||
return (
|
||||
<AlgoliaSearchProvider config={searchConfig.config}>
|
||||
{children}
|
||||
</AlgoliaSearchProvider>
|
||||
);
|
||||
case "kbar":
|
||||
return (
|
||||
<KBarProvider config={searchConfig.config}>{children}</KBarProvider>
|
||||
);
|
||||
default:
|
||||
return <>{children}</>;
|
||||
}
|
||||
};
|
||||
|
||||
export const SearchContext = (provider: SearchProviderType) => {
|
||||
switch (provider) {
|
||||
case "algolia":
|
||||
return AlgoliaSearchContext;
|
||||
case "kbar":
|
||||
return KBarSearchContext;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
3
packages/core/src/ui/Search/index.ts
Normal file
3
packages/core/src/ui/Search/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// TODO tidy up this API
|
||||
export { SearchField } from "./SearchField";
|
||||
export { SearchContext, SearchProvider } from "./SearchProvider";
|
||||
26
packages/core/src/ui/Search/kbarActionsFromDocuments.ts
Normal file
26
packages/core/src/ui/Search/kbarActionsFromDocuments.ts
Normal file
@ -0,0 +1,26 @@
|
||||
// TODO don't import router here?
|
||||
import router from "next/router.js";
|
||||
import { Action } from "kbar";
|
||||
|
||||
import { formatDate } from "../../utils/formatDate";
|
||||
import { nameFromUrl } from "../../utils/nameFromUrl";
|
||||
|
||||
// TODO temp type
|
||||
type Document = any;
|
||||
|
||||
export const kbarActionsFromDocuments = (docs: Document[]): Action[] => {
|
||||
const actions: Action[] = [];
|
||||
for (const doc of docs) {
|
||||
// excluding home path as this is defined in starting actions
|
||||
doc.url_path &&
|
||||
actions.push({
|
||||
id: doc.url_path,
|
||||
name: doc.title ?? nameFromUrl(doc.url_path),
|
||||
keywords: doc.description ?? "",
|
||||
section: doc.sourceDir ?? "Page",
|
||||
subtitle: doc.date && formatDate(doc.date, "en-US"),
|
||||
perform: () => router.push(`/${doc.url_path}`),
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
};
|
||||
39
packages/core/src/ui/SimpleLayout/Container.tsx
Normal file
39
packages/core/src/ui/SimpleLayout/Container.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { forwardRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const OuterContainer = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.PropsWithChildren & { className?: string }
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={clsx("sm:px-8", className)} {...props}>
|
||||
<div className="mx-auto max-w-5xl lg:px-8">{children}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const InnerContainer = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.PropsWithChildren & { className?: string }
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx("relative px-4 sm:px-8 lg:px-12", className)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mx-auto max-w-2xl lg:max-w-5xl">{children}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const Container = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.PropsWithChildren & { className?: string }
|
||||
>(({ children, ...props }, ref) => {
|
||||
return (
|
||||
<OuterContainer ref={ref} {...props}>
|
||||
<InnerContainer>{children}</InnerContainer>
|
||||
</OuterContainer>
|
||||
);
|
||||
});
|
||||
20
packages/core/src/ui/SimpleLayout/SimpleLayout.tsx
Normal file
20
packages/core/src/ui/SimpleLayout/SimpleLayout.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
/* eslint import/no-default-export: off */
|
||||
import { Container } from "./Container";
|
||||
|
||||
// TODO types
|
||||
export const SimpleLayout: React.FC<any> = ({ children, ...frontMatter }) => {
|
||||
const { title, description } = frontMatter;
|
||||
return (
|
||||
<Container className="my-16 sm:mt-32">
|
||||
<header className="max-w-2xl">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-6 text-base text-zinc-600 dark:text-zinc-400">
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
<div className="mt-16 sm:mt-20">{children}</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/SimpleLayout/index.ts
Normal file
1
packages/core/src/ui/SimpleLayout/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { SimpleLayout } from "./SimpleLayout";
|
||||
108
packages/core/src/ui/SiteToc/SiteToc.tsx
Normal file
108
packages/core/src/ui/SiteToc/SiteToc.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import Link from "next/link.js";
|
||||
import clsx from "clsx";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
|
||||
export interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
name: string;
|
||||
path: string;
|
||||
level: number;
|
||||
children: Array<NavItem | NavGroup>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentPath: string;
|
||||
nav: Array<NavItem | NavGroup>;
|
||||
}
|
||||
|
||||
function isNavGroup(item: NavItem | NavGroup): item is NavGroup {
|
||||
return (item as NavGroup).children !== undefined;
|
||||
}
|
||||
|
||||
function navItemBeforeNavGroup(a, b) {
|
||||
if (isNavGroup(a) === isNavGroup(b)) {
|
||||
return 0;
|
||||
}
|
||||
if (isNavGroup(a) && !isNavGroup(b)) {
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function sortNavGroupChildren(items: Array<NavItem | NavGroup>) {
|
||||
return items.sort(
|
||||
(a, b) => navItemBeforeNavGroup(a, b) || a.name.localeCompare(b.name)
|
||||
);
|
||||
}
|
||||
|
||||
export const SiteToc: React.FC<Props> = ({ currentPath, nav }) => {
|
||||
function isActiveItem(item: NavItem) {
|
||||
return item.href === currentPath;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav data-testid="lhs-sidebar" className="flex flex-col space-y-3 text-sm">
|
||||
{sortNavGroupChildren(nav).map((n) => (
|
||||
<NavComponent item={n} isActive={false} />
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const NavComponent: React.FC<{
|
||||
item: NavItem | NavGroup;
|
||||
isActive: boolean;
|
||||
}> = ({ item, isActive }) => {
|
||||
return !isNavGroup(item) ? (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={clsx(
|
||||
isActive
|
||||
? "text-sky-500"
|
||||
: "font-normal text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300",
|
||||
"block"
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
) : (
|
||||
<Disclosure as="div" key={item.name} className="flex flex-col space-y-3">
|
||||
{({ open }) => (
|
||||
<div>
|
||||
<Disclosure.Button className="group w-full flex items-center text-left text-md font-medium text-slate-900 dark:text-white">
|
||||
<svg
|
||||
className={clsx(
|
||||
open ? "text-slate-400 rotate-90" : "text-slate-300",
|
||||
"h-3 w-3 mr-2 flex-shrink-0 transform transition-colors duration-150 ease-in-out group-hover:text-slate-400"
|
||||
)}
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M6 6L14 10L6 14V6Z" fill="currentColor" />
|
||||
</svg>
|
||||
{item.name}
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform scale-95 opacity-0"
|
||||
enterTo="transform scale-100 opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform scale-100 opacity-100"
|
||||
leaveTo="transform scale-95 opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className="flex flex-col space-y-3 pl-5 mt-3">
|
||||
{sortNavGroupChildren(item.children).map((subItem) => (
|
||||
<NavComponent item={subItem} isActive={false} />
|
||||
))}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/SiteToc/index.ts
Normal file
1
packages/core/src/ui/SiteToc/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { SiteToc, NavItem, NavGroup } from "./SiteToc";
|
||||
225
packages/core/src/ui/Tooltip/Tooltip.tsx
Normal file
225
packages/core/src/ui/Tooltip/Tooltip.tsx
Normal file
@ -0,0 +1,225 @@
|
||||
import React, { useState, useEffect, useRef, Fragment } from "react";
|
||||
|
||||
// importing separately due to build error
|
||||
// Module '"@floating-ui/react-dom-interactions"' has no exported member 'autoPlacement' ...
|
||||
import {
|
||||
arrow,
|
||||
autoPlacement,
|
||||
inline,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/react-dom";
|
||||
|
||||
import {
|
||||
FloatingPortal,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useHover,
|
||||
useFocus,
|
||||
useInteractions,
|
||||
useRole,
|
||||
} from "@floating-ui/react-dom-interactions";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface Props extends React.PropsWithChildren {
|
||||
render: (t) => React.ReactNode;
|
||||
href: string;
|
||||
data: any;
|
||||
usehook?: any;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const tooltipBoxStyle = (theme: string) =>
|
||||
({
|
||||
height: "auto",
|
||||
maxWidth: "40rem",
|
||||
padding: "1rem",
|
||||
background: theme === "light" ? "#fff" : "#000",
|
||||
color: theme === "light" ? "rgb(99, 98, 98)" : "#A8A8A8",
|
||||
borderRadius: "4px",
|
||||
boxShadow: "rgba(0, 0, 0, 0.55) 0px 0px 16px -3px",
|
||||
} as React.CSSProperties);
|
||||
|
||||
const tooltipBodyStyle = (theme: string) =>
|
||||
({
|
||||
maxHeight: "4.8rem",
|
||||
position: "relative",
|
||||
lineHeight: "1.2rem",
|
||||
overflow: "hidden",
|
||||
} as React.CSSProperties);
|
||||
|
||||
const tooltipArrowStyle = ({ theme, x, y, side }) =>
|
||||
({
|
||||
position: "absolute",
|
||||
left: x != null ? `${x}px` : "",
|
||||
top: y != null ? `${y}px` : "",
|
||||
right: "",
|
||||
bottom: "",
|
||||
[side]: "-4px",
|
||||
height: "8px",
|
||||
width: "8px",
|
||||
background: theme === "light" ? "#fff" : "#000",
|
||||
transform: "rotate(45deg)",
|
||||
} as React.CSSProperties);
|
||||
|
||||
export const Tooltip: React.FC<Props> = ({
|
||||
render,
|
||||
data,
|
||||
usehook,
|
||||
...props
|
||||
}) => {
|
||||
const theme = "light"; // temporarily hard-coded; light theme tbd in next PR
|
||||
|
||||
const arrowRef = useRef(null);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [tooltipData, setTooltipData] = useState({
|
||||
content: null || <Fragment />,
|
||||
image: "",
|
||||
});
|
||||
const [tooltipContentLoaded, setTooltipContentLoaded] = useState(false);
|
||||
// floating-ui hook
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
reference, // trigger element back ref
|
||||
floating, // tooltip back ref
|
||||
placement, // default: 'bottom'
|
||||
strategy, // default: 'absolute'
|
||||
context,
|
||||
middlewareData: { arrow: { x: arrowX = 0, y: arrowY = 0 } = {} }, // data for arrow positioning
|
||||
} = useFloating({
|
||||
open: showTooltip, // state value binding
|
||||
onOpenChange: setShowTooltip, // state value setter
|
||||
middleware: [
|
||||
offset(5), // offset from container border
|
||||
autoPlacement({ padding: 5 }), // auto place vertically
|
||||
shift({ padding: 5 }), // flip horizontally if necessary
|
||||
arrow({ element: arrowRef, padding: 4 }), // add arrow element
|
||||
inline(), // correct position for multiline anchor tags
|
||||
],
|
||||
});
|
||||
// floating-ui hook
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||
useHover(context, { delay: 100 }),
|
||||
useFocus(context),
|
||||
useRole(context, { role: "tooltip" }),
|
||||
useDismiss(context, { ancestorScroll: true }),
|
||||
]);
|
||||
|
||||
const triggerElementProps = getReferenceProps({ ...props, ref: reference });
|
||||
const tooltipProps = getFloatingProps({
|
||||
ref: floating,
|
||||
style: {
|
||||
position: strategy,
|
||||
left: x ?? "",
|
||||
top: y ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const arrowPlacement = {
|
||||
top: "bottom",
|
||||
right: "left",
|
||||
bottom: "top",
|
||||
left: "right",
|
||||
}[placement.split("-")[0]];
|
||||
|
||||
// get tooltip data
|
||||
let image: string;
|
||||
let PageContent;
|
||||
const filePath = props.href.slice(1); // remove slash from the beginning
|
||||
|
||||
const page = data.find((p) => p._raw.flattenedPath === filePath);
|
||||
|
||||
if (page && page.body.code.length > 0) {
|
||||
const Component = usehook(page.body.code);
|
||||
PageContent = Component;
|
||||
image = page.image ?? "";
|
||||
}
|
||||
|
||||
const fetchTooltipContent = () => {
|
||||
setTooltipContentLoaded(false);
|
||||
|
||||
let Body: React.ReactElement = <Fragment />;
|
||||
|
||||
// strip out all other elements from tooltip content
|
||||
// since we only need the paragraph
|
||||
const elems = ["h1", "h2", "h3", "div", "img", "pre", "blockquote"].reduce(
|
||||
(acc, elem) => ({ ...acc, [elem]: () => <Fragment /> }),
|
||||
{}
|
||||
);
|
||||
|
||||
if (PageContent) {
|
||||
Body = (
|
||||
<PageContent
|
||||
components={{
|
||||
...elems,
|
||||
p: (props) => <Fragment {...props} />, // avoid hydration errors
|
||||
wrapper: (props) => <div className="line-clamp-3" {...props} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
setTooltipData({
|
||||
content: Body,
|
||||
image: image,
|
||||
});
|
||||
|
||||
setTooltipContentLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showTooltip) {
|
||||
fetchTooltipContent();
|
||||
}
|
||||
}, [showTooltip]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{render?.(triggerElementProps)}
|
||||
<FloatingPortal>
|
||||
<AnimatePresence>
|
||||
{showTooltip && tooltipContentLoaded && (
|
||||
<motion.div
|
||||
{...tooltipProps}
|
||||
initial={{ opacity: 0, scale: 0.85 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
>
|
||||
<div
|
||||
className="tooltip-box flex items-center space-x-2"
|
||||
style={tooltipBoxStyle(theme)}
|
||||
>
|
||||
{tooltipData.image && (
|
||||
<img
|
||||
src={tooltipData.image}
|
||||
alt=""
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
)}
|
||||
{tooltipData.content && (
|
||||
<div className="tooltip-body" style={tooltipBodyStyle(theme)}>
|
||||
{tooltipData.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={arrowRef}
|
||||
className="tooltip-arrow"
|
||||
style={tooltipArrowStyle({
|
||||
theme,
|
||||
x: arrowX,
|
||||
y: arrowY,
|
||||
side: arrowPlacement,
|
||||
})}
|
||||
></div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</FloatingPortal>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
1
packages/core/src/ui/Tooltip/index.ts
Normal file
1
packages/core/src/ui/Tooltip/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Tooltip } from "./Tooltip";
|
||||
6
packages/core/src/ui/UnstyledLayout/Unstyled.tsx
Normal file
6
packages/core/src/ui/UnstyledLayout/Unstyled.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
/* eslint import/no-default-export: off */
|
||||
export const UnstyledLayout: React.FC<React.PropsWithChildren> = ({
|
||||
children,
|
||||
}) => {
|
||||
return <div className="unstyled dark:text-white">{children}</div>;
|
||||
};
|
||||
1
packages/core/src/ui/UnstyledLayout/index.ts
Normal file
1
packages/core/src/ui/UnstyledLayout/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { UnstyledLayout } from "./Unstyled";
|
||||
22
packages/core/src/ui/index.ts
Normal file
22
packages/core/src/ui/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
export { ThemeProvider } from "next-themes";
|
||||
export { Nav, NavConfig, ThemeConfig } from "./Nav";
|
||||
export { SearchProvider } from "./Search";
|
||||
export {
|
||||
Layout,
|
||||
TableOfContents,
|
||||
TocSection,
|
||||
EditThisPage,
|
||||
useTableOfContents,
|
||||
} from "./Layout";
|
||||
export { Pre } from "./Pre";
|
||||
export { CustomLink } from "./Base/CustomLink";
|
||||
export { BlogsList } from "./Blog";
|
||||
export { SimpleLayout } from "./SimpleLayout";
|
||||
export { DocsLayout } from "./DocsLayout";
|
||||
export { UnstyledLayout } from "./UnstyledLayout";
|
||||
export { BlogLayout } from "./BlogLayout";
|
||||
export { Mermaid } from "./Mermaid";
|
||||
export { SiteToc, NavItem, NavGroup } from "./SiteToc";
|
||||
export { Comments, CommentsConfig } from "./Comments";
|
||||
|
||||
export { AuthorConfig } from "./types";
|
||||
57
packages/core/src/ui/types.ts
Normal file
57
packages/core/src/ui/types.ts
Normal file
@ -0,0 +1,57 @@
|
||||
// shared types used in more than one component
|
||||
// TODO find out what's the best place to put them, what's the best practice
|
||||
|
||||
// layout
|
||||
export interface NavLink {
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface AuthorConfig {
|
||||
name: string;
|
||||
url: string;
|
||||
logo: string;
|
||||
}
|
||||
|
||||
// social
|
||||
export type SocialPlatform = "github" | "discord";
|
||||
|
||||
export interface SocialLink {
|
||||
label: SocialPlatform;
|
||||
href: string;
|
||||
}
|
||||
|
||||
// search
|
||||
export type SearchProvider = "algolia" | "kbar";
|
||||
|
||||
export interface SearchProviderConfig {
|
||||
provider: SearchProvider;
|
||||
config: object;
|
||||
}
|
||||
|
||||
// TEMP contentlayer
|
||||
interface SharedFields {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
layout: string;
|
||||
showEditLink?: boolean;
|
||||
showToc?: boolean;
|
||||
showComments?: boolean;
|
||||
isDraft?: boolean;
|
||||
data: Array<string>;
|
||||
}
|
||||
|
||||
interface ComputedFields {
|
||||
urlPath: string;
|
||||
editUrl?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface Page extends SharedFields, ComputedFields {}
|
||||
|
||||
export interface Blog extends SharedFields, ComputedFields {
|
||||
date: string; // TODO type?
|
||||
authors?: Array<string>;
|
||||
tags?: Array<string>;
|
||||
}
|
||||
60
packages/core/src/utils/collectHeadings.ts
Normal file
60
packages/core/src/utils/collectHeadings.ts
Normal file
@ -0,0 +1,60 @@
|
||||
// ToC: get the html nodelist for headings
|
||||
import { TocSection } from "../ui/Layout";
|
||||
|
||||
export function collectHeadings(nodes: NodeListOf<HTMLHeadingElement>) {
|
||||
const sections: Array<TocSection> = [];
|
||||
|
||||
Array.from(nodes).forEach((node) => {
|
||||
const { id, innerText: title, tagName: level } = node;
|
||||
|
||||
if (!(id && title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (level === "H1") {
|
||||
sections.push({ id, title, level, children: [] });
|
||||
}
|
||||
|
||||
const parentSection = sections[sections.length - 1];
|
||||
|
||||
if (level === "H2") {
|
||||
if (parentSection && level > parentSection.level) {
|
||||
(parentSection as TocSection).children.push({
|
||||
id,
|
||||
title,
|
||||
level,
|
||||
children: [],
|
||||
});
|
||||
} else {
|
||||
sections.push({ id, title, level, children: [] });
|
||||
}
|
||||
}
|
||||
|
||||
if (level === "H3") {
|
||||
const subSection =
|
||||
parentSection?.children[parentSection?.children?.length - 1];
|
||||
if (subSection && level > subSection.level) {
|
||||
(subSection as TocSection).children.push({
|
||||
id,
|
||||
title,
|
||||
level,
|
||||
children: [],
|
||||
});
|
||||
} else if (parentSection && level > parentSection.level) {
|
||||
(parentSection as TocSection).children.push({
|
||||
id,
|
||||
title,
|
||||
level,
|
||||
children: [],
|
||||
});
|
||||
} else {
|
||||
sections.push({ id, title, level, children: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// TODO types
|
||||
sections.push(...collectHeadings((node.children as any) ?? []));
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
8
packages/core/src/utils/formatDate.ts
Normal file
8
packages/core/src/utils/formatDate.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const formatDate = (date: string, locales = "en-US") => {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
};
|
||||
return new Date(date).toLocaleDateString(locales, options);
|
||||
};
|
||||
25
packages/core/src/utils/gtag.ts
Normal file
25
packages/core/src/utils/gtag.ts
Normal file
@ -0,0 +1,25 @@
|
||||
// https://developers.google.com/analytics/devguides/collection/gtagjs/pages
|
||||
export const pageview = ({
|
||||
url,
|
||||
analyticsID,
|
||||
}: {
|
||||
url: string;
|
||||
analyticsID: string;
|
||||
}) => {
|
||||
if (typeof window.gtag !== undefined) {
|
||||
window.gtag("config", analyticsID, {
|
||||
page_path: url,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
|
||||
export const event = ({ action, category, label, value }) => {
|
||||
if (typeof window.gtag !== undefined) {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
}
|
||||
};
|
||||
2
packages/core/src/utils/index.ts
Normal file
2
packages/core/src/utils/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { pageview } from "./gtag";
|
||||
export { collectHeadings } from "./collectHeadings";
|
||||
4
packages/core/src/utils/nameFromUrl.ts
Normal file
4
packages/core/src/utils/nameFromUrl.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export const nameFromUrl = (url: string) => {
|
||||
const name = url.split("/").slice(-1)[0].replace("-", " ");
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
};
|
||||
28
packages/core/tsconfig.json
Normal file
28
packages/core/tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"module": "es2020",
|
||||
"moduleResolution": "node",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
packages/core/tsconfig.lib.json
Normal file
23
packages/core/tsconfig.lib.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"types": ["node"]
|
||||
},
|
||||
"files": [
|
||||
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
|
||||
"../../node_modules/@nrwl/react/typings/image.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.test.jsx"
|
||||
],
|
||||
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
20
packages/core/tsconfig.spec.json
Normal file
20
packages/core/tsconfig.spec.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.js",
|
||||
"**/*.spec.js",
|
||||
"**/*.test.jsx",
|
||||
"**/*.spec.jsx",
|
||||
"**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@ -17,7 +17,8 @@
|
||||
"paths": {
|
||||
"@portaljs/portaljs-components": [
|
||||
"packages/portaljs-components/src/index.ts"
|
||||
]
|
||||
],
|
||||
"@portaljs/core": ["packages/core/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "tmp"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user