treewide: Migrate to the new dream2nix API

This commit is contained in:
Tristan Daniël Maat 2024-01-01 19:56:16 +01:00
parent 5163ef9d6a
commit 40e0946201
Signed by: tlater
GPG key ID: 49670FD774E43268
59 changed files with 15003 additions and 24504 deletions

View file

@ -0,0 +1,4 @@
((rust-mode . ((indent-tabs-mode . nil)
(tab-width . 4)
(fill-column . 80)
(projectile-project-run-cmd . "cd server && cargo run -- --dev-mode --template-directory ../templates/result"))))

1515
packages/server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
[package]
name = "tlaternet-webserver"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-files = "0.6.2"
actix-web = { version = "4.2.1", features = ["macros"] }
clap = { version = "3.2.17", features = ["derive"] }
derive_more = "0.99.17"
env_logger = "0.9.0"
handlebars = { version = "4.3.3", features = ["dir_source"] }
log = "0.4.17"
serde = {version = "1.0.144", features = ["derive"]}
serde_json = "1.0.83"

View file

@ -0,0 +1,17 @@
{dream2nix, ...}: {
imports = [
dream2nix.modules.dream2nix.rust-cargo-lock
dream2nix.modules.dream2nix.rust-crane
];
deps = {fenix, ...}: {
deps.cargo = fenix.stable.minimalToolchain;
};
name = "tlaternet-webserver";
version = "0.1.0";
mkDerivation = {
src = ./.;
};
}

View file

