Rework website with bulma instead of bootstrap #6
					 60 changed files with 6434 additions and 5503 deletions
				
			
		| 
						 | 
				
			
			@ -6,6 +6,7 @@
 | 
			
		|||
 (pug-mode
 | 
			
		||||
  (tab-width . 2))
 | 
			
		||||
 (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/
 | 
			
		||||
/node_modules
 | 
			
		||||
/result
 | 
			
		||||
/package.json
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,7 +11,7 @@
 | 
			
		|||
    },
 | 
			
		||||
    "posthtml-favicons": {
 | 
			
		||||
      "root": "src",
 | 
			
		||||
      "outDir": "./dist/browser/",
 | 
			
		||||
      "outDir": "./dist/",
 | 
			
		||||
      "configuration": {
 | 
			
		||||
        "appName": "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
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
npm update --package-lock-only
 | 
			
		||||
```console
 | 
			
		||||
nix run .#update-lockfile
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
After that, the NixOS derivations can be updated by running
 | 
			
		||||
`generate.sh` in the `nix` subdirectory.
 | 
			
		||||
When ignoring semver is desired, use:
 | 
			
		||||
 | 
			
		||||
### Note
 | 
			
		||||
 | 
			
		||||
[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 |
 | 
			
		||||
```console
 | 
			
		||||
nix run .#update-lockfile -- --ignore-semver
 | 
			
		||||
```
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										23
									
								
								flake.lock
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										23
									
								
								flake.lock
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -1,26 +1,27 @@
 | 
			
		|||
{
 | 
			
		||||
  "nodes": {
 | 
			
		||||
    "flake-utils": {
 | 
			
		||||
    "nix-filter": {
 | 
			
		||||
      "locked": {
 | 
			
		||||
        "lastModified": 1653893745,
 | 
			
		||||
        "narHash": "sha256-0jntwV3Z8//YwuOjzhV2sgJJPt+HY6KhU7VZUL0fKZQ=",
 | 
			
		||||
        "lastModified": 1653590866,
 | 
			
		||||
        "narHash": "sha256-E4yKIrt/S//WfW5D9IhQ1dVuaAy8RE7EiCMfnbrOC78=",
 | 
			
		||||
        "owner": "numtide",
 | 
			
		||||
        "repo": "flake-utils",
 | 
			
		||||
        "rev": "1ed9fb1935d260de5fe1c2f7ee0ebaae17ed2fa1",
 | 
			
		||||
        "repo": "nix-filter",
 | 
			
		||||
        "rev": "3e81a637cdf9f6e9b39aeb4d6e6394d1ad158e16",
 | 
			
		||||
        "type": "github"
 | 
			
		||||
      },
 | 
			
		||||
      "original": {
 | 
			
		||||
        "id": "flake-utils",
 | 
			
		||||
        "type": "indirect"
 | 
			
		||||
        "owner": "numtide",
 | 
			
		||||
        "repo": "nix-filter",
 | 
			
		||||
        "type": "github"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "nixpkgs": {
 | 
			
		||||
      "locked": {
 | 
			
		||||
        "lastModified": 1655200170,
 | 
			
		||||
        "narHash": "sha256-/yzkX+10sJhYNIcTtZ5ObS+nh/HrJp01XLaubzbRDcU=",
 | 
			
		||||
        "lastModified": 1659052185,
 | 
			
		||||
        "narHash": "sha256-TUbwbzCbprtWB9EtXPM52cWuKETuCV3H+cMXjLRbwTw=",
 | 
			
		||||
        "owner": "nixos",
 | 
			
		||||
        "repo": "nixpkgs",
 | 
			
		||||
        "rev": "9ff91ce2e4c5d70551d4c8fd8830931c6c6b26b8",
 | 
			
		||||
        "rev": "9370544d849be8a07193e7611d02e6f6f1b10768",
 | 
			
		||||
        "type": "github"
 | 
			
		||||
      },
 | 
			
		||||
      "original": {
 | 
			
		||||
| 
						 | 
				
			
			@ -48,7 +49,7 @@
 | 
			
		|||
    },
 | 
			
		||||
    "root": {
 | 
			
		||||
      "inputs": {
 | 
			
		||||
        "flake-utils": "flake-utils",
 | 
			
		||||
        "nix-filter": "nix-filter",
 | 
			
		||||
        "nixpkgs": "nixpkgs",
 | 
			
		||||
        "npmlock2nix": "npmlock2nix"
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										48
									
								
								flake.nix
									
										
									
									
									
								
							
							
						
						
									
										48
									
								
								flake.nix
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -3,6 +3,7 @@
 | 
			
		|||
 | 
			
		||||
  inputs = {
 | 
			
		||||
    nixpkgs.url = "github:nixos/nixpkgs/nixos-22.05";
 | 
			
		||||
    nix-filter.url = "github:numtide/nix-filter";
 | 
			
		||||
    npmlock2nix = {
 | 
			
		||||
      url = "github:nix-community/npmlock2nix";
 | 
			
		||||
      flake = false;
 | 
			
		||||
| 
						 | 
				
			
			@ -12,7 +13,7 @@
 | 
			
		|||
  outputs = {
 | 
			
		||||
    self,
 | 
			
		||||
    nixpkgs,
 | 
			
		||||
    flake-utils,
 | 
			
		||||
    nix-filter,
 | 
			
		||||
    npmlock2nix,
 | 
			
		||||
  }: let
 | 
			
		||||
    # At the moment, we only deploy to x86_64-linux. Update when we
 | 
			
		||||
| 
						 | 
				
			
			@ -24,44 +25,25 @@
 | 
			
		|||
      })
 | 
			
		||||
    ];
 | 
			
		||||
    pkgs = import nixpkgs {inherit system overlays;};
 | 
			
		||||
    package = import ./nix/package.nix {
 | 
			
		||||
      inherit self pkgs;
 | 
			
		||||
      nix-filter = nix-filter.lib;
 | 
			
		||||
    };
 | 
			
		||||
  in {
 | 
			
		||||
    apps.${system} = import ./nix/utilities {inherit pkgs;};
 | 
			
		||||
 | 
			
		||||
    packages.${system} = rec {
 | 
			
		||||
      tlaternet-templates = pkgs.npmlock2nix.build {
 | 
			
		||||
        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
 | 
			
		||||
          ];
 | 
			
		||||
        };
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      tlaternet-templates = package.package;
 | 
			
		||||
      default = tlaternet-templates;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    devShells.${system} = {
 | 
			
		||||
      default = pkgs.npmlock2nix.shell {
 | 
			
		||||
        src = self;
 | 
			
		||||
        node_modules_attrs = {
 | 
			
		||||
          buildInputs = with pkgs; [
 | 
			
		||||
            pkg-config
 | 
			
		||||
            python3
 | 
			
		||||
            vips
 | 
			
		||||
            glib
 | 
			
		||||
          ];
 | 
			
		||||
        };
 | 
			
		||||
      };
 | 
			
		||||
      default = package.shell;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    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
 | 
			
		||||
							
								
								
									
										8750
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										8750
									
								
								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'
 | 
			
		||||
							
								
								
									
										146
									
								
								src/index.html
									
										
									
									
									
								
							
							
						
						
									
										146
									
								
								src/index.html
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1,92 +1,80 @@
 | 
			
		|||
<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">
 | 
			
		||||
    <h1 class="head-line">
 | 
			
		||||
      $ <span class="typed">Welcome to tlater.net!</span>
 | 
			
		||||
    </h1>
 | 
			
		||||
    <section class="section">
 | 
			
		||||
      <div class="container">
 | 
			
		||||
        <h1 class="title has-text-weight-normal is-family-monospace">
 | 
			
		||||
          $ <span id="typed-welcome"></span>
 | 
			
		||||
        </h1>
 | 
			
		||||
 | 
			
		||||
    <hr />
 | 
			
		||||
        <hr />
 | 
			
		||||
 | 
			
		||||
    <div class="row">
 | 
			
		||||
      <div class="col-md-6">
 | 
			
		||||
        <markdown>
 | 
			
		||||
          #### About Me
 | 
			
		||||
        <div class="columns">
 | 
			
		||||
          <div class="column content">
 | 
			
		||||
            <!-- prettier-ignore -->
 | 
			
		||||
            <markdown>
 | 
			
		||||
 | 
			
		||||
          Looks like you found my website. I suppose introductions are
 | 
			
		||||
          in order.
 | 
			
		||||
              ### About Me
 | 
			
		||||
 | 
			
		||||
          My name's Tristan, I'm an avid Dutch-South African software
 | 
			
		||||
          consultant working in the UK. You probably either met me at an
 | 
			
		||||
          open source conference, a hackathon, a badminton session or at
 | 
			
		||||
          a roleplaying table.
 | 
			
		||||
              Looks like you found my website. I suppose introductions are
 | 
			
		||||
              in order.
 | 
			
		||||
 | 
			
		||||
          If not, well, this is also a great place to "meet" me. Have a
 | 
			
		||||
          nosey!
 | 
			
		||||
              My name's Tristan, I'm an avid Dutch-South African software
 | 
			
		||||
              consultant working in the UK. You probably either met me at an
 | 
			
		||||
              open source conference, a hackathon, a badminton session or at
 | 
			
		||||
              a roleplaying table.
 | 
			
		||||
 | 
			
		||||
          #### This Website
 | 
			
		||||
              If not, well, this is also a great place to "meet" me. Have a
 | 
			
		||||
              nosey!
 | 
			
		||||
 | 
			
		||||
          There is not a whole lot here at the moment.
 | 
			
		||||
              ### This Website
 | 
			
		||||
 | 
			
		||||
          You may find the following interesting though:
 | 
			
		||||
        </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>
 | 
			
		||||
              There is not a whole lot here at the moment.
 | 
			
		||||
 | 
			
		||||
              You may find the following interesting though:
 | 
			
		||||
 | 
			
		||||
              - A [little web app](~/src/music_sample.html) showing
 | 
			
		||||
              off what WebGL can do in combination with the JavaScript
 | 
			
		||||
              Audio interface.
 | 
			
		||||
            </markdown>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="column content">
 | 
			
		||||
            <!-- prettier-ignore -->
 | 
			
		||||
            <markdown>
 | 
			
		||||
              ### My Work
 | 
			
		||||
 | 
			
		||||
              I'm a software consultant working for
 | 
			
		||||
              [Codethink](https://www.codethink.co.uk) in Manchester,
 | 
			
		||||
              UK. Our specializaiton is open source software, so this has
 | 
			
		||||
              allowed me to directly contribute to a number of open source
 | 
			
		||||
              projects, notably
 | 
			
		||||
              [BuildStream](https://www.gitlab.com/buildstream/buildstream),
 | 
			
		||||
              an integration tool for large software stacks.
 | 
			
		||||
 | 
			
		||||
              I've given a couple of talks on it, as well:
 | 
			
		||||
 | 
			
		||||
              - Build meetup 2017
 | 
			
		||||
              - Build meetup 2018
 | 
			
		||||
              - Build meetup 2019
 | 
			
		||||
 | 
			
		||||
              Outside of work for Codethink, I'm generally interested in
 | 
			
		||||
              things such as NixOS and other tools that assist maintaining
 | 
			
		||||
              Linux systems - mostly born out of my pursuit of the perfect
 | 
			
		||||
              Linux desktop (feel free to have a browse through my
 | 
			
		||||
              [dotfiles](https://github.com/tlater/dotfiles)).
 | 
			
		||||
 | 
			
		||||
              I also just enjoy Programming, my core languages currently are
 | 
			
		||||
              Rust, Python, Lisp and JavaScript (including a number of
 | 
			
		||||
              frameworks and tools for these), although I have hopes to
 | 
			
		||||
              eventually reduce these to just Rust ;)
 | 
			
		||||
 | 
			
		||||
              If you're interested in seeing these things for yourself,
 | 
			
		||||
              visit my [Gitlab](https://gitlab.com/tlater) and
 | 
			
		||||
              [GitHub](https://github.com/tlater) pages.
 | 
			
		||||
            </markdown>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="col-md-6">
 | 
			
		||||
        <markdown>
 | 
			
		||||
          #### My Work
 | 
			
		||||
 | 
			
		||||
          I'm a software consultant working for
 | 
			
		||||
          [Codethink](https://www.codethink.co.uk) in Manchester,
 | 
			
		||||
          UK. Our specializaiton is open source software, so this has
 | 
			
		||||
          allowed me to directly contribute to a number of open source
 | 
			
		||||
          projects, notably
 | 
			
		||||
          [BuildStream](https://www.gitlab.com/buildstream/buildstream),
 | 
			
		||||
          an integration tool for large software stacks.
 | 
			
		||||
 | 
			
		||||
          I've given a couple of talks on it, as well:
 | 
			
		||||
 | 
			
		||||
          - Build meetup 2017
 | 
			
		||||
          - Build meetup 2018
 | 
			
		||||
          - Build meetup 2019
 | 
			
		||||
 | 
			
		||||
          Outside of work for Codethink, I'm generally interested in
 | 
			
		||||
          things such as NixOS and other tools that assist maintaining
 | 
			
		||||
          Linux systems - mostly born out of my pursuit of the perfect
 | 
			
		||||
          Linux desktop (feel free to have a browse through my
 | 
			
		||||
          [dotfiles](https://github.com/tlater/dotfiles)).
 | 
			
		||||
 | 
			
		||||
          I also just enjoy Programming, my core languages currently are
 | 
			
		||||
          Rust, Python, Lisp and JavaScript (including a number of
 | 
			
		||||
          frameworks and tools for these), although I have hopes to
 | 
			
		||||
          eventually reduce these to just Rust ;)
 | 
			
		||||
 | 
			
		||||
          If you're interested in seeing these things for yourself,
 | 
			
		||||
          visit my [Gitlab](https://gitlab.com/tlater) and
 | 
			
		||||
          [GitHub](https://github.com/tlater) pages.
 | 
			
		||||
        </markdown>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </block>
 | 
			
		||||
 | 
			
		||||
  <block name="footer">
 | 
			
		||||
    <script type="module" src="./index.ts" defer></script>
 | 
			
		||||
    </section>
 | 
			
		||||
  </block>
 | 
			
		||||
</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>
 | 
			
		||||
<html class="no-js" lang="en">
 | 
			
		||||
<html lang="en">
 | 
			
		||||
  <head>
 | 
			
		||||
    <meta charset="utf-8" />
 | 
			
		||||
    <meta name="description" content="tlater.net web server" />
 | 
			
		||||
    <meta name="author" contnet="Tristan Daniël Maat" />
 | 
			
		||||
    <meta name="viewport" content="width=device-width initial-scale=1" />
 | 
			
		||||
    <meta name="viewport" content="width=device-width, initial-scale=1" />
 | 
			
		||||
    <link rel="icon" href="./icon.svg" type="image/x-icon" />
 | 
			
		||||
    <link rel="stylesheet" href="~/src/lib/scss/main.scss" />
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -13,28 +13,17 @@
 | 
			
		|||
    <title>tlater.net</title>
 | 
			
		||||
  </head>
 | 
			
		||||
 | 
			
		||||
  <body class="d-flex flex-column">
 | 
			
		||||
  <body>
 | 
			
		||||
    <block name="navigation">
 | 
			
		||||
      <include src="lib/html/navigation.html"></include>
 | 
			
		||||
    </block>
 | 
			
		||||
 | 
			
		||||
    <div class="container floating-container">
 | 
			
		||||
      <include src="lib/html/message-flash.html"></include>
 | 
			
		||||
      <block name="content"></block>
 | 
			
		||||
    </div>
 | 
			
		||||
    <include src="lib/html/message-flash.html"></include>
 | 
			
		||||
 | 
			
		||||
    <script
 | 
			
		||||
      type="text/javascript"
 | 
			
		||||
      src="~/node_modules/jquery/dist/jquery.min.js"
 | 
			
		||||
      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="content"></block>
 | 
			
		||||
 | 
			
		||||
    <block name="footer"></block>
 | 
			
		||||
    <block name="footer">
 | 
			
		||||
      <script type="module" src="lib/js/index.ts"></script>
 | 
			
		||||
    </block>
 | 
			
		||||
  </body>
 | 
			
		||||
</html>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,8 @@
 | 
			
		|||
{{#if flash}}
 | 
			
		||||
<div
 | 
			
		||||
  class="alert alert-{{flash.type}} alert-dismissible fade show"
 | 
			
		||||
  role="alert"
 | 
			
		||||
>
 | 
			
		||||
  {{ flash.message }}
 | 
			
		||||
  <button type="button" class="close" data-dismiss="alert" aria-label="Close">
 | 
			
		||||
    <span aria-hidden="true">×</span>
 | 
			
		||||
  </button>
 | 
			
		||||
</div>
 | 
			
		||||
{{/if}}
 | 
			
		||||
<span>
 | 
			
		||||
  {{#if flash}}
 | 
			
		||||
  <div class="notification is-{{flash.type}}">
 | 
			
		||||
    <button class="delete" aria-label="Close"></button>
 | 
			
		||||
    <span role="alert"> {{ flash.message }} </span>
 | 
			
		||||
  </div>
 | 
			
		||||
  {{/if}}
 | 
			
		||||
</span>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,31 +1,23 @@
 | 
			
		|||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
 | 
			
		||||
  <div class="container">
 | 
			
		||||
    <a class="navbar-brand" href="/">tlater</a>
 | 
			
		||||
 | 
			
		||||
    <button
 | 
			
		||||
      class="navbar-toggler"
 | 
			
		||||
      type="button"
 | 
			
		||||
      data-toggle="collapse"
 | 
			
		||||
      data-target="#navbar"
 | 
			
		||||
      aria-controls="#navbar"
 | 
			
		||||
<nav class="navbar is-dark" role="navigation" aria-label="main navigation">
 | 
			
		||||
  <div class="navbar-brand">
 | 
			
		||||
    <a class="navbar-item has-text-primary is-uppercase" href="/">tlater</a>
 | 
			
		||||
    <a
 | 
			
		||||
      class="navbar-burger"
 | 
			
		||||
      role="button"
 | 
			
		||||
      aria-label="menu"
 | 
			
		||||
      aria-expanded="false"
 | 
			
		||||
      aria-label="Toggle navigation"
 | 
			
		||||
      data-target="main-navigation"
 | 
			
		||||
    >
 | 
			
		||||
      <span class="navbar-toggler-icon"></span>
 | 
			
		||||
    </button>
 | 
			
		||||
 | 
			
		||||
    <div id="navbar" class="navbar-collapse collapse">
 | 
			
		||||
      <ul class="navbar-nav mr-auto">
 | 
			
		||||
        <li class="nav-item">
 | 
			
		||||
          <a class="nav-link" href="~/src/mail.html">E-Mail</a>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li class="nav-item">
 | 
			
		||||
          <a class="nav-link" href="https://www.gitlab.com/tlater">GitLab</a>
 | 
			
		||||
        </li>
 | 
			
		||||
        <li class="nav-item">
 | 
			
		||||
          <a class="nav-link" href="https://www.github.com/TLATER">GitHub</a>
 | 
			
		||||
        </li>
 | 
			
		||||
      </ul>
 | 
			
		||||
      <span aria-hidden="true"></span>
 | 
			
		||||
      <span aria-hidden="true"></span>
 | 
			
		||||
      <span aria-hidden="true"></span>
 | 
			
		||||
    </a>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div id="main-navigation" class="navbar-menu">
 | 
			
		||||
    <div class="navbar-start">
 | 
			
		||||
      <a class="navbar-item" href="~/src/mail.html"> E-Mail </a>
 | 
			
		||||
      <a class="navbar-item" href="https://www.gitlab.com/tlater"> GitLab </a>
 | 
			
		||||
      <a class="navbar-item" href="https://www.github.com/TLATER"> GitHub </a>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</nav>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										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 */
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: 'Nunito';
 | 
			
		||||
  font-style: italic;
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  src: url('~/src/lib/fonts/nunito-v9-latin-italic.eot'); /* IE9 Compat Modes */
 | 
			
		||||
  src: local('Nunito Italic'), local('Nunito-Italic'),
 | 
			
		||||
  url('~/src/lib/fonts/nunito-v9-latin-italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
 | 
			
		||||
  url('~/src/lib/fonts/nunito-v9-latin-italic.woff2') format('woff2'), /* Super Modern Browsers */
 | 
			
		||||
  url('~/src/lib/fonts/nunito-v9-latin-italic.woff') format('woff'), /* Modern Browsers */
 | 
			
		||||
  url('~/src/lib/fonts/nunito-v9-latin-italic.ttf') format('truetype'), /* Safari, Android, iOS */
 | 
			
		||||
  url('~/src/lib/fonts/nunito-v9-latin-italic.svg#Nunito') format('svg'); /* Legacy iOS */
 | 
			
		||||
@use "~/node_modules/@fontsource/nunito/scss/mixins" as Nunito;
 | 
			
		||||
@use "~/node_modules/@fontsource/arimo/scss/mixins" as Arimo;
 | 
			
		||||
 | 
			
		||||
$weights: 300, 400, 500, 600, 700;
 | 
			
		||||
 | 
			
		||||
@each $weight in $weights {
 | 
			
		||||
  @include Nunito.fontFace(
 | 
			
		||||
    $weight: $weight,
 | 
			
		||||
    $display: auto,
 | 
			
		||||
    $style: normal,
 | 
			
		||||
    $fontDir: "npm:@fontsource/nunito/files"
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  @include Nunito.fontFace(
 | 
			
		||||
    $weight: $weight,
 | 
			
		||||
    $display: auto,
 | 
			
		||||
    $style: italic,
 | 
			
		||||
    $fontDir: "npm:@fontsource/nunito/files"
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* nunito-regular - latin */
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: 'Nunito';
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  src: url('~/src/lib/fonts/nunito-v9-latin-regular.eot'); /* IE9 Compat Modes */
 | 
			
		||||
  src: local('Nunito Regular'), local('Nunito-Regular'),
 | 
			
		||||
       url('~/src/lib/fonts/nunito-v9-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
 | 
			
		||||
       url('~/src/lib/fonts/nunito-v9-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
 | 
			
		||||
       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 */
 | 
			
		||||
@include Arimo.fontFace(
 | 
			
		||||
  $weight: 400,
 | 
			
		||||
  $display: auto,
 | 
			
		||||
  $style: normal,
 | 
			
		||||
  $fontDir: "npm:@fontsource/arimo/files"
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// Hack *does* come with its own CSS, but it's broken and hasn't seen
 | 
			
		||||
// a release since https://github.com/source-foundry/Hack/issues/467
 | 
			
		||||
// was resolved.
 | 
			
		||||
 | 
			
		||||
$variants: regular 400 normal, bold 700 normal, italic 400 italic,
 | 
			
		||||
  bolditalic 700 italic;
 | 
			
		||||
 | 
			
		||||
@each $name, $weight, $style in $variants {
 | 
			
		||||
  @font-face {
 | 
			
		||||
    font-family: "Hack";
 | 
			
		||||
    src: url("npm:hack-font/build/web/fonts/hack-#{$name}-subset.woff2?sha=3114f1256")
 | 
			
		||||
        format("woff2"),
 | 
			
		||||
      url("npm:hack-font/build/web/fonts/hack-#{$name}-subset.woff?sha=3114f1256")
 | 
			
		||||
        format("woff");
 | 
			
		||||
    font-weight: $weight;
 | 
			
		||||
    font-style: $style;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 'fonts'; */
 | 
			
		||||
@import 'headings';
 | 
			
		||||
@import "./_custom-bulma";
 | 
			
		||||
@import "./_typed";
 | 
			
		||||
 | 
			
		||||
html, body {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
body {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  min-height: 100vh;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#typed-welcome {
 | 
			
		||||
  &::before {
 | 
			
		||||
    @include typed("Welcome to tlater.net!", 1.2s);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &::after {
 | 
			
		||||
    @include cursor(6s);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										117
									
								
								src/mail.html
									
										
									
									
									
								
							
							
						
						
									
										117
									
								
								src/mail.html
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1,65 +1,72 @@
 | 
			
		|||
<extends src="./lib/html/base.html">
 | 
			
		||||
  <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="col-md-6">
 | 
			
		||||
        <form id="sendmail" role="form" action="mail.html" method="post">
 | 
			
		||||
          <div class="form-group">
 | 
			
		||||
            <label div="control-label" for="mail">Email address</label>
 | 
			
		||||
            <input
 | 
			
		||||
              id="mail"
 | 
			
		||||
              class="form-control"
 | 
			
		||||
              type="email"
 | 
			
		||||
              placeholder="Your address"
 | 
			
		||||
              name="mail"
 | 
			
		||||
              required
 | 
			
		||||
            />
 | 
			
		||||
        <div class="columns">
 | 
			
		||||
          <div class="column">
 | 
			
		||||
            <form id="sendmail" role="form" action="mail.html" method="post">
 | 
			
		||||
              <div class="field">
 | 
			
		||||
                <label class="label" for="mail">Email address</label>
 | 
			
		||||
                <input
 | 
			
		||||
                  id="mail"
 | 
			
		||||
                  class="input"
 | 
			
		||||
                  type="email"
 | 
			
		||||
                  placeholder="Your address"
 | 
			
		||||
                  name="mail"
 | 
			
		||||
                  required
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div class="field">
 | 
			
		||||
                <label class="label" for="subject">Subject</label>
 | 
			
		||||
                <input
 | 
			
		||||
                  id="subject"
 | 
			
		||||
                  class="input"
 | 
			
		||||
                  type="text"
 | 
			
		||||
                  placeholder="E.g. There's a typo on your home page!"
 | 
			
		||||
                  name="subject"
 | 
			
		||||
                  autocomplete="off"
 | 
			
		||||
                  required
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div class="field">
 | 
			
		||||
                <label class="label" for="message">Message</label>
 | 
			
		||||
                <textarea
 | 
			
		||||
                  id="message"
 | 
			
		||||
                  class="textarea"
 | 
			
		||||
                  type="text"
 | 
			
		||||
                  rows="6"
 | 
			
		||||
                  name="message"
 | 
			
		||||
                  autocomplete="off"
 | 
			
		||||
                  required
 | 
			
		||||
                ></textarea>
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div class="field">
 | 
			
		||||
                <div class="control">
 | 
			
		||||
                  <button class="button is-link">Send</button>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </form>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <div class="form-group">
 | 
			
		||||
            <label div="control-label" for="subject">Subject</label>
 | 
			
		||||
            <input
 | 
			
		||||
              id="subject"
 | 
			
		||||
              class="form-control"
 | 
			
		||||
              type="text"
 | 
			
		||||
              placeholder="E.g. There's a typo on your home page!"
 | 
			
		||||
              name="subject"
 | 
			
		||||
              autocomplete="off"
 | 
			
		||||
              required
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="column content">
 | 
			
		||||
            <!-- prettier-ignore -->
 | 
			
		||||
            <markdown>
 | 
			
		||||
              Any messages you enter here are directly forwarded to me. I aim to
 | 
			
		||||
              respond within a day.
 | 
			
		||||
 | 
			
		||||
          <div class="form-group">
 | 
			
		||||
            <label div="control-label" for="message">Message</label>
 | 
			
		||||
            <textarea
 | 
			
		||||
              id="message"
 | 
			
		||||
              class="form-control"
 | 
			
		||||
              type="text"
 | 
			
		||||
              rows="6"
 | 
			
		||||
              name="message"
 | 
			
		||||
              autocomplete="off"
 | 
			
		||||
              required
 | 
			
		||||
            ></textarea>
 | 
			
		||||
              Don't be upset about the form, I want to avoid the spam
 | 
			
		||||
              publishing your email address brings with it... And minimize
 | 
			
		||||
              the amount of mail that doesn't reach me, this form is an
 | 
			
		||||
              exception in all my spam filters, you see ;)
 | 
			
		||||
            </markdown>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <button class="btn btn-primary" type="submit" form="sendmail">
 | 
			
		||||
            Send
 | 
			
		||||
          </button>
 | 
			
		||||
        </form>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="col-md-6">
 | 
			
		||||
        <markdown>
 | 
			
		||||
          Any messages you enter here are directly forwarded to me. I aim to
 | 
			
		||||
          respond within a day.
 | 
			
		||||
 | 
			
		||||
          Don't be upset about the form, I want to avoid the spam
 | 
			
		||||
          publishing your email address brings with it... And minimize
 | 
			
		||||
          the amount of mail that doesn't reach me, this form is an
 | 
			
		||||
          exception in all my spam filters, you see ;)
 | 
			
		||||
        </markdown>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  </block>
 | 
			
		||||
</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 ReactDOM from "react-dom";
 | 
			
		||||
import { createRoot } from "react-dom/client";
 | 
			
		||||
import { Provider } from "react-redux";
 | 
			
		||||
 | 
			
		||||
import { store } from "./store";
 | 
			
		||||
import MusicPlayer from "./MusicPlayer";
 | 
			
		||||
import { setSource, setTitle } from "./store/music/types";
 | 
			
		||||
// @ts-ignore - mp3 files have no types.
 | 
			
		||||
import mseq from "./Mseq_-_Journey.mp3";
 | 
			
		||||
import store from "./store";
 | 
			
		||||
import MusicPlayer from "./features/musicplayer/MusicPlayer";
 | 
			
		||||
import { setSource } from "./features/musicplayer/musicPlayerSlice";
 | 
			
		||||
import mseq from "./assets/Mseq_-_Journey.mp3";
 | 
			
		||||
 | 
			
		||||
const rootElement = document.getElementById("playerUI");
 | 
			
		||||
 | 
			
		||||
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}>
 | 
			
		||||
        <MusicPlayer />
 | 
			
		||||
    </Provider>,
 | 
			
		||||
    rootElement
 | 
			
		||||
    </Provider>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
store.dispatch(setSource(mseq));
 | 
			
		||||
store.dispatch(
 | 
			
		||||
    setTitle({
 | 
			
		||||
        name: "Journey",
 | 
			
		||||
    setSource({
 | 
			
		||||
        source: mseq,
 | 
			
		||||
        artist: "Mseq",
 | 
			
		||||
        name: "Journey",
 | 
			
		||||
        album: "Unknown album",
 | 
			
		||||
        length: 192052244,
 | 
			
		||||
    })
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,18 @@
 | 
			
		|||
@import url("~/node_modules/@fortawesome/fontawesome-free/scss/fontawesome");
 | 
			
		||||
@import url("~/node_modules/@fortawesome/fontawesome-free/scss/solid");
 | 
			
		||||
$fa-font-path: "npm:@fortawesome/fontawesome-free/webfonts";
 | 
			
		||||
 | 
			
		||||
#playerControls {
 | 
			
		||||
  background-color: #11151c;
 | 
			
		||||
@import "~/node_modules/@fortawesome/fontawesome-free/scss/fontawesome";
 | 
			
		||||
@import "~/node_modules/@fortawesome/fontawesome-free/scss/solid";
 | 
			
		||||
 | 
			
		||||
.is-border-box {
 | 
			
		||||
  box-sizing: border-box !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.is-absolute {
 | 
			
		||||
  position: absolute !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.is-bottom-left {
 | 
			
		||||
  bottom: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  position: absolute !important;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										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">
 | 
			
		||||
  <block name="stylesheets">
 | 
			
		||||
    <link rel="stylesheet" , href="music/music.scss" />
 | 
			
		||||
    <link rel="stylesheet" href="music/music.scss" />
 | 
			
		||||
  </block>
 | 
			
		||||
 | 
			
		||||
  <block name="footer">
 | 
			
		||||
    <div id="playerUI" class="container-fluid flex-grow-1"></div>
 | 
			
		||||
    <script type="module" src="./music/index.tsx"></script>
 | 
			
		||||
  <block name="content">
 | 
			
		||||
    <div id="playerUI" class="is-flex-grow-1 is-flex"></div>
 | 
			
		||||
  </block>
 | 
			
		||||
 | 
			
		||||
  <block name="footer" type="append">
 | 
			
		||||
    <script type="module" src="music/index.tsx"></script>
 | 
			
		||||
  </block>
 | 
			
		||||
</extends>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,8 +1,16 @@
 | 
			
		|||
{
 | 
			
		||||
  "compilerOptions": {
 | 
			
		||||
    "strictNullChecks": true,
 | 
			
		||||
    "strictPropertyInitialization": true,
 | 
			
		||||
    "esModuleInterop": true,
 | 
			
		||||
    "jsx": "react"
 | 
			
		||||
    "jsx": "react",
 | 
			
		||||
    "isolatedModules": true,
 | 
			
		||||
    "target": "es2015",
 | 
			
		||||
    "moduleResolution": "node",
 | 
			
		||||
    "plugins": [
 | 
			
		||||
      {
 | 
			
		||||
        "name": "typescript-eslint-language-service"
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Reference in a new issue