Switch over to WebAssembly, Rust and Yew (#35)

ssg-yew-search
Blake Rain 2023-08-30 19:01:40 +01:00 committed by GitHub
parent aaae9d2033
commit f83d0633f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
230 changed files with 17287 additions and 18712 deletions

View File

@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

View File

@ -14,24 +14,35 @@ jobs:
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: 16
node-version: 18
- name: Configure Cache
uses: actions/cache@v2
with:
path: |
${{ github.workspace }}/node_modules
${{ github.workspace }}/public/content/**/optimized
${{ github.workspace }}/public/content/images.sha256.json
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}
- name: Install Dependencies
run: yarn install
- name: Build the Website
run: yarn build
- name: Export the Website
run: yarn export
- name: Install Rust Toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Install Trunk
uses: jetli/trunk-action@v0.4.0
with:
version: "v0.17.5"
- name: Build Hydrating Application
run: trunk build --release --features hydration
- name: Perform Site Generation
run: cargo run --release --features static --bin site-build
- name: Synchronize with S3 Bucket
uses: jakejarvis/s3-sync-action@v0.5.1
with:
@ -41,7 +52,8 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: "eu-west-1"
SOURCE_DIR: "out"
SOURCE_DIR: "output"
- name: Create CloudFront Invalidation
uses: awact/cloudfront-action@master
env:

49
.gitignore vendored
View File

@ -1,46 +1,5 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Test data
/public/data
/public/feeds
/public/robots.txt
/public/sitemap.xml
/public/sitemap-0.xml
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
/.netlify
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# Optimized images
/public/content/**/optimized
/public/content/images.sha256.json
/dist
/node_modules
/output
/target

71
.swcrc Normal file
View File

@ -0,0 +1,71 @@
{
"jsc": {
"parser": {
"syntax": "ecmascript",
"jsx": false
},
"target": "es2017",
"loose": false,
"minify": {
"compress": {
"arguments": false,
"arrows": true,
"booleans": true,
"booleans_as_integers": false,
"collapse_vars": true,
"comparisons": true,
"computed_props": true,
"conditionals": true,
"dead_code": true,
"directives": true,
"drop_console": false,
"drop_debugger": true,
"evaluate": true,
"expression": false,
"hoist_funs": false,
"hoist_props": true,
"hoist_vars": false,
"if_return": true,
"join_vars": true,
"keep_classnames": false,
"keep_fargs": true,
"keep_fnames": false,
"keep_infinity": false,
"loops": true,
"negate_iife": true,
"properties": true,
"reduce_funcs": false,
"reduce_vars": false,
"side_effects": true,
"switches": true,
"typeofs": true,
"unsafe": false,
"unsafe_arrows": false,
"unsafe_comps": false,
"unsafe_Function": false,
"unsafe_math": false,
"unsafe_symbols": false,
"unsafe_methods": false,
"unsafe_proto": false,
"unsafe_regexp": false,
"unsafe_undefined": false,
"unused": true,
"const_to_let": true,
"pristine_globals": true
},
"mangle": {
"toplevel": false,
"keep_classnames": false,
"keep_fnames": false,
"keep_private_props": false,
"ie8": false,
"safari10": false
}
}
},
"module": {
"type": "es6"
},
"minify": true,
"isModule": true
}

View File

@ -1,3 +0,0 @@
{
"print.colourScheme": "Atelier Dune"
}

2666
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

105
Cargo.toml Normal file
View File

@ -0,0 +1,105 @@
[package]
name = "site"
version = "2.0.0"
edition = "2021"
publish = false
[profile.release]
lto = true
opt-level = 'z'
[features]
hydration = [
"yew/hydration"
]
static = [
"atom_syndication",
"chrono",
"rss",
"yew/ssr"
]
[[bin]]
name = "site-build"
required-features = [
"static",
]
[[bin]]
name = "site"
[dependencies]
async-trait = { version = "0.1" }
enum-iterator = { version = "1.4" }
gloo = { version = "0.10" }
include_dir = { version = "0.7" }
log = { version = "0.4" }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
thiserror = { version = "1.0" }
wasm-bindgen = { version = "0.2" }
yew = { version = "0.20" }
yew-hooks = { version = "0.2" }
yew-router = { version = "0.17" }
macros = { path = "./macros" }
model = { path = "./model" }
[dependencies.time]
version = "0.3"
features = [
"formatting",
"local-offset",
"macros",
"parsing",
"serde",
"wasm-bindgen"
]
[dependencies.yew_icons]
version = "0.7"
features = [
"BootstrapDot",
"BootstrapGithub",
"BootstrapLightningChargeFill",
"BootstrapMastodon",
"HeroiconsSolidInformationCircle",
"HeroiconsSolidQuestionMarkCircle",
"LucideAlertTriangle",
"LucideBug",
"LucideCheck",
"LucideCheckCircle",
"LucideFlame",
"LucideLink",
"LucideList",
"LucideMenu",
"LucidePencil",
"LucideRss",
"LucideX"
]
[dependencies.web-sys]
version = "0.3"
features = [
"Document",
"DomRect",
"Element",
"IntersectionObserver",
"IntersectionObserverEntry",
"ScrollBehavior",
"ScrollToOptions",
"Window"
]
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen-futures = { version = "0.4" }
wasm-logger = { version = "0.2" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
atom_syndication = { version = "0.12", optional = true }
chrono = { version = "0.4", optional = true, features = ["clock"] }
rss = { version = "2.0", optional = true }
tokio = { version = "1.32", features = ["full"] }
env_logger = { version = "0.10" }

133
README.md
View File

@ -1,127 +1,38 @@
# blakerain.com
![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB)
![Next JS](https://img.shields.io/badge/Next-black?style=for-the-badge&logo=next.js&logoColor=white)
![SASS](https://img.shields.io/badge/SASS-hotpink.svg?style=for-the-badge&logo=SASS&logoColor=white)
![AWS](https://img.shields.io/badge/AWS-%23FF9900.svg?style=for-the-badge&logo=amazon-aws&logoColor=white)
![AmazonDynamoDB](https://img.shields.io/badge/DynamoDB-4053D6?style=for-the-badge&logo=Amazon%20DynamoDB&logoColor=white)
![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white)
![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white)
![GitHub Actions](https://img.shields.io/badge/github%20actions-%232671E5.svg?style=for-the-badge&logo=githubactions&logoColor=white)
This repository contains the sources for my website: [blakerain.com]. The website is built using [Next.js]. It also
This repository contains the sources for my website: [blakerain.com]. The website is built using [Yew]. It also
includes some analytics code that is written in Rust and runs in AWS. The website is stored in an [S3] bucket and served
using the AWS [CloudFront] CDN. Deployment from this repository is performed by a GitHub workflow.
## Analytics
## Cargo Features
The analytics for the site is quite simple, consisting of a couple of AWS [lambda] functions and a [DynamoDB] table. The
two lambda functions provide both the API and a back-end trigger function to perform accumulation.
There are a number of Cargo feature flags that are used during development and during release
builds. These features are as follows:
![Layout](https://github.com/BlakeRain/blakerain.com/blob/main/public/content/site-analytics/analytics-layout.drawio.png?raw=true)
- `static` feature is set when we want to build the static rendering application, called
`site-build`, which will generate the static HTML pages for the site.
- `hydration` feature is set when we're building the WebAssembly for hydration into a statically
rendered page.
The domain `pv.blakerain.com` is aliased to an AWS [API Gateway] domain. The domain is mapped to an API which allows
`GET` and `POST` requests and forwards these requests to the [api] Lambda function. The Lambda function receives these
HTTP `GET` or `POST` requests encoded as JSON (see the [input format] for a Lambda function for a proxy integration).
The Lambda function processes the request and then returns an HTTP response (see the [output format]).
During development, neither `static` nor `hydration` are set. This allows commands like `trunk
serve` to serve the WebAssembly and nicely rebuild upon file changes and so on.
The lambda function response to the following resources:
During a release build, we first build the site using `trunk build` with the `hydration` feature
enabled. This will build a WebAssembly module with the hydration support. Afterwards, we use
`cargo run` to run the `site-build` app with the `static` feature set, which allows us to generate
all the static pages.
| Method | URL | Description |
|--------|-----------------------|--------------------------------------------|
| `GET` | `/pv.gif` | Records a page view |
| `POST` | `/append` | Update a page view record |
| `POST` | `/api/auth/signin` | Authenticate access to the analytics |
| `POST` | `/api/views/week` | Get page view stats for a specific week |
| `POST` | `/api/views/month` | Get page view stats for a specific month |
| `POST` | `/api/browsers/week` | Get browser stats for a specific week |
| `POST` | `/api/browsers/month` | Get browser stats for a specific month |
| `POST` | `/api/pages/week` | Get the total page count for a given week |
| `POST` | `/api/pages/month` | Get the total page count for a given month |
```
trunk build --release --features hydration
cargo run --release --features static --bin site-build
```
### Recording Page Views
When a visitor loads the site, if they visit a page that includes the `<Analytics>` component, a request is made to the
`pv.blakerain.com` domain. Specifically, it tries to load an image from `https://pv.blakerain.com/pv.gif`. The URL
includes a query-string that encodes the various information about the site visit.
When the `GET` request for `pv.gif` image is received by the lambda function, it attempts to extract meaningful values
from the query string, and should it find any, record them as a page view into the DynamoDB table. The record for a page
view contains the following items:
| Field | Type | Description |
|------------------|----------|----------------------------------------------------------|
| `Path` | `String` | The path to the page being visited |
| `Section` | `String` | The string `view-` followed by the UUID of the page view |
| `Time` | `String` | The `OffsetDateTime` for the page view |
| `UserAgent` | `String` | The user agent string for the visitor's web browser |
| `ViewportWidth` | `Number` | The width of the viewport (i.e.: size of the window) |
| `ViewportHeight` | `Number` | The height of the viewport |
| `ScreenWidth` | `Number` | The width of the screen |
| `ScreenHeight` | `Number` | The height of the screen |
| `Timezone` | `String` | The visitor's timezone |
| `Referrer` | `String` | The referrer for the page view (e.g. `google.com`) |
| `Duration` | `Number` | The number of seconds the visitor has been on this page |
| `Scroll` | `Number` | The maximum distance that was scrolled (as a percentage) |
Of the above fields, the only fields that are required are the `Path`, `Section` and `Time` fields. All other fields are
entirely optional, and are only included in the record if they are present in the query-string given in the `GET`
request and can be parsed.
Once the `<Analytics>` component has mounted, it adds event listeners for the `scroll`, `visibilitychange` and
`pagehide` events. When the visitor scrolls the page, the `Analytics` component keeps track of the maximum scroll
distance. When the visibility of the page is changed, and the page is no longer visible, we also record the time at
which the page was hidden. Once the page is visible again, we record the total amount of time that the page was hidden.
This allows us to calculate the total amount of time that the page was visible.
When the page is hidden, or the `<Analytics>` component is unmounted, we send an update to the analytics record using a
`POST` request to the `/append` URL. This allows us to update the duration of a visit to the page and the maximum scroll
distance. The `POST` request send to the `/append` URL contains a JSON object with the following fields:
| Field | Type | Description |
|------------|----------|-----------------------------------------------------|
| `path` | `String` | The path to the page being visited |
| `uuid` | `String` | The UUID of the page view |
| `duration` | `Number` | The updated duration spent on the page (in seconds) |
| `scroll` | `Number` | The maximum scroll distance (as a percentage) |
In order to transmit updates to the page view, we use the `Navigator.sendBeacon` function (see the MDN documentation for
[sendBeacon]). Using the `sendBeacon` function allows us to send data, even when the browser is about to unload the
page.
### Accumulating Page Views
Once a new page view has been recorded in the DynamoDB table, I also want to update the accumulation of these page
views in a number of dimensions. The following table describes the dimensions that we want to record. This is given as
the subject (such as the page being visited, or the site as a whole) and the time-frame being accumulated (daily, weekly,
etc). The table also provides an example of the primary key and hash key used in the DynamoDB table for this recording.
| Object | Time-frame | Primary Key | Hash key | Description |
|-------------|------------|----------------------------|---------------------|--------------------------------------------------------|
| Total Views | Day | `page-view-day-2022-12-02` | `/page` | Number of views for a specific day (2nd December 2022) |
| | Week | `page-view-week-2022-48` | `/page` | Number of views for a specific week (week 48, 2022) |
| | Month | `page-view-month-2022-12` | `/page` | Number of views for a specific month (December 2022) |
| Page | Day | `/post/some-blog-post` | `Day-2022-12-02-18` | Number of views of page at hour of day (here at 18:00) |
| | Week | `/post/some-blog-post` | `Week-2022-48-05` | Number of views of page on day of week |
| | Month | `/post/some-blog-post` | `Month-2022-12-02` | Number of views of page on day of month |
| Entire Site | Day | `/site` | `Day-2022-12-02-18` | Number of views of page at hour of day (here at 18:00) |
| | Week | `/site` | `Week-2022-48-05` | Number of views of page on day of week |
| | Month | `/site` | `Month-2022-12-02` | Number of views of page on day of month |
Accumulating this data for each page view requires performing nine updates to the DynamoDB table. Performing these
updates whenever a request is made to the `pv.gif` image by the client is less than ideal. For this reason, a [trigger]
Lambda function is included which receives events from the DynamoDB table. If the event received by the trigger function
is the insertion of a new page view record, the function performs the above record updates.
[blakerain.com]: https://blakerain.com
[next.js]: https://nextjs.org
[s3]: https://aws.amazon.com/s3/
[cloudfront]: https://aws.amazon.com/cloudfront/
[lambda]: https://aws.amazon.com/lambda/
[dynamodb]: https://aws.amazon.com/dynamodb/
[api gateway]: https://aws.amazon.com/api-gateway/
[api]: https://github.com/BlakeRain/blakerain.com/blob/main/lambda/src/bin/api.rs
[trigger]: https://github.com/BlakeRain/blakerain.com/blob/main/lambda/src/bin/trigger.rs
[input format]: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
[output format]: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-output-format
[sendbeacon]: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon
[blakerain.com]: https://blakerain.com/
[Yew]: https://yew.rs/
[S3]: https://aws.amazon.com/s3/
[CloudFront]: https://aws.amazon.com/cloudfront/

9
Trunk.toml Normal file
View File

@ -0,0 +1,9 @@
[[hooks]]
stage = "pre_build"
command = "bash"
command_arguments = [ "tools/prepare.sh" ]
[[hooks]]
stage = "post_build"
command = "node"
command_arguments = [ "tools/finalize.js" ]

3
build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=content");
}

View File

@ -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;

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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> &copy;{" "}
{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>
);
};

View File

@ -1,12 +0,0 @@
@import "../styles/tools.scss";
// .content {
// display: flex;
// justify-content: center;
// padding: 0 5vw;
// }
//
// .inner {
// width: 100%;
// max-width: 1040px;
// }

View File

@ -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>
);
};

View File

@ -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;
}
}

View File

@ -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"