@ -0,0 +1,72 @@
use actix_web::body::BoxBody;
use actix_web::dev::ServiceResponse;
use actix_web::http::header::ContentType;
use actix_web::http::StatusCode;
use actix_web::middleware::ErrorHandlerResponse;
use actix_web::{web, HttpResponse, ResponseError};
use derive_more::{Display, Error};
use super::SharedData;
use crate::template_utils::{render_template, ErrorMessage, TemplateArgs};
#[derive(Debug, Display, Error)]
pub enum UserError {
NotFound,
#[display(fmt = "Internal error. Try again later.")]
InternalError,
}
impl ResponseError for UserError {
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code())
.insert_header(ContentType::html())
.body(self.to_string())
}
fn status_code(&self) -> StatusCode {
match *self {
UserError::NotFound => StatusCode::NOT_FOUND,
UserError::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
pub fn generic_error<B>(
res: ServiceResponse<B>,
) -> actix_web::Result<ErrorHandlerResponse<BoxBody>> {
let data = res
.request()
.app_data::<web::Data<SharedData>>()
.map(|t| t.get_ref());
let status_code = res.response().status();
let message = if let Some(error) = status_code.canonical_reason() {
error
} else {
""
};
let response = match data {
Some(SharedData {
handlebars,
config: _,
}) => {
let args = TemplateArgs::builder()
.error_page(ErrorMessage::new(message, status_code.as_u16()))
.build();
let body = render_template(handlebars, "error", &args)
.map_err(|_| UserError::InternalError)?;
HttpResponse::build(res.status())
.content_type(ContentType::html())
.body(body)
}
None => Err(UserError::InternalError)?,
};
Ok(ErrorHandlerResponse::Response(ServiceResponse::new(
res.into_parts().0,
response.map_into_left_body(),
)))
}

View file

@ -0,0 +1,99 @@
#![allow(dead_code)]
use std::net::SocketAddr;
use std::path::PathBuf;
use actix_files::Files;
use actix_web::{
http::{Method, StatusCode},
middleware::{self, ErrorHandlers},
web, App, HttpServer,
};
use clap::Parser;
use env_logger::{Env, WriteStyle};
use handlebars::Handlebars;
use log::LevelFilter;
mod errors;
mod main_pages;
mod template_utils;
use errors::generic_error;
use main_pages::{mail_post, template};
#[derive(Parser, Debug, Clone)]
struct Config {
#[clap(long, value_parser)]
/// The directory from which to serve static content and
/// handlebars templates
template_directory: PathBuf,
#[clap(long, default_value = "127.0.0.1:8000", value_parser)]
/// The address on which to listen
address: SocketAddr,
#[clap(long, action)]
/// Whether to start the server in dev mode; this enables some nice
/// handlebars features that are not intended for production, and enables
/// more verbose logs
dev_mode: bool,
}
#[derive(Debug)]
struct SharedData<'a> {
handlebars: Handlebars<'a>,
config: Config,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let config = {
let mut config = Config::parse();
config.template_directory = config.template_directory.canonicalize()?;
config
};
env_logger::Builder::new()
.filter_level(if config.dev_mode {
LevelFilter::Info
} else {
LevelFilter::Debug
})
.write_style(WriteStyle::Always)
.parse_env(
Env::new()
.filter("TLATERNET_LOG_LEVEL")
.write_style("TLATERNET_LOG_STYLE"),
)
.init();
let mut handlebars = Handlebars::new();
handlebars
.register_templates_directory(".html", config.template_directory.clone())
.expect("templates should compile correctly");
handlebars.set_dev_mode(config.dev_mode);
let shared_data = web::Data::new(SharedData {
handlebars,
config: config.clone(),
});
HttpServer::new(move || {
App::new()
.wrap(middleware::NormalizePath::trim())
// TODO(tlater): When actix-web 4.3 releases, this can be improved a
// lot because of this PR:
//
// https://github.com/actix/actix-web/pull/2784
.wrap(
ErrorHandlers::new()
.handler(StatusCode::NOT_FOUND, generic_error)
.handler(StatusCode::INTERNAL_SERVER_ERROR, generic_error),
)
.app_data(shared_data.clone())
.service(template)
.service(mail_post)
.service(Files::new("/", &config.template_directory))
.default_service(web::route().method(Method::GET))
})
.bind(config.address)?
.run()
.await
}

View file

@ -0,0 +1,45 @@
use actix_web::{post, routes, web, HttpRequest, HttpResponse, Responder};
use log::info;
use serde::Deserialize;
use crate::template_utils::{render_template, Flash, FlashType, TemplateArgs};
use crate::SharedData;
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct Mail {
mail: String,
subject: String,
message: String,
}
#[routes]
#[get(r"/")]
#[get(r"/{filename:.*\.html}")]
pub(crate) async fn template(
shared: web::Data<SharedData<'_>>,
req: HttpRequest,
) -> actix_web::Result<impl Responder> {
let path = match req.match_info().query("filename") {
"" => "index",
other => other
.strip_suffix(".html")
.expect("only paths with this suffix should get here"),
};
render_template(&shared.handlebars, path, &TemplateArgs::default())
.map(|body| HttpResponse::Ok().body(body))
}
#[post("/mail.html")]
pub(crate) async fn mail_post(
shared: web::Data<SharedData<'_>>,
form: web::Form<Mail>,
) -> actix_web::Result<impl Responder> {
info!("{:?}", form);
let args = TemplateArgs::builder()
.flash(Flash::new("Mail successfully sent!", FlashType::Success))
.build();
render_template(&shared.handlebars, "mail", &args).map(|body| HttpResponse::Ok().body(body))
}

View file

@ -0,0 +1,105 @@
use log::error;
use serde::Serialize;
use crate::errors::UserError;
pub fn render_template(
handlebars: &handlebars::Handlebars,
name: &str,
args: &TemplateArgs,
) -> actix_web::Result<String> {
if handlebars.has_template(name) {
Ok(handlebars
.render(name, args)
.map_err(|_| UserError::InternalError)?)
} else {
error!("template not found: {}", name);
Err(UserError::NotFound)?
}
}
/** All arguments that can be given to a template. */
#[derive(Default, Serialize)]
pub struct TemplateArgs {
flash: Option<Flash>,
error: Option<ErrorMessage>,
}
impl TemplateArgs {
pub fn builder() -> TemplateArgsBuilder {
TemplateArgsBuilder::new()
}
}
pub struct TemplateArgsBuilder {
flash: Option<Flash>,
error: Option<ErrorMessage>,
}
impl TemplateArgsBuilder {
pub fn new() -> Self {
TemplateArgsBuilder {
flash: None,
error: None,
}
}
pub fn flash(mut self, flash: Flash) -> Self {
self.flash = Some(flash);
self
}
pub fn error_page(mut self, error: ErrorMessage) -> Self {
self.error = Some(error);
self
}
pub fn build(self) -> TemplateArgs {
TemplateArgs {
flash: self.flash,
error: self.error,
}
}
}
/** A flash message that should be displayed as a notification on the page. */
#[derive(Serialize)]
pub struct Flash {
message: String,
#[serde(rename = "type")]
level: FlashType,
}
impl Flash {
pub fn new(message: &str, level: FlashType) -> Self {
Self {
message: message.to_string(),
level,
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashType {
Info,
Success,
Warning,
Danger,
}
/** Contents of an error page. */
#[derive(Serialize)]
pub struct ErrorMessage {
message: String,
status_code: u16,
}
impl ErrorMessage {
pub fn new(message: &str, status_code: u16) -> Self {
Self {
message: message.to_string(),
status_code,
}
}
}

View file

@ -0,0 +1,5 @@
((nil . ((indent-tabs-mode . nil)
(tab-width . 4)
(fill-column . 80)
(projectile-project-run-cmd . "cd templates && parcel serve --no-autoinstall")))
(scss-mode . ((css-indent-offset . 2))))

View file

@ -0,0 +1,59 @@
root: true
parser: "@typescript-eslint/parser"
parserOptions:
project:
- ./tsconfig.json
plugins:
- "@typescript-eslint"
extends:
- eslint:recommended
- plugin:@typescript-eslint/recommended
- plugin:@typescript-eslint/recommended-requiring-type-checking
env:
es6: true
browser: true
# {
# "parser": "@typescript-eslint/parser",
# "plugins": [
# "@typescript-eslint"
# ],
# "env": {
# "es6": true,
# "browser": true,
# "jquery": true
# },
# "extends": [
# "eslint:recommended",
# "plugin:@typescript-eslint/recommended"
# ],
# "rules": {
# "indent": [
# "error",
# 4
# ],
# "linebreak-style": [
# "error",
# "unix"
# ],
# "quotes": [
# "error",
# "double"
# ],
# "semi": [
# "warn",
# "always"
# ],
# "no-console": [
# "off"
# ],
# "no-unused-vars": [
# "warn",
# { "argsIgnorePattern": "^_" }
# ]
# }
# }

6
packages/templates/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.parcel-cache/
dist/
node_modules/
package.json
result
.checksum-package.yaml

View file

@ -0,0 +1,6 @@
{
"extends": ["@parcel/config-default"],
"transformers": {
"*.mp3": [ "@parcel/transformer-raw" ]
}
}

View file

@ -0,0 +1,11 @@
# -*- yaml -*-
tabWidth: 4
overrides:
- files: "**/*.html"
options:
tabWidth: 2
- files: "**/*.scss"
options:
tabWidth: 2

View file

@ -0,0 +1,44 @@
{
dream2nix,
config,
lib,
...
}: {
imports = [
dream2nix.modules.dream2nix.nodejs-package-json-v3
dream2nix.modules.dream2nix.nodejs-granular-v3
];
deps = {nixpkgs, ...}: {
inherit (nixpkgs) pkg-config runCommandLocal rsync vips yj;
};
name = "tlaternet-templates";
version = "0.1.0";
mkDerivation = {
src = config.deps.runCommandLocal "source" {nativeBuildInputs = [config.deps.yj];} ''
cp -r ${./.} $out/
chmod -R u+w $out
yj < $out/package.yaml > $out/package.json
'';
# The default phase (which is hidden in `preInstallPhases`) will
# copy the full node_modules directory to the output, and symlink
# executables and whatnot.
#
# Since this package's output is static HTML/JS, we do not want
# that.
preInstallPhases = lib.mkForce [];
installPhase = ''
cp -r dist $out
'';
};
nodejs-granular-v3.deps = {
sharp."0.28.3".mkDerivation.buildInputs = with config.deps; [
vips
pkg-config
];
};
}

14899
packages/templates/lock.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,108 @@
name: tlaternet
version: 1.0.0
description: tlaternet web interface
author: Tristan Maat <tm@tlater.net>
license: MIT
private: true
dependencies:
# Libraries
gl-matrix: ^3.4.3 # To help with 3D math in WebGL code
classnames: ^2.3.2 # To manage CSS class names in react code
# Fonts
hack-font: ^3.3.0
'@fontsource/arimo': ^5.0.3
'@fontsource/nunito': ^5.0.3
'@fortawesome/fontawesome-free': ^6.4.0
# Frameworks for static content
bulma: ^0.9.4
# React-redux stuff
react: ^18.2.0
react-dom: ^18.2.0
react-use-error-boundary: ^3.0.0 # TODO(tlater): Remove when react implement their own
redux: ^4.2.1
'@reduxjs/toolkit': ^1.9.5
react-redux: ^8.1.0
devDependencies:
# Parcel & plugins
parcel: ^2.9.2
'@parcel/transformer-sass': ^2.9.2
'@parcel/transformer-glsl': ^2.9.2
# Build tools
typescript: ^5.1.3
sass: ^1.63.3
posthtml-extend: ^0.6.5
posthtml-favicons: ^1.4.0
posthtml-include: ^1.7.4
posthtml-markdownit: ^1.3.1
'@babel/preset-env': ^7.22.5
# Type shims
'@types/react-dom': ^18.2.5
'@types/react-redux': ^7.1.25
# Dev tools
npm-check-updates: ^16.10.12
prettier: ^2.8.8
typescript-language-server: ^3.3.2
typescript-eslint-language-service: ^5.0.5
eslint: ^8.42.0
'@typescript-eslint/parser': ^5.59.11
'@typescript-eslint/eslint-plugin': ^5.59.11
vscode-langservers-extracted: ^4.7.0
scripts:
# Dev workflow
build: parcel build --no-autoinstall
serve: parcel serve --no-autoinstall
watch: parcel watch --no-autoinstall
# Production build
build-dist: parcel build --no-cache --no-autoinstall
# Checks
check: tsc --noEmit
style: prettier --check src
lint: eslint --max-warnings=0 --format unix src
# Parcel config
source:
- src/index.html
- src/error.html
browserslist: '> 1%, not dead'
posthtml:
plugins:
posthtml-markdownit:
root: src
posthtml-extend:
root: src
posthtml-include:
root: src
posthtml-favicons:
root: src
outDir: ./dist/
configuration:
appName: tlater.net
appShortName: tlater.net
appDescription: tlater's home page
developerName: Tristan Daniël Maat
developerURL: https://tlater.net
dir: auto
lang: en-US
background: '#0f0f0f'
theme_color: '#99d1ce'
appleStatusBarStyle: black-translucent
display: browser
orientation: any
start_url: https://tlater.net
version: "1.0"
icons:
favicons: true

View file

@ -0,0 +1,21 @@
<extends src="./lib/html/base.html">
<block name="content">
<section class="section">
<div class="container">
<h1 class="title has-text-weight-normal">{{ error.status_code }}</h1>
<div class="columns">
<div class="column content">
<!-- prettier-ignore -->
<markdown>
{{ error.message }}
If you think this is a mistake, feel free to [contact
me](~/src/mail.html)!
</markdown>
</div>
</div>
</div>
</section>
</block>
</extends>

View file

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.0"
width="260.000000pt"
height="260.000000pt"
viewBox="0 0 260.000000 260.000000"
preserveAspectRatio="xMidYMid meet"
id="svg70"
sodipodi:docname="icon.svg"
inkscape:version="1.0 (4035a4fb49, 2020-05-01)">
<metadata
id="metadata76">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs74" />
<sodipodi:namedview
inkscape:pagecheckerboard="true"
inkscape:document-rotation="0"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1916"
inkscape:window-height="1059"
id="namedview72"
showgrid="false"
inkscape:zoom="2.2269231"
inkscape:cx="173.33333"
inkscape:cy="173.33333"
inkscape:window-x="-2"
inkscape:window-y="13"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="Background">
<rect
style="fill:#99d1ce;stroke-width:0.75;fill-opacity:1"
id="rect843"
width="260"
height="260"
x="2.0117849e-08"
y="2.0117849e-08" />
</g>
<path
style="fill:#0f0f0f;fill-opacity:1;stroke:#991dce;stroke-width:0.1"
id="path62"
d="M 2.0117848e-8,130 V 260 H 130 260 V 130 2.0117848e-8 H 130 2.0117848e-8 Z M 132.5,19.099999 c 1.4,1.300001 2,3.6 2.3,8.300001 l 0.4,6.4 8.2,0.699999 C 160.7,35.9 177.9,40.300001 182.89999,44.600002 188.9,49.6 189.1,58.399998 183.3,62.999999 180,65.6 176.6,65.5 161.7,62.499998 155,61.199999 145.99999,59.8 141.79999,59.399999 l -7.8,-0.700001 V 85.7 v 27 L 147,116.9 c 34.8,11.2 49.4,24.6 50.69999,46.39999 C 198.7,178 194.8,188.9 184.79999,199.5 174.6,210.4 162.6,216.5 144.89999,219.6 l -9.69999,1.7 -0.4,7.5 c -0.3,6.29999 -0.7,7.69999 -2.7,9.29999 -3.5,2.8 -8.5,2.5 -11,-0.69999 -1.7,-2.10001 -2.1,-4.1 -2.1,-9.4 v -6.7 l -7.1,-0.70001 c -8.8,-0.79999 -23.900001,-4 -34.400001,-7.3 -10.300001,-3.3 -15.5,-7.99999 -15.5,-14 0,-5.59999 3.5,-10.49999 8.2,-11.7 2.999999,-0.8 5.4,-0.39999 12.500002,1.7 C 93.099998,192.5 103,194.59999 112.3,195.5 l 6.7,0.7 v -29.1 c 0,-22.50001 -0.3,-29.20001 -1.20001,-29.5 C 93.099998,130.3 81.199997,124.9 72.900001,117.3 58.5,103.9 54.800001,82.499998 63.699999,64.300001 70.999998,49.399999 91.399998,37.199999 113.4,34.499999 L 119,33.8 v -5.7 c 0,-10.400001 7.1,-15.1 13.5,-9.000001 z" />
<path
style="fill:#0f0f0f;stroke:#991dce;stroke-width:0.1;fill-opacity:1"
id="path64"
d="m 113.2,60.6 c -18.4,4.7 -25.8,18.1 -17.7,32 1.3,2.2 4,5.3 6,6.8 3.7,2.9 16.4,9 17.1,8.3 0.3,-0.2 0.3,-11.2 0.2,-24.4 l -0.3,-24 z" />
<path
style="fill:#0f0f0f;stroke:#991dce;stroke-width:0.1;fill-opacity:1"
id="path66"
d="m 134,169.1 v 26.2 l 4.4,-0.5 c 9.6,-1.1 21.1,-8.9 24.2,-16.4 1.9,-4.6 1.7,-12.1 -0.5,-16.9 -2.6,-5.7 -10,-11.8 -18.6,-15.4 -4,-1.7 -7.8,-3.1 -8.4,-3.1 -0.8,0 -1.1,8.2 -1.1,26.1 z" />
</svg>

After

(image error) Size: 3.6 KiB

View file

@ -0,0 +1,80 @@
<extends src="./lib/html/base.html">
<block name="content">
<section class="section">
<div class="container">
<h1 class="title has-text-weight-normal is-family-monospace">
$&nbsp;<span id="typed-welcome"></span>
</h1>
<hr />
<div class="columns">
<div class="column content">
<!-- prettier-ignore -->
<markdown>
### About Me
Looks like you found my website. I suppose introductions are
in order.
My name's Tristan, I'm an avid Dutch-South African software
consultant working in the UK. You probably either met me at an
open source conference, a hackathon, a badminton session or at
a roleplaying table.
If not, well, this is also a great place to "meet" me. Have a
nosey!
### This Website
There is not a whole lot here at the moment.
You may find the following interesting though:
- A [little web app](~/src/music_sample.html) showing
off what WebGL can do in combination with the JavaScript
Audio interface.
</markdown>
</div>
<div class="column content">
<!-- prettier-ignore -->
<markdown>
### My Work
I'm a software consultant working for
[Codethink](https://www.codethink.co.uk) in Manchester,
UK. Our specializaiton is open source software, so this has
allowed me to directly contribute to a number of open source
projects, notably
[BuildStream](https://www.gitlab.com/buildstream/buildstream),
an integration tool for large software stacks.
I've given a couple of talks on it, as well:
- Build meetup 2017
- Build meetup 2018
- Build meetup 2019
Outside of work for Codethink, I'm generally interested in
things such as NixOS and other tools that assist maintaining
Linux systems - mostly born out of my pursuit of the perfect
Linux desktop (feel free to have a browse through my
[dotfiles](https://github.com/tlater/dotfiles)).
I also just enjoy Programming, my core languages currently are
Rust, Python, Lisp and JavaScript (including a number of
frameworks and tools for these), although I have hopes to
eventually reduce these to just Rust ;)
If you're interested in seeing these things for yourself,
visit my [Gitlab](https://gitlab.com/tlater) and
[GitHub](https://github.com/tlater) pages.
</markdown>
</div>
</div>
</div>
</section>
</block>
</extends>

Binary file not shown.

After

(image error) Size: 1.5 KiB

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="tlater.net web server" />
<meta name="author" contnet="Tristan Daniël Maat" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="./icon.svg" type="image/x-icon" />
<link rel="stylesheet" href="~/src/lib/scss/main.scss" />
<block name="stylesheets"></block>
<title>tlater.net</title>
</head>
<body>
<block name="navigation">
<include src="lib/html/navigation.html"></include>
</block>
<include src="lib/html/message-flash.html"></include>
<block name="content"></block>
<block name="footer">
<script type="module" src="lib/js/index.ts"></script>
</block>
</body>
</html>

View file

@ -0,0 +1,8 @@
<span>
{{#if flash}}
<div class="notification is-{{flash.type}}">
<button class="delete" aria-label="Close"></button>
<span role="alert"> {{ flash.message }} </span>
</div>
{{/if}}
</span>

View file

@ -0,0 +1,23 @@
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item has-text-primary is-uppercase" href="/">tlater</a>
<a
class="navbar-burger"
role="button"
aria-label="menu"
aria-expanded="false"
data-target="main-navigation"
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div id="main-navigation" class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item" href="~/src/mail.html"> E-Mail </a>
<a class="navbar-item" href="https://www.gitlab.com/tlater"> GitLab </a>
<a class="navbar-item" href="https://www.github.com/TLATER"> GitHub </a>
</div>
</div>
</nav>

View file

@ -0,0 +1,67 @@
function registerFlashCloseButtons() {
const flashButtons = document.querySelectorAll(".notification .delete");
for (const flashButton of flashButtons) {
if (flashButton.parentNode === null) {
throw new Error("invalid flash button");
}
const flash = flashButton.parentNode;
flash.addEventListener("click", () => {
if (flash.parentNode === null) {
throw new Error("invalid flash message");
}
flash.parentNode.removeChild(flash);
});
// In development, there won't be a web server hooked up to
// this to render the flash message, so we remove it entirely
if (process.env.NODE_ENV === "development") {
if (
flash.parentNode === null ||
flash.parentNode.parentNode === null
) {
return;
}
console.warn("Disabling flash message");
// Get the containing <span> element
const block = flash.parentNode;
flash.parentNode.parentNode.removeChild(block);
}
}
}
function registerNavCollapseButtons() {
const navbarButtons = document.getElementsByClassName("navbar-burger");
for (const navbarButton of navbarButtons) {
navbarButton.addEventListener("click", () => {
if (
!(navbarButton instanceof HTMLElement) ||
!navbarButton.dataset.target
) {
throw new Error("invalid navbar button");
}
const target = document.getElementById(navbarButton.dataset.target);
if (target === null) {
throw new Error("could not find navbar button target");
}
navbarButton.classList.toggle("is-active");
target.classList.toggle("is-active");
});
}
}
document.addEventListener("DOMContentLoaded", () => {
registerFlashCloseButtons();
registerNavCollapseButtons();
});
export {};

View file

@ -0,0 +1,45 @@
@use "sass:color";
@use "./_fonts";
@import "~/node_modules/bulma/sass/utilities/initial-variables.sass";
@import "~/node_modules/bulma/sass/utilities/functions.sass";
$black: #0f0f0f;
$grey-darker: #11151c;
$grey-light: #dddddd;
$white: #ffffff;
$red: #dc322f;
$orange: #d26937;
$yellow: #b58900;
$blue: #195466;
$cyan: #599cab;
$green: #2aa889;
$primary: #99d1ce;
$link: $green;
$link-hover: color.scale($green, $lightness: +10%);
$link-active: color.scale($green, $lightness: +10%);
$link-focus: color.scale($green, $lightness: +10%);
$input-color: $grey-light;
$input-placeholder-color: $grey-light; // Some opacity is applied to this
$weight-normal: 400;
$scheme-main: $black;
$family-sans-serif: Nunito, $family-sans-serif;
$family-monospace: Hack, $family-monospace;
$text: $grey-light;
$text-strong: $primary;
$label-color: $text;
$content-heading-color: $text;
$hr-background-color: $grey-light;
$hr-height: 1px;
$pre-background: $grey-darker;
@import "~/node_modules/bulma";
@import "./_navbar";

View file

@ -0,0 +1,44 @@
@use "~/node_modules/@fontsource/nunito/scss/mixins" as Nunito;
@use "~/node_modules/@fontsource/arimo/scss/mixins" as Arimo;
@include Nunito.faces(
$weights: (
300,
400,
500,
600,
700,
),
$display: auto,
$styles: (
normal,
italic,
),
$directory: "npm:@fontsource/nunito/files"
);
@include Arimo.faces(
$weights: 400,
$display: auto,
$styles: normal,
$directory: "npm:@fontsource/arimo/files"
);
// Hack *does* come with its own CSS, but it's broken and hasn't seen
// a release since https://github.com/source-foundry/Hack/issues/467
// was resolved.
$variants: regular 400 normal, bold 700 normal, italic 400 italic,
bolditalic 700 italic;
@each $name, $weight, $style in $variants {
@font-face {
font-family: "Hack";
src: url("npm:hack-font/build/web/fonts/hack-#{$name}-subset.woff2?sha=3114f1256")
format("woff2"),
url("npm:hack-font/build/web/fonts/hack-#{$name}-subset.woff?sha=3114f1256")
format("woff");
font-weight: $weight;
font-style: $style;
}
}

View file

@ -0,0 +1,21 @@
.navbar.is-dark {
border: 1px solid #000000;
& .navbar-brand > .navbar-item {
font-family: Arimo;
&:hover {
background-color: $dark !important;
color: $white !important;
}
}
& .navbar-start > .navbar-item {
color: rgba($white, 0.75);
&:hover {
background-color: $dark !important;
color: $white !important;
}
}
}

View file

@ -0,0 +1,136 @@
@use "sass:math";
@use "sass:list";
/// Animate a blinking cursor.
@mixin cursor($duration) {
$name: cursor-09d03260130069771b6ddc1cb415f39fdd27ddfab7b01ba91273398c2d245ae4;
// The number of times we need to blink is = the number of full
// seconds (500ms * 2) that fit in the total duration, rounded up,
// and doubled.
$iterations: math.ceil(math.div($duration, 1s)) * 2;
animation: $name ease-in-out 500ms $iterations alternate;
content: " ";
@keyframes #{$name} {
from {
content: " ";
}
to {
content: "";
}
}
}
/// Animate a piece of text as if it was being typed by a human.
@mixin typed($text, $duration) {
word-break: break-all;
// We don't want a linearly typed set of text, which makes this
// singificantly more complex.
//
// CSS animations normally do not permit per-frame changes in
// duration (since the total animation time is fixed). This means we
// need to create multiple animations, and delay them so that they
// happen in the time sequence we want.
//
// We generate the raw values with _generate-animations, and then
// split up the result into the animation API.
$frames: str-length($text);
$animations: _generate-animations($frames, 1.2s);
animation-name: _unzip($animations, 1);
animation-delay: _unzip($animations, 3);
animation-fill-mode: forwards;
content: "";
// We need to type each character in separate animations, see above
// comment.
@each $name, $character in $animations {
@keyframes #{$name} {
from {
content: str-slice($text, 0, $character);
}
to {
content: str-slice($text, 0, $character + 1);
}
}
}
}
/// Unzip a nested set of lists, taking the nth value of each sublist.
@function _unzip($lists, $i) {
$out: ();
$sep: comma;
@each $sublist in $lists {
$out: list.append($out, list.nth($sublist, $i), $sep);
}
@return $out;
}
/// Compute the sum of all numbers in a list.
@function _sum($list) {
$out: 0;
@each $val in $list {
$out: $out + $val;
}
@return $out;
}
/// Produce a list from a shorter list by repeating it up until size
/// $length.
@function _round-robin($base, $length) {
$out: ();
$sep: list.separator($out);
@for $i from 0 through $length {
$out: list.append($out, list.nth($base, $i % list.length($base) + 1));
}
@return $out;
}
/// Generate the actual animation values.
///
/// This generates a nested list as:
///
/// (keyframe-name, index, start time)
///
/// The duration of each frame is taken from the internal $delays in a
/// round robin fashion, to give some amount of human-like variance to
/// the duration of each frame.
///
/// Start time is set to the time at which the frame should start to
/// achieve the desired frame-by-frame duration.
@function _generate-animations($number, $total_duration) {
$id: d66fa0449c0b4d4ca287f8c96428af928b2987b4d88b72b7d60152d9a55d9f29;
$out: ();
$sep: list.separator($out);
// A set of "human-like" delays for each typed character. In
// practice, my typing seems to be about 20-70ms, but it looks a bit
// nicer to increase all typing by 20ms to make the effect more
// noticeable.
//
// Numbers generated once with a random number generator, rather
// than using `math.random()`, since they end up in CSS verbatim,
// and the build would be non-reproducible if we didn't do it this
// way. Using `math.random() wouldn't change this dynamically each
// time the page loads anyway, so we don't really lose anything by
// pre-generating these numbers.
$delays: 69ms, 83ms, 49ms, 48ms, 52ms, 59ms, 40ms, 71ms, 80ms, 67ms;
@for $animation from 0 through $number {
$out: list.append(
$out,
(
type-#{$id}-#{$animation},
$animation,
_sum(_round_robin($delays, $animation))
),
$sep
);
}
@return $out;
}

View file

@ -0,0 +1,18 @@
@import "./_custom-bulma";
@import "./_typed";
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
#typed-welcome {
&::before {
@include typed("Welcome to tlater.net!", 1.2s);
}
&::after {
@include cursor(6s);
}
}

View file

@ -0,0 +1,72 @@
<extends src="./lib/html/base.html">
<block name="content">
<section class="section">
<div class="container">
<h1 class="title has-text-weight-normal">Contact Me</h1>
<div class="columns">
<div class="column">
<form id="sendmail" role="form" action="mail.html" method="post">
<div class="field">
<label class="label" for="mail">Email address</label>
<input
id="mail"
class="input"
type="email"
placeholder="Your address"
name="mail"
required
/>
</div>
<div class="field">
<label class="label" for="subject">Subject</label>
<input
id="subject"
class="input"
type="text"
placeholder="E.g. There's a typo on your home page!"
name="subject"
autocomplete="off"
required
/>
</div>
<div class="field">
<label class="label" for="message">Message</label>
<textarea
id="message"
class="textarea"
type="text"
rows="6"
name="message"
autocomplete="off"
required
></textarea>
</div>
<div class="field">
<div class="control">
<button class="button is-link">Send</button>
</div>
</div>
</form>
</div>
<div class="column content">
<!-- prettier-ignore -->
<markdown>
Any messages you enter here are directly forwarded to me. I aim to
respond within a day.
Don't be upset about the form, I want to avoid the spam
publishing your email address brings with it... And minimize
the amount of mail that doesn't reach me, this form is an
exception in all my spam filters, you see ;)
</markdown>
</div>
</div>
</div>
</section>
</block>
</extends>

Binary file not shown.

View file

@ -0,0 +1,2 @@
declare const mseq: string;
export default mseq;

View file

@ -0,0 +1,44 @@
import React from "react";
import Indicator from "../indicator/Indicator";
import { useAppSelector } from "../../hooks";
function Controls() {
const title = useAppSelector((state) => state.musicPlayer.title);
let titleLine = <div className="level-item">{title.name}</div>;
if (title.name === "Journey" && title.artist === "Mseq") {
titleLine = (
<div className="level-item is-size-7-mobile is-flex-shrink-1">
<div>
<a href="http://dig.ccmixter.org/files/Mseq/54702">
Journey
</a>
&nbsp;by Mseq (c) copyright 2016 Licensed under a Creative
Commons&nbsp;
<a href="http://creativecommons.org/licenses/by-nc/3.0/">
Attribution Noncommercial (3.0)
</a>
&nbsp; license. Ft: Admiral Bob,Texas Radio Fish
</div>
</div>
);
}
return (
<div className="notification is-primary">
<div className="level is-mobile">
<div className="level-left is-flex-shrink-1">
<Indicator />
{titleLine}
</div>
<div className="level-right is-hidden-mobile">
<div className="level-item">{title.artist}</div>
</div>
</div>
</div>
);
}
export default Controls;

View file

@ -0,0 +1,44 @@
import React from "react";
import classNames from "classnames";
import { useAppSelector, useAppDispatch } from "../../hooks";
import { togglePlay, PlayState } from "../musicplayer/musicPlayerSlice";
function Indicator() {
const playing = useAppSelector((state) => state.musicPlayer.playing);
const muted = useAppSelector((state) => state.musicPlayer.muted);
const dispatch = useAppDispatch();
const buttonClass = classNames({
button: true,
"is-primary": true,
"level-item": true,
"is-loading": playing === PlayState.Loading,
});
const iconClass = classNames({
fas: true,
"fa-2x": true,
"fa-muted": muted,
"fa-play": playing === PlayState.Paused,
"fa-pause": playing === PlayState.Playing,
});
return (
<button
type="button"
onClick={() => {
dispatch(togglePlay(null)).catch((e) => {
console.error(e);
});
}}
className={buttonClass}
>
<span className="icon is-medium">
<i className={iconClass}></i>
</span>
</button>
);
}
export default Indicator;

View file

@ -0,0 +1,17 @@
import React from "react";
import Controls from "../controls/Controls";
import Visualizer from "../visualizer/Visualizer";
function MusicPlayer() {
return (
<div className="is-flex-grow-1 is-flex is-flex-direction-column">
<Visualizer />
<div className="is-flex-grow-0">
<Controls />
</div>
</div>
);
}
export default MusicPlayer;

View file

@ -0,0 +1,157 @@
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";
import { RootState, AppDispatch } from "../../store";
//************************
// Interface definitions *
//************************
interface MusicPlayerState {
muted: boolean;
playing: PlayState;
title: MusicPlayerTitle;
}
interface MusicPlayerTitle {
source: string;
artist: string;
name: string;
album: string;
length: number;
}
enum PlayState {
Playing = "Playing",
Paused = "Paused",
Loading = "Loading",
}
//*********************
// Music player logic *
//*********************
class MusicPlayer {
private context?: AudioContext;
private source: HTMLAudioElement;
private sourceNode?: MediaElementAudioSourceNode;
private volume?: GainNode;
private analyser?: AnalyserNode;
constructor() {
this.source = new Audio();
}
get audioAnalyser() {
return this.analyser;
}
set src(source: string) {
this.source.src = source;
}
togglePlay = async (
_: null,
{ getState }: { getState: () => RootState }
): Promise<PlayState> => {
if (this.context === undefined) {
this.context = new AudioContext();
this.sourceNode = this.context.createMediaElementSource(
this.source
);
this.volume = this.context.createGain();
this.analyser = this.context.createAnalyser();
this.analyser.fftSize = 2048;
this.analyser.smoothingTimeConstant = 0.8;
this.sourceNode.connect(this.analyser);
this.sourceNode.connect(this.volume);
this.volume.connect(this.context.destination);
}
const playing = getState().musicPlayer.playing;
switch (playing) {
case PlayState.Playing:
this.source.pause();
return PlayState.Paused;
case PlayState.Paused:
case PlayState.Loading:
// Chrome's extra cookie, it refuses to play if we
// don't resume after the first user interaction.
await this.context.resume();
return this.source.play().then(() => PlayState.Playing);
}
};
}
const player = new MusicPlayer();
//*************************
// Redux state management *
//*************************
const initialState: MusicPlayerState = {
muted: false,
playing: PlayState.Paused,
title: {
source: "",
artist: "",
name: "",
album: "",
length: 0,
},
};
export const musicPlayerSlice = createSlice({
name: "musicPlayer",
initialState,
reducers: {
setSource: (state, action: PayloadAction<MusicPlayerTitle>) => {
state.title = action.payload;
player.src = state.title.source;
},
},
extraReducers: (builder) => {
builder
.addCase(togglePlay.pending, (state) => {
// If we are currently paused or loading, then this is
// actually an async call, otherwise we just
// synchronously pause the music.
if (state.playing !== PlayState.Playing) {
state.playing = PlayState.Loading;
}
})
.addCase(togglePlay.fulfilled, (state, { payload }) => {
state.playing = payload;
})
.addCase(togglePlay.rejected, (state, { error }) => {
if (error.message !== undefined) {
console.error(`Could not play music: ${error.message}`);
}
state.playing = PlayState.Paused;
});
},
});
export const togglePlay = createAsyncThunk<
PlayState,
null,
{ dispatch: AppDispatch; state: RootState }
>("musicPlayer/togglePlay", player.togglePlay, {
condition: (_, { getState }) => {
const playing = getState().musicPlayer.playing;
if (playing == PlayState.Loading) {
// Block updates when we're loading
return false;
}
},
});
export const { setSource } = musicPlayerSlice.actions;
export { PlayState, player as musicPlayer };
export type { MusicPlayerState };
export default musicPlayerSlice.reducer;

View file

@ -0,0 +1,346 @@
import { Shader } from "./Shader";
import { mat4 } from "gl-matrix";
import { Cube } from "./cube";
import vertexSource from "./shaders/vertices.glsl";
import fragmentSource from "./shaders/fragments.glsl";
const ROTATION_SPEED = 0.0;
const BACKGROUND_COLOR = [0.0588235294118, 0.0588235294118, 0.0588235294118];
class RendererError extends Error {}
class Renderer {
private canvas: HTMLCanvasElement;
private overlay: HTMLSpanElement;
private analyser: AnalyserNode;
private analyserData: Uint8Array;
private lastFrameTime: number;
private dTime: number;
private nextAnimationFrame?: number;
private rotation: number;
private buffers: {
indices?: WebGLBuffer;
positions?: WebGLBuffer;
normals?: WebGLBuffer;
fft?: WebGLBuffer;
velocitiesRead?: WebGLBuffer;
velocitiesWrite?: WebGLBuffer;
};
constructor(
analyser: AnalyserNode,
canvas: HTMLCanvasElement,
overlay: HTMLSpanElement
) {
this.canvas = canvas;
this.overlay = overlay;
this.analyser = analyser;
this.analyserData = new Uint8Array(analyser.frequencyBinCount);
this.lastFrameTime = 0;
this.dTime = 0;
this.rotation = 0;
this.buffers = {};
}
resizeAndDraw(
gl: WebGL2RenderingContext,
shader: Shader,
observerData: ResizeObserverEntry | null
) {
if (this.canvas.parentElement === null) {
throw new Error("renderer has been removed from dom");
}
if (this.nextAnimationFrame) {
cancelAnimationFrame(this.nextAnimationFrame);
}
// Note: For this to work, it's *incredibly important* for the
// canvas to be overflowable by its parent, and its parent to
// have `overflow: hidden` set. If using a flexbox, this means
// that the canvas has to be `position: absolute`.
let width: number;
let height: number;
if (observerData !== null && observerData.devicePixelContentBoxSize) {
width = observerData.devicePixelContentBoxSize[0].inlineSize;
height = observerData.devicePixelContentBoxSize[0].blockSize;
} else {
// Fallback; the above API is even newer than
// ResizeObserver, and by setting the observerData to null
// we can manually resize at least once without going
// through the API.
if (this.canvas.parentElement === null) {
throw new Error("canvas parent disappeared");
}
// Note: This *requires* `box-sizing: border-box`
({ width, height } =
this.canvas.parentElement.getBoundingClientRect());
}
// Preserve the aspect ratio
const aspect_ratio = (this.analyser.frequencyBinCount * 2) / 512;
const target_height = width / aspect_ratio;
if (target_height > height) {
this.canvas.width = aspect_ratio * height;
this.canvas.height = height;
} else {
this.canvas.width = width;
this.canvas.height = width / aspect_ratio;
}
gl.viewport(0, 0, this.canvas.clientWidth, this.canvas.clientHeight);
this.updateProjection(gl, shader);
// ResizeObserver will call when we should draw, so do our own
// time calculation and draw the scene.
this.updateTime(performance.now());
this.drawScene(gl, shader);
}
updateTime(time: number) {
this.dTime = time - this.lastFrameTime;
this.lastFrameTime = time;
}
initializeScene() {
if (this.canvas.parentElement === null) {
throw new Error("canvas was not added to page");
}
const gl = this.canvas.getContext("webgl2");
if (gl === null) {
throw new RendererError("WebGL (2) is unsupported on this browser");
}
const shader = Shader.builder(gl)
.addShader(vertexSource, gl.VERTEX_SHADER)
.addShader(fragmentSource, gl.FRAGMENT_SHADER)
.addAttribute("aVertexPosition")
.addAttribute("aVertexNormal")
.addAttribute("aHeight")
.addUniforms("uProjectionMatrix")
.addUniforms("uModelViewMatrix")
.addUniforms("uNormalMatrix")
.build();
this.initGL(gl, shader);
this.updateProjection(gl, shader);
this.initBuffers(gl);
try {
const observer = new ResizeObserver((elements) => {
// We only observe one element
const element = elements[0];
this.resizeAndDraw(gl, shader, element);
});
observer.observe(this.canvas.parentElement);
} catch (error) {
// If the browser does not support ResizeObserver, we
// simply don't resize. Resizing is hard enough, just use
// a modern browser.
if (error instanceof ReferenceError) {
console.warn(
"Browser does not support `ResizeObserver`. Canvas resizing will be disabled."
);
} else throw error;
}
this.resizeAndDraw(gl, shader, null);
}
updateProjection(gl: WebGLRenderingContext, shader: Shader) {
const projectionMatrix = mat4.create();
mat4.perspective(
projectionMatrix,
(45 * Math.PI) / 180,
gl.canvas.clientWidth / gl.canvas.clientHeight,
0.1,
100.0
);
gl.uniformMatrix4fv(
shader.getUniform("uProjectionMatrix"),
false,
projectionMatrix
);
}
initBuffers(gl: WebGLRenderingContext) {
// Scale down the unit cube before we use it
Cube.vertices = Cube.vertices.map(
(num: number) => num / this.analyser.frequencyBinCount
);
// Position buffer
const positionBuffer = gl.createBuffer();
if (positionBuffer === null) {
throw new Error("could not initialize position buffer");
}
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, Cube.vertices, gl.STATIC_DRAW);
this.buffers.positions = positionBuffer;
// Index buffer
const indexBuffer = gl.createBuffer();
if (indexBuffer === null) {
throw new Error("could not initialize index buffer");
}
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, Cube.indices, gl.STATIC_DRAW);
this.buffers.indices = indexBuffer;
// Surface normal buffer
const normalBuffer = gl.createBuffer();
if (normalBuffer === null) {
throw new Error("could not initialize normal buffer");
}
gl.bindBuffer(gl.ARRAY_BUFFER, normalBuffer);
gl.bufferData(gl.ARRAY_BUFFER, Cube.normals, gl.STATIC_DRAW);
this.buffers.normals = normalBuffer;
// fft data buffer
const fftBuffer = gl.createBuffer();
if (fftBuffer === null) {
throw new Error("could not initialize fft buffer");
}
// No need to initialize this buffer here since we will be
// updating it as soon as we start rendering anyway.
this.buffers.fft = fftBuffer;
}
initGL(gl: WebGLRenderingContext, shader: Shader) {
gl.useProgram(shader.program);
gl.clearColor(
BACKGROUND_COLOR[0],
BACKGROUND_COLOR[1],
BACKGROUND_COLOR[2],
1.0
);
gl.clearDepth(1.0);
gl.enable(gl.DEPTH_TEST);
gl.depthFunc(gl.LESS);
}
updateMatrices(gl: WebGLRenderingContext, shader: Shader) {
this.rotation += (this.dTime / 1000.0) * ROTATION_SPEED;
const modelViewMatrix = mat4.create();
mat4.translate(modelViewMatrix, modelViewMatrix, [0.0, 0.0, -0.8]);
mat4.rotateX(modelViewMatrix, modelViewMatrix, Math.PI / 32);
mat4.rotateY(modelViewMatrix, modelViewMatrix, this.rotation);
mat4.translate(modelViewMatrix, modelViewMatrix, [-1.0, 0.0, 0.0]);
gl.uniformMatrix4fv(
shader.getUniform("uModelViewMatrix"),
false,
modelViewMatrix
);
const normalMatrix = mat4.create();
mat4.invert(normalMatrix, modelViewMatrix);
mat4.transpose(normalMatrix, normalMatrix);
gl.uniformMatrix4fv(
shader.getUniform("uNormalMatrix"),
false,
normalMatrix
);
}
updateBuffers(gl: WebGL2RenderingContext, shader: Shader) {
if (
this.buffers.indices === undefined ||
this.buffers.positions === undefined ||
this.buffers.normals === undefined ||
this.buffers.fft === undefined
) {
throw new Error("failed to create buffers before rendering");
}
// Update cube buffers
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.positions);
gl.vertexAttribPointer(
shader.getAttribute("aVertexPosition"),
3,
gl.FLOAT,
false,
0,
0
);
gl.enableVertexAttribArray(shader.getAttribute("aVertexPosition"));
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.buffers.indices);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.normals);
gl.vertexAttribPointer(
shader.getAttribute("aVertexNormal"),
3,
gl.FLOAT,
false,
0,
0
);
gl.enableVertexAttribArray(shader.getAttribute("aVertexNormal"));
// Update fft
this.analyser.getByteFrequencyData(this.analyserData);
gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers.fft);
gl.bufferData(gl.ARRAY_BUFFER, this.analyserData, gl.STREAM_DRAW);
gl.vertexAttribPointer(
shader.getAttribute("aHeight"),
1,
gl.UNSIGNED_BYTE,
false,
0,
0
);
gl.vertexAttribDivisor(shader.getAttribute("aHeight"), 1);
gl.enableVertexAttribArray(shader.getAttribute("aHeight"));
}
drawScene(gl: WebGL2RenderingContext, shader: Shader) {
this.updateMatrices(gl, shader);
this.updateBuffers(gl, shader);
let cpuTime = 0;
if (process.env.NODE_ENV === "development") {
cpuTime = Math.round(performance.now() - this.lastFrameTime);
}
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.drawElementsInstanced(
gl.TRIANGLES,
36,
gl.UNSIGNED_SHORT,
0,
this.analyser.frequencyBinCount
);
if (process.env.NODE_ENV === "development") {
const gpuTime = Math.round(performance.now() - this.lastFrameTime);
const dTime = Math.round(this.dTime);
this.overlay.innerText = `${dTime}ms (${cpuTime}ms / ${gpuTime}ms)`;
}
this.nextAnimationFrame = requestAnimationFrame((time) => {
this.updateTime(time);
this.drawScene(gl, shader);
});
}
}
export { Renderer, RendererError };

View file

@ -0,0 +1,183 @@
type ShaderType =
| WebGLRenderingContext["VERTEX_SHADER"]
| WebGLRenderingContext["FRAGMENT_SHADER"];
interface ShaderSource {
source: string;
kind: ShaderType;
}
type ShaderAttributes = Map<string, number>;
type ShaderUniforms = Map<string, WebGLUniformLocation>;
class ShaderError extends Error {}
class Shader {
private program_: WebGLProgram;
private attributes_: ShaderAttributes;
private uniforms_: ShaderUniforms;
constructor(
program: WebGLProgram,
attributes: ShaderAttributes,
uniforms: ShaderUniforms
) {
this.program_ = program;
this.attributes_ = attributes;
this.uniforms_ = uniforms;
}
static builder(gl: WebGLRenderingContext): ShaderBuilder {
return new ShaderBuilder(gl);
}
get program(): WebGLProgram {
return this.program_;
}
public getAttribute(name: string): number {
const attribute = this.attributes_.get(name);
if (attribute === undefined) {
throw new ShaderError(`undefined shader attribute: ${name}`);
}
return attribute;
}
public getUniform(name: string): WebGLUniformLocation {
const uniform = this.uniforms_.get(name);
if (uniform === undefined) {
throw new ShaderError(`undefined shader uniform: ${name}`);
}
return uniform;
}
get uniforms(): ShaderUniforms {
return this.uniforms_;
}
}
class ShaderBuilder {
private gl: WebGLRenderingContext;
private sources: Array<ShaderSource>;
private attributes: Array<string>;
private uniforms: Array<string>;
public constructor(gl: WebGLRenderingContext) {
this.gl = gl;
this.sources = new Array<ShaderSource>();
this.attributes = new Array<string>();
this.uniforms = new Array<string>();
}
public addShader(source: string, kind: ShaderType): ShaderBuilder {
this.sources.push({ source, kind });
return this;
}
public addAttribute(name: string): ShaderBuilder {
this.attributes.push(name);
return this;
}
public addUniforms(name: string): ShaderBuilder {
this.uniforms.push(name);
return this;
}
public build(): Shader {
// Load, compile and link shader sources
const shaders = this.sources.map(({ source, kind }) => {
return this.loadShader(source, kind);
});
const shaderProgram = this.gl.createProgram();
if (shaderProgram === null) {
throw new ShaderError("failed to create shader program");
}
for (const shader of shaders) {
this.gl.attachShader(shaderProgram, shader);
}
this.gl.linkProgram(shaderProgram);
if (!this.gl.getProgramParameter(shaderProgram, this.gl.LINK_STATUS)) {
let message = "failed to link shader program";
const log = this.gl.getProgramInfoLog(shaderProgram);
if (log !== null) {
message = `failed to link shader program: ${log}`;
}
throw new ShaderError(message);
}
// Find attribute and uniform locations
const attributes = this.attributes.reduce((acc, attribute) => {
const attributeLocation = this.gl.getAttribLocation(
shaderProgram,
attribute
);
if (attributeLocation === -1) {
throw new ShaderError(
`shader attribute '${attribute}' could not be found`
);
}
return new Map<string, number>([
...acc,
[attribute, attributeLocation],
]);
}, new Map<string, number>());
const uniforms = this.uniforms.reduce((acc, uniform) => {
const uniformLocation = this.gl.getUniformLocation(
shaderProgram,
uniform
);
if (uniformLocation === null) {
throw new ShaderError(
`shader uniform '${uniform}' could not be found`
);
}
return new Map<string, WebGLUniformLocation>([
...acc,
[uniform, uniformLocation],
]);
}, new Map<string, WebGLUniformLocation>());
// Build actual shader object
return new Shader(shaderProgram, attributes, uniforms);
}
private loadShader(source: string, kind: ShaderType): WebGLShader {
const shader = this.gl.createShader(kind);
if (shader === null) {
throw new ShaderError(`failed to initialize shader "${source}"`);
}
this.gl.shaderSource(shader, source);
this.gl.compileShader(shader);
if (!this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS)) {
let message = `failed to compile shader "${source}"`;
const log = this.gl.getShaderInfoLog(shader);
if (log !== null) {
message = `failed to compile shader "${source}": ${log}`;
}
this.gl.deleteShader(shader);
throw new ShaderError(message);
}
return shader;
}
}
export { Shader, ShaderError };

