Integrate previously external templates into this repository #12

Manually merged
tlater merged 8 commits from tlater/integrated-templates into master 2022-10-05 12:39:33 +01:00
55 changed files with 11946 additions and 144 deletions
Showing only changes of commit 76f5246814 - Show all commits

View file

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

1
.envrc
View file

@ -1 +0,0 @@
use flake

4
.gitignore vendored
View file

@ -1,2 +1,2 @@
/target /server/target
/templates/ /result

View file

@ -1,56 +1,208 @@
{ {
"nodes": { "nodes": {
"flake-utils": { "alejandra": {
"locked": {
"lastModified": 1656928814,
"narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"naersk": {
"inputs": { "inputs": {
"fenix": "fenix",
"flakeCompat": "flakeCompat",
"nixpkgs": [ "nixpkgs": [
"dream2nix",
"nixpkgs" "nixpkgs"
] ]
}, },
"locked": { "locked": {
"lastModified": 1662220400, "lastModified": 1658427149,
"narHash": "sha256-9o2OGQqu4xyLZP9K6kNe1pTHnyPz0Wr3raGYnr9AIgY=", "narHash": "sha256-ToD/1z/q5VHsLMrS2h96vjJoLho59eNRtknOUd19ey8=",
"owner": "nmattia", "owner": "kamadorueda",
"repo": "naersk", "repo": "alejandra",
"rev": "6944160c19cb591eb85bbf9b2f2768a935623ed3", "rev": "f5a22afd2adfb249b4e68e0b33aa1f0fb73fb1be",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nmattia", "owner": "kamadorueda",
"repo": "naersk", "repo": "alejandra",
"type": "github" "type": "github"
} }
}, },
"nix-filter": { "crane": {
"flake": false,
"locked": { "locked": {
"lastModified": 1661201956, "lastModified": 1661875961,
"narHash": "sha256-RizGJH/buaw9A2+fiBf9WnXYw4LZABB5kMAZIEE5/T8=", "narHash": "sha256-f1h/2c6Teeu1ofAHWzrS8TwBPcnN+EEu+z1sRVmMQTk=",
"owner": "ipetkov",
"repo": "crane",
"rev": "d9f394e4e20e97c2a60c3ad82c2b6ef99be19e24",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"devshell": {
"flake": false,
"locked": {
"lastModified": 1653917170,
"narHash": "sha256-FyxOnEE/V4PNEcMU62ikY4FfYPo349MOhMM97HS0XEo=",
"owner": "numtide", "owner": "numtide",
"repo": "nix-filter", "repo": "devshell",
"rev": "3b821578685d661a10b563cba30b1861eec05748", "rev": "fc7a3e3adde9bbcab68af6d1e3c6eb738e296a92",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "numtide", "owner": "numtide",
"repo": "nix-filter", "repo": "devshell",
"type": "github" "type": "github"
} }
}, },
"dream2nix": {
"inputs": {
"alejandra": "alejandra",
"crane": "crane",
"devshell": "devshell",
"flake-utils-pre-commit": "flake-utils-pre-commit",
"gomod2nix": "gomod2nix",
"mach-nix": "mach-nix",
"nixpkgs": "nixpkgs",
"poetry2nix": "poetry2nix",
"pre-commit-hooks": "pre-commit-hooks"
},
"locked": {
"lastModified": 1663323895,
"narHash": "sha256-ZmI9C8HNVz2w3OnB79WR/LIgVEY8tDnR8tEPi3hMiJk=",
"owner": "nix-community",
"repo": "dream2nix",
"rev": "25be741ec92c77b8308ca6a7ab89593fe37b6542",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "dream2nix",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"dream2nix",
"alejandra",
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1657607339,
"narHash": "sha256-HaqoAwlbVVZH2n4P3jN2FFPMpVuhxDy1poNOR7kzODc=",
"owner": "nix-community",
"repo": "fenix",
"rev": "b814c83d9e6aa5a28d0cf356ecfdafb2505ad37d",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"fenix_2": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src_2"
},
"locked": {
"lastModified": 1663396212,
"narHash": "sha256-dlK10QPTDYNpJ/vl2QPKOTrqEbQwAR/v2f4+xsetTkw=",
"owner": "nix-community",
"repo": "fenix",
"rev": "263cd7f991c07a9592a6e825bfc37b23b00eb244",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils-pre-commit": {
"locked": {
"lastModified": 1644229661,
"narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flakeCompat": {
"flake": false,
"locked": {
"lastModified": 1650374568,
"narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "b4a34015c698c7793d592d66adbab377907a2be8",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"gomod2nix": {
"flake": false,
"locked": {
"lastModified": 1627572165,
"narHash": "sha256-MFpwnkvQpauj799b4QTBJQFEddbD02+Ln5k92QyHOSk=",
"owner": "tweag",
"repo": "gomod2nix",
"rev": "67f22dd738d092c6ba88e420350ada0ed4992ae8",
"type": "github"
},
"original": {
"owner": "tweag",
"repo": "gomod2nix",
"type": "github"
}
},
"mach-nix": {
"flake": false,
"locked": {
"lastModified": 1634711045,
"narHash": "sha256-m5A2Ty88NChLyFhXucECj6+AuiMZPHXNbw+9Kcs7F6Y=",
"owner": "DavHau",
"repo": "mach-nix",
"rev": "4433f74a97b94b596fa6cd9b9c0402104aceef5d",
"type": "github"
},
"original": {
"id": "mach-nix",
"type": "indirect"
}
},
"nixpkgs": { "nixpkgs": {
"locked": {
"lastModified": 1657638268,
"narHash": "sha256-blBNtQSslAFkg0Gym9fWNJk+bPxGSZib4SOcPrmTPi4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d80993b5f885515254746ba6d1917276ee386149",
"type": "github"
},
"original": {
"id": "nixpkgs",
"ref": "nixos-unstable",
"type": "indirect"
}
},
"nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1663244735, "lastModified": 1663244735,
"narHash": "sha256-+EukKkeAx6ithOLM1u5x4D12ZFuoi6vpPYjhNDmLz1o=", "narHash": "sha256-+EukKkeAx6ithOLM1u5x4D12ZFuoi6vpPYjhNDmLz1o=",
@ -66,85 +218,87 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_2": { "poetry2nix": {
"flake": false,
"locked": { "locked": {
"lastModified": 1660318005, "lastModified": 1632969109,
"narHash": "sha256-g9WCa9lVUmOV6dYRbEPjv/TLOR5hamjeCcKExVGS3OQ=", "narHash": "sha256-jPDclkkiAy5m2gGLBlKgH+lQtbF7tL4XxBrbSzw+Ioc=",
"owner": "nixos", "owner": "nix-community",
"repo": "nixpkgs", "repo": "poetry2nix",
"rev": "5c211b47aeadcc178c5320afd4e74c7eed5c389f", "rev": "aee8f04296c39d88155e05d25cfc59dfdd41cc77",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "nix-community",
"ref": "nixos-22.05", "ref": "1.21.0",
"repo": "nixpkgs", "repo": "poetry2nix",
"type": "github" "type": "github"
} }
}, },
"npmlock2nix": { "pre-commit-hooks": {
"flake": false, "inputs": {
"flake-utils": [
"dream2nix",
"flake-utils-pre-commit"
],
"nixpkgs": [
"dream2nix",
"nixpkgs"
]
},
"locked": { "locked": {
"lastModified": 1654775747, "lastModified": 1646153636,
"narHash": "sha256-9pXHDpIjmsK5390wmpGHu9aA4QOPpegPBvThHeBlef4=", "narHash": "sha256-AlWHMzK+xJ1mG267FdT8dCq/HvLCA6jwmx2ZUy5O8tY=",
"owner": "nix-community", "owner": "cachix",
"repo": "npmlock2nix", "repo": "pre-commit-hooks.nix",
"rev": "5c4f247688fc91d665df65f71c81e0726621aaa8", "rev": "b6bc0b21e1617e2b07d8205e7fae7224036dfa4b",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nix-community", "owner": "cachix",
"repo": "npmlock2nix", "repo": "pre-commit-hooks.nix",
"type": "github" "type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"naersk": "naersk", "dream2nix": "dream2nix",
"nixpkgs": "nixpkgs", "fenix": "fenix_2",
"rust-overlay": "rust-overlay", "nixpkgs": "nixpkgs_2"
"tlaternet-templates": "tlaternet-templates"
} }
}, },
"rust-overlay": { "rust-analyzer-src": {
"inputs": { "flake": false,
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": { "locked": {
"lastModified": 1663297375, "lastModified": 1657557289,
"narHash": "sha256-7pjd2x9fSXXynIzp9XiXjbYys7sR6MKCot/jfGL7dgE=", "narHash": "sha256-PRW+nUwuqNTRAEa83SfX+7g+g8nQ+2MMbasQ9nt6+UM=",
"owner": "oxalica", "owner": "rust-lang",
"repo": "rust-overlay", "repo": "rust-analyzer",
"rev": "0678b6187a153eb0baa9688335b002fe14ba6712", "rev": "caf23f29144b371035b864a1017dbc32573ad56d",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "oxalica", "owner": "rust-lang",
"repo": "rust-overlay", "ref": "nightly",
"repo": "rust-analyzer",
"type": "github" "type": "github"
} }
}, },
"tlaternet-templates": { "rust-analyzer-src_2": {
"inputs": { "flake": false,
"nix-filter": "nix-filter",
"nixpkgs": "nixpkgs_2",
"npmlock2nix": "npmlock2nix"
},
"locked": { "locked": {
"lastModified": 1663345814, "lastModified": 1662896065,
"narHash": "sha256-wIl8P+Hv8zHwBATlEoppPNJMpcR2EiQ4dbkgGXszmf8=", "narHash": "sha256-1LkSsXzI1JTAmP/GMTz4fTJd8y/tw8R79l96q+h7mu8=",
"ref": "master", "owner": "rust-lang",
"rev": "789431c13cf1e906cbaf48e9b1078056c8ec3cc8", "repo": "rust-analyzer",
"revCount": 111, "rev": "2e9f1204ca01c3e20898d4a67c8b84899d394a88",
"type": "git", "type": "github"
"url": "https://gitea.tlater.net/tlaternet/tlaternet-templates.git"
}, },
"original": { "original": {
"type": "git", "owner": "rust-lang",
"url": "https://gitea.tlater.net/tlaternet/tlaternet-templates.git" "ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
} }
} }
}, },

View file

@ -3,78 +3,63 @@
inputs = { inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-22.05"; nixpkgs.url = "github:nixos/nixpkgs/nixos-22.05";
rust-overlay = { dream2nix.url = "github:nix-community/dream2nix";
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
naersk = {
url = "github:nmattia/naersk";
inputs.nixpkgs.follows = "nixpkgs";
};
tlaternet-templates = { fenix = {
url = "git+https://gitea.tlater.net/tlaternet/tlaternet-templates.git"; url = "github:nix-community/fenix";
# No need to override anything here; we can save some downloads inputs.nixpkgs.follows = "nixpkgs";
# if we rely on the webserver to do that.
}; };
}; };
outputs = { outputs = {
self, self,
nixpkgs, nixpkgs,
rust-overlay, dream2nix,
naersk, fenix,
tlaternet-templates,
}: let }: let
# At the moment, we only deploy to x86_64-linux. Update when we # At the moment, we only deploy to x86_64-linux. Update when we
# care about another platform. # care about another platform.
system = "x86_64-linux"; system = "x86_64-linux";
overlays = [ flakeOutputs = import ./nix/packages.nix {inherit nixpkgs dream2nix fenix system;};
rust-overlay.overlays.default
];
pkgs = import nixpkgs {inherit system overlays;};
# Rust build config
rust-toolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
naersk-lib = naersk.lib.${system}.override {
cargo = rust-toolchain;
rustc = rust-toolchain;
};
buildInputs = with pkgs; [
pkg-config
openssl
];
in { in {
packages.${system} = rec { packages.${system} = {
tlaternet-webserver = naersk-lib.buildPackage { server = flakeOutputs.server.packages.${system}.default;
inherit buildInputs; templates = flakeOutputs.templates.packages.${system}.default;
pname = "tlaternet-webserver";
root = pkgs.lib.cleanSource self;
};
default = tlaternet-webserver;
};
apps.${system} = {
run-with-templates = let
script = pkgs.writeShellScriptBin "run-with-templates" ''
RUST_LOG=info ${self.packages.${system}.tlaternet-webserver}/bin/tlaternet-webserver \
--template-directory ${tlaternet-templates.packages.${system}.default}
'';
in {
type = "app";
program = "${script}/bin/run-with-templates";
};
}; };
devShells.${system} = { devShells.${system} = {
default = pkgs.mkShell { templates = flakeOutputs.templates.devShells.${system}.default.overrideAttrs (old: {
packages = builtins.concatLists [ buildInputs = with nixpkgs.legacyPackages.${system};
buildInputs
[ [
(rust-toolchain.override { yj
extensions = ["rust-src" "rust-analysis" "rust-analyzer-preview"];
})
] ]
++ old.buildInputs;
shellHook =
''
# Update package.json
if [ -e ./package.json ]; then
unlink ./package.json
fi
cat ./package.yaml | yj > ./package.json
''
+ old.shellHook;
});
server = nixpkgs.legacyPackages.${system}.mkShell {
packages = [
(flakeOutputs.server.rust-toolchain.withComponents [
"rustc"
"cargo"
"rustfmt"
"rust-std"
"rust-docs"
"clippy"
"rust-src"
"rust-analysis"
])
fenix.packages.${system}.rust-analyzer
]; ];
}; };
}; };

94
nix/packages.nix Normal file
View file

@ -0,0 +1,94 @@
{
nixpkgs,
dream2nix,
fenix,
system,
}: {
server = let
rust-toolchain = fenix.packages.${system}.stable;
in
dream2nix.lib.makeFlakeOutputs {
systems = [system];
config.projectRoot = ../server;
source = ../server;
packageOverrides = {
"^.*".set-toolchain.overrideRustToolchain = old: {
cargo = rust-toolchain.minimalToolchain;
rustc = rust-toolchain.minimalToolchain;
};
};
}
// {
inherit rust-toolchain;
};
templates = let
inherit (nixpkgs.legacyPackages.${system}) runCommandLocal yj;
in
dream2nix.lib.makeFlakeOutputs {
systems = [system];
config.projectRoot = ../templates;
# Generate `package.json` from `package.yaml`, since the nodejs
# ecosystem doesn't support yaml.
source = runCommandLocal "templates" {nativeBuildInputs = [yj];} ''
cp -r ${../templates} $out/
chmod u+w $out
yj < ${../templates/package.yaml} > $out/package.json
'';
packageOverrides = {
tlaternet = {
add-build-script = {
# Dream2nix' built-in install script assumes this is just
# a usual npm package and will install it in
# `node_modules`.
#
# Parcel will detect this, and completely break all
# configuration. Furthermore, we don't actually want to
# install this as if it was an npm library.
#
# The easiest way to fix this is just to rename the
# top-level directory.
preBuild = ''
# Rename top-level directory so parcel doesn't think we're in a
# node_module
mv ../../node_modules ../../top-level
# Rewrite $PATH and co. to use the new path.
export PATH=''${PATH//lib\/node_modules\/.bin/lib\/top-level\/.bin}
export NODE_PATH=''${NODE_PATH//lib\/node_modules/lib\/top-level}
'';
installPhase = ''
# For some reason, dream2nix builds in the out directory. Don't ask
# me, I don't know either.
# First, go to a sane directory and back up our actual build output
mv dist /build/dist
cd /build
# Then, delete everything currently in $out
chmod -R u+rwx $out
rm -r $out
# Finally, actually install our output
mv dist $out
'';
};
};
sharp = {
add-libvips = {
buildInputs = old:
old
++ (with nixpkgs.legacyPackages.${system}; [
vips
pkg-config
]);
};
};
};
};
}

View file

@ -1,2 +0,0 @@
[toolchain]
channel = "nightly"

View file

59
templates/.eslintrc.yaml Normal file
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": "^_" }
# ]
# }
# }

5
templates/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.parcel-cache/
dist/
node_modules/
package.json
result

6
templates/.parcelrc Normal file
View file

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

36
templates/.posthtmlrc Normal file
View file

@ -0,0 +1,36 @@
{
"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,
},
},
},
},
}

11
templates/.prettierrc Normal file
View file

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

9539
templates/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

85
templates/package.yaml Normal file
View file

@ -0,0 +1,85 @@
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.1 # To manage CSS class names in react code
# Fonts
hack-font: ^3.3.0
'@fontsource/arimo': ^4.5.8
'@fontsource/nunito': ^4.5.9
'@fortawesome/fontawesome-free': ^6.1.1
# 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.0
'@reduxjs/toolkit': ^1.8.3
react-redux: ^8.0.2
devDependencies:
# Parcel & plugins
parcel: ^2.7.0
'@parcel/transformer-sass': ^2.7.0
'@parcel/transformer-glsl': ^2.7.0
# Build tools
typescript: ^4.7.4
sass: ^1.53.0
posthtml-extend: ^0.6.3
posthtml-favicons: ^1.4.0
posthtml-include: ^1.7.4
posthtml-markdownit: ^1.3.0
'@babel/preset-env': ^7.18.6
# Type shims
'@types/react-dom': ^18.0.6
'@types/react-redux': ^7.1.24
# Dev tools
npm-check-updates: ^16.0.5
prettier: ^2.7.1
typescript-language-server: ^0.11.2
typescript-eslint-language-service: ^5.0.0
eslint: ^8.21.0
'@typescript-eslint/parser': ^5.32.0
'@typescript-eslint/eslint-plugin': ^5.32.0
vscode-langservers-extracted: ^4.2.1
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'

21
templates/src/error.html Normal file
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>

78
templates/src/icon.svg Normal file
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

Width:  |  Height:  |  Size: 3.6 KiB

80
templates/src/index.html Normal file
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

Width:  |  Height:  |  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,46 @@
@use "~/node_modules/@fontsource/nunito/scss/mixins" as Nunito;
@use "~/node_modules/@fontsource/arimo/scss/mixins" as Arimo;
$weights: 300, 400, 500, 600, 700;
@each $weight in $weights {
@include Nunito.fontFace(
$weight: $weight,
$display: auto,
$style: normal,
$fontDir: "npm:@fontsource/nunito/files"
);
@include Nunito.fontFace(
$weight: $weight,
$display: auto,
$style: italic,
$fontDir: "npm:@fontsource/nunito/files"
);
}
@include Arimo.fontFace(
$weight: 400,
$display: auto,
$style: normal,
$fontDir: "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,134 @@
@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) {
// 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);
}
}

72
templates/src/mail.html Normal file
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,342 @@
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());
}
this.canvas.width = width;
this.canvas.height = height;
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.025,
-((this.analyser.frequencyBinCount / gl.canvas.clientWidth) * 3),
]);
mat4.rotateX(modelViewMatrix, modelViewMatrix, Math.PI / 16);
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,139 @@
import React, { useCallback, useState } from "react";
import { Renderer, RendererError } from "./Renderer";
import { ShaderError } from "./Shader";
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="is-flex-grow-1 is-clipped is-relative"
>
<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>

16
templates/tsconfig.json Normal file
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"
}
]
}
}