subtree(users/wpcarro): docking briefcase at '24f5a642'
git-subtree-dir: users/wpcarro git-subtree-mainline:464bbcb15cgit-subtree-split:24f5a642afChange-Id: I6105b3762b79126b3488359c95978cadb3efa789
This commit is contained in:
commit
019f8fd211
766 changed files with 175420 additions and 0 deletions
4
users/wpcarro/website/sandbox/contentful/.envrc
Normal file
4
users/wpcarro/website/sandbox/contentful/.envrc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
source_up
|
||||
use_nix
|
||||
export CONTENTFUL_SPACE_ID="$(jq -j '.contentful | .spaceId' < ~/briefcase/secrets.json)"
|
||||
export CONTENTFUL_ACCESS_TOKEN="$(jq -j '.contentful | .accessToken' < ~/briefcase/secrets.json)"
|
||||
2
users/wpcarro/website/sandbox/contentful/.gitignore
vendored
Normal file
2
users/wpcarro/website/sandbox/contentful/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
.cache
|
||||
dist
|
||||
18
users/wpcarro/website/sandbox/contentful/README.md
Normal file
18
users/wpcarro/website/sandbox/contentful/README.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Contentful
|
||||
|
||||
I have not used a CMS in a few years. I learned about Contentful from a
|
||||
Gatsby.js tutorial, and I wanted to learn more; I created a Contentful account,
|
||||
and I'm experimenting with the data here.
|
||||
|
||||
## Developing
|
||||
|
||||
```shell
|
||||
$ nix-shell
|
||||
$ yarn run dev
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```shell
|
||||
$ nix-build
|
||||
```
|
||||
19
users/wpcarro/website/sandbox/contentful/default.nix
Normal file
19
users/wpcarro/website/sandbox/contentful/default.nix
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{ pkgs, ... }:
|
||||
|
||||
pkgs.stdenv.mkDerivation {
|
||||
name = "ideal-website";
|
||||
src = builtins.path { path = ./.; name = "contentful"; };
|
||||
buildInputs = with pkgs; [
|
||||
nodejs
|
||||
# Exposes lscpu for parcel.js
|
||||
utillinux
|
||||
];
|
||||
# parcel.js needs number of CPUs
|
||||
PARCEL_WORKERS = "1";
|
||||
buildPhase = ''
|
||||
npx parcel build index.html
|
||||
'';
|
||||
installPhase = ''
|
||||
mv dist $out
|
||||
'';
|
||||
}
|
||||
26
users/wpcarro/website/sandbox/contentful/package.json
Normal file
26
users/wpcarro/website/sandbox/contentful/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "tailwindcss",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "npx parcel src/index.html & npx tsc --watch --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^13.9.3",
|
||||
"parcel-bundler": "^1.12.4",
|
||||
"tailwindcss": "^1.2.0",
|
||||
"typescript": "^3.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.2.5",
|
||||
"@types/react-dom": "^16.9.5",
|
||||
"@types/react-redux": "^7.1.7",
|
||||
"@types/react-router-dom": "^5.1.3",
|
||||
"contentful": "^7.14.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-redux": "^7.2.0",
|
||||
"react-router-dom": "^5.1.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
const tailwindcss = require("tailwindcss");
|
||||
|
||||
module.exports = {
|
||||
plugins: [tailwindcss("./tailwind.config.js")],
|
||||
};
|
||||
9
users/wpcarro/website/sandbox/contentful/shell.nix
Normal file
9
users/wpcarro/website/sandbox/contentful/shell.nix
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
let
|
||||
briefcase = import <briefcase> {};
|
||||
pkgs = briefcase.third_party.pkgs;
|
||||
in pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs
|
||||
yarn
|
||||
];
|
||||
}
|
||||
49
users/wpcarro/website/sandbox/contentful/src/App.tsx
Normal file
49
users/wpcarro/website/sandbox/contentful/src/App.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { actions, useTypedSelector } from "./store";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getClient } from "./contentful";
|
||||
import type { Book } from "./store";
|
||||
|
||||
const App: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { isLoading, books } = useTypedSelector((state) => ({
|
||||
isLoading: state.isLoading,
|
||||
books: state.books,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const entries = await getClient().getEntries();
|
||||
const books = entries.items.map((x) => x.fields) as Book[];
|
||||
|
||||
dispatch(actions.setBooks(books));
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<div className="container mx-auto">
|
||||
<h1 className="py-6 text-2xl">Books</h1>
|
||||
<ul>
|
||||
{books.map((book) => (
|
||||
<li key={book.title} className="py-3">
|
||||
<p>
|
||||
<span className="font-bold pr-3">{book.title}</span>
|
||||
<span className="text-gray-600">{book.author}</span>
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
27
users/wpcarro/website/sandbox/contentful/src/contentful.ts
Normal file
27
users/wpcarro/website/sandbox/contentful/src/contentful.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createClient } from "contentful";
|
||||
import type { ContentfulClientApi } from "contentful";
|
||||
|
||||
const space = process.env.CONTENTFUL_SPACE_ID;
|
||||
const accessToken = process.env.CONTENTFUL_ACCESS_TOKEN;
|
||||
|
||||
let client: ContentfulClientApi;
|
||||
|
||||
// Idempotent way to get a reference to the Contentful client.
|
||||
export const getClient = (): ContentfulClientApi => {
|
||||
if (typeof client !== "undefined") {
|
||||
return client;
|
||||
} else {
|
||||
if (typeof space === "string" && typeof accessToken === "string") {
|
||||
let client = createClient({
|
||||
space,
|
||||
accessToken,
|
||||
});
|
||||
|
||||
return client;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Please set CONTENTFUL_SPACE_ID and CONTENTFUL_ACCESS_TOKEN"
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
3
users/wpcarro/website/sandbox/contentful/src/index.css
Normal file
3
users/wpcarro/website/sandbox/contentful/src/index.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
11
users/wpcarro/website/sandbox/contentful/src/index.html
Normal file
11
users/wpcarro/website/sandbox/contentful/src/index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="./index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="mount"></div>
|
||||
<script src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
12
users/wpcarro/website/sandbox/contentful/src/index.tsx
Normal file
12
users/wpcarro/website/sandbox/contentful/src/index.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import App from "./App";
|
||||
import { Provider } from "react-redux";
|
||||
import store from "./store";
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>,
|
||||
document.getElementById("mount")
|
||||
);
|
||||
36
users/wpcarro/website/sandbox/contentful/src/store.ts
Normal file
36
users/wpcarro/website/sandbox/contentful/src/store.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { createSlice, configureStore, PayloadAction } from "@reduxjs/toolkit";
|
||||
import { useSelector, TypedUseSelectorHook } from "react-redux";
|
||||
|
||||
export interface Book {
|
||||
title: string;
|
||||
author: string;
|
||||
// TODO(wpcarro): Prefer datetime type here.
|
||||
publicationDate: string;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
isLoading: boolean;
|
||||
books: Book[];
|
||||
}
|
||||
|
||||
const initialState: State = {
|
||||
isLoading: true,
|
||||
books: [],
|
||||
};
|
||||
|
||||
export const { actions, reducer } = createSlice({
|
||||
name: "application",
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleIsLoading: (state) => ({ ...state, isLoading: !state.isLoading }),
|
||||
setBooks: (state, action) => ({ ...state, books: action.payload }),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Defining and consuming this allows us to avoid annotating State in all of our
|
||||
* selectors.
|
||||
*/
|
||||
export const useTypedSelector: TypedUseSelectorHook<State> = useSelector;
|
||||
|
||||
export default configureStore({ reducer });
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
variants: {},
|
||||
plugins: [],
|
||||
};
|
||||
19
users/wpcarro/website/sandbox/contentful/tsconfig.json
Normal file
19
users/wpcarro/website/sandbox/contentful/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
5717
users/wpcarro/website/sandbox/contentful/yarn.lock
Normal file
5717
users/wpcarro/website/sandbox/contentful/yarn.lock
Normal file
File diff suppressed because it is too large
Load diff
16
users/wpcarro/website/sandbox/covid-uk/default.nix.ignore
Normal file
16
users/wpcarro/website/sandbox/covid-uk/default.nix.ignore
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{ pkgs, ... }:
|
||||
|
||||
pkgs.stdenv.mkDerivation {
|
||||
name = "covid-uk";
|
||||
buildInputs = [];
|
||||
src = builtins.path { path = ./.; name = "covid-uk"; };
|
||||
# TODO(wpcarro): Need to run `yarn install` somehow.
|
||||
# TODO(wpcarro): Need to run `npx tailwindcss build styles.css -o output.css`.
|
||||
buildPhase = ''
|
||||
mkdir -p $out
|
||||
mkdir -p $out/node_modules/chart.js/dist
|
||||
cp $src/node_modules/chart.js/dist/Chart.bundle.min.js $out/node_modules/chart.js/dist
|
||||
cp $src/index.html $src/output.css $out
|
||||
'';
|
||||
dontInstall = true;
|
||||
}
|
||||
99
users/wpcarro/website/sandbox/covid-uk/index.html
Normal file
99
users/wpcarro/website/sandbox/covid-uk/index.html
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>COVID-19 UK</title>
|
||||
<link rel="stylesheet" href="output.css">
|
||||
</head>
|
||||
<body class="container mx-auto py-10">
|
||||
<div>
|
||||
<h1 class="text-center">COVID-19 in the UK</h1>
|
||||
<p>
|
||||
Up until recently, I used a couple of resources (i.e.
|
||||
<a href="https://multimedia.scmp.com/infographics/news/china/article/3047038/wuhan-virus/index.html">one</a>,
|
||||
<a href="https://www.worldometers.info/coronavirus/">two</a>) for tracking
|
||||
an updated number of confirmed covid-19 cases.
|
||||
</p>
|
||||
<p>
|
||||
Given the high speed at which the virus is spreading, I was having a
|
||||
difficult time intuiting the shape of this growth. For example if today
|
||||
the total number of confirmed cases for covid-19 in the UK was 500, I
|
||||
could not remember if yesterday it was 450, 400, or 200.
|
||||
</p>
|
||||
<p>
|
||||
Thankfully someone is <a
|
||||
href="https://github.com/pomber/covid19">publishing this data</a> as a
|
||||
timeseries database. I am currently living in London, so I decided to
|
||||
chart the <u>daily number of confirmed covid-19 cases in the UK</u> to
|
||||
better understand what is happening.
|
||||
</p>
|
||||
</div>
|
||||
<canvas id="myChart" class="py-12"></canvas>
|
||||
<script src="./node_modules/chart.js/dist/Chart.bundle.min.js"></script>
|
||||
<script>
|
||||
var timeseries =
|
||||
fetch('https://pomber.github.io/covid19/timeseries.json')
|
||||
.then(res => res.json())
|
||||
.then(createChart);
|
||||
|
||||
function createChart(data) {
|
||||
var uk = data["United Kingdom"];
|
||||
var data = uk.map(x => x["confirmed"]);
|
||||
var labels = uk.map(x => x["date"]);
|
||||
|
||||
var ctx = document.getElementById('myChart').getContext('2d');
|
||||
var myChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Number of confirmed COVID-19 cases in the U.K.',
|
||||
data: data,
|
||||
backgroundColor: 'rgba(255, 0, 100, 0.2)',
|
||||
borderWidth: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
scales: {
|
||||
yAxes: [{
|
||||
ticks: {
|
||||
beginAtZero: true
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<div>
|
||||
<h2 class="text-center">Back of the envelope predictions</h2>
|
||||
<p>
|
||||
From what I have read, a population where 60% of its constituents have
|
||||
been infected with covid-19 and have recovered is said to have "herd
|
||||
immunity". Once a population has herd immunity, the rate at which the
|
||||
virus spreads decreases.
|
||||
</p>
|
||||
<p>
|
||||
Roughly 60M people live in the UK; 60% of 60M is around 40M. Before a
|
||||
population reaches "herd immunity", the total number of <em>true
|
||||
covid-19 cases</em> <u>doubles every five days</u>. Therefore in <u>fifty
|
||||
days</u> you might expect the number of true cases to be <u>1000x
|
||||
larger</u> than what it is today.
|
||||
</p>
|
||||
<p>
|
||||
So if you think the total number of <em>true covid-19 cases</em>
|
||||
<u>today</u> is 40,000 then you might expect the rate of growth to slow
|
||||
down in a little less than two months.
|
||||
</p>
|
||||
<p>
|
||||
Thank you for reading.
|
||||
</p>
|
||||
</div>
|
||||
<footer class="pt-5 mb-8 lg:flex">
|
||||
<a class="block py-2 lg:w-1/4 text-center hover:underline" href="https://learn.wpcarro.dev">Learn</a>
|
||||
<a class="block py-2 lg:w-1/4 text-center hover:underline" href="https://blog.wpcarro.dev">Blog</a>
|
||||
<a class="block py-2 lg:w-1/4 text-center hover:underline" href="https://twitter.com/wpcarro">Twitter</a>
|
||||
<a class="block py-2 lg:w-1/4 text-center hover:underline" href="https://github.com/wpcarro">Github</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
16
users/wpcarro/website/sandbox/covid-uk/package.json
Normal file
16
users/wpcarro/website/sandbox/covid-uk/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "covid-uk",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"chart.js": "^2.9.3",
|
||||
"tailwindcss": "^1.2.0"
|
||||
}
|
||||
}
|
||||
9
users/wpcarro/website/sandbox/covid-uk/shell.nix
Normal file
9
users/wpcarro/website/sandbox/covid-uk/shell.nix
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
let
|
||||
briefcase = import <briefcase> {};
|
||||
pkgs = briefcase.third_party.pkgs;
|
||||
in pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
yarn
|
||||
nodejs
|
||||
];
|
||||
}
|
||||
28
users/wpcarro/website/sandbox/covid-uk/styles.css
Normal file
28
users/wpcarro/website/sandbox/covid-uk/styles.css
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
@tailwind base;
|
||||
|
||||
body {
|
||||
@apply font-mono;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-3xl mb-5 font-bold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl mb-5 font-bold;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl mb-5 font-bold;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-blue-600 underline;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mt-2 mb-5;
|
||||
}
|
||||
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
variants: {},
|
||||
plugins: [],
|
||||
}
|
||||
542
users/wpcarro/website/sandbox/covid-uk/yarn.lock
Normal file
542
users/wpcarro/website/sandbox/covid-uk/yarn.lock
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@types/color-name@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
|
||||
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
|
||||
|
||||
acorn-node@^1.6.1:
|
||||
version "1.8.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn-node/-/acorn-node-1.8.2.tgz#114c95d64539e53dede23de8b9d96df7c7ae2af8"
|
||||
integrity sha512-8mt+fslDufLYntIoPAaIMUe/lrbrehIiwmR3t2k9LljIzoigEPF27eLk2hy8zSGzmR/ogr7zbRKINMo1u0yh5A==
|
||||
dependencies:
|
||||
acorn "^7.0.0"
|
||||
acorn-walk "^7.0.0"
|
||||
xtend "^4.0.2"
|
||||
|
||||
acorn-walk@^7.0.0:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.1.1.tgz#345f0dffad5c735e7373d2fec9a1023e6a44b83e"
|
||||
integrity sha512-wdlPY2tm/9XBr7QkKlq0WQVgiuGTX6YWPyRyBviSoScBuLfTVQhvwg6wJ369GJ/1nPfTLMfnrFIfjqVg6d+jQQ==
|
||||
|
||||
acorn@^7.0.0:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.1.tgz#e35668de0b402f359de515c5482a1ab9f89a69bf"
|
||||
integrity sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==
|
||||
|
||||
ansi-styles@^3.2.1:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
|
||||
integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
|
||||
dependencies:
|
||||
color-convert "^1.9.0"
|
||||
|
||||
ansi-styles@^4.1.0:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359"
|
||||
integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==
|
||||
dependencies:
|
||||
"@types/color-name" "^1.1.1"
|
||||
color-convert "^2.0.1"
|
||||
|
||||
autoprefixer@^9.4.5:
|
||||
version "9.7.4"
|
||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.7.4.tgz#f8bf3e06707d047f0641d87aee8cfb174b2a5378"
|
||||
integrity sha512-g0Ya30YrMBAEZk60lp+qfX5YQllG+S5W3GYCFvyHTvhOki0AEQJLPEcIuGRsqVwLi8FvXPVtwTGhfr38hVpm0g==
|
||||
dependencies:
|
||||
browserslist "^4.8.3"
|
||||
caniuse-lite "^1.0.30001020"
|
||||
chalk "^2.4.2"
|
||||
normalize-range "^0.1.2"
|
||||
num2fraction "^1.2.2"
|
||||
postcss "^7.0.26"
|
||||
postcss-value-parser "^4.0.2"
|
||||
|
||||
balanced-match@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
|
||||
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
|
||||
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
|
||||
dependencies:
|
||||
balanced-match "^1.0.0"
|
||||
concat-map "0.0.1"
|
||||
|
||||
browserslist@^4.8.3:
|
||||
version "4.10.0"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.10.0.tgz#f179737913eaf0d2b98e4926ac1ca6a15cbcc6a9"
|
||||
integrity sha512-TpfK0TDgv71dzuTsEAlQiHeWQ/tiPqgNZVdv046fvNtBZrjbv2O3TsWCDU0AWGJJKCF/KsjNdLzR9hXOsh/CfA==
|
||||
dependencies:
|
||||
caniuse-lite "^1.0.30001035"
|
||||
electron-to-chromium "^1.3.378"
|
||||
node-releases "^1.1.52"
|
||||
pkg-up "^3.1.0"
|
||||
|
||||
bytes@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
|
||||
integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
|
||||
|
||||
camelcase-css@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5"
|
||||
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
|
||||
|
||||
caniuse-lite@^1.0.30001020, caniuse-lite@^1.0.30001035:
|
||||
version "1.0.30001035"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001035.tgz#2bb53b8aa4716b2ed08e088d4dc816a5fe089a1e"
|
||||
integrity sha512-C1ZxgkuA4/bUEdMbU5WrGY4+UhMFFiXrgNAfxiMIqWgFTWfv/xsZCS2xEHT2LMq7xAZfuAnu6mcqyDl0ZR6wLQ==
|
||||
|
||||
chalk@^2.4.1, chalk@^2.4.2:
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
|
||||
integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
|
||||
dependencies:
|
||||
ansi-styles "^3.2.1"
|
||||
escape-string-regexp "^1.0.5"
|
||||
supports-color "^5.3.0"
|
||||
|
||||
chalk@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4"
|
||||
integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==
|
||||
dependencies:
|
||||
ansi-styles "^4.1.0"
|
||||
supports-color "^7.1.0"
|
||||
|
||||
chart.js@^2.9.3:
|
||||
version "2.9.3"
|
||||
resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.9.3.tgz#ae3884114dafd381bc600f5b35a189138aac1ef7"
|
||||
integrity sha512-+2jlOobSk52c1VU6fzkh3UwqHMdSlgH1xFv9FKMqHiNCpXsGPQa/+81AFa+i3jZ253Mq9aAycPwDjnn1XbRNNw==
|
||||
dependencies:
|
||||
chartjs-color "^2.1.0"
|
||||
moment "^2.10.2"
|
||||
|
||||
chartjs-color-string@^0.6.0:
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz#1df096621c0e70720a64f4135ea171d051402f71"
|
||||
integrity sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==
|
||||
dependencies:
|
||||
color-name "^1.0.0"
|
||||
|
||||
chartjs-color@^2.1.0:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.4.1.tgz#6118bba202fe1ea79dd7f7c0f9da93467296c3b0"
|
||||
integrity sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==
|
||||
dependencies:
|
||||
chartjs-color-string "^0.6.0"
|
||||
color-convert "^1.9.3"
|
||||
|
||||
color-convert@^1.9.0, color-convert@^1.9.3:
|
||||
version "1.9.3"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
|
||||
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
|
||||
dependencies:
|
||||
color-name "1.1.3"
|
||||
|
||||
color-convert@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
|
||||
integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
|
||||
dependencies:
|
||||
color-name "~1.1.4"
|
||||
|
||||
color-name@1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
|
||||
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
|
||||
|
||||
color-name@^1.0.0, color-name@~1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
||||
concat-map@0.0.1:
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
|
||||
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
|
||||
|
||||
css-unit-converter@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/css-unit-converter/-/css-unit-converter-1.1.1.tgz#d9b9281adcfd8ced935bdbaba83786897f64e996"
|
||||
integrity sha1-2bkoGtz9jO2TW9urqDeGiX9k6ZY=
|
||||
|
||||
cssesc@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||
|
||||
defined@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
|
||||
integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=
|
||||
|
||||
detective@^5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b"
|
||||
integrity sha512-6SsIx+nUUbuK0EthKjv0zrdnajCCXVYGmbYYiYjFVpzcjwEs/JMDZ8tPRG29J/HhN56t3GJp2cGSWDRjjot8Pg==
|
||||
dependencies:
|
||||
acorn-node "^1.6.1"
|
||||
defined "^1.0.0"
|
||||
minimist "^1.1.1"
|
||||
|
||||
electron-to-chromium@^1.3.378:
|
||||
version "1.3.379"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.379.tgz#81dc5e82a3e72bbb830d93e15bc35eda2bbc910e"
|
||||
integrity sha512-NK9DBBYEBb5f9D7zXI0hiE941gq3wkBeQmXs1ingigA/jnTg5mhwY2Z5egwA+ZI8OLGKCx0h1Cl8/xeuIBuLlg==
|
||||
|
||||
escape-string-regexp@^1.0.5:
|
||||
version "1.0.5"
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
|
||||
integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
|
||||
|
||||
find-up@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
|
||||
integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==
|
||||
dependencies:
|
||||
locate-path "^3.0.0"
|
||||
|
||||
fs-extra@^8.0.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
|
||||
integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.0"
|
||||
jsonfile "^4.0.0"
|
||||
universalify "^0.1.0"
|
||||
|
||||
fs.realpath@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
|
||||
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
|
||||
|
||||
glob@^7.1.2:
|
||||
version "7.1.6"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
|
||||
integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
|
||||
dependencies:
|
||||
fs.realpath "^1.0.0"
|
||||
inflight "^1.0.4"
|
||||
inherits "2"
|
||||
minimatch "^3.0.4"
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
graceful-fs@^4.1.6, graceful-fs@^4.2.0:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423"
|
||||
integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==
|
||||
|
||||
has-flag@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
|
||||
integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
|
||||
|
||||
has-flag@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
|
||||
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
|
||||
|
||||
indexes-of@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
|
||||
integrity sha1-8w9xbI4r00bHtn0985FVZqfAVgc=
|
||||
|
||||
inflight@^1.0.4:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
|
||||
integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
|
||||
dependencies:
|
||||
once "^1.3.0"
|
||||
wrappy "1"
|
||||
|
||||
inherits@2:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
|
||||
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
|
||||
|
||||
jsonfile@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
|
||||
integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=
|
||||
optionalDependencies:
|
||||
graceful-fs "^4.1.6"
|
||||
|
||||
locate-path@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
|
||||
integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==
|
||||
dependencies:
|
||||
p-locate "^3.0.0"
|
||||
path-exists "^3.0.0"
|
||||
|
||||
lodash.toarray@^4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561"
|
||||
integrity sha1-JMS/zWsvuji/0FlNsRedjptlZWE=
|
||||
|
||||
lodash@^4.17.15:
|
||||
version "4.17.15"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
|
||||
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
|
||||
|
||||
minimatch@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
|
||||
integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
|
||||
dependencies:
|
||||
brace-expansion "^1.1.7"
|
||||
|
||||
minimist@^1.1.1:
|
||||
version "1.2.5"
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
|
||||
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
|
||||
|
||||
moment@^2.10.2:
|
||||
version "2.24.0"
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
|
||||
integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
|
||||
|
||||
node-emoji@^1.8.1:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.10.0.tgz#8886abd25d9c7bb61802a658523d1f8d2a89b2da"
|
||||
integrity sha512-Yt3384If5H6BYGVHiHwTL+99OzJKHhgp82S8/dktEK73T26BazdgZ4JZh92xSVtGNJvz9UbXdNAc5hcrXV42vw==
|
||||
dependencies:
|
||||
lodash.toarray "^4.4.0"
|
||||
|
||||
node-releases@^1.1.52:
|
||||
version "1.1.52"
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.52.tgz#bcffee3e0a758e92e44ecfaecd0a47554b0bcba9"
|
||||
integrity sha512-snSiT1UypkgGt2wxPqS6ImEUICbNCMb31yaxWrOLXjhlt2z2/IBpaOxzONExqSm4y5oLnAqjjRWu+wsDzK5yNQ==
|
||||
dependencies:
|
||||
semver "^6.3.0"
|
||||
|
||||
normalize-range@^0.1.2:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
|
||||
integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=
|
||||
|
||||
normalize.css@^8.0.1:
|
||||
version "8.0.1"
|
||||
resolved "https://registry.yarnpkg.com/normalize.css/-/normalize.css-8.0.1.tgz#9b98a208738b9cc2634caacbc42d131c97487bf3"
|
||||
integrity sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==
|
||||
|
||||
num2fraction@^1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede"
|
||||
integrity sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=
|
||||
|
||||
object-assign@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
|
||||
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
|
||||
|
||||
once@^1.3.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
|
||||
integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
|
||||
dependencies:
|
||||
wrappy "1"
|
||||
|
||||
p-limit@^2.0.0:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.2.tgz#61279b67721f5287aa1c13a9a7fbbc48c9291b1e"
|
||||
integrity sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ==
|
||||
dependencies:
|
||||
p-try "^2.0.0"
|
||||
|
||||
p-locate@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
|
||||
integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==
|
||||
dependencies:
|
||||
p-limit "^2.0.0"
|
||||
|
||||
p-try@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
|
||||
integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
|
||||
|
||||
path-exists@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
|
||||
integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
|
||||
|
||||
path-is-absolute@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
|
||||
integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
|
||||
|
||||
path-parse@^1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
|
||||
integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
|
||||
|
||||
pkg-up@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5"
|
||||
integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==
|
||||
dependencies:
|
||||
find-up "^3.0.0"
|
||||
|
||||
postcss-functions@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/postcss-functions/-/postcss-functions-3.0.0.tgz#0e94d01444700a481de20de4d55fb2640564250e"
|
||||
integrity sha1-DpTQFERwCkgd4g3k1V+yZAVkJQ4=
|
||||
dependencies:
|
||||
glob "^7.1.2"
|
||||
object-assign "^4.1.1"
|
||||
postcss "^6.0.9"
|
||||
postcss-value-parser "^3.3.0"
|
||||
|
||||
postcss-js@^2.0.0:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-2.0.3.tgz#a96f0f23ff3d08cec7dc5b11bf11c5f8077cdab9"
|
||||
integrity sha512-zS59pAk3deu6dVHyrGqmC3oDXBdNdajk4k1RyxeVXCrcEDBUBHoIhE4QTsmhxgzXxsaqFDAkUZfmMa5f/N/79w==
|
||||
dependencies:
|
||||
camelcase-css "^2.0.1"
|
||||
postcss "^7.0.18"
|
||||
|
||||
postcss-nested@^4.1.1:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-4.2.1.tgz#4bc2e5b35e3b1e481ff81e23b700da7f82a8b248"
|
||||
integrity sha512-AMayXX8tS0HCp4O4lolp4ygj9wBn32DJWXvG6gCv+ZvJrEa00GUxJcJEEzMh87BIe6FrWdYkpR2cuyqHKrxmXw==
|
||||
dependencies:
|
||||
postcss "^7.0.21"
|
||||
postcss-selector-parser "^6.0.2"
|
||||
|
||||
postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2:
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.2.tgz#934cf799d016c83411859e09dcecade01286ec5c"
|
||||
integrity sha512-36P2QR59jDTOAiIkqEprfJDsoNrvwFei3eCqKd1Y0tUsBimsq39BLp7RD+JWny3WgB1zGhJX8XVePwm9k4wdBg==
|
||||
dependencies:
|
||||
cssesc "^3.0.0"
|
||||
indexes-of "^1.0.1"
|
||||
uniq "^1.0.1"
|
||||
|
||||
postcss-value-parser@^3.3.0:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
|
||||
integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==
|
||||
|
||||
postcss-value-parser@^4.0.2:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz#651ff4593aa9eda8d5d0d66593a2417aeaeb325d"
|
||||
integrity sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg==
|
||||
|
||||
postcss@^6.0.9:
|
||||
version "6.0.23"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324"
|
||||
integrity sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==
|
||||
dependencies:
|
||||
chalk "^2.4.1"
|
||||
source-map "^0.6.1"
|
||||
supports-color "^5.4.0"
|
||||
|
||||
postcss@^7.0.11, postcss@^7.0.18, postcss@^7.0.21, postcss@^7.0.26:
|
||||
version "7.0.27"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.27.tgz#cc67cdc6b0daa375105b7c424a85567345fc54d9"
|
||||
integrity sha512-WuQETPMcW9Uf1/22HWUWP9lgsIC+KEHg2kozMflKjbeUtw9ujvFX6QmIfozaErDkmLWS9WEnEdEe6Uo9/BNTdQ==
|
||||
dependencies:
|
||||
chalk "^2.4.2"
|
||||
source-map "^0.6.1"
|
||||
supports-color "^6.1.0"
|
||||
|
||||
pretty-hrtime@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
|
||||
integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
|
||||
|
||||
reduce-css-calc@^2.1.6:
|
||||
version "2.1.7"
|
||||
resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-2.1.7.tgz#1ace2e02c286d78abcd01fd92bfe8097ab0602c2"
|
||||
integrity sha512-fDnlZ+AybAS3C7Q9xDq5y8A2z+lT63zLbynew/lur/IR24OQF5x98tfNwf79mzEdfywZ0a2wpM860FhFfMxZlA==
|
||||
dependencies:
|
||||
css-unit-converter "^1.1.1"
|
||||
postcss-value-parser "^3.3.0"
|
||||
|
||||
resolve@^1.14.2:
|
||||
version "1.15.1"
|
||||
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.1.tgz#27bdcdeffeaf2d6244b95bb0f9f4b4653451f3e8"
|
||||
integrity sha512-84oo6ZTtoTUpjgNEr5SJyzQhzL72gaRodsSfyxC/AXRvwu0Yse9H8eF9IpGo7b8YetZhlI6v7ZQ6bKBFV/6S7w==
|
||||
dependencies:
|
||||
path-parse "^1.0.6"
|
||||
|
||||
semver@^6.3.0:
|
||||
version "6.3.0"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
|
||||
integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
|
||||
|
||||
source-map@^0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
||||
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
||||
|
||||
supports-color@^5.3.0, supports-color@^5.4.0:
|
||||
version "5.5.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
|
||||
integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
|
||||
dependencies:
|
||||
has-flag "^3.0.0"
|
||||
|
||||
supports-color@^6.1.0:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
|
||||
integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==
|
||||
dependencies:
|
||||
has-flag "^3.0.0"
|
||||
|
||||
supports-color@^7.1.0:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1"
|
||||
integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==
|
||||
dependencies:
|
||||
has-flag "^4.0.0"
|
||||
|
||||
tailwindcss@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-1.2.0.tgz#5df317cebac4f3131f275d258a39da1ba3a0f291"
|
||||
integrity sha512-CKvY0ytB3ze5qvynG7qv4XSpQtFNGPbu9pUn8qFdkqgD8Yo/vGss8mhzbqls44YCXTl4G62p3qVZBj45qrd6FQ==
|
||||
dependencies:
|
||||
autoprefixer "^9.4.5"
|
||||
bytes "^3.0.0"
|
||||
chalk "^3.0.0"
|
||||
detective "^5.2.0"
|
||||
fs-extra "^8.0.0"
|
||||
lodash "^4.17.15"
|
||||
node-emoji "^1.8.1"
|
||||
normalize.css "^8.0.1"
|
||||
postcss "^7.0.11"
|
||||
postcss-functions "^3.0.0"
|
||||
postcss-js "^2.0.0"
|
||||
postcss-nested "^4.1.1"
|
||||
postcss-selector-parser "^6.0.0"
|
||||
pretty-hrtime "^1.0.3"
|
||||
reduce-css-calc "^2.1.6"
|
||||
resolve "^1.14.2"
|
||||
|
||||
uniq@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff"
|
||||
integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=
|
||||
|
||||
universalify@^0.1.0:
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
|
||||
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
|
||||
|
||||
wrappy@1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
|
||||
integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
|
||||
|
||||
xtend@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
|
||||
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
||||
13
users/wpcarro/website/sandbox/default.nix.ignore
Normal file
13
users/wpcarro/website/sandbox/default.nix.ignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{ pkgs, briefcase, ... }:
|
||||
|
||||
pkgs.stdenv.mkDerivation {
|
||||
name = "covid-uk";
|
||||
buildInputs = [];
|
||||
src = builtins.path { path = ./.; name = "sandbox"; };
|
||||
buildPhase = ''
|
||||
mkdir -p $out
|
||||
cp $src/index.html $out
|
||||
cp -r ${briefcase.website.sandbox.covid-uk} $out/covid-uk
|
||||
'';
|
||||
dontInstall = true;
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# Github Issues Service (GIS)
|
||||
|
||||
> 'Cause I got issues. But you got 'em too...
|
||||
> - [Issues by Julia Michaels][issues]
|
||||
|
||||
You have a website and your users want to request features or report bugs. How
|
||||
do they do this?
|
||||
|
||||
Our robot, GIS, can help you. GIS adds a widget to your website that allows
|
||||
users to easily request features and report bugs.
|
||||
|
||||
## Getting Started
|
||||
|
||||
If Github is hosting your website's source code, you're ready to start using
|
||||
GIS. GIS works with public and private repositories.
|
||||
|
||||
Let's adopt Github's notion of "issues" to group feature requests and bug
|
||||
reports together. When users click the GIS widget to create an issue, GIS
|
||||
displays a modal form that the user completes. When the user submits the form,
|
||||
GIS creates an issue on your Github repository. Now your team can use all of
|
||||
Github's rich issue-tracking tools to manage your issues.
|
||||
|
||||
## Installation
|
||||
|
||||
To add GIS to your website, register your Github repository with us and we'll
|
||||
give you a snippet to add to your website's HTML. It's that simple.
|
||||
|
||||
[issues]: https://www.youtube.com/watch?v=9Ke4480MicU
|
||||
15
users/wpcarro/website/sandbox/index.html
Normal file
15
users/wpcarro/website/sandbox/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>sandbox.wpcarro.dev</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Projects</h1>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/covid-uk">COVID-19 in the UK</a>
|
||||
</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
2
users/wpcarro/website/sandbox/learnpianochords/.envrc
Normal file
2
users/wpcarro/website/sandbox/learnpianochords/.envrc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
source_up
|
||||
use_nix
|
||||
3
users/wpcarro/website/sandbox/learnpianochords/.gitignore
vendored
Normal file
3
users/wpcarro/website/sandbox/learnpianochords/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/elm-stuff
|
||||
/Main.min.js
|
||||
/output.css
|
||||
57
users/wpcarro/website/sandbox/learnpianochords/README.md
Normal file
57
users/wpcarro/website/sandbox/learnpianochords/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Learn Piano Chords (LPC)
|
||||
|
||||
Are you a musician looking for a more effective way to improve your craft? Maybe
|
||||
you're a music teacher looking to create useful exercises to give your students.
|
||||
|
||||
Studying music theory can be a fruitful undertaking, but it can often overwhelm
|
||||
or bore students. I think that if practicing is enjoyable, students will
|
||||
practice more. Practice doesn't make perfect; *perfect* practice makes perfect.
|
||||
Learn Piano Chords is a web app that lowers the barrier to practicing and
|
||||
internalizing music theory.
|
||||
|
||||
## How does it work?
|
||||
|
||||
1. Grab a cell phone or a laptop and your instrument.
|
||||
2. Open a web browser and visit the Learn Piano Chords app (URL and app
|
||||
forthcoming).
|
||||
6. Set the tempo (i.e. pace) at which you would like to practice.
|
||||
4. Set the target duration of your session.
|
||||
5. Select the key(s) and chord(s) you would like to practice.
|
||||
7. LPC will display chords at various rhythmic intervals during your practice
|
||||
session. It is your job to play these chords in time before the next chord
|
||||
appears.
|
||||
|
||||
## Highlights
|
||||
|
||||
Here are some useful features of LPC:
|
||||
- Tempo: Set the rate at which LPC displays chords.
|
||||
- Predefined practice sessions: LPC offers users a few practice sessions to get
|
||||
users started. The goal, however, is to teach users to create their own
|
||||
bespoke practice sessions. LPC aims to foster a community of practitioners who
|
||||
curate and share their practice sessions.
|
||||
- Whitelist / blacklist: Construct the set of chords you would like to
|
||||
practice. Let's say you only want to practice triads in the keys of F, C, and
|
||||
G. Would you also like to avoid diminished chords? Or maybe you *only* want to
|
||||
practice major-7th chords for *all* keys. LPC supports all of these scenarios
|
||||
and many others. You can save these chord configurations to reuse them at any
|
||||
time. You can also share chord configurations with other LPC users if you find
|
||||
the practice useful.
|
||||
- Inversions: Every chord has inversions. For instance, every triad (i.e. chord
|
||||
composed of three notes) has three inversions: root, second, and third
|
||||
positions. LPC acknowledges all of the positions in which chords may appear
|
||||
and helps you study all, some, or none of these inversions.
|
||||
- Harmony: LPC understands basic harmony and can sort the chords you would like
|
||||
to train in various harmonious permutations.
|
||||
- Chaos-mode: Feeling confident? Throw the classical notions of harmony to the
|
||||
wayside and use LPC in "chaos-mode" where LPC samples randomly from the Circle
|
||||
of Fifths.
|
||||
|
||||
## Developing
|
||||
|
||||
If you're interested in contributing, the following will create an environment
|
||||
in which you can develop:
|
||||
|
||||
```shell
|
||||
$ nix-shell
|
||||
$ elm-live -- src/Main.elm --output=elm.js
|
||||
```
|
||||
60
users/wpcarro/website/sandbox/learnpianochords/default.nix
Normal file
60
users/wpcarro/website/sandbox/learnpianochords/default.nix
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{ pkgs ? <nixpkgs>, ... }:
|
||||
|
||||
with pkgs;
|
||||
|
||||
let
|
||||
mkDerivation =
|
||||
{ srcs ? ./elm-srcs.nix
|
||||
, src
|
||||
, name
|
||||
, srcdir ? "./src"
|
||||
, targets ? []
|
||||
, registryDat ? ./registry.dat
|
||||
, outputJavaScript ? false
|
||||
}:
|
||||
stdenv.mkDerivation {
|
||||
inherit name src;
|
||||
|
||||
buildInputs = [ elmPackages.elm ]
|
||||
++ lib.optional outputJavaScript nodePackages_10_x.uglify-js;
|
||||
|
||||
buildPhase = pkgs.elmPackages.fetchElmDeps {
|
||||
elmPackages = import srcs;
|
||||
elmVersion = "0.19.1";
|
||||
inherit registryDat;
|
||||
};
|
||||
|
||||
installPhase = let
|
||||
elmfile = module: "${srcdir}/${builtins.replaceStrings ["."] ["/"] module}.elm";
|
||||
extension = if outputJavaScript then "js" else "html";
|
||||
in ''
|
||||
mkdir -p $out/share/doc
|
||||
${lib.concatStrings (map (module: ''
|
||||
echo "compiling ${elmfile module}"
|
||||
elm make ${elmfile module} --output $out/${module}.${extension} --docs $out/share/doc/${module}.json
|
||||
${lib.optionalString outputJavaScript ''
|
||||
echo "minifying ${elmfile module}"
|
||||
uglifyjs $out/${module}.${extension} --compress 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe' \
|
||||
| uglifyjs --mangle --output=$out/${module}.min.${extension}
|
||||
''}
|
||||
'') targets)}
|
||||
'';
|
||||
};
|
||||
mainDotElm = mkDerivation {
|
||||
name = "elm-app-0.1.0";
|
||||
srcs = ./elm-srcs.nix;
|
||||
src = builtins.path { path = ./.; name = "learnpianochords"; };
|
||||
targets = ["Main"];
|
||||
srcdir = "./src";
|
||||
outputJavaScript = true;
|
||||
};
|
||||
in stdenv.mkDerivation {
|
||||
name = "learn-piano-chords";
|
||||
buildInputs = [];
|
||||
src = builtins.path { path = ./.; name = "learnpianochords"; };
|
||||
buildPhase = ''
|
||||
mkdir -p $out
|
||||
cp index.html output.css ${mainDotElm}/Main.min.js $out
|
||||
'';
|
||||
dontInstall = true;
|
||||
}
|
||||
67
users/wpcarro/website/sandbox/learnpianochords/elm-srcs.nix
Normal file
67
users/wpcarro/website/sandbox/learnpianochords/elm-srcs.nix
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{
|
||||
|
||||
"elm-community/maybe-extra" = {
|
||||
sha256 = "0qslmgswa625d218djd3p62pnqcrz38f5p558mbjl6kc1ss0kzv3";
|
||||
version = "5.2.0";
|
||||
};
|
||||
|
||||
"elm/html" = {
|
||||
sha256 = "1n3gpzmpqqdsldys4ipgyl1zacn0kbpc3g4v3hdpiyfjlgh8bf3k";
|
||||
version = "1.0.0";
|
||||
};
|
||||
|
||||
"elm-community/random-extra" = {
|
||||
sha256 = "1dg2nz77w2cvp16xazbdsxkkw0xc9ycqpkd032faqdyky6gmz9g6";
|
||||
version = "3.1.0";
|
||||
};
|
||||
|
||||
"elm/svg" = {
|
||||
sha256 = "1cwcj73p61q45wqwgqvrvz3aypjyy3fw732xyxdyj6s256hwkn0k";
|
||||
version = "1.0.1";
|
||||
};
|
||||
|
||||
"elm/browser" = {
|
||||
sha256 = "0nagb9ajacxbbg985r4k9h0jadqpp0gp84nm94kcgbr5sf8i9x13";
|
||||
version = "1.0.2";
|
||||
};
|
||||
|
||||
"elm/core" = {
|
||||
sha256 = "19w0iisdd66ywjayyga4kv2p1v9rxzqjaxhckp8ni6n8i0fb2dvf";
|
||||
version = "1.0.5";
|
||||
};
|
||||
|
||||
"elm-community/list-extra" = {
|
||||
sha256 = "1ayv3148drynqnxdfwpjxal8vwzgsjqanjg7yxp6lhdcbkxgd3vd";
|
||||
version = "8.2.3";
|
||||
};
|
||||
|
||||
"elm/random" = {
|
||||
sha256 = "138n2455wdjwa657w6sjq18wx2r0k60ibpc4frhbqr50sncxrfdl";
|
||||
version = "1.0.0";
|
||||
};
|
||||
|
||||
"elm/time" = {
|
||||
sha256 = "0vch7i86vn0x8b850w1p69vplll1bnbkp8s383z7pinyg94cm2z1";
|
||||
version = "1.0.0";
|
||||
};
|
||||
|
||||
"elm/json" = {
|
||||
sha256 = "0kjwrz195z84kwywaxhhlnpl3p251qlbm5iz6byd6jky2crmyqyh";
|
||||
version = "1.1.3";
|
||||
};
|
||||
|
||||
"owanturist/elm-union-find" = {
|
||||
sha256 = "13gm7msnp0gr1lqia5m7m4lhy3m6kvjg37d304whb3psn88wqhj5";
|
||||
version = "1.0.0";
|
||||
};
|
||||
|
||||
"elm/url" = {
|
||||
sha256 = "0av8x5syid40sgpl5vd7pry2rq0q4pga28b4yykn9gd9v12rs3l4";
|
||||
version = "1.0.0";
|
||||
};
|
||||
|
||||
"elm/virtual-dom" = {
|
||||
sha256 = "0q1v5gi4g336bzz1lgwpn5b1639lrn63d8y6k6pimcyismp2i1yg";
|
||||
version = "1.0.2";
|
||||
};
|
||||
}
|
||||
30
users/wpcarro/website/sandbox/learnpianochords/elm.json
Normal file
30
users/wpcarro/website/sandbox/learnpianochords/elm.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"type": "application",
|
||||
"source-directories": [
|
||||
"src"
|
||||
],
|
||||
"elm-version": "0.19.1",
|
||||
"dependencies": {
|
||||
"direct": {
|
||||
"elm/browser": "1.0.2",
|
||||
"elm/core": "1.0.5",
|
||||
"elm/html": "1.0.0",
|
||||
"elm/random": "1.0.0",
|
||||
"elm/svg": "1.0.1",
|
||||
"elm/time": "1.0.0",
|
||||
"elm-community/list-extra": "8.2.3",
|
||||
"elm-community/maybe-extra": "5.2.0",
|
||||
"elm-community/random-extra": "3.1.0"
|
||||
},
|
||||
"indirect": {
|
||||
"elm/json": "1.1.3",
|
||||
"elm/url": "1.0.0",
|
||||
"elm/virtual-dom": "1.0.2",
|
||||
"owanturist/elm-union-find": "1.0.0"
|
||||
}
|
||||
},
|
||||
"test-dependencies": {
|
||||
"direct": {},
|
||||
"indirect": {}
|
||||
}
|
||||
}
|
||||
3
users/wpcarro/website/sandbox/learnpianochords/ideas.org
Normal file
3
users/wpcarro/website/sandbox/learnpianochords/ideas.org
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
* Support a frequency table of all of the chords
|
||||
* Support using spaced-repetition to help populate the frequency table of chords
|
||||
* If doing a frequency table, support left and right hands
|
||||
3
users/wpcarro/website/sandbox/learnpianochords/index.css
Normal file
3
users/wpcarro/website/sandbox/learnpianochords/index.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
15
users/wpcarro/website/sandbox/learnpianochords/index.html
Normal file
15
users/wpcarro/website/sandbox/learnpianochords/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Learn Piano Chords</title>
|
||||
<link rel="stylesheet" href="./output.css" />
|
||||
<script src="./Main.min.js"></script>
|
||||
</head>
|
||||
<body class="font-serif">
|
||||
<div id="mount"></div>
|
||||
<script>
|
||||
Elm.Main.init({node: document.getElementById("mount")});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
users/wpcarro/website/sandbox/learnpianochords/registry.dat
Normal file
BIN
users/wpcarro/website/sandbox/learnpianochords/registry.dat
Normal file
Binary file not shown.
10
users/wpcarro/website/sandbox/learnpianochords/shell.nix
Normal file
10
users/wpcarro/website/sandbox/learnpianochords/shell.nix
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
let
|
||||
briefcase = import <briefcase> {};
|
||||
pkgs = briefcase.third_party.pkgs;
|
||||
in pkgs.mkShell {
|
||||
buildInputs = with pkgs.elmPackages; [
|
||||
elm
|
||||
elm-format
|
||||
elm-live
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
module FlashCard exposing (render)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Responsive
|
||||
import State
|
||||
import Tailwind
|
||||
import Theory
|
||||
|
||||
|
||||
render :
|
||||
{ chord : Theory.Chord
|
||||
, visible : Bool
|
||||
}
|
||||
-> Html State.Msg
|
||||
render { chord, visible } =
|
||||
let
|
||||
classes =
|
||||
[ "bg-white"
|
||||
, "fixed"
|
||||
, "top-0"
|
||||
, "left-0"
|
||||
, "z-30"
|
||||
, "w-screen"
|
||||
, "h-screen"
|
||||
, Tailwind.if_ visible "opacity-100" "opacity-0"
|
||||
]
|
||||
in
|
||||
button
|
||||
[ classes |> Tailwind.use |> class ]
|
||||
[ h1
|
||||
[ [ "text-center"
|
||||
, "transform"
|
||||
, "-rotate-90"
|
||||
, Responsive.h1
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ text (Theory.viewChord chord) ]
|
||||
]
|
||||
44
users/wpcarro/website/sandbox/learnpianochords/src/Icon.elm
Normal file
44
users/wpcarro/website/sandbox/learnpianochords/src/Icon.elm
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
module Icon exposing (..)
|
||||
|
||||
import Svg exposing (node, svg)
|
||||
import Svg.Attributes exposing (..)
|
||||
import UI
|
||||
|
||||
|
||||
svgColor color =
|
||||
let
|
||||
classes =
|
||||
case color of
|
||||
UI.Primary ->
|
||||
[ "text-gray-500", "fill-current" ]
|
||||
|
||||
UI.Secondary ->
|
||||
[ "text-gray-300", "fill-current" ]
|
||||
in
|
||||
class <| String.join " " classes
|
||||
|
||||
|
||||
cog =
|
||||
svg [ class "icon-cog", viewBox "0 0 24 24", xmlLang "http://www.w3.org/2000/svg" ]
|
||||
[ Svg.path
|
||||
[ svgColor UI.Primary
|
||||
, d "M6.8 3.45c.87-.52 1.82-.92 2.83-1.17a2.5 2.5 0 0 0 4.74 0c1.01.25 1.96.65 2.82 1.17a2.5 2.5 0 0 0 3.36 3.36c.52.86.92 1.8 1.17 2.82a2.5 2.5 0 0 0 0 4.74c-.25 1.01-.65 1.96-1.17 2.82a2.5 2.5 0 0 0-3.36 3.36c-.86.52-1.8.92-2.82 1.17a2.5 2.5 0 0 0-4.74 0c-1.01-.25-1.96-.65-2.82-1.17a2.5 2.5 0 0 0-3.36-3.36 9.94 9.94 0 0 1-1.17-2.82 2.5 2.5 0 0 0 0-4.74c.25-1.01.65-1.96 1.17-2.82a2.5 2.5 0 0 0 3.36-3.36zM12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"
|
||||
, fill "red"
|
||||
]
|
||||
[]
|
||||
, node "circle"
|
||||
[ svgColor UI.Secondary, cx "12", cy "12", r "2" ]
|
||||
[]
|
||||
]
|
||||
|
||||
|
||||
close =
|
||||
svg [ class "icon-close", viewBox "0 0 24 24", xmlLang "http://www.w3.org/2000/svg" ]
|
||||
[ Svg.path
|
||||
[ svgColor UI.Primary
|
||||
, d "M15.78 14.36a1 1 0 0 1-1.42 1.42l-2.82-2.83-2.83 2.83a1 1 0 1 1-1.42-1.42l2.83-2.82L7.3 8.7a1 1 0 0 1 1.42-1.42l2.83 2.83 2.82-2.83a1 1 0 0 1 1.42 1.42l-2.83 2.83 2.83 2.82z"
|
||||
, fill "red"
|
||||
, fillRule "evenodd"
|
||||
]
|
||||
[]
|
||||
]
|
||||
44
users/wpcarro/website/sandbox/learnpianochords/src/Main.elm
Normal file
44
users/wpcarro/website/sandbox/learnpianochords/src/Main.elm
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
module Main exposing (main)
|
||||
|
||||
import Browser
|
||||
import Html exposing (..)
|
||||
import Misc
|
||||
import Overview
|
||||
import Practice
|
||||
import Preferences
|
||||
import State
|
||||
import Time exposing (..)
|
||||
|
||||
|
||||
subscriptions : State.Model -> Sub State.Msg
|
||||
subscriptions model =
|
||||
if model.isPaused then
|
||||
Sub.none
|
||||
|
||||
else
|
||||
Sub.batch
|
||||
[ Time.every (model.tempo * 2 |> Misc.bpmToMilliseconds |> toFloat) (\_ -> State.ToggleFlashCard)
|
||||
, Time.every (model.tempo |> Misc.bpmToMilliseconds |> toFloat) (\_ -> State.NextChord)
|
||||
]
|
||||
|
||||
|
||||
view : State.Model -> Html State.Msg
|
||||
view model =
|
||||
case model.view of
|
||||
State.Preferences ->
|
||||
Preferences.render model
|
||||
|
||||
State.Practice ->
|
||||
Practice.render model
|
||||
|
||||
State.Overview ->
|
||||
Overview.render model
|
||||
|
||||
|
||||
main =
|
||||
Browser.element
|
||||
{ init = \() -> ( State.init, Cmd.none )
|
||||
, subscriptions = subscriptions
|
||||
, update = State.update
|
||||
, view = view
|
||||
}
|
||||
59
users/wpcarro/website/sandbox/learnpianochords/src/Misc.elm
Normal file
59
users/wpcarro/website/sandbox/learnpianochords/src/Misc.elm
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
module Misc exposing (..)
|
||||
|
||||
import Array exposing (Array)
|
||||
|
||||
|
||||
comesAfter : a -> List a -> Maybe a
|
||||
comesAfter x xs =
|
||||
case xs of
|
||||
[] ->
|
||||
Nothing
|
||||
|
||||
_ :: [] ->
|
||||
Nothing
|
||||
|
||||
y :: z :: rest ->
|
||||
if y == x then
|
||||
Just z
|
||||
|
||||
else
|
||||
comesAfter x (z :: rest)
|
||||
|
||||
|
||||
comesBefore : a -> List a -> Maybe a
|
||||
comesBefore x xs =
|
||||
case xs of
|
||||
[] ->
|
||||
Nothing
|
||||
|
||||
_ :: [] ->
|
||||
Nothing
|
||||
|
||||
y :: z :: rest ->
|
||||
if z == x then
|
||||
Just y
|
||||
|
||||
else
|
||||
comesBefore x (z :: rest)
|
||||
|
||||
|
||||
find : (a -> Bool) -> List a -> Maybe a
|
||||
find pred xs =
|
||||
case xs |> List.filter pred of
|
||||
[] ->
|
||||
Nothing
|
||||
|
||||
x :: _ ->
|
||||
Just x
|
||||
|
||||
|
||||
{-| Return the number of milliseconds that elapse during an interval in a
|
||||
`target` bpm.
|
||||
-}
|
||||
bpmToMilliseconds : Int -> Int
|
||||
bpmToMilliseconds target =
|
||||
let
|
||||
msPerMinute =
|
||||
1000 * 60
|
||||
in
|
||||
round (toFloat msPerMinute / toFloat target)
|
||||
122
users/wpcarro/website/sandbox/learnpianochords/src/Overview.elm
Normal file
122
users/wpcarro/website/sandbox/learnpianochords/src/Overview.elm
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
module Overview exposing (render)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Responsive
|
||||
import State
|
||||
import Tailwind
|
||||
import UI
|
||||
|
||||
|
||||
header1 : String -> Html msg
|
||||
header1 copy =
|
||||
h2
|
||||
[ [ "text-center"
|
||||
, "pt-24"
|
||||
, "pb-12"
|
||||
, Responsive.h1
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ text copy ]
|
||||
|
||||
|
||||
header2 : String -> Html msg
|
||||
header2 copy =
|
||||
h2
|
||||
[ [ "text-center"
|
||||
, "pb-10"
|
||||
, Responsive.h2
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ text copy ]
|
||||
|
||||
|
||||
paragraph : String -> Html msg
|
||||
paragraph copy =
|
||||
p
|
||||
[ [ "pb-10"
|
||||
, Responsive.h3
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ text copy ]
|
||||
|
||||
|
||||
sect : { title : String, copy : List String } -> Html msg
|
||||
sect { title, copy } =
|
||||
section [] (header2 title :: (copy |> List.map paragraph))
|
||||
|
||||
|
||||
numberedList : List String -> Html msg
|
||||
numberedList items =
|
||||
ol
|
||||
[ [ "list-inside"
|
||||
, "list-decimal"
|
||||
, Responsive.h3
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
(items |> List.map (\x -> li [ [ "pb-10" ] |> Tailwind.use |> class ] [ text x ]))
|
||||
|
||||
|
||||
render : State.Model -> Html State.Msg
|
||||
render model =
|
||||
div [ [ "container", "mx-auto" ] |> Tailwind.use |> class ]
|
||||
[ header1 "Welcome to LearnPianoChords.app!"
|
||||
, paragraph """
|
||||
Learn Piano Chords helps piano players master chords.
|
||||
"""
|
||||
, paragraph """
|
||||
Chords are the building blocks songwriters use to create
|
||||
music. Whether you're a performer or songwriter, you need
|
||||
to understand chords to unlock your full musical potential.
|
||||
"""
|
||||
, paragraph """
|
||||
I think that if practicing is enjoyable, students will
|
||||
practice more. Practice doesn’t make perfect; perfect
|
||||
practice makes perfect.
|
||||
"""
|
||||
, section []
|
||||
[ header2 "Ready to get started?"
|
||||
, numberedList
|
||||
[ """
|
||||
Sit down at the piano.
|
||||
"""
|
||||
, """
|
||||
Set the tempo at which you would like to practice.
|
||||
"""
|
||||
, """
|
||||
Select the key or keys in which you would like to
|
||||
practice.
|
||||
"""
|
||||
, """
|
||||
When you are ready, close the preferences pane. We will show
|
||||
you the name of a chord, and you should play that chord on
|
||||
the piano.
|
||||
"""
|
||||
, """
|
||||
If you don't know how to play the chord, toggle the piano
|
||||
viewer to see the notes.
|
||||
"""
|
||||
, """
|
||||
At any point while you're training, press the screen to pause
|
||||
or resume your practice.
|
||||
"""
|
||||
]
|
||||
]
|
||||
, div [ [ "text-center", "py-20" ] |> Tailwind.use |> class ]
|
||||
[ UI.simpleButton
|
||||
{ label = "Let's get started"
|
||||
, handleClick = State.SetView State.Preferences
|
||||
, color = UI.Secondary
|
||||
, classes = []
|
||||
}
|
||||
]
|
||||
]
|
||||
194
users/wpcarro/website/sandbox/learnpianochords/src/Piano.elm
Normal file
194
users/wpcarro/website/sandbox/learnpianochords/src/Piano.elm
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
module Piano exposing (render)
|
||||
|
||||
import Browser
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import List.Extra
|
||||
import Theory
|
||||
import UI
|
||||
|
||||
|
||||
type alias KeyMarkup a =
|
||||
{ offset : Int
|
||||
, isHighlit : Bool
|
||||
, note : Theory.Note
|
||||
, isRootNote : Bool
|
||||
}
|
||||
-> Html a
|
||||
|
||||
|
||||
type alias Props =
|
||||
{ chord : Maybe Theory.Chord
|
||||
, firstNote : Theory.Note
|
||||
, lastNote : Theory.Note
|
||||
}
|
||||
|
||||
|
||||
naturalThickness : Int
|
||||
naturalThickness =
|
||||
105
|
||||
|
||||
|
||||
accidentalThickness : Int
|
||||
accidentalThickness =
|
||||
round (toFloat naturalThickness / 2.0)
|
||||
|
||||
|
||||
{-| Convert an integer into its pixel representation for CSS.
|
||||
-}
|
||||
pixelate : Int -> String
|
||||
pixelate x =
|
||||
String.fromInt x ++ "px"
|
||||
|
||||
|
||||
{-| Return the markup for either a white or a black key.
|
||||
-}
|
||||
pianoKey : KeyMarkup a
|
||||
pianoKey { offset, isHighlit, note, isRootNote } =
|
||||
let
|
||||
{ natColor, accColor, hiColor, rootColor } =
|
||||
{ natColor = "bg-white"
|
||||
, accColor = "bg-black"
|
||||
, hiColor = "bg-red-400"
|
||||
, rootColor = "bg-red-600"
|
||||
}
|
||||
|
||||
sharedClasses =
|
||||
[ "box-border"
|
||||
, "absolute"
|
||||
, "border"
|
||||
, "border-black"
|
||||
]
|
||||
|
||||
{ keyLength, keyThickness, keyColor, offsetEdge, extraClasses } =
|
||||
case Theory.keyClass note of
|
||||
Theory.Natural ->
|
||||
{ keyLength = "w-screen"
|
||||
, keyThickness = naturalThickness
|
||||
, keyColor = natColor
|
||||
, offsetEdge = "top"
|
||||
, extraClasses = []
|
||||
}
|
||||
|
||||
Theory.Accidental ->
|
||||
{ keyLength = "w-2/3"
|
||||
, keyThickness = accidentalThickness
|
||||
, keyColor = accColor
|
||||
, offsetEdge = "top"
|
||||
, extraClasses = [ "z-10" ]
|
||||
}
|
||||
in
|
||||
div
|
||||
[ class
|
||||
(case ( isHighlit, isRootNote ) of
|
||||
( False, _ ) ->
|
||||
keyColor
|
||||
|
||||
( True, True ) ->
|
||||
rootColor
|
||||
|
||||
( True, False ) ->
|
||||
hiColor
|
||||
)
|
||||
, class keyLength
|
||||
, style "height" (pixelate keyThickness)
|
||||
, style offsetEdge (String.fromInt offset ++ "px")
|
||||
, class <| String.join " " (List.concat [ sharedClasses, extraClasses ])
|
||||
]
|
||||
[]
|
||||
|
||||
|
||||
{-| A section of the piano consisting of all twelve notes.
|
||||
-}
|
||||
keys :
|
||||
{ start : Theory.Note
|
||||
, end : Theory.Note
|
||||
, highlitNotes : List Theory.Note
|
||||
, rootNote : Maybe Theory.Note
|
||||
}
|
||||
-> List (Html a)
|
||||
keys { start, end, highlitNotes, rootNote } =
|
||||
let
|
||||
isHighlit note =
|
||||
List.member note highlitNotes
|
||||
|
||||
spacing prevOffset prev curr =
|
||||
case ( Theory.keyClass prev, Theory.keyClass curr ) of
|
||||
( Theory.Natural, Theory.Accidental ) ->
|
||||
prevOffset + naturalThickness - round (toFloat accidentalThickness / 2)
|
||||
|
||||
( Theory.Accidental, Theory.Natural ) ->
|
||||
prevOffset + round (toFloat accidentalThickness / 2)
|
||||
|
||||
( Theory.Natural, Theory.Natural ) ->
|
||||
prevOffset + naturalThickness
|
||||
|
||||
-- This pattern should never hit.
|
||||
_ ->
|
||||
prevOffset
|
||||
|
||||
( _, _, notes ) =
|
||||
Theory.notesFromRange start end
|
||||
|> List.reverse
|
||||
|> List.foldl
|
||||
(\curr ( prevOffset, prev, result ) ->
|
||||
case ( prevOffset, prev ) of
|
||||
( Nothing, Nothing ) ->
|
||||
( Just 0
|
||||
, Just curr
|
||||
, pianoKey
|
||||
{ offset = 0
|
||||
, isHighlit = List.member curr highlitNotes
|
||||
, note = curr
|
||||
, isRootNote =
|
||||
rootNote
|
||||
|> Maybe.map (\x -> x == curr)
|
||||
|> Maybe.withDefault False
|
||||
}
|
||||
:: result
|
||||
)
|
||||
|
||||
( Just po, Just p ) ->
|
||||
let
|
||||
offset =
|
||||
spacing po p curr
|
||||
in
|
||||
( Just offset
|
||||
, Just curr
|
||||
, pianoKey
|
||||
{ offset = offset
|
||||
, isHighlit = List.member curr highlitNotes
|
||||
, note = curr
|
||||
, isRootNote =
|
||||
rootNote
|
||||
|> Maybe.map (\x -> x == curr)
|
||||
|> Maybe.withDefault False
|
||||
}
|
||||
:: result
|
||||
)
|
||||
|
||||
-- This pattern should never hit.
|
||||
_ ->
|
||||
( Nothing, Nothing, [] )
|
||||
)
|
||||
( Nothing, Nothing, [] )
|
||||
in
|
||||
notes
|
||||
|
||||
|
||||
{-| Return the HTML that renders a piano representation.
|
||||
-}
|
||||
render : Props -> Html a
|
||||
render { chord } =
|
||||
div [ style "display" "flex" ]
|
||||
(keys
|
||||
{ start = Theory.G3
|
||||
, end = Theory.C6
|
||||
, rootNote = chord |> Maybe.map .note
|
||||
, highlitNotes =
|
||||
chord
|
||||
|> Maybe.andThen Theory.notesForChord
|
||||
|> Maybe.withDefault []
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
module Practice exposing (render)
|
||||
|
||||
import FlashCard
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Icon
|
||||
import Piano
|
||||
import State
|
||||
import Tailwind
|
||||
import Theory
|
||||
import UI
|
||||
|
||||
|
||||
openPreferences : Html State.Msg
|
||||
openPreferences =
|
||||
button
|
||||
[ class "w-48 h-48 absolute left-0 top-0 z-50"
|
||||
, onClick (State.SetView State.Preferences)
|
||||
]
|
||||
[ Icon.cog ]
|
||||
|
||||
|
||||
render : State.Model -> Html State.Msg
|
||||
render model =
|
||||
let
|
||||
( handleClick, buttonText ) =
|
||||
if model.isPaused then
|
||||
( State.Play, "Tap to practice" )
|
||||
|
||||
else
|
||||
( State.Pause, "" )
|
||||
in
|
||||
div []
|
||||
[ openPreferences
|
||||
, case model.selectedChord of
|
||||
Just chord ->
|
||||
FlashCard.render
|
||||
{ chord = chord
|
||||
, visible = model.showFlashCard
|
||||
}
|
||||
|
||||
Nothing ->
|
||||
-- Here I'm abusing the overlayButton component to render text
|
||||
-- horizontally. I should support a UI component for this.
|
||||
UI.overlayButton
|
||||
{ label = "Get ready..."
|
||||
, handleClick = State.DoNothing
|
||||
, isVisible = True
|
||||
}
|
||||
, UI.overlayButton
|
||||
{ label = buttonText
|
||||
, handleClick = handleClick
|
||||
, isVisible = model.isPaused
|
||||
}
|
||||
, Piano.render
|
||||
{ chord = model.selectedChord
|
||||
, firstNote = model.firstNote
|
||||
, lastNote = model.lastNote
|
||||
}
|
||||
]
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
module Preferences exposing (render)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Icon
|
||||
import Responsive
|
||||
import State
|
||||
import Tailwind
|
||||
import Tempo
|
||||
import Theory
|
||||
import UI
|
||||
|
||||
|
||||
selectKey :
|
||||
State.Model
|
||||
->
|
||||
{ relativeMajor : Theory.Key
|
||||
, relativeMinor : Theory.Key
|
||||
}
|
||||
-> Html State.Msg
|
||||
selectKey model { relativeMajor, relativeMinor } =
|
||||
let
|
||||
active key =
|
||||
List.member key model.whitelistedKeys
|
||||
|
||||
buttonLabel major minor =
|
||||
Theory.viewKey major ++ ", " ++ Theory.viewKey minor
|
||||
in
|
||||
div [ class "flex pt-0" ]
|
||||
[ UI.textToggleButton
|
||||
{ label = buttonLabel relativeMajor relativeMinor
|
||||
, handleClick = State.ToggleKey relativeMajor
|
||||
, classes = [ "flex-1" ]
|
||||
, toggled = active relativeMajor
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
inversionCheckboxes : State.Model -> Html State.Msg
|
||||
inversionCheckboxes model =
|
||||
div []
|
||||
[ h2
|
||||
[ [ "text-gray-500"
|
||||
, "text-center"
|
||||
, "pt-10"
|
||||
, Responsive.h2
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ text "Select inversions" ]
|
||||
, ul
|
||||
[ [ "flex", "justify-center" ] |> Tailwind.use |> class ]
|
||||
(Theory.allInversions
|
||||
|> List.map
|
||||
(\inversion ->
|
||||
li []
|
||||
[ UI.textToggleButton
|
||||
{ label = Theory.inversionName inversion
|
||||
, handleClick = State.ToggleInversion inversion
|
||||
, classes = []
|
||||
, toggled = List.member inversion model.whitelistedInversions
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
keyCheckboxes : State.Model -> Html State.Msg
|
||||
keyCheckboxes model =
|
||||
let
|
||||
majorKey pitchClass =
|
||||
{ pitchClass = pitchClass, mode = Theory.MajorMode }
|
||||
|
||||
minorKey pitchClass =
|
||||
{ pitchClass = pitchClass, mode = Theory.MinorMode }
|
||||
|
||||
circleOfFifths =
|
||||
[ ( Theory.C, Theory.A )
|
||||
, ( Theory.G, Theory.E )
|
||||
, ( Theory.D, Theory.B )
|
||||
, ( Theory.A, Theory.F_sharp )
|
||||
, ( Theory.E, Theory.C_sharp )
|
||||
, ( Theory.B, Theory.G_sharp )
|
||||
, ( Theory.F_sharp, Theory.D_sharp )
|
||||
, ( Theory.C_sharp, Theory.A_sharp )
|
||||
, ( Theory.G_sharp, Theory.F )
|
||||
, ( Theory.D_sharp, Theory.C )
|
||||
, ( Theory.A_sharp, Theory.G )
|
||||
, ( Theory.F, Theory.D )
|
||||
]
|
||||
in
|
||||
div []
|
||||
[ h2
|
||||
[ [ "text-gray-500"
|
||||
, "text-center"
|
||||
, "pt-10"
|
||||
, Responsive.h2
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ text "Select keys" ]
|
||||
, ul []
|
||||
(circleOfFifths
|
||||
|> List.map
|
||||
(\( major, minor ) ->
|
||||
selectKey model
|
||||
{ relativeMajor = majorKey major
|
||||
, relativeMinor = minorKey minor
|
||||
}
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
closePreferences : Html State.Msg
|
||||
closePreferences =
|
||||
button
|
||||
[ [ "w-48"
|
||||
, "lg:w-32"
|
||||
, "h-48"
|
||||
, "lg:h-32"
|
||||
, "absolute"
|
||||
, "right-0"
|
||||
, "top-0"
|
||||
, "z-10"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
, onClick (State.SetView State.Practice)
|
||||
]
|
||||
[ Icon.close ]
|
||||
|
||||
|
||||
render : State.Model -> Html State.Msg
|
||||
render model =
|
||||
div [ class "pt-10 pb-20 px-10" ]
|
||||
[ closePreferences
|
||||
, Tempo.render
|
||||
{ tempo = model.tempo
|
||||
, handleInput = State.SetTempo
|
||||
}
|
||||
, inversionCheckboxes model
|
||||
, keyCheckboxes model
|
||||
]
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
module Responsive exposing (..)
|
||||
|
||||
{-| Returns a string containing all of the Tailwind selectors we use to size
|
||||
h2-sized elements across various devices. -}
|
||||
h1 : String
|
||||
h1 =
|
||||
"text-6xl lg:text-4xl"
|
||||
|
||||
{-| Returns a string containing all of the Tailwind selectors we use to size
|
||||
h2-sized elements across various devices. -}
|
||||
h2 : String
|
||||
h2 =
|
||||
"text-5xl lg:text-3xl"
|
||||
|
||||
{-| Returns a string containing all of the Tailwind selectors we use to size
|
||||
h3-sized elements across various devices. -}
|
||||
h3 : String
|
||||
h3 =
|
||||
"text-4xl lg:text-2xl"
|
||||
179
users/wpcarro/website/sandbox/learnpianochords/src/State.elm
Normal file
179
users/wpcarro/website/sandbox/learnpianochords/src/State.elm
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
module State exposing (..)
|
||||
|
||||
import Random
|
||||
import Random.List
|
||||
import Theory
|
||||
|
||||
|
||||
type Msg
|
||||
= NextChord
|
||||
| NewChord Theory.Chord
|
||||
| Play
|
||||
| Pause
|
||||
| SetTempo String
|
||||
| ToggleInversion Theory.ChordInversion
|
||||
| ToggleKey Theory.Key
|
||||
| DoNothing
|
||||
| SetView View
|
||||
| ToggleFlashCard
|
||||
|
||||
|
||||
type View
|
||||
= Preferences
|
||||
| Practice
|
||||
| Overview
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ whitelistedChords : List Theory.Chord
|
||||
, whitelistedChordTypes : List Theory.ChordType
|
||||
, whitelistedInversions : List Theory.ChordInversion
|
||||
, whitelistedPitchClasses : List Theory.PitchClass
|
||||
, whitelistedKeys : List Theory.Key
|
||||
, selectedChord : Maybe Theory.Chord
|
||||
, isPaused : Bool
|
||||
, tempo : Int
|
||||
, firstNote : Theory.Note
|
||||
, lastNote : Theory.Note
|
||||
, view : View
|
||||
, showFlashCard : Bool
|
||||
}
|
||||
|
||||
|
||||
{-| The initial state for the application.
|
||||
-}
|
||||
init : Model
|
||||
init =
|
||||
let
|
||||
( firstNote, lastNote ) =
|
||||
( Theory.C3, Theory.C6 )
|
||||
|
||||
inversions =
|
||||
[ Theory.Root ]
|
||||
|
||||
chordTypes =
|
||||
Theory.allChordTypes
|
||||
|
||||
pitchClasses =
|
||||
Theory.allPitchClasses
|
||||
|
||||
keys =
|
||||
[ { pitchClass = Theory.C, mode = Theory.MajorMode } ]
|
||||
in
|
||||
{ whitelistedChords =
|
||||
keys
|
||||
|> List.concatMap Theory.chordsForKey
|
||||
|> List.filter (\chord -> List.member chord.chordInversion inversions)
|
||||
, whitelistedChordTypes = chordTypes
|
||||
, whitelistedInversions = inversions
|
||||
, whitelistedPitchClasses = pitchClasses
|
||||
, whitelistedKeys = keys
|
||||
, selectedChord = Nothing
|
||||
, isPaused = True
|
||||
, tempo = 10
|
||||
, firstNote = firstNote
|
||||
, lastNote = lastNote
|
||||
, view = Overview
|
||||
, showFlashCard = True
|
||||
}
|
||||
|
||||
|
||||
{-| Now that we have state, we need a function to change the state.
|
||||
-}
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
DoNothing ->
|
||||
( model, Cmd.none )
|
||||
|
||||
SetView x ->
|
||||
( { model
|
||||
| view = x
|
||||
, isPaused = True
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
NewChord chord ->
|
||||
( { model | selectedChord = Just chord }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
NextChord ->
|
||||
( model
|
||||
, Random.generate
|
||||
(\x ->
|
||||
case x of
|
||||
( Just chord, _ ) ->
|
||||
NewChord chord
|
||||
|
||||
( Nothing, _ ) ->
|
||||
DoNothing
|
||||
)
|
||||
(Random.List.choose model.whitelistedChords)
|
||||
)
|
||||
|
||||
Play ->
|
||||
( { model | isPaused = False }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
Pause ->
|
||||
( { model | isPaused = True }
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
ToggleInversion inversion ->
|
||||
let
|
||||
inversions =
|
||||
if List.member inversion model.whitelistedInversions then
|
||||
List.filter ((/=) inversion) model.whitelistedInversions
|
||||
|
||||
else
|
||||
inversion :: model.whitelistedInversions
|
||||
in
|
||||
( { model
|
||||
| whitelistedInversions = inversions
|
||||
, whitelistedChords =
|
||||
model.whitelistedKeys
|
||||
|> List.concatMap Theory.chordsForKey
|
||||
|> List.filter (\chord -> List.member chord.chordInversion inversions)
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
ToggleKey key ->
|
||||
let
|
||||
keys =
|
||||
if List.member key model.whitelistedKeys then
|
||||
List.filter ((/=) key) model.whitelistedKeys
|
||||
|
||||
else
|
||||
key :: model.whitelistedKeys
|
||||
in
|
||||
( { model
|
||||
| whitelistedKeys = keys
|
||||
, whitelistedChords =
|
||||
keys
|
||||
|> List.concatMap Theory.chordsForKey
|
||||
|> List.filter (\chord -> List.member chord.chordInversion model.whitelistedInversions)
|
||||
, selectedChord = Nothing
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
SetTempo tempo ->
|
||||
( { model
|
||||
| tempo =
|
||||
case String.toInt tempo of
|
||||
Just x ->
|
||||
x
|
||||
|
||||
Nothing ->
|
||||
model.tempo
|
||||
}
|
||||
, Cmd.none
|
||||
)
|
||||
|
||||
ToggleFlashCard ->
|
||||
( { model | showFlashCard = not model.showFlashCard }, Cmd.none )
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
module Tailwind exposing (..)
|
||||
|
||||
{-| Functions to make Tailwind development in Elm even more pleasant.
|
||||
-}
|
||||
|
||||
|
||||
{-| Conditionally use `class` selection when `condition` is true.
|
||||
-}
|
||||
when : Bool -> String -> String
|
||||
when condition class =
|
||||
if condition then
|
||||
class
|
||||
|
||||
else
|
||||
""
|
||||
|
||||
|
||||
if_ : Bool -> String -> String -> String
|
||||
if_ condition whenTrue whenFalse =
|
||||
if condition then
|
||||
whenTrue
|
||||
|
||||
else
|
||||
whenFalse
|
||||
|
||||
|
||||
use : List String -> String
|
||||
use styles =
|
||||
String.join " " styles
|
||||
33
users/wpcarro/website/sandbox/learnpianochords/src/Tempo.elm
Normal file
33
users/wpcarro/website/sandbox/learnpianochords/src/Tempo.elm
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
module Tempo exposing (render)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Responsive
|
||||
import Tailwind
|
||||
import UI
|
||||
|
||||
|
||||
type alias Props msg =
|
||||
{ tempo : Int
|
||||
, handleInput : String -> msg
|
||||
}
|
||||
|
||||
|
||||
render : Props msg -> Html msg
|
||||
render { tempo, handleInput } =
|
||||
div [ class "text-center" ]
|
||||
[ p
|
||||
[ [ "py-10"
|
||||
, Responsive.h2
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ text (String.fromInt tempo ++ " BPM") ]
|
||||
, UI.textField
|
||||
{ placeholderText = "Set tempo..."
|
||||
, handleInput = handleInput
|
||||
, classes = []
|
||||
}
|
||||
]
|
||||
1100
users/wpcarro/website/sandbox/learnpianochords/src/Theory.elm
Normal file
1100
users/wpcarro/website/sandbox/learnpianochords/src/Theory.elm
Normal file
File diff suppressed because it is too large
Load diff
159
users/wpcarro/website/sandbox/learnpianochords/src/UI.elm
Normal file
159
users/wpcarro/website/sandbox/learnpianochords/src/UI.elm
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
module UI exposing (..)
|
||||
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Responsive
|
||||
import Tailwind
|
||||
|
||||
|
||||
type Color
|
||||
= Primary
|
||||
| Secondary
|
||||
|
||||
|
||||
bgForColor : Color -> String
|
||||
bgForColor color =
|
||||
case color of
|
||||
Primary ->
|
||||
"bg-gray-600"
|
||||
|
||||
Secondary ->
|
||||
"bg-gray-300"
|
||||
|
||||
|
||||
textForColor : Color -> String
|
||||
textForColor color =
|
||||
case color of
|
||||
Primary ->
|
||||
"text-white"
|
||||
|
||||
Secondary ->
|
||||
"text-black"
|
||||
|
||||
|
||||
simpleButton :
|
||||
{ label : String
|
||||
, handleClick : msg
|
||||
, color : Color
|
||||
, classes : List String
|
||||
}
|
||||
-> Html msg
|
||||
simpleButton { label, handleClick, color, classes } =
|
||||
let
|
||||
buttonClasses =
|
||||
[ bgForColor color
|
||||
, textForColor color
|
||||
, "py-10"
|
||||
, "lg:py-6"
|
||||
, "px-20"
|
||||
, "lg:px-12"
|
||||
, "rounded-lg"
|
||||
, Responsive.h2
|
||||
]
|
||||
in
|
||||
button
|
||||
[ class (Tailwind.use <| List.concat [ buttonClasses, classes ])
|
||||
, onClick handleClick
|
||||
]
|
||||
[ text label ]
|
||||
|
||||
|
||||
textToggleButton :
|
||||
{ label : String
|
||||
, handleClick : msg
|
||||
, classes : List String
|
||||
, toggled : Bool
|
||||
}
|
||||
-> Html msg
|
||||
textToggleButton { label, toggled, handleClick, classes } =
|
||||
let
|
||||
( textColor, textTreatment ) =
|
||||
if toggled then
|
||||
( "text-red-600", "underline" )
|
||||
|
||||
else
|
||||
( "text-black", "no-underline" )
|
||||
|
||||
buttonClasses =
|
||||
[ textColor
|
||||
, textTreatment
|
||||
, "py-8"
|
||||
, "lg:py-5"
|
||||
, "px-10"
|
||||
, "lg:px-6"
|
||||
, Responsive.h2
|
||||
]
|
||||
in
|
||||
button
|
||||
[ class (Tailwind.use <| List.concat [ buttonClasses, classes ])
|
||||
, onClick handleClick
|
||||
]
|
||||
[ text label ]
|
||||
|
||||
|
||||
textField :
|
||||
{ placeholderText : String
|
||||
, handleInput : String -> msg
|
||||
, classes : List String
|
||||
}
|
||||
-> Html msg
|
||||
textField { placeholderText, handleInput, classes } =
|
||||
let
|
||||
inputClasses =
|
||||
[ "w-full"
|
||||
, "py-10"
|
||||
, "lg:py-6"
|
||||
, "px-16"
|
||||
, "lg:px-10"
|
||||
, "border"
|
||||
, "rounded-lg"
|
||||
, Responsive.h2
|
||||
]
|
||||
in
|
||||
input
|
||||
[ class (Tailwind.use <| List.concat [ inputClasses, classes ])
|
||||
, onInput handleInput
|
||||
, placeholder placeholderText
|
||||
]
|
||||
[]
|
||||
|
||||
|
||||
overlayButton :
|
||||
{ label : String
|
||||
, handleClick : msg
|
||||
, isVisible : Bool
|
||||
}
|
||||
-> Html msg
|
||||
overlayButton { label, handleClick, isVisible } =
|
||||
let
|
||||
classes =
|
||||
[ "fixed"
|
||||
, "top-0"
|
||||
, "left-0"
|
||||
, "block"
|
||||
, "z-40"
|
||||
, "w-screen"
|
||||
, "h-screen"
|
||||
, Tailwind.if_ isVisible "opacity-100" "opacity-0"
|
||||
]
|
||||
in
|
||||
button
|
||||
[ classes |> Tailwind.use |> class
|
||||
, style "background-color" "rgba(0,0,0,1.0)"
|
||||
, onClick handleClick
|
||||
]
|
||||
[ h1
|
||||
[ style "-webkit-text-stroke-width" "2px"
|
||||
, style "-webkit-text-stroke-color" "black"
|
||||
, class <|
|
||||
Tailwind.use
|
||||
[ "transform"
|
||||
, "-rotate-90"
|
||||
, "text-white"
|
||||
, "font-mono"
|
||||
, Responsive.h1
|
||||
]
|
||||
]
|
||||
[ text label ]
|
||||
]
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
source_up
|
||||
use_nix
|
||||
export SERVER_PORT=3000
|
||||
export CLIENT_PORT=8000
|
||||
export GOOGLE_CLIENT_ID="$(jq -j '.google | .clientId' < ~/briefcase/secrets.json)"
|
||||
export STRIPE_API_KEY="$(jq -j '.stripe | .apiKey' < ~/briefcase/secrets.json)"
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
:set prompt "> "
|
||||
:set -Wall
|
||||
|
||||
:set -XOverloadedStrings
|
||||
:set -XNoImplicitPrelude
|
||||
:set -XRecordWildCards
|
||||
:set -XTypeApplications
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE TypeOperators #-}
|
||||
--------------------------------------------------------------------------------
|
||||
module API where
|
||||
--------------------------------------------------------------------------------
|
||||
import Servant.API
|
||||
|
||||
import qualified Types as T
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
type API = "verify"
|
||||
:> ReqBody '[JSON] T.VerifyGoogleSignInRequest
|
||||
:> Post '[JSON] NoContent
|
||||
:<|> "create-payment-intent"
|
||||
:> ReqBody '[JSON] T.PaymentIntent
|
||||
:> Post '[JSON] T.CreatePaymentIntentResponse
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
--------------------------------------------------------------------------------
|
||||
module App where
|
||||
--------------------------------------------------------------------------------
|
||||
import RIO hiding (Handler)
|
||||
import Servant
|
||||
import API
|
||||
import Data.String.Conversions (cs)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Network.Wai.Middleware.Cors
|
||||
import GoogleSignIn (EncodedJWT(..), ValidationResult(..))
|
||||
import Utils
|
||||
|
||||
import qualified Network.Wai.Handler.Warp as Warp
|
||||
import qualified GoogleSignIn
|
||||
import qualified Stripe
|
||||
import qualified Types as T
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
server :: T.Context -> Server API
|
||||
server ctx@T.Context{..} = verifyGoogleSignIn
|
||||
:<|> createPaymentIntent
|
||||
where
|
||||
verifyGoogleSignIn :: T.VerifyGoogleSignInRequest -> Handler NoContent
|
||||
verifyGoogleSignIn T.VerifyGoogleSignInRequest{..} = do
|
||||
validationResult <- liftIO $ GoogleSignIn.validateJWT False (EncodedJWT idToken)
|
||||
case validationResult of
|
||||
Valid _ -> do
|
||||
-- If GoogleLinkedAccounts has email from JWT:
|
||||
-- create a new session for email
|
||||
-- Else:
|
||||
-- Redirect the SPA to the sign-up / payment page
|
||||
pure NoContent
|
||||
err -> do
|
||||
throwError err401 { errBody = err |> GoogleSignIn.explainResult |> cs }
|
||||
|
||||
createPaymentIntent :: T.PaymentIntent -> Handler T.CreatePaymentIntentResponse
|
||||
createPaymentIntent pmt = do
|
||||
clientSecret <- liftIO $ Stripe.createPaymentIntent ctx pmt
|
||||
pure T.CreatePaymentIntentResponse{..}
|
||||
|
||||
run :: T.App
|
||||
run = do
|
||||
ctx@T.Context{..} <- ask
|
||||
ctx
|
||||
|> server
|
||||
|> serve (Proxy @ API)
|
||||
|> cors (const $ Just corsPolicy)
|
||||
|> Warp.run contextServerPort
|
||||
|> liftIO
|
||||
pure $ Right ()
|
||||
where
|
||||
corsPolicy :: CorsResourcePolicy
|
||||
corsPolicy = simpleCorsResourcePolicy
|
||||
{ corsOrigins = Just (["http://localhost:8000"], True)
|
||||
, corsMethods = simpleMethods ++ ["PUT", "PATCH", "DELETE", "OPTIONS"]
|
||||
, corsRequestHeaders = simpleHeaders ++ ["Content-Type", "Authorization"]
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
--------------------------------------------------------------------------------
|
||||
module Fixtures where
|
||||
--------------------------------------------------------------------------------
|
||||
import RIO
|
||||
import Web.JWT
|
||||
import Utils
|
||||
|
||||
import qualified Data.Map as Map
|
||||
import qualified GoogleSignIn
|
||||
import qualified TestUtils
|
||||
import qualified Data.Time.Clock.POSIX as POSIX
|
||||
import qualified System.IO.Unsafe as Unsafe
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- | These are the JWT fields that I'd like to overwrite in the `googleJWT`
|
||||
-- function.
|
||||
data JWTFields = JWTFields
|
||||
{ overwriteSigner :: Signer
|
||||
, overwriteAuds :: [StringOrURI]
|
||||
, overwriteIss :: StringOrURI
|
||||
, overwriteExp :: NumericDate
|
||||
}
|
||||
|
||||
defaultJWTFields :: JWTFields
|
||||
defaultJWTFields = do
|
||||
let tenDaysFromToday = POSIX.getPOSIXTime
|
||||
|> Unsafe.unsafePerformIO
|
||||
|> (\x -> x * 60 * 60 * 25 * 10)
|
||||
|> numericDate
|
||||
|> TestUtils.unsafeJust
|
||||
JWTFields
|
||||
{ overwriteSigner = hmacSecret "secret"
|
||||
, overwriteAuds = ["771151720060-buofllhed98fgt0j22locma05e7rpngl.apps.googleusercontent.com"]
|
||||
|> fmap TestUtils.unsafeStringOrURI
|
||||
, overwriteIss = TestUtils.unsafeStringOrURI "accounts.google.com"
|
||||
, overwriteExp = tenDaysFromToday
|
||||
}
|
||||
|
||||
googleJWT :: JWTFields -> GoogleSignIn.EncodedJWT
|
||||
googleJWT JWTFields{..} =
|
||||
encodeSigned signer jwtHeader claimSet
|
||||
|> GoogleSignIn.EncodedJWT
|
||||
where
|
||||
signer :: Signer
|
||||
signer = overwriteSigner
|
||||
|
||||
jwtHeader :: JOSEHeader
|
||||
jwtHeader = JOSEHeader
|
||||
{ typ = Just "JWT"
|
||||
, cty = Nothing
|
||||
, alg = Just RS256
|
||||
, kid = Just "f05415b13acb9590f70df862765c655f5a7a019e"
|
||||
}
|
||||
|
||||
claimSet :: JWTClaimsSet
|
||||
claimSet = JWTClaimsSet
|
||||
{ iss = Just overwriteIss
|
||||
, sub = stringOrURI "114079822315085727057"
|
||||
, aud = overwriteAuds |> Right |> Just
|
||||
-- TODO: Replace date creation with a human-readable date constructor.
|
||||
, Web.JWT.exp = Just overwriteExp
|
||||
, nbf = Nothing
|
||||
-- TODO: Replace date creation with a human-readable date constructor.
|
||||
, iat = numericDate 1596752853
|
||||
, unregisteredClaims = ClaimsMap (Map.fromList [])
|
||||
, jti = stringOrURI "0d3d7fa1fe05bedec0a91c88294936b2b4d1b13c"
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
--------------------------------------------------------------------------------
|
||||
module GoogleSignIn where
|
||||
--------------------------------------------------------------------------------
|
||||
import RIO
|
||||
import Data.String.Conversions (cs)
|
||||
import Web.JWT
|
||||
import Utils
|
||||
|
||||
import qualified Network.HTTP.Simple as HTTP
|
||||
import qualified Data.Text as Text
|
||||
import qualified Web.JWT as JWT
|
||||
import qualified Data.Time.Clock.POSIX as POSIX
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
newtype EncodedJWT = EncodedJWT Text
|
||||
deriving (Show)
|
||||
|
||||
newtype DecodedJWT = DecodedJWT (JWT UnverifiedJWT)
|
||||
deriving (Show)
|
||||
|
||||
instance Eq DecodedJWT where
|
||||
(DecodedJWT _) == (DecodedJWT _) = True
|
||||
|
||||
data ValidationResult
|
||||
= Valid DecodedJWT
|
||||
| CannotDecodeJWT
|
||||
| GoogleSaysInvalid Text
|
||||
| NoMatchingClientIDs [StringOrURI]
|
||||
| WrongIssuer StringOrURI
|
||||
| StringOrURIParseFailure Text
|
||||
| TimeConversionFailure
|
||||
| MissingRequiredClaim Text
|
||||
| StaleExpiry NumericDate
|
||||
deriving (Eq, Show)
|
||||
|
||||
-- | Returns True when the supplied `jwt` meets the following criteria:
|
||||
-- * The token has been signed by Google
|
||||
-- * The value of `aud` matches my Google client's ID
|
||||
-- * The value of `iss` matches is "accounts.google.com" or
|
||||
-- "https://accounts.google.com"
|
||||
-- * The `exp` time has not passed
|
||||
--
|
||||
-- Set `skipHTTP` to `True` to avoid making the network request for testing.
|
||||
validateJWT :: Bool
|
||||
-> EncodedJWT
|
||||
-> IO ValidationResult
|
||||
validateJWT skipHTTP (EncodedJWT encodedJWT) = do
|
||||
case encodedJWT |> decode of
|
||||
Nothing -> pure CannotDecodeJWT
|
||||
Just jwt -> do
|
||||
if skipHTTP then
|
||||
continue jwt
|
||||
else do
|
||||
let request = "https://oauth2.googleapis.com/tokeninfo"
|
||||
|> HTTP.setRequestQueryString [ ( "id_token", Just (cs encodedJWT) ) ]
|
||||
res <- HTTP.httpLBS request
|
||||
if HTTP.getResponseStatusCode res /= 200 then
|
||||
pure $ GoogleSaysInvalid (res |> HTTP.getResponseBody |> cs)
|
||||
else
|
||||
continue jwt
|
||||
where
|
||||
continue :: JWT UnverifiedJWT -> IO ValidationResult
|
||||
continue jwt = do
|
||||
let audValues :: [StringOrURI]
|
||||
audValues = jwt |> claims |> auds
|
||||
expectedClientID :: Text
|
||||
expectedClientID = "771151720060-buofllhed98fgt0j22locma05e7rpngl.apps.googleusercontent.com"
|
||||
expectedIssuers :: [Text]
|
||||
expectedIssuers = [ "accounts.google.com"
|
||||
, "https://accounts.google.com"
|
||||
]
|
||||
mExpectedClientID :: Maybe StringOrURI
|
||||
mExpectedClientID = stringOrURI expectedClientID
|
||||
mExpectedIssuers :: Maybe [StringOrURI]
|
||||
mExpectedIssuers = expectedIssuers |> traverse stringOrURI
|
||||
case (mExpectedClientID, mExpectedIssuers) of
|
||||
(Nothing, _) -> pure $ StringOrURIParseFailure expectedClientID
|
||||
(_, Nothing) -> pure $ StringOrURIParseFailure (Text.unwords expectedIssuers)
|
||||
(Just clientID, Just parsedIssuers) ->
|
||||
-- TODO: Prefer reading clientID from a config. I'm thinking of the
|
||||
-- AppContext type having my Configuration
|
||||
if not $ clientID `elem` audValues then
|
||||
pure $ NoMatchingClientIDs audValues
|
||||
else
|
||||
case (jwt |> claims |> iss, jwt |> claims |> JWT.exp) of
|
||||
(Nothing, _) -> pure $ MissingRequiredClaim "iss"
|
||||
(_, Nothing) -> pure $ MissingRequiredClaim "exp"
|
||||
(Just jwtIssuer, Just jwtExpiry) ->
|
||||
if not $ jwtIssuer `elem` parsedIssuers then
|
||||
pure $ WrongIssuer jwtIssuer
|
||||
else do
|
||||
mCurrentTime <- POSIX.getPOSIXTime |> fmap numericDate
|
||||
case mCurrentTime of
|
||||
Nothing -> pure TimeConversionFailure
|
||||
Just currentTime ->
|
||||
if not $ currentTime <= jwtExpiry then
|
||||
pure $ StaleExpiry jwtExpiry
|
||||
else
|
||||
pure $ jwt |> DecodedJWT |> Valid
|
||||
|
||||
-- | Attempt to explain the `ValidationResult` to a human.
|
||||
explainResult :: ValidationResult -> String
|
||||
explainResult (Valid _) = "Everything appears to be valid"
|
||||
explainResult CannotDecodeJWT = "We had difficulty decoding the provided JWT"
|
||||
explainResult (GoogleSaysInvalid x) = "After checking with Google, they claimed that the provided JWT was invalid: " ++ cs x
|
||||
explainResult (NoMatchingClientIDs audFields) = "None of the values in the `aud` field on the provided JWT match our client ID: " ++ show audFields
|
||||
explainResult (WrongIssuer issuer) = "The `iss` field in the provided JWT does not match what we expect: " ++ show issuer
|
||||
explainResult (StringOrURIParseFailure x) = "We had difficulty parsing values as URIs" ++ show x
|
||||
explainResult TimeConversionFailure = "We had difficulty converting the current time to a value we can use to compare with the JWT's `exp` field"
|
||||
explainResult (MissingRequiredClaim claim) = "Your JWT is missing the following claim: " ++ cs claim
|
||||
explainResult (StaleExpiry x) = "The `exp` field on your JWT has expired" ++ x |> show |> cs
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
--------------------------------------------------------------------------------
|
||||
module Main where
|
||||
--------------------------------------------------------------------------------
|
||||
import RIO
|
||||
import Prelude (putStr, putStrLn)
|
||||
|
||||
import qualified Types as T
|
||||
import qualified System.Envy as Envy
|
||||
import qualified App
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- | Attempt to read environment variables from the system and initialize the
|
||||
-- Context data type for our application.
|
||||
getAppContext :: IO (Either String T.Context)
|
||||
getAppContext = do
|
||||
mEnv <- Envy.decodeEnv
|
||||
case mEnv of
|
||||
Left err -> pure $ Left err
|
||||
Right T.Env{..} -> pure $ Right T.Context
|
||||
{ contextGoogleClientID = envGoogleClientID
|
||||
, contextStripeAPIKey = envStripeAPIKey
|
||||
, contextServerPort = envServerPort
|
||||
, contextClientPort = envClientPort
|
||||
}
|
||||
|
||||
main :: IO ()
|
||||
main = do
|
||||
mContext <- getAppContext
|
||||
case mContext of
|
||||
Left err -> putStrLn err
|
||||
Right ctx -> do
|
||||
result <- runRIO ctx App.run
|
||||
case result of
|
||||
Left err -> do
|
||||
putStr "Something went wrong when executing the application: "
|
||||
putStrLn $ show err
|
||||
Right _ -> putStrLn "The application successfully executed."
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
--------------------------------------------------------------------------------
|
||||
module Spec where
|
||||
--------------------------------------------------------------------------------
|
||||
import RIO
|
||||
import Test.Hspec
|
||||
import Utils
|
||||
import Web.JWT (numericDate, decode)
|
||||
import GoogleSignIn (EncodedJWT(..), DecodedJWT(..), ValidationResult(..))
|
||||
|
||||
import qualified GoogleSignIn
|
||||
import qualified Fixtures as F
|
||||
import qualified TestUtils
|
||||
import qualified Data.Time.Clock.POSIX as POSIX
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
main :: IO ()
|
||||
main = hspec $ do
|
||||
describe "GoogleSignIn" $
|
||||
describe "validateJWT" $ do
|
||||
let validateJWT' = GoogleSignIn.validateJWT True
|
||||
it "returns a decode error when an incorrectly encoded JWT is used" $ do
|
||||
validateJWT' (GoogleSignIn.EncodedJWT "rubbish") `shouldReturn` CannotDecodeJWT
|
||||
|
||||
it "returns validation error when the aud field doesn't match my client ID" $ do
|
||||
let auds = ["wrong-client-id"]
|
||||
|> fmap TestUtils.unsafeStringOrURI
|
||||
encodedJWT = F.defaultJWTFields { F.overwriteAuds = auds }
|
||||
|> F.googleJWT
|
||||
validateJWT' encodedJWT `shouldReturn` NoMatchingClientIDs auds
|
||||
|
||||
it "returns validation success when one of the aud fields matches my client ID" $ do
|
||||
let auds = ["wrong-client-id", "771151720060-buofllhed98fgt0j22locma05e7rpngl.apps.googleusercontent.com"]
|
||||
|> fmap TestUtils.unsafeStringOrURI
|
||||
encodedJWT@(EncodedJWT jwt) =
|
||||
F.defaultJWTFields { F.overwriteAuds = auds }
|
||||
|> F.googleJWT
|
||||
decodedJWT = jwt |> decode |> TestUtils.unsafeJust |> DecodedJWT
|
||||
validateJWT' encodedJWT `shouldReturn` Valid decodedJWT
|
||||
|
||||
it "returns validation error when one of the iss field doesn't match accounts.google.com or https://accounts.google.com" $ do
|
||||
let erroneousIssuer = TestUtils.unsafeStringOrURI "not-accounts.google.com"
|
||||
encodedJWT = F.defaultJWTFields { F.overwriteIss = erroneousIssuer }
|
||||
|> F.googleJWT
|
||||
validateJWT' encodedJWT `shouldReturn` WrongIssuer erroneousIssuer
|
||||
|
||||
it "returns validation success when the iss field matches accounts.google.com or https://accounts.google.com" $ do
|
||||
let erroneousIssuer = TestUtils.unsafeStringOrURI "https://accounts.google.com"
|
||||
encodedJWT@(EncodedJWT jwt) =
|
||||
F.defaultJWTFields { F.overwriteIss = erroneousIssuer }
|
||||
|> F.googleJWT
|
||||
decodedJWT = jwt |> decode |> TestUtils.unsafeJust |> DecodedJWT
|
||||
validateJWT' encodedJWT `shouldReturn` Valid decodedJWT
|
||||
|
||||
it "fails validation when the exp field has expired" $ do
|
||||
let mErroneousExp = numericDate 0
|
||||
case mErroneousExp of
|
||||
Nothing -> True `shouldBe` False
|
||||
Just erroneousExp -> do
|
||||
let encodedJWT = F.defaultJWTFields { F.overwriteExp = erroneousExp }
|
||||
|> F.googleJWT
|
||||
validateJWT' encodedJWT `shouldReturn` StaleExpiry erroneousExp
|
||||
|
||||
it "passes validation when the exp field is current" $ do
|
||||
mFreshExp <- POSIX.getPOSIXTime
|
||||
|> fmap (\x -> x * 60 * 60 * 24 * 10) -- 10 days later
|
||||
|> fmap numericDate
|
||||
case mFreshExp of
|
||||
Nothing -> True `shouldBe` False
|
||||
Just freshExp -> do
|
||||
let encodedJWT@(EncodedJWT jwt) =
|
||||
F.defaultJWTFields { F.overwriteExp = freshExp }
|
||||
|> F.googleJWT
|
||||
decodedJWT = jwt |> decode |> TestUtils.unsafeJust |> DecodedJWT
|
||||
validateJWT' encodedJWT `shouldReturn` Valid decodedJWT
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{-# LANGUAGE KindSignatures #-}
|
||||
{-# LANGUAGE DataKinds #-}
|
||||
--------------------------------------------------------------------------------
|
||||
module Stripe where
|
||||
--------------------------------------------------------------------------------
|
||||
import RIO
|
||||
import Prelude (print)
|
||||
import Data.String.Conversions (cs)
|
||||
import Data.Aeson
|
||||
import Network.HTTP.Req
|
||||
|
||||
import qualified Types as T
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
endpoint :: Text -> Url 'Https
|
||||
endpoint slug =
|
||||
https "api.stripe.com" /: "v1" /: slug
|
||||
|
||||
post :: (FromJSON b) => Text -> Text -> T.PaymentIntent -> IO (JsonResponse b)
|
||||
post apiKey slug T.PaymentIntent{..} = runReq defaultHttpConfig $ do
|
||||
let params = "amount" =: paymentIntentAmount
|
||||
<> "currency" =: paymentIntentCurrency
|
||||
req POST (endpoint slug) (ReqBodyUrlEnc params) jsonResponse (oAuth2Bearer (cs apiKey))
|
||||
|
||||
createPaymentIntent :: T.Context -> T.PaymentIntent -> IO T.Secret
|
||||
createPaymentIntent T.Context{..} pmtIntent = do
|
||||
res <- post contextStripeAPIKey "payment_intents" pmtIntent
|
||||
let T.StripePaymentIntent{..} = responseBody res :: T.StripePaymentIntent
|
||||
pure pmtIntentClientSecret
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
--------------------------------------------------------------------------------
|
||||
module TestUtils where
|
||||
--------------------------------------------------------------------------------
|
||||
import RIO
|
||||
import Web.JWT
|
||||
import Data.String.Conversions (cs)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
unsafeStringOrURI :: String -> StringOrURI
|
||||
unsafeStringOrURI x =
|
||||
case stringOrURI (cs x) of
|
||||
Nothing -> error $ "Failed to convert to StringOrURI: " ++ x
|
||||
Just res -> res
|
||||
|
||||
unsafeJust :: Maybe a -> a
|
||||
unsafeJust Nothing = error "Attempted to force a Nothing to be a something"
|
||||
unsafeJust (Just x) = x
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
--------------------------------------------------------------------------------G
|
||||
module Types where
|
||||
--------------------------------------------------------------------------------
|
||||
import RIO
|
||||
import Data.Aeson
|
||||
import Network.HTTP.Req
|
||||
import Web.Internal.HttpApiData (ToHttpApiData(..))
|
||||
import System.Envy (FromEnv, fromEnv, env)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
-- | Read from .envrc
|
||||
data Env = Env
|
||||
{ envGoogleClientID :: !Text
|
||||
, envServerPort :: !Int
|
||||
, envClientPort :: !Int
|
||||
, envStripeAPIKey :: !Text
|
||||
} deriving (Eq, Show)
|
||||
|
||||
instance FromEnv Env where
|
||||
fromEnv _ = do
|
||||
envGoogleClientID <- env "GOOGLE_CLIENT_ID"
|
||||
envStripeAPIKey <- env "STRIPE_API_KEY"
|
||||
envServerPort <- env "SERVER_PORT"
|
||||
envClientPort <- env "CLIENT_PORT"
|
||||
pure Env {..}
|
||||
|
||||
-- | Application context: a combination of Env and additional values.
|
||||
data Context = Context
|
||||
{ contextGoogleClientID :: !Text
|
||||
, contextStripeAPIKey :: !Text
|
||||
, contextServerPort :: !Int
|
||||
, contextClientPort :: !Int
|
||||
}
|
||||
|
||||
-- | Top-level except for our application, as RIO recommends defining.
|
||||
type Failure = ()
|
||||
|
||||
-- | When our app executes along the "happy path" this is the type of result it
|
||||
-- produces.
|
||||
type Success = ()
|
||||
|
||||
-- | This is our application monad.
|
||||
type AppM = RIO Context
|
||||
|
||||
-- | The concrete type of our application.
|
||||
type App = AppM (Either Failure Success)
|
||||
|
||||
data VerifyGoogleSignInRequest = VerifyGoogleSignInRequest
|
||||
{ idToken :: !Text
|
||||
} deriving (Eq, Show)
|
||||
|
||||
instance FromJSON VerifyGoogleSignInRequest where
|
||||
parseJSON = withObject "VerifyGoogleSignInRequest" $ \x -> do
|
||||
idToken <- x .: "idToken"
|
||||
pure VerifyGoogleSignInRequest{..}
|
||||
|
||||
data GoogleLinkedAccount = GoogleLinkedAccount
|
||||
{
|
||||
-- { googleLinkedAccountUUID :: UUID
|
||||
-- , googleLinkedAccountEmail :: Email
|
||||
-- , googleLinkedAccountTsCreated :: Timestamp
|
||||
googleLinkedAccountGivenName :: !(Maybe Text)
|
||||
, googleLinkedAccountFamilyName :: !(Maybe Text)
|
||||
, googleLinkedAccountFullName :: !(Maybe Text)
|
||||
-- , googleLinkedAccountPictureURL :: URL
|
||||
-- , googleLinkedAccountLocale :: Maybe Locale
|
||||
} deriving (Eq, Show)
|
||||
|
||||
data PayingCustomer = PayingCustomer
|
||||
{
|
||||
-- { payingCustomerAccountUUID :: UUID
|
||||
-- , payingCustomerTsCreated :: Timestamp
|
||||
} deriving (Eq, Show)
|
||||
|
||||
data Session = Session
|
||||
{
|
||||
-- { sessionUUID :: UUID
|
||||
-- , sessionAccountUUID :: UUID
|
||||
-- , sessionTsCreated :: Timestamp
|
||||
} deriving (Eq, Show)
|
||||
|
||||
data CurrencyCode = USD
|
||||
deriving (Eq, Show)
|
||||
|
||||
instance ToJSON CurrencyCode where
|
||||
toJSON USD = String "usd"
|
||||
|
||||
instance FromJSON CurrencyCode where
|
||||
parseJSON = withText "CurrencyCode" $ \x ->
|
||||
case x of
|
||||
"usd" -> pure USD
|
||||
_ -> fail "Expected a valid currency code like: \"usd\""
|
||||
|
||||
instance ToHttpApiData CurrencyCode where
|
||||
toQueryParam USD = "usd"
|
||||
|
||||
data PaymentIntent = PaymentIntent
|
||||
{ paymentIntentAmount :: !Int
|
||||
, paymentIntentCurrency :: !CurrencyCode
|
||||
} deriving (Eq, Show)
|
||||
|
||||
instance ToJSON PaymentIntent where
|
||||
toJSON PaymentIntent{..} =
|
||||
object [ "amount" .= paymentIntentAmount
|
||||
, "currency" .= paymentIntentCurrency
|
||||
]
|
||||
|
||||
instance FromJSON PaymentIntent where
|
||||
parseJSON = withObject "" $ \x -> do
|
||||
paymentIntentAmount <- x .: "amount"
|
||||
paymentIntentCurrency <- x .: "currency"
|
||||
pure PaymentIntent{..}
|
||||
|
||||
instance QueryParam PaymentIntent where
|
||||
queryParam = undefined
|
||||
|
||||
-- All applications have their secrets... Using the secret type ensures that no
|
||||
-- sensitive information will get printed to the screen.
|
||||
newtype Secret = Secret Text deriving (Eq)
|
||||
|
||||
instance Show Secret where
|
||||
show (Secret _) = "[REDACTED]"
|
||||
|
||||
instance ToJSON Secret where
|
||||
toJSON (Secret x) = toJSON x
|
||||
|
||||
instance FromJSON Secret where
|
||||
parseJSON = withText "Secret" $ \x -> pure $ Secret x
|
||||
|
||||
data CreatePaymentIntentResponse = CreatePaymentIntentResponse
|
||||
{ clientSecret :: Secret
|
||||
} deriving (Eq, Show)
|
||||
|
||||
instance ToJSON CreatePaymentIntentResponse where
|
||||
toJSON CreatePaymentIntentResponse{..} =
|
||||
object [ "clientSecret" .= clientSecret
|
||||
]
|
||||
|
||||
data StripePaymentIntent = StripePaymentIntent
|
||||
{ pmtIntentClientSecret :: Secret
|
||||
} deriving (Eq, Show)
|
||||
|
||||
instance FromJSON StripePaymentIntent where
|
||||
parseJSON = withObject "StripeCreatePaymentIntentResponse" $ \x -> do
|
||||
pmtIntentClientSecret <- x .: "client_secret"
|
||||
pure StripePaymentIntent{..}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
--------------------------------------------------------------------------------
|
||||
module Utils where
|
||||
--------------------------------------------------------------------------------
|
||||
import Data.Function ((&))
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
(|>) :: a -> (a -> b) -> b
|
||||
(|>) = (&)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
let
|
||||
briefcase = import <briefcase> {};
|
||||
in briefcase.buildHaskell.program {
|
||||
name = "server";
|
||||
srcs = builtins.path {
|
||||
path = ./.;
|
||||
name = "LearnPianoChords-server-src";
|
||||
};
|
||||
ghcExtensions = [
|
||||
"OverloadedStrings"
|
||||
"NoImplicitPrelude"
|
||||
"RecordWildCards"
|
||||
"TypeApplications"
|
||||
];
|
||||
deps = hpkgs: with hpkgs; [
|
||||
servant-server
|
||||
aeson
|
||||
wai-cors
|
||||
warp
|
||||
jwt
|
||||
unordered-containers
|
||||
base64
|
||||
http-conduit
|
||||
rio
|
||||
envy
|
||||
req
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Google Sign-in</title>
|
||||
<script src="https://apis.google.com/js/platform.js" async defer></script>
|
||||
<meta name="google-signin-client_id" content="771151720060-buofllhed98fgt0j22locma05e7rpngl.apps.googleusercontent.com">
|
||||
</head>
|
||||
<body>
|
||||
<div class="g-signin2" data-onsuccess="onSignIn"></div>
|
||||
<a href="#" onclick="signOut();">Sign out</a>
|
||||
<script>
|
||||
function onSignIn(googleUser) {
|
||||
var idToken = googleUser.getAuthResponse().id_token;
|
||||
fetch('http://localhost:3000/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
idToken: idToken,
|
||||
})
|
||||
})
|
||||
.then(x => console.log(x))
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
function signOut() {
|
||||
var auth2 = gapi.auth2.getAuthInstance();
|
||||
auth2.signOut().then(function () {
|
||||
console.log('User signed out.');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
BEGIN TRANSACTION;
|
||||
|
||||
DROP TABLE IF EXISTS GoogleLinkedAccounts;
|
||||
DROP TABLE IF EXISTS PayingCustomers;
|
||||
DROP TABLE IF EXISTS Sessions;
|
||||
|
||||
-- Store some of the information that Google provides to us from the JWT.
|
||||
CREATE TABLE GoogleLinkedAccounts (
|
||||
accountUUID TEXT CHECK(LENGTH(uuid) == 36) NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
tsCreated TEXT NOT NULL, -- 'YYYY-MM-DD HH:MM:SS'
|
||||
givenName TEXT,
|
||||
familyName TEXT,
|
||||
fullName TEXT,
|
||||
pictureURL TEXT,
|
||||
locale TEXT,
|
||||
PRIMARY KEY (accountUUID)
|
||||
);
|
||||
|
||||
-- Track which of our customers have a paid account.
|
||||
-- Defines a one-to-one relationship between:
|
||||
-- GoogleLinkedAccounts and PayingCustomers
|
||||
CREATE TABLE PayingCustomers (
|
||||
accountUUID TEXT,
|
||||
tsCreated TEXT,
|
||||
PRIMARY KEY (accountUUID),
|
||||
FOREIGN KEY (accountUUID) REFERENCES GoogleLinkedAccounts ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Define mobile and web sessions for our users.
|
||||
-- Defines a one-to-many relationship between:
|
||||
-- GoogleLinkedAccounts and Sessions
|
||||
CREATE TABLE Sessions (
|
||||
sessionUUID TEXT CHECK(LENGTH(sessionUUID) == 36) NOT NULL UNIQUE,
|
||||
accountUUID TEXT,
|
||||
tsCreated TEXT NOT NULL, -- 'YYYY-MM-DD HH:MM:SS'
|
||||
PRIMARY KEY (sessionUUID)
|
||||
FOREIGN KEY(accountUUID) REFERENCES GoogleLinkedAccounts ON DELETE CASCADE
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
let
|
||||
briefcase = import <briefcase> {};
|
||||
in briefcase.buildHaskell.shell {
|
||||
deps = hpkgs: with hpkgs; [
|
||||
hspec
|
||||
servant-server
|
||||
aeson
|
||||
wai-cors
|
||||
warp
|
||||
jwt
|
||||
unordered-containers
|
||||
base64
|
||||
http-conduit
|
||||
rio
|
||||
envy
|
||||
req
|
||||
];
|
||||
}
|
||||
10
users/wpcarro/website/sandbox/typo-po/README.md
Normal file
10
users/wpcarro/website/sandbox/typo-po/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# Typo-po
|
||||
|
||||
Have you ever published a blog post with typos? Or perhaps you've shared a blog
|
||||
post draft with a group of friends to solicit their feedback. If anyone reads
|
||||
your blog post and finds places where they can correct your typos or suggest
|
||||
grammatical improvements they can use typo-po.
|
||||
|
||||
## What's with the name?
|
||||
|
||||
We police your typos. We prefer po-po to police though. Send us donuts.
|
||||
Loading…
Add table
Add a link
Reference in a new issue