View file

@ -0,0 +1,147 @@
import React, { useCallback, useState } from "react";
import { Renderer, RendererError } from "./Renderer";
import { ShaderError } from "./Shader";
import classNames from "classnames";
import { useAppSelector } from "../../hooks";
import { PlayState, musicPlayer } from "../musicplayer/musicPlayerSlice";
function Visualizer() {
const playing = useAppSelector((state) => state.musicPlayer.playing);
const rendererState = useState<Renderer | null>(null);
let renderer = rendererState[0];
const setRenderer = rendererState[1];
const [renderError, setRenderError] = useState<JSX.Element | null>(null);
const visualizer = useCallback(
(visualizer: HTMLDivElement | null) => {
// TODO(tlater): Clean up state management. This is all
// but trivial; there's seemingly no good place to keep
// these big api objects (WebGLRenderingcontext or
// AudioContext).
//
// It's tricky, too, because obviously react expects to be
// in control of the DOM, and be allowed to delete our
// canvas and create a new one.
//
// For the moment, this works, but it's a definite hack.
if (renderer) {
return;
}
// Until we start playing music, there is nothing to render.
if (playing !== PlayState.Playing) {
return;
}
if (musicPlayer.audioAnalyser === undefined) {
throw new Error("MusicPlayer analyser was not set up on time");
}
// If we're rendering an error message, we won't be
// setting up the visualizer.
//
// Also, nonintuitively, renderError will be null here on
// subsequent iterations, so we can't rely on it to
// identify errors.
if (visualizer === null) {
return;
}
const canvas = visualizer.children[0];
const overlay = visualizer.children[1];
if (
!(canvas instanceof HTMLCanvasElement) ||
!(overlay instanceof HTMLSpanElement)
) {
throw new Error(
"react did not create our visualizer div correctly"
);
}
if (renderer === null) {
renderer = new Renderer(
musicPlayer.audioAnalyser,
canvas,
overlay
);
setRenderer(renderer);
}
try {
renderer.initializeScene();
} catch (error) {
// Log so we don't lose the stack trace
console.log(error);
if (error instanceof ShaderError) {
setRenderError(
<span>
Failed to compile shader; This is a bug, feel free
to contact me with this error message:
<pre>
<code className="has-text-danger">
{error.message}
</code>
</pre>
</span>
);
} else if (error instanceof RendererError) {
setRenderError(
<span>
This browser does not support WebGL 2, sadly. This
demo uses WebGL and specifically instanced drawing,
so unfortunately this means it can't run on your
browser/device.
</span>
);
} else if (error instanceof Error) {
setRenderError(
<span>
Something went very wrong; apologies, either your
browser is not behaving or there's a serious bug.
You can contact me with this error message:
<pre>
<code className="has-text-danger">
{error.message}
</code>
</pre>
</span>
);
} else {
setRenderError(
<span>
Something went very wrong; apologies, either your
browser is not behaving or there's a serious bug.
</span>
);
}
}
},
[playing, renderer, musicPlayer.audioAnalyser]
);
if (renderError === null) {
return (
<div
ref={visualizer}
className={classNames({
"is-flex-grow-1": true,
"is-clipped": true,
"is-relative": true,
"is-flex": true,
"is-align-items-center": true,
"is-justify-content-center": true,
})}
>
<canvas className="is-block is-absolute is-border-box"></canvas>
<span className="is-bottom-left"></span>
</div>
);
} else {
return renderError;
}
}
export default Visualizer;

View file

@ -0,0 +1,84 @@
/** * A hand-written 3d model of a cube.
*
* If this ever needs to be more than this, consider moving it to a
* proper .obj model.
*/
const Cube = {
// prettier-ignore
vertices: new Float32Array([
-1.0, -1.0, 1.0,
1.0, -1.0, 1.0,
1.0, 1.0, 1.0,
-1.0, 1.0, 1.0,
-1.0, -1.0, -1.0,
-1.0, 1.0, -1.0,
1.0, 1.0, -1.0,
1.0, -1.0, -1.0,
-1.0, 1.0, -1.0,
-1.0, 1.0, 1.0,
1.0, 1.0, 1.0,
1.0, 1.0, -1.0,
-1.0, -1.0, -1.0,
1.0, -1.0, -1.0,
1.0, -1.0, 1.0,
-1.0, -1.0, 1.0,
1.0, -1.0, -1.0,
1.0, 1.0, -1.0,
1.0, 1.0, 1.0,
1.0, -1.0, 1.0,
-1.0, -1.0, -1.0,
-1.0, -1.0, 1.0,
-1.0, 1.0, 1.0,
-1.0, 1.0, -1.0,
]),
// prettier-ignore
indices: new Uint16Array([
0, 1, 2, 0, 2, 3,
4, 5, 6, 4, 6, 7,
8, 9, 10, 8, 10, 11,
12, 13, 14, 12, 14, 15,
16, 17, 18, 16, 18, 19,
20, 21, 22, 20, 22, 23,
]),
// prettier-ignore
normals: new Float32Array([
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
0.0, 0.0, 1.0,
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
0.0, 0.0, -1.0,
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, 1.0, 0.0,
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
0.0, -1.0, 0.0,
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
1.0, 0.0, 0.0,
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0,
-1.0, 0.0, 0.0
]),
};
export { Cube };

View file

@ -0,0 +1,12 @@
#version 300 es
// FRAGMENT SHADER
//
// Basic fragment shader, just passes along colors, we don't do much
// with textures or anything else complex in this project.
precision highp float;
flat in vec4 vColor;
out vec4 color;
void main() { color = vColor; }

View file

@ -0,0 +1,2 @@
declare const fragments: string;
export default fragments;

View file

@ -0,0 +1,50 @@
#version 300 es
// VERTEX SHADER
//
// Takes vertices of a unit cube, scales them up along Y according to
// aHeight, and colors them with basic diffuse shading.
#define CLEAR_COLOR vec4(0.0588235294118, 0.0588235294118, 0.0588235294118, 1.0)
#define BASE_COLOR vec3(0.6, 0.819607843137, 0.807843137255)
#define AMBIENT_LIGHT vec3(0.3, 0.3, 0.3)
#define LIGHT_DIRECTION normalize(vec3(0.85, 0.8, 0.75))
#define LIGHT_COLOR vec3(1.0, 1.0, 1.0)
precision highp float;
layout(location = 0) in vec4 aVertexPosition;
layout(location = 1) in vec3 aVertexNormal;
layout(location = 2) in float aHeight;
flat out vec4 vColor;
uniform mat4 uModelViewMatrix;
uniform mat4 uProjectionMatrix;
uniform mat4 uNormalMatrix;
void main() {
// The X position of each vertex depends on its cube's instance;
// they should align to the X axis.
float instanceX =
float(gl_InstanceID * 2) * abs(aVertexPosition.x) + aVertexPosition.x;
// To scale the boxes by their frequencies, scale vertex Y by the
// frequency.
float vertexY = aVertexPosition.y * aHeight;
gl_Position = uProjectionMatrix * uModelViewMatrix *
vec4(instanceX, vertexY, aVertexPosition.zw);
if (aHeight == 0.0) {
// Don't render cubes that don't currently have a height
// (frequency = 0)
vColor = CLEAR_COLOR;
} else {
// Properly shade and color any other cubes
vec4 transformedNormal = uNormalMatrix * vec4(aVertexNormal, 1.0);
float directionalLight =
max(dot(transformedNormal.xyz, LIGHT_DIRECTION), 0.0);
vec3 appliedColor =
BASE_COLOR * (directionalLight * LIGHT_COLOR + AMBIENT_LIGHT);
vColor = vec4(appliedColor.rgb, 1.0);
}
}

View file

@ -0,0 +1,2 @@
declare const vertices: string;
export default vertices;

View file

@ -0,0 +1,5 @@
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "./store";
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

View file

@ -0,0 +1,31 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";
import store from "./store";
import MusicPlayer from "./features/musicplayer/MusicPlayer";
import { setSource } from "./features/musicplayer/musicPlayerSlice";
import mseq from "./assets/Mseq_-_Journey.mp3";
const rootElement = document.getElementById("playerUI");
if (rootElement === null) {
throw Error("DOM seems to have failed to load. Something went very wrong.");
}
const root = createRoot(rootElement);
root.render(
<Provider store={store}>
<MusicPlayer />
</Provider>
);
store.dispatch(
setSource({
source: mseq,
artist: "Mseq",
name: "Journey",
album: "Unknown album",
length: 192052244,
})
);

View file

@ -0,0 +1,18 @@
$fa-font-path: "npm:@fortawesome/fontawesome-free/webfonts";
@import "~/node_modules/@fortawesome/fontawesome-free/scss/fontawesome";
@import "~/node_modules/@fortawesome/fontawesome-free/scss/solid";
.is-border-box {
box-sizing: border-box !important;
}
.is-absolute {
position: absolute !important;
}
.is-bottom-left {
bottom: 0;
left: 0;
position: absolute !important;
}

View file

@ -0,0 +1,15 @@
class Player {
constructor() {
console.info("Test");
}
}
let player: Player | null = null;
export default () => {
if (player === null) {
player = new Player();
}
return player;
};

View file

@ -0,0 +1,13 @@
import { configureStore } from "@reduxjs/toolkit";
import musicPlayerReducer from "./features/musicplayer/musicPlayerSlice";
const store = configureStore({
reducer: {
musicPlayer: musicPlayerReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;

View file

@ -0,0 +1,13 @@
<extends src="./lib/html/base.html">
<block name="stylesheets">
<link rel="stylesheet" href="music/music.scss" />
</block>
<block name="content">
<div id="playerUI" class="is-flex-grow-1 is-flex"></div>
</block>
<block name="footer" type="append">
<script type="module" src="music/index.tsx"></script>
</block>
</extends>

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"strictNullChecks": true,
"strictPropertyInitialization": true,
"esModuleInterop": true,
"jsx": "react",
"isolatedModules": true,
"target": "es2015",
"moduleResolution": "node",
"plugins": [
{
"name": "typescript-eslint-language-service"
}
]
}
}