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