├── src ├── react-app-env.d.ts ├── AudioPlayer │ ├── AudioPlayer.css │ ├── format-time.ts │ ├── AudioWave.tsx │ ├── InputAudio.tsx │ ├── AudioTranscript.tsx │ └── AudioPlayer.tsx ├── App.css ├── setupTests.ts ├── AudioTranscript │ ├── LiveTranstruct.test.ts │ ├── LiveTranstruct.ts │ ├── __snapshots__ │ │ └── LiveTranstruct.test.ts.snap │ └── LiveTranstruct.test.json ├── reportWebVitals.ts ├── index.tsx ├── App.tsx ├── logo.svg └── index.css ├── .githooks └── pre-commit ├── public ├── example.mp3 ├── favicon.ico ├── logo192.png ├── logo512.png ├── robots.txt ├── manifest.json └── index.html ├── .github └── workflows │ └── test.yml ├── tsconfig.json ├── netlify.toml ├── LICENSE ├── package.json ├── docs └── strctures.md ├── README.md └── .gitignore /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('.bin/lint-staged'); 3 | -------------------------------------------------------------------------------- /public/example.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/transcript-audio/HEAD/public/example.mp3 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/transcript-audio/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/transcript-audio/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/azu/transcript-audio/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/AudioPlayer/AudioPlayer.css: -------------------------------------------------------------------------------- 1 | .Audio, 2 | .SelectAudioDevice { 3 | flex: 1; 4 | } 5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App-Main { 2 | display: flex; 3 | align-content: center; 4 | justify-content: center; 5 | } 6 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom"; 6 | -------------------------------------------------------------------------------- /src/AudioTranscript/LiveTranstruct.test.ts: -------------------------------------------------------------------------------- 1 | import { createLiveTranscript } from "./LiveTranstruct"; 2 | import fixture from "./LiveTranstruct.test.json"; 3 | 4 | describe("LiveTranscript", function () { 5 | it("example", () => { 6 | const tr = createLiveTranscript(); 7 | fixture.forEach((item) => { 8 | tr.add(item); 9 | }); 10 | expect(tr.get()).toMatchSnapshot(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/AudioPlayer/format-time.ts: -------------------------------------------------------------------------------- 1 | export const toHHMMSS = (totalSeconds?: number): string => { 2 | if (totalSeconds === undefined) { 3 | return ":"; 4 | } 5 | const hours = Math.floor(totalSeconds / 3600); 6 | const minutes = Math.floor(totalSeconds / 60) % 60; 7 | const seconds = Math.floor(totalSeconds % 60); 8 | 9 | return [hours, minutes, seconds] 10 | .map((v) => (v < 10 ? "0" + v : v)) 11 | .filter((v, i) => v !== "00" || i > 0) 12 | .join(":"); 13 | }; 14 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from "web-vitals"; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import reportWebVitals from "./reportWebVitals"; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById("root") 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: "Test on Node.js ${{ matrix.node-version }}" 6 | runs-on: ubuntu-18.04 7 | strategy: 8 | matrix: 9 | node-version: [10, 12, 14] 10 | steps: 11 | - name: checkout 12 | uses: actions/checkout@v2 13 | - name: setup Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - name: Install 18 | run: yarn install 19 | - name: Test 20 | run: yarn test 21 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | # example netlify.toml 2 | [build] 3 | command = "npm run build" 4 | functions = "functions" 5 | publish = "build" 6 | 7 | ## Uncomment to use this redirect for Single Page Applications like create-react-app. 8 | ## Not needed for static site generators. 9 | #[[redirects]] 10 | # from = "/*" 11 | # to = "/index.html" 12 | # status = 200 13 | 14 | ## (optional) Settings for Netlify Dev 15 | ## https://github.com/netlify/cli/blob/master/docs/netlify-dev.md#project-detection 16 | #[dev] 17 | # command = "yarn start" # Command to start your dev server 18 | # port = 3000 # Port that the dev server will be listening on 19 | # publish = "dist" # Folder with the static content for _redirect file 20 | 21 | ## more info on configuring this file: https://www.netlify.com/docs/netlify-toml-reference/ 22 | -------------------------------------------------------------------------------- /src/AudioPlayer/AudioWave.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from "react"; 2 | // @ts-ignore 3 | import WFPlayer from "wfplayer"; 4 | 5 | export function AudioWave(props: { audioElement: HTMLAudioElement | undefined }) { 6 | const [waveform, setWaveform] = useState(); 7 | const waveRef = useCallback((node) => { 8 | setWaveform(node); 9 | }, []); 10 | useEffect(() => { 11 | console.log("useEffect"); 12 | if (!props.audioElement || !waveform) { 13 | return; 14 | } 15 | console.log("waveform", waveform); 16 | const wp = new WFPlayer({ 17 | container: waveform 18 | }); 19 | wp.load(props.audioElement); 20 | return () => {}; 21 | }, [waveform, props.audioElement]); 22 | 23 | return ( 24 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 azu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/AudioPlayer/InputAudio.tsx: -------------------------------------------------------------------------------- 1 | export interface InputAudioProps { 2 | preferAudioDeviceNames?: string[]; 3 | } 4 | 5 | export function InputAudio(props: InputAudioProps) { 6 | const click = () => { 7 | (async () => { 8 | const devices = await navigator.mediaDevices.enumerateDevices().then(function (devices) { 9 | return devices.filter((device) => device.kind === "audioinput"); 10 | }); 11 | console.log("Input devices", devices); 12 | const device = devices[2]; 13 | if (!device) { 14 | console.log("No device"); 15 | return; 16 | } 17 | const inputDevideId = device.deviceId; 18 | console.log("inputDeviceId", inputDevideId); 19 | const constraints = { 20 | audio: { 21 | deviceId: inputDevideId 22 | } 23 | }; 24 | await navigator.mediaDevices.getUserMedia(constraints).then(function (stream) { 25 | // 成功した時の処理 26 | console.log("SUCCC", stream); 27 | }); 28 | })().catch((error) => { 29 | console.error(error); 30 | }); 31 | }; 32 | return ; 33 | } 34 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./App.css"; 3 | import { AudioPlayer } from "./AudioPlayer/AudioPlayer"; 4 | 5 | function App() { 6 | return ( 7 |
8 |
9 |

🔈📝 Transcript Audio

10 |
11 | 12 | Usage: ⚠️ Need BlackHole and Chrome 13 | 14 |

First

15 |
    16 |
  • 17 | You need to install BlackHole on 18 | your PC 19 |
  • 20 |
  • Click "Play" audio button ▶ at first and Confirm "OK" for use your mike 🎤
  • 21 |
  • **Reload** the page
  • 22 |
23 |

How to get transcript?

24 |
    25 |
  1. Drag and Drop you audio file you want to transcript
  2. 26 |
  3. Play Audio and wait for transcription!
  4. 27 |
28 |
29 |
30 |
31 | 32 |
33 |
34 | ); 35 | } 36 | 37 | export default App; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "transcript-audio", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "react-scripts build", 7 | "eject": "react-scripts eject", 8 | "prepare": "git config --local core.hooksPath .githooks", 9 | "prettier": "prettier --write \"**/*.{js,jsx,ts,tsx,css}\"", 10 | "start": "react-scripts start", 11 | "test": "react-scripts test", 12 | "updateSnapshot": "react-scripts test -u" 13 | }, 14 | "lint-staged": { 15 | "*.{js,jsx,ts,tsx,css}": [ 16 | "prettier --write" 17 | ] 18 | }, 19 | "browserslist": { 20 | "production": [ 21 | ">0.2%", 22 | "not dead", 23 | "not op_mini all" 24 | ], 25 | "development": [ 26 | "last 1 chrome version", 27 | "last 1 firefox version", 28 | "last 1 safari version" 29 | ] 30 | }, 31 | "prettier": { 32 | "printWidth": 120, 33 | "singleQuote": false, 34 | "tabWidth": 4, 35 | "trailingComma": "none" 36 | }, 37 | "eslintConfig": { 38 | "extends": [ 39 | "react-app", 40 | "react-app/jest" 41 | ] 42 | }, 43 | "dependencies": { 44 | "@testing-library/jest-dom": "^5.11.4", 45 | "@testing-library/react": "^11.1.0", 46 | "@testing-library/user-event": "^12.1.10", 47 | "@types/jest": "^26.0.15", 48 | "@types/node": "^12.0.0", 49 | "@types/react": "^16.9.53", 50 | "@types/react-dom": "^16.9.8", 51 | "react": "^17.0.1", 52 | "react-dom": "^17.0.1", 53 | "react-dropzone": "^11.2.4", 54 | "react-scripts": "4.0.1", 55 | "typescript": "^4.0.3", 56 | "web-vitals": "^0.2.4", 57 | "wfplayer": "^1.1.3" 58 | }, 59 | "devDependencies": { 60 | "lint-staged": "^10.5.3", 61 | "prettier": "^2.2.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /docs/strctures.md: -------------------------------------------------------------------------------- 1 | ## SpeechRecognitionResult Data Structure 2 | 3 | - SpeechRecognitionResult[] 4 | - SpeechRecognitionResult: A B C 5 | - SpeechRecognitionResult: D E C... 6 | 7 | We want to https://cloud.google.com/speech-to-text/docs/async-time-offsets like format from SpeechRecognitionResult. 8 | 9 | ```json 10 | { 11 | "transcript": "okay so what am I doing here...(etc)...", 12 | "confidence": 0.96596134, 13 | "words": [ 14 | { 15 | "startTime": "1.400s", 16 | "endTime": "1.800s", 17 | "word": "okay" 18 | }, 19 | { 20 | "startTime": "1.800s", 21 | "endTime": "2.300s", 22 | "word": "so" 23 | }, 24 | { 25 | "startTime": "2.300s", 26 | "endTime": "2.400s", 27 | "word": "what" 28 | }, 29 | { 30 | "startTime": "2.400s", 31 | "endTime": "2.600s", 32 | "word": "am" 33 | }, 34 | { 35 | "startTime": "2.600s", 36 | "endTime": "2.600s", 37 | "word": "I" 38 | }, 39 | { 40 | "startTime": "2.600s", 41 | "endTime": "2.700s", 42 | "word": "doing" 43 | }, 44 | { 45 | "startTime": "2.700s", 46 | "endTime": "3s", 47 | "word": "here" 48 | }, 49 | { 50 | "startTime": "3s", 51 | "endTime": "3.300s", 52 | "word": "why" 53 | }, 54 | { 55 | "startTime": "3.300s", 56 | "endTime": "3.400s", 57 | "word": "am" 58 | }, 59 | { 60 | "startTime": "3.400s", 61 | "endTime": "3.500s", 62 | "word": "I" 63 | }, 64 | { 65 | "startTime": "3.500s", 66 | "endTime": "3.500s", 67 | "word": "here" 68 | }, 69 | ... 70 | ] 71 | } 72 | ``` 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Transcript Audio 2 | 3 | Transcript your audio file like Podcast using [SpeechRecognition](https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition) and Virtual Audio Device. 4 | 5 | ## Requirements 6 | 7 | - Chrome 8 | - depend on [SpeechRecognition](https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition) implementation 9 | - [BlackHole](https://github.com/ExistentialAudio/BlackHole) on macOS 10 | - If you know other application, please let me it. 11 | 12 | ## Usage 13 | 14 | 1. Install [BlackHole](https://github.com/ExistentialAudio/BlackHole) into your PC 15 | - [BlackHole](https://github.com/ExistentialAudio/BlackHole) is virtual loopback audio device 16 | 2. Visit 17 | 3. Click "Play" button at first and Confirm "OK" for use your mike🎤 18 | 4. **Reload** the page 19 | 5. Drag and Drop your audio file you want to transcript to following 20 | 6. Play audio and want for transcription. 21 | 22 | :memo: Input/Output device should be [BlackHole](https://github.com/ExistentialAudio/BlackHole) during transcription. 23 | If Input/Output device is empty, you need to reload the page. 24 | 25 | ## Mechanism 26 | 27 | This application set Input/Output audio device to Virtual Audio device like [BlackHole](https://github.com/ExistentialAudio/BlackHole). 28 | 29 | It aims to create loop back device by setting input/output is same device. 30 | 31 | - Play audio --> loopback device 32 | - [SpeechRecognition](https://developer.mozilla.org/en-US/docs/Web/API/SpeechRecognition) recognize the sound from loopback device 33 | - transcript the text for the audio 34 | 35 | ## Contributing 36 | 37 | 1. Fork it! 38 | 2. Create your feature branch: `git checkout -b my-new-feature` 39 | 3. Commit your changes: `git commit -am 'Add some feature'` 40 | 4. Push to the branch: `git push origin my-new-feature` 41 | 5. Submit a pull request :D 42 | 43 | ## License 44 | 45 | MIT 46 | 47 | example.mp3 use [AquesTalk](https://www.a-quest.com/) 48 | 49 | ## Supporter 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 🔈📝 Transcript Audio 28 | 29 | 30 | Fork me on GitHub 33 | 34 |
35 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/AudioPlayer/AudioTranscript.tsx: -------------------------------------------------------------------------------- 1 | import { createLiveTranscriptResult } from "../AudioTranscript/LiveTranstruct"; 2 | import { toHHMMSS } from "./format-time"; 3 | import { ReactEventHandler } from "react"; 4 | 5 | export type AudioTranscriptPropos = { 6 | onClickLog: (log: createLiveTranscriptResult) => void; 7 | speechingText: JSX.Element; 8 | logs: createLiveTranscriptResult[]; 9 | currentTime: number; 10 | }; 11 | 12 | export function AudioTranscript(props: AudioTranscriptPropos) { 13 | const onClick: ReactEventHandler = (e) => { 14 | const index = e.currentTarget.getAttribute("data-index"); 15 | if (index === null) { 16 | return; 17 | } 18 | props.onClickLog(props.logs?.[Number(index)]); 19 | }; 20 | const output = props.logs.map((log, index) => { 21 | const startTime = log.items[0].startTime; 22 | const endTime = log.items[log.items.length - 1].endTime; 23 | const isActiveLog = startTime <= props.currentTime && props.currentTime < endTime; 24 | return ( 25 |

35 | 49 | {`${toHHMMSS(startTime)} --> ${toHHMMSS(endTime)} 50 | ${log.text}`} 51 |

52 | ); 53 | }); 54 | return ( 55 |
61 |

Transcript 🔉

62 |

{props.speechingText}

63 |

Logs 📝

64 |
69 |                 {output}
70 |             
71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/AudioTranscript/LiveTranstruct.ts: -------------------------------------------------------------------------------- 1 | export type LiveTranscript = { text: string; currentTime: number }; 2 | export type IRTranscript = { 3 | startIndex: number; 4 | endIndex: number; 5 | startTime: number; 6 | endTime: number; 7 | }; 8 | export type createLiveTranscriptResultItem = { 9 | startIndex: number; 10 | endIndex: number; 11 | text: string; 12 | startTime: number; 13 | endTime: number; 14 | }; 15 | export type createLiveTranscriptResult = { 16 | text: string; 17 | items: createLiveTranscriptResultItem[]; 18 | }; 19 | export const createLiveTranscript = (startTime: number = 0) => { 20 | let rawTranscripts: LiveTranscript[] = []; 21 | let internalTranscripts: IRTranscript[] = []; 22 | return { 23 | valid(transcript: LiveTranscript) { 24 | const lastTranscript = rawTranscripts[rawTranscripts.length - 1]; 25 | if (!lastTranscript) { 26 | return true; 27 | } 28 | // if text length is lower than previous, it is invalid 29 | return transcript.text.length > lastTranscript.text.length; 30 | }, 31 | add(transcript: LiveTranscript) { 32 | if (!this.valid(transcript)) { 33 | return; 34 | } 35 | rawTranscripts.push(transcript); 36 | const lastIR = internalTranscripts[internalTranscripts.length - 1]; 37 | if (lastIR) { 38 | internalTranscripts.push({ 39 | startIndex: lastIR.endIndex, 40 | endIndex: transcript.text.length, 41 | startTime: lastIR.endTime + 1, 42 | endTime: transcript.currentTime 43 | }); 44 | } else { 45 | // first IR 46 | internalTranscripts.push({ 47 | startIndex: 0, 48 | endIndex: transcript.text.length, 49 | startTime: startTime, 50 | endTime: transcript.currentTime 51 | }); 52 | } 53 | }, 54 | get(): createLiveTranscriptResult { 55 | const lastTranscript = rawTranscripts[rawTranscripts.length - 1]; 56 | if (!lastTranscript) { 57 | return { 58 | text: "", 59 | items: [] 60 | }; 61 | } 62 | return { 63 | text: lastTranscript.text, 64 | items: internalTranscripts.map((ir) => { 65 | return { 66 | ...ir, 67 | text: lastTranscript.text.slice(ir.startIndex, ir.endIndex) 68 | }; 69 | }) 70 | }; 71 | }, 72 | clear() { 73 | rawTranscripts = []; 74 | internalTranscripts = []; 75 | }, 76 | setStartTime(newStartTime: number) { 77 | startTime = newStartTime; 78 | } 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | debug* 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | ### https://raw.github.com/github/gitignore/d2c1bb2b9c72ead618c9f6a48280ebc7a8e0dff6/Node.gitignore 26 | 27 | # Logs 28 | logs 29 | *.log 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | 34 | # Runtime data 35 | pids 36 | *.pid 37 | *.seed 38 | *.pid.lock 39 | 40 | # Directory for instrumented libs generated by jscoverage/JSCover 41 | lib-cov 42 | 43 | # Coverage directory used by tools like istanbul 44 | coverage 45 | 46 | # nyc test coverage 47 | .nyc_output 48 | 49 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 50 | .grunt 51 | 52 | # Bower dependency directory (https://bower.io/) 53 | bower_components 54 | 55 | # node-waf configuration 56 | .lock-wscript 57 | 58 | # Compiled binary addons (https://nodejs.org/api/addons.html) 59 | build/Release 60 | 61 | # Dependency directories 62 | node_modules/ 63 | jspm_packages/ 64 | 65 | # TypeScript v1 declaration files 66 | typings/ 67 | 68 | # Optional npm cache directory 69 | .npm 70 | 71 | # Optional eslint cache 72 | .eslintcache 73 | 74 | # Optional REPL history 75 | .node_repl_history 76 | 77 | # Output of 'npm pack' 78 | *.tgz 79 | 80 | # Yarn Integrity file 81 | .yarn-integrity 82 | 83 | # dotenv environment variables file 84 | .env 85 | .env.test 86 | 87 | # parcel-bundler cache (https://parceljs.org/) 88 | .cache 89 | 90 | # next.js build output 91 | .next 92 | 93 | # nuxt.js build output 94 | .nuxt 95 | 96 | # vuepress build output 97 | .vuepress/dist 98 | 99 | # Serverless directories 100 | .serverless/ 101 | 102 | # FuseBox cache 103 | .fusebox/ 104 | 105 | # DynamoDB Local files 106 | .dynamodb/ 107 | 108 | 109 | ### https://raw.github.com/github/gitignore/d2c1bb2b9c72ead618c9f6a48280ebc7a8e0dff6/Global/JetBrains.gitignore 110 | 111 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 112 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 113 | 114 | # User-specific stuff 115 | .idea/**/workspace.xml 116 | .idea/**/tasks.xml 117 | .idea/**/usage.statistics.xml 118 | .idea/**/dictionaries 119 | .idea/**/shelf 120 | 121 | # Generated files 122 | .idea/**/contentModel.xml 123 | 124 | # Sensitive or high-churn files 125 | .idea/**/dataSources/ 126 | .idea/**/dataSources.ids 127 | .idea/**/dataSources.local.xml 128 | .idea/**/sqlDataSources.xml 129 | .idea/**/dynamic.xml 130 | .idea/**/uiDesigner.xml 131 | .idea/**/dbnavigator.xml 132 | 133 | # Gradle 134 | .idea/**/gradle.xml 135 | .idea/**/libraries 136 | 137 | # Gradle and Maven with auto-import 138 | # When using Gradle or Maven with auto-import, you should exclude module files, 139 | # since they will be recreated, and may cause churn. Uncomment if using 140 | # auto-import. 141 | # .idea/modules.xml 142 | # .idea/*.iml 143 | # .idea/modules 144 | 145 | # CMake 146 | cmake-build-*/ 147 | 148 | # Mongo Explorer plugin 149 | .idea/**/mongoSettings.xml 150 | 151 | # File-based project format 152 | *.iws 153 | 154 | # IntelliJ 155 | out/ 156 | 157 | # mpeltonen/sbt-idea plugin 158 | .idea_modules/ 159 | 160 | # JIRA plugin 161 | atlassian-ide-plugin.xml 162 | 163 | # Cursive Clojure plugin 164 | .idea/replstate.xml 165 | 166 | # Crashlytics plugin (for Android Studio and IntelliJ) 167 | com_crashlytics_export_strings.xml 168 | crashlytics.properties 169 | crashlytics-build.properties 170 | fabric.properties 171 | 172 | # Editor-based Rest Client 173 | .idea/httpRequests 174 | 175 | # Android studio 3.1+ serialized cache file 176 | .idea/caches/build_file_checksums.ser 177 | 178 | 179 | /lib 180 | 181 | # Local Netlify folder 182 | .netlify -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --nc-font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, 3 | "Open Sans", "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 4 | --nc-font-mono: Consolas, monaco, "Ubuntu Mono", "Liberation Mono", "Courier New", Courier, monospace; 5 | 6 | /* Light theme */ 7 | --nc-tx-1: #000000; 8 | --nc-tx-2: #1a1a1a; 9 | --nc-bg-1: #ffffff; 10 | --nc-bg-2: #f6f8fa; 11 | --nc-bg-3: #e5e7eb; 12 | --nc-lk-1: #0070f3; 13 | --nc-lk-2: #0366d6; 14 | --nc-lk-tx: #ffffff; 15 | --nc-ac-1: #79ffe1; 16 | --nc-ac-tx: #0c4047; 17 | 18 | /* Dark theme */ 19 | --nc-d-tx-1: #ffffff; 20 | --nc-d-tx-2: #eeeeee; 21 | --nc-d-bg-1: #000000; 22 | --nc-d-bg-2: #111111; 23 | --nc-d-bg-3: #222222; 24 | --nc-d-lk-1: #3291ff; 25 | --nc-d-lk-2: #0070f3; 26 | --nc-d-lk-tx: #ffffff; 27 | --nc-d-ac-1: #7928ca; 28 | --nc-d-ac-tx: #ffffff; 29 | } 30 | 31 | @media (prefers-color-scheme: dark) { 32 | :root { 33 | --nc-tx-1: var(--nc-d-tx-1); 34 | --nc-tx-2: var(--nc-d-tx-2); 35 | --nc-bg-1: var(--nc-d-bg-1); 36 | --nc-bg-2: var(--nc-d-bg-2); 37 | --nc-bg-3: var(--nc-d-bg-3); 38 | --nc-lk-1: var(--nc-d-lk-1); 39 | --nc-lk-2: var(--nc-d-lk-2); 40 | --nc-lk-tx: var(--nc--dlk-tx); 41 | --nc-ac-1: var(--nc-d-ac-1); 42 | --nc-ac-tx: var(--nc--dac-tx); 43 | } 44 | } 45 | 46 | * { 47 | /* Reset margins and padding */ 48 | margin: 0; 49 | padding: 0; 50 | } 51 | 52 | address, 53 | area, 54 | article, 55 | aside, 56 | audio, 57 | blockquote, 58 | datalist, 59 | details, 60 | dl, 61 | fieldset, 62 | figure, 63 | form, 64 | input, 65 | iframe, 66 | img, 67 | meter, 68 | nav, 69 | ol, 70 | optgroup, 71 | option, 72 | output, 73 | p, 74 | pre, 75 | progress, 76 | ruby, 77 | section, 78 | table, 79 | textarea, 80 | ul, 81 | video { 82 | /* Margins for most elements */ 83 | margin-bottom: 1rem; 84 | } 85 | 86 | html, 87 | input, 88 | select, 89 | button { 90 | /* Set body font family and some finicky elements */ 91 | font-family: var(--nc-font-sans); 92 | } 93 | 94 | body { 95 | /* Center body in page */ 96 | margin: 0 auto; 97 | padding: 2rem; 98 | border-radius: 6px; 99 | overflow-x: hidden; 100 | word-break: break-word; 101 | overflow-wrap: break-word; 102 | background: var(--nc-bg-1); 103 | 104 | /* Main body text */ 105 | color: var(--nc-tx-2); 106 | font-size: 1.03rem; 107 | line-height: 1.5; 108 | } 109 | 110 | ::selection { 111 | /* Set background color for selected text */ 112 | background: var(--nc-ac-1); 113 | color: var(--nc-ac-tx); 114 | } 115 | 116 | h1, 117 | h2, 118 | h3, 119 | h4, 120 | h5, 121 | h6 { 122 | line-height: 1; 123 | color: var(--nc-tx-1); 124 | padding-top: 0.875rem; 125 | } 126 | 127 | h1, 128 | h2, 129 | h3 { 130 | color: var(--nc-tx-1); 131 | padding-bottom: 2px; 132 | margin-bottom: 8px; 133 | border-bottom: 1px solid var(--nc-bg-2); 134 | } 135 | 136 | h4, 137 | h5, 138 | h6 { 139 | margin-bottom: 0.3rem; 140 | } 141 | 142 | h1 { 143 | font-size: 2.25rem; 144 | } 145 | 146 | h2 { 147 | font-size: 1.85rem; 148 | } 149 | 150 | h3 { 151 | font-size: 1.55rem; 152 | } 153 | 154 | h4 { 155 | font-size: 1.25rem; 156 | } 157 | 158 | h5 { 159 | font-size: 1rem; 160 | } 161 | 162 | h6 { 163 | font-size: 0.875rem; 164 | } 165 | 166 | a { 167 | color: var(--nc-lk-1); 168 | } 169 | 170 | a:hover { 171 | color: var(--nc-lk-2); 172 | } 173 | 174 | abbr:hover { 175 | /* Set the '?' cursor while hovering an abbreviation */ 176 | cursor: help; 177 | } 178 | 179 | blockquote { 180 | padding: 1.5rem; 181 | background: var(--nc-bg-2); 182 | border-left: 5px solid var(--nc-bg-3); 183 | } 184 | 185 | abbr { 186 | cursor: help; 187 | } 188 | 189 | blockquote *:last-child { 190 | padding-bottom: 0; 191 | margin-bottom: 0; 192 | } 193 | 194 | header { 195 | background: var(--nc-bg-2); 196 | border-bottom: 1px solid var(--nc-bg-3); 197 | padding: 2rem 1.5rem; 198 | 199 | /* This sets the right and left margins to cancel out the body's margins. It's width is still the same, but the background stretches across the page's width. */ 200 | 201 | margin: -2rem calc(0px - (50vw - 50%)) 2rem; 202 | 203 | /* Shorthand for: 204 | 205 | margin-top: -2rem; 206 | margin-bottom: 2rem; 207 | 208 | margin-left: calc(0px - (50vw - 50%)); 209 | margin-right: calc(0px - (50vw - 50%)); */ 210 | 211 | padding-left: calc(50vw - 50%); 212 | padding-right: calc(50vw - 50%); 213 | } 214 | 215 | header h1, 216 | header h2, 217 | header h3 { 218 | padding-bottom: 0; 219 | border-bottom: 0; 220 | } 221 | 222 | header > *:first-child { 223 | margin-top: 0; 224 | padding-top: 0; 225 | } 226 | 227 | header > *:last-child { 228 | margin-bottom: 0; 229 | } 230 | 231 | a button, 232 | button, 233 | input[type="submit"], 234 | input[type="reset"], 235 | input[type="button"] { 236 | font-size: 1rem; 237 | display: inline-block; 238 | padding: 6px 12px; 239 | text-align: center; 240 | text-decoration: none; 241 | white-space: nowrap; 242 | background: var(--nc-lk-1); 243 | color: var(--nc-lk-tx); 244 | border: 0; 245 | border-radius: 4px; 246 | box-sizing: border-box; 247 | cursor: pointer; 248 | color: var(--nc-lk-tx); 249 | } 250 | 251 | a button[disabled], 252 | button[disabled], 253 | input[type="submit"][disabled], 254 | input[type="reset"][disabled], 255 | input[type="button"][disabled] { 256 | cursor: default; 257 | opacity: 0.5; 258 | 259 | /* Set the [X] cursor while hovering a disabled link */ 260 | cursor: not-allowed; 261 | } 262 | 263 | .button:focus, 264 | .button:enabled:hover, 265 | button:focus, 266 | button:enabled:hover, 267 | input[type="submit"]:focus, 268 | input[type="submit"]:enabled:hover, 269 | input[type="reset"]:focus, 270 | input[type="reset"]:enabled:hover, 271 | input[type="button"]:focus, 272 | input[type="button"]:enabled:hover { 273 | background: var(--nc-lk-2); 274 | } 275 | 276 | code, 277 | pre, 278 | kbd, 279 | samp { 280 | /* Set the font family for monospaced elements */ 281 | font-family: var(--nc-font-mono); 282 | } 283 | 284 | code, 285 | samp, 286 | kbd, 287 | pre { 288 | /* The main preformatted style. This is changed slightly across different cases. */ 289 | background: var(--nc-bg-2); 290 | border: 1px solid var(--nc-bg-3); 291 | border-radius: 4px; 292 | padding: 3px 6px; 293 | /* ↓ font-size is relative to containing element, so it scales for titles*/ 294 | font-size: 0.9em; 295 | } 296 | 297 | kbd { 298 | /* Makes the kbd element look like a keyboard key */ 299 | border-bottom: 3px solid var(--nc-bg-3); 300 | } 301 | 302 | pre { 303 | padding: 1rem 1.4rem; 304 | max-width: 100%; 305 | overflow: auto; 306 | } 307 | 308 | pre code { 309 | /* When is in a
, reset it's formatting to blend in */
310 |     background: inherit;
311 |     font-size: inherit;
312 |     color: inherit;
313 |     border: 0;
314 |     padding: 0;
315 |     margin: 0;
316 | }
317 | 
318 | code pre {
319 |     /* When 
 is in a , reset it's formatting to blend in */
320 |     display: inline;
321 |     background: inherit;
322 |     font-size: inherit;
323 |     color: inherit;
324 |     border: 0;
325 |     padding: 0;
326 |     margin: 0;
327 | }
328 | 
329 | details {
330 |     /* Make the 
look more "clickable" */ 331 | padding: 0.6rem 1rem; 332 | background: var(--nc-bg-2); 333 | border: 1px solid var(--nc-bg-3); 334 | border-radius: 4px; 335 | } 336 | 337 | summary { 338 | /* Makes the look more like a "clickable" link with the pointer cursor */ 339 | cursor: pointer; 340 | font-weight: bold; 341 | } 342 | 343 | details[open] { 344 | /* Adjust the
padding while open */ 345 | padding-bottom: 0.75rem; 346 | } 347 | 348 | details[open] summary { 349 | /* Adjust the
padding while open */ 350 | margin-bottom: 6px; 351 | } 352 | 353 | details[open] > *:last-child { 354 | /* Resets the bottom margin of the last element in the
while
is opened. This prevents double margins/paddings. */ 355 | margin-bottom: 0; 356 | } 357 | 358 | dt { 359 | font-weight: bold; 360 | } 361 | 362 | dd::before { 363 | /* Add an arrow to data table definitions */ 364 | content: "→ "; 365 | } 366 | 367 | hr { 368 | /* Reset the border of the
separator, then set a better line */ 369 | border: 0; 370 | border-bottom: 1px solid var(--nc-bg-3); 371 | margin: 1rem auto; 372 | } 373 | 374 | fieldset { 375 | margin-top: 1rem; 376 | padding: 2rem; 377 | border: 1px solid var(--nc-bg-3); 378 | border-radius: 4px; 379 | } 380 | 381 | legend { 382 | padding: auto 0.5rem; 383 | } 384 | 385 | table { 386 | /* border-collapse sets the table's elements to share borders, rather than floating as separate "boxes". */ 387 | border-collapse: collapse; 388 | width: 100%; 389 | } 390 | 391 | td, 392 | th { 393 | border: 1px solid var(--nc-bg-3); 394 | text-align: left; 395 | padding: 0.5rem; 396 | } 397 | 398 | th { 399 | background: var(--nc-bg-2); 400 | } 401 | 402 | tr:nth-child(even) { 403 | /* Set every other cell slightly darker. Improves readability. */ 404 | background: var(--nc-bg-2); 405 | } 406 | 407 | table caption { 408 | font-weight: bold; 409 | margin-bottom: 0.5rem; 410 | } 411 | 412 | textarea { 413 | /* Don't let the