subtree(users/wpcarro): docking briefcase at '24f5a642'

git-subtree-dir: users/wpcarro
git-subtree-mainline: 464bbcb15c
git-subtree-split: 24f5a642af
Change-Id: I6105b3762b79126b3488359c95978cadb3efa789
This commit is contained in:
Vincent Ambo 2021-12-14 01:51:19 +03:00
commit 019f8fd211
766 changed files with 175420 additions and 0 deletions

View 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)"

View file

@ -0,0 +1,2 @@
.cache
dist

View 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
```

View 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
'';
}

View 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"
}
}

View file

@ -0,0 +1,5 @@
const tailwindcss = require("tailwindcss");
module.exports = {
plugins: [tailwindcss("./tailwind.config.js")],
};

View file

@ -0,0 +1,9 @@
let
briefcase = import <briefcase> {};
pkgs = briefcase.third_party.pkgs;
in pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
yarn
];
}

View 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;

View 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"
);
}
}
};

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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>

View 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")
);

View 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 });

View file

@ -0,0 +1,7 @@
module.exports = {
theme: {
extend: {},
},
variants: {},
plugins: [],
};

View 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/**/*"]
}

File diff suppressed because it is too large Load diff

View 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;
}

View 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>

View 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"
}
}

View file

@ -0,0 +1,9 @@
let
briefcase = import <briefcase> {};
pkgs = briefcase.third_party.pkgs;
in pkgs.mkShell {
buildInputs = with pkgs; [
yarn
nodejs
];
}

View 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;

View file

@ -0,0 +1,7 @@
module.exports = {
theme: {
extend: {},
},
variants: {},
plugins: [],
}

View 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==

View 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;
}

View file

@ -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

View 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>

View file

@ -0,0 +1,2 @@
source_up
use_nix

View file

@ -0,0 +1,3 @@
/elm-stuff
/Main.min.js
/output.css

View 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
```

View 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;
}

View 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";
};
}

View 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": {}
}
}

View 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

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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>

View 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
];
}

View file

@ -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) ]
]

View 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"
]
[]
]

View 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
}

View 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)

View 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 doesnt 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 = []
}
]
]

View 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 []
}
)

View file

@ -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
}
]

View file

@ -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
]

View file

@ -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"

View 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 )

View file

@ -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

View 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 = []
}
]

File diff suppressed because it is too large Load diff

View 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 ]
]

View file

@ -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)"

View file

@ -0,0 +1,7 @@
:set prompt "> "
:set -Wall
:set -XOverloadedStrings
:set -XNoImplicitPrelude
:set -XRecordWildCards
:set -XTypeApplications

View file

@ -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

View file

@ -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"]
}

View file

@ -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"
}

View file

@ -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

View file

@ -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."

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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{..}

View file

@ -0,0 +1,8 @@
--------------------------------------------------------------------------------
module Utils where
--------------------------------------------------------------------------------
import Data.Function ((&))
--------------------------------------------------------------------------------
(|>) :: a -> (a -> b) -> b
(|>) = (&)

View file

@ -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
];
}

View file

@ -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>

View file

@ -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;

View file

@ -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
];
}

View 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.