Switch over to WebAssembly, Rust and Yew (#35)
parent
aaae9d2033
commit
f83d0633f8
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"print.colourScheme": "Atelier Dune"
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -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
133
README.md
|
@ -1,127 +1,38 @@
|
|||
# blakerain.com
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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:
|
||||
|
||||

|
||||
- `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/
|
||||
|
|
|
@ -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" ]
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
println!("cargo:rerun-if-changed=content");
|
||||
}
|
|
@ -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"
|
||||