Switch over to WebAssembly, Rust and Yew #35
@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
@ -1,339 +0,0 @@
|
||||
//
|
||||
// Site Analytics
|
||||
//
|
||||
// This module provides an `Analytics` component that embeds an image into the page which records analytics data.
|
||||
//
|
||||
// More information about this can be found here: https://blakerain.com/disclaimer#analytics
|
||||
//
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
// Generate a UUID for a page request
|
||||
//
|
||||
// I think I got this from: https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid
|
||||
const uuidv4 = (): string => {
|
||||
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c) => {
|
||||
const n = parseInt(c, 10);
|
||||
return (
|
||||
n ^
|
||||
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (n / 4)))
|
||||
).toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
// We prefer all paths to start with a forward slash
|
||||
const ensureStartSlash = (path: string): string =>
|
||||
path.startsWith("/") ? path : "/" + path;
|
||||
|
||||
// Check if the hostname looks a bit like an IP address
|
||||
const isIPAddressLike = (host: string): boolean =>
|
||||
/[0-9]+$/.test(host.replace(/\./g, ""));
|
||||
|
||||
// Clear up the referrer, removing any excessive components
|
||||
const cleanReferrer = (url: string): string =>
|
||||
url
|
||||
.replace(/^https?:\/\/((m|l|w{2,3})([0-9]+)?\.)?([^?#]+)(.*)$/, "$4")
|
||||
.replace(/^([^/]+)$/, "$1");
|
||||
|
||||
// Get the distance scrolled through the document
|
||||
const getPosition = (): number => {
|
||||
try {
|
||||
const doc = window.document.documentElement;
|
||||
const body = window.document.body;
|
||||
|
||||
return Math.min(
|
||||
100,
|
||||
5 *
|
||||
Math.round(
|
||||
(100 * (doc.scrollTop + doc.clientHeight)) / body.scrollHeight / 5
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
// The dimensions of the viewport and screen
|
||||
interface Dimensions {
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
screenWidth: number;
|
||||
screenHeight: number;
|
||||
}
|
||||
|
||||
// Get the dimensions of the viewport and screen
|
||||
const getDimensions = (): Dimensions => ({
|
||||
viewportWidth: window.innerWidth || 0,
|
||||
viewportHeight: window.innerHeight || 0,
|
||||
screenWidth: (window.screen && window.screen.width) || 0,
|
||||
screenHeight: (window.screen && window.screen.height) || 0,
|
||||
});
|
||||
|
||||
// Get the TZ
|
||||
const getTimeZone = (): string | undefined => {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// The parameters that we pass to our analytics API
|
||||
interface AnalyticsParams {
|
||||
uuid: string;
|
||||
path: string;
|
||||
ua: string;
|
||||
viewport_width: number;
|
||||
viewport_height: number;
|
||||
screen_width: number;
|
||||
screen_height: number;
|
||||
referrer: string;
|
||||
|
||||
timezone?: string;
|
||||
duration?: number;
|
||||
scroll?: number;
|
||||
}
|
||||
|
||||
// Encode a value as a parameter for the API call query-string. This handles the encoding of numbers, strings,
|
||||
// or missing fields that are 'undefined'.
|
||||
const encodeParamValue = (
|
||||
value: string | number | undefined
|
||||
): string | undefined => {
|
||||
if (typeof value === "string") {
|
||||
return encodeURIComponent(value);
|
||||
} else if (typeof value === "number") {
|
||||
return value.toString(10);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
class AnalyticsData {
|
||||
public uuid: string;
|
||||
public pathname: string;
|
||||
public ua: string;
|
||||
public referrer: string;
|
||||
public dimensions: Dimensions;
|
||||
public timezone: string | undefined;
|
||||
public duration: number = 0;
|
||||
public scroll: number = 0;
|
||||
private start: number;
|
||||
private hideStart: number = 0;
|
||||
private totalHidden: number = 0;
|
||||
|
||||
constructor() {
|
||||
this.uuid = uuidv4();
|
||||
this.pathname = ensureStartSlash(window.location.pathname);
|
||||
this.ua = navigator.userAgent;
|
||||
this.referrer = cleanReferrer(document.referrer || "");
|
||||
this.dimensions = getDimensions();
|
||||
this.timezone = getTimeZone();
|
||||
this.start = Date.now();
|
||||
}
|
||||
|
||||
// Collect up the `AnalyticsParams` and render them into a querystring
|
||||
toParams(): string {
|
||||
var obj: AnalyticsParams = {
|
||||
uuid: this.uuid,
|
||||
path: this.pathname,
|
||||
ua: this.ua,
|
||||
viewport_width: this.dimensions.viewportWidth,
|
||||
viewport_height: this.dimensions.viewportHeight,
|
||||
screen_width: this.dimensions.screenWidth,
|
||||
screen_height: this.dimensions.screenHeight,
|
||||
referrer: this.referrer,
|
||||
duration: this.duration,
|
||||
scroll: this.scroll,
|
||||
};
|
||||
|
||||
if (this.timezone) {
|
||||
obj.timezone = this.timezone;
|
||||
}
|
||||
|
||||
return (Object.keys(obj) as Array<keyof AnalyticsParams>)
|
||||
.map((key) => {
|
||||
const encoded = encodeParamValue(obj[key]);
|
||||
return encoded ? `${key}=${encoded}` : undefined;
|
||||
})
|
||||
.filter((param) => typeof param === "string")
|
||||
.join("&");
|
||||
}
|
||||
|
||||
toBeaconJson(): string {
|
||||
return JSON.stringify({
|
||||
uuid: this.uuid,
|
||||
path: this.pathname,
|
||||
duration: this.duration,
|
||||
scroll: this.scroll,
|
||||
});
|
||||
}
|
||||
|
||||
onScroll() {
|
||||
const position = getPosition();
|
||||
if (this.scroll < position) {
|
||||
this.scroll = position;
|
||||
}
|
||||
}
|
||||
|
||||
onVisibilityChange() {
|
||||
if (window.document.hidden) {
|
||||
if (!("onpagehide" in window)) {
|
||||
this.sendBeacon();
|
||||
}
|
||||
|
||||
this.hideStart = Date.now();
|
||||
} else {
|
||||
this.totalHidden += Date.now() - this.hideStart;
|
||||
}
|
||||
}
|
||||
|
||||
sendBeacon() {
|
||||
this.duration = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
MAX_DURATION,
|
||||
Math.round((Date.now() - this.start - this.totalHidden) / 1000.0)
|
||||
)
|
||||
);
|
||||
this.scroll = Math.max(0, this.scroll, getPosition());
|
||||
navigator.sendBeacon(ANALYTICS_APPEND_URL, this.toBeaconJson());
|
||||
}
|
||||
}
|
||||
|
||||
// The maximum duration (2 hours)
|
||||
const MAX_DURATION = 2 * 60 * 60;
|
||||
|
||||
// This is the path to our analytics image. We append the query string from the `AnalyticsData` to this URL.
|
||||
const ANALYTICS_URL = "https://pv.blakerain.com/pv.gif";
|
||||
|
||||
// This is the path to our analytics append function.
|
||||
const ANALYTICS_APPEND_URL = "https://pv.blakerain.com/append";
|
||||
|
||||
// Renders an image using our analytics image.
|
||||
//
|
||||
// This effectively calls our analytics API and passes the data we collect in the `AnalyticsData` class.
|
||||
const AnalyticsImage = ({ data }: { data: AnalyticsData }) => {
|
||||
return (
|
||||
<img
|
||||
alt=""
|
||||
style={{
|
||||
visibility: "hidden",
|
||||
width: 0,
|
||||
height: 0,
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
}}
|
||||
src={`${ANALYTICS_URL}?${data.toParams()}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Analytics embed
|
||||
*
|
||||
* This component will add an invisible image element into the document. The URL of the image includes analytics
|
||||
* information gathered in the `AnalyticsData` class. The loading of this image causes the analytics information to be
|
||||
* stored in our database.
|
||||
*/
|
||||
const Analytics = () => {
|
||||
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// Do not record analytics for localhost or an IP address
|
||||
if (hostname === "localhost" || isIPAddressLike(hostname)) {
|
||||
console.warn(`Ignoring analytics for hostname: ${hostname}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const analytics = new AnalyticsData();
|
||||
setAnalyticsData(analytics);
|
||||
|
||||
const onScrollEvent = () => {
|
||||
analytics.onScroll();
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
analytics.onVisibilityChange();
|
||||
};
|
||||
|
||||
const onPageHide = () => {
|
||||
analytics.sendBeacon();
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", onScrollEvent);
|
||||
window.addEventListener("visibilitychange", onVisibilityChange);
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScrollEvent);
|
||||
window.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
window.removeEventListener("pagehide", onPageHide);
|
||||
analytics.sendBeacon();
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// navigator.sendBeacon
|
||||
|
||||
return analyticsData ? <AnalyticsImage data={analyticsData} /> : null;
|
||||
};
|
||||
|
||||
export const AnalyticsInformation = () => {
|
||||
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setAnalyticsData(new AnalyticsData());
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (analyticsData) {
|
||||
return (
|
||||
<table className="columnOriented">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={{ width: "20rem" }}>Pathname of page</th>
|
||||
<td>{analyticsData.pathname}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>User Agent</th>
|
||||
<td>{analyticsData.ua}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Referrer</th>
|
||||
<td>{analyticsData.referrer}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Screen Dimensions</th>
|
||||
<td>
|
||||
{analyticsData.dimensions.screenWidth} x{" "}
|
||||
{analyticsData.dimensions.screenHeight}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Viewport Dimensions</th>
|
||||
<td>
|
||||
{analyticsData.dimensions.viewportWidth} x{" "}
|
||||
{analyticsData.dimensions.viewportHeight}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Time zone</th>
|
||||
<td>{analyticsData.timezone}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default Analytics;
|
@ -1,41 +0,0 @@
|
||||
import React, { FC, useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Provides a dismissable wrapper around an element
|
||||
*
|
||||
* When a mouse click event is received outside of this component, the `onDismiss` property is called.
|
||||
*/
|
||||
export const Dismissable: FC<
|
||||
React.PropsWithChildren<{
|
||||
onDismiss: (event: MouseEvent) => void;
|
||||
className?: string;
|
||||
}>
|
||||
> = ({ onDismiss, className, children }) => {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
// Make sure that the click was not on or in our container before we call the `onDismiss` function.
|
||||
if (
|
||||
event.target &&
|
||||
container.current &&
|
||||
!container.current.contains(event.target as Node)
|
||||
) {
|
||||
onDismiss(event);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClickOutside, true);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside, true);
|
||||
};
|
||||
}, [onDismiss]);
|
||||
|
||||
return (
|
||||
<div ref={container} className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dismissable;
|
@ -1,272 +0,0 @@
|
||||
@import "../styles/colors.scss";
|
||||
@import "../styles/tools.scss";
|
||||
|
||||
$color-footer-link: rgba(255, 255, 255, 0.7);
|
||||
$color-footer-link-hover: rgba(255, 255, 255, 1);
|
||||
$color-footer-link-separator: rgba(255, 255, 255, 0.75);
|
||||
$color-footer-popup-background: lighten($primary-background, 10%);
|
||||
$color-footer-popup-link: rgba(255, 255, 255, 1);
|
||||
$color-footer-popup-link-hover-background: lighten($primary-background, 20%);
|
||||
|
||||
// The outer footer component, which provides the full-width primary background and some padding
|
||||
.footer {
|
||||
background-color: $primary-background;
|
||||
padding-top: 20px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
// THe inner component of the footer, which consumes all available space up to a maximum width, corresponding to the
|
||||
// main body size. This arranges for it's children to be spaced either side of the footer, which we override on smaller
|
||||
// displays to arrange children vertically.
|
||||
.footerInner {
|
||||
// Use a slightly smaller font for the footer
|
||||
font-size: 1.3rem;
|
||||
|
||||
// Arrange our children horizontally (a row) and space them equally.
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
// Any link found in the footer should be white, and become a tad brighter when we hover.
|
||||
a {
|
||||
color: $color-footer-link;
|
||||
|
||||
&:hover {
|
||||
color: $color-footer-link-hover;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The copyright notice matches the color of our links
|
||||
.copyright {
|
||||
color: $color-footer-link;
|
||||
}
|
||||
|
||||
// A navigation in the footer. This contains a series of links that are arranged horizontally, or vertically on mobile.
|
||||
.navigation {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
// Ensure that links in the navigation have a dot between them. We do this by using a ':before' pseudo class that
|
||||
// lets us add a small dot to the left of the link. Note that we don't apply this to the first child.
|
||||
> a,
|
||||
.popup {
|
||||
position: relative;
|
||||
margin-left: 20px;
|
||||
|
||||
&:not(:first-child):before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 12px;
|
||||
left: -11px;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
background-color: $color-footer-link-separator;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a small triangle at the end of the link that points up. We override this on mobile devices to point in different
|
||||
// directions, corresponding with the UI arrangement.
|
||||
.popup {
|
||||
> a:after {
|
||||
content: "";
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
top: -3px;
|
||||
left: 4px;
|
||||
border-left: 0.5rem solid transparent;
|
||||
border-right: 0.5rem solid transparent;
|
||||
border-bottom: 0.5rem solid $color-footer-link;
|
||||
}
|
||||
}
|
||||
|
||||
// When the popup is open, set the menu display to block (previous was 'none')
|
||||
.popup.popupOpen {
|
||||
.popupMenu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// Arrange the popup menu to be an absolute positioned element aligned to the right of it's container.
|
||||
.popupMenu {
|
||||
position: absolute;
|
||||
|
||||
bottom: 3rem;
|
||||
right: -1.5em;
|
||||
width: 20rem;
|
||||
z-index: 100;
|
||||
|
||||
display: none;
|
||||
|
||||
padding: 1rem 0;
|
||||
|
||||
background-color: $color-footer-popup-background;
|
||||
border-radius: 5px;
|
||||
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
display: block;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: $color-footer-popup-link;
|
||||
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-footer-popup-link-hover-background;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 700px) {
|
||||
// On larger displays, add a small arrow to the bottom-right of the popup menu that should line up with the arrow on
|
||||
// the right of the popup link.
|
||||
.popupMenu {
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
||||
right: 1rem;
|
||||
bottom: -1rem;
|
||||
|
||||
width: 0;
|
||||
height: 0;
|
||||
|
||||
border-left: 1rem solid transparent;
|
||||
border-right: 1rem solid transparent;
|
||||
border-top: 1rem solid $color-footer-popup-background;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.inner {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.copyright {
|
||||
margin-bottom: 1rem;
|
||||
color: darken($color-footer-link, 20%);
|
||||
|
||||
> a {
|
||||
color: darken($color-footer-link, 20%);
|
||||
}
|
||||
}
|
||||
|
||||
.navigation {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin-bottom: 0;
|
||||
|
||||
> a,
|
||||
.popup {
|
||||
margin-left: 0;
|
||||
padding: 1rem 0;
|
||||
|
||||
&:not(:first-child):before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .popup {
|
||||
// The pop-up should arrange it's children in a column
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
// Adjust a pop-up menu so that it's full width (overlapping the container padding)
|
||||
padding: 1rem 2rem;
|
||||
margin-left: -2rem;
|
||||
margin-right: -2rem;
|
||||
|
||||
// When the popup is open on smaller displays, we hide the padding at the bottom and match the background of the
|
||||
// popup menu.
|
||||
&.popupOpen {
|
||||
padding-bottom: 0;
|
||||
background-color: $color-footer-popup-background;
|
||||
}
|
||||
|
||||
// We re-arrange the flex order of the children so the menu opens _below_ the link, rather than above it.
|
||||
> div {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
> a {
|
||||
order: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Change the arrows in the popup menu link to a different orientation for smaller devices
|
||||
.popup {
|
||||
> a {
|
||||
// Make the arrow after the link of a footer popup menu point to the right on smaller devices
|
||||
&:after {
|
||||
top: 0px;
|
||||
left: 6px;
|
||||
border-top: 0.5rem solid transparent;
|
||||
border-bottom: 0.5rem solid transparent;
|
||||
border-left: 0.5rem solid $color-footer-link;
|
||||
}
|
||||
}
|
||||
|
||||
&.popupOpen {
|
||||
// Make the arrow after the link of a footer popup menu point down on smaller devices
|
||||
> a:after {
|
||||
top: 4px;
|
||||
border-left: 0.5rem solid transparent;
|
||||
border-right: 0.5rem solid transparent;
|
||||
border-top: 0.5rem solid $color-footer-link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show the pop-up menu as a normal flex child rather than a floating element.
|
||||
.popupMenu {
|
||||
position: relative;
|
||||
|
||||
bottom: initial;
|
||||
right: initial;
|
||||
width: auto;
|
||||
|
||||
// Don't have any shadow, background or border radius
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
|
||||
// Make sure that the links in the popup menu have an increased gap for mobile
|
||||
ul > li > a {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
import React, { FC, useState } from "react";
|
||||
import cn from "classnames";
|
||||
import Link from "next/link";
|
||||
import styles from "./Footer.module.scss";
|
||||
import Dismissable from "./Dismissable";
|
||||
|
||||
/**
|
||||
* A drop-down (or drop-up) menu in the footer
|
||||
*
|
||||
* This component encapsulates the drop-down footer menus
|
||||
*/
|
||||
const FooterDropdown: FC<React.PropsWithChildren<{ title: string }>> = ({
|
||||
title,
|
||||
children,
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const onLinkClick: React.MouseEventHandler<HTMLAnchorElement> = (event) => {
|
||||
event.preventDefault();
|
||||
setVisible(!visible);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dismissable
|
||||
onDismiss={() => setVisible(false)}
|
||||
className={cn(styles.popup, visible && styles.popupOpen)}
|
||||
>
|
||||
<div className={cn(styles.popupMenu)}>
|
||||
<ul>{children}</ul>
|
||||
</div>
|
||||
<a href="#" onClick={onLinkClick}>
|
||||
{title}
|
||||
</a>
|
||||
</Dismissable>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides the footer for the website.
|
||||
*
|
||||
* The site footer includes a copyright notice and a set of links. Some of those links may be drop-down menus that
|
||||
* provide additional links.
|
||||
*/
|
||||
export const Footer: FC = () => {
|
||||
const date = new Date();
|
||||
|
||||
return (
|
||||
<footer className={cn(styles.footer, styles.outer)}>
|
||||
<div className={cn(styles.footerInner, styles.inner)}>
|
||||
<section className={styles.copyright}>
|
||||
<Link href="/">Blake Rain</Link> ©{" "}
|
||||
{date.getFullYear().toString()}
|
||||
</section>
|
||||
<nav className={styles.navigation}>
|
||||
<Link href="/blog">Latest Posts</Link>
|
||||
<Link href="/tags">Tags</Link>
|
||||
<Link href="/disclaimer">Disclaimer</Link>
|
||||
<a rel="noreferrer" href="https://github.com/BlakeRain">
|
||||
GitHub
|
||||
</a>
|
||||
<a rel="noreferrer" href="https://mastodonapp.uk/@BlakeRain">
|
||||
Mastodon
|
||||
</a>
|
||||
<FooterDropdown title="Tools">
|
||||
<li>
|
||||
<Link href="/analytics">Analytics Dashboard</Link>
|
||||
<Link href="/tools/position-size">Position Size Calculator</Link>
|
||||
</li>
|
||||
</FooterDropdown>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
@ -1,12 +0,0 @@
|
||||
@import "../styles/tools.scss";
|
||||
|
||||
// .content {
|
||||
// display: flex;
|
||||
// justify-content: center;
|
||||
// padding: 0 5vw;
|
||||
// }
|
||||
//
|
||||
// .inner {
|
||||
// width: 100%;
|
||||
// max-width: 1040px;
|
||||
// }
|
@ -1,66 +0,0 @@
|
||||
import Head from "next/head";
|
||||
import React, { FC } from "react";
|
||||
import { SiteNavigation } from "../lib/navigation";
|
||||
import { Footer } from "./Footer";
|
||||
import styles from "./Layout.module.scss";
|
||||
import { Navigation } from "./Navigation";
|
||||
|
||||
export interface LayoutProps {
|
||||
navigation: SiteNavigation[];
|
||||
wrap?: boolean;
|
||||
}
|
||||
|
||||
export const Layout: FC<React.PropsWithChildren<LayoutProps>> = ({
|
||||
navigation,
|
||||
children,
|
||||
wrap,
|
||||
}) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Head>
|
||||
<meta name="referer" content="no-referrer-when-downgrade" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="76x76"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#12304c" />
|
||||
<meta name="msapplication-TileColor" content="#12304e" />
|
||||
<meta name="theme-color" content="#12304e" />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
name: "Blake Rain",
|
||||
url: "https://blakerain.com",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Head>
|
||||
<Navigation navigation={navigation} />
|
||||
{wrap ? (
|
||||
<div className={styles.outer}>
|
||||
<div className={styles.inner}>{children}</div>
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
<Footer />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
@ -1,150 +0,0 @@
|
||||
@import "../styles/colors.scss";
|
||||
|
||||
.navigation {
|
||||
background-color: $primary-background;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
padding: 0 5vw;
|
||||
}
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
max-width: 1040px;
|
||||
width: 100%;
|
||||
|
||||
margin: 9px 0;
|
||||
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.right {
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: 11px 10px 3px 10px;
|
||||
|
||||
svg {
|
||||
height: 1.8rem;
|
||||
fill: white;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
padding: 6px 12px 0 0;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.siteNavigation {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
|
||||
li {
|
||||
display: block;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 12px 12px 0px 12px;
|
||||
|
||||
color: white;
|
||||
opacity: 0.8;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
height: 1px;
|
||||
|
||||
background-color: white;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
||||
&:before {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.highlightControls {
|
||||
background-color: $primary-background;
|
||||
color: $color-light-grey;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
padding: 1rem 5vw;
|
||||
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
|
||||
div {
|
||||
padding: 1rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
div + button,
|
||||
button + button {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 750px) {
|
||||
.right {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.navigation {
|
||||
display: none;
|
||||
}
|
||||
}
|
@ -1,139 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import Link from "next/link";
|
||||
import { SiteNavigation } from "../lib/navigation";
|
||||
import styles from "./Navigation.module.scss";
|
||||
import Search from "./icons/Search";
|
||||
import GitHub from "./icons/GitHub";
|
||||
import DevTo from "./icons/DevTo";
|
||||
import Rss from "./icons/Rss";
|
||||
import Mastodon from "./icons/Mastodon";
|
||||
|
||||
const trimTrailingSlash = (str: string): string => {
|
||||
return str.length > 0 && str.endsWith("/")
|
||||
? str.substring(0, str.length - 1)
|
||||
: str;
|
||||
};
|
||||
|
||||
const SiteNavLinks: FC<{ navigation: SiteNavigation[] }> = ({ navigation }) => {
|
||||
return (
|
||||
<ul className={styles.siteNavigation}>
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<li key={index.toString()}>
|
||||
<Link href={trimTrailingSlash(item.url)}>{item.label}</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const SiteNav: FC<{ navigation: SiteNavigation[] }> = ({ navigation }) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Link href="/" className={styles.logo}>
|
||||
<img
|
||||
src="/media/logo-text.png"
|
||||
width={154}
|
||||
height={28}
|
||||
alt="Blake Rain"
|
||||
/>
|
||||
</Link>
|
||||
<SiteNavLinks navigation={navigation} />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchLink: FC = () => {
|
||||
return (
|
||||
<Link href="/search">
|
||||
<Search />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const GitHubLink: FC = () => {
|
||||
return (
|
||||
<a
|
||||
href="https://github.com/BlakeRain"
|
||||
title="GitHub"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<GitHub />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const MastodonLink: FC = () => {
|
||||
return (
|
||||
<a
|
||||
href="https://mastodonapp.uk/@BlakeRain"
|
||||
title="@BlakeRain@mastodonapp.uk"
|
||||
target="_blank"
|
||||
rel="me noreferrer"
|
||||
>
|
||||
<Mastodon />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const DevLink: FC = () => {
|
||||
return (
|
||||
<a
|
||||
href="https://dev.to/blakerain"
|
||||
title="blakerain"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<DevTo />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const RssLink: FC = () => {
|
||||
return (
|
||||
<a href="/feeds/feed.xml" title="RSS feed">
|
||||
<Rss />
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const NavigationBar: FC<{ navigation: SiteNavigation[] }> = (props) => {
|
||||
return (
|
||||
<nav className={styles.navigation}>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.left}>
|
||||
<SiteNav navigation={props.navigation} />
|
||||
</div>
|
||||
<div className={styles.right}>
|
||||
<ul>
|
||||
<li>
|
||||
<SearchLink />
|
||||
</li>
|
||||
<li>
|
||||
<GitHubLink />
|
||||
</li>
|
||||
<li>
|
||||
<MastodonLink />
|
||||
</li>
|
||||
<li>
|
||||
<DevLink />
|
||||
</li>
|
||||
<li>
|
||||
<RssLink />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export const Navigation: FC<{ navigation: SiteNavigation[] }> = (props) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<NavigationBar {...props} />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
@ -1,91 +0,0 @@
|
||||
@import "../styles/colors.scss";
|
||||
@import "../styles/fonts.scss";
|
||||
|
||||
.postCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.postCardCoverImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.postCardInner {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
a {
|
||||
display: block;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
header {
|
||||
font-size: 2.2rem;
|
||||
line-height: 1.15em;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
|
||||
margin-top: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
section {
|
||||
color: $color-mid-grey;
|
||||
font-family: $text-font-family;
|
||||
font-size: 2rem;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1070px) {
|
||||
.postCardLarge {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: 4;
|
||||
|
||||
flex-direction: row;
|
||||
|
||||
.postCardCoverImage {
|
||||
width: 320px;
|
||||
height: 220px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.postCardInner {
|
||||
margin-left: 40px;
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.postCardInner {
|
||||
header {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
section {
|
||||
color: lighten($color-mid-grey, 30%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.postCards {
|
||||
margin: 40px 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
grid-row-gap: 80px;
|
||||
grid-column-gap: 40px;
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import cn from "classnames";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Tag, Tags } from "../lib/tags";
|
||||
import { PostInfo } from "../lib/content";
|
||||
import { PostDetails } from "./PostDetails";
|
||||
import { TagList } from "./display/TagList";
|
||||
import Image from "./display/Image";
|
||||
|
||||
import styles from "./PostCard.module.scss";
|
||||
|
||||
export const PostCard: FC<{
|
||||
post: PostInfo;
|
||||
large: boolean;
|
||||
tags: Tag[];
|
||||
}> = ({ post, large, tags }) => {
|
||||
return (
|
||||
<article className={cn(styles.postCard, { [styles.postCardLarge]: large })}>
|
||||
{post.coverImage ? (
|
||||
<Link href={"/blog/" + post.slug} className={styles.postCardCoverImage}>
|
||||
<Image
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
fill
|
||||
priority={true}
|
||||
sizes="(max-width: 1200px) 100vw, 33vw"
|
||||
/>
|
||||
</Link>
|
||||
) : null}
|
||||
<div className={styles.postCardInner}>
|
||||
<Link href={"/blog/" + post.slug}>
|
||||
<header>{post.title}</header>
|
||||
{post.excerpt ? <section>{post.excerpt}</section> : null}
|
||||
</Link>
|
||||
<PostDetails doc={post}>
|
||||
<TagList tags={tags} />
|
||||
</PostDetails>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
export const PostCards: FC<{
|
||||
tags: Tags;
|
||||
posts: PostInfo[];
|
||||
feature?: boolean;
|
||||
}> = ({ tags, posts, feature }) => {
|
||||
return (
|
||||
<div className={styles.postCards}>
|
||||
{posts.map((post, index) => (
|
||||
<PostCard
|
||||
key={index.toString()}
|
||||
post={post}
|
||||
large={Boolean(feature) && index === 0 && posts.length > 2}
|
||||
tags={post.tags.reduce((acc, tag_slug) => {
|
||||
const tag = tags.get(tag_slug);
|
||||
if (tag) {
|
||||
acc.push(tag);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Tag[])}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,96 +0,0 @@
|
||||
@import "../styles/colors.scss";
|
||||
|
||||
.postDetails {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.postDetailsInner {
|
||||
margin-left: 6px;
|
||||
text-transform: uppercase;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.4em;
|
||||
font-weight: 400;
|
||||
color: $color-dark-grey;
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
margin-right: 10px;
|
||||
|
||||
font-weight: 600;
|
||||
font-size: 1.3rem;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
||||
top: 7px;
|
||||
right: -6px;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-left: 0;
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.postDetailsInner {
|
||||
color: $color-light-grey;
|
||||
}
|
||||
}
|
||||
|
||||
.authorImage {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.dateAndTime {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.readingTime {
|
||||
margin-left: 20px;
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
||||
top: 7px;
|
||||
left: -11px;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import { DocInfo } from "../lib/content";
|
||||
import { DateSpan } from "./display/DateSpan";
|
||||
|
||||
import styles from "./PostDetails.module.scss";
|
||||
|
||||
export const PostDetails: FC<
|
||||
React.PropsWithChildren<{ doc: DocInfo & { readingTime?: number } }>
|
||||
> = ({ doc, children }) => {
|
||||
return (
|
||||
<div className={styles.postDetails}>
|
||||
<div>
|
||||
<img
|
||||
className={styles.authorImage}
|
||||
src="/media/profile.png"
|
||||
alt="Blake Rain profile picture"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.postDetailsInner}>
|
||||
<ul>
|
||||
<li>Blake Rain</li>
|
||||
</ul>
|
||||
<div className={styles.dateAndTime}>
|
||||
<DateSpan date={doc.published || "1970-01-01T00:00:00.000Z"} />
|
||||
{typeof doc.readingTime === "number" && (
|
||||
<span className={styles.readingTime}>
|
||||
{doc.readingTime} min read
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,37 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
|
||||
const BrowserIcon: FC<{ name: string }> = ({ name }) => {
|
||||
if (name.includes("Chrome")) {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Google Chrome</title>
|
||||
<path d="M12 0C8.21 0 4.831 1.757 2.632 4.501l3.953 6.848A5.454 5.454 0 0 1 12 6.545h10.691A12 12 0 0 0 12 0zM1.931 5.47A11.943 11.943 0 0 0 0 12c0 6.012 4.42 10.991 10.189 11.864l3.953-6.847a5.45 5.45 0 0 1-6.865-2.29zm13.342 2.166a5.446 5.446 0 0 1 1.45 7.09l.002.001h-.002l-5.344 9.257c.206.01.413.016.621.016 6.627 0 12-5.373 12-12 0-1.54-.29-3.011-.818-4.364zM12 16.364a4.364 4.364 0 1 1 0-8.728 4.364 4.364 0 0 1 0 8.728Z" />
|
||||
</svg>
|
||||
);
|
||||
} else if (name.includes("Safari")) {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Safari</title>
|
||||
<path d="M12 24C5.373 24 0 18.627 0 12S5.373 0 12 0s12 5.373 12 12-5.373 12-12 12zm0-.75c6.213 0 11.25-5.037 11.25-11.25S18.213.75 12 .75.75 5.787.75 12 5.787 23.25 12 23.25zM12 2a.25.25 0 0 1 .25.25v1a.25.25 0 1 1-.5 0v-1A.25.25 0 0 1 12 2zm0 18.5a.25.25 0 0 1 .25.25v1a.25.25 0 1 1-.5 0v-1a.25.25 0 0 1 .25-.25zm7.071-15.571a.25.25 0 0 1 0 .353l-.707.708a.25.25 0 0 1-.354-.354l.708-.707a.25.25 0 0 1 .353 0zM5.99 18.01a.25.25 0 0 1 0 .354l-.708.707a.25.25 0 1 1-.353-.353l.707-.708a.25.25 0 0 1 .354 0zM4.929 4.93a.25.25 0 0 1 .353 0l.708.707a.25.25 0 0 1-.354.354l-.707-.708a.25.25 0 0 1 0-.353zM18.01 18.01a.25.25 0 0 1 .354 0l.707.708a.25.25 0 1 1-.353.353l-.708-.707a.25.25 0 0 1 0-.354zM2 12a.25.25 0 0 1 .25-.25h1a.25.25 0 1 1 0 .5h-1A.25.25 0 0 1 2 12zm18.5 0a.25.25 0 0 1 .25-.25h1a.25.25 0 1 1 0 .5h-1a.25.25 0 0 1-.25-.25zm-4.593-9.205a.25.25 0 0 1 .133.328l-.391.92a.25.25 0 1 1-.46-.195l.39-.92a.25.25 0 0 1 .328-.133zM8.68 19.825a.25.25 0 0 1 .132.327l-.39.92a.25.25 0 0 1-.46-.195l.39-.92a.25.25 0 0 1 .328-.133zM21.272 8.253a.25.25 0 0 1-.138.325l-.927.375a.25.25 0 1 1-.188-.464l.927-.374a.25.25 0 0 1 .326.138zm-17.153 6.93a.25.25 0 0 1-.138.326l-.927.374a.25.25 0 1 1-.188-.463l.927-.375a.25.25 0 0 1 .326.138zM8.254 2.728a.25.25 0 0 1 .325.138l.375.927a.25.25 0 0 1-.464.188l-.374-.927a.25.25 0 0 1 .138-.326zm6.93 17.153a.25.25 0 0 1 .326.138l.374.927a.25.25 0 1 1-.463.188l-.375-.927a.25.25 0 0 1 .138-.326zM2.795 8.093a.25.25 0 0 1 .328-.133l.92.391a.25.25 0 0 1-.195.46l-.92-.39a.25.25 0 0 1-.133-.328zm17.03 7.228a.25.25 0 0 1 .327-.132l.92.39a.25.25 0 1 1-.195.46l-.92-.39a.25.25 0 0 1-.133-.328zM12.879 12.879L11.12 11.12l-4.141 5.9 5.899-4.142zm6.192-7.95l-5.834 8.308-8.308 5.834 5.834-8.308 8.308-5.834z" />
|
||||
</svg>
|
||||
);
|
||||
} else if (name.includes("Firefox")) {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Firefox Browser</title>
|
||||
<path d="M8.824 7.287c.008 0 .004 0 0 0zm-2.8-1.4c.006 0 .003 0 0 0zm16.754 2.161c-.505-1.215-1.53-2.528-2.333-2.943.654 1.283 1.033 2.57 1.177 3.53l.002.02c-1.314-3.278-3.544-4.6-5.366-7.477-.091-.147-.184-.292-.273-.446a3.545 3.545 0 01-.13-.24 2.118 2.118 0 01-.172-.46.03.03 0 00-.027-.03.038.038 0 00-.021 0l-.006.001a.037.037 0 00-.01.005L15.624 0c-2.585 1.515-3.657 4.168-3.932 5.856a6.197 6.197 0 00-2.305.587.297.297 0 00-.147.37c.057.162.24.24.396.17a5.622 5.622 0 012.008-.523l.067-.005a5.847 5.847 0 011.957.222l.095.03a5.816 5.816 0 01.616.228c.08.036.16.073.238.112l.107.055a5.835 5.835 0 01.368.211 5.953 5.953 0 012.034 2.104c-.62-.437-1.733-.868-2.803-.681 4.183 2.09 3.06 9.292-2.737 9.02a5.164 5.164 0 01-1.513-.292 4.42 4.42 0 01-.538-.232c-1.42-.735-2.593-2.121-2.74-3.806 0 0 .537-2 3.845-2 .357 0 1.38-.998 1.398-1.287-.005-.095-2.029-.9-2.817-1.677-.422-.416-.622-.616-.8-.767a3.47 3.47 0 00-.301-.227 5.388 5.388 0 01-.032-2.842c-1.195.544-2.124 1.403-2.8 2.163h-.006c-.46-.584-.428-2.51-.402-2.913-.006-.025-.343.176-.389.206-.406.29-.787.616-1.136.974-.397.403-.76.839-1.085 1.303a9.816 9.816 0 00-1.562 3.52c-.003.013-.11.487-.19 1.073-.013.09-.026.181-.037.272a7.8 7.8 0 00-.069.667l-.002.034-.023.387-.001.06C.386 18.795 5.593 24 12.016 24c5.752 0 10.527-4.176 11.463-9.661.02-.149.035-.298.052-.448.232-1.994-.025-4.09-.753-5.844z" />
|
||||
</svg>
|
||||
);
|
||||
} else if (name.includes("Edge")) {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Microsoft Edge</title>
|
||||
<path d="M21.86 17.86q.14 0 .25.12.1.13.1.25t-.11.33l-.32.46-.43.53-.44.5q-.21.25-.38.42l-.22.23q-.58.53-1.34 1.04-.76.51-1.6.91-.86.4-1.74.64t-1.67.24q-.9 0-1.69-.28-.8-.28-1.48-.78-.68-.5-1.22-1.17-.53-.66-.92-1.44-.38-.77-.58-1.6-.2-.83-.2-1.67 0-1 .32-1.96.33-.97.87-1.8.14.95.55 1.77.41.82 1.02 1.5.6.68 1.38 1.21.78.54 1.64.9.86.36 1.77.56.92.2 1.8.2 1.12 0 2.18-.24 1.06-.23 2.06-.72l.2-.1.2-.05zm-15.5-1.27q0 1.1.27 2.15.27 1.06.78 2.03.51.96 1.24 1.77.74.82 1.66 1.4-1.47-.2-2.8-.74-1.33-.55-2.48-1.37-1.15-.83-2.08-1.9-.92-1.07-1.58-2.33T.36 14.94Q0 13.54 0 12.06q0-.81.32-1.49.31-.68.83-1.23.53-.55 1.2-.96.66-.4 1.35-.66.74-.27 1.5-.39.78-.12 1.55-.12.7 0 1.42.1.72.12 1.4.35.68.23 1.32.57.63.35 1.16.83-.35 0-.7.07-.33.07-.65.23v-.02q-.63.28-1.2.74-.57.46-1.05 1.04-.48.58-.87 1.26-.38.67-.65 1.39-.27.71-.42 1.44-.15.72-.15 1.38zM11.96.06q1.7 0 3.33.39 1.63.38 3.07 1.15 1.43.77 2.62 1.93 1.18 1.16 1.98 2.7.49.94.76 1.96.28 1 .28 2.08 0 .89-.23 1.7-.24.8-.69 1.48-.45.68-1.1 1.22-.64.53-1.45.88-.54.24-1.11.36-.58.13-1.16.13-.42 0-.97-.03-.54-.03-1.1-.12-.55-.1-1.05-.28-.5-.19-.84-.5-.12-.09-.23-.24-.1-.16-.1-.33 0-.15.16-.35.16-.2.35-.5.2-.28.36-.68.16-.4.16-.95 0-1.06-.4-1.96-.4-.91-1.06-1.64-.66-.74-1.52-1.28-.86-.55-1.79-.89-.84-.3-1.72-.44-.87-.14-1.76-.14-1.55 0-3.06.45T.94 7.55q.71-1.74 1.81-3.13 1.1-1.38 2.52-2.35Q6.68 1.1 8.37.58q1.7-.52 3.58-.52Z" />
|
||||
</svg>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default BrowserIcon;
|
@ -1,164 +0,0 @@
|
||||
import React, { FC, useMemo } from "react";
|
||||
import cn from "classnames";
|
||||
import { BrowserData } from "../../lib/analytics";
|
||||
import reportStyles from "./Report.module.scss";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { formatNumber } from "../../lib/utils";
|
||||
|
||||
const OTHER_COLORS = ["#6588b7", "#88a2bc", "#f0dbb0", "#efb680", "#d99477"];
|
||||
|
||||
const BROWSER_COLORS: { [key: string]: string } = {
|
||||
Safari: "#4594b5",
|
||||
Chrome: "#FFA055",
|
||||
Firefox: "#C9472F",
|
||||
};
|
||||
|
||||
function browserColor(name: string, index: number): string {
|
||||
const color = BROWSER_COLORS[name];
|
||||
if (color) {
|
||||
return color;
|
||||
}
|
||||
|
||||
return OTHER_COLORS[index % OTHER_COLORS.length];
|
||||
}
|
||||
|
||||
type NamedData = { [name: string]: any };
|
||||
|
||||
interface CombinedData extends NamedData {
|
||||
label: string;
|
||||
}
|
||||
|
||||
const BrowserTooltip = ({
|
||||
year,
|
||||
param,
|
||||
names,
|
||||
formatDay,
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
year: number;
|
||||
param: number;
|
||||
names: string[];
|
||||
formatDay: (year: number, param: number, category: string) => string;
|
||||
active?: boolean;
|
||||
payload?: any;
|
||||
}) => {
|
||||
if (active && payload && payload.length > 0) {
|
||||
const data = payload[0].payload as CombinedData;
|
||||
const total = names.reduce((total, name) => total + data[name], 0);
|
||||
|
||||
const rows: any[] = [];
|
||||
names.forEach((name, index) => {
|
||||
if (data[name] > 0) {
|
||||
rows.unshift(
|
||||
<tr key={index.toString()}>
|
||||
<th
|
||||
style={{
|
||||
color: "#000000",
|
||||
backgroundColor: browserColor(name, index),
|
||||
}}
|
||||
>
|
||||
{name.replace("-", " ")}
|
||||
</th>
|
||||
<td>{formatNumber(data[name], 0)}</td>
|
||||
<td>
|
||||
{formatNumber((100 * data[name]) / total, 0, undefined, "%")}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={cn(reportStyles.tooltip, reportStyles.large)}>
|
||||
<p className={reportStyles.title}>
|
||||
{formatDay(year, param, data.label)} - {formatNumber(total, 0)} views
|
||||
</p>
|
||||
<table>
|
||||
<tbody>{rows}</tbody>
|
||||
</table>
|
||||
{names.length === 0 && (
|
||||
<div className={reportStyles.notice}>No Data</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return <div className={reportStyles.tooltip}></div>;
|
||||
}
|
||||
};
|
||||
|
||||
export const BrowserReport: FC<{
|
||||
year: number;
|
||||
param: number;
|
||||
formatDay: (year: number, param: number, category: string) => string;
|
||||
browserData: BrowserData;
|
||||
startOffset: number;
|
||||
labelMapper: (day: number) => string;
|
||||
}> = ({ year, param, formatDay, browserData, startOffset, labelMapper }) => {
|
||||
const [browsers, names] = useMemo(() => {
|
||||
let combined: CombinedData[] = [];
|
||||
let names: string[] = Object.keys(browserData);
|
||||
let totals: { [key: string]: number } = {};
|
||||
|
||||
Object.keys(browserData).forEach((name) => {
|
||||
let data = browserData[name];
|
||||
let total = 0;
|
||||
|
||||
for (let item of data) {
|
||||
let index = item.day - startOffset;
|
||||
while (index >= combined.length) {
|
||||
combined.push({
|
||||
label: labelMapper(startOffset + combined.length),
|
||||
});
|
||||
}
|
||||
|
||||
combined[index][name] = item.count || 0;
|
||||
total += item.count || 0;
|
||||
}
|
||||
|
||||
totals[name] = total;
|
||||
});
|
||||
|
||||
names.sort((a, b) => totals[a] - totals[b]);
|
||||
|
||||
return [combined, names];
|
||||
}, [browserData, labelMapper, startOffset]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AreaChart width={1000} height={400} data={browsers}>
|
||||
{names.map((name, index) => (
|
||||
<Area
|
||||
key={index.toString()}
|
||||
type="monotone"
|
||||
dataKey={name}
|
||||
stackId="1"
|
||||
stroke={browserColor(name, index)}
|
||||
fill={browserColor(name, index)}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
<CartesianGrid stroke="#ccc" strokeDasharray="5 5" />
|
||||
<XAxis dataKey="label" />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
content={
|
||||
<BrowserTooltip
|
||||
year={year}
|
||||
param={param}
|
||||
names={names}
|
||||
formatDay={formatDay}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</AreaChart>
|
||||
</>
|
||||
);
|
||||
};
|
@ -1,48 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import {
|
||||
getMonthViews,
|
||||
getBrowsersMonth,
|
||||
getMonthPageCount,
|
||||
} from "../../lib/analytics";
|
||||
import { Report } from "./Report";
|
||||
|
||||
const MonthlyReport: FC<{ paths: string[]; token: string }> = ({
|
||||
paths,
|
||||
token,
|
||||
}) => {
|
||||
return (
|
||||
<Report
|
||||
paths={paths}
|
||||
paramInfo={{
|
||||
min: 0,
|
||||
max: 11,
|
||||
startOffset: 1,
|
||||
labelMapper: (day) => day.toString(),
|
||||
fromDate: (date) => date.getMonth(),
|
||||
format: (year, month) =>
|
||||
`${(1 + month).toString().padStart(2, "0")}/${year
|
||||
.toString()
|
||||
.padStart(4, "0")}`,
|
||||
formatDay: (year, month, day) =>
|
||||
`${day.toString().padStart(2, "0")}/${(1 + month)
|
||||
.toString()
|
||||
.padStart(2, "0")}/${year.toString().padStart(4, "0")}`,
|
||||
}}
|
||||
getData={async (path, year, month) => {
|
||||
const data = (await getMonthViews(token, path, year, month)).map(
|
||||
(item) => ({
|
||||
category: item.day.toString(),
|
||||
views: item.count || 0,
|
||||
scroll: item.scroll || 0,
|
||||
duration: item.duration || 0,
|
||||
})
|
||||
);
|
||||
const browsers = (await getBrowsersMonth(token, year, month)).browsers;
|
||||
const pages = await getMonthPageCount(token, year, month);
|
||||
return { data, browsers, pages };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonthlyReport;
|
@ -1,222 +0,0 @@
|
||||
@import "../../styles/colors.scss";
|
||||
|
||||
.reportContainer {
|
||||
margin: 2em 0;
|
||||
}
|
||||
|
||||
.reportToolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
background-color: $primary-background;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
|
||||
.left,
|
||||
.right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.left {
|
||||
padding-left: 10px;
|
||||
padding-top: 8px;
|
||||
|
||||
button + button {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
padding-top: 4px;
|
||||
padding-right: 5px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.reportTabButton {
|
||||
display: inline-block;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.activeTabButton {
|
||||
background-color: $button-active-background;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.reportContents {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
border-color: $primary-background;
|
||||
border-width: 1px;
|
||||
border-style: none solid solid solid;
|
||||
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.reportControls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
background-color: $color-light-grey;
|
||||
padding: 1rem;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
line-height: 1.15;
|
||||
padding: 1rem 2rem;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
|
||||
background-color: $button-background;
|
||||
}
|
||||
|
||||
span {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
span + div,
|
||||
div + span,
|
||||
div + div,
|
||||
span + span {
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
select {
|
||||
max-width: 30rem;
|
||||
}
|
||||
}
|
||||
|
||||
.reportCharts {
|
||||
padding: 2rem 0;
|
||||
|
||||
> div + div {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.reportChartsRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
|
||||
min-width: 50%;
|
||||
max-width: 100%;
|
||||
|
||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
thead {
|
||||
tr {
|
||||
background-color: $primary-background;
|
||||
color: white;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $color-light-grey;
|
||||
}
|
||||
|
||||
&:nth-of-type(even) {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-color: $color-mid-grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
font-size: 80%;
|
||||
width: 22rem;
|
||||
background-color: $color-white-grey;
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
background-color: $color-light-grey;
|
||||
}
|
||||
|
||||
.notice {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
table {
|
||||
border: none;
|
||||
width: 100%;
|
||||
|
||||
tbody tr {
|
||||
th,
|
||||
td {
|
||||
width: 50%;
|
||||
padding: 0.25rem 1rem;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
background-color: $color-light-grey;
|
||||
}
|
||||
|
||||
td {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.large {
|
||||
width: 30rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.reportControls {
|
||||
background-color: $color-mid-grey;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
background-color: $color-dark-grey;
|
||||
|
||||
.title,
|
||||
table tbody tr th {
|
||||
background-color: $color-mid-grey;
|
||||
}
|
||||
}
|
||||
|
||||
.reportCharts {
|
||||
table {
|
||||
tbody {
|
||||
tr {
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $color-mid-grey;
|
||||
}
|
||||
|
||||
&:nth-of-type(even) {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-color: $color-dark-grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,331 +0,0 @@
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import styles from "./Report.module.scss";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { BrowserData, PageCount } from "../../lib/analytics";
|
||||
import { BrowserReport } from "./BrowserReport";
|
||||
import { formatNumber } from "../../lib/utils";
|
||||
|
||||
const PIE_COLORS = [
|
||||
"#003f5c",
|
||||
"#2f4b7c",
|
||||
"#665191",
|
||||
"#a05195",
|
||||
"#d45087",
|
||||
"#f95d6a",
|
||||
"#ff7c43",
|
||||
"#ffa600",
|
||||
];
|
||||
|
||||
export interface ReportView {
|
||||
category: string;
|
||||
views: number;
|
||||
scroll: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface ParamInfo {
|
||||
min: number;
|
||||
max: number;
|
||||
startOffset: number;
|
||||
labelMapper: (day: number) => string;
|
||||
fromDate: (date: Date) => number;
|
||||
format: (year: number, param: number) => string;
|
||||
formatDay: (year: number, param: number, category: string) => string;
|
||||
}
|
||||
|
||||
export interface ReportProps {
|
||||
paths: string[];
|
||||
paramInfo: ParamInfo;
|
||||
getData: (
|
||||
path: string,
|
||||
year: number,
|
||||
param: number
|
||||
) => Promise<{
|
||||
data: ReportView[];
|
||||
browsers: BrowserData;
|
||||
pages: PageCount[];
|
||||
}>;
|
||||
}
|
||||
|
||||
const ReportTooltip = ({
|
||||
year,
|
||||
param,
|
||||
active,
|
||||
payload,
|
||||
paramInfo,
|
||||
}: {
|
||||
year: number;
|
||||
param: number;
|
||||
active?: boolean;
|
||||
payload?: any;
|
||||
paramInfo: ParamInfo;
|
||||
}) => {
|
||||
if (active && payload && payload.length > 0) {
|
||||
const view = payload[0].payload as ReportView;
|
||||
|
||||
return (
|
||||
<div className={styles.tooltip}>
|
||||
<p className={styles.title}>
|
||||
{paramInfo.formatDay(year, param, view.category)}
|
||||
</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>View Count</th>
|
||||
<td>{formatNumber(view.views, 0)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Avg. Scroll</th>
|
||||
<td>
|
||||
{view.views > 0
|
||||
? formatNumber(view.scroll / view.views, 0, undefined, "%")
|
||||
: "-"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Avg. Duration</th>
|
||||
<td>
|
||||
{view.views > 0
|
||||
? formatNumber(
|
||||
view.duration / view.views,
|
||||
0,
|
||||
undefined,
|
||||
" secs"
|
||||
)
|
||||
: "-"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className={styles.tooltip}>
|
||||
<div className={styles.notice}>No Data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ReportPageTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: any;
|
||||
}) => {
|
||||
if (active && payload && payload.length > 0) {
|
||||
const page = payload[0].payload as PageCount;
|
||||
|
||||
return (
|
||||
<div className={styles.tooltip}>
|
||||
<p className={styles.title}>{page.page}</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>View Count</th>
|
||||
<td>{formatNumber(page.count, 0)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className={styles.tooltip}>
|
||||
<div className={styles.notice}>No Data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const Report: FC<ReportProps> = ({ paths, paramInfo, getData }) => {
|
||||
const now = new Date();
|
||||
const [path, setPath] = useState("site");
|
||||
const [year, setYear] = useState(now.getFullYear());
|
||||
const [param, setParam] = useState(paramInfo.fromDate(now));
|
||||
const [views, setViews] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [scroll, setScroll] = useState(0);
|
||||
const [data, setData] = useState<ReportView[]>([]);
|
||||
const [browsers, setBrowsers] = useState<BrowserData>({});
|
||||
const [pages, setPages] = useState<PageCount[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getData(path, year, param).then(({ data, browsers, pages }) => {
|
||||
let total_views = 0;
|
||||
let counted_views = 0;
|
||||
let total_scroll = 0;
|
||||
let total_duration = 0;
|
||||
|
||||
data.forEach((item) => {
|
||||
if (item.scroll > 0 && item.duration > 0) {
|
||||
total_scroll += item.scroll;
|
||||
total_duration += item.duration;
|
||||
counted_views += item.views;
|
||||
}
|
||||
|
||||
total_views += item.views;
|
||||
});
|
||||
|
||||
setBrowsers(browsers);
|
||||
setData(data);
|
||||
setViews(total_views);
|
||||
|
||||
pages.sort((a, b) => b.count - a.count);
|
||||
setPages(pages);
|
||||
|
||||
if (counted_views > 0) {
|
||||
setScroll(total_scroll / counted_views);
|
||||
setDuration(total_duration / counted_views);
|
||||
} else {
|
||||
setScroll(0);
|
||||
setDuration(0);
|
||||
}
|
||||
});
|
||||
}, [getData, path, year, param]);
|
||||
|
||||
const handlePrevClick = () => {
|
||||
if (param === paramInfo.min) {
|
||||
setYear(year - 1);
|
||||
setParam(paramInfo.max);
|
||||
} else {
|
||||
setParam(param - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextClick = () => {
|
||||
if (param === paramInfo.max) {
|
||||
setYear(year + 1);
|
||||
setParam(paramInfo.min);
|
||||
} else {
|
||||
setParam(param + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathChange: React.ChangeEventHandler<HTMLSelectElement> = (
|
||||
event
|
||||
) => {
|
||||
setPath(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.reportContents}>
|
||||
<div className={styles.reportControls}>
|
||||
<span>
|
||||
<b>Date:</b> {paramInfo.format(year, param)}
|
||||
</span>
|
||||
<div className="buttonGroup">
|
||||
<button type="button" onClick={handlePrevClick}>
|
||||
←
|
||||
</button>
|
||||
<button type="button" onClick={handleNextClick}>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<select value={path} onChange={handlePathChange}>
|
||||
{paths.map((path, index) => (
|
||||
<option key={index.toString()} value={path}>
|
||||
{path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{data.length > 0 && (
|
||||
<div className={styles.reportControls}>
|
||||
<span>
|
||||
<b>Total:</b> {views}
|
||||
</span>
|
||||
{scroll > 0 && (
|
||||
<span>
|
||||
<b>Avg. Scroll:</b> {scroll.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
{duration > 0 && (
|
||||
<span>
|
||||
<b>Avg. Duration:</b> {duration.toFixed(2)} seconds
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.reportCharts}>
|
||||
{data.length > 0 && (
|
||||
<LineChart width={1000} height={400} data={data}>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="views"
|
||||
stroke="#0074d9"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<CartesianGrid stroke="#ccc" strokeDasharray="5 5" />
|
||||
<XAxis dataKey="category" />
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
content={
|
||||
<ReportTooltip
|
||||
year={year}
|
||||
param={param}
|
||||
paramInfo={paramInfo}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</LineChart>
|
||||
)}
|
||||
{browsers && (
|
||||
<BrowserReport
|
||||
year={year}
|
||||
param={param}
|
||||
formatDay={paramInfo.formatDay}
|
||||
startOffset={paramInfo.startOffset}
|
||||
browserData={browsers}
|
||||
labelMapper={paramInfo.labelMapper}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.reportChartsRow}>
|
||||
<PieChart width={500} height={400}>
|
||||
<Pie data={pages} dataKey="count" fill="#0074d9">
|
||||
{pages.map((_, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={PIE_COLORS[index % PIE_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<ReportPageTooltip />} />
|
||||
</PieChart>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page</th>
|
||||
<th style={{ textAlign: "right" }}>Views</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pages.map((page, index) => (
|
||||
<tr key={index.toString()}>
|
||||
<td>{page.page}</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
{formatNumber(page.count, 0)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,47 +0,0 @@
|
||||
@import "../../styles/colors.scss";
|
||||
|
||||
.signInFormContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.signInForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
margin: 5em 10em;
|
||||
padding: 1em;
|
||||
|
||||
background-color: $primary-background;
|
||||
|
||||
button {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
margin: 0.5em 0;
|
||||
|
||||
label {
|
||||
width: 25%;
|
||||
padding: 1rem 0;
|
||||
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 75%;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: $color-red;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
min-height: 1.6em;
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
import React, { FC, useState } from "react";
|
||||
|
||||
import { authenticate } from "../../lib/analytics";
|
||||
|
||||
import styles from "./SignIn.module.scss";
|
||||
|
||||
const SignIn: FC<{ setToken: (token: string) => void }> = ({ setToken }) => {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const canSubmit = username.length > 1 && password.length > 1;
|
||||
|
||||
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setProcessing(true);
|
||||
authenticate(username, password)
|
||||
.then((token) => setToken(token))
|
||||
.catch((err) => {
|
||||
console.log("Sign in API error", err, typeof err);
|
||||
setProcessing(false);
|
||||
setError(err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUsernameChange: React.ChangeEventHandler<HTMLInputElement> = (
|
||||
event
|
||||
) => {
|
||||
setUsername(event.target.value);
|
||||
};
|
||||
|
||||
const handlePasswordChange: React.ChangeEventHandler<HTMLInputElement> = (
|
||||
event
|
||||
) => {
|
||||
setPassword(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.signInFormContainer}>
|
||||
<form
|
||||
className={styles.signInForm}
|
||||
noValidate
|
||||
autoComplete="off"
|
||||
onSubmit={handleFormSubmit}
|
||||
>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="Username"
|
||||
disabled={processing}
|
||||
value={username}
|
||||
onChange={handleUsernameChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
disabled={processing}
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.error}>{error || " "}</div>
|
||||
<button type="submit" disabled={!canSubmit || processing}>
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignIn;
|
@ -1,46 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import {
|
||||
getWeekViews,
|
||||
getBrowsersWeek,
|
||||
getWeekPageCount,
|
||||
} from "../../lib/analytics";
|
||||
import { getISOWeek } from "../../lib/utils";
|
||||
import { Report } from "./Report";
|
||||
|
||||
const WEEK_LABELS: string[] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
const WeeklyReport: FC<{ paths: string[]; token: string }> = ({
|
||||
paths,
|
||||
token,
|
||||
}) => {
|
||||
return (
|
||||
<Report
|
||||
paths={paths}
|
||||
paramInfo={{
|
||||
min: 1,
|
||||
max: 52,
|
||||
startOffset: 0,
|
||||
labelMapper: (day) => WEEK_LABELS[day],
|
||||
fromDate: getISOWeek,
|
||||
format: (year, week) => `${year.toString()} W${week.toString()}`,
|
||||
formatDay: (year, week, category) =>
|
||||
`${year.toString()} W${week.toString()} ${category}`,
|
||||
}}
|
||||
getData={async (path, year, week) => {
|
||||
const data = (await getWeekViews(token, path, year, week)).map(
|
||||
(item) => ({
|
||||
category: WEEK_LABELS[item.day],
|
||||
views: item.count || 0,
|
||||
scroll: item.scroll || 0,
|
||||
duration: item.duration || 0,
|
||||
})
|
||||
);
|
||||
const browsers = (await getBrowsersWeek(token, year, week)).browsers;
|
||||
const pages = await getWeekPageCount(token, year, week);
|
||||
return { data, browsers, pages };
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WeeklyReport;
|
@ -1,698 +0,0 @@
|
||||
@import "../../styles/colors.scss";
|
||||
@import "../../styles/fonts.scss";
|
||||
@import "../../styles/tools.scss";
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
|
||||
margin: 0 auto;
|
||||
padding: 70px 70px 0px;
|
||||
}
|
||||
|
||||
.headerWithImage {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
|
||||
.headerInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.title {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.details {
|
||||
background: white;
|
||||
border-bottom: 1px solid white;
|
||||
border-radius: 5px 5px 0 0;
|
||||
|
||||
width: 100%;
|
||||
|
||||
padding-left: 40px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.headerInner {
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 0.2em 0;
|
||||
font-size: 5.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
margin: 20px 0 0 0;
|
||||
color: white;
|
||||
font-family: $text-font-family;
|
||||
font-size: 2.3rem;
|
||||
line-height: 1.4em;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.details {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
|
||||
background-color: white;
|
||||
|
||||
margin: 0 auto;
|
||||
padding: 50px 70px 6vw;
|
||||
min-height: 230px;
|
||||
|
||||
font-family: $text-font-family;
|
||||
font-size: 2rem;
|
||||
line-height: 1.6em;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
min-width: 100%;
|
||||
color: darken($color-dark-grey, 5%);
|
||||
font-family: $body-font-family;
|
||||
|
||||
&:hover {
|
||||
> a {
|
||||
> span {
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -1em;
|
||||
top: 0.125em;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
|
||||
opacity: 0.5;
|
||||
background-color: darken($color-mid-grey, 30%);
|
||||
mask-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 16 16"><path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/><path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243L6.586 4.672z"/></svg>');
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
+ figure {
|
||||
margin-top: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.5em 0 0.4em;
|
||||
|
||||
font-size: 4.2rem;
|
||||
line-height: 1.25em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0.5em 0 0.4em;
|
||||
|
||||
font-size: 3.2rem;
|
||||
line-height: 1.25em;
|
||||
font-weight: 600;
|
||||
|
||||
+ h3 {
|
||||
margin-top: 0.7em;
|
||||
}
|
||||
|
||||
+ h4 {
|
||||
margin-top: 0.7em;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0.5em 0 0.2em;
|
||||
|
||||
font-size: 2.5rem;
|
||||
line-height: 1.3em;
|
||||
font-weight: 600;
|
||||
|
||||
+ h4 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0.5em 0 0.2em;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin: 0.5em 0;
|
||||
padding: 0.3em 1em 0.9em;
|
||||
font-size: 3.2rem;
|
||||
line-height: 1.35em;
|
||||
text-align: center;
|
||||
color: $color-blue;
|
||||
}
|
||||
|
||||
h6 {
|
||||
margin: 0.5em 0 0.2em 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $color-dark-grey;
|
||||
word-break: break-word;
|
||||
box-shadow: $color-dark-grey 0 -1px 0 inset;
|
||||
|
||||
&:hover {
|
||||
color: $color-blue;
|
||||
text-decoration: none;
|
||||
box-shadow: $color-blue 0 -1px 0 inset;
|
||||
}
|
||||
|
||||
&[data-footnote-backref="true"] {
|
||||
box-shadow: none;
|
||||
color: $color-light-grey;
|
||||
}
|
||||
|
||||
> code {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1.5em 0;
|
||||
min-width: 100%;
|
||||
|
||||
+ h1,
|
||||
+ h2 {
|
||||
margin-top: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol,
|
||||
dl {
|
||||
margin: 0 0 1.5em 0;
|
||||
max-width: 100%;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
li {
|
||||
word-break: break-word;
|
||||
margin: 0.5em 0;
|
||||
padding-left: 0.3em;
|
||||
line-height: 1.6em;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
|
||||
overflow-x: auto;
|
||||
|
||||
border: 1px darken($color-light-grey, 10%) solid;
|
||||
border-radius: 5px;
|
||||
color: #383a42;
|
||||
background-color: rgb(255, 247, 236);
|
||||
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.5em;
|
||||
|
||||
&::selection {
|
||||
color: darken($color-mid-grey, 25%);
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
background: transparent;
|
||||
|
||||
&.language-box-drawing,
|
||||
&:not([class]) {
|
||||
line-height: 1.3em;
|
||||
}
|
||||
|
||||
&:not(span) {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1.5em;
|
||||
padding: 0 1.5em;
|
||||
min-width: 100%;
|
||||
|
||||
border-left: 3px solid #3eb0ef;
|
||||
font-style: italic;
|
||||
color: $color-mid-grey;
|
||||
|
||||
p {
|
||||
margin: 0 0 1em 0;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-style: italic;
|
||||
line-height: inherit;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
figure {
|
||||
&:global(.code) {
|
||||
width: 100%;
|
||||
margin: 0.8em 0 2.3em;
|
||||
|
||||
figcaption {
|
||||
margin: 1em 0 0;
|
||||
|
||||
color: darken($color-mid-grey, 10%);
|
||||
font-family: $body-font-family;
|
||||
font-size: 75%;
|
||||
line-height: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
strong,
|
||||
em {
|
||||
color: darken($color-dark-grey, 5%);
|
||||
}
|
||||
|
||||
small {
|
||||
display: inline-block;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
video {
|
||||
display: block;
|
||||
margin: 1.5em auto;
|
||||
max-width: 1040px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 0 5px 2px;
|
||||
font-size: 0.8em;
|
||||
line-height: 1em;
|
||||
font-weight: 400 !important;
|
||||
background-color: lighten($color-light-grey, 15%);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 2em 0;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
left: 50%;
|
||||
display: block;
|
||||
margin-left: -10px;
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background-color: lighten($color-light-grey, 10%);
|
||||
box-shadow: #ffffff 0 0 0 5px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
+ p {
|
||||
margin-top: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
|
||||
margin: 0.5em 0 2.5em;
|
||||
|
||||
font-size: 0.9em;
|
||||
font-family: $body-font-family;
|
||||
|
||||
min-width: 50%;
|
||||
max-width: 100%;
|
||||
|
||||
box-shadow: 2px 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
thead {
|
||||
tr {
|
||||
background-color: $primary-background;
|
||||
color: white;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $color-light-grey;
|
||||
}
|
||||
|
||||
&:nth-of-type(even) {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-color: $color-mid-grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section[data-footnotes="true"] {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.contentInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.headerWithImage {
|
||||
.headerInner {
|
||||
.details {
|
||||
background-color: $dark-mode-background;
|
||||
border-bottom-color: $dark-mode-background;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
background-color: $dark-mode-background;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
|
||||
&:hover {
|
||||
> a {
|
||||
> span {
|
||||
&:before {
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
box-shadow: inset 0 -1px 0 white;
|
||||
|
||||
&[data-footnote-backref="true"] {
|
||||
color: $color-mid-grey;
|
||||
}
|
||||
}
|
||||
|
||||
strong,
|
||||
em {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
blockquote {
|
||||
color: lighten($color-mid-grey, 30%);
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #282c34;
|
||||
border-color: darken($color-dark-grey, 10%);
|
||||
color: #c8ced7;
|
||||
}
|
||||
|
||||
code {
|
||||
color: white;
|
||||
background: black;
|
||||
}
|
||||
|
||||
figure {
|
||||
&:global(.code) {
|
||||
figcaption {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
tbody {
|
||||
tr {
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px solid $color-mid-grey;
|
||||
}
|
||||
|
||||
&:nth-of-type(even) {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-color: $color-dark-grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1180px) {
|
||||
.content {
|
||||
h5 {
|
||||
max-width: 1060px;
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1170px) {
|
||||
.header {
|
||||
padding: 60px 11vw 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 11vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.header {
|
||||
padding-left: 5vw;
|
||||
padding-right: 5vw;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 50px 5vw;
|
||||
font-size: 1.8rem;
|
||||
|
||||
h1 {
|
||||
font-size: 3.2rem;
|
||||
line-height: 1.25em;
|
||||
|
||||
+ figure {
|
||||
margin-top: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0.3em;
|
||||
font-size: 2.8rem;
|
||||
line-height: 1.25em;
|
||||
|
||||
+ figure {
|
||||
margin-top: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 0.3em;
|
||||
font-size: 2.4rem;
|
||||
line-height: 1.3em;
|
||||
|
||||
+ figure {
|
||||
margin-top: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 0.3em;
|
||||
font-size: 2.4rem;
|
||||
line-height: 1.3em;
|
||||
|
||||
+ figure {
|
||||
margin-top: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin-bottom: 1em;
|
||||
margin-left: 1.3em;
|
||||
padding: 0 0 0.5em;
|
||||
font-size: 2.4rem;
|
||||
text-align: initial;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1.8rem;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0.2em 0 1.3em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.header {
|
||||
padding: 20px 0 0 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 3.3rem;
|
||||
}
|
||||
|
||||
.headerWithImage {
|
||||
.headerInner {
|
||||
.details {
|
||||
box-sizing: border-box;
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 35px 0.5rem 0 0.5rem;
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
dl,
|
||||
pre {
|
||||
margin-bottom: 1.28em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding: 0 1.3em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.header {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.headerWithImage {
|
||||
.title {
|
||||
font-size: 4rem;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
text-shadow: none;
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
p,
|
||||
li,
|
||||
blockquote {
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.6em;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-size: 3.2rem;
|
||||
}
|
||||
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: none;
|
||||
font-size: 1em;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
a > code {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import cn from "classnames";
|
||||
import { useRouter } from "next/router";
|
||||
import { MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||
|
||||
import { DocInfo } from "../../lib/content";
|
||||
import { Tag } from "../../lib/tags";
|
||||
|
||||
import { ScrollToTopButton } from "../fields/ScrollToTop";
|
||||
import { PostDetails } from "../PostDetails";
|
||||
import { TagList } from "../display/TagList";
|
||||
import { Render } from "./Render";
|
||||
|
||||
import styles from "./Content.module.scss";
|
||||
import { GitLogEntry } from "../../lib/git";
|
||||
import RevisionHistory from "./RevisionHistory";
|
||||
|
||||
const ContentHeader: FC<{
|
||||
tags?: Tag[];
|
||||
doc: DocInfo;
|
||||
featureImage?: string;
|
||||
}> = ({ tags, doc, featureImage }) => {
|
||||
return (
|
||||
<header
|
||||
className={cn(styles.header, styles.outer, {
|
||||
[styles.headerWithImage]: Boolean(featureImage),
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: featureImage
|
||||
? `linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6)), url(${featureImage})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div className={cn(styles.headerInner, styles.inner)}>
|
||||
<h1 className={styles.title}>{doc.title}</h1>
|
||||
{doc.excerpt ? <p className={styles.excerpt}>{doc.excerpt}</p> : null}
|
||||
<div className={styles.details}>
|
||||
<PostDetails doc={doc} />
|
||||
{tags && <TagList tags={tags} large />}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
const ContentBody: FC<{
|
||||
content: MDXRemoteSerializeResult;
|
||||
history: GitLogEntry[];
|
||||
}> = ({ content, history }) => {
|
||||
const router = useRouter();
|
||||
const highlight =
|
||||
"s" in router.query &&
|
||||
typeof router.query["s"] === "string" &&
|
||||
router.query["s"].length > 0
|
||||
? router.query["s"]
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className={cn(styles.content, styles.outer)}>
|
||||
<div className={cn(styles.contentInner, styles.inner)}>
|
||||
<Render content={content} query={highlight} />
|
||||
{history.length > 0 && <RevisionHistory history={history} />}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollToTopButton />
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ContentProps {
|
||||
tags?: Tag[];
|
||||
doc: DocInfo;
|
||||
featureImage?: string;
|
||||
content: MDXRemoteSerializeResult;
|
||||
history?: GitLogEntry[];
|
||||
}
|
||||
|
||||
export const Content: FC<ContentProps> = ({
|
||||
tags,
|
||||
doc,
|
||||
featureImage,
|
||||
content,
|
||||
history = [],
|
||||
}) => {
|
||||
return (
|
||||
<article className="post">
|
||||
<ContentHeader tags={tags} doc={doc} featureImage={featureImage} />
|
||||
<ContentBody content={content} history={history} />
|
||||
</article>
|
||||
);
|
||||
};
|
@ -1,49 +0,0 @@
|
||||
@import "../../styles/colors.scss";
|
||||
@import "../../styles/fonts.scss";
|
||||
|
||||
.imageCard {
|
||||
margin: 0.8em 0 2.3em;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
|
||||
figcaption {
|
||||
margin: 1em 0 0;
|
||||
|
||||
color: darken($color-mid-grey, 10%);
|
||||
font-family: $body-font-family;
|
||||
font-size: 75%;
|
||||
line-height: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.imageCardImage {
|
||||
}
|
||||
|
||||
.imageCardWide {
|
||||
img {
|
||||
max-width: 1040px;
|
||||
}
|
||||
}
|
||||
|
||||
.imageCardFull {
|
||||
img {
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1170px) {
|
||||
.imageCard {
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,623 +0,0 @@
|
||||
import React, {
|
||||
DetailedHTMLProps,
|
||||
FC,
|
||||
HTMLAttributes,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||
import Image from "../display/Image";
|
||||
|
||||
import PreparedIndex from "../../lib/search/index/prepared";
|
||||
import { Range } from "../../lib/search/tree/node";
|
||||
import Load from "../../lib/search/encoding/load";
|
||||
|
||||
import Bookmark from "./components/Bookmark";
|
||||
import Quote from "./components/Quote";
|
||||
import { AnalyticsInformation } from "../Analytics";
|
||||
|
||||
import styles from "./Render.module.scss";
|
||||
|
||||
// Nodes are generated that include a `path` property that is a string containing a comma-separated list of numbers.
|
||||
// This is used to number the elements in the document, and is used to perform index operations (like search
|
||||
// highlighting).
|
||||
interface PathProps {
|
||||
path: number[];
|
||||
}
|
||||
|
||||
// When we're highlighting matching search terms, we want to store the search terms that we've loaded from the
|
||||
// `PreparedIndex`. An array of this structure is stored in the `LoadedSearchPositionContext`. We refer to this when we
|
||||
// perform our search highlighting. Each of these objects contains the path to the document element and the set of
|
||||
// ranges within that element.
|
||||
interface LoadedSearchPosition {
|
||||
path: number[];
|
||||
ranges: Range[];
|
||||
}
|
||||
|
||||
const LoadedSearchPositionsContext = React.createContext<
|
||||
LoadedSearchPosition[]
|
||||
>([]);
|
||||
|
||||
function decodeQuery(query?: string): { docId: number; term: string } {
|
||||
if (typeof query === "string") {
|
||||
return JSON.parse(query);
|
||||
}
|
||||
|
||||
return { docId: -1, term: "" };
|
||||
}
|
||||
|
||||
const SearchPositionsProvider: FC<
|
||||
React.PropsWithChildren<{ query?: string }>
|
||||
> = ({ query, children }) => {
|
||||
const { docId, term } = decodeQuery(query);
|
||||
const [index, setIndex] = useState<PreparedIndex | null>(null);
|
||||
const positions = useMemo(() => {
|
||||
if (term.length === 0 || index === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results = index.search(term, docId);
|
||||
const loaded: LoadedSearchPosition[] = [];
|
||||
for (const position of results.get(docId) || []) {
|
||||
const location = index.locations.getLocation(position.location_id);
|
||||
if (location) {
|
||||
loaded.push({
|
||||
path: location.path,
|
||||
ranges: position.ranges.sort((a, b) => a.start - b.start),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return loaded;
|
||||
}, [index !== null, query || ""]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof query !== "string" || query.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abort = new AbortController();
|
||||
void (async function () {
|
||||
try {
|
||||
const res = await fetch("/data/search.bin", { signal: abort.signal });
|
||||
|
||||
const index = PreparedIndex.load(new Load(await res.arrayBuffer()));
|
||||
setIndex(index);
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
// Ignore abort errors.
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
abort.abort();
|
||||
};
|
||||
}, [query || ""]);
|
||||
|
||||
return (
|
||||
<LoadedSearchPositionsContext.Provider value={positions}>
|
||||
{children}
|
||||
</LoadedSearchPositionsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Given the array of `LoadedSearchPosition` (usually from the `LoadedSearchPositionsContext`) and the path to the
|
||||
// current node (usually from `PathProps`), return any highlight ranges in that node.
|
||||
function getSearchRanges(
|
||||
positions: LoadedSearchPosition[],
|
||||
path: number[]
|
||||
): Range[] {
|
||||
for (const position of positions) {
|
||||
if (position.path.length === path.length) {
|
||||
let match = true;
|
||||
for (let i = 0; i < position.path.length; i++) {
|
||||
if (position.path[i] !== path[i]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (match) {
|
||||
return position.ranges;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function splitRanges(ranges: Range[], offset: number): Range[] {
|
||||
const result: Range[] = [];
|
||||
for (const range of ranges) {
|
||||
if (range.start < offset) {
|
||||
if (range.start + range.length > offset) {
|
||||
result.push({
|
||||
start: range.start,
|
||||
length: offset - range.start,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.push({
|
||||
start: range.start - offset,
|
||||
length: range.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Standard trivial highlighter that takes a set of ranges and a string and returns a React component tree in which the
|
||||
// text is highlighted using `<mark>` elements at various ranges.
|
||||
function renderHighlight(ranges: Range[], text: string): React.ReactElement {
|
||||
const parts: React.ReactElement[] = [];
|
||||
let start_index = 0;
|
||||
|
||||
for (const range of ranges) {
|
||||
const prefix = text.substring(start_index, range.start);
|
||||
if (prefix.length > 0) {
|
||||
parts.push(
|
||||
<React.Fragment key={parts.length.toString()}>{prefix}</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const highlighted = text.substring(range.start, range.start + range.length);
|
||||
if (highlighted.length > 0) {
|
||||
parts.push(<mark key={parts.length.toString()}>{highlighted}</mark>);
|
||||
}
|
||||
|
||||
start_index = range.start + range.length;
|
||||
}
|
||||
|
||||
const suffix = text.substring(start_index);
|
||||
if (suffix.length > 0) {
|
||||
parts.push(
|
||||
<React.Fragment key={parts.length.toString()}>{suffix}</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (parts.length > 0) {
|
||||
return <React.Fragment>{parts}</React.Fragment>;
|
||||
} else {
|
||||
return <React.Fragment>{text}</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
// This function examines an anonymous 'props' object to see if it contains a `data-path` property. If so, and the
|
||||
// property value is a string, then it is split into an array of numbers and returned in a `PathProps` interface.
|
||||
function expandDataPathProps(props: object): PathProps {
|
||||
if (typeof props === "object" && "data-path" in props) {
|
||||
return {
|
||||
path: ((props as any)["data-path"] as string)
|
||||
.split(",")
|
||||
.map((n) => parseInt(n, 10)),
|
||||
};
|
||||
}
|
||||
|
||||
return { path: [] };
|
||||
}
|
||||
|
||||
// Given some phrasing content (similar to a `#text` node), attempt to highlight the contents. This will only perform
|
||||
// this operation if the `LoadedSearchPositionsContext` actually contains any positions; otherwise it will just return
|
||||
// the children in a `React.Fragment`.
|
||||
const RenderPhrasingChildren: FC<React.PropsWithChildren<PathProps>> = ({
|
||||
path,
|
||||
children,
|
||||
}) => {
|
||||
// Get the loaded search highlight positions.
|
||||
const positions = useContext(LoadedSearchPositionsContext);
|
||||
|
||||
// If there are no children, then there's nothing to do.
|
||||
if (typeof children === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If we don't have any positions, just return the children in a fragment.
|
||||
if (positions.length === 0) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// If the `children` is just a string, then use the trivial `renderHighlight` to highlight all the ranges that match
|
||||
// the current path (if any). Note that `renderHighlight` will basically do nothing if `getSearchRanges` returns an
|
||||
// empty array.
|
||||
if (typeof children === "string") {
|
||||
return renderHighlight(getSearchRanges(positions, [...path, 0]), children);
|
||||
}
|
||||
|
||||
// If the `children` is an array of things, then apply highlighting to all the children.
|
||||
if (children instanceof Array) {
|
||||
return (
|
||||
<>
|
||||
{children.map((child, index) => {
|
||||
if (typeof child === "string") {
|
||||
return (
|
||||
<React.Fragment key={index.toString()}>
|
||||
{renderHighlight(
|
||||
getSearchRanges(positions, [...path, index]),
|
||||
child
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
// The child is not a string, so it could be a normal react element. In which case we can just leave it
|
||||
// as-is: if it's something like a `<em>` element, it will have been replaced with our `RenderEmphasis`
|
||||
// component.
|
||||
return child;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// We don't know what to do here: `children` is neither a string nor an array.
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Render an <em> element, but render it's children using `RenderPhrasingChildren`.
|
||||
const RenderEmphasis: (
|
||||
props: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>
|
||||
) => JSX.Element = ({ children, ...props }) => {
|
||||
return (
|
||||
<em>
|
||||
<RenderPhrasingChildren {...expandDataPathProps(props)}>
|
||||
{children}
|
||||
</RenderPhrasingChildren>
|
||||
</em>
|
||||
);
|
||||
};
|
||||
|
||||
// Render a <strong> element, but render it's children using `RenderPhrasingChildren`.
|
||||
const RenderStrong: (
|
||||
props: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>
|
||||
) => JSX.Element = ({ children, ...props }) => {
|
||||
return (
|
||||
<strong>
|
||||
<RenderPhrasingChildren {...expandDataPathProps(props)}>
|
||||
{children}
|
||||
</RenderPhrasingChildren>
|
||||
</strong>
|
||||
);
|
||||
};
|
||||
|
||||
// Render an <li> element, but render it's children using `RenderPhrasingChildren`.
|
||||
const RenderListItem: (
|
||||
props: DetailedHTMLProps<HTMLAttributes<HTMLLIElement>, HTMLLIElement>
|
||||
) => JSX.Element = ({ children, ...props }) => {
|
||||
return (
|
||||
<li>
|
||||
<RenderPhrasingChildren {...expandDataPathProps(props)}>
|
||||
{children}
|
||||
</RenderPhrasingChildren>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
// Render an <a> element, but render it's children using `RenderPhrasingChildren`.
|
||||
const RenderLink: (
|
||||
props: DetailedHTMLProps<
|
||||
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||
HTMLAnchorElement
|
||||
>
|
||||
) => JSX.Element = ({ children, ...props }) => {
|
||||
return (
|
||||
<a {...props}>
|
||||
<RenderPhrasingChildren {...expandDataPathProps(props)}>
|
||||
{children}
|
||||
</RenderPhrasingChildren>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
// Render a <p> element, but render it's children using `RenderPhrasingChildren`.
|
||||
const RenderParagraph: (
|
||||
props: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLParagraphElement>,
|
||||
HTMLParagraphElement
|
||||
>
|
||||
) => JSX.Element = ({ children, ...props }) => {
|
||||
return (
|
||||
<p>
|
||||
<RenderPhrasingChildren {...expandDataPathProps(props)}>
|
||||
{children}
|
||||
</RenderPhrasingChildren>
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
// Render a <blockquote> element, but render it's children using `RenderPhrasingChildren`.
|
||||
const RenderBlockQuote: (
|
||||
props: DetailedHTMLProps<
|
||||
React.BlockquoteHTMLAttributes<HTMLElement>,
|
||||
HTMLElement
|
||||
>
|
||||
) => JSX.Element = ({ children, ...props }) => {
|
||||
return (
|
||||
<blockquote>
|
||||
<RenderPhrasingChildren {...expandDataPathProps(props)}>
|
||||
{children}
|
||||
</RenderPhrasingChildren>
|
||||
</blockquote>
|
||||
);
|
||||
};
|
||||
|
||||
// Create a heading at the given level (1..6) that will render a corresponding heading element (e.g. `<h1>`) that uses
|
||||
// `RenderPhrasingChildren` to render its children.
|
||||
function createHeading(
|
||||
level: number
|
||||
): (
|
||||
props: DetailedHTMLProps<
|
||||
HTMLAttributes<HTMLHeadingElement>,
|
||||
HTMLHeadingElement
|
||||
>
|
||||
) => JSX.Element {
|
||||
if (level < 1 || level > 6) {
|
||||
throw new Error(`Heading level ${level} is not a valid HTML heading level`);
|
||||
}
|
||||
|
||||
return function headingFunction(props) {
|
||||
return React.createElement(
|
||||
`h${level}`,
|
||||
{ id: props.id },
|
||||
<RenderPhrasingChildren {...expandDataPathProps(props)}>
|
||||
{props.children}
|
||||
</RenderPhrasingChildren>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// This component is used to override the `<img>` element rendering in MDX, to perform a number of changes:
|
||||
//
|
||||
// 1. The entire image is wrapped in a `<figure>` and a `<div>` to handle the image positioning.
|
||||
// 2. We use the Next `<Image>` element (actually our version of it) to render the image.
|
||||
// 3. If there is an alt-text for the image, we also render that in a `<figcaption>`.
|
||||
const RenderImage: (
|
||||
props: DetailedHTMLProps<
|
||||
React.ImgHTMLAttributes<HTMLImageElement>,
|
||||
HTMLImageElement
|
||||
>
|
||||
) => JSX.Element = (props) => {
|
||||
const caption = props.alt && props.alt !== "" ? props.alt : undefined;
|
||||
|
||||
return (
|
||||
<figure className={styles.imageCard}>
|
||||
<div className={styles.imageCardImage}>
|
||||
<Image
|
||||
src={props.src || ""}
|
||||
width={
|
||||
typeof props.width === "string"
|
||||
? parseInt(props.width)
|
||||
: props.width
|
||||
}
|
||||
height={
|
||||
typeof props.height === "string"
|
||||
? parseInt(props.height)
|
||||
: props.height
|
||||
}
|
||||
alt={props.alt || ""}
|
||||
/>
|
||||
</div>
|
||||
{caption && <figcaption>{caption}</figcaption>}
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
||||
interface HighlightRow {
|
||||
properties: any;
|
||||
type: "text" | "element";
|
||||
tagName?: string;
|
||||
value?: string;
|
||||
children?: HighlightRow[];
|
||||
}
|
||||
|
||||
function createCodeElement(
|
||||
positions: Range[],
|
||||
row: HighlightRow,
|
||||
index: number,
|
||||
offset: number
|
||||
): { element: React.ReactNode; newOffset: number } {
|
||||
let { properties, type, tagName: TagName, value, children } = row;
|
||||
|
||||
if (type === "text") {
|
||||
if (positions.length > 0 && value) {
|
||||
return {
|
||||
element: (
|
||||
<React.Fragment key={index.toString()}>
|
||||
{renderHighlight(splitRanges(positions, offset), value)}
|
||||
</React.Fragment>
|
||||
),
|
||||
newOffset: offset + value.length,
|
||||
};
|
||||
}
|
||||
|
||||
return { element: value, newOffset: offset + (value || "").length };
|
||||
}
|
||||
|
||||
if (TagName) {
|
||||
let props = {
|
||||
...properties,
|
||||
key: index.toString(),
|
||||
className: properties.className.join(" "),
|
||||
};
|
||||
|
||||
children = children || [];
|
||||
|
||||
const childElements = children.map((child, index) => {
|
||||
const { element, newOffset } = createCodeElement(
|
||||
positions,
|
||||
child,
|
||||
index,
|
||||
offset
|
||||
);
|
||||
offset = newOffset;
|
||||
return element;
|
||||
});
|
||||
|
||||
return {
|
||||
element: <TagName {...props}>{childElements}</TagName>,
|
||||
newOffset: offset,
|
||||
};
|
||||
}
|
||||
|
||||
return { element: null, newOffset: offset };
|
||||
}
|
||||
|
||||
type CodeRenderer = (input: {
|
||||
rows: HighlightRow[];
|
||||
stylesheet: any;
|
||||
useInlineStyles: boolean;
|
||||
}) => React.ReactNode;
|
||||
|
||||
function getCodeRenderer(positions: Range[]): CodeRenderer {
|
||||
return ({ rows }): React.ReactNode => {
|
||||
let offset = 0;
|
||||
return rows.map((node, index) => {
|
||||
const { element, newOffset } = createCodeElement(
|
||||
positions,
|
||||
node,
|
||||
index,
|
||||
offset
|
||||
);
|
||||
offset = newOffset;
|
||||
return element;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const LANGUAGE_RE = /language-(\w+)/;
|
||||
|
||||
function extractLanguage(className?: string): string | undefined {
|
||||
if (typeof className === "string") {
|
||||
const match = className.match(LANGUAGE_RE);
|
||||
if (match && match[1]) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Somewhat annoyingly, the syntax highlighter we use will always want to encapsulate the code block in a `<pre>`
|
||||
// element. When we're executing `RenderCodeBlock`, we are _already_ in a `<pre>`, as this was added during hast
|
||||
// pre-processing.
|
||||
//
|
||||
// To avoid nested `<pre>` elements, we can change the element using the `PreTag` property to the syntax highlighter.
|
||||
// Unfortunately we can't just pass `null` or `undefined`: If we use `null` we'll get an error (quite rightly) from
|
||||
// `React.createElement` which was called by the syntax highlighter. If we use `undefined` then it's the same as not
|
||||
// setting the property, and the syntax highlighter will add a `<pre>`.
|
||||
//
|
||||
// So, naturally we'd thing to just pass in `React.Fragment`. After all, that's what it is for! Alas this is not going
|
||||
// to work, as `React.Fragment` will complain (again, quite rightly) that it's being passed a `className` property. As
|
||||
// it turns out, the syntax highlighter expects to be able to pass a `className` property. This is unfortunate, as the
|
||||
// type of the `PreTag` property is just `React.ReactNode`, which doesn't tell us about this assumption.
|
||||
//
|
||||
// Finally this brings us to `PropIgnoringFragment`, which is just a wrapper around `React.Fragment` that doesn't pass
|
||||
// any properties. We don't need to acknowledge that the syntax highlighter will pass any properties in our types, as
|
||||
// the `PropType` property is just a `React.ReactNode`.
|
||||
const PropIgnoringFragment: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const RenderCodeBlock: (
|
||||
props: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>
|
||||
) => JSX.Element = (props) => {
|
||||
const { path } = expandDataPathProps(props);
|
||||
const loaded = useContext(LoadedSearchPositionsContext);
|
||||
const positions = getSearchRanges(loaded, [...path, 0]);
|
||||
const language = extractLanguage(props.className);
|
||||
const [highlighter, setHighlighter] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (language) {
|
||||
import("./SyntaxHighlight").then((module) => {
|
||||
setHighlighter(module);
|
||||
});
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
if (highlighter && language) {
|
||||
const SyntaxHighlighter = highlighter.SyntaxHighlighter;
|
||||
const content = props.children as string;
|
||||
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
useInlineStyles={false}
|
||||
language={language}
|
||||
renderer={getCodeRenderer(positions)}
|
||||
PreTag={PropIgnoringFragment}
|
||||
>
|
||||
{content.endsWith("\n")
|
||||
? content.substring(0, content.length - 1)
|
||||
: content}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code>
|
||||
<RenderPhrasingChildren {...expandDataPathProps(props)}>
|
||||
{props.children}
|
||||
</RenderPhrasingChildren>
|
||||
</code>
|
||||
);
|
||||
};
|
||||
|
||||
// This component is used to override the `<code>` element in MDX. This component picks the type of transformation to
|
||||
// perform based on `className` of the `<code>` element:
|
||||
//
|
||||
// 1. If the `className` contains `block`, then this is a code block, and we defer the rendering activity to the
|
||||
// `RenderCodeBlock` element for special handling.
|
||||
// 2. Otherwise, we simply render a `<code>` element, but use `RenderPhrasingChildren` to render the contents, which is
|
||||
// similar to our overrides for `<em>` or `<strong>`.
|
||||
const RenderCode: (
|
||||
props: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>
|
||||
) => JSX.Element = (props) => {
|
||||
if (props.className && props.className?.indexOf("block") !== -1) {
|
||||
return <RenderCodeBlock {...props} />;
|
||||
} else {
|
||||
return (
|
||||
<code>
|
||||
<RenderPhrasingChildren {...expandDataPathProps(props)}>
|
||||
{props.children}
|
||||
</RenderPhrasingChildren>
|
||||
</code>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const Render: FC<{
|
||||
content: MDXRemoteSerializeResult;
|
||||
query?: string;
|
||||
}> = ({ content, query: highlight }) => {
|
||||
const components: any = {
|
||||
img: RenderImage,
|
||||
p: RenderParagraph,
|
||||
blockquote: RenderBlockQuote,
|
||||
em: RenderEmphasis,
|
||||
strong: RenderStrong,
|
||||
code: RenderCode,
|
||||
li: RenderListItem,
|
||||
a: RenderLink,
|
||||
h1: createHeading(1),
|
||||
h2: createHeading(2),
|
||||
h3: createHeading(3),
|
||||
h4: createHeading(4),
|
||||
h5: createHeading(5),
|
||||
h6: createHeading(6),
|
||||
|
||||
Bookmark: Bookmark,
|
||||
Quote: Quote,
|
||||
AnalyticsInformation: AnalyticsInformation,
|
||||
};
|
||||
return (
|
||||
<SearchPositionsProvider query={highlight}>
|
||||
<MDXRemote {...content} components={components} />
|
||||
</SearchPositionsProvider>
|
||||
);
|
||||
};
|
@ -1,32 +0,0 @@
|
||||
@import "../../styles/colors.scss";
|
||||
|
||||
.revisionHistory {
|
||||
width: 100%;
|
||||
|
||||
border-top: 1px solid $primary-background;
|
||||
margin: 5rem 0;
|
||||
|
||||
font-size: 80%;
|
||||
|
||||
h3 {
|
||||
font-size: 120%;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.revisionHistory {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
}
|
@ -1,38 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import { GitLogEntry } from "../../lib/git";
|
||||
import { zeroPad } from "../../lib/utils";
|
||||
import styles from "./RevisionHistory.module.scss";
|
||||
|
||||
export interface RevisionHistoryProps {
|
||||
history: GitLogEntry[];
|
||||
}
|
||||
|
||||
const renderDate = (input: string): string => {
|
||||
const date = new Date(input);
|
||||
return `${zeroPad(date.getFullYear(), 4)}-${zeroPad(
|
||||
1 + date.getMonth(),
|
||||
2
|
||||
)}-${zeroPad(date.getDate(), 2)}`;
|
||||
};
|
||||
|
||||
export const RevisionHistory: FC<RevisionHistoryProps> = ({ history }) => {
|
||||
return (
|
||||
<div className={styles.revisionHistory}>
|
||||
<h3>Revisions</h3>
|
||||
<ul>
|
||||
{history.map((entry, index) => (
|
||||
<li key={index.toString()}>
|
||||
{renderDate(entry.date)} —{" "}
|
||||
<a
|
||||
href={`https://github.com/BlakeRain/blakerain.com/commit/${entry.hash}`}
|
||||
>
|
||||
{entry.message}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RevisionHistory;
|
@ -1,19 +0,0 @@
|
||||
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
|
||||
import bash from "react-syntax-highlighter/dist/cjs/languages/hljs/bash";
|
||||
import cpp from "react-syntax-highlighter/dist/cjs/languages/hljs/cpp";
|
||||
import css from "react-syntax-highlighter/dist/cjs/languages/hljs/css";
|
||||
import js from "react-syntax-highlighter/dist/cjs/languages/hljs/javascript";
|
||||
import nginx from "react-syntax-highlighter/dist/cjs/languages/hljs/nginx";
|
||||
import rust from "react-syntax-highlighter/dist/cjs/languages/hljs/rust";
|
||||
import python from "react-syntax-highlighter/dist/cjs/languages/hljs/python";
|
||||
|
||||
SyntaxHighlighter.registerLanguage("bash", bash);
|
||||
SyntaxHighlighter.registerLanguage("cpp", cpp);
|
||||
SyntaxHighlighter.registerLanguage("css", css);
|
||||
SyntaxHighlighter.registerLanguage("javascript", js);
|
||||
SyntaxHighlighter.registerLanguage("nginx", nginx);
|
||||
SyntaxHighlighter.registerLanguage("rust", rust);
|
||||
SyntaxHighlighter.registerLanguage("python", python);
|
||||
|
||||
export { SyntaxHighlighter };
|
@ -1,169 +0,0 @@
|
||||
@import "../../../styles/colors.scss";
|
||||
@import "../../../styles/fonts.scss";
|
||||
|
||||
.bookmark {
|
||||
width: 100%;
|
||||
margin: 0.8em 0 2.3em;
|
||||
}
|
||||
|
||||
.bookmarkContainer {
|
||||
display: flex;
|
||||
|
||||
min-height: 148px;
|
||||
|
||||
color: $color-dark-grey;
|
||||
font-family: $body-font-family;
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 2px 5px -1px rgba(0, 0, 0, 0.25), 0 0 1px rgba(0, 0, 0, 0.09) !important;
|
||||
|
||||
&:hover {
|
||||
.bookmarkTitle {
|
||||
color: $color-blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarkContent {
|
||||
flex-grow: 1;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.bookmarkTitle {
|
||||
color: darken($color-mid-grey, 30%);
|
||||
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.65em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bookmarkDescription {
|
||||
display: -webkit-box;
|
||||
|
||||
overflow-y: hidden;
|
||||
|
||||
margin-top: 12px;
|
||||
max-height: 48px;
|
||||
|
||||
color: darken($color-mid-grey, 10%);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.5em;
|
||||
font-weight: 400;
|
||||
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.bookmarkThumbnail {
|
||||
position: relative;
|
||||
min-width: 33%;
|
||||
max-height: 100%;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
border-radius: 0 3px 3px 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarkMetadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
margin-top: 14px;
|
||||
|
||||
color: darken($color-mid-grey, 10%);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.bookmarkIcon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.bookmarkPublisher {
|
||||
line-height: 1.5em;
|
||||
|
||||
&:after {
|
||||
content: "•";
|
||||
margin: 0 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarkAuthor {
|
||||
overflow: hidden;
|
||||
|
||||
max-width: 240px;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bookmark {
|
||||
background: $dark-mode-background;
|
||||
}
|
||||
|
||||
.bookmarkContainer {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
box-shadow: none !important;
|
||||
background: $dark-mode-primary;
|
||||
}
|
||||
|
||||
.bookmarkTitle {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.bookmarkDescription,
|
||||
.bookmarkMetadata {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.bookmarkContainer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bookmarkIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.bookmarkContent {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.bookmarkTitle,
|
||||
.bookmarkDescription,
|
||||
.bookmarkMetadata {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.bookmarkThumbnail {
|
||||
order: 1;
|
||||
|
||||
min-height: 160px;
|
||||
width: 100%;
|
||||
|
||||
img {
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
import React from "react";
|
||||
import styles from "./Bookmark.module.scss";
|
||||
|
||||
export interface BookmarkProps {
|
||||
url: string;
|
||||
title: string;
|
||||
author: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
publisher?: string;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
const Bookmark: (props: BookmarkProps) => JSX.Element = ({
|
||||
url,
|
||||
title,
|
||||
author,
|
||||
description,
|
||||
icon,
|
||||
publisher,
|
||||
thumbnail,
|
||||
}) => {
|
||||
return (
|
||||
<figure className={styles.bookmark}>
|
||||
<a className={styles.bookmarkContainer} href={url}>
|
||||
<div className={styles.bookmarkContent}>
|
||||
<div className={styles.bookmarkTitle}>{title}</div>
|
||||
{description && (
|
||||
<div className={styles.bookmarkDescription}>{description}</div>
|
||||
)}
|
||||
<div className={styles.bookmarkMetadata}>
|
||||
{icon && (
|
||||
<img
|
||||
className={styles.bookmarkIcon}
|
||||
alt={publisher || undefined}
|
||||
src={icon}
|
||||
/>
|
||||
)}
|
||||
{publisher && (
|
||||
<span className={styles.bookmarkPublisher}>{publisher}</span>
|
||||
)}
|
||||
{author && <span className={styles.bookmarkAuthor}>{author}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{thumbnail && (
|
||||
<div className={styles.bookmarkThumbnail}>
|
||||
<img src={thumbnail} alt={title} loading="lazy" decoding="async" />
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bookmark;
|
@ -1,61 +0,0 @@
|
||||
@import "../../../styles/colors.scss";
|
||||
@import "../../../styles/fonts.scss";
|
||||
|
||||
.quote {
|
||||
position: relative;
|
||||
|
||||
margin: 0 0 1.5em;
|
||||
padding: 0 1.5em;
|
||||
min-width: 100%;
|
||||
|
||||
font-style: italic;
|
||||
color: $color-mid-grey;
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
|
||||
top: 16px;
|
||||
left: -20px;
|
||||
|
||||
display: block;
|
||||
content: "\201C";
|
||||
font-size: 8rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1em 0;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-style: italic;
|
||||
line-height: inherit;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
cite {
|
||||
font-family: $body-font-family;
|
||||
font-size: 1.8rem;
|
||||
|
||||
&:before {
|
||||
content: "\2014 \2009";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.quote {
|
||||
color: lighten($color-mid-grey, 30%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.quote {
|
||||
padding: 0;
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
import { PropsWithChildren } from "react";
|
||||
import styles from "./Quote.module.scss";
|
||||
|
||||
export interface QuoteProps {
|
||||
url?: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
const Quote: (props: PropsWithChildren<QuoteProps>) => JSX.Element = ({
|
||||
url,
|
||||
author,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.quote}>
|
||||
{children}
|
||||
{author && (
|
||||
<cite className={styles.quoteAuthor}>
|
||||
{url ? (
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
{author}
|
||||
</a>
|
||||
) : (
|
||||
author
|
||||
)}
|
||||
</cite>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Quote;
|
@ -1,32 +0,0 @@
|
||||
@import "../../styles/colors.scss";
|
||||
|
||||
.card {
|
||||
border-radius: 5px;
|
||||
border: 1px solid $color-light-grey;
|
||||
box-shadow: 2px 2px 4px adjust-color($color-light-grey, $alpha: 0.5);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
border-bottom: 1px solid $color-light-grey;
|
||||
padding: 1rem;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card {
|
||||
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.25);
|
||||
border-color: $color-mid-grey;
|
||||
background-color: lighten($dark-mode-background, 5%);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
border-bottom-color: $color-mid-grey;
|
||||
}
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import styles from "./Card.module.scss";
|
||||
|
||||
export interface CardProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const Card: FC<React.PropsWithChildren<CardProps>> = ({
|
||||
title,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
{title && (
|
||||
<div className={styles.cardTitle}>
|
||||
<h4>{title}</h4>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.cardBody}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
@ -1,17 +0,0 @@
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
|
||||
const ClientOnly: FC<any> = ({ children, ...delegated }) => {
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!hasMounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div {...delegated}>{children}</div>;
|
||||
};
|
||||
|
||||
export default ClientOnly;
|
@ -1,27 +0,0 @@
|
||||
import { FC } from "react";
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"JAN",
|
||||
"FEB",
|
||||
"MAR",
|
||||
"APR",
|
||||
"MAY",
|
||||
"JUN",
|
||||
"JUL",
|
||||
"AUG",
|
||||
"SEP",
|
||||
"OCT",
|
||||
"NOV",
|
||||
"DEC",
|
||||
];
|
||||
|
||||
export const DateSpan: FC<{ date: string }> = ({ date }) => {
|
||||
const date_obj = new Date(date);
|
||||
|
||||
return (
|
||||
<span>
|
||||
{date_obj.getDate()} {MONTH_NAMES[date_obj.getMonth()]}{" "}
|
||||
{date_obj.getFullYear()}
|
||||
</span>
|
||||
);
|
||||
};
|
@ -1,24 +0,0 @@
|
||||
.floatingLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
|
||||
small {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.floatingLabelBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.floatingLabelRow {
|
||||
.floatingLabelBody {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import cn from "classnames";
|
||||
import styles from "./FloatingLabel.module.scss";
|
||||
|
||||
export interface FloatingLabelProps {
|
||||
title: React.ReactNode;
|
||||
className?: string;
|
||||
row?: boolean;
|
||||
}
|
||||
|
||||
export const FloatingLabel: FC<React.PropsWithChildren<FloatingLabelProps>> = ({
|
||||
title,
|
||||
className,
|
||||
row = false,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
styles.floatingLabel,
|
||||
{ [styles.floatingLabelRow]: row },
|
||||
className
|
||||
)}
|
||||
>
|
||||
<h4>{title}</h4>
|
||||
<div className={styles.floatingLabelBody}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingLabel;
|
@ -1,3 +0,0 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import cn from "classnames";
|
||||
import styles from "./Grid.module.scss";
|
||||
|
||||
export interface GridProps {
|
||||
rows?: number | string[];
|
||||
columns?: number | string[];
|
||||
rowGap?: number;
|
||||
columnGap?: number;
|
||||
mt?: number;
|
||||
mr?: number;
|
||||
mb?: number;
|
||||
ml?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const Grid: FC<React.PropsWithChildren<GridProps>> = ({
|
||||
rows,
|
||||
columns,
|
||||
rowGap,
|
||||
columnGap,
|
||||
mt,
|
||||
mr,
|
||||
mb,
|
||||
ml,
|
||||
style,
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
const computedStyle: React.CSSProperties = { ...style };
|
||||
|
||||
if (typeof rows === "number") {
|
||||
computedStyle.gridTemplateRows = `repeat(${rows}, 1fr)`;
|
||||
} else if (rows instanceof Array) {
|
||||
computedStyle.gridTemplateRows = rows.join(" ");
|
||||
}
|
||||
|
||||
if (typeof columns === "number") {
|
||||
computedStyle.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`;
|
||||
} else if (columns instanceof Array) {
|
||||
computedStyle.gridTemplateColumns = columns.join(" ");
|
||||
}
|
||||
|
||||
if (typeof rowGap === "number") {
|
||||
computedStyle.rowGap = `${rowGap}rem`;
|
||||
}
|
||||
|
||||
if (typeof columnGap === "number") {
|
||||
computedStyle.columnGap = `${columnGap}rem`;
|
||||
}
|
||||
|
||||
if (typeof mt === "number") {
|
||||
computedStyle.marginTop = `${mt}rem`;
|
||||
}
|
||||
|
||||
if (typeof mr === "number") {
|
||||
computedStyle.marginRight = `${mr}rem`;
|
||||
}
|
||||
|
||||
if (typeof mb === "number") {
|
||||
computedStyle.marginBottom = `${mb}rem`;
|
||||
}
|
||||
|
||||
if (typeof ml === "number") {
|
||||
computedStyle.marginLeft = `${ml}rem`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(styles.grid, className)} style={computedStyle}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Grid;
|
@ -1,64 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import path from "path";
|
||||
import NextImage, { ImageProps } from "next/image";
|
||||
|
||||
interface SplitFilePath {
|
||||
dir: string;
|
||||
filename: string;
|
||||
extension: string;
|
||||
}
|
||||
|
||||
const splitFilePath: (file_path: string) => SplitFilePath = (file_path) => {
|
||||
return {
|
||||
dir: path.dirname(file_path),
|
||||
filename: path.basename(file_path),
|
||||
extension: path.extname(file_path),
|
||||
};
|
||||
};
|
||||
|
||||
const isImageExtension: (extension: string) => Boolean = (extension) => {
|
||||
return [".jpg", ".jpeg", ".webp", ".png", ".avif"].includes(
|
||||
extension.toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
const customLoader: (props: { src: string; width: number }) => string = ({
|
||||
src,
|
||||
width,
|
||||
}) => {
|
||||
const { dir, filename, extension } = splitFilePath(src);
|
||||
if (!isImageExtension(extension)) {
|
||||
// The image has an unsupported extension
|
||||
return src;
|
||||
}
|
||||
|
||||
// We are going to use WEBP for all image formats
|
||||
let target_ext = extension;
|
||||
if (target_ext.toLowerCase() != ".webp") {
|
||||
target_ext = ".webp";
|
||||
}
|
||||
|
||||
const target = path.join(
|
||||
dir,
|
||||
"optimized",
|
||||
`${path.basename(filename, extension)}-opt-${width}${target_ext}`
|
||||
);
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
export default function Image(props: ImageProps): JSX.Element {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const { src, ...rest } = props;
|
||||
|
||||
return (
|
||||
<NextImage
|
||||
{...rest}
|
||||
loader={imageError ? ({ src }) => src : customLoader}
|
||||
src={src}
|
||||
onError={() => {
|
||||
setImageError(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,60 +0,0 @@
|
||||
.tagList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
text-transform: uppercase;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.4em;
|
||||
font-weight: 400;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
margin-right: 10px;
|
||||
font-weight: 600;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
||||
top: 7px;
|
||||
right: -6px;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 0;
|
||||
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tagListLarge {
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.6em;
|
||||
font-weight: 600;
|
||||
|
||||
li {
|
||||
margin-right: 20px;
|
||||
|
||||
&:after {
|
||||
top: 8px;
|
||||
right: -12px;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
import { FC } from "react";
|
||||
import Link from "next/link";
|
||||
import { Tag } from "../../lib/tags";
|
||||
import cn from "classnames";
|
||||
import styles from "./TagList.module.scss";
|
||||
|
||||
export const TagList: FC<{
|
||||
tags: Tag[];
|
||||
large?: boolean;
|
||||
}> = ({ tags, large }) => {
|
||||
return (
|
||||
<ul
|
||||
className={cn({
|
||||
[styles.tagList]: true,
|
||||
[styles.tagListLarge]: large,
|
||||
})}
|
||||
>
|
||||
{tags
|
||||
.filter((tag) => tag.visibility === "public")
|
||||
.map((tag, index) => (
|
||||
<li key={index.toString()}>
|
||||
<Link href={"/tags/" + tag.slug} title={tag.description || ""}>
|
||||
{tag.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
@ -1,126 +0,0 @@
|
||||
@import "../../styles/colors.scss";
|
||||
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
top: 2px;
|
||||
|
||||
margin-left: 0.5rem;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
|
||||
&:hover {
|
||||
color: lighten($primary-background, 20%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.tooltip {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipBody {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
|
||||
width: max-content;
|
||||
max-width: 200px;
|
||||
|
||||
padding: 1rem;
|
||||
|
||||
text-align: center;
|
||||
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-weight: normal;
|
||||
|
||||
border-radius: 5px;
|
||||
background-color: $primary-background;
|
||||
box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipBody_top {
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
|
||||
margin-left: -100px;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:after {
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -1rem;
|
||||
|
||||
border-left: 1rem solid transparent;
|
||||
border-right: 1rem solid transparent;
|
||||
|
||||
border-top: 1rem solid $primary-background;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipBody_bottom {
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
|
||||
margin-left: -100px;
|
||||
margin-top: 1rem;
|
||||
|
||||
&:after {
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
margin-left: -1rem;
|
||||
|
||||
border-left: 1rem solid transparent;
|
||||
border-right: 1rem solid transparent;
|
||||
|
||||
border-bottom: 1rem solid $primary-background;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipBody_left {
|
||||
top: -5px;
|
||||
right: 100%;
|
||||
|
||||
margin-right: 1rem;
|
||||
|
||||
&:after {
|
||||
top: 5px;
|
||||
left: 100%;
|
||||
margin-right: 1rem;
|
||||
|
||||
border-top: 1rem solid transparent;
|
||||
border-bottom: 1rem solid transparent;
|
||||
|
||||
border-left: 1rem solid $primary-background;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltipBody_right {
|
||||
top: -5px;
|
||||
left: 100%;
|
||||
|
||||
margin-left: 1rem;
|
||||
|
||||
&:after {
|
||||
top: 5px;
|
||||
right: 100%;
|
||||
margin-left: 1rem;
|
||||
|
||||
border-top: 1rem solid transparent;
|
||||
border-bottom: 1rem solid transparent;
|
||||
|
||||
border-right: 1rem solid $primary-background;
|
||||
}
|
||||
}
|
@ -1,42 +0,0 @@
|
||||
import React, { FC, useState } from "react";
|
||||
import cn from "classnames";
|
||||
import styles from "./Tooltip.module.scss";
|
||||
import QuestionCircle from "../icons/QuestionCircle";
|
||||
|
||||
export interface TooltipProps {
|
||||
position?: "top" | "left" | "bottom" | "right";
|
||||
}
|
||||
|
||||
export const Tooltip: FC<React.PropsWithChildren<TooltipProps>> = ({
|
||||
position = "top",
|
||||
children,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const onMouseOver: React.MouseEventHandler<HTMLDivElement> = () => {
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const onMouseOut: React.MouseEventHandler<HTMLDivElement> = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
>
|
||||
<QuestionCircle />
|
||||
{open && (
|
||||
<div
|
||||
className={cn(styles.tooltipBody, styles[`tooltipBody_${position}`])}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
@ -1,42 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import { Currency, CURRENCY_SYMBOLS, CURRENCIES } from "../../lib/tools/position-size/forex";
|
||||
|
||||
export interface CurrencySelectProps {
|
||||
value: Currency;
|
||||
exclude?: Currency;
|
||||
onChange?: (currency: Currency) => void;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CurrencySelect: FC<CurrencySelectProps> = ({
|
||||
value,
|
||||
exclude,
|
||||
onChange,
|
||||
id,
|
||||
disabled,
|
||||
}) => {
|
||||
const options = CURRENCIES.filter((currency) => currency !== exclude).map(
|
||||
(currency, index) => (
|
||||
<option key={index} value={currency}>
|
||||
{currency} ({CURRENCY_SYMBOLS.get(currency)})
|
||||
</option>
|
||||
)
|
||||
);
|
||||
|
||||
const onSelectChange: React.ChangeEventHandler<HTMLSelectElement> = (
|
||||
event
|
||||
) => {
|
||||
if (onChange) {
|
||||
onChange(event.target.value as Currency);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<select id={id} value={value} onChange={onSelectChange} disabled={disabled}>
|
||||
{options}
|
||||
</select>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrencySelect;
|
@ -1,94 +0,0 @@
|
||||
@import "../../styles/colors.scss";
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
width: 100%;
|
||||
|
||||
// Immediate buttons in the container: the default and the dropdown
|
||||
> button {
|
||||
&:nth-child(1) {
|
||||
flex-grow: 1;
|
||||
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right-width: 0;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
flex-grow: 0;
|
||||
|
||||
padding: 1rem 1.2rem;
|
||||
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
|
||||
margin-top: -1px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
background-color: white;
|
||||
border: 1px solid $color-light-grey;
|
||||
box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
|
||||
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
|
||||
button {
|
||||
background-color: transparent;
|
||||
text-align: left;
|
||||
color: $color-body-text;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-light-grey;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: $color-light-grey;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dropdown {
|
||||
background-color: lighten($dark-mode-background, 5%);
|
||||
border-color: $color-mid-grey;
|
||||
|
||||
button {
|
||||
color: $dark-mode-body-text;
|
||||
|
||||
&:hover {
|
||||
background-color: $color-mid-grey;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: $color-mid-grey;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,60 +0,0 @@
|
||||
import React, { FC, useRef, useState } from "react";
|
||||
import cn from "classnames";
|
||||
import styles from "./DropdownButton.module.scss";
|
||||
import Caret from "../icons/Caret";
|
||||
import Dismissable from "../Dismissable";
|
||||
|
||||
export interface DropdownButtonProps {
|
||||
title: string;
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DropdownButton: FC<
|
||||
React.PropsWithChildren<DropdownButtonProps>
|
||||
> = ({ title, onClick, disabled, children }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const onToggleClick: React.MouseEventHandler<HTMLButtonElement> = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
const onDismiss = (event?: MouseEvent) => {
|
||||
if (
|
||||
event &&
|
||||
toggleRef.current &&
|
||||
toggleRef.current.contains(event.target as Node)
|
||||
) {
|
||||
// This is a dismiss click on the actual toggle button, so we don't need to dismiss
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<button type="button" onClick={onClick} disabled={disabled}>
|
||||
{title}
|
||||
</button>
|
||||
<button
|
||||
ref={toggleRef}
|
||||
type="button"
|
||||
onClick={onToggleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Caret direction="down" filled />
|
||||
</button>
|
||||
{open && (
|
||||
<Dismissable onDismiss={onDismiss}>
|
||||
<div className={styles.dropdown} onClick={() => onDismiss()}>
|
||||
{children}
|
||||
</div>
|
||||
</Dismissable>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownButton;
|
@ -1,3 +0,0 @@
|
||||
.numberInput {
|
||||
text-align: right;
|
||||
}
|
@ -1,102 +0,0 @@
|
||||
import React, { FC, useRef, useState } from "react";
|
||||
import cn from "classnames";
|
||||
import { formatNumber } from "../../lib/utils";
|
||||
import styles from "./NumberInput.module.scss";
|
||||
|
||||
export interface NumberInputProps {
|
||||
value: number;
|
||||
|
||||
places?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
|
||||
onChange?: (value: number) => void;
|
||||
className?: string;
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const NumberInput: FC<NumberInputProps> = ({
|
||||
value,
|
||||
places = 2,
|
||||
prefix,
|
||||
suffix,
|
||||
onChange,
|
||||
className,
|
||||
id,
|
||||
disabled,
|
||||
}) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [editValue, setEditValue] = useState(value.toFixed(places));
|
||||
const inputEl = useRef<HTMLInputElement>(null);
|
||||
|
||||
var elementValue = editValue;
|
||||
if (!focused) {
|
||||
elementValue = formatNumber(value, places, prefix, suffix);
|
||||
const valString = value.toFixed(places);
|
||||
if (editValue != valString) {
|
||||
setEditValue(valString);
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
window.setTimeout(() => {
|
||||
if (inputEl.current) {
|
||||
inputEl.current.select();
|
||||
}
|
||||
}, 150);
|
||||
|
||||
setFocused(true);
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
setFocused(false);
|
||||
|
||||
try {
|
||||
var valueNum = parseFloat(editValue);
|
||||
if (onChange) {
|
||||
onChange(valueNum);
|
||||
}
|
||||
} catch (exc) {
|
||||
console.error(exc);
|
||||
setEditValue(value.toFixed(places));
|
||||
}
|
||||
};
|
||||
|
||||
const onInputChange: React.ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
setEditValue(event.target.value);
|
||||
};
|
||||
|
||||
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||
if (event.key === "Enter") {
|
||||
try {
|
||||
var valueNum = parseFloat(editValue);
|
||||
if (onChange) {
|
||||
onChange(valueNum);
|
||||
|
||||
if (inputEl.current) {
|
||||
inputEl.current.select();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputEl}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className={cn(styles.numberInput, className)}
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
value={elementValue}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onChange={onInputChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NumberInput;
|
@ -1,41 +0,0 @@
|
||||
@import "../../styles/colors.scss";
|
||||
|
||||
.button {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
|
||||
bottom: 2em;
|
||||
right: 2em;
|
||||
|
||||
opacity: 0;
|
||||
transform: translateY(100px);
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.skipFooter {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1170px) {
|
||||
.button {
|
||||
> span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.button {
|
||||
display: none;
|
||||
}
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
import cn from "classnames";
|
||||
import styles from "./ScrollToTop.module.scss";
|
||||
|
||||
export const ScrollToTopButton: FC = () => {
|
||||
const footerRef = useRef<HTMLElement | null>(null);
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
const [footerVisible, setFooterVisible] = useState<boolean>(false);
|
||||
|
||||
const onFooterIntersection: IntersectionObserverCallback = (entries) => {
|
||||
entries.forEach((entry) => {
|
||||
switch (entry.target.tagName) {
|
||||
case "NAV":
|
||||
setVisible(!entry.isIntersecting);
|
||||
break;
|
||||
case "FOOTER":
|
||||
setFooterVisible(entry.isIntersecting);
|
||||
break;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const header = document.querySelector("nav:first-of-type");
|
||||
const footer = document.querySelector("footer");
|
||||
let observer = new IntersectionObserver(onFooterIntersection);
|
||||
|
||||
if (header) {
|
||||
observer.observe(header);
|
||||
}
|
||||
|
||||
if (footer) {
|
||||
observer.observe(footer);
|
||||
footerRef.current = footer;
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(styles.button, "scroll-button", {
|
||||
[styles.visible]: visible,
|
||||
[styles.skipFooter]: footerVisible,
|
||||
})}
|
||||
style={{
|
||||
transform:
|
||||
visible && footerVisible && footerRef.current
|
||||
? `translateY(-${
|
||||
footerRef.current.getBoundingClientRect().height
|
||||
}px)`
|
||||
: undefined,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
>
|
||||
↑<span> Goto Top</span>
|
||||
</button>
|
||||
);
|
||||
};
|
@ -1,62 +0,0 @@
|
||||
@import "../../styles/colors.scss";
|
||||
|
||||
.toggle {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
width: 6rem;
|
||||
height: 3rem;
|
||||
|
||||
&.toggleActive {
|
||||
.toggleBackground {
|
||||
background-color: lighten($primary-background, 20%);
|
||||
}
|
||||
|
||||
.toggleInner {
|
||||
right: 0rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggleBackground {
|
||||
position: absolute;
|
||||
|
||||
width: 6rem;
|
||||
height: 1.5rem;
|
||||
|
||||
top: 0.75rem;
|
||||
|
||||
border-radius: 1.5rem;
|
||||
|
||||
background-color: $color-light-grey;
|
||||
}
|
||||
|
||||
.toggleInner {
|
||||
position: absolute;
|
||||
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
|
||||
top: 0;
|
||||
right: 3rem;
|
||||
|
||||
border-radius: 1.5rem;
|
||||
|
||||
background-color: white;
|
||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||
box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.25);
|
||||
|
||||
transition: right 0.125s ease-in-out;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.toggleBackground {
|
||||
background-color: $color-mid-grey;
|
||||
}
|
||||
|
||||
.toggleInner {
|
||||
background-color: lighten($primary-background, 10%);
|
||||
}
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import cn from "classnames";
|
||||
import styles from "./Toggle.module.scss";
|
||||
|
||||
export interface ToggleProps {
|
||||
value: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const Toggle: FC<ToggleProps> = ({
|
||||
value,
|
||||
disabled = false,
|
||||
onChange,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(styles.toggle, {
|
||||
[styles.toggleActive]: value,
|
||||
[styles.toggleDisabled]: disabled,
|
||||
})}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onChange(!value);
|
||||
}}
|
||||
style={style}
|
||||
>
|
||||
<div className={styles.toggleBackground} />
|
||||
<div className={styles.toggleInner} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toggle;
|
@ -1,126 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import { Direction } from "../../lib/types";
|
||||
|
||||
const ARROWS: { [direction: string]: React.ReactElement<any, any>[] } = {
|
||||
down: [
|
||||
<svg
|
||||
key="down-short"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 4a.5.5 0 0 1 .5.5v5.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 1 1 .708-.708L7.5 10.293V4.5A.5.5 0 0 1 8 4z"
|
||||
/>
|
||||
</svg>,
|
||||
<svg
|
||||
key="down-long"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"
|
||||
/>
|
||||
</svg>,
|
||||
],
|
||||
left: [
|
||||
<svg
|
||||
key="left-short"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 8a.5.5 0 0 1-.5.5H5.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L5.707 7.5H11.5a.5.5 0 0 1 .5.5z"
|
||||
/>
|
||||
</svg>,
|
||||
<svg
|
||||
key="left-long"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z"
|
||||
/>
|
||||
</svg>,
|
||||
],
|
||||
up: [
|
||||
<svg
|
||||
key="up-short"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 12a.5.5 0 0 0 .5-.5V5.707l2.146 2.147a.5.5 0 0 0 .708-.708l-3-3a.5.5 0 0 0-.708 0l-3 3a.5.5 0 1 0 .708.708L7.5 5.707V11.5a.5.5 0 0 0 .5.5z"
|
||||
/>
|
||||
</svg>,
|
||||
<svg
|
||||
key="up-long"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"
|
||||
/>
|
||||
</svg>,
|
||||
],
|
||||
right: [
|
||||
<svg
|
||||
key="right-short"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 8a.5.5 0 0 1 .5-.5h5.793L8.146 5.354a.5.5 0 1 1 .708-.708l3 3a.5.5 0 0 1 0 .708l-3 3a.5.5 0 0 1-.708-.708L10.293 8.5H4.5A.5.5 0 0 1 4 8z"
|
||||
/>
|
||||
</svg>,
|
||||
<svg
|
||||
key="right-long"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M1 8a.5.5 0 0 1 .5-.5h11.793l-3.147-3.146a.5.5 0 0 1 .708-.708l4 4a.5.5 0 0 1 0 .708l-4 4a.5.5 0 0 1-.708-.708L13.293 8.5H1.5A.5.5 0 0 1 1 8z"
|
||||
/>
|
||||
</svg>,
|
||||
],
|
||||
};
|
||||
|
||||
export const Arrow: FC<{ direction: Direction; short?: boolean }> = ({
|
||||
direction,
|
||||
short = false,
|
||||
}) => {
|
||||
return ARROWS[direction][short ? 0 : 1];
|
||||
};
|
||||
|
||||
export default Arrow;
|
@ -1,102 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import { Direction } from "../../lib/types";
|
||||
|
||||
const CARETS: { [direction: string]: React.ReactElement<any, any>[] } = {
|
||||
up: [
|
||||
<svg
|
||||
key="up-filled"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M3.204 11h9.592L8 5.519 3.204 11zm-.753-.659 4.796-5.48a1 1 0 0 1 1.506 0l4.796 5.48c.566.647.106 1.659-.753 1.659H3.204a1 1 0 0 1-.753-1.659z" />
|
||||
</svg>,
|
||||
<svg
|
||||
key="up"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="m7.247 4.86-4.796 5.481c-.566.647-.106 1.659.753 1.659h9.592a1 1 0 0 0 .753-1.659l-4.796-5.48a1 1 0 0 0-1.506 0z" />
|
||||
</svg>,
|
||||
],
|
||||
down: [
|
||||
<svg
|
||||
key="down-filled"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M3.204 5h9.592L8 10.481 3.204 5zm-.753.659 4.796 5.48a1 1 0 0 0 1.506 0l4.796-5.48c.566-.647.106-1.659-.753-1.659H3.204a1 1 0 0 0-.753 1.659z" />
|
||||
</svg>,
|
||||
<svg
|
||||
key="down"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z" />
|
||||
</svg>,
|
||||
],
|
||||
left: [
|
||||
<svg
|
||||
key="left-filled"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M10 12.796V3.204L4.519 8 10 12.796zm-.659.753-5.48-4.796a1 1 0 0 1 0-1.506l5.48-4.796A1 1 0 0 1 11 3.204v9.592a1 1 0 0 1-1.659.753z" />
|
||||
</svg>,
|
||||
<svg
|
||||
key="left"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="m3.86 8.753 5.482 4.796c.646.566 1.658.106 1.658-.753V3.204a1 1 0 0 0-1.659-.753l-5.48 4.796a1 1 0 0 0 0 1.506z" />
|
||||
</svg>,
|
||||
],
|
||||
right: [
|
||||
<svg
|
||||
key="right-filled"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M6 12.796V3.204L11.481 8 6 12.796zm.659.753 5.48-4.796a1 1 0 0 0 0-1.506L6.66 2.451C6.011 1.885 5 2.345 5 3.204v9.592a1 1 0 0 0 1.659.753z" />
|
||||
</svg>,
|
||||
<svg
|
||||
key="right"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="m12.14 8.753-5.482 4.796c-.646.566-1.658.106-1.658-.753V3.204a1 1 0 0 1 1.659-.753l5.48 4.796a1 1 0 0 1 0 1.506z" />
|
||||
</svg>,
|
||||
],
|
||||
};
|
||||
|
||||
export const Caret: FC<{
|
||||
direction: Direction;
|
||||
filled?: boolean;
|
||||
}> = ({ direction, filled = false }) => {
|
||||
return CARETS[direction][filled ? 1 : 0];
|
||||
};
|
||||
|
||||
export default Caret;
|
@ -1,12 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
|
||||
export const DevTo: FC = () => {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>dev.to</title>
|
||||
<path d="M7.42 10.05c-.18-.16-.46-.23-.84-.23H6l.02 2.44.04 2.45.56-.02c.41 0 .63-.07.83-.26.24-.24.26-.36.26-2.2 0-1.91-.02-1.96-.29-2.18zM0 4.94v14.12h24V4.94H0zM8.56 15.3c-.44.58-1.06.77-2.53.77H4.71V8.53h1.4c1.67 0 2.16.18 2.6.9.27.43.29.6.32 2.57.05 2.23-.02 2.73-.47 3.3zm5.09-5.47h-2.47v1.77h1.52v1.28l-.72.04-.75.03v1.77l1.22.03 1.2.04v1.28h-1.6c-1.53 0-1.6-.01-1.87-.3l-.3-.28v-3.16c0-3.02.01-3.18.25-3.48.23-.31.25-.31 1.88-.31h1.64v1.3zm4.68 5.45c-.17.43-.64.79-1 .79-.18 0-.45-.15-.67-.39-.32-.32-.45-.63-.82-2.08l-.9-3.39-.45-1.67h.76c.4 0 .75.02.75.05 0 .06 1.16 4.54 1.26 4.83.04.15.32-.7.73-2.3l.66-2.52.74-.04c.4-.02.73 0 .73.04 0 .14-1.67 6.38-1.8 6.68z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevTo;
|
@ -1,11 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
|
||||
export const GitHub: FC = () => {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32">
|
||||
<path d="M16 .395c-8.836 0-16 7.163-16 16 0 7.069 4.585 13.067 10.942 15.182.8.148 1.094-.347 1.094-.77 0-.381-.015-1.642-.022-2.979-4.452.968-5.391-1.888-5.391-1.888-.728-1.849-1.776-2.341-1.776-2.341-1.452-.993.11-.973.11-.973 1.606.113 2.452 1.649 2.452 1.649 1.427 2.446 3.743 1.739 4.656 1.33.143-1.034.558-1.74 1.016-2.14-3.554-.404-7.29-1.777-7.29-7.907 0-1.747.625-3.174 1.649-4.295-.166-.403-.714-2.03.155-4.234 0 0 1.344-.43 4.401 1.64a15.353 15.353 0 0 1 4.005-.539c1.359.006 2.729.184 4.008.539 3.054-2.07 4.395-1.64 4.395-1.64.871 2.204.323 3.831.157 4.234 1.026 1.12 1.647 2.548 1.647 4.295 0 6.145-3.743 7.498-7.306 7.895.574.497 1.085 1.47 1.085 2.963 0 2.141-.019 3.864-.019 4.391 0 .426.288.925 1.099.768C27.421 29.457 32 23.462 32 16.395c0-8.837-7.164-16-16-16z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default GitHub;
|
@ -1,9 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
|
||||
export const Mastodon: FC = () => {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Mastodon</title><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
)
|
||||
};
|
||||
|
||||
export default Mastodon;
|
@ -1,17 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
|
||||
export const QuestionCircle: FC = () => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 16 16"
|
||||
>
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247zm2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuestionCircle;
|
@ -1,11 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
|
||||
export const Rss: FC = () => {
|
||||
return (
|
||||
<svg viewBox="0 0 16 16">
|
||||
<path d="M5.5 12a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm-3-8.5a1 1 0 0 1 1-1c5.523 0 10 4.477 10 10a1 1 0 1 1-2 0 8 8 0 0 0-8-8 1 1 0 0 1-1-1zm0 4a1 1 0 0 1 1-1 6 6 0 0 1 6 6 1 1 0 1 1-2 0 4 4 0 0 0-4-4 1 1 0 0 1-1-1z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Rss;
|
@ -1,11 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
|
||||
export const Search: FC = () => {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32">
|
||||
<path d="M 12 0 A 12 12 0 0 0 0 12 A 12 12 0 0 0 12 24 A 12 12 0 0 0 18.753906 21.917969 C 18.887375 22.146246 19.042704 22.366923 19.242188 22.566406 L 27.779297 31.103516 C 28.854914 32.179133 30.462126 32.303499 31.382812 31.382812 C 32.303499 30.462126 32.179133 28.854914 31.103516 27.779297 L 22.566406 19.242188 C 22.364055 19.039836 22.140067 18.882462 21.908203 18.748047 A 12 12 0 0 0 24 12 A 12 12 0 0 0 12 0 z M 12 3.4570312 A 8.5423727 8.5423727 0 0 1 20.542969 12 A 8.5423727 8.5423727 0 0 1 12 20.542969 A 8.5423727 8.5423727 0 0 1 3.4570312 12 A 8.5423727 8.5423727 0 0 1 12 3.4570312 z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Search;
|
@ -1,61 +0,0 @@
|
||||
import React, { FC, useEffect } from "react";
|
||||
import {
|
||||
AccountAction,
|
||||
AccountInfo,
|
||||
accountReducer,
|
||||
loadAccount,
|
||||
} from "../../../lib/tools/position-size/account";
|
||||
import { getExchangeRates } from "../../../lib/tools/position-size/forex";
|
||||
|
||||
export interface AccountContextProps {
|
||||
account: AccountInfo;
|
||||
dispatch: React.Dispatch<AccountAction>;
|
||||
}
|
||||
|
||||
export const AccountContext = React.createContext<
|
||||
AccountContextProps | undefined
|
||||
>(undefined);
|
||||
|
||||
function load(): AccountInfo {
|
||||
const res = loadAccount();
|
||||
if (res === null) {
|
||||
return {
|
||||
places: 4,
|
||||
currency: "GBP",
|
||||
exchangeRates: {
|
||||
base: "GBP",
|
||||
rates: new Map(),
|
||||
},
|
||||
amount: 500,
|
||||
marginRisk: 0.01,
|
||||
positionRisk: 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export const AccountProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const [account, dispatch] = React.useReducer(accountReducer, load());
|
||||
|
||||
useEffect(() => {
|
||||
getExchangeRates(account.currency).then((exchangeRates) => {
|
||||
dispatch({ action: "setExchangeRates", exchangeRates });
|
||||
});
|
||||
}, [account.currency]);
|
||||
|
||||
return (
|
||||
<AccountContext.Provider value={{ account, dispatch }}>
|
||||
{children}
|
||||
</AccountContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useAccount(): AccountContextProps {
|
||||
const context = React.useContext(AccountContext);
|
||||
if (!context) {
|
||||
throw new Error("useAccount must be used within an AccountProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
@ -1,56 +0,0 @@
|
||||
import React, { FC, useEffect } from "react";
|
||||
import { getExchangeRates } from "../../../lib/tools/position-size/forex";
|
||||
import {
|
||||
PositionAction,
|
||||
PositionInfo,
|
||||
positionReducer,
|
||||
} from "../../../lib/tools/position-size/position";
|
||||
|
||||
export interface PositionContextProps {
|
||||
position: PositionInfo;
|
||||
dispatch: React.Dispatch<PositionAction>;
|
||||
}
|
||||
|
||||
export const PositionContext = React.createContext<
|
||||
PositionContextProps | undefined
|
||||
>(undefined);
|
||||
|
||||
export const PositionProvider: FC<React.PropsWithChildren> = ({ children }) => {
|
||||
const [position, dispatch] = React.useReducer(positionReducer, {
|
||||
posCurrency: "GBP",
|
||||
quoteCurrency: "GBP",
|
||||
conversion: 1,
|
||||
openPrice: 0,
|
||||
quantity: null,
|
||||
direction: "buy",
|
||||
margin: 0.05,
|
||||
takeProfit: null,
|
||||
stopLoss: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getExchangeRates(position.posCurrency, position.quoteCurrency).then(
|
||||
(exchangeRates) => {
|
||||
dispatch({
|
||||
action: "setConversion",
|
||||
conversion: exchangeRates.rates.get(position.quoteCurrency) || 1,
|
||||
});
|
||||
}
|
||||
);
|
||||
}, [position.posCurrency, position.quoteCurrency]);
|
||||
|
||||
return (
|
||||
<PositionContext.Provider value={{ position, dispatch }}>
|
||||
{children}
|
||||
</PositionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function usePosition(): PositionContextProps {
|
||||
const context = React.useContext(PositionContext);
|
||||
if (!context) {
|
||||
throw new Error("usePosition must be used within a PositionProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
.riskGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.riskGrid {
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 2rem;
|
||||
}
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import { Currency, CURRENCY_SYMBOLS } from "../../../../lib/tools/position-size/forex";
|
||||
import Card from "../../../display/Card";
|
||||
import CurrencySelect from "../../../fields/CurrencySelect";
|
||||
import FloatingLabel from "../../../display/FloatingLabel";
|
||||
import Grid from "../../../display/Grid";
|
||||
import NumberInput from "../../../fields/NumberInput";
|
||||
import { useAccount } from "../AccountProvider";
|
||||
import styles from "./AccountInfo.module.scss";
|
||||
|
||||
export const AccountInfoPanel: FC = () => {
|
||||
const { account, dispatch } = useAccount();
|
||||
|
||||
const onCurrencyChange = (currency: Currency) => {
|
||||
dispatch({ action: "setCurrency", currency });
|
||||
};
|
||||
|
||||
const onAmountChange = (amount: number) => {
|
||||
dispatch({ action: "setAmount", amount });
|
||||
};
|
||||
|
||||
const onMarginRiskChange = (risk: number) => {
|
||||
dispatch({ action: "setMarginRisk", risk: risk / 100 });
|
||||
};
|
||||
|
||||
const onPositionRiskChange = (risk: number) => {
|
||||
dispatch({ action: "setPositionRisk", risk: risk / 100 });
|
||||
};
|
||||
|
||||
const onPlacesChange = (places: number) => {
|
||||
dispatch({ action: "setPlaces", places });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Account Information">
|
||||
<Grid rowGap={2}>
|
||||
<FloatingLabel title="Account Currency">
|
||||
<CurrencySelect
|
||||
value={account.currency}
|
||||
onChange={onCurrencyChange}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
<FloatingLabel title="Account Value">
|
||||
<NumberInput
|
||||
value={account.amount}
|
||||
prefix={CURRENCY_SYMBOLS.get(account.currency)}
|
||||
places={account.places}
|
||||
onChange={onAmountChange}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
<Grid className={styles.riskGrid} columnGap={2}>
|
||||
<FloatingLabel title="Margin Risk">
|
||||
<NumberInput
|
||||
value={account.marginRisk * 100}
|
||||
places={0}
|
||||
suffix="%"
|
||||
onChange={onMarginRiskChange}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
<FloatingLabel title="Position Risk">
|
||||
<NumberInput
|
||||
value={account.positionRisk * 100}
|
||||
places={0}
|
||||
suffix="%"
|
||||
onChange={onPositionRiskChange}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
</Grid>
|
||||
<FloatingLabel title="Decimal Places">
|
||||
<NumberInput
|
||||
value={account.places}
|
||||
places={0}
|
||||
suffix=" digits"
|
||||
onChange={onPlacesChange}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountInfoPanel;
|
@ -1,22 +0,0 @@
|
||||
.positionGrid,
|
||||
.quantityGrid,
|
||||
.takeStopGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.priceGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 1170px) {
|
||||
.positionGrid,
|
||||
.quantityGrid,
|
||||
.takeStopGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.positionGrid,
|
||||
.quantityGrid,
|
||||
.takeStopGrid {
|
||||
row-gap: 2rem;
|
||||
}
|
||||
}
|
@ -1,319 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import { Currency, CURRENCY_SYMBOLS } from "../../../../lib/tools/position-size/forex";
|
||||
import {
|
||||
computedStopLossQuantity,
|
||||
computePositionSize,
|
||||
Direction,
|
||||
} from "../../../../lib/tools/position-size/position";
|
||||
import { formatNumber } from "../../../../lib/utils";
|
||||
import Card from "../../../display/Card";
|
||||
import CurrencySelect from "../../../fields/CurrencySelect";
|
||||
import DropdownButton from "../../../fields/DropdownButton";
|
||||
import FloatingLabel from "../../../display/FloatingLabel";
|
||||
import Grid from "../../../display/Grid";
|
||||
import NumberInput from "../../../fields/NumberInput";
|
||||
import Toggle from "../../../fields/Toggle";
|
||||
import { useAccount } from "../AccountProvider";
|
||||
import { usePosition } from "../PositionProvider";
|
||||
import styles from "./PositionInfo.module.scss";
|
||||
|
||||
export const PositionInfoPanel: FC = () => {
|
||||
const { account } = useAccount();
|
||||
const { position, dispatch } = usePosition();
|
||||
|
||||
const leverage =
|
||||
position.margin !== 0 ? (
|
||||
<small>{`(${Math.round(1 / position.margin)}x leverage)`}</small>
|
||||
) : null;
|
||||
|
||||
// const takeProfitDistance =
|
||||
// typeof position.takeProfit === "number"
|
||||
// ? position.direction === "buy"
|
||||
// ? position.takeProfit - position.openPrice
|
||||
// : position.openPrice - position.takeProfit
|
||||
// : 0;
|
||||
|
||||
const stopLossDistance =
|
||||
typeof position.stopLoss === "number"
|
||||
? position.direction === "buy"
|
||||
? position.openPrice - position.stopLoss
|
||||
: position.stopLoss - position.openPrice
|
||||
: 0;
|
||||
|
||||
const onPosCurrencyChange = (currency: Currency) => {
|
||||
dispatch({ action: "setPosCurrency", currency });
|
||||
};
|
||||
|
||||
const onQuoteCurrencyChange = (currency: Currency) => {
|
||||
dispatch({ action: "setQuoteCurrency", currency });
|
||||
};
|
||||
|
||||
const onMarginChange = (margin: number) => {
|
||||
dispatch({ action: "setMargin", margin: margin / 100 });
|
||||
};
|
||||
|
||||
const onOpenPriceChange = (openPrice: number) => {
|
||||
dispatch({ action: "setOpenPrice", openPrice });
|
||||
};
|
||||
|
||||
const onQuantityToggleChange = (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
dispatch({ action: "setQuantity", quantity: 0 });
|
||||
} else {
|
||||
dispatch({ action: "setQuantity", quantity: null });
|
||||
}
|
||||
};
|
||||
|
||||
const onQuantityChange = (quantity: number) => {
|
||||
dispatch({ action: "setQuantity", quantity });
|
||||
};
|
||||
|
||||
const onUseAffordableClick = () => {
|
||||
console.log("useAffordableClick");
|
||||
const { quantity } = computePositionSize(account, position);
|
||||
dispatch({ action: "setQuantity", quantity });
|
||||
};
|
||||
|
||||
const onUseAvailableClick = () => {
|
||||
const { quantity } = computedStopLossQuantity(account, position);
|
||||
dispatch({ action: "setQuantity", quantity });
|
||||
};
|
||||
|
||||
const onDirectionChange: React.ChangeEventHandler<HTMLSelectElement> = (
|
||||
event
|
||||
) => {
|
||||
dispatch({
|
||||
action: "setDirection",
|
||||
direction: event.target.value as Direction,
|
||||
});
|
||||
};
|
||||
|
||||
// const onTakeProfitToggleChange = (enabled: boolean) => {
|
||||
// if (enabled) {
|
||||
// dispatch({ action: "setTakeProfit", takeProfit: position.openPrice });
|
||||
// } else {
|
||||
// dispatch({ action: "setTakeProfit", takeProfit: null });
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// const onTakeProfitChange = (takeProfit: number) => {
|
||||
// dispatch({ action: "setTakeProfit", takeProfit });
|
||||
// };
|
||||
//
|
||||
// const onTakeProfitDistanceChange = (takeProfitDistance: number) => {
|
||||
// dispatch({
|
||||
// action: "setTakeProfit",
|
||||
// takeProfit:
|
||||
// position.direction === "buy"
|
||||
// ? position.openPrice + takeProfitDistance
|
||||
// : position.openPrice - takeProfitDistance,
|
||||
// });
|
||||
// };
|
||||
|
||||
const onStopLossToggleChange = (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
dispatch({ action: "setStopLoss", stopLoss: position.openPrice });
|
||||
} else {
|
||||
dispatch({ action: "setStopLoss", stopLoss: null });
|
||||
}
|
||||
};
|
||||
|
||||
const onStopLossChange = (stopLoss: number) => {
|
||||
dispatch({ action: "setStopLoss", stopLoss });
|
||||
};
|
||||
|
||||
const onStopLossDistanceChange = (stopLossDistance: number) => {
|
||||
dispatch({
|
||||
action: "setStopLoss",
|
||||
stopLoss:
|
||||
position.direction === "buy"
|
||||
? position.openPrice - stopLossDistance
|
||||
: position.openPrice + stopLossDistance,
|
||||
});
|
||||
};
|
||||
|
||||
const positionSymbol = CURRENCY_SYMBOLS.get(position.posCurrency);
|
||||
const quoteSymbol = CURRENCY_SYMBOLS.get(position.quoteCurrency);
|
||||
|
||||
const posExchange =
|
||||
account.currency !== position.posCurrency ? (
|
||||
<>
|
||||
({account.currency}→{position.posCurrency}{" "}
|
||||
{formatNumber(
|
||||
account.exchangeRates.rates.get(position.posCurrency) || 0,
|
||||
account.places,
|
||||
positionSymbol
|
||||
)}
|
||||
)
|
||||
</>
|
||||
) : (
|
||||
<> </>
|
||||
);
|
||||
|
||||
const quoteExchange =
|
||||
position.quoteCurrency !== position.posCurrency ? (
|
||||
<>
|
||||
({position.posCurrency}→{position.quoteCurrency}{" "}
|
||||
{formatNumber(position.conversion, account.places, quoteSymbol)})
|
||||
</>
|
||||
) : (
|
||||
<> </>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card title="Position Information">
|
||||
<Grid rowGap={2}>
|
||||
<Grid className={styles.positionGrid} columnGap={2}>
|
||||
<FloatingLabel
|
||||
title={
|
||||
<span>
|
||||
Position <small>{posExchange}</small>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<CurrencySelect
|
||||
value={position.posCurrency}
|
||||
onChange={onPosCurrencyChange}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
<FloatingLabel
|
||||
title={
|
||||
<span>
|
||||
Quote <small>{quoteExchange}</small>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<CurrencySelect
|
||||
value={position.quoteCurrency}
|
||||
onChange={onQuoteCurrencyChange}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
</Grid>
|
||||
<Grid className={styles.positionGrid} columnGap={2}>
|
||||
<FloatingLabel title={<span>Position Margin {leverage}</span>}>
|
||||
<NumberInput
|
||||
value={position.margin * 100}
|
||||
places={2}
|
||||
suffix="%"
|
||||
onChange={onMarginChange}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
<FloatingLabel title="Position Direction">
|
||||
<select value={position.direction} onChange={onDirectionChange}>
|
||||
<option value="buy">Buy</option>
|
||||
<option value="sell">Sell</option>
|
||||
</select>
|
||||
</FloatingLabel>
|
||||
</Grid>
|
||||
<Grid className={styles.priceGrid}>
|
||||
<FloatingLabel title="Open Price">
|
||||
<NumberInput
|
||||
value={position.openPrice}
|
||||
places={account.places}
|
||||
prefix={quoteSymbol}
|
||||
onChange={onOpenPriceChange}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
</Grid>
|
||||
<Grid columns={["6rem", "1fr"]} columnGap={2}>
|
||||
<Toggle
|
||||
value={typeof position.quantity === "number"}
|
||||
onChange={onQuantityToggleChange}
|
||||
style={{ marginTop: "3rem" }}
|
||||
/>
|
||||
<Grid className={styles.quantityGrid} columnGap={2}>
|
||||
<FloatingLabel title="Quantity">
|
||||
<NumberInput
|
||||
value={position.quantity || 0}
|
||||
places={2}
|
||||
suffix=" units"
|
||||
onChange={onQuantityChange}
|
||||
disabled={typeof position.quantity !== "number"}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
<FloatingLabel title="Use Calculated Value">
|
||||
<DropdownButton
|
||||
title="Affordable"
|
||||
onClick={onUseAffordableClick}
|
||||
disabled={
|
||||
typeof position.quantity !== "number" ||
|
||||
position.openPrice === 0
|
||||
}
|
||||
>
|
||||
<button type="button" onClick={onUseAffordableClick}>
|
||||
Affordable Quantity
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUseAvailableClick}
|
||||
disabled={
|
||||
typeof position.stopLoss !== "number" ||
|
||||
stopLossDistance === 0
|
||||
}
|
||||
>
|
||||
Stop Loss Quantity
|
||||
</button>
|
||||
</DropdownButton>
|
||||
</FloatingLabel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{/*
|
||||
<Grid columns={["6rem", "1fr"]} columnGap={2}>
|
||||
<Toggle
|
||||
value={typeof position.takeProfit === "number"}
|
||||
onChange={onTakeProfitToggleChange}
|
||||
style={{ marginTop: "3rem" }}
|
||||
/>
|
||||
<Grid className={styles.takeStopGrid} columnGap={2}>
|
||||
<FloatingLabel title="Take Profit">
|
||||
<NumberInput
|
||||
value={position.takeProfit || 0}
|
||||
places={account.places}
|
||||
prefix={positionSymbol}
|
||||
onChange={onTakeProfitChange}
|
||||
disabled={typeof position.takeProfit !== "number"}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
<FloatingLabel title="Take Profit Distance">
|
||||
<NumberInput
|
||||
value={takeProfitDistance}
|
||||
places={account.places}
|
||||
prefix={positionSymbol}
|
||||
onChange={onTakeProfitDistanceChange}
|
||||
disabled={typeof position.takeProfit !== "number"}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
*/}
|
||||
<Grid columns={["6rem", "1fr"]} columnGap={2}>
|
||||
<Toggle
|
||||
value={typeof position.stopLoss === "number"}
|
||||
onChange={onStopLossToggleChange}
|
||||
style={{ marginTop: "3rem" }}
|
||||
/>
|
||||
<Grid className={styles.takeStopGrid} columnGap={2}>
|
||||
<FloatingLabel title="Stop Loss">
|
||||
<NumberInput
|
||||
value={position.stopLoss || 0}
|
||||
places={account.places}
|
||||
prefix={quoteSymbol}
|
||||
onChange={onStopLossChange}
|
||||
disabled={typeof position.stopLoss !== "number"}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
<FloatingLabel title="Stop Loss Distance">
|
||||
<NumberInput
|
||||
value={stopLossDistance}
|
||||
places={account.places}
|
||||
prefix={quoteSymbol}
|
||||
onChange={onStopLossDistanceChange}
|
||||
disabled={typeof position.stopLoss !== "number"}
|
||||
/>
|
||||
</FloatingLabel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Card>
|
||||
);
|
||||
};
|
@ -1,30 +0,0 @@
|
||||
.resultTable {
|
||||
width: 100%;
|
||||
|
||||
tbody {
|
||||
th {
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.numberCell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.gridNoLoss {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.gridWithLoss {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 1170px) {
|
||||
.gridNoLoss,
|
||||
.gridWithLoss {
|
||||
grid-template-columns: 1fr;
|
||||
grid-row-gap: 2rem;
|
||||
}
|
||||
}
|
@ -1,724 +0,0 @@
|
||||
import React, { FC } from "react";
|
||||
import cn from "classnames";
|
||||
import { CURRENCY_SYMBOLS } from "../../../../lib/tools/position-size/forex";
|
||||
import {
|
||||
computedStopLossQuantity,
|
||||
computePositionSize,
|
||||
computeStopLoss,
|
||||
} from "../../../../lib/tools/position-size/position";
|
||||
import { formatNumber } from "../../../../lib/utils";
|
||||
import Card from "../../../display/Card";
|
||||
import Grid from "../../../display/Grid";
|
||||
import Tooltip from "../../../display/Tooltip";
|
||||
import { useAccount } from "../AccountProvider";
|
||||
import { usePosition } from "../PositionProvider";
|
||||
import styles from "./PositionSizePanel.module.scss";
|
||||
|
||||
const SimplePositionSize: FC = () => {
|
||||
const { account } = useAccount();
|
||||
const { position } = usePosition();
|
||||
const {
|
||||
available,
|
||||
availablePos,
|
||||
availableQuote,
|
||||
margin,
|
||||
marginPos,
|
||||
marginQuote,
|
||||
quantity,
|
||||
actual,
|
||||
} = computePositionSize(account, position);
|
||||
|
||||
return (
|
||||
<Card title="Simple Position Size">
|
||||
<table className={styles.resultTable}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th
|
||||
rowSpan={
|
||||
1 +
|
||||
(account.currency !== position.posCurrency ? 1 : 0) +
|
||||
(position.posCurrency !== position.quoteCurrency ? 1 : 0)
|
||||
}
|
||||
>
|
||||
Available Account
|
||||
</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
available,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(account.currency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Amount of account available under margin risk
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
{account.currency !== position.posCurrency && (
|
||||
<tr>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
availablePos,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.posCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Available account under margin risk in the position currency
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{position.posCurrency !== position.quoteCurrency && (
|
||||
<tr>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
availableQuote,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Available account under margin risk in the quote currency
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th
|
||||
rowSpan={
|
||||
1 +
|
||||
(account.currency !== position.posCurrency ? 1 : 0) +
|
||||
(position.posCurrency !== position.quoteCurrency ? 1 : 0)
|
||||
}
|
||||
>
|
||||
Available Margin
|
||||
</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
margin,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(account.currency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Available amount with a{" "}
|
||||
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
|
||||
position margin
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
{account.currency !== position.posCurrency && (
|
||||
<tr>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
marginPos,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.posCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Available amount with a{" "}
|
||||
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
|
||||
position margin converted to position currency
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{position.posCurrency !== position.quoteCurrency && (
|
||||
<tr>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
marginQuote,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Available amount with a{" "}
|
||||
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
|
||||
position margin converted to quote currency
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th>Affordable Quantity</th>
|
||||
<td className={styles.numberCell}>
|
||||
{position.openPrice !== 0 ? (
|
||||
<b>{formatNumber(quantity, 2, undefined, " units")}</b>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Position size that can be taken at an open price of{" "}
|
||||
{formatNumber(
|
||||
position.openPrice,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}{" "}
|
||||
with available margin of{" "}
|
||||
{formatNumber(
|
||||
marginQuote,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
{actual && (position.quantity || 0) > 0 && (
|
||||
<>
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Actual Quantity</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(position.quantity || 0, 2, undefined, " units")}
|
||||
<Tooltip position="left">
|
||||
Quantity entered into position form
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Actual Cost</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
(position.quantity || 0) * position.openPrice,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Cost of opening the position of{" "}
|
||||
{formatNumber(position.quantity || 0, 2)} units at{" "}
|
||||
{formatNumber(
|
||||
position.openPrice,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th
|
||||
rowSpan={
|
||||
2 +
|
||||
(account.currency !== position.posCurrency ? 1 : 0) +
|
||||
(position.posCurrency !== position.quoteCurrency ? 1 : 0)
|
||||
}
|
||||
>
|
||||
Required Margin
|
||||
</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
actual.costQuote,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Amount required at{" "}
|
||||
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
|
||||
position margin (
|
||||
{formatNumber(
|
||||
1.0 / (position.margin || 1),
|
||||
0,
|
||||
undefined,
|
||||
"x"
|
||||
)}{" "}
|
||||
leverage)
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
{position.quoteCurrency !== position.posCurrency && (
|
||||
<tr>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
actual.costPos,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.posCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Amount required at{" "}
|
||||
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
|
||||
margin, converted into the position currency.
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{account.currency !== position.posCurrency && (
|
||||
<tr>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
actual.cost,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(account.currency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Amount required at{" "}
|
||||
{formatNumber(position.margin * 100, 2, undefined, "%")}{" "}
|
||||
margin, converted into the account currency.
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<td
|
||||
className={cn(styles.numberCell, {
|
||||
"text-danger":
|
||||
Math.round(100 * actual.margin) >
|
||||
100 * account.marginRisk,
|
||||
})}
|
||||
>
|
||||
{formatNumber(actual.margin * 100, 2, undefined, "%")}
|
||||
<Tooltip position="left">
|
||||
The percentage of the account that will be committed as
|
||||
margin to open the position
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
{Math.round(100 * actual.margin) > 100 * account.marginRisk && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
className="text-danger"
|
||||
style={{ paddingTop: "2rem" }}
|
||||
>
|
||||
Actual quantity of {formatNumber(position.quantity || 0, 2)}{" "}
|
||||
units exceeds account margin risk of{" "}
|
||||
{formatNumber(account.marginRisk * 100, 0, undefined, "%")}{" "}
|
||||
by{" "}
|
||||
{formatNumber(
|
||||
actual.costPos - available,
|
||||
2,
|
||||
CURRENCY_SYMBOLS.get(account.currency)
|
||||
)}
|
||||
.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Given the account <i>Margin Risk</i>, what is the maximum possible
|
||||
position size that can be opened at the given position <i>Open Price</i>
|
||||
.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const StopLossPosition: FC = () => {
|
||||
const { account } = useAccount();
|
||||
const { position } = usePosition();
|
||||
const quantity =
|
||||
typeof position.quantity === "number"
|
||||
? position.quantity
|
||||
: computePositionSize(account, position).quantity;
|
||||
const { available, availablePos, availableQuote, distance, actual } =
|
||||
computeStopLoss(account, position, quantity);
|
||||
|
||||
return (
|
||||
<Card title="Stop Loss Position">
|
||||
<table className={styles.resultTable}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th
|
||||
rowSpan={
|
||||
1 +
|
||||
(account.currency !== position.posCurrency ? 1 : 0) +
|
||||
(position.quoteCurrency !== position.posCurrency ? 1 : 0)
|
||||
}
|
||||
>
|
||||
Available Account
|
||||
</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
available,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(account.currency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Amount of account available under position risk of{" "}
|
||||
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
{account.currency !== position.posCurrency && (
|
||||
<tr>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
availablePos,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.posCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Available account under position risk of{" "}
|
||||
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}{" "}
|
||||
in the position currency
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{position.quoteCurrency !== position.posCurrency && (
|
||||
<tr>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
availableQuote,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Available account under position risk of{" "}
|
||||
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}{" "}
|
||||
in the quote currency
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th>Stop Loss Distance</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
distance,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
The maximum stop loss distance for a position of{" "}
|
||||
{formatNumber(quantity, 2, undefined, " units")} at{" "}
|
||||
{formatNumber(
|
||||
position.openPrice,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}{" "}
|
||||
to remain within the position risk of{" "}
|
||||
{formatNumber(account.positionRisk * 100, 2, undefined, "%")} of
|
||||
the account
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Stop Loss</th>
|
||||
<td className={styles.numberCell}>
|
||||
<b>
|
||||
{formatNumber(
|
||||
position.direction === "buy"
|
||||
? position.openPrice - distance
|
||||
: position.openPrice + distance,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
The maximum stop loss for a position of{" "}
|
||||
{formatNumber(quantity, 2, undefined, " units")} at{" "}
|
||||
{formatNumber(
|
||||
position.openPrice,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}{" "}
|
||||
to remain within the position risk of{" "}
|
||||
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}{" "}
|
||||
of the account
|
||||
</Tooltip>
|
||||
</b>
|
||||
</td>
|
||||
</tr>
|
||||
{actual && (
|
||||
<>
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Actual Distance</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
actual.distance,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.posCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
The distance provided in the position form
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Actual Loss</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
actual.loss,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(account.currency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
The actual account loss that will be incurred should the
|
||||
position close at the provided stop loss position of{" "}
|
||||
{formatNumber(
|
||||
position.stopLoss || 0,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Actual Risk</th>
|
||||
<td
|
||||
className={cn(styles.numberCell, {
|
||||
"text-danger":
|
||||
Math.round(100 * actual.risk) >
|
||||
100 * account.positionRisk,
|
||||
})}
|
||||
>
|
||||
{formatNumber(actual.risk * 100, 2, undefined, "%")}
|
||||
<Tooltip position="left">
|
||||
Percentage of account at risk for the provided stop loss
|
||||
position of{" "}
|
||||
{formatNumber(
|
||||
position.stopLoss || 0,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
{Math.round(100 * actual.risk) > 100 * account.positionRisk && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
className="text-danger"
|
||||
style={{ paddingTop: "2rem" }}
|
||||
>
|
||||
Actual stop loss of{" "}
|
||||
{formatNumber(
|
||||
actual.loss,
|
||||
2,
|
||||
CURRENCY_SYMBOLS.get(account.currency)
|
||||
)}{" "}
|
||||
exceeds account position risk of{" "}
|
||||
{formatNumber(
|
||||
account.positionRisk * 100,
|
||||
0,
|
||||
undefined,
|
||||
"%"
|
||||
)}{" "}
|
||||
by{" "}
|
||||
{formatNumber(
|
||||
actual.loss - available,
|
||||
2,
|
||||
CURRENCY_SYMBOLS.get(account.currency)
|
||||
)}
|
||||
.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Given the{" "}
|
||||
{typeof position.quantity === "number" ? "specified" : "simple"}{" "}
|
||||
position size of <b>{formatNumber(quantity, 2)}</b> units, and the
|
||||
account <i>Position Risk</i>, what is the maximum available stop loss.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const PlannedStopLossQuantity: FC = () => {
|
||||
const { account } = useAccount();
|
||||
const { position } = usePosition();
|
||||
const {
|
||||
available,
|
||||
availablePos,
|
||||
availableQuote,
|
||||
stopLossDistance,
|
||||
quantity,
|
||||
margin,
|
||||
} = computedStopLossQuantity(account, position);
|
||||
|
||||
return (
|
||||
<Card title="Planned Stop Loss Maximum">
|
||||
<table className={styles.resultTable}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th
|
||||
rowSpan={
|
||||
1 +
|
||||
(account.currency !== position.posCurrency ? 1 : 0) +
|
||||
(position.quoteCurrency !== position.posCurrency ? 1 : 0)
|
||||
}
|
||||
>
|
||||
Available Account
|
||||
</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
available,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(account.currency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Amount of account available under position risk of{" "}
|
||||
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
{account.currency !== position.posCurrency && (
|
||||
<tr>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
availablePos,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.posCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Available account under position risk of{" "}
|
||||
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}{" "}
|
||||
in the position currency
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{position.quoteCurrency !== position.posCurrency && (
|
||||
<tr>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
availableQuote,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Available account under position risk of{" "}
|
||||
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}{" "}
|
||||
in the quote currency
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
<tr>
|
||||
<th>Stop Loss</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
position.stopLoss || 0,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Stop loss entered in position form.
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Stop Distance</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
stopLossDistance,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
Stop loss distance entered into position form.
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Available Quantity</th>
|
||||
<td className={styles.numberCell}>
|
||||
{stopLossDistance !== 0 ? (
|
||||
<b>{formatNumber(quantity, 2)}</b>
|
||||
) : (
|
||||
"0"
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
The position size that can be taken at an open price of{" "}
|
||||
{formatNumber(
|
||||
position.openPrice,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}
|
||||
, given an account position risk of{" "}
|
||||
{formatNumber(account.positionRisk * 100, 2, undefined, "%")}
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
{stopLossDistance !== 0 && (
|
||||
<>
|
||||
<tr>
|
||||
<th rowSpan={2}>Required Margin</th>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
margin,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(account.currency)
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
The amount of account margin that will be committed to
|
||||
opening a position of{" "}
|
||||
{formatNumber(quantity, 2, undefined, " units")} at{" "}
|
||||
{formatNumber(
|
||||
position.openPrice,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}{" "}
|
||||
with a position margin of{" "}
|
||||
{formatNumber(position.margin * 100, 2, undefined, "%")} (
|
||||
{formatNumber(1 / position.margin, 0, undefined, "x")}{" "}
|
||||
leverage)
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={styles.numberCell}>
|
||||
{formatNumber(
|
||||
(margin / account.amount) * 100,
|
||||
2,
|
||||
undefined,
|
||||
"%"
|
||||
)}
|
||||
<Tooltip position="left">
|
||||
The amount of account margin, as a percentage of the account
|
||||
value, that will be committed to opening a position of{" "}
|
||||
{formatNumber(quantity, 2, undefined, " units")} at{" "}
|
||||
{formatNumber(
|
||||
position.openPrice,
|
||||
account.places,
|
||||
CURRENCY_SYMBOLS.get(position.quoteCurrency)
|
||||
)}{" "}
|
||||
with a position margin of{" "}
|
||||
{formatNumber(position.margin * 100, 2, undefined, "%")} (
|
||||
{formatNumber(1 / position.margin, 0, undefined, "x")}{" "}
|
||||
leverage)
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<p>
|
||||
Given the entered position <i>Stop Loss</i> and the account{" "}
|
||||
<i>Position Risk</i>, what is the maximum position size available.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const PositionSizePanel: FC = () => {
|
||||
const { position } = usePosition();
|
||||
|
||||
return (
|
||||
<Grid
|
||||
className={
|
||||
typeof position.stopLoss === "number"
|
||||
? styles.gridWithLoss
|
||||
: styles.gridNoLoss
|
||||
}
|
||||
columnGap={2}
|
||||
mb={2}
|
||||
>
|
||||
<SimplePositionSize />
|
||||
<StopLossPosition />
|
||||
{typeof position.stopLoss === "number" && <PlannedStopLossQuantity />}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default PositionSizePanel;
|
@ -1 +0,0 @@
|
||||
<mxfile host="Electron" modified="2022-12-31T11:44:54.919Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/20.7.4 Chrome/106.0.5249.199 Electron/21.3.3 Safari/537.36" etag="vdH-aiIYMUC2v1GbkQq7" version="20.7.4" type="device"><diagram id="vnPnQphzv46ZmVF16kpH" name="Page-1">7Vlbb+I4FP41SLsPi3KDMI+ES6dSd1VNR+rMEzKJSTx14shxgMyv3+PEIRennaJtd1oVQMLns33sHJ/zfSGM7EV8vOIojf5mAaYjywiOI3s5sizbdEz4kkhRIaY7cyok5CRQWAPckZ9YgYZCcxLgrDNQMEYFSbugz5IE+6KDIc7ZoTtsx2h31RSFWAPufER19J4EIqpQ1zIa/DMmYaRWtmaqI0b1WAVkEQrYoQXZq5G94IyJqhUfF5jK4NVhqeatH+k97YvjRDxnwurO/v4Pd+9/fvlxn3/aevP99ewvUx3GHtFcXfA8QbQQxM/UtkVRhyJlJBFlOCcefGC5hTGaQM9CWmNr0gP6ttsFTN2SPrpA33a7gNl3b/bWN/sbbAGa1XFv9NY3WhuEj+2xXFCS4MUp8QwAQ44CAieyYJRxwBKWQPS8SMQULBOah4gIfJciX0b1AFUD2I4lQqW+adW2Crz0CsktEKzFlY/yJDBf7XF1INUYSlGake1pFsd+zjOyx19wVjmXKORhKtvxMZQlO0aHzBmHnOVpuf1rWGuwdwPNjU9ZHmwQFdKR4OwB1xc6smx4r2XueTtCaS8Ae8whpxCdUxJK/4LJ5ZCyKN6VHiEqJAlvSmtpGyoSQ0sEKItwoC5JLwVVHXJVfGxBqjSuMIux4AUMUb2OqtKiax6akp/WWNQud0OBSNFMePLclCI0VDWeU5kTrTJXFMflgb9E5U+18sYBEJ8yGRcRCxlQwapBPUiDJDhFvRlzw+Rplpn5AwtRqFRGuWDd3IcN8+Kbml8a36UBVabM5bHduSza1i3mBGJbFsHwqT8ZyIzl3MdPDVTqgniIn3JoV+NkuJ7MLI4pElB9XUl68TzR0gSlRDvb7AELP1KBG+TxQS4f4vNBTtd5vTOsZNqBFfrgEObqoKkPq8lZB4ewISXqzzYHZpu92Y/rwGO81dcH6Fu7s5XhtPqWBJhbkJKHE1llPUqFOUtjsjDdIRLela8+Q9b0e4O2mN6yjCj3WyYEi3/Jzz5Oyrpr1/KvNAtlaRWOHTnKfQwLD8dVXVayA5KVDQkQRfE2QOfX/PP5vy8AM10A3JnO/zX24mU9+y30fCTixM7QrsjZnSizIWdpFC2jT80nnj+RftvVuUSfASWLubyXb+4nSmxNZFBfRQysdykGliYGgpMwhEheBOEiCBdBOEMQJtO3JQju7xSEul2xuPUfBKF342+9G0GwnykI1psSBFsTBPnAarMn+AA/I6fl7/gtqMM0lC2B5MODi1S8H6lwli50nicV8HK8Tx9GKoIiQTELtq8pFo7xtsRiohXxR3+44zyTvs03Rd/60/nPX7/eAjK/vdbp+woJfECF3vFHuh9vKXrAHJFk7LP4zwvJvyOSX6+d9cw7j+S9hWlPph+G5FFKNqFK/1fk+dn/RfNgNv8Mln2t/1ft1b8=</diagram></mxfile>
|
@ -1,388 +0,0 @@
|
||||
// Website analytics API client.
|
||||
//
|
||||
// This module contains the functionality that interfaces with the website analytics.
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
const last = new Date(0);
|
||||
last.setFullYear(year, 1 + month, 0);
|
||||
last.setHours(0, 0, 0, 0);
|
||||
return last.getDate();
|
||||
}
|
||||
|
||||
export function getAnalyticsURL(path: string): string {
|
||||
const host = process.env.ANALYTICS_HOSTNAME || "https://pv.blakerain.com";
|
||||
return host + "/" + path;
|
||||
}
|
||||
|
||||
// The session token where we store the authentication token.
|
||||
const SESSION_TOKEN_NAME = "blakerain:analytics:token";
|
||||
|
||||
// Retrieve the authentication token from the session storage (if we have one).
|
||||
export function getSessionToken(): string | null {
|
||||
if (typeof window !== "undefined") {
|
||||
return sessionStorage.getItem(SESSION_TOKEN_NAME);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Store the authentication token in the session storage.
|
||||
export function setSessionToken(token: string) {
|
||||
sessionStorage.setItem(SESSION_TOKEN_NAME, token);
|
||||
}
|
||||
|
||||
/// Given a username and password, attempt to authenticate with the API.
|
||||
///
|
||||
/// If this succeeds, it will return the authentication token from the promise. This token can then be used as the
|
||||
/// `Bearer` token in an `Authorization` header for subsequent requests.
|
||||
export const authenticate = async (
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<string> => {
|
||||
const res = await fetch(getAnalyticsURL("api/auth/signin"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const result = (await res.json()) as { error?: string; token?: string };
|
||||
if (result.error) {
|
||||
return Promise.reject(result.error);
|
||||
}
|
||||
|
||||
if (result.token) {
|
||||
return result.token;
|
||||
}
|
||||
|
||||
return Promise.reject("Expected to receive authentication token (or error)");
|
||||
};
|
||||
|
||||
/// The analytics data for a day of a week
|
||||
export interface WeekView {
|
||||
/// The year.
|
||||
year: number;
|
||||
/// The ISO week number.
|
||||
week: number;
|
||||
/// The day of the week.
|
||||
day: number;
|
||||
/// The number of views on this day.
|
||||
count?: number;
|
||||
/// The average amount of scroll distance on this day.
|
||||
scroll?: number;
|
||||
/// The average visit duration on this day.
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
// Remapping from day-of-week index in Rust to JavaScript.
|
||||
const DAYS_REMAP = [6, 0, 1, 2, 3, 4, 5];
|
||||
|
||||
/// Get the week view for the given path, year and ISO week number.
|
||||
///
|
||||
/// The `path` argument is the path to the URL we want to view. If this path is `site` (does not start with a `/`), then
|
||||
/// we will see the accumulated data for the entire site.
|
||||
///
|
||||
/// This will return an array of `WeekView` for each day in the week. For days on which no data has been recorded, this
|
||||
/// will fill in empty `WeekView` records. The returned array is sorted by the day-of-week.
|
||||
export const getWeekViews = async (
|
||||
token: string,
|
||||
path: string,
|
||||
year: number,
|
||||
week: number
|
||||
): Promise<WeekView[]> => {
|
||||
const res = await fetch(getAnalyticsURL("api/views/week"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
path,
|
||||
year: year,
|
||||
week: week,
|
||||
}),
|
||||
});
|
||||
|
||||
var weeks: WeekView[] = await res.json();
|
||||
|
||||
// Add in any `WeekView` records that do not exist, to ensure there are no gaps.
|
||||
for (let day = 0; day < 7; ++day) {
|
||||
var found = false;
|
||||
|
||||
for (let index = 0; index < weeks.length; ++index) {
|
||||
if (weeks[index].day === day) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
weeks.push({ year, week, day });
|
||||
}
|
||||
}
|
||||
|
||||
// Remap days from Rust to JavaScript.
|
||||
weeks.forEach((week) => {
|
||||
week.day = DAYS_REMAP[week.day];
|
||||
});
|
||||
|
||||
return weeks.sort((a, b) => a.day - b.day);
|
||||
};
|
||||
|
||||
/// The analytics data for a day of a month.
|
||||
export interface MonthView {
|
||||
/// The year.
|
||||
year: number;
|
||||
/// The month.
|
||||
month: number;
|
||||
/// The day of the month.
|
||||
day: number;
|
||||
/// The number of views on this day.
|
||||
count?: number;
|
||||
/// The average scroll distance on this day.
|
||||
scroll?: number;
|
||||
/// The average visit duration on this day.
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/// Get the month view for the given path, year and month.
|
||||
///
|
||||
/// The `path` argument is the path to the URL we want to view. If this path is `site` (does not start with a `/`),
|
||||
/// then we will see the accumulated data for the entire site.
|
||||
///
|
||||
/// This will return an array of `MonthView` for each day in the month. For days on which no data has been recorded,
|
||||
/// this will fill in empty `MonthView` records. The returned array is sorted by day.
|
||||
export const getMonthViews = async (
|
||||
token: string,
|
||||
path: string,
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<MonthView[]> => {
|
||||
const res = await fetch(getAnalyticsURL("api/views/month"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
path,
|
||||
year: year,
|
||||
month: 1 + month,
|
||||
}),
|
||||
});
|
||||
|
||||
const days = getDaysInMonth(year, month);
|
||||
var months: MonthView[] = await res.json();
|
||||
|
||||
// Fill in any missing days with empty records.
|
||||
for (let day = 1; day <= days; ++day) {
|
||||
var found = false;
|
||||
|
||||
for (let index = 0; index < months.length; ++index) {
|
||||
if (months[index].day === day) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
months.push({ year, month, day, count: 0, scroll: 0, duration: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
return months.sort((a, b) => a.day - b.day);
|
||||
};
|
||||
|
||||
/// The total number of views of a specific page.
|
||||
export interface PageCount {
|
||||
page: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/// Get the total number of views of pages over the given week.
|
||||
export const getWeekPageCount = async (
|
||||
token: string,
|
||||
year: number,
|
||||
week: number
|
||||
): Promise<PageCount[]> => {
|
||||
const res = await fetch(getAnalyticsURL("api/pages/week"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
year,
|
||||
week,
|
||||
}),
|
||||
});
|
||||
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
/// Get the total number of views of pages over the given month.
|
||||
export const getMonthPageCount = async (
|
||||
token: string,
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<PageCount[]> => {
|
||||
const res = await fetch(getAnalyticsURL("api/pages/month"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
year,
|
||||
month: month + 1,
|
||||
}),
|
||||
});
|
||||
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
/// Represents the number of visits by a given browser on a certain day.
|
||||
export interface BrowserDataItem {
|
||||
day: number;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/// A mapping from a browser name to a set of data points for that browser over a number of days (sorted by day).
|
||||
export type BrowserData = { [key: string]: BrowserDataItem[] };
|
||||
|
||||
/// Represents the recorded browser activity over a week.
|
||||
export interface BrowsersWeek {
|
||||
/// The year.
|
||||
year: number;
|
||||
/// The ISO week.
|
||||
week: number;
|
||||
/// Mapping from a browser to the data for that browser (sorted by day).
|
||||
browsers: BrowserData;
|
||||
}
|
||||
|
||||
/// Get the view counts for various browsers over the given week.
|
||||
export const getBrowsersWeek = async (
|
||||
token: string,
|
||||
year: number,
|
||||
week: number
|
||||
): Promise<BrowsersWeek> => {
|
||||
const res = await fetch(getAnalyticsURL("api/browsers/week"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
year,
|
||||
week,
|
||||
}),
|
||||
});
|
||||
|
||||
var data: BrowsersWeek = {
|
||||
year,
|
||||
week,
|
||||
browsers: {},
|
||||
};
|
||||
|
||||
const json: {
|
||||
browser: string;
|
||||
year: number;
|
||||
week: number;
|
||||
day: number;
|
||||
count: number;
|
||||
}[] = await res.json();
|
||||
json.forEach((obj) => {
|
||||
const day = DAYS_REMAP[obj["day"]];
|
||||
const count = obj["count"];
|
||||
const browser = obj["browser"];
|
||||
|
||||
if (browser in data.browsers) {
|
||||
data.browsers[browser].push({ day, count });
|
||||
} else {
|
||||
data.browsers[browser] = [{ day, count }];
|
||||
}
|
||||
});
|
||||
|
||||
for (let day = 0; day < 7; ++day) {
|
||||
Object.keys(data.browsers).forEach((browser) => {
|
||||
const found = data.browsers[browser].find((item) => item.day === day);
|
||||
if (!found) {
|
||||
data.browsers[browser].push({ day });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(data.browsers).forEach((browser) => {
|
||||
data.browsers[browser].sort((a, b) => a.day - b.day);
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/// Represents the recorded browser activity over a month.
|
||||
export interface BrowsersMonth {
|
||||
/// The year.
|
||||
year: number;
|
||||
/// The month.
|
||||
month: number;
|
||||
/// Mapping from a browser to the data for that browser (sorted by day).
|
||||
browsers: BrowserData;
|
||||
}
|
||||
|
||||
/// Get the view counts for various browsers over the given month.
|
||||
export const getBrowsersMonth = async (
|
||||
token: string,
|
||||
year: number,
|
||||
month: number
|
||||
): Promise<BrowsersMonth> => {
|
||||
const res = await fetch(getAnalyticsURL("api/browsers/month"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token, year, month: 1 + month }),
|
||||
});
|
||||
|
||||
var data: BrowsersMonth = {
|
||||
year,
|
||||
month,
|
||||
browsers: {},
|
||||
};
|
||||
|
||||
const json: {
|
||||
browser: string;
|
||||
year: number;
|
||||
week: number;
|
||||
day: number;
|
||||
count: number;
|
||||
}[] = await res.json();
|
||||
|
||||
json.forEach((obj) => {
|
||||
const day = obj["day"];
|
||||
const count = obj["count"];
|
||||
const browser = obj["browser"];
|
||||
|
||||
if (browser in data.browsers) {
|
||||
data.browsers[browser].push({ day, count });
|
||||
} else {
|
||||
data.browsers[browser] = [{ day, count }];
|
||||
}
|
||||
});
|
||||
|
||||
const days = getDaysInMonth(year, month);
|
||||
for (let day = 1; day <= days; ++day) {
|
||||
Object.keys(data.browsers).forEach((browser) => {
|
||||
const found = data.browsers[browser].find((item) => item.day === day);
|
||||
if (!found) {
|
||||
data.browsers[browser].push({ day });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(data.browsers).forEach((browser) => {
|
||||
data.browsers[browser].sort((a, b) => a.day - b.day);
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
@ -1,323 +0,0 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { TagId } from "./tags";
|
||||
import { serialize } from "next-mdx-remote/serialize";
|
||||
import { MDXRemoteSerializeResult } from "next-mdx-remote";
|
||||
import remarkEmoji from "remark-emoji";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||
import rehypeImageSize from "rehype-img-size";
|
||||
import matter from "gray-matter";
|
||||
import { GitLogEntry, loadFileRevisions } from "./git";
|
||||
import {
|
||||
rehypeAddPaths,
|
||||
rehypeWrapFigures,
|
||||
remarkUnwrapImages,
|
||||
} from "./plugins";
|
||||
|
||||
/// Information about a document
|
||||
export interface DocInfo {
|
||||
/// The slug used to form the URL for the document.
|
||||
slug: string;
|
||||
/// The rendered title for the document.
|
||||
title: string;
|
||||
/// Any excerpt given in indices or at the start of a document.
|
||||
excerpt: string | null;
|
||||
/// The ISO-8601 date string on which the document was published.
|
||||
published: string;
|
||||
}
|
||||
|
||||
/// Interface for something that has a number of tags.
|
||||
export interface Tagged {
|
||||
/// The tags (if any) for this object.
|
||||
tags: TagId[];
|
||||
}
|
||||
|
||||
/// Summary information about a blog post.
|
||||
///
|
||||
/// This extends both `DocInfo` for summary information about the document and `Tagged` to associate tags with a blog
|
||||
/// post.
|
||||
export interface PostInfo extends DocInfo, Tagged {
|
||||
/// The amount of time it will roughly take to read the blog post.
|
||||
readingTime: number;
|
||||
/// URL for the cover image (if there is one).
|
||||
coverImage: string | null;
|
||||
}
|
||||
|
||||
/// A fully deserialized blog post.
|
||||
///
|
||||
/// This interface extends `PostInfo` to include the content of the blog post.
|
||||
export interface Post extends PostInfo {
|
||||
/// The content of the blog post, as parsed by MDX.
|
||||
content: MDXRemoteSerializeResult;
|
||||
/// Any pre-amble data for the blog post.
|
||||
preamble: PostPreamble;
|
||||
/// The git history of changes made to this blog post.
|
||||
history: GitLogEntry[];
|
||||
}
|
||||
|
||||
/// A full deserialized page.
|
||||
///
|
||||
/// This interface extends `DocInfo` to include the contents of the page.
|
||||
export interface Page extends DocInfo {
|
||||
/// The content of the page, as parsed by MDX.
|
||||
content: MDXRemoteSerializeResult;
|
||||
/// Any pre-amble data for the page.
|
||||
preamble: PagePreamble;
|
||||
}
|
||||
|
||||
/// Represents general document preamble.
|
||||
///
|
||||
/// Preambles are provided using YAML in the frontispiece of a markdown document. This structure represents the basic
|
||||
/// information extracted from the preamble for all documents (posts or pages).
|
||||
export interface Preamble {
|
||||
/// The title of the document (if any).
|
||||
title?: string;
|
||||
/// When the document was published (if any, as an ISO-8601 string).
|
||||
published?: string;
|
||||
/// The excerpt for the document (if any).
|
||||
excerpt?: string;
|
||||
/// Whether to include the git history of this document.
|
||||
history?: boolean;
|
||||
/// Should we index this post for search (default is 'true').
|
||||
search?: boolean;
|
||||
}
|
||||
|
||||
/// Preamble specific to a blog post.
|
||||
export interface PostPreamble extends Preamble {
|
||||
/// The cover image URL.
|
||||
cover?: string;
|
||||
/// The IDs (slugs) of the tags for this post.
|
||||
tags?: TagId[];
|
||||
}
|
||||
|
||||
/// Preamble specific to a page.
|
||||
export interface PagePreamble extends Preamble {
|
||||
/// SEO settings for the page.
|
||||
seo?: {
|
||||
/// Whether to include this page for indexing.
|
||||
index?: boolean;
|
||||
/// Whether robots should follow links from this page.
|
||||
follow?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const WORD_RE = /[a-zA-Z0-9_-]\w+/;
|
||||
|
||||
// Roughly count the words in a source string.
|
||||
function countWords(source: string): number {
|
||||
return source.split(/\s+/).filter((word) => WORD_RE.exec(word)).length;
|
||||
}
|
||||
|
||||
// Load the document source for a given path.
|
||||
//
|
||||
// This function will load the source at the given path and split out any front-matter.
|
||||
export async function loadDocSource<P extends Preamble>(
|
||||
doc_path: string
|
||||
): Promise<{ preamble: P; source: string }> {
|
||||
const source = await fs.readFile(doc_path, "utf-8");
|
||||
const { content, data } = matter(source, {});
|
||||
return {
|
||||
preamble: data as P,
|
||||
source: content,
|
||||
};
|
||||
}
|
||||
|
||||
// Load a document from the given path.
|
||||
//
|
||||
// This function will load the document from the given path using `loadDocSource`. It will then parse the contents of
|
||||
// the document, returning the components needed to produce the various interfaces such as a `Post` or `Page`.
|
||||
//
|
||||
// 1. Count the number of words in the document.
|
||||
// 2. Use MDX to parse the contents of the document, including our chosen remark and rehype plugins.
|
||||
// 3. Load and parse the git history for the file (unless instructed otherwise).
|
||||
async function loadDoc<P extends Preamble>(
|
||||
doc_path: string
|
||||
): Promise<{
|
||||
preamble: P;
|
||||
source: string;
|
||||
wordCount: number;
|
||||
content: MDXRemoteSerializeResult;
|
||||
history: GitLogEntry[];
|
||||
}> {
|
||||
const { preamble, source } = await loadDocSource<P>(doc_path);
|
||||
return {
|
||||
preamble,
|
||||
source,
|
||||
wordCount: countWords(source),
|
||||
content: await serialize(source, {
|
||||
scope: preamble as Record<string, any>,
|
||||
mdxOptions: {
|
||||
development: process.env.NODE_ENV === "development",
|
||||
remarkPlugins: [remarkUnwrapImages, remarkGfm, remarkEmoji],
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
rehypeAutolinkHeadings,
|
||||
[rehypeImageSize as any, { dir: "public" }],
|
||||
rehypeWrapFigures,
|
||||
rehypeAddPaths,
|
||||
],
|
||||
},
|
||||
}),
|
||||
history:
|
||||
preamble.history !== false ? await loadFileRevisions(doc_path) : [],
|
||||
};
|
||||
}
|
||||
|
||||
// Parse the date received in some preamble.
|
||||
//
|
||||
// Dates can be stored either as strings or a `Date` object (due to helpful YAML parsing), or be missing. In all three
|
||||
// cases we try to extract an ISO-8601 string that we can serialize to JSON.
|
||||
function processDate(date: string | Date | undefined): string {
|
||||
if (typeof date === "string") {
|
||||
return date;
|
||||
} else if (typeof date === "undefined") {
|
||||
return "2020-01-01T09:00:00.000Z";
|
||||
} else {
|
||||
return date.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse any date-like objects found in the given object.
|
||||
//
|
||||
// This goes some way to ensure that the `Record` doesn't contain any `Date` objects, which we cannot serialize to JSON.
|
||||
// Instead all dates should be stored as ISO-8601 strings.
|
||||
function processDates(obj: Record<string, any>): Record<string, any> {
|
||||
Object.keys(obj).forEach((key) => {
|
||||
let value = obj[key];
|
||||
|
||||
if (value instanceof Date) {
|
||||
obj[key] = value.toISOString();
|
||||
} else if (typeof value === "object") {
|
||||
obj[key] = processDates(value);
|
||||
}
|
||||
});
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Given a path to a document and some preamble, build the `DocInfo`.
|
||||
//
|
||||
// This function constructs the `DocInfo` interface using the given data:
|
||||
//
|
||||
// 1. The `slug` of the document is the document's filename without the '.md' extension.
|
||||
// 2. The title is "Untitled" unless a title is provided in the preamble.
|
||||
// 3. The excerpt is extracted from the preamble (if there is any).
|
||||
// 4. The `published` date string is retrieved from the preamble (if there is any).
|
||||
export function extractDocInfo(
|
||||
filename: string,
|
||||
preamble: PagePreamble
|
||||
): DocInfo {
|
||||
return {
|
||||
slug: path.basename(filename).replace(".md", ""),
|
||||
title: preamble.title || "Untitled",
|
||||
excerpt: preamble.excerpt || null,
|
||||
published: processDate(preamble.published),
|
||||
};
|
||||
}
|
||||
|
||||
// Given a path to a document and some preamble, build the `PostInfo`.
|
||||
//
|
||||
// This function builds the `PostInfo` by first building the `DocInfo` that `PostInfo` extends using the
|
||||
// `extractDocInfo` function defined above. This function then extracts the following:
|
||||
//
|
||||
// 1. The `coverImage` is extracted from the preamble if one is present. A `/` is prepended to the cover image path if
|
||||
// one is not already present.
|
||||
// 2. The `readingTime` is "calculated" by dividing the number of words in the document by 200.
|
||||
// 3. The `tags` are extracted from the preamble if any are present.
|
||||
function extractPostInfo(
|
||||
filename: string,
|
||||
preamble: PostPreamble,
|
||||
wordCount: number
|
||||
): PostInfo {
|
||||
const obj = extractDocInfo(filename, preamble) as PostInfo;
|
||||
obj.coverImage = preamble.cover || null;
|
||||
if (typeof obj.coverImage === "string" && !obj.coverImage.startsWith("/")) {
|
||||
obj.coverImage = "/" + obj.coverImage;
|
||||
}
|
||||
obj.readingTime = Math.trunc(wordCount / 200);
|
||||
obj.tags = preamble.tags || [];
|
||||
return obj;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// Load a `Page` from the given path.
|
||||
export async function loadPage(doc_path: string): Promise<Page> {
|
||||
const { preamble, content } = await loadDoc<PagePreamble>(doc_path);
|
||||
return {
|
||||
...extractDocInfo(doc_path, preamble),
|
||||
preamble: processDates(preamble),
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
/// Load the slugs for all pages in the site.
|
||||
export async function loadPageSlugs(): Promise<string[]> {
|
||||
const pagesDir = path.join(process.cwd(), "content", "pages");
|
||||
const filenames = await fs.readdir(pagesDir);
|
||||
|
||||
return filenames.map((filename) =>
|
||||
path.basename(filename).replace(".md", "")
|
||||
);
|
||||
}
|
||||
|
||||
/// Load a `Page` with the given slug.
|
||||
export async function loadPageWithSlug(slug: string): Promise<Page> {
|
||||
const pagePath = path.join(process.cwd(), "content", "pages", slug + ".md");
|
||||
return await loadPage(pagePath);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// Load a `Post` from the given path.
|
||||
export async function loadPost(doc_path: string): Promise<Post> {
|
||||
const { preamble, wordCount, content, history } = await loadDoc<PostPreamble>(
|
||||
doc_path
|
||||
);
|
||||
return {
|
||||
...extractPostInfo(doc_path, preamble, wordCount),
|
||||
preamble: processDates(preamble),
|
||||
history,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
/// Load the slugs for all posts in the site.
|
||||
export async function loadPostSlugs(): Promise<string[]> {
|
||||
const postsDir = path.join(process.cwd(), "content", "posts");
|
||||
const filenames = await fs.readdir(postsDir);
|
||||
|
||||
return filenames.map((filename) =>
|
||||
path.basename(filename).replace(".md", "")
|
||||
);
|
||||
}
|
||||
|
||||
/// Load all `PostInfo` for the posts in the site.
|
||||
///
|
||||
/// This is used to build the index of blog posts, where only some summary information of a post is required (as encoded
|
||||
/// by the `PostInfo` interface). This function will also sort the results by descending published date.
|
||||
export async function loadPostInfos(): Promise<PostInfo[]> {
|
||||
const postsDir = path.join(process.cwd(), "content", "posts");
|
||||
const filenames = await fs.readdir(postsDir);
|
||||
|
||||
const posts = await Promise.all(
|
||||
filenames.map(async (filename) => {
|
||||
const { preamble, source } = await loadDocSource<PostPreamble>(
|
||||
path.join(postsDir, filename)
|
||||
);
|
||||
return extractPostInfo(filename, preamble, countWords(source));
|
||||
})
|
||||
);
|
||||
|
||||
return posts.sort(
|
||||
(a, b) => new Date(b.published).getTime() - new Date(a.published).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/// Load a `Post` with the given slug.
|
||||
export async function loadPostWithSlug(slug: string): Promise<Post> {
|
||||
const postPath = path.join(process.cwd(), "content", "posts", slug + ".md");
|
||||
return await loadPost(postPath);
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { Feed, Item } from "feed";
|
||||
|
||||
import { loadPostInfos } from "../lib/content";
|
||||
|
||||
const BASE_URL = "https://www.blakerain.com";
|
||||
|
||||
/// Create the various RSS and Atom feeds for all blog posts in the site.
|
||||
export async function generateFeeds() {
|
||||
const now = new Date();
|
||||
const feed = new Feed({
|
||||
title: "Blake Rain",
|
||||
description: "Feed of blog posts on the website of Blake Rain",
|
||||
id: `${BASE_URL}/`,
|
||||
link: `${BASE_URL}/`,
|
||||
language: "en",
|
||||
image: `${BASE_URL}/media/logo-text.png`,
|
||||
favicon: `${BASE_URL}/favicon.png`,
|
||||
copyright: `All Rights Reserved ${now.getFullYear()}, Blake Rain`,
|
||||
updated: now,
|
||||
feedLinks: {
|
||||
json: `${BASE_URL}/feeds/feed.json`,
|
||||
atom: `${BASE_URL}/feeds/atom.xml`,
|
||||
rss2: `${BASE_URL}/feeds/feed.xml`,
|
||||
},
|
||||
author: {
|
||||
name: "Blake Rain",
|
||||
email: "blake.rain@blakerain.com",
|
||||
link: `${BASE_URL}/about`,
|
||||
},
|
||||
});
|
||||
|
||||
const posts = await loadPostInfos();
|
||||
for (const post of posts) {
|
||||
const post_url = `${BASE_URL}/blog/${post.slug}`;
|
||||
const item: Item = {
|
||||
title: post.title,
|
||||
id: post_url,
|
||||
link: post_url,
|
||||
date: new Date(post.published),
|
||||
author: [
|
||||
{
|
||||
name: "Blake Rain",
|
||||
email: "blake.rain@blakerain.com",
|
||||
link: `${BASE_URL}/about`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (post.excerpt) {
|
||||
item.description = post.excerpt;
|
||||
}
|
||||
|
||||
if (post.coverImage) {
|
||||
item.image = `${BASE_URL}${post.coverImage}`;
|
||||
}
|
||||
|
||||
feed.addItem(item);
|
||||
}
|
||||
|
||||
const feedsDir = path.join(process.cwd(), "public", "feeds");
|
||||
|
||||
await fs.mkdir(feedsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(feedsDir, "feed.xml"), feed.rss2());
|
||||
await fs.writeFile(path.join(feedsDir, "feed.json"), feed.json1());
|
||||
await fs.writeFile(path.join(feedsDir, "atom.xml"), feed.atom1());
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
// A utility module for extracting git history
|
||||
//
|
||||
// This module exports some functions that can be used to extract the history for a given path in the repository. This
|
||||
// allows us to include a revision history with each file (e.g. a blog post).
|
||||
//
|
||||
// The trick here is to get git to format it's output as JSON so we can parse it. However, git will not escape any
|
||||
// inverted commas (`"`) in commit messages. For this reason we use a placeholder (currently `^^^^`) for an inverted
|
||||
// comma, and later perform the escaping ourselves (see the `parseGitLogEntries` function).
|
||||
|
||||
import util from "util";
|
||||
import { exec } from "child_process";
|
||||
|
||||
const exec_async = util.promisify(exec);
|
||||
const GIT_LOG_FORMAT =
|
||||
"{^^^^hash^^^^:^^^^%H^^^^,^^^^abbreviated^^^^:^^^^%h^^^^,^^^^author^^^^:^^^^%an^^^^,^^^^date^^^^:^^^^%aI^^^^,^^^^message^^^^:^^^^%s^^^^}";
|
||||
|
||||
export interface GitLogEntry {
|
||||
hash: string;
|
||||
abbreviated: string;
|
||||
author: string;
|
||||
date: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
function parseGitLogEntries(source: string): GitLogEntry[] {
|
||||
return JSON.parse(
|
||||
"[" +
|
||||
source
|
||||
.replaceAll("`", "'")
|
||||
.replaceAll('"', '\\"')
|
||||
.replaceAll("^^^^", '"')
|
||||
.split("\n")
|
||||
.join(",") +
|
||||
"]"
|
||||
);
|
||||
}
|
||||
|
||||
/// Load the revisions history of the given file.
|
||||
export async function loadFileRevisions(
|
||||
file_path: string
|
||||
): Promise<GitLogEntry[]> {
|
||||
let { stdout } = await exec_async(
|
||||
`git log --pretty=format:'${GIT_LOG_FORMAT}' "${file_path}"`
|
||||
);
|
||||
|
||||
try {
|
||||
return parseGitLogEntries(stdout);
|
||||
} catch (exc) {
|
||||
console.error("Failed to parse JSON from 'git log' for '" + file_path + "'");
|
||||
throw exc;
|
||||
}
|
||||
}
|
@ -1,147 +0,0 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkMdx from "remark-mdx";
|
||||
import remarkRehype from "remark-rehype";
|
||||
import remarkEmoji from "remark-emoji";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||
import rehypeImageSize from "rehype-img-size";
|
||||
import { Root } from "hast";
|
||||
|
||||
import Store from "./search/encoding/store";
|
||||
import IndexDoc from "./search/document/document";
|
||||
import IndexBuilder from "./search/index/builder";
|
||||
|
||||
import { loadDocSource, Preamble } from "./content";
|
||||
import { rehypeWrapFigures, remarkUnwrapImages } from "./plugins";
|
||||
|
||||
// Create the unified processor that we use to parse markdown into HTML.
|
||||
function createProcessor() {
|
||||
return unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkMdx)
|
||||
|
||||
.use(remarkUnwrapImages)
|
||||
.use(remarkEmoji)
|
||||
.use(remarkGfm)
|
||||
.use(remarkRehype)
|
||||
|
||||
.use(rehypeSlug)
|
||||
.use(rehypeAutolinkHeadings)
|
||||
.use(rehypeImageSize, { dir: "public" })
|
||||
.use(rehypeWrapFigures);
|
||||
}
|
||||
|
||||
// Load a search document from a markdown file.
|
||||
async function loadSearchDoc<P extends Preamble & { cover?: string }>(
|
||||
id: number,
|
||||
page: boolean,
|
||||
doc_path: string
|
||||
): Promise<{ doc: IndexDoc; structure: Root } | null> {
|
||||
const slug = path.basename(doc_path).replace(".md", "");
|
||||
const { preamble, source } = await loadDocSource<P>(doc_path);
|
||||
if (typeof preamble.search === "boolean" && !preamble.search) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = new IndexDoc(id, slug, preamble.title || "No Title");
|
||||
|
||||
if (preamble.published) {
|
||||
doc.published = preamble.published;
|
||||
}
|
||||
|
||||
if (preamble.cover) {
|
||||
doc.cover = preamble.cover;
|
||||
}
|
||||
|
||||
if (preamble.excerpt) {
|
||||
doc.excerpt = preamble.excerpt;
|
||||
}
|
||||
|
||||
doc.page = page;
|
||||
|
||||
// Process the markdown source to HTML.
|
||||
const processor = createProcessor();
|
||||
const root = processor.parse(source);
|
||||
const hast = processor.runSync(root);
|
||||
|
||||
return { doc, structure: hast as Root };
|
||||
}
|
||||
|
||||
/// Build the search index over all pages and blog posts.
|
||||
///
|
||||
/// This will return the `IndexBuilder` that can be serialized to a binary file.
|
||||
async function buildSearchIndex(): Promise<IndexBuilder> {
|
||||
const index = new IndexBuilder();
|
||||
let doc_index = 0;
|
||||
|
||||
// Iterate through all the pages, extract their source, and add it to the `IndexBuilder`.
|
||||
const pagesDir = path.join(process.cwd(), "content", "pages");
|
||||
for (let filename of await fs.readdir(pagesDir)) {
|
||||
const doc = await loadSearchDoc(
|
||||
doc_index++,
|
||||
true,
|
||||
path.join(pagesDir, filename)
|
||||
);
|
||||
|
||||
if (doc) {
|
||||
index.addDocument(doc.doc, doc.structure);
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate through all the blog posts, extract their source, and add it to the `IndexBuilder`.
|
||||
const postsDir = path.join(process.cwd(), "content", "posts");
|
||||
for (let filename of await fs.readdir(postsDir)) {
|
||||
const doc = await loadSearchDoc(
|
||||
doc_index++,
|
||||
false,
|
||||
path.join(postsDir, filename)
|
||||
);
|
||||
|
||||
if (doc) {
|
||||
index.addDocument(doc.doc, doc.structure);
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare the final index and return it.
|
||||
return index;
|
||||
}
|
||||
|
||||
// Write a `IndexBuilder` to the given file under `/public/data/` directory.
|
||||
async function writeIndex(filename: string, index: IndexBuilder) {
|
||||
// Create a new binary store.
|
||||
const store = new Store();
|
||||
|
||||
// Encode the prepared index using the store
|
||||
index.store(store);
|
||||
console.log(`Generate search index:`);
|
||||
index.sizes.log();
|
||||
|
||||
// Write the contents of the encoder to the destination file.
|
||||
return fs.writeFile(
|
||||
path.join(process.cwd(), "public", "data", filename),
|
||||
Buffer.from(store.finish())
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate all the search indices for this site.
|
||||
///
|
||||
/// Currently we only have the one index, which we store under `/public/data/search.bin` and indexes all pages and blog
|
||||
/// posts.
|
||||
///
|
||||
/// This function will use the `buildSearchIndex` function from the `content` module to build a `PreparedIndex`, which
|
||||
/// we then store to the `/public/data/search.bin` file.
|
||||
export async function generateIndices() {
|
||||
// Create the 'data' directory in the 'public' directory if it doesn't exist. This is where we store the prepared
|
||||
// index, and is what will be served by CloudFront.
|
||||
await fs.mkdir(path.join(process.cwd(), "public", "data"), {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Create the search index and write it to 'search.bin'.
|
||||
await writeIndex("search.bin", await buildSearchIndex());
|
||||
}
|
1
old/lib/missing.d.ts
vendored
1
old/lib/missing.d.ts
vendored
@ -1 +0,0 @@
|
||||
declare module "next-image-export-optimizer";
|
@ -1,18 +0,0 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import yaml from "yaml";
|
||||
|
||||
/// An entry in the site navigation.
|
||||
export interface SiteNavigation {
|
||||
/// The title of the navigation item.
|
||||
label: string;
|
||||
/// The URL of the navigation item.
|
||||
url: string;
|
||||
}
|
||||
|
||||
/// Load the site navigation from the `navigation.yaml` file in the `/content/` directory.
|
||||
export async function loadNavigation(): Promise<SiteNavigation[]> {
|
||||
const navPath = path.join(process.cwd(), "content", "navigation.yaml");
|
||||
const navSrc = await fs.readFile(navPath, "utf-8");
|
||||
return yaml.parse(navSrc) as SiteNavigation[];
|
||||
}
|
@ -1,160 +0,0 @@
|
||||
import { Node } from "unist";
|
||||
import * as mdast from "mdast";
|
||||
import * as hast from "hast";
|
||||
|
||||
const parseAttributes = (str: string): { [key: string]: string } => {
|
||||
const attrs: { [key: string]: string } = {};
|
||||
const regex = /(\S+)=["']?((?:.(?!["']?\s+(?:\S+)=|[>"']))+.)["']?/g;
|
||||
let match;
|
||||
while ((match = regex.exec(str)) !== null) {
|
||||
attrs[match[1]] = match[2];
|
||||
}
|
||||
return attrs;
|
||||
};
|
||||
|
||||
// A remark plugin that extracts images that live inside paragraphs, to avoid `<p><img .../></p>`.
|
||||
export function remarkUnwrapImages() {
|
||||
function transformChildren(node: mdast.Parent) {
|
||||
node.children = node.children.map((child) =>
|
||||
walkNode(child)
|
||||
) as mdast.Content[];
|
||||
}
|
||||
|
||||
function walkNode(node: Node): Node {
|
||||
switch (node.type) {
|
||||
case "root":
|
||||
transformChildren(node as mdast.Root);
|
||||
break;
|
||||
case "paragraph": {
|
||||
const paragraph = node as mdast.Paragraph;
|
||||
if (
|
||||
paragraph.children.length === 1 &&
|
||||
paragraph.children[0].type === "image"
|
||||
) {
|
||||
return walkNode(paragraph.children[0]);
|
||||
} else {
|
||||
transformChildren(paragraph);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
return (tree: Node) => {
|
||||
return walkNode(tree);
|
||||
};
|
||||
}
|
||||
|
||||
// A rehype plugin that wraps code blocks in figures
|
||||
export function rehypeWrapFigures() {
|
||||
function transformChildren(node: hast.Parent) {
|
||||
node.children = node.children.map((child) =>
|
||||
walkNode(child, node)
|
||||
) as hast.Content[];
|
||||
}
|
||||
|
||||
function walkNode(node: Node, parent?: hast.Parent): Node {
|
||||
switch (node.type) {
|
||||
case "root": {
|
||||
transformChildren(node as hast.Root);
|
||||
break;
|
||||
}
|
||||
|
||||
case "element": {
|
||||
const element = node as hast.Element;
|
||||
transformChildren(element);
|
||||
|
||||
if (
|
||||
element.tagName === "code" &&
|
||||
parent &&
|
||||
parent.type === "element" &&
|
||||
(parent as hast.Element).tagName === "pre"
|
||||
) {
|
||||
element.properties = element.properties || {};
|
||||
const className = element.properties.className;
|
||||
if (typeof className === "string") {
|
||||
element.properties.className = ["block", className];
|
||||
} else if (className instanceof Array) {
|
||||
element.properties.className = ["block", ...className];
|
||||
} else {
|
||||
element.properties.className = "block";
|
||||
}
|
||||
} else if (element.tagName === "pre" && element.children.length > 0) {
|
||||
const child = element.children[0];
|
||||
const child_data = (child.data as any) || {};
|
||||
let caption: string | null = null;
|
||||
|
||||
if ("meta" in child_data && typeof child_data["meta"] === "string") {
|
||||
const meta = parseAttributes(child_data["meta"]);
|
||||
if ("caption" in meta && typeof meta["caption"] === "string") {
|
||||
caption = meta["caption"];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "element",
|
||||
tagName: "figure",
|
||||
properties: {
|
||||
className: "code" + (caption ? " caption" : ""),
|
||||
},
|
||||
position: element.position,
|
||||
children: [
|
||||
element,
|
||||
...(caption
|
||||
? [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "figcaption",
|
||||
children: [{ type: "text", value: caption }],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
} as hast.Node;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
return (tree: Node) => {
|
||||
return walkNode(tree);
|
||||
};
|
||||
}
|
||||
|
||||
// A rehype plugin that adds a 'data-path' field to every element indicating it's path (according to seach structure).
|
||||
export function rehypeAddPaths() {
|
||||
function transformChildren(path: number[], node: hast.Parent) {
|
||||
node.children = node.children.map((child, index) =>
|
||||
walkNode([...path, index], child)
|
||||
) as hast.Content[];
|
||||
}
|
||||
|
||||
function walkNode(path: number[], node: Node): Node {
|
||||
switch (node.type) {
|
||||
case "root":
|
||||
transformChildren(path, node as hast.Root);
|
||||
break;
|
||||
case "element": {
|
||||
const element = node as hast.Element;
|
||||
if (typeof element.properties === "undefined") {
|
||||
element.properties = {};
|
||||
}
|
||||
|
||||
element.properties["data-path"] = path.join(",");
|
||||
transformChildren(path, element);
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
return (tree: Node) => {
|
||||
return walkNode([], tree);
|
||||
};
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
import Load from "../encoding/load";
|
||||
import Store from "../encoding/store";
|
||||
|
||||
export default class IndexDoc {
|
||||
public id: number;
|
||||
public page: boolean;
|
||||
public slug: string;
|
||||
public title: string;
|
||||
public published: string | null;
|
||||
public cover: string | null;
|
||||
public excerpt: string | null;
|
||||
|
||||
constructor(id: number, slug: string, title: string) {
|
||||
this.id = id;
|
||||
this.page = false;
|
||||
this.slug = slug;
|
||||
this.title = title;
|
||||
this.published = null;
|
||||
this.cover = null;
|
||||
this.excerpt = null;
|
||||
}
|
||||
|
||||
public get url(): string {
|
||||
return this.page ? `/${this.slug}` : `/blog/${this.slug}`;
|
||||
}
|
||||
|
||||
public store(store: Store) {
|
||||
store.writeUintVlq(
|
||||
(this.id << 4) |
|
||||
(this.excerpt ? 0x08 : 0x00) |
|
||||
(this.cover ? 0x04 : 0x00) |
|
||||
(this.published ? 0x02 : 0x00) |
|
||||
(this.page ? 0x01 : 0x00)
|
||||
);
|
||||
store.writeUtf8(this.slug);
|
||||
store.writeUtf8(this.title);
|
||||
|
||||
if (this.published) {
|
||||
store.writeUtf8(this.published);
|
||||
}
|
||||
|
||||
if (this.cover) {
|
||||
store.writeUtf8(this.cover);
|
||||
}
|
||||
|
||||
if (this.excerpt) {
|
||||
store.writeUtf8(this.excerpt);
|
||||
}
|
||||
}
|
||||
|
||||
public static load(load: Load): IndexDoc {
|
||||
const tag = load.readUintVlq();
|
||||
const slug = load.readUtf8();
|
||||
const title = load.readUtf8();
|
||||
|
||||
const doc = new IndexDoc(tag >> 4, slug, title);
|
||||
|
||||
if ((tag & 0x01) === 0x01) {
|
||||
doc.page = true;
|
||||
}
|
||||
|
||||
if ((tag & 0x02) == 0x02) {
|
||||
doc.published = load.readUtf8();
|
||||
}
|
||||
|
||||
if ((tag & 0x04) == 0x04) {
|
||||
doc.cover = load.readUtf8();
|
||||
}
|
||||
|
||||
if ((tag & 0x08) === 0x08) {
|
||||
doc.excerpt = load.readUtf8();
|
||||
}
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
import Load from "../encoding/load";
|
||||
import Store from "../encoding/store";
|
||||
import { DecoderStats } from "../index/stats";
|
||||
|
||||
export class IndexDocLocation {
|
||||
/// The ID of the document in which this location is to be found.
|
||||
public docId: number;
|
||||
/// The path through the document structure to this location.
|
||||
public path: number[];
|
||||
|
||||
constructor(docId: number, path: number[]) {
|
||||
this.docId = docId;
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/// A cache of all IndexDocLocation records
|
||||
export class IndexDocLocations {
|
||||
public locations: Map<number, IndexDocLocation> = new Map();
|
||||
|
||||
public addLocation(docId: number, path: number[]): number {
|
||||
const index = this.locations.size;
|
||||
this.locations.set(index, new IndexDocLocation(docId, path));
|
||||
return index;
|
||||
}
|
||||
|
||||
public getLocation(id: number): IndexDocLocation | undefined {
|
||||
return this.locations.get(id);
|
||||
}
|
||||
|
||||
public store(store: Store) {
|
||||
store.writeUintVlq(this.locations.size);
|
||||
for (const [id, location] of this.locations) {
|
||||
store.writeUintVlq(id);
|
||||
store.writeUintVlq(location.docId);
|
||||
store.writeUintVlqSeq(location.path);
|
||||
}
|
||||
}
|
||||
|
||||
public static load(load: Load, stats: DecoderStats): IndexDocLocations {
|
||||
const locations = new IndexDocLocations();
|
||||
|
||||
let nlocations = load.readUintVlq();
|
||||
while (nlocations-- > 0) {
|
||||
const id = load.readUintVlq();
|
||||
const doc_id = load.readUintVlq();
|
||||
const path = load.readUintVlqSeq();
|
||||
|
||||
locations.locations.set(id, new IndexDocLocation(doc_id, path));
|
||||
}
|
||||
|
||||
stats.sizes.locations += locations.locations.size;
|
||||
return locations;
|
||||
}
|
||||
}
|
@ -1,72 +0,0 @@
|
||||
export default class Load {
|
||||
private buffer: ArrayBuffer;
|
||||
private offset: number;
|
||||
private view: DataView;
|
||||
|
||||
constructor(buffer: ArrayBuffer) {
|
||||
this.buffer = buffer;
|
||||
this.offset = 0;
|
||||
this.view = new DataView(this.buffer);
|
||||
}
|
||||
|
||||
public get length(): number {
|
||||
return this.buffer.byteLength;
|
||||
}
|
||||
|
||||
public get remaining(): number {
|
||||
return this.buffer.byteLength - this.offset;
|
||||
}
|
||||
|
||||
public readUint8(): number {
|
||||
const value = this.view.getUint8(this.offset);
|
||||
this.offset += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
public readUint16(): number {
|
||||
const value = this.view.getUint16(this.offset);
|
||||
this.offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
public readUint32(): number {
|
||||
const value = this.view.getUint32(this.offset);
|
||||
this.offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
public readUintVlq(): number {
|
||||
var byte = 0,
|
||||
value = 0,
|
||||
shift = 0;
|
||||
|
||||
do {
|
||||
byte = this.view.getUint8(this.offset++);
|
||||
value |= (byte & 0x7f) << shift;
|
||||
shift += 7;
|
||||
} while (byte >= 0x80);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public readUintVlqSeq(): number[] {
|
||||
const seq: number[] = [];
|
||||
var value: number;
|
||||
|
||||
do {
|
||||
value = this.readUintVlq();
|
||||
seq.push(value >> 1);
|
||||
} while ((value & 0x01) === 0x01);
|
||||
|
||||
return seq;
|
||||
}
|
||||
|
||||
public readUtf8(): string {
|
||||
const len = this.readUintVlq();
|
||||
const str = new TextDecoder("utf-8").decode(
|
||||
new DataView(this.buffer, this.offset, len)
|
||||
);
|
||||
this.offset += len;
|
||||
return str;
|
||||
}
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
export default class Store {
|
||||
private parts: Uint8Array[] = [];
|
||||
|
||||
constructor() {}
|
||||
|
||||
public get length(): number {
|
||||
return this.parts.reduce((acc, part) => acc + part.length, 0);
|
||||
}
|
||||
|
||||
public writeUint8(value: number) {
|
||||
const arr = new ArrayBuffer(1);
|
||||
new DataView(arr).setUint8(0, value);
|
||||
this.parts.push(new Uint8Array(arr));
|
||||
}
|
||||
|
||||
public writeUint16(value: number) {
|
||||
const arr = new ArrayBuffer(2);
|
||||
new DataView(arr).setUint16(0, value);
|
||||
this.parts.push(new Uint8Array(arr));
|
||||
}
|
||||
|
||||
public writeUint32(value: number) {
|
||||
const arr = new ArrayBuffer(4);
|
||||
new DataView(arr).setUint32(0, value);
|
||||
this.parts.push(new Uint8Array(arr));
|
||||
}
|
||||
|
||||
public writeUintVlq(value: number) {
|
||||
const values: number[] = [];
|
||||
|
||||
while (value >= 0x80) {
|
||||
values.push((value & 0x7f) | 0x80);
|
||||
value = value >> 7;
|
||||
}
|
||||
|
||||
values.push(value & 0x7f);
|
||||
this.parts.push(new Uint8Array(values));
|
||||
}
|
||||
|
||||
public writeUtf8(value: string) {
|
||||
const arr = new TextEncoder().encode(value);
|
||||
this.writeUintVlq(arr.length);
|
||||
this.parts.push(arr);
|
||||
}
|
||||
|
||||
public writeUintVlqSeq(seq: number[]) {
|
||||
for (let i = 0; i < seq.length; i++) {
|
||||
this.writeUintVlq((seq[i] << 1) | (i === seq.length - 1 ? 0x00 : 0x01));
|
||||
}
|
||||
}
|
||||
|
||||
public finish(): ArrayBuffer {
|
||||
const len = this.parts.reduce((acc, part) => acc + part.length, 0);
|
||||
const arr = new ArrayBuffer(len);
|
||||
const buf = new Uint8Array(arr);
|
||||
var offset = 0;
|
||||
|
||||
this.parts.forEach((part) => {
|
||||
buf.set(part, offset);
|
||||
offset += part.length;
|
||||
});
|
||||
|
||||
return arr;
|
||||
}
|
||||
}
|
@ -1,106 +0,0 @@
|
||||
import { Element, Node, Root, Text } from "hast";
|
||||
import IndexDoc from "../document/document";
|
||||
import { IndexDocLocations } from "../document/location";
|
||||
import Store from "../encoding/store";
|
||||
import Tree from "../tree/tree";
|
||||
import { BuilderSizes } from "./stats";
|
||||
import { tokenizeCode, tokenizePhrasing } from "./tokens";
|
||||
|
||||
export const MAGIC = 0x53524348;
|
||||
|
||||
interface WalkStructItem {
|
||||
path: number[];
|
||||
tagName: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
function* walkStruct(root: Root): Generator<WalkStructItem> {
|
||||
let path: number[] = [];
|
||||
let index: number = 0;
|
||||
let nodes: Node[] = [...root.children];
|
||||
let tagName: string = "";
|
||||
let stack: { tagName: string; nodes: Node[] }[] = [];
|
||||
|
||||
for (;;) {
|
||||
while (nodes.length > 0) {
|
||||
const node = nodes.shift()!;
|
||||
|
||||
if (node.type === "text" && (node as Text).value.length > 0) {
|
||||
yield {
|
||||
path: [...path, index],
|
||||
tagName,
|
||||
content: (node as Text).value,
|
||||
};
|
||||
index += 1;
|
||||
} else if (node.type === "element") {
|
||||
path.push(index);
|
||||
stack.push({ tagName, nodes });
|
||||
|
||||
const element = node as Element;
|
||||
nodes = [...element.children];
|
||||
index = 0;
|
||||
tagName = element.tagName;
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const top = stack.pop()!;
|
||||
nodes = top.nodes;
|
||||
tagName = top.tagName;
|
||||
index = path.pop()! + 1;
|
||||
}
|
||||
}
|
||||
|
||||
export default class IndexBuilder {
|
||||
sizes: BuilderSizes = new BuilderSizes();
|
||||
documents: Map<number, IndexDoc> = new Map();
|
||||
locations: IndexDocLocations = new IndexDocLocations();
|
||||
tree: Tree = new Tree();
|
||||
|
||||
constructor() {}
|
||||
|
||||
public addDocument(doc: IndexDoc, structure: Root) {
|
||||
if (this.documents.has(doc.id)) {
|
||||
throw new Error(`Duplicate index document ID ${doc.id}`);
|
||||
}
|
||||
|
||||
this.documents.set(doc.id, doc);
|
||||
this.sizes.documents += 1;
|
||||
|
||||
for (const { path, tagName, content } of walkStruct(structure)) {
|
||||
const tokens =
|
||||
tagName === "code" ? tokenizeCode(content) : tokenizePhrasing(content);
|
||||
if (tokens.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.sizes.tokens += tokens.length;
|
||||
this.sizes.locations += 1;
|
||||
|
||||
const location_id = this.locations.addLocation(doc.id, path);
|
||||
for (const token of tokens) {
|
||||
this.tree.insert(token.text, location_id, {
|
||||
start: token.start,
|
||||
length: token.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public store(store: Store) {
|
||||
store.writeUint32(MAGIC);
|
||||
|
||||
// Store the document information
|
||||
store.writeUintVlq(this.documents.size);
|
||||
for (const doc of this.documents.values()) {
|
||||
doc.store(store);
|
||||
}
|
||||
|
||||
this.locations.store(store);
|
||||
this.tree.store(store, this.sizes);
|
||||
this.sizes.size = store.length;
|
||||
}
|
||||
}
|
@ -1,152 +0,0 @@
|
||||
import IndexDoc from "../document/document";
|
||||
import { IndexDocLocations } from "../document/location";
|
||||
import Load from "../encoding/load";
|
||||
import { mergeRanges, Range } from "../tree/node";
|
||||
import Tree from "../tree/tree";
|
||||
import { MAGIC } from "./builder";
|
||||
import { DecoderStats } from "./stats";
|
||||
import { tokenizePhrasing } from "./tokens";
|
||||
|
||||
export interface SearchPositions {
|
||||
location_id: number;
|
||||
ranges: Range[];
|
||||
}
|
||||
|
||||
function mergeSearchPositions(
|
||||
left: SearchPositions[],
|
||||
right: SearchPositions[]
|
||||
): SearchPositions[] {
|
||||
const combined: SearchPositions[] = [...left];
|
||||
|
||||
for (const position of right) {
|
||||
let found = false;
|
||||
for (const existing of combined) {
|
||||
if (existing.location_id === position.location_id) {
|
||||
mergeRanges(existing.ranges, position.ranges);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
combined.push(position);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort the combined positions by location ID to ensure they are increasing
|
||||
return combined.sort((a, b) => a.location_id - b.location_id);
|
||||
}
|
||||
|
||||
export default class PreparedIndex {
|
||||
public documents: Map<number, IndexDoc> = new Map();
|
||||
public locations: IndexDocLocations = new IndexDocLocations();
|
||||
public tree: Tree = new Tree();
|
||||
|
||||
public searchTerm(
|
||||
term: string,
|
||||
docId?: number
|
||||
): Map<number, SearchPositions[]> {
|
||||
const found_locations = this.tree.search(term);
|
||||
|
||||
// Iterate through the set of locations, and build a mapping from an IndexDoc ID to an object containing the
|
||||
// selector and list of positions.
|
||||
let results: Map<number, SearchPositions[]> = new Map();
|
||||
for (const [location_id, positions] of found_locations) {
|
||||
const location = this.locations.getLocation(location_id)!;
|
||||
|
||||
// If we're only looking for a certain document, and this location isn't in that document, skip it.
|
||||
if (typeof docId === "number" && location.docId !== docId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = results.get(location.docId);
|
||||
if (result) {
|
||||
result.push({ location_id, ranges: positions });
|
||||
} else {
|
||||
results.set(location.docId, [{ location_id, ranges: positions }]);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public search(input: string, docId?: number): Map<number, SearchPositions[]> {
|
||||
const tokens = tokenizePhrasing(input);
|
||||
if (tokens.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const matches = tokens.map((token) => this.searchTerm(token.text, docId));
|
||||
|
||||
// Build a set that combines the intersection of all document IDs
|
||||
let combined_ids: Set<number> | null = null;
|
||||
for (const match of matches) {
|
||||
const match_ids = new Set(match.keys());
|
||||
if (combined_ids === null) {
|
||||
combined_ids = match_ids;
|
||||
} else {
|
||||
for (const doc_id of combined_ids) {
|
||||
if (!match_ids.has(doc_id)) {
|
||||
combined_ids.delete(doc_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't find anything, or the intersection of documents was an empty set (no document(s) include all terms),
|
||||
// then the result of the search is empty.
|
||||
if (combined_ids === null || combined_ids.size === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Build the combined map
|
||||
const combined: Map<number, SearchPositions[]> = new Map();
|
||||
for (const match of matches) {
|
||||
for (const [document_id, positions] of match) {
|
||||
// If this document is not in the combined document IDs, then skip it.
|
||||
if (!combined_ids.has(document_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!combined.has(document_id)) {
|
||||
combined.set(document_id, positions);
|
||||
} else {
|
||||
let current = combined.get(document_id)!;
|
||||
combined.set(document_id, mergeSearchPositions(current, positions));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return combined;
|
||||
}
|
||||
|
||||
public static load(load: Load): PreparedIndex {
|
||||
const start = performance.now();
|
||||
const stats = new DecoderStats(load.length);
|
||||
const magic = load.readUint32();
|
||||
if (magic !== MAGIC) {
|
||||
console.error(
|
||||
`Expected file magic 0x${MAGIC.toString(16)}, found 0x${magic.toString(
|
||||
16
|
||||
)}`
|
||||
);
|
||||
throw new Error(`Invalid prepared index file`);
|
||||
}
|
||||
|
||||
const index = new PreparedIndex();
|
||||
|
||||
let doc_count = load.readUintVlq();
|
||||
while (doc_count-- > 0) {
|
||||
const doc = IndexDoc.load(load);
|
||||
index.documents.set(doc.id, doc);
|
||||
stats.sizes.documents++;
|
||||
}
|
||||
|
||||
index.locations = IndexDocLocations.load(load, stats);
|
||||
index.tree = Tree.load(load, stats);
|
||||
stats.timings.load = performance.now() - start;
|
||||
stats.log();
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
@ -1,57 +0,0 @@
|
||||
export class BuilderSizes {
|
||||
documents: number = 0;
|
||||
locations: number = 0;
|
||||
tokens: number = 0;
|
||||
nodes: number = 0;
|
||||
maxDepth: number = 0;
|
||||
size: number = 0;
|
||||
|
||||
public log() {
|
||||
const nf = Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
|
||||
console.log(
|
||||
`Index of ${nf.format(this.size / 1024.0)} Kib covering ${nf.format(
|
||||
this.documents
|
||||
)} document(s) containing ${nf.format(
|
||||
this.tokens
|
||||
)} token(s) over ${nf.format(
|
||||
this.locations
|
||||
)} location(s) formed a tree of ${nf.format(
|
||||
this.nodes
|
||||
)} node(s) (max. depth ${nf.format(this.maxDepth)})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class DecoderStats {
|
||||
public sizes = {
|
||||
documents: 0,
|
||||
locations: 0,
|
||||
nodes: 0,
|
||||
size: 0,
|
||||
};
|
||||
|
||||
public timings = {
|
||||
load: 0,
|
||||
documents: 0,
|
||||
locations: 0,
|
||||
tree: 0,
|
||||
};
|
||||
|
||||
constructor(size: number) {
|
||||
this.sizes.size = size;
|
||||
}
|
||||
|
||||
public log() {
|
||||
const nf = Intl.NumberFormat(undefined, { maximumFractionDigits: 2 });
|
||||
const { documents, locations, nodes, size } = this.sizes;
|
||||
console.log(
|
||||
`Index of ${nf.format(size / 1024.0)} Kib covering ${nf.format(
|
||||
documents
|
||||
)} document(s) containing ${nf.format(
|
||||
locations
|
||||
)} location(s) and a tree of ${nf.format(
|
||||
nodes
|
||||
)} node(s) loaded in ${nf.format(this.timings.load)} ms`
|
||||
);
|
||||
}
|
||||
}
|
@ -1,164 +0,0 @@
|
||||
// https://tartarus.org/martin/PorterStemmer/
|
||||
|
||||
const STEP2_LIST: { [key: string]: string } = {
|
||||
ational: "ate",
|
||||
tional: "tion",
|
||||
enci: "ence",
|
||||
anci: "ance",
|
||||
izer: "ize",
|
||||
bli: "ble",
|
||||
alli: "al",
|
||||
entli: "ent",
|
||||
eli: "e",
|
||||
ousli: "ous",
|
||||
ization: "ize",
|
||||
ation: "ate",
|
||||
ator: "ate",
|
||||
alism: "al",
|
||||
iveness: "ive",
|
||||
fulness: "ful",
|
||||
ousness: "ous",
|
||||
aliti: "al",
|
||||
iviti: "ive",
|
||||
biliti: "ble",
|
||||
logi: "log",
|
||||
};
|
||||
|
||||
const STEP3_LIST: { [key: string]: string } = {
|
||||
icate: "ic",
|
||||
ative: "",
|
||||
alize: "al",
|
||||
iciti: "ic",
|
||||
ical: "ic",
|
||||
ful: "",
|
||||
ness: "",
|
||||
};
|
||||
|
||||
// Consonant-vowel sequences.
|
||||
const CONSONANT = "[^aeiou]";
|
||||
const VOWEL = "[aeiouy]";
|
||||
const CONSONANTS = "(" + CONSONANT + "[^aeiouy]*)";
|
||||
const VOWELS = "(" + VOWEL + "[aeiou]*)";
|
||||
|
||||
const GT0 = new RegExp("^" + CONSONANTS + "?" + VOWELS + CONSONANTS);
|
||||
const EQ1 = new RegExp(
|
||||
"^" + CONSONANTS + "?" + VOWELS + CONSONANTS + VOWELS + "?$"
|
||||
);
|
||||
const GT1 = new RegExp("^" + CONSONANTS + "?(" + VOWELS + CONSONANTS + "){2,}");
|
||||
const VOWEL_IN_STEM = new RegExp("^" + CONSONANTS + "?" + VOWEL);
|
||||
const CONSONANT_LIKE = new RegExp("^" + CONSONANTS + VOWEL + "[^aeiouwxy]$");
|
||||
|
||||
// Exception expressions.
|
||||
const SFX_LL = /ll$/;
|
||||
const SFX_E = /^(.+?)e$/;
|
||||
const SFX_Y = /^(.+?)y$/;
|
||||
const SFX_ION = /^(.+?(s|t))(ion)$/;
|
||||
const SFX_ED_OR_ING = /^(.+?)(ed|ing)$/;
|
||||
const SFX_AT_OR_BL_OR_IZ = /(at|bl|iz)$/;
|
||||
const SFX_EED = /^(.+?)eed$/;
|
||||
const SFX_S = /^.+?[^s]s$/;
|
||||
const SFX_SSES_OR_IES = /^.+?(ss|i)es$/;
|
||||
const SFX_MULTI_CONSONANT_LIKE = /([^aeiouylsz])\1$/;
|
||||
|
||||
const STEP2 =
|
||||
/^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/;
|
||||
const STEP3 = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/;
|
||||
const STEP4 =
|
||||
/^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/;
|
||||
|
||||
export default function stemmer(value: string): string {
|
||||
// Exit early.
|
||||
if (value.length < 3) {
|
||||
return value;
|
||||
}
|
||||
|
||||
let firstCharacterWasLowerCaseY = false;
|
||||
|
||||
// Detect initial `y`, make sure it never matches.
|
||||
if (
|
||||
value.codePointAt(0) === 121 // Lowercase Y
|
||||
) {
|
||||
firstCharacterWasLowerCaseY = true;
|
||||
value = "Y" + value.slice(1);
|
||||
}
|
||||
|
||||
// Step 1a.
|
||||
if (SFX_SSES_OR_IES.test(value)) {
|
||||
// Remove last two characters.
|
||||
value = value.slice(0, -2);
|
||||
} else if (SFX_S.test(value)) {
|
||||
// Remove last character.
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
|
||||
let match: RegExpMatchArray | null;
|
||||
|
||||
// Step 1b.
|
||||
if ((match = SFX_EED.exec(value))) {
|
||||
if (GT0.test(match[1])) {
|
||||
// Remove last character.
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
} else if (
|
||||
(match = SFX_ED_OR_ING.exec(value)) &&
|
||||
VOWEL_IN_STEM.test(match[1])
|
||||
) {
|
||||
value = match[1];
|
||||
|
||||
if (SFX_AT_OR_BL_OR_IZ.test(value)) {
|
||||
// Append `e`.
|
||||
value += "e";
|
||||
} else if (SFX_MULTI_CONSONANT_LIKE.test(value)) {
|
||||
// Remove last character.
|
||||
value = value.slice(0, -1);
|
||||
} else if (CONSONANT_LIKE.test(value)) {
|
||||
// Append `e`.
|
||||
value += "e";
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1c.
|
||||
if ((match = SFX_Y.exec(value)) && VOWEL_IN_STEM.test(match[1])) {
|
||||
// Remove suffixing `y` and append `i`.
|
||||
value = match[1] + "i";
|
||||
}
|
||||
|
||||
// Step 2.
|
||||
if ((match = STEP2.exec(value)) && GT0.test(match[1])) {
|
||||
value = match[1] + STEP2_LIST[match[2]];
|
||||
}
|
||||
|
||||
// Step 3.
|
||||
if ((match = STEP3.exec(value)) && GT0.test(match[1])) {
|
||||
value = match[1] + STEP3_LIST[match[2]];
|
||||
}
|
||||
|
||||
// Step 4.
|
||||
if ((match = STEP4.exec(value))) {
|
||||
if (GT1.test(match[1])) {
|
||||
value = match[1];
|
||||
}
|
||||
} else if ((match = SFX_ION.exec(value)) && GT1.test(match[1])) {
|
||||
value = match[1];
|
||||
}
|
||||
|
||||
// Step 5.
|
||||
if (
|
||||
(match = SFX_E.exec(value)) &&
|
||||
(GT1.test(match[1]) ||
|
||||
(EQ1.test(match[1]) && !CONSONANT_LIKE.test(match[1])))
|
||||
) {
|
||||
value = match[1];
|
||||
}
|
||||
|
||||
if (SFX_LL.test(value) && GT1.test(value)) {
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
|
||||
// Turn initial `Y` back to `y`.
|
||||
if (firstCharacterWasLowerCaseY) {
|
||||
value = "y" + value.slice(1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
@ -1,168 +0,0 @@
|
||||
const STOP_WORDS = [
|
||||
"that",
|
||||
"been",
|
||||
"him",
|
||||
"us",
|
||||
"would",
|
||||
"own",
|
||||
"or",
|
||||
"yourselves",
|
||||
"new",
|
||||
"no",
|
||||
"such",
|
||||
"below",
|
||||
"did",
|
||||
"if",
|
||||
"myself",
|
||||
"against",
|
||||
"do",
|
||||
"because",
|
||||
"am",
|
||||
"back",
|
||||
"his",
|
||||
"to",
|
||||
"what",
|
||||
"people",
|
||||
"make",
|
||||
"who",
|
||||
"but",
|
||||
"on",
|
||||
"there",
|
||||
"between",
|
||||
"way",
|
||||
"other",
|
||||
"than",
|
||||
"which",
|
||||
"while",
|
||||
"see",
|
||||
"all",
|
||||
"I",
|
||||
"was",
|
||||
"them",
|
||||
"of",
|
||||
"just",
|
||||
"good",
|
||||
"she",
|
||||
"whom",
|
||||
"day",
|
||||
"only",
|
||||
"two",
|
||||
"first",
|
||||
"know",
|
||||
"ourselves",
|
||||
"come",
|
||||
"he",
|
||||
"from",
|
||||
"why",
|
||||
"few",
|
||||
"for",
|
||||
"their",
|
||||
"one",
|
||||
"the",
|
||||
"this",
|
||||
"any",
|
||||
"down",
|
||||
"more",
|
||||
"ours",
|
||||
"we",
|
||||
"think",
|
||||
"will",
|
||||
"about",
|
||||
"above",
|
||||
"were",
|
||||
"be",
|
||||
"our",
|
||||
"themselves",
|
||||
"having",
|
||||
"they",
|
||||
"time",
|
||||
"say",
|
||||
"under",
|
||||
"once",
|
||||
"doing",
|
||||
"further",
|
||||
"yours",
|
||||
"look",
|
||||
"with",
|
||||
"want",
|
||||
"in",
|
||||
"how",
|
||||
"like",
|
||||
"has",
|
||||
"had",
|
||||
"give",
|
||||
"by",
|
||||
"it",
|
||||
"during",
|
||||
"nor",
|
||||
"t",
|
||||
"a",
|
||||
"could",
|
||||
"very",
|
||||
"some",
|
||||
"well",
|
||||
"have",
|
||||
"your",
|
||||
"is",
|
||||
"so",
|
||||
"you",
|
||||
"i",
|
||||
"after",
|
||||
"yourself",
|
||||
"even",
|
||||
"should",
|
||||
"when",
|
||||
"himself",
|
||||
"at",
|
||||
"its",
|
||||
"and",
|
||||
"too",
|
||||
"same",
|
||||
"until",
|
||||
"hers",
|
||||
"as",
|
||||
"don",
|
||||
"most",
|
||||
"also",
|
||||
"herself",
|
||||
"take",
|
||||
"again",
|
||||
"before",
|
||||
"these",
|
||||
"through",
|
||||
"both",
|
||||
"theirs",
|
||||
"use",
|
||||
"her",
|
||||
"those",
|
||||
"where",
|
||||
"year",
|
||||
"being",
|
||||
"does",
|
||||
"off",
|
||||
"are",
|
||||
"s",
|
||||
"over",
|
||||
"here",
|
||||
"me",
|
||||
"go",
|
||||
"into",
|
||||
"each",
|
||||
"work",
|
||||
"up",
|
||||
"an",
|
||||
"itself",
|
||||
"my",
|
||||
"get",
|
||||
"out",
|
||||
"can",
|
||||
"then",
|
||||
"not",
|
||||
"now",
|
||||
];
|
||||
|
||||
const STOP_SET: Set<string> = new Set(STOP_WORDS);
|
||||
|
||||
export default function isStopWord(word: string): boolean {
|
||||
return STOP_SET.has(word);
|
||||
}
|
@ -1,74 +0,0 @@
|
||||
import stemmer from "./stem";
|
||||
import isStopWord from "./stop";
|
||||
|
||||
const SEPARATOR_RE = /[\W]+/;
|
||||
const IDENTIFIER_RE = /[\w_-]+/g;
|
||||
|
||||
export class Token {
|
||||
public start: number;
|
||||
public length: number;
|
||||
public text: string;
|
||||
|
||||
constructor(start: number, length: number, text: string) {
|
||||
this.start = start;
|
||||
this.length = length;
|
||||
this.text = text;
|
||||
}
|
||||
}
|
||||
|
||||
export function tokenizePhrasing(input: string): Token[] {
|
||||
input = input.toLowerCase();
|
||||
|
||||
const tokens: Token[] = [];
|
||||
for (let end = 0, start = 0; end <= input.length; ++end) {
|
||||
const char = input.charAt(end);
|
||||
const len = end - start;
|
||||
|
||||
if (char.match(SEPARATOR_RE) || end == input.length) {
|
||||
if (len > 0) {
|
||||
let text = input
|
||||
.slice(start, end)
|
||||
.replace(/^\W+/, "")
|
||||
.replace(/\W+$/, "");
|
||||
|
||||
if (text.length > 0 && !isStopWord(text)) {
|
||||
let token = new Token(start, len, stemmer(text));
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
start = end + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function tokenizeCode(input: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
|
||||
for (const match of input.matchAll(IDENTIFIER_RE)) {
|
||||
const parts = match[0].split(/[_]+/);
|
||||
let offset = 0;
|
||||
|
||||
for (let i = 0; i < parts.length; ++i) {
|
||||
for (const subpart of parts[i].split(/(?=[A-Z])/)) {
|
||||
if (subpart.length > 2) {
|
||||
tokens.push(
|
||||
new Token(
|
||||
(match.index || 0) + offset,
|
||||
subpart.length,
|
||||
subpart.toLowerCase()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
offset += subpart.length;
|
||||
}
|
||||
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
import { promises as fs } from "fs";
|
||||
import TreeNode from "./node";
|
||||
import Tree from "./tree";
|
||||
|
||||
export async function writeTreeDigraph(path: string, tree: Tree) {
|
||||
const lines: string[] = ["digraph {"];
|
||||
let node_index = 0;
|
||||
|
||||
function walk(node: TreeNode): number {
|
||||
let node_id = node_index++;
|
||||
lines.push(` node${node_id} [label=${JSON.stringify(node.fragment)}];`);
|
||||
for (const [ch, child] of node.children) {
|
||||
const child_id = walk(child);
|
||||
lines.push(
|
||||
` node${node_id} -> node${child_id} [label=${JSON.stringify(
|
||||
String.fromCharCode(ch)
|
||||
)}];`
|
||||
);
|
||||
}
|
||||
|
||||
return node_id;
|
||||
}
|
||||
|
||||
for (const node of tree.root.children.values()) {
|
||||
walk(node);
|
||||
}
|
||||
|
||||
lines.push("}");
|
||||
await fs.writeFile(path, lines.join("\n"), "utf-8");
|
||||
}
|
@ -1,117 +0,0 @@
|
||||
import Load from "../encoding/load";
|
||||
import Store from "../encoding/store";
|
||||
|
||||
export interface Range {
|
||||
start: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
export function mergeRanges(as: Range[], bs: Range[]) {
|
||||
for (const b of bs) {
|
||||
const b_start = b.start;
|
||||
const b_end = b.start + b.length;
|
||||
|
||||
let inserted = false;
|
||||
for (let i = 0; i < as.length; ++i) {
|
||||
const a_start = as[i].start;
|
||||
const a_end = a_start + as[i].length;
|
||||
|
||||
if (a_end < b_start) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (a_start > b_end) {
|
||||
inserted = true;
|
||||
as.splice(i, 0, b);
|
||||
break;
|
||||
}
|
||||
|
||||
as[i].start = Math.min(a_start, b_start);
|
||||
const end = Math.max(a_end, b_end);
|
||||
as[i].length = end - as[i].start;
|
||||
inserted = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!inserted) {
|
||||
as.push(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A node in the search tree
|
||||
///
|
||||
/// Each node in the search tree contains a set of zero or more children, where each child is indexed by character. Each
|
||||
/// node also contains a mapping from a location ID to an array of one or more ranges.
|
||||
export default class TreeNode {
|
||||
public fragment: string;
|
||||
public children: Map<number, TreeNode> = new Map();
|
||||
public ranges: Map<number, Range[]> = new Map();
|
||||
|
||||
constructor(fragment: string = "") {
|
||||
this.fragment = fragment;
|
||||
}
|
||||
|
||||
public addRange(location_id: number, position: Range) {
|
||||
let ranges = this.ranges.get(location_id);
|
||||
if (ranges) {
|
||||
ranges.push(position);
|
||||
} else {
|
||||
this.ranges.set(location_id, [position]);
|
||||
}
|
||||
}
|
||||
|
||||
public store(store: Store, key: number) {
|
||||
const tag =
|
||||
(key << 2) |
|
||||
(this.ranges.size > 0 ? 0x02 : 0x00) |
|
||||
(this.children.size > 0 ? 0x01 : 0x00);
|
||||
|
||||
store.writeUintVlq(tag);
|
||||
store.writeUtf8(this.fragment);
|
||||
if (this.ranges.size > 0) {
|
||||
store.writeUintVlq(this.ranges.size);
|
||||
for (const [location_id, ranges] of this.ranges) {
|
||||
store.writeUintVlq(location_id);
|
||||
store.writeUintVlq(ranges.length);
|
||||
for (const range of ranges) {
|
||||
store.writeUintVlq(range.start);
|
||||
store.writeUintVlq(range.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static load(load: Load): {
|
||||
key: number;
|
||||
hasChildren: boolean;
|
||||
node: TreeNode;
|
||||
} {
|
||||
const tag = load.readUintVlq();
|
||||
const fragment = load.readUtf8();
|
||||
const node = new TreeNode(fragment);
|
||||
|
||||
if ((tag & 0x02) === 0x02) {
|
||||
let nlocations = load.readUintVlq();
|
||||
while (nlocations-- > 0) {
|
||||
const location_id = load.readUintVlq();
|
||||
const positions: Range[] = [];
|
||||
|
||||
let npositions = load.readUintVlq();
|
||||
while (npositions-- > 0) {
|
||||
const start = load.readUintVlq();
|
||||
const length = load.readUintVlq();
|
||||
positions.push({ start, length });
|
||||
}
|
||||
|
||||
node.ranges.set(location_id, positions);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: tag >> 2,
|
||||
hasChildren: (tag & 1) !== 0,
|
||||
node,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,209 +0,0 @@
|
||||
import Load from "../encoding/load";
|
||||
import Store from "../encoding/store";
|
||||
import { BuilderSizes, DecoderStats } from "../index/stats";
|
||||
import TreeNode, { Range } from "./node";
|
||||
|
||||
function getCommonPrefix(left: string, right: string): string {
|
||||
let prefix = "";
|
||||
|
||||
for (let i = 0; i < Math.min(left.length, right.length); ++i) {
|
||||
if (left[i] === right[i]) {
|
||||
prefix += left[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return prefix;
|
||||
}
|
||||
|
||||
export default class Tree {
|
||||
public root: TreeNode;
|
||||
|
||||
constructor(root?: TreeNode) {
|
||||
this.root = root || new TreeNode();
|
||||
}
|
||||
|
||||
public insert(text: string, location_id: number, range: Range) {
|
||||
let node = this.root;
|
||||
|
||||
for (let i = 0; i < text.length; ) {
|
||||
const ch = text.charCodeAt(i);
|
||||
const remainder = text.substring(i);
|
||||
let child = node.children.get(ch);
|
||||
|
||||
if (child) {
|
||||
// If the child's fragment and the remainder of 'text' are the same, then this is our node we want to change.
|
||||
if (child.fragment === remainder) {
|
||||
child.addRange(location_id, range);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the common prefix of the child's fragment and the remainder of 'text'.
|
||||
const prefix = getCommonPrefix(child.fragment, remainder);
|
||||
|
||||
if (prefix.length < child.fragment.length) {
|
||||
let mid: TreeNode | null = null;
|
||||
|
||||
if (prefix.length === text.length - i) {
|
||||
mid = new TreeNode(remainder);
|
||||
mid.addRange(location_id, range);
|
||||
} else if (prefix.length < text.length - i) {
|
||||
mid = new TreeNode(prefix);
|
||||
|
||||
const tail = new TreeNode(remainder.substring(prefix.length));
|
||||
tail.addRange(location_id, range);
|
||||
mid.children.set(tail.fragment.charCodeAt(0), tail);
|
||||
}
|
||||
|
||||
if (mid !== null) {
|
||||
// Set the 'child' node to be a child of the new 'mid' node
|
||||
mid.children.set(child.fragment.charCodeAt(prefix.length), child);
|
||||
|
||||
// Update the fragment of 'child' to be what's left of the fragment.
|
||||
child.fragment = child.fragment.substring(prefix.length);
|
||||
|
||||
// Replace 'child' with 'mid' in 'node'
|
||||
node.children.set(ch, mid);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
i += child.fragment.length;
|
||||
node = child;
|
||||
} else {
|
||||
child = new TreeNode(text.substring(i));
|
||||
child.addRange(location_id, range);
|
||||
node.children.set(ch, child);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public search(prefix: string): Map<number, Range[]> {
|
||||
let node = this.root;
|
||||
|
||||
for (let i = 0; i < prefix.length; ) {
|
||||
const ch = prefix.charCodeAt(i);
|
||||
let child = node.children.get(ch);
|
||||
|
||||
if (child) {
|
||||
// Get the common prefix between the remainder of the prefix and the child's fragment.
|
||||
const common_prefix = getCommonPrefix(
|
||||
child.fragment,
|
||||
prefix.substring(i)
|
||||
);
|
||||
|
||||
// If the common prefix doesn't match the fragment or what's left of the prefix, this prefix cannot be found in
|
||||
// the tree.
|
||||
if (
|
||||
common_prefix.length !== child.fragment.length &&
|
||||
common_prefix.length !== prefix.length - i
|
||||
) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
i += child.fragment.length;
|
||||
node = child;
|
||||
} else {
|
||||
// No child exists with this character, so the prefix cannot be found in the tree.
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
const found_ranges: Map<number, Range[]> = new Map();
|
||||
|
||||
// Collect up all the ranges of the tree nodes from 'node'.
|
||||
function collect(node: TreeNode) {
|
||||
if (node.ranges.size > 0) {
|
||||
for (const [location_id, ranges] of node.ranges) {
|
||||
let result_ranges = found_ranges.get(location_id);
|
||||
found_ranges.set(
|
||||
location_id,
|
||||
result_ranges ? [...result_ranges, ...ranges] : ranges
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.children.values()) {
|
||||
collect(child);
|
||||
}
|
||||
}
|
||||
|
||||
collect(node);
|
||||
return found_ranges;
|
||||
}
|
||||
|
||||
public store(store: Store, sizes: BuilderSizes) {
|
||||
// Prepare a variable to store our stack depth and a function that will write the stack depth to the store. This
|
||||
// instruction is used to tell our decoder how far back we want to pop the construction stack to get back to our
|
||||
// parent.
|
||||
let stackDepth = 0;
|
||||
function retrace() {
|
||||
if (stackDepth > 0) {
|
||||
store.writeUintVlq(stackDepth);
|
||||
sizes.maxDepth = Math.max(sizes.maxDepth, stackDepth);
|
||||
stackDepth = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeNode(key: number, node: TreeNode) {
|
||||
// Retrate our steps back to the parent node
|
||||
retrace();
|
||||
|
||||
// Store the tree node, and if the node has children, recursively store those aswell.
|
||||
node.store(store, key);
|
||||
for (const [child_key, child] of node.children) {
|
||||
encodeNode(child_key, child);
|
||||
}
|
||||
|
||||
// We have finished this node, so increment our stack depth and node count.
|
||||
stackDepth++;
|
||||
sizes.nodes++;
|
||||
}
|
||||
|
||||
encodeNode(0, this.root);
|
||||
retrace();
|
||||
}
|
||||
|
||||
public static load(load: Load, stats: DecoderStats): Tree {
|
||||
let start = performance.now();
|
||||
let stack: TreeNode[] = [];
|
||||
let root: TreeNode | null = null;
|
||||
|
||||
for (;;) {
|
||||
// Decode the tree node from the buffer.
|
||||
const { key, hasChildren, node } = TreeNode.load(load);
|
||||
stats.sizes.nodes++;
|
||||
|
||||
// If there is a node on the top of the stack, then add this node as a child.
|
||||
if (stack.length > 0) {
|
||||
stack[stack.length - 1].children.set(key, node);
|
||||
}
|
||||
|
||||
// Push this node onto the stack, and if there's no root node yet, then this is the root node.
|
||||
stack.push(node);
|
||||
if (!root) {
|
||||
root = node;
|
||||
}
|
||||
|
||||
// If we don't have any children, then read the number of stack elements to pop to get back to the correct parent
|
||||
// node. This value was previously written by 'retrace'. If we end up with an empty stack then we've reached the
|
||||
// end of the tree.
|
||||
if (!hasChildren) {
|
||||
let depth = load.readUintVlq();
|
||||
while (depth-- > 0) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
if (stack.length === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats.timings.tree = performance.now() - start;
|
||||
return new Tree(root);
|
||||
}
|
||||
}
|
@ -1,71 +0,0 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import yaml from "yaml";
|
||||
|
||||
/// The ID (slug) of a tag.
|
||||
export type TagId = string;
|
||||
|
||||
/// Represents a tag.
|
||||
export interface Tag {
|
||||
/// The ID (slug) of the tag.
|
||||
slug: TagId;
|
||||
/// The title of the tag.
|
||||
name: string;
|
||||
/// Whether the tag is visible or not (only public tags are shown).
|
||||
visibility?: "public" | "private";
|
||||
/// A quick description of the tag.
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/// A mapping from a `TagId` to a `Tag`.
|
||||
export type Tags = Map<TagId, Tag>;
|
||||
|
||||
/// Load the tags from the `/contents/tags.yaml` file.
|
||||
///
|
||||
/// Each entry in the `tags.yaml` file describes a tag for the site. Each tag can have the following fields:
|
||||
///
|
||||
/// 1. A `slug` field, which overrides the name used in the YAML dictionary,
|
||||
/// 2. A `name` field, which is used as the title of the tag. If no `name` is given, the slug is used instead.
|
||||
/// 3. The `visibility` of the tag. If missing, the visibility is assumed to be `"public"`.
|
||||
/// 4. Any `description` for the tag.
|
||||
export async function loadTags(): Promise<Tags> {
|
||||
const tagsPath = path.join(process.cwd(), "content", "tags.yaml");
|
||||
const tagsSrc = await fs.readFile(tagsPath, "utf-8");
|
||||
const tags = yaml.parse(tagsSrc) as { [key: string]: Tag };
|
||||
const res: Tags = new Map();
|
||||
|
||||
Object.keys(tags).forEach((tag_id) => {
|
||||
const tag = tags[tag_id];
|
||||
|
||||
if (!tag.slug) {
|
||||
tag.slug = tag_id;
|
||||
}
|
||||
|
||||
if (!tag.name) {
|
||||
tag.name = tag_id;
|
||||
}
|
||||
|
||||
if (!tag.visibility) {
|
||||
tag.visibility = "public";
|
||||
}
|
||||
|
||||
res.set(tag_id, tag);
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
/// Given a slug, retrieve the `Tag` with that slug.
|
||||
///
|
||||
/// Note that this function will throw an error if the tag does not exist. Additionally, this function will load the
|
||||
/// entire set of tags from the `/contents/tags.yaml` file by way of the `loadTags` function.
|
||||
export async function getTagWithSlug(slug: string): Promise<Tag> {
|
||||
const tags = await loadTags();
|
||||
for (let tag of tags.values()) {
|
||||
if (tag.slug === slug) {
|
||||
return tag;
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(`Unable to find tag with slug '${slug}'`);
|
||||
}
|
@ -1,144 +0,0 @@
|
||||
import { Currency, ExchangeRates } from "./forex";
|
||||
|
||||
const ACCOUNT_VERSION: number = 1;
|
||||
|
||||
export interface AccountInfo {
|
||||
places: number;
|
||||
currency: Currency;
|
||||
exchangeRates: ExchangeRates;
|
||||
amount: number;
|
||||
marginRisk: number;
|
||||
positionRisk: number;
|
||||
}
|
||||
|
||||
export interface SetPlacesAccountAction {
|
||||
action: "setPlaces";
|
||||
places: number;
|
||||
}
|
||||
|
||||
export interface SetCurrencyAccountAction {
|
||||
action: "setCurrency";
|
||||
currency: Currency;
|
||||
}
|
||||
|
||||
export interface SetExchangeRatesAccountAction {
|
||||
action: "setExchangeRates";
|
||||
exchangeRates: ExchangeRates;
|
||||
}
|
||||
|
||||
export interface SetAmountAccountAction {
|
||||
action: "setAmount";
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export interface SetMarginRiskAction {
|
||||
action: "setMarginRisk";
|
||||
risk: number;
|
||||
}
|
||||
|
||||
export interface SetPositionRiskAction {
|
||||
action: "setPositionRisk";
|
||||
risk: number;
|
||||
}
|
||||
|
||||
export type AccountAction =
|
||||
| SetPlacesAccountAction
|
||||
| SetCurrencyAccountAction
|
||||
| SetExchangeRatesAccountAction
|
||||
| SetAmountAccountAction
|
||||
| SetMarginRiskAction
|
||||
| SetPositionRiskAction;
|
||||
|
||||
export function accountReducer(
|
||||
state: AccountInfo,
|
||||
action: AccountAction
|
||||
): AccountInfo {
|
||||
let new_value = null;
|
||||
switch (action.action) {
|
||||
case "setPlaces":
|
||||
new_value = { ...state, places: action.places };
|
||||
break;
|
||||
case "setCurrency":
|
||||
new_value = { ...state, currency: action.currency };
|
||||
break;
|
||||
case "setExchangeRates":
|
||||
new_value = { ...state, exchangeRates: action.exchangeRates };
|
||||
break;
|
||||
case "setAmount":
|
||||
new_value = { ...state, amount: action.amount };
|
||||
break;
|
||||
case "setMarginRisk":
|
||||
new_value = { ...state, marginRisk: action.risk };
|
||||
break;
|
||||
case "setPositionRisk":
|
||||
new_value = { ...state, positionRisk: action.risk };
|
||||
break;
|
||||
default:
|
||||
console.error("Unrecognized account action", action);
|
||||
return state;
|
||||
}
|
||||
|
||||
storeAccount(new_value);
|
||||
return new_value;
|
||||
}
|
||||
|
||||
export function storeAccount(account: AccountInfo) {
|
||||
window.localStorage.setItem(
|
||||
"blakerain.tools.accountinfo",
|
||||
JSON.stringify({
|
||||
...account,
|
||||
exchangeRates: {
|
||||
base: account.currency,
|
||||
rates: Array.from(account.exchangeRates.rates).reduce(
|
||||
(obj, [key, value]) => {
|
||||
obj[key] = value;
|
||||
return obj;
|
||||
},
|
||||
{} as { [currency: string]: number }
|
||||
),
|
||||
},
|
||||
__version: ACCOUNT_VERSION,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteStoredAccount() {
|
||||
window.localStorage.removeItem("blakerain.tools.accountinfo");
|
||||
}
|
||||
|
||||
export function loadAccount(): AccountInfo | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = window.localStorage.getItem("blakerain.tools.accountinfo");
|
||||
if (typeof value === "string") {
|
||||
const account = JSON.parse(value) as AccountInfo & {
|
||||
exchangeRates: { rates: { [name: string]: number } };
|
||||
__version: number;
|
||||
};
|
||||
|
||||
if (
|
||||
typeof account.__version !== "number" ||
|
||||
account.__version !== ACCOUNT_VERSION
|
||||
) {
|
||||
deleteStoredAccount();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rates = new Map<Currency, number>();
|
||||
for (let symbol of Object.keys(account.exchangeRates.rates)) {
|
||||
rates.set(symbol as Currency, account.exchangeRates.rates[symbol]);
|
||||
}
|
||||
|
||||
return {
|
||||
...account,
|
||||
exchangeRates: {
|
||||
base: account.currency,
|
||||
rates,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
export type Currency = "AUD" | "CAD" | "EUR" | "GBP" | "JPY" | "USD";
|
||||
|
||||
export const CURRENCIES: Currency[] = [
|
||||
"AUD",
|
||||
"CAD",
|
||||
"EUR",
|
||||
"GBP",
|
||||
"JPY",
|
||||
"USD",
|
||||
];
|
||||
|
||||
export const CURRENCY_SYMBOLS: Map<Currency, string> = new Map([
|
||||
["AUD", "$"],
|
||||
["CAD", "$"],
|
||||
["EUR", "€"],
|
||||
["GBP", "£"],
|
||||
["JPY", "¥"],
|
||||
["USD", "$"],
|
||||
]);
|
||||
|
||||
export interface ExchangeRates {
|
||||
/// The base currency
|
||||
base: Currency;
|
||||
/// The rates converting from this currency
|
||||
rates: Map<Currency, number>;
|
||||
}
|
||||
|
||||
interface ExchangeRateResult {
|
||||
motd?: { msg?: string; url?: string };
|
||||
success: boolean;
|
||||
base: string;
|
||||
date: string;
|
||||
rates: { [key: string]: number };
|
||||
}
|
||||
|
||||
export async function getExchangeRates(
|
||||
base: Currency,
|
||||
target?: Currency
|
||||
): Promise<ExchangeRates> {
|
||||
const symbols = target || CURRENCIES.join(",");
|
||||
const res: ExchangeRateResult = await (
|
||||
await fetch(
|
||||
`https://api.exchangerate.host/latest?base=${base}&symbols=${symbols}`
|
||||
)
|
||||
).json();
|
||||
|
||||
const rates = new Map<Currency, number>();
|
||||
for (let symbol of Object.keys(res.rates)) {
|
||||
rates.set(symbol as Currency, res.rates[symbol]);
|
||||
}
|
||||
|
||||
return { base, rates };
|
||||
}
|
@ -1,306 +0,0 @@
|
||||
import { AccountInfo } from "./account";
|
||||
import { Currency } from "./forex";
|
||||
|
||||
export type Direction = "buy" | "sell";
|
||||
|
||||
export interface PositionInfo {
|
||||
posCurrency: Currency;
|
||||
quoteCurrency: Currency;
|
||||
conversion: number;
|
||||
openPrice: number;
|
||||
quantity: number | null;
|
||||
direction: Direction;
|
||||
margin: number;
|
||||
takeProfit: number | null;
|
||||
stopLoss: number | null;
|
||||
}
|
||||
|
||||
export interface SetPosCurrencyPositionAction {
|
||||
action: "setPosCurrency";
|
||||
currency: Currency;
|
||||
}
|
||||
|
||||
export interface SetQuoteCurrencyPositionAction {
|
||||
action: "setQuoteCurrency";
|
||||
currency: Currency;
|
||||
}
|
||||
|
||||
export interface SetConversionPositionAction {
|
||||
action: "setConversion";
|
||||
conversion: number;
|
||||
}
|
||||
|
||||
export interface SetOpenPricePositionAction {
|
||||
action: "setOpenPrice";
|
||||
openPrice: number;
|
||||
}
|
||||
|
||||
export interface SetQuantityPositionAction {
|
||||
action: "setQuantity";
|
||||
quantity: number | null;
|
||||
}
|
||||
|
||||
export interface SetDirectionPositionAction {
|
||||
action: "setDirection";
|
||||
direction: Direction;
|
||||
}
|
||||
|
||||
export interface SetMarginPositionAction {
|
||||
action: "setMargin";
|
||||
margin: number;
|
||||
}
|
||||
|
||||
export interface SetTakeProfitPositionAction {
|
||||
action: "setTakeProfit";
|
||||
takeProfit: number | null;
|
||||
}
|
||||
|
||||
export interface SetStopLossPositionAction {
|
||||
action: "setStopLoss";
|
||||
stopLoss: number | null;
|
||||
}
|
||||
|
||||
export type PositionAction =
|
||||
| SetPosCurrencyPositionAction
|
||||
| SetQuoteCurrencyPositionAction
|
||||
| SetConversionPositionAction
|
||||
| SetOpenPricePositionAction
|
||||
| SetQuantityPositionAction
|
||||
| SetDirectionPositionAction
|
||||
| SetMarginPositionAction
|
||||
| SetTakeProfitPositionAction
|
||||
| SetStopLossPositionAction;
|
||||
|
||||
export function positionReducer(
|
||||
state: PositionInfo,
|
||||
action: PositionAction
|
||||
): PositionInfo {
|
||||
switch (action.action) {
|
||||
case "setPosCurrency":
|
||||
return { ...state, posCurrency: action.currency };
|
||||
case "setQuoteCurrency":
|
||||
return { ...state, quoteCurrency: action.currency };
|
||||
case "setConversion":
|
||||
return { ...state, conversion: action.conversion };
|
||||
case "setOpenPrice":
|
||||
return { ...state, openPrice: action.openPrice };
|
||||
case "setQuantity":
|
||||
return { ...state, quantity: action.quantity };
|
||||
case "setDirection": {
|
||||
let takeProfit = state.takeProfit;
|
||||
let stopLoss = state.stopLoss;
|
||||
|
||||
if (state.direction !== action.direction) {
|
||||
if (typeof takeProfit === "number") {
|
||||
let tp_distance =
|
||||
state.direction === "buy"
|
||||
? takeProfit - state.openPrice
|
||||
: state.openPrice - takeProfit;
|
||||
takeProfit =
|
||||
action.direction === "buy"
|
||||
? state.openPrice + tp_distance
|
||||
: state.openPrice - tp_distance;
|
||||
}
|
||||
|
||||
if (typeof stopLoss === "number") {
|
||||
let sl_distance =
|
||||
state.direction === "buy"
|
||||
? state.openPrice - stopLoss
|
||||
: stopLoss - state.openPrice;
|
||||
stopLoss =
|
||||
action.direction === "buy"
|
||||
? state.openPrice - sl_distance
|
||||
: state.openPrice + sl_distance;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...state, direction: action.direction, takeProfit, stopLoss };
|
||||
}
|
||||
case "setMargin":
|
||||
return { ...state, margin: action.margin };
|
||||
case "setTakeProfit":
|
||||
return { ...state, takeProfit: action.takeProfit };
|
||||
case "setStopLoss":
|
||||
return { ...state, stopLoss: action.stopLoss };
|
||||
default:
|
||||
console.error("Unrecognized position action", action);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
interface PositionSize {
|
||||
/// Funds available under margin risk (in account currency)
|
||||
available: number;
|
||||
/// Funds available under margin risk (in position currency)
|
||||
availablePos: number;
|
||||
/// Funds available under margin risk (in quote currency)
|
||||
availableQuote: number;
|
||||
/// Margin available under margin risk (in account currency)
|
||||
margin: number;
|
||||
/// Margin available (in position currency)
|
||||
marginPos: number;
|
||||
/// Margin available (in quote currency)
|
||||
marginQuote: number;
|
||||
/// Quantity affordable at position price (as units)
|
||||
quantity: number;
|
||||
/// Optional actual position size margin risk
|
||||
actual: null | {
|
||||
/// The actual quote cost (in quote currency)
|
||||
costQuote: number;
|
||||
/// The actual position const (in position currency)
|
||||
costPos: number;
|
||||
/// The actual position cost (in account currency)
|
||||
cost: number;
|
||||
/// The account margin required (as a %)
|
||||
margin: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function computePositionSize(
|
||||
account: AccountInfo,
|
||||
position: PositionInfo
|
||||
): PositionSize {
|
||||
const p_rate = account.exchangeRates.rates.get(position.posCurrency) || 1;
|
||||
const q_rate = position.conversion;
|
||||
const available = account.amount * account.marginRisk;
|
||||
const availablePos = available * p_rate;
|
||||
const availableQuote = availablePos * q_rate;
|
||||
const margin = available / (position.margin || 1);
|
||||
const marginPos = margin * p_rate;
|
||||
const marginQuote = marginPos * q_rate;
|
||||
const quantity = marginQuote / position.openPrice;
|
||||
|
||||
const computeActual = (actualQuantity: number) => {
|
||||
const costQuote = actualQuantity * position.openPrice * position.margin;
|
||||
const costPos = costQuote / q_rate;
|
||||
const cost = costPos / p_rate;
|
||||
const margin = cost / account.amount;
|
||||
|
||||
return { costQuote, costPos, cost, margin };
|
||||
};
|
||||
|
||||
const actual =
|
||||
typeof position.quantity === "number"
|
||||
? computeActual(position.quantity)
|
||||
: null;
|
||||
|
||||
return {
|
||||
available,
|
||||
availablePos,
|
||||
availableQuote,
|
||||
margin,
|
||||
marginPos,
|
||||
marginQuote,
|
||||
quantity,
|
||||
actual,
|
||||
};
|
||||
}
|
||||
|
||||
interface StopLoss {
|
||||
/// Funds available under position risk (in account currency)
|
||||
available: number;
|
||||
/// Funds available under position risk (in position currency)
|
||||
availablePos: number;
|
||||
/// Funds available under position risk (in quote currency)
|
||||
availableQuote: number;
|
||||
/// Specified position size
|
||||
quantity: number;
|
||||
/// Required stop-loss distance
|
||||
distance: number;
|
||||
/// Optional actual stop-loss assessment
|
||||
actual: null | {
|
||||
/// The actual stop-loss distance (in position currency)
|
||||
distance: number;
|
||||
/// The possible loss
|
||||
loss: number;
|
||||
/// The actual position risk (as a %)
|
||||
risk: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function computeStopLoss(
|
||||
account: AccountInfo,
|
||||
position: PositionInfo,
|
||||
quantity: number
|
||||
): StopLoss {
|
||||
const p_rate = account.exchangeRates.rates.get(position.posCurrency) || 1;
|
||||
const q_rate = position.conversion;
|
||||
const available = account.amount * account.positionRisk;
|
||||
const availablePos = available * p_rate;
|
||||
const availableQuote = availablePos * q_rate;
|
||||
const distance = quantity === 0 ? 0 : availableQuote / quantity;
|
||||
|
||||
const computeActual = (stopLoss: number) => {
|
||||
const actualDistance =
|
||||
position.direction === "buy"
|
||||
? position.openPrice - stopLoss
|
||||
: stopLoss - position.openPrice;
|
||||
const loss = (actualDistance * quantity) / (p_rate * q_rate);
|
||||
const risk = loss / account.amount;
|
||||
|
||||
return {
|
||||
distance: actualDistance,
|
||||
loss,
|
||||
risk,
|
||||
};
|
||||
};
|
||||
|
||||
const actual =
|
||||
typeof position.stopLoss === "number"
|
||||
? computeActual(position.stopLoss)
|
||||
: null;
|
||||
|
||||
return {
|
||||
available,
|
||||
availablePos,
|
||||
availableQuote,
|
||||
quantity,
|
||||
distance,
|
||||
actual,
|
||||
};
|
||||
}
|
||||
|
||||
interface StopLossQuantity {
|
||||
/// Funds available under position risk (in account currency)
|
||||
available: number;
|
||||
/// Funds available under position risk (in position currency)
|
||||
availablePos: number;
|
||||
/// Funds available under positin risk (in quote currency)
|
||||
availableQuote: number;
|
||||
/// Computed stop loss distance (in position currency)
|
||||
stopLossDistance: number;
|
||||
/// Amount that can be bought at the given stop loss (as units)
|
||||
quantity: number;
|
||||
/// Required margin for that amount (in account currency)
|
||||
margin: number;
|
||||
}
|
||||
|
||||
export function computedStopLossQuantity(
|
||||
account: AccountInfo,
|
||||
position: PositionInfo
|
||||
): StopLossQuantity {
|
||||
const stopLossDistance =
|
||||
typeof position.stopLoss === "number"
|
||||
? position.direction === "buy"
|
||||
? position.openPrice - position.stopLoss
|
||||
: position.stopLoss - position.openPrice
|
||||
: 0;
|
||||
|
||||
const p_rate = account.exchangeRates.rates.get(position.posCurrency) || 1;
|
||||
const q_rate = position.conversion;
|
||||
const available = account.amount * account.positionRisk;
|
||||
const availablePos = available * p_rate;
|
||||
const availableQuote = availablePos * q_rate;
|
||||
const quantity = availableQuote / stopLossDistance;
|
||||
const margin =
|
||||
(quantity * position.openPrice * position.margin) / (p_rate * q_rate);
|
||||
|
||||
return {
|
||||
available,
|
||||
availablePos,
|
||||
availableQuote,
|
||||
stopLossDistance,
|
||||
quantity,
|
||||
margin,
|
||||
};
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
|
||||
export type Direction = "up" | "down" | "left" | "right";
|
@ -1,36 +0,0 @@
|
||||
/// Pad a number with zeros to a specific length.
|
||||
export function zeroPad(n: number, count: number = 2): string {
|
||||
const s = n.toString();
|
||||
if (s.length < count) {
|
||||
return new Array(count - s.length).fill("0") + s;
|
||||
} else {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a number
|
||||
///
|
||||
/// This function will format the given number to a certain number of decimal places. It will also comma-separate any
|
||||
/// lengthy numbers. Numbers may also have prefixes and suffixes added to them.
|
||||
export function formatNumber(
|
||||
value: number,
|
||||
places: number,
|
||||
prefix?: string,
|
||||
suffix?: string
|
||||
): string {
|
||||
const aval = Math.abs(value);
|
||||
const neg = value < 0;
|
||||
var parts = aval.toFixed(places).split(".");
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
return (neg ? "-" : "") + (prefix || "") + parts.join(".") + (suffix || "");
|
||||
}
|
||||
|
||||
/// Get the ISO week for the given `Date`.
|
||||
export function getISOWeek(date: Date): number {
|
||||
var d = new Date(date);
|
||||
var dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
var yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
var diff = d.getTime() - yearStart.getTime();
|
||||
return Math.ceil((diff / 86400000 + 1) / 7);
|
||||
}
|
5
old/next-env.d.ts
vendored
5
old/next-env.d.ts
vendored
@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
@ -1,12 +0,0 @@
|
||||
export default {
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_GB",
|
||||
url: "https://www.blakerain.com/",
|
||||
site_name: "Blake Rain",
|
||||
},
|
||||
twitter: {
|
||||
handle: "@HalfWayMan",
|
||||
cardType: "summary",
|
||||
},
|
||||
};
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user