Rework website with bulma instead of bootstrap #6
|
@ -6,6 +6,7 @@
|
||||||
(pug-mode
|
(pug-mode
|
||||||
(tab-width . 2))
|
(tab-width . 2))
|
||||||
(scss-mode
|
(scss-mode
|
||||||
(css-indent-offset . 2)))
|
(css-indent-offset . 2))
|
||||||
|
(auto-mode-alist . (("update-lockfile" . sh-mode))))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,40 +0,0 @@
|
||||||
{
|
|
||||||
"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": "^_" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
59
.eslintrc.yaml
Normal file
59
.eslintrc.yaml
Normal 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": "^_" }
|
||||||
|
# ]
|
||||||
|
# }
|
||||||
|
# }
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,3 +3,4 @@
|
||||||
/dist/
|
/dist/
|
||||||
/node_modules
|
/node_modules
|
||||||
/result
|
/result
|
||||||
|
/package.json
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
},
|
},
|
||||||
"posthtml-favicons": {
|
"posthtml-favicons": {
|
||||||
"root": "src",
|
"root": "src",
|
||||||
"outDir": "./dist/browser/",
|
"outDir": "./dist/",
|
||||||
"configuration": {
|
"configuration": {
|
||||||
"appName": "tlater.net",
|
"appName": "tlater.net",
|
||||||
"appShortName": "tlater.net",
|
"appShortName": "tlater.net",
|
||||||
|
|
11
.prettierrc
Normal file
11
.prettierrc
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
# -*- yaml -*-
|
||||||
|
|
||||||
|
tabWidth: 4
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
- files: "**/*.html"
|
||||||
|
options:
|
||||||
|
tabWidth: 2
|
||||||
|
- files: "**/*.scss"
|
||||||
|
options:
|
||||||
|
tabWidth: 2
|
30
README.md
30
README.md
|
@ -4,29 +4,15 @@ Website templates bundled with parcel.
|
||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
Firstly, updating the npm dependencies needs:
|
When adding/removing packages from `package.yaml`, the corresponding
|
||||||
|
`package.json` and `package-lock.json` need to be updated. Use:
|
||||||
|
|
||||||
```sh
|
```console
|
||||||
npm update --package-lock-only
|
nix run .#update-lockfile
|
||||||
```
|
```
|
||||||
|
|
||||||
After that, the NixOS derivations can be updated by running
|
When ignoring semver is desired, use:
|
||||||
`generate.sh` in the `nix` subdirectory.
|
|
||||||
|
|
||||||
### Note
|
```console
|
||||||
|
nix run .#update-lockfile -- --ignore-semver
|
||||||
[Sharp](https://github.com/lovell/sharp/releases) is gloriously hard
|
```
|
||||||
to install, because it depends on the most cutting-edge patch version
|
|
||||||
of `vips`.
|
|
||||||
|
|
||||||
Check which version it needs in the `package.json` of sharp, and then
|
|
||||||
match this up which major sharp version matches up to which OS version
|
|
||||||
of vips.
|
|
||||||
|
|
||||||
Known versions:
|
|
||||||
|
|
||||||
| Sharp | vips | NixOS |
|
|
||||||
| ----: | ---------: | ----: |
|
|
||||||
| 28.* | 8.10.6 | |
|
|
||||||
| 27.* | 8.10.5 | 21.05 |
|
|
||||||
| 26.* | 8.10.0 | 20.09 |
|
|
||||||
|
|
23
flake.lock
23
flake.lock
|
@ -1,26 +1,27 @@
|
||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"flake-utils": {
|
"nix-filter": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1653893745,
|
"lastModified": 1653590866,
|
||||||
"narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=",
|
"narHash": "sha256-E4yKIrt/S//WfW5D9IhQ1dVuaAy8RE7EiCMfnbrOC78=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "nix-filter",
|
||||||
"rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1",
|
"rev": "3e81a637cdf9f6e9b39aeb4d6e6394d1ad158e16",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"id": "flake-utils",
|
"owner": "numtide",
|
||||||
"type": "indirect"
|
"repo": "nix-filter",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1655200170,
|
"lastModified": 1659052185,
|
||||||
"narHash": "sha256-/yzkX+10sJhYNIcTtZ5ObS+nh/HrJp01XLaubzbRDcU=",
|
"narHash": "sha256-TUbwbzCbprtWB9EtXPM52cWuKETuCV3H+cMXjLRbwTw=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "9ff91ce2e4c5d70551d4c8fd8830931c6c6b26b8",
|
"rev": "9370544d849be8a07193e7611d02e6f6f1b10768",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
@ -48,7 +49,7 @@
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"nix-filter": "nix-filter",
|
||||||
"nixpkgs": "nixpkgs",
|
"nixpkgs": "nixpkgs",
|
||||||
"npmlock2nix": "npmlock2nix"
|
"npmlock2nix": "npmlock2nix"
|
||||||
}
|
}
|
||||||
|
|
46
flake.nix
46
flake.nix
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-22.05";
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-22.05";
|
||||||
|
nix-filter.url = "github:numtide/nix-filter";
|
||||||
npmlock2nix = {
|
npmlock2nix = {
|
||||||
url = "github:nix-community/npmlock2nix";
|
url = "github:nix-community/npmlock2nix";
|
||||||
flake = false;
|
flake = false;
|
||||||
|
@ -12,7 +13,7 @@
|
||||||
outputs = {
|
outputs = {
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
flake-utils,
|
nix-filter,
|
||||||
npmlock2nix,
|
npmlock2nix,
|
||||||
}: 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
|
||||||
|
@ -24,44 +25,25 @@
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
pkgs = import nixpkgs {inherit system overlays;};
|
pkgs = import nixpkgs {inherit system overlays;};
|
||||||
|
package = import ./nix/package.nix {
|
||||||
|
inherit self pkgs;
|
||||||
|
nix-filter = nix-filter.lib;
|
||||||
|
};
|
||||||
in {
|
in {
|
||||||
|
apps.${system} = import ./nix/utilities {inherit pkgs;};
|
||||||
|
|
||||||
packages.${system} = rec {
|
packages.${system} = rec {
|
||||||
tlaternet-templates = pkgs.npmlock2nix.build {
|
tlaternet-templates = package.package;
|
||||||
src = self;
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
cp -r dist $out/
|
|
||||||
'';
|
|
||||||
|
|
||||||
postFixup = ''
|
|
||||||
${pkgs.rename}/bin/rename 's/.html$/.html.hbs/' $out/browser/*.html
|
|
||||||
'';
|
|
||||||
|
|
||||||
node_modules_attrs = {
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
pkg-config
|
|
||||||
python3
|
|
||||||
vips
|
|
||||||
glib
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
default = tlaternet-templates;
|
default = tlaternet-templates;
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.${system} = {
|
devShells.${system} = {
|
||||||
default = pkgs.npmlock2nix.shell {
|
default = package.shell;
|
||||||
src = self;
|
|
||||||
node_modules_attrs = {
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
pkg-config
|
|
||||||
python3
|
|
||||||
vips
|
|
||||||
glib
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
checks.${system} = import ./nix/checks.nix {
|
||||||
|
inherit self pkgs;
|
||||||
|
nix-filter = nix-filter.lib;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
67
nix/checks.nix
Normal file
67
nix/checks.nix
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
{
|
||||||
|
self,
|
||||||
|
nix-filter,
|
||||||
|
pkgs,
|
||||||
|
}: let
|
||||||
|
inherit (builtins) removeAttrs;
|
||||||
|
inherit (pkgs.lib) concatStringsSep;
|
||||||
|
|
||||||
|
mkNodeCheck = {
|
||||||
|
buildInputs ? [],
|
||||||
|
checkCommands,
|
||||||
|
...
|
||||||
|
} @ attrs: let
|
||||||
|
extraAttrs = removeAttrs attrs ["buildInputs"];
|
||||||
|
in
|
||||||
|
self.packages.${pkgs.system}.default.overrideAttrs (old:
|
||||||
|
{
|
||||||
|
src = nix-filter {
|
||||||
|
root = self;
|
||||||
|
|
||||||
|
include = [
|
||||||
|
../package.json
|
||||||
|
../tsconfig.json
|
||||||
|
../.eslintrc.yaml
|
||||||
|
../.parcelrc
|
||||||
|
../.posthtmlrc
|
||||||
|
../.prettierrc
|
||||||
|
|
||||||
|
nix-filter.isDirectory
|
||||||
|
(nix-filter.matchExt "ts")
|
||||||
|
(nix-filter.matchExt "tsx")
|
||||||
|
(nix-filter.matchExt "html")
|
||||||
|
(nix-filter.matchExt "scss")
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
buildInputs = old.buildInputs ++ buildInputs;
|
||||||
|
|
||||||
|
checkPhase = ''
|
||||||
|
mkdir -p $out
|
||||||
|
${concatStringsSep "\n" (map (command: "${command} | tee $out/check.log") checkCommands)}
|
||||||
|
'';
|
||||||
|
|
||||||
|
doCheck = true;
|
||||||
|
dontBuild = true;
|
||||||
|
dontInstall = true;
|
||||||
|
}
|
||||||
|
// extraAttrs);
|
||||||
|
in {
|
||||||
|
style = mkNodeCheck {
|
||||||
|
checkCommands = [
|
||||||
|
"npm run style"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
types = mkNodeCheck {
|
||||||
|
checkCommands = [
|
||||||
|
"npm run check"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
lints = mkNodeCheck {
|
||||||
|
checkCommands = [
|
||||||
|
"npm run lint"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
74
nix/package.nix
Normal file
74
nix/package.nix
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
{
|
||||||
|
self,
|
||||||
|
nix-filter,
|
||||||
|
pkgs,
|
||||||
|
}: let
|
||||||
|
inherit (pkgs.lib) cleanSource;
|
||||||
|
|
||||||
|
packageJson =
|
||||||
|
pkgs.runCommand "package.json" {
|
||||||
|
nativeBuildInputs = with pkgs; [yj];
|
||||||
|
src = "";
|
||||||
|
} ''
|
||||||
|
cat ${self}/package.yaml | yj > $out
|
||||||
|
'';
|
||||||
|
|
||||||
|
prePatch = ''
|
||||||
|
ln -s ${packageJson} package.json;
|
||||||
|
'';
|
||||||
|
|
||||||
|
node_modules_attrs = {
|
||||||
|
packageJson = "${packageJson}";
|
||||||
|
|
||||||
|
# Dependencies that should be available in the node build
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
pkg-config
|
||||||
|
python3
|
||||||
|
vips
|
||||||
|
glib
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Dependencies that should be available outside of the node build
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
util-linux
|
||||||
|
];
|
||||||
|
in {
|
||||||
|
package = pkgs.npmlock2nix.build {
|
||||||
|
inherit buildInputs prePatch node_modules_attrs;
|
||||||
|
|
||||||
|
src = cleanSource self;
|
||||||
|
|
||||||
|
buildCommands = ["npm run build-dist"];
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
cp -r dist $out/
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
shell = pkgs.npmlock2nix.shell {
|
||||||
|
inherit prePatch node_modules_attrs;
|
||||||
|
|
||||||
|
buildInputs =
|
||||||
|
buildInputs
|
||||||
|
++ (with pkgs; [
|
||||||
|
clang-tools
|
||||||
|
]);
|
||||||
|
|
||||||
|
src = nix-filter {
|
||||||
|
root = self;
|
||||||
|
include = [
|
||||||
|
"package.yaml"
|
||||||
|
"package-lock.json"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
if [ -e package.json ]; then
|
||||||
|
unlink package.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -s ${packageJson} package.json
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
20
nix/utilities/default.nix
Normal file
20
nix/utilities/default.nix
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{pkgs}: let
|
||||||
|
inherit (builtins) readFile;
|
||||||
|
|
||||||
|
update-lockfile = pkgs.writeShellApplication {
|
||||||
|
name = "update-lockfile";
|
||||||
|
runtimeInputs = with pkgs; [
|
||||||
|
git
|
||||||
|
yj
|
||||||
|
nodejs-14_x
|
||||||
|
direnv
|
||||||
|
nodePackages.npm-check-updates
|
||||||
|
];
|
||||||
|
text = readFile ./update-lockfile;
|
||||||
|
};
|
||||||
|
in {
|
||||||
|
update-lockfile = {
|
||||||
|
type = "app";
|
||||||
|
program = "${update-lockfile}/bin/update-lockfile";
|
||||||
|
};
|
||||||
|
}
|
25
nix/utilities/update-lockfile
Normal file
25
nix/utilities/update-lockfile
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
if [ -L node_modules ]; then
|
||||||
|
unlink node_modules
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -L package.json ]; then
|
||||||
|
unlink package.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
yj < package.yaml > package.json
|
||||||
|
|
||||||
|
if [ "${1}" == "--ignore-semver" ]; then
|
||||||
|
npm-check-updates -u
|
||||||
|
fi
|
||||||
|
|
||||||
|
npm install --package-lock-only
|
||||||
|
rm -rf node_modules
|
||||||
|
rm package.json
|
||||||
|
|
||||||
|
direnv reload
|
8898
package-lock.json
generated
8898
package-lock.json
generated
File diff suppressed because it is too large
Load diff
61
package.json
61
package.json
|
@ -1,61 +0,0 @@
|
||||||
{
|
|
||||||
"name": "tlaternet",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "tlaternet web interface",
|
|
||||||
"author": "Tristan Maat <tm@tlater.net>",
|
|
||||||
"license": "MIT",
|
|
||||||
"private": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@fortawesome/fontawesome-free": "^5.13.1",
|
|
||||||
"bootstrap": "^4.5.0",
|
|
||||||
"classnames": "^2.2.6",
|
|
||||||
"immutability-helper": "^3.1.1",
|
|
||||||
"jquery": "^3.5.1",
|
|
||||||
"popper.js": "^1.16.1",
|
|
||||||
"react": "^16.13.1",
|
|
||||||
"react-dom": "^16.13.1",
|
|
||||||
"react-redux": "^7.2.0",
|
|
||||||
"redux": "^4.0.5",
|
|
||||||
"redux-act": "^1.8.0",
|
|
||||||
"three": "^0.127.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@babel/preset-env": "^7.18.2",
|
|
||||||
"@babel/preset-typescript": "^7.17.12",
|
|
||||||
"@parcel/transformer-sass": "^2.6.1",
|
|
||||||
"@parcel/validator-typescript": "^2.6.1",
|
|
||||||
"@types/jquery": "^3.5.14",
|
|
||||||
"@types/react": "^16.14.28",
|
|
||||||
"@types/react-dom": "^17.0.17",
|
|
||||||
"@types/react-redux": "^7.1.24",
|
|
||||||
"@types/three": "^0.127.1",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
|
||||||
"@typescript-eslint/parser": "^4.33.0",
|
|
||||||
"eslint": "^7.32.0",
|
|
||||||
"parcel": "^2.6.1",
|
|
||||||
"posthtml-extend": "^0.6.3",
|
|
||||||
"posthtml-favicons": "^1.3.0",
|
|
||||||
"posthtml-include": "^1.7.4",
|
|
||||||
"posthtml-markdownit": "^1.1.0",
|
|
||||||
"sass": "^1.52.3",
|
|
||||||
"typescript": "^4.7.4",
|
|
||||||
"typescript-language-server": "^0.4.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "parcel build --no-autoinstall src/index.html",
|
|
||||||
"serve": "parcel serve --no-autoinstall src/index.html",
|
|
||||||
"watch": "parcel watch --no-autoinstall src/index.html",
|
|
||||||
"build-dist": "parcel build --no-cache --no-autoinstall src/index.html; rename '.html' '.html.hbs' dist/browser/*.html"
|
|
||||||
},
|
|
||||||
"targets": {
|
|
||||||
"browser": {
|
|
||||||
"engines": {
|
|
||||||
"browsers": [
|
|
||||||
">1%",
|
|
||||||
"not dead"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"distDir": "dist"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
82
package.yaml
Normal file
82
package.yaml
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
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.6.2
|
||||||
|
'@parcel/transformer-sass': ^2.6.2
|
||||||
|
'@parcel/transformer-glsl': 2.6.2
|
||||||
|
|
||||||
|
# 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 && rename '.html' '.html.hbs' dist/*.html
|
||||||
|
|
||||||
|
# Checks
|
||||||
|
check: tsc --noEmit
|
||||||
|
style: prettier --check src
|
||||||
|
lint: eslint --max-warnings=0 --format unix src
|
||||||
|
|
||||||
|
# Parcel config
|
||||||
|
source: src/index.html
|
||||||
|
browserslist: '> 1%, not dead'
|
|
@ -1,26 +1,19 @@
|
||||||
<extends src="./lib/html/base.html">
|
<extends src="./lib/html/base.html">
|
||||||
<block name="stylesheets">
|
|
||||||
<style>
|
|
||||||
.no-js .head-line .typed {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
.head-line .typed {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</block>
|
|
||||||
|
|
||||||
<block name="content">
|
<block name="content">
|
||||||
<h1 class="head-line">
|
<section class="section">
|
||||||
$ <span class="typed">Welcome to tlater.net!</span>
|
<div class="container">
|
||||||
|
<h1 class="title has-text-weight-normal is-family-monospace">
|
||||||
|
$ <span id="typed-welcome"></span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<div class="row">
|
<div class="columns">
|
||||||
<div class="col-md-6">
|
<div class="column content">
|
||||||
|
<!-- prettier-ignore -->
|
||||||
<markdown>
|
<markdown>
|
||||||
#### About Me
|
|
||||||
|
### About Me
|
||||||
|
|
||||||
Looks like you found my website. I suppose introductions are
|
Looks like you found my website. I suppose introductions are
|
||||||
in order.
|
in order.
|
||||||
|
@ -33,25 +26,22 @@
|
||||||
If not, well, this is also a great place to "meet" me. Have a
|
If not, well, this is also a great place to "meet" me. Have a
|
||||||
nosey!
|
nosey!
|
||||||
|
|
||||||
#### This Website
|
### This Website
|
||||||
|
|
||||||
There is not a whole lot here at the moment.
|
There is not a whole lot here at the moment.
|
||||||
|
|
||||||
You may find the following interesting though:
|
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>
|
</markdown>
|
||||||
<!-- Parcel isn't smart enough to pick up cross-page links if they're in markdown blocks -->
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
A <a href="~/src/music_sample.html">little web app</a> showing off
|
|
||||||
what WebGL can do in combination with the JavaScript Audio
|
|
||||||
interface.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="column content">
|
||||||
|
<!-- prettier-ignore -->
|
||||||
<markdown>
|
<markdown>
|
||||||
#### My Work
|
### My Work
|
||||||
|
|
||||||
I'm a software consultant working for
|
I'm a software consultant working for
|
||||||
[Codethink](https://www.codethink.co.uk) in Manchester,
|
[Codethink](https://www.codethink.co.uk) in Manchester,
|
||||||
|
@ -84,9 +74,7 @@
|
||||||
</markdown>
|
</markdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</block>
|
</div>
|
||||||
|
</section>
|
||||||
<block name="footer">
|
|
||||||
<script type="module" src="./index.ts" defer></script>
|
|
||||||
</block>
|
</block>
|
||||||
</extends>
|
</extends>
|
||||||
|
|
119
src/index.ts
119
src/index.ts
|
@ -1,119 +0,0 @@
|
||||||
import jQuery from "jquery";
|
|
||||||
|
|
||||||
// Helpers
|
|
||||||
|
|
||||||
/**
|
|
||||||
* "Types" out a DOM element, emulating the way a human might.
|
|
||||||
*/
|
|
||||||
class Typer {
|
|
||||||
private element: JQuery;
|
|
||||||
private text: string;
|
|
||||||
private cursor: boolean;
|
|
||||||
private typed: number;
|
|
||||||
private min: number;
|
|
||||||
private max: number;
|
|
||||||
private blink_tick: number;
|
|
||||||
private blink_timeout: number;
|
|
||||||
private end?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the typer.
|
|
||||||
* @param {HTMLElement} element - The element to type.
|
|
||||||
* @param {number} blink - The time between cursor blinks.
|
|
||||||
* @param {number} blink_timeout - How long the cursor should keep
|
|
||||||
* blinking for after the text
|
|
||||||
* finishes typing.
|
|
||||||
*/
|
|
||||||
constructor(element: JQuery<HTMLElement>, blink: number, blink_timeout: number) {
|
|
||||||
// Retrieve the current content and wipe it. We also make the
|
|
||||||
// element visible if it was hidden.
|
|
||||||
this.element = element;
|
|
||||||
this.text = this.element.html();
|
|
||||||
this.element.html("");
|
|
||||||
this.element.css("visibility", "visible");
|
|
||||||
|
|
||||||
this.cursor = false;
|
|
||||||
this.typed = 0;
|
|
||||||
|
|
||||||
this.min = 20;
|
|
||||||
this.max = 70;
|
|
||||||
this.blink_tick = blink;
|
|
||||||
this.blink_timeout = blink_timeout;
|
|
||||||
|
|
||||||
this.end = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start typing.
|
|
||||||
*/
|
|
||||||
type() {
|
|
||||||
this._type();
|
|
||||||
this._blink();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw the current text line, i.e., anything that has been typed
|
|
||||||
* so far, and a cursor if it is currently supposed to be on.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_draw() {
|
|
||||||
let text = this.text.slice(0, this.typed);
|
|
||||||
|
|
||||||
if (this.cursor) {
|
|
||||||
text += "\u2588";
|
|
||||||
}
|
|
||||||
|
|
||||||
window.requestAnimationFrame(() => this.element.html(text));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Type the next character, and prepare to draw the next one. If
|
|
||||||
* no new characters are to be drawn, set the end timestamp.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_type() {
|
|
||||||
this.typed += 1;
|
|
||||||
this._draw();
|
|
||||||
|
|
||||||
if (this.typed != this.text.length)
|
|
||||||
setTimeout(this._type.bind(this), this._type_tick());
|
|
||||||
else {
|
|
||||||
this.end = Date.now();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make the cursor change blink status, and prepare for the next
|
|
||||||
* blink.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_blink() {
|
|
||||||
this.cursor = !this.cursor;
|
|
||||||
this._draw();
|
|
||||||
|
|
||||||
// As long as we are typing, keep blinking
|
|
||||||
if (this.typed != this.text.length)
|
|
||||||
setTimeout(this._blink.bind(this), this.blink_tick);
|
|
||||||
// Once typing ends, keep going for a little bit
|
|
||||||
else if (Date.now() - this.end < this.blink_timeout)
|
|
||||||
setTimeout(this._blink.bind(this), this.blink_tick);
|
|
||||||
// Make sure we get rid of the cursor in the end
|
|
||||||
else {
|
|
||||||
this.cursor = true;
|
|
||||||
setTimeout(this._blink.bind(this), this.blink_tick);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate a "human" time for the next character to type.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
_type_tick() {
|
|
||||||
return Math.round(Math.random() * this.max) + this.min;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jQuery(($) => {
|
|
||||||
const typer = new Typer($(".head-line .typed").first(), 500, 3000);
|
|
||||||
typer.type();
|
|
||||||
});
|
|
|
@ -1,10 +1,10 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html class="no-js" lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="description" content="tlater.net web server" />
|
<meta name="description" content="tlater.net web server" />
|
||||||
<meta name="author" contnet="Tristan Daniël Maat" />
|
<meta name="author" contnet="Tristan Daniël Maat" />
|
||||||
<meta name="viewport" content="width=device-width initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" href="./icon.svg" type="image/x-icon" />
|
<link rel="icon" href="./icon.svg" type="image/x-icon" />
|
||||||
<link rel="stylesheet" href="~/src/lib/scss/main.scss" />
|
<link rel="stylesheet" href="~/src/lib/scss/main.scss" />
|
||||||
|
|
||||||
|
@ -13,28 +13,17 @@
|
||||||
<title>tlater.net</title>
|
<title>tlater.net</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="d-flex flex-column">
|
<body>
|
||||||
<block name="navigation">
|
<block name="navigation">
|
||||||
<include src="lib/html/navigation.html"></include>
|
<include src="lib/html/navigation.html"></include>
|
||||||
</block>
|
</block>
|
||||||
|
|
||||||
<div class="container floating-container">
|
|
||||||
<include src="lib/html/message-flash.html"></include>
|
<include src="lib/html/message-flash.html"></include>
|
||||||
|
|
||||||
<block name="content"></block>
|
<block name="content"></block>
|
||||||
</div>
|
|
||||||
|
|
||||||
<script
|
<block name="footer">
|
||||||
type="text/javascript"
|
<script type="module" src="lib/js/index.ts"></script>
|
||||||
src="~/node_modules/jquery/dist/jquery.min.js"
|
</block>
|
||||||
defer
|
|
||||||
></script>
|
|
||||||
<script
|
|
||||||
type="module"
|
|
||||||
src="~/node_modules/bootstrap/dist/js/bootstrap.min.js"
|
|
||||||
defer
|
|
||||||
></script>
|
|
||||||
<script type="module" src="~/src/lib/js/main.ts" defer></script>
|
|
||||||
|
|
||||||
<block name="footer"></block>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
|
<span>
|
||||||
{{#if flash}}
|
{{#if flash}}
|
||||||
<div
|
<div class="notification is-{{flash.type}}">
|
||||||
class="alert alert-{{flash.type}} alert-dismissible fade show"
|
<button class="delete" aria-label="Close"></button>
|
||||||
role="alert"
|
<span role="alert"> {{ flash.message }} </span>
|
||||||
>
|
|
||||||
{{ flash.message }}
|
|
||||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
</span>
|
||||||
|
|
|
@ -1,31 +1,23 @@
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
|
||||||
<div class="container">
|
<div class="navbar-brand">
|
||||||
<a class="navbar-brand" href="/">tlater</a>
|
<a class="navbar-item has-text-primary is-uppercase" href="/">tlater</a>
|
||||||
|
<a
|
||||||
<button
|
class="navbar-burger"
|
||||||
class="navbar-toggler"
|
role="button"
|
||||||
type="button"
|
aria-label="menu"
|
||||||
data-toggle="collapse"
|
|
||||||
data-target="#navbar"
|
|
||||||
aria-controls="#navbar"
|
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-label="Toggle navigation"
|
data-target="main-navigation"
|
||||||
>
|
>
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span aria-hidden="true"></span>
|
||||||
</button>
|
<span aria-hidden="true"></span>
|
||||||
|
<span aria-hidden="true"></span>
|
||||||
<div id="navbar" class="navbar-collapse collapse">
|
</a>
|
||||||
<ul class="navbar-nav mr-auto">
|
</div>
|
||||||
<li class="nav-item">
|
<div id="main-navigation" class="navbar-menu">
|
||||||
<a class="nav-link" href="~/src/mail.html">E-Mail</a>
|
<div class="navbar-start">
|
||||||
</li>
|
<a class="navbar-item" href="~/src/mail.html"> E-Mail </a>
|
||||||
<li class="nav-item">
|
<a class="navbar-item" href="https://www.gitlab.com/tlater"> GitLab </a>
|
||||||
<a class="nav-link" href="https://www.gitlab.com/tlater">GitLab</a>
|
<a class="navbar-item" href="https://www.github.com/TLATER"> GitHub </a>
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="https://www.github.com/TLATER">GitHub</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
67
src/lib/js/index.ts
Normal file
67
src/lib/js/index.ts
Normal 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 {};
|
|
@ -1,3 +0,0 @@
|
||||||
import jQuery from "jquery";
|
|
||||||
|
|
||||||
jQuery(($) => $("html").removeClass("no-js"));
|
|
|
@ -1,99 +0,0 @@
|
||||||
@import "~/node_modules/bootstrap/scss/_functions";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_variables";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_mixins";
|
|
||||||
|
|
||||||
// Theme colors
|
|
||||||
$green: #2aa889;
|
|
||||||
$cyan: #99d1ce;
|
|
||||||
|
|
||||||
$dark: #11151c;
|
|
||||||
|
|
||||||
$colors: (
|
|
||||||
"green": $green,
|
|
||||||
"cyan": $cyan
|
|
||||||
);
|
|
||||||
|
|
||||||
$theme-colors: (
|
|
||||||
"primary": $primary,
|
|
||||||
"secondary": $secondary,
|
|
||||||
"success": $green,
|
|
||||||
"info": $cyan,
|
|
||||||
"warning": $warning,
|
|
||||||
"danger": $danger,
|
|
||||||
"light": $light,
|
|
||||||
"dark": $dark
|
|
||||||
);
|
|
||||||
|
|
||||||
// Site colors
|
|
||||||
$body-bg: #0f0f0f;
|
|
||||||
$body-color: #dddddd;
|
|
||||||
|
|
||||||
// Headers
|
|
||||||
$headings-font-family: "Nunito", $font-family-base;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: $cyan !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Links
|
|
||||||
$link-hover-color: $green;
|
|
||||||
a:visited {
|
|
||||||
color: $green;
|
|
||||||
}
|
|
||||||
|
|
||||||
// hr
|
|
||||||
$hr-border-color: #dddddd;
|
|
||||||
|
|
||||||
// Navbar
|
|
||||||
$navbar-dark-color: rgba($white, .75);
|
|
||||||
$navbar-dark-hover-color: rgba($white, 90);
|
|
||||||
|
|
||||||
.navbar-dark {
|
|
||||||
border: 1px solid #080808;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-dark .navbar-brand {
|
|
||||||
color: $cyan !important;
|
|
||||||
text-transform: uppercase;
|
|
||||||
font-family: $headings-font-family;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $white !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional
|
|
||||||
@import "~/node_modules/bootstrap/scss/_root";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_reboot";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_type";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_images";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_code";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_grid";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_tables";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_forms";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_buttons";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_transitions";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_dropdown";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_button-group";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_input-group";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_custom-forms";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_nav";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_navbar";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_card";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_breadcrumb";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_pagination";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_badge";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_jumbotron";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_alert";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_progress";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_media";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_list-group";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_close";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_toasts";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_modal";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_tooltip";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_popover";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_carousel";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_spinners";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_utilities";
|
|
||||||
@import "~/node_modules/bootstrap/scss/_print";
|
|
45
src/lib/scss/_custom-bulma.scss
Normal file
45
src/lib/scss/_custom-bulma.scss
Normal 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";
|
|
@ -1,27 +1,46 @@
|
||||||
/* nunito-italic - latin */
|
@use "~/node_modules/@fontsource/nunito/scss/mixins" as Nunito;
|
||||||
@font-face {
|
@use "~/node_modules/@fontsource/arimo/scss/mixins" as Arimo;
|
||||||
font-family: 'Nunito';
|
|
||||||
font-style: italic;
|
$weights: 300, 400, 500, 600, 700;
|
||||||
font-weight: 400;
|
|
||||||
src: url('~/src/lib/fonts/nunito-v9-latin-italic.eot'); /* IE9 Compat Modes */
|
@each $weight in $weights {
|
||||||
src: local('Nunito Italic'), local('Nunito-Italic'),
|
@include Nunito.fontFace(
|
||||||
url('~/src/lib/fonts/nunito-v9-latin-italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
$weight: $weight,
|
||||||
url('~/src/lib/fonts/nunito-v9-latin-italic.woff2') format('woff2'), /* Super Modern Browsers */
|
$display: auto,
|
||||||
url('~/src/lib/fonts/nunito-v9-latin-italic.woff') format('woff'), /* Modern Browsers */
|
$style: normal,
|
||||||
url('~/src/lib/fonts/nunito-v9-latin-italic.ttf') format('truetype'), /* Safari, Android, iOS */
|
$fontDir: "npm:@fontsource/nunito/files"
|
||||||
url('~/src/lib/fonts/nunito-v9-latin-italic.svg#Nunito') format('svg'); /* Legacy iOS */
|
);
|
||||||
|
|
||||||
|
@include Nunito.fontFace(
|
||||||
|
$weight: $weight,
|
||||||
|
$display: auto,
|
||||||
|
$style: italic,
|
||||||
|
$fontDir: "npm:@fontsource/nunito/files"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* nunito-regular - latin */
|
@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-face {
|
||||||
font-family: 'Nunito';
|
font-family: "Hack";
|
||||||
font-style: normal;
|
src: url("npm:hack-font/build/web/fonts/hack-#{$name}-subset.woff2?sha=3114f1256")
|
||||||
font-weight: 400;
|
format("woff2"),
|
||||||
src: url('~/src/lib/fonts/nunito-v9-latin-regular.eot'); /* IE9 Compat Modes */
|
url("npm:hack-font/build/web/fonts/hack-#{$name}-subset.woff?sha=3114f1256")
|
||||||
src: local('Nunito Regular'), local('Nunito-Regular'),
|
format("woff");
|
||||||
url('~/src/lib/fonts/nunito-v9-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
font-weight: $weight;
|
||||||
url('~/src/lib/fonts/nunito-v9-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
|
font-style: $style;
|
||||||
url('~/src/lib/fonts/nunito-v9-latin-regular.woff') format('woff'), /* Modern Browsers */
|
}
|
||||||
url('~/src/lib/fonts/nunito-v9-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */
|
|
||||||
url('~/src/lib/fonts/nunito-v9-latin-regular.svg#Nunito') format('svg'); /* Legacy iOS */
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
.head-line {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-line {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
21
src/lib/scss/_navbar.scss
Normal file
21
src/lib/scss/_navbar.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
134
src/lib/scss/_typed.scss
Normal file
134
src/lib/scss/_typed.scss
Normal 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;
|
||||||
|
}
|
|
@ -1,8 +1,18 @@
|
||||||
@import 'custom-bootstrap';
|
@import "./_custom-bulma";
|
||||||
/* @import 'fonts'; */
|
@import "./_typed";
|
||||||
@import 'headings';
|
|
||||||
|
|
||||||
html, body {
|
body {
|
||||||
height: 100%;
|
display: flex;
|
||||||
width: 100%;
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#typed-welcome {
|
||||||
|
&::before {
|
||||||
|
@include typed("Welcome to tlater.net!", 1.2s);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
@include cursor(6s);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,17 @@
|
||||||
<extends src="./lib/html/base.html">
|
<extends src="./lib/html/base.html">
|
||||||
<block name="content">
|
<block name="content">
|
||||||
<h1 class="head-line">Contact Me</h1>
|
<section class="section">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title has-text-weight-normal">Contact Me</h1>
|
||||||
|
|
||||||
<div class="row">
|
<div class="columns">
|
||||||
<div class="col-md-6">
|
<div class="column">
|
||||||
<form id="sendmail" role="form" action="mail.html" method="post">
|
<form id="sendmail" role="form" action="mail.html" method="post">
|
||||||
<div class="form-group">
|
<div class="field">
|
||||||
<label div="control-label" for="mail">Email address</label>
|
<label class="label" for="mail">Email address</label>
|
||||||
<input
|
<input
|
||||||
id="mail"
|
id="mail"
|
||||||
class="form-control"
|
class="input"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Your address"
|
placeholder="Your address"
|
||||||
name="mail"
|
name="mail"
|
||||||
|
@ -17,11 +19,11 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="field">
|
||||||
<label div="control-label" for="subject">Subject</label>
|
<label class="label" for="subject">Subject</label>
|
||||||
<input
|
<input
|
||||||
id="subject"
|
id="subject"
|
||||||
class="form-control"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="E.g. There's a typo on your home page!"
|
placeholder="E.g. There's a typo on your home page!"
|
||||||
name="subject"
|
name="subject"
|
||||||
|
@ -30,11 +32,11 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="field">
|
||||||
<label div="control-label" for="message">Message</label>
|
<label class="label" for="message">Message</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
class="form-control"
|
class="textarea"
|
||||||
type="text"
|
type="text"
|
||||||
rows="6"
|
rows="6"
|
||||||
name="message"
|
name="message"
|
||||||
|
@ -43,13 +45,16 @@
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary" type="submit" form="sendmail">
|
<div class="field">
|
||||||
Send
|
<div class="control">
|
||||||
</button>
|
<button class="button is-link">Send</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="column content">
|
||||||
|
<!-- prettier-ignore -->
|
||||||
<markdown>
|
<markdown>
|
||||||
Any messages you enter here are directly forwarded to me. I aim to
|
Any messages you enter here are directly forwarded to me. I aim to
|
||||||
respond within a day.
|
respond within a day.
|
||||||
|
@ -61,5 +66,7 @@
|
||||||
</markdown>
|
</markdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</block>
|
</block>
|
||||||
</extends>
|
</extends>
|
||||||
|
|
|
@ -1,94 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { connect } from "react-redux";
|
|
||||||
|
|
||||||
import Controls from "./components/controls";
|
|
||||||
import Visualizer from "./components/visualizer";
|
|
||||||
import { State } from "./store";
|
|
||||||
|
|
||||||
type AudioState = {
|
|
||||||
audioContext: AudioContext;
|
|
||||||
audioSource: HTMLAudioElement;
|
|
||||||
audioSourceNode: MediaElementAudioSourceNode;
|
|
||||||
audioVolume: GainNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MusicPlayerProps = {
|
|
||||||
playing: boolean;
|
|
||||||
muted: boolean;
|
|
||||||
source?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
class MusicPlayer extends React.Component<MusicPlayerProps, State> {
|
|
||||||
private audioState: AudioState;
|
|
||||||
|
|
||||||
constructor(props: MusicPlayerProps) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
const context = new AudioContext();
|
|
||||||
const source = new Audio();
|
|
||||||
const sourceNode = context.createMediaElementSource(source);
|
|
||||||
const volume = context.createGain();
|
|
||||||
|
|
||||||
sourceNode.connect(volume);
|
|
||||||
volume.connect(context.destination);
|
|
||||||
|
|
||||||
this.audioState = {
|
|
||||||
audioContext: context,
|
|
||||||
audioSourceNode: sourceNode,
|
|
||||||
audioSource: source,
|
|
||||||
audioVolume: volume,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div id="player" style={{ height: "100%", width: "100%" }}>
|
|
||||||
<Visualizer
|
|
||||||
audioContext={this.audioState.audioContext}
|
|
||||||
audioSource={this.audioState.audioSourceNode}
|
|
||||||
/>
|
|
||||||
<Controls />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate() {
|
|
||||||
const context = this.audioState.audioContext;
|
|
||||||
const source = this.audioState.audioSource;
|
|
||||||
const volume = this.audioState.audioVolume;
|
|
||||||
|
|
||||||
// First, set the audio source (if it changed)
|
|
||||||
if (this.props.source && source.src != this.props.source) {
|
|
||||||
source.src = this.props.source;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.playing) {
|
|
||||||
source
|
|
||||||
.play()
|
|
||||||
.then(() => {
|
|
||||||
console.info("Started playing audio");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(`Could not play audio: ${error}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
source.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.props.muted) {
|
|
||||||
volume.gain.setValueAtTime(1, context.currentTime);
|
|
||||||
} else {
|
|
||||||
volume.gain.setValueAtTime(0, context.currentTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStateToProps(state: State): MusicPlayerProps {
|
|
||||||
return {
|
|
||||||
playing: state.musicState.playing,
|
|
||||||
muted: state.musicState.muted,
|
|
||||||
source: state.musicState.source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(MusicPlayer);
|
|
2
src/music/assets/Mseq_-_Journey.mp3.d.ts
vendored
Normal file
2
src/music/assets/Mseq_-_Journey.mp3.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
declare const mseq: string;
|
||||||
|
export default mseq;
|
|
@ -1,49 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { connect } from "react-redux";
|
|
||||||
|
|
||||||
import { State } from "../store";
|
|
||||||
import { Title } from "../store/music/types";
|
|
||||||
import Indicator from "./indicator";
|
|
||||||
|
|
||||||
type ControlProps = {
|
|
||||||
title: Title;
|
|
||||||
};
|
|
||||||
|
|
||||||
class Controls extends React.Component<ControlProps, State> {
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div id="playerControls" className="container-fluid fixed-bottom">
|
|
||||||
<div className="align-items-center row p-2">
|
|
||||||
<Indicator></Indicator>
|
|
||||||
<div
|
|
||||||
id="playerText"
|
|
||||||
className="text-justify text-truncate col-6 playerControlsContent"
|
|
||||||
>
|
|
||||||
{this.props.title.name} - {this.props.title.album}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{this.props.title.name === "Journey" &&
|
|
||||||
this.props.title.artist === "Mseq" ? (
|
|
||||||
<div id="copyrightNotice" className="col text-center">
|
|
||||||
<a href="http://dig.ccmixter.org/files/Mseq/54702">Journey</a>
|
|
||||||
by Mseq (c) copyright 2016 Licensed under a Creative
|
|
||||||
Commons
|
|
||||||
<a href="http://creativecommons.org/licenses/by-nc/3.0/">
|
|
||||||
Attribution Noncommercial (3.0)
|
|
||||||
</a>
|
|
||||||
license. Ft: Admiral Bob,Texas Radio Fish
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStateToProps(state: State): ControlProps {
|
|
||||||
return {
|
|
||||||
title: state.musicState.title,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(Controls);
|
|
|
@ -1,60 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { connect } from "react-redux";
|
|
||||||
import classNames from "classnames";
|
|
||||||
|
|
||||||
import { Dispatch, State } from "../store";
|
|
||||||
import { togglePlay } from "../store/music/types";
|
|
||||||
|
|
||||||
type IndicatorProps = {
|
|
||||||
muted: boolean;
|
|
||||||
playing: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type IndicatorDispatch = {
|
|
||||||
play: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = IndicatorProps & IndicatorDispatch;
|
|
||||||
|
|
||||||
class Indicator extends React.Component<Props, State> {
|
|
||||||
click() {
|
|
||||||
this.props.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const classes = classNames({
|
|
||||||
btn: true,
|
|
||||||
"col-auto": true,
|
|
||||||
fas: true,
|
|
||||||
"fa-muted": this.props.muted,
|
|
||||||
"fa-play": this.props.playing,
|
|
||||||
"fa-pause": !this.props.playing,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
id="playerIndicator"
|
|
||||||
onClick={this.click.bind(this)}
|
|
||||||
className={classes}
|
|
||||||
></button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStateToProps(state: State): IndicatorProps {
|
|
||||||
return {
|
|
||||||
muted: state.musicState.muted,
|
|
||||||
playing: state.musicState.playing,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapDispatchToProps(dispatch: Dispatch): IndicatorDispatch {
|
|
||||||
return {
|
|
||||||
play: () => {
|
|
||||||
dispatch(togglePlay());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Indicator);
|
|
|
@ -1,220 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import * as three from "three";
|
|
||||||
|
|
||||||
import { State } from "../store";
|
|
||||||
|
|
||||||
type VisualizerProps = {
|
|
||||||
audioContext: AudioContext;
|
|
||||||
audioSource: AudioNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
class CanvasDrawer {
|
|
||||||
private analyser: AnalyserNode;
|
|
||||||
private canvas: HTMLCanvasElement;
|
|
||||||
|
|
||||||
private analyserData: Float32Array;
|
|
||||||
|
|
||||||
private boxes: Array<three.Mesh>;
|
|
||||||
private camera: three.PerspectiveCamera;
|
|
||||||
private renderer: three.WebGLRenderer;
|
|
||||||
private scene: three.Scene;
|
|
||||||
|
|
||||||
private angle: number;
|
|
||||||
|
|
||||||
private animationFrame: number;
|
|
||||||
private lastTime: number;
|
|
||||||
|
|
||||||
constructor(analyser: AnalyserNode, canvas: HTMLCanvasElement) {
|
|
||||||
this.analyser = analyser;
|
|
||||||
this.canvas = canvas;
|
|
||||||
|
|
||||||
// Set up analyser data storage
|
|
||||||
this.analyserData = new Float32Array(analyser.frequencyBinCount);
|
|
||||||
|
|
||||||
// Initialize the scene
|
|
||||||
this.scene = new three.Scene();
|
|
||||||
|
|
||||||
// Make a bunch of boxes to represent the bars
|
|
||||||
this.boxes = Array(analyser.frequencyBinCount);
|
|
||||||
const width = 2 / analyser.frequencyBinCount;
|
|
||||||
for (let freq = 0; freq < analyser.frequencyBinCount; freq++) {
|
|
||||||
const geometry = new three.BoxGeometry(1, 1, 1);
|
|
||||||
const material = new three.MeshLambertMaterial({
|
|
||||||
color: new three.Color(0x99d1ce),
|
|
||||||
});
|
|
||||||
const cube = new three.Mesh(geometry, material);
|
|
||||||
|
|
||||||
cube.scale.set(width, 1e-6, width);
|
|
||||||
cube.position.set(-1 + freq * width, 0, 0);
|
|
||||||
|
|
||||||
this.scene.add(cube);
|
|
||||||
this.boxes[freq] = cube;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add lights for shadowing
|
|
||||||
const ambientLight = new three.AmbientLight(0xffffff, 0.4);
|
|
||||||
this.scene.add(ambientLight);
|
|
||||||
|
|
||||||
const directionalLight = new three.DirectionalLight(0xffffff, 1);
|
|
||||||
directionalLight.position.set(-1, 0.3, -1);
|
|
||||||
directionalLight.castShadow = true;
|
|
||||||
this.scene.add(directionalLight);
|
|
||||||
|
|
||||||
// Add a camera
|
|
||||||
this.angle = 3;
|
|
||||||
this.camera = new three.PerspectiveCamera(
|
|
||||||
70,
|
|
||||||
canvas.width / canvas.height,
|
|
||||||
0.01,
|
|
||||||
10
|
|
||||||
);
|
|
||||||
this.camera.lookAt(0, 0, 0);
|
|
||||||
this.scene.add(this.camera);
|
|
||||||
this.rotateCamera(1);
|
|
||||||
|
|
||||||
// Add a renderer
|
|
||||||
this.renderer = new three.WebGLRenderer({
|
|
||||||
antialias: true,
|
|
||||||
canvas: canvas,
|
|
||||||
powerPreference: "low-power",
|
|
||||||
});
|
|
||||||
|
|
||||||
this.renderer.setClearColor(new three.Color(0x0f0f0f));
|
|
||||||
this.renderer.setSize(canvas.width, canvas.height);
|
|
||||||
|
|
||||||
// Set up canvas resizing
|
|
||||||
window.addEventListener("resize", this.resize.bind(this));
|
|
||||||
|
|
||||||
// Run the first, set the first animation frame time and start requesting
|
|
||||||
// animation frames
|
|
||||||
this.resize();
|
|
||||||
this.lastTime = 0;
|
|
||||||
this.animationFrame = requestAnimationFrame(this.render.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
render(time: number) {
|
|
||||||
// Set our animation frame to 0, so that if we stop, we don't try to cancel a past animation frame
|
|
||||||
this.animationFrame = 0;
|
|
||||||
// Update elapsed time
|
|
||||||
const elapsed = time - this.lastTime;
|
|
||||||
this.lastTime = time;
|
|
||||||
|
|
||||||
const camera = this.camera;
|
|
||||||
const renderer = this.renderer;
|
|
||||||
const scene = this.scene;
|
|
||||||
|
|
||||||
this.scaleBoxes();
|
|
||||||
this.rotateCamera(elapsed);
|
|
||||||
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
this.animationFrame = requestAnimationFrame(this.render.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
scaleBoxes() {
|
|
||||||
const analyser = this.analyser;
|
|
||||||
|
|
||||||
analyser.getFloatFrequencyData(this.analyserData);
|
|
||||||
|
|
||||||
for (let freq = 0; freq < analyser.frequencyBinCount; freq++) {
|
|
||||||
let height = analyser.maxDecibels / this.analyserData[freq];
|
|
||||||
|
|
||||||
if (height > 0.3) {
|
|
||||||
height -= 0.3;
|
|
||||||
} else {
|
|
||||||
height = 1e-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.boxes[freq].scale.y = height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rotateCamera(elapsed: number) {
|
|
||||||
if (this.angle >= Math.PI * 2) {
|
|
||||||
this.angle = 0;
|
|
||||||
} else {
|
|
||||||
this.angle += 0.1 * (elapsed / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const camera = this.camera;
|
|
||||||
const angle = this.angle;
|
|
||||||
|
|
||||||
camera.position.x = 1.01 * Math.sin(angle);
|
|
||||||
camera.position.z = 1.01 * Math.cos(angle);
|
|
||||||
|
|
||||||
/* camera.position.y = (1 - Math.abs(angle - 0.5) / 0.5); */
|
|
||||||
camera.lookAt(0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
resize() {
|
|
||||||
const canvas = this.canvas;
|
|
||||||
if (canvas.parentElement === null) {
|
|
||||||
throw Error("Could not access canvas parent for size calculation");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute the height of all our siblings
|
|
||||||
let combinedHeight = 0;
|
|
||||||
for (let i = 0; i < canvas.parentElement.children.length; i++) {
|
|
||||||
const child = canvas.parentElement.children[i];
|
|
||||||
|
|
||||||
if (child != canvas) {
|
|
||||||
combinedHeight += child.clientHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The remaining space we want to fill
|
|
||||||
const remainingHeight = canvas.parentElement.clientHeight - combinedHeight;
|
|
||||||
canvas.height = remainingHeight;
|
|
||||||
canvas.width = canvas.parentElement.clientWidth;
|
|
||||||
|
|
||||||
this.camera.aspect = canvas.width / remainingHeight;
|
|
||||||
this.camera.updateProjectionMatrix();
|
|
||||||
this.renderer.setSize(canvas.width, remainingHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
if (this.animationFrame != 0) {
|
|
||||||
cancelAnimationFrame(this.animationFrame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Visualizer extends React.Component<VisualizerProps, State> {
|
|
||||||
private analyser: AnalyserNode;
|
|
||||||
private canvas: React.RefObject<HTMLCanvasElement>;
|
|
||||||
private drawer: CanvasDrawer;
|
|
||||||
|
|
||||||
constructor(props: VisualizerProps) {
|
|
||||||
super(props);
|
|
||||||
this.canvas = React.createRef();
|
|
||||||
}
|
|
||||||
|
|
||||||
render(): React.ReactNode {
|
|
||||||
return (
|
|
||||||
<canvas
|
|
||||||
id="visualizer"
|
|
||||||
ref={this.canvas}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
></canvas>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount(): void {
|
|
||||||
if (this.canvas.current === null) {
|
|
||||||
throw Error("Failed to create canvas; aborting");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.analyser = this.props.audioContext.createAnalyser();
|
|
||||||
this.analyser.fftSize = 2048;
|
|
||||||
this.analyser.smoothingTimeConstant = 0.8;
|
|
||||||
this.props.audioSource.connect(this.analyser);
|
|
||||||
|
|
||||||
this.drawer = new CanvasDrawer(this.analyser, this.canvas.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount(): void {
|
|
||||||
this.drawer.stop();
|
|
||||||
this.props.audioSource.disconnect(this.analyser);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Visualizer;
|
|
44
src/music/features/controls/Controls.tsx
Normal file
44
src/music/features/controls/Controls.tsx
Normal 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>
|
||||||
|
by Mseq (c) copyright 2016 Licensed under a Creative
|
||||||
|
Commons
|
||||||
|
<a href="http://creativecommons.org/licenses/by-nc/3.0/">
|
||||||
|
Attribution Noncommercial (3.0)
|
||||||
|
</a>
|
||||||
|
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;
|
44
src/music/features/indicator/Indicator.tsx
Normal file
44
src/music/features/indicator/Indicator.tsx
Normal 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;
|
17
src/music/features/musicplayer/MusicPlayer.tsx
Normal file
17
src/music/features/musicplayer/MusicPlayer.tsx
Normal 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;
|
157
src/music/features/musicplayer/musicPlayerSlice.ts
Normal file
157
src/music/features/musicplayer/musicPlayerSlice.ts
Normal 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;
|
342
src/music/features/visualizer/Renderer.ts
Normal file
342
src/music/features/visualizer/Renderer.ts
Normal 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 };
|
183
src/music/features/visualizer/Shader.ts
Normal file
183
src/music/features/visualizer/Shader.ts
Normal 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 };
|
139
src/music/features/visualizer/Visualizer.tsx
Normal file
139
src/music/features/visualizer/Visualizer.tsx
Normal 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;
|
84
src/music/features/visualizer/cube.ts
Normal file
84
src/music/features/visualizer/cube.ts
Normal 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 };
|
12
src/music/features/visualizer/shaders/fragments.glsl
Normal file
12
src/music/features/visualizer/shaders/fragments.glsl
Normal 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; }
|
2
src/music/features/visualizer/shaders/fragments.glsl.d.ts
vendored
Normal file
2
src/music/features/visualizer/shaders/fragments.glsl.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
declare const fragments: string;
|
||||||
|
export default fragments;
|
50
src/music/features/visualizer/shaders/vertices.glsl
Normal file
50
src/music/features/visualizer/shaders/vertices.glsl
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
2
src/music/features/visualizer/shaders/vertices.glsl.d.ts
vendored
Normal file
2
src/music/features/visualizer/shaders/vertices.glsl.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
declare const vertices: string;
|
||||||
|
export default vertices;
|
5
src/music/hooks.ts
Normal file
5
src/music/hooks.ts
Normal 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;
|
|
@ -1,27 +1,30 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import { createRoot } from "react-dom/client";
|
||||||
import { Provider } from "react-redux";
|
import { Provider } from "react-redux";
|
||||||
|
|
||||||
import { store } from "./store";
|
import store from "./store";
|
||||||
import MusicPlayer from "./MusicPlayer";
|
import MusicPlayer from "./features/musicplayer/MusicPlayer";
|
||||||
import { setSource, setTitle } from "./store/music/types";
|
import { setSource } from "./features/musicplayer/musicPlayerSlice";
|
||||||
// @ts-ignore - mp3 files have no types.
|
import mseq from "./assets/Mseq_-_Journey.mp3";
|
||||||
import mseq from "./Mseq_-_Journey.mp3";
|
|
||||||
|
|
||||||
const rootElement = document.getElementById("playerUI");
|
const rootElement = document.getElementById("playerUI");
|
||||||
|
|
||||||
ReactDOM.render(
|
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}>
|
<Provider store={store}>
|
||||||
<MusicPlayer />
|
<MusicPlayer />
|
||||||
</Provider>,
|
</Provider>
|
||||||
rootElement
|
|
||||||
);
|
);
|
||||||
|
|
||||||
store.dispatch(setSource(mseq));
|
|
||||||
store.dispatch(
|
store.dispatch(
|
||||||
setTitle({
|
setSource({
|
||||||
name: "Journey",
|
source: mseq,
|
||||||
artist: "Mseq",
|
artist: "Mseq",
|
||||||
|
name: "Journey",
|
||||||
album: "Unknown album",
|
album: "Unknown album",
|
||||||
length: 192052244,
|
length: 192052244,
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,6 +1,18 @@
|
||||||
@import url("~/node_modules/@fortawesome/fontawesome-free/scss/fontawesome");
|
$fa-font-path: "npm:@fortawesome/fontawesome-free/webfonts";
|
||||||
@import url("~/node_modules/@fortawesome/fontawesome-free/scss/solid");
|
|
||||||
|
|
||||||
#playerControls {
|
@import "~/node_modules/@fortawesome/fontawesome-free/scss/fontawesome";
|
||||||
background-color: #11151c;
|
@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;
|
||||||
}
|
}
|
||||||
|
|
15
src/music/player.ts
Normal file
15
src/music/player.ts
Normal 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;
|
||||||
|
};
|
13
src/music/store.ts
Normal file
13
src/music/store.ts
Normal 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;
|
|
@ -1,20 +0,0 @@
|
||||||
import { createStore, combineReducers } from "redux";
|
|
||||||
|
|
||||||
import { MusicState } from "./music/types";
|
|
||||||
import { musicStateReducer } from "./music/reducers";
|
|
||||||
|
|
||||||
export interface State {
|
|
||||||
musicState: MusicState;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootReducer = combineReducers<State>({
|
|
||||||
musicState: musicStateReducer,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const store = createStore(
|
|
||||||
rootReducer,
|
|
||||||
// @ts-ignore - These properties are set by the devtools extension
|
|
||||||
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Dispatch = typeof store.dispatch;
|
|
|
@ -1,45 +0,0 @@
|
||||||
import { createReducer } from "redux-act";
|
|
||||||
import update from "immutability-helper";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Title,
|
|
||||||
MusicState,
|
|
||||||
setTitle,
|
|
||||||
toggleMute,
|
|
||||||
togglePlay,
|
|
||||||
setSource,
|
|
||||||
} from "./types";
|
|
||||||
|
|
||||||
const defaultTitle: Title = {
|
|
||||||
name: "Untitled",
|
|
||||||
artist: "Unknown Artist",
|
|
||||||
album: "Unknown Album",
|
|
||||||
length: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialState: MusicState = {
|
|
||||||
muted: false,
|
|
||||||
playing: false,
|
|
||||||
title: defaultTitle,
|
|
||||||
playTime: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const musicStateReducer = createReducer<MusicState>(
|
|
||||||
{
|
|
||||||
[setTitle]: (state: MusicState, title: Title): MusicState => {
|
|
||||||
return update(state, {
|
|
||||||
title: { $set: title },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[togglePlay]: (state: MusicState): MusicState => {
|
|
||||||
return update(state, { $toggle: ["playing"] });
|
|
||||||
},
|
|
||||||
[toggleMute]: (state: MusicState): MusicState => {
|
|
||||||
return update(state, { $toggle: ["muted"] });
|
|
||||||
},
|
|
||||||
[setSource]: (state: MusicState, source: string): MusicState => {
|
|
||||||
return update(state, { source: { $set: source } });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
initialState
|
|
||||||
);
|
|
|
@ -1,35 +0,0 @@
|
||||||
import { Action, createAction } from "redux-act";
|
|
||||||
|
|
||||||
export interface Title {
|
|
||||||
name: string;
|
|
||||||
artist: string;
|
|
||||||
album: string;
|
|
||||||
/**
|
|
||||||
* The length of the title in nanoseconds.
|
|
||||||
*/
|
|
||||||
length: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MusicState {
|
|
||||||
muted: boolean;
|
|
||||||
playing: boolean;
|
|
||||||
title: Title;
|
|
||||||
playTime: number;
|
|
||||||
source?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setTitle: (_title: Title) => Action<null, null> = createAction(
|
|
||||||
"set currently playing title"
|
|
||||||
);
|
|
||||||
|
|
||||||
export const setPlayTime: (_time: number) => Action<null, null> = createAction(
|
|
||||||
"set the play time"
|
|
||||||
);
|
|
||||||
|
|
||||||
export const toggleMute: () => Action<null, null> = createAction("toggle mute");
|
|
||||||
|
|
||||||
export const togglePlay: () => Action<null, null> = createAction("toggle play");
|
|
||||||
|
|
||||||
export const setSource: (_source: string) => Action<null, null> = createAction(
|
|
||||||
"set the title"
|
|
||||||
);
|
|
|
@ -1,10 +1,13 @@
|
||||||
<extends src="./lib/html/base.html">
|
<extends src="./lib/html/base.html">
|
||||||
<block name="stylesheets">
|
<block name="stylesheets">
|
||||||
<link rel="stylesheet" , href="music/music.scss" />
|
<link rel="stylesheet" href="music/music.scss" />
|
||||||
</block>
|
</block>
|
||||||
|
|
||||||
<block name="footer">
|
<block name="content">
|
||||||
<div id="playerUI" class="container-fluid flex-grow-1"></div>
|
<div id="playerUI" class="is-flex-grow-1 is-flex"></div>
|
||||||
<script type="module" src="./music/index.tsx"></script>
|
</block>
|
||||||
|
|
||||||
|
<block name="footer" type="append">
|
||||||
|
<script type="module" src="music/index.tsx"></script>
|
||||||
</block>
|
</block>
|
||||||
</extends>
|
</extends>
|
||||||
|
|
|
@ -1,8 +1,16 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
|
"strictPropertyInitialization": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"jsx": "react"
|
"jsx": "react",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"target": "es2015",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "typescript-eslint-language-service"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Reference in a new issue