add a web UI proof of concept
See react/README
This commit is contained in:
parent
f3a6a661fe
commit
82f0db7583
@ -41,8 +41,9 @@ Subcomponents
|
||||
- rspy contains a Python module (ankirspy) for accessing the Rust code.
|
||||
- rslib contains the parts of the code implemented in Rust. This
|
||||
is only a tiny subsection at the moment.
|
||||
- proto contains the interface used to communicate between the Rust and
|
||||
Python code.
|
||||
- proto contains the interface used to communicate between different
|
||||
languages.
|
||||
- tslib and react are just an experiment at the moment.
|
||||
|
||||
Makefile
|
||||
--------------
|
||||
|
1
react/.env
Normal file
1
react/.env
Normal file
@ -0,0 +1 @@
|
||||
EXTEND_ESLINT=true
|
9
react/.eslintrc.js
Normal file
9
react/.eslintrc.js
Normal file
@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
extends: ["react-app"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" }
|
||||
]
|
||||
},
|
||||
};
|
24
react/.gitignore
vendored
Normal file
24
react/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.vscode
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
@ -1,3 +1,34 @@
|
||||
pybackend_run:
|
||||
pip install bottle
|
||||
python pybackend.py
|
||||
SHELL := bash
|
||||
.SHELLFLAGS := -eu -o pipefail -c
|
||||
.DELETE_ON_ERROR:
|
||||
MAKEFLAGS += --warn-undefined-variables
|
||||
MAKEFLAGS += --no-builtin-rules
|
||||
.SUFFIXES:
|
||||
|
||||
$(shell mkdir -p .build)
|
||||
|
||||
.PHONY: all
|
||||
all: run
|
||||
|
||||
.PHONY: run
|
||||
run: build-tslib
|
||||
tmux new -d -s anki-react "make run-pybackend"
|
||||
tmux split-window "make run-react"
|
||||
tmux attach
|
||||
|
||||
.PHONY: run-pybackend
|
||||
run-pybackend:
|
||||
../pyenv/bin/pip install bottle
|
||||
../pyenv/bin/python pybackend.py
|
||||
|
||||
.PHONY: build-tslib
|
||||
build-tslib:
|
||||
(cd ../tslib && make build)
|
||||
|
||||
.PHONY: run-react
|
||||
run-react: .build/deps
|
||||
npm run start
|
||||
|
||||
.build/deps: package.json
|
||||
npm i
|
||||
@touch $@
|
||||
|
13
react/README
Normal file
13
react/README
Normal file
@ -0,0 +1,13 @@
|
||||
This is a proof of concept for an alternate, web-based UI. It is in the very
|
||||
early stages, and does not do anything useful at the moment.
|
||||
|
||||
The code uses the helpers in ../tslib to communicate with the Python
|
||||
backend defined in ../pylib/anki/pybackend.py, leveraging the same
|
||||
protobuf definitions used for the Rust/Python bridge.
|
||||
|
||||
To try it:
|
||||
|
||||
- ensure 'tmux' is installed
|
||||
- run 'make run' in the top level folder and close Anki
|
||||
- copy a test .anki2 file to ~/test.anki2
|
||||
- run 'make run' in this folder
|
15598
react/package-lock.json
generated
Normal file
15598
react/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
react/package.json
Normal file
54
react/package.json
Normal file
@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "anki-react",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
"@types/jest": "^24.0.23",
|
||||
"@types/node": "^12.12.14",
|
||||
"@types/react": "^16.9.15",
|
||||
"@types/react-dom": "^16.9.4",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-scripts": "3.3.0",
|
||||
"typescript": "^3.7.3",
|
||||
"anki": "../tslib"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react-router-dom": "^5.1.3",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.0",
|
||||
"@types/react-window": "^1.8.1",
|
||||
"eslint": "^6.8.0",
|
||||
"prettier": "^1.19.1",
|
||||
"protobufjs": "^6.8.8",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-split-pane": "^0.1.89",
|
||||
"react-virtualized-auto-sizer": "^1.0.2",
|
||||
"react-window": "^1.8.5",
|
||||
"typescript-plugin-css-modules": "^2.1.1"
|
||||
},
|
||||
"proxy": "http://localhost:5006/"
|
||||
}
|
13
react/public/index.html
Normal file
13
react/public/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>Anki</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
2
react/public/robots.txt
Normal file
2
react/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
9
react/src/index.tsx
Normal file
9
react/src/index.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import "./ui/index.css";
|
||||
import App from "./ui/App";
|
||||
import * as serviceWorker from "./serviceWorker";
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById("root"));
|
||||
|
||||
serviceWorker.unregister();
|
1
react/src/react-app-env.d.ts
vendored
Normal file
1
react/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
145
react/src/serviceWorker.ts
Normal file
145
react/src/serviceWorker.ts
Normal file
@ -0,0 +1,145 @@
|
||||
// This optional code is used to register a service worker.
|
||||
// register() is not called by default.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on subsequent visits to a page, after all the
|
||||
// existing tabs open on the page have been closed, since previously cached
|
||||
// resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model and instructions on how to
|
||||
// opt-in, read https://bit.ly/CRA-PWA
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.0/8 are considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
);
|
||||
|
||||
type Config = {
|
||||
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||
};
|
||||
|
||||
export function register(config?: Config) {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(
|
||||
process.env.PUBLIC_URL,
|
||||
window.location.href
|
||||
);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl, config);
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://bit.ly/CRA-PWA'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Is not localhost. Just register service worker
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl: string, config?: Config) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (installingWorker == null) {
|
||||
return;
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the updated precached content has been fetched,
|
||||
// but the previous service worker will still serve the older
|
||||
// content until all client tabs are closed.
|
||||
console.log(
|
||||
'New content is available and will be used when all ' +
|
||||
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
|
||||
);
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onUpdate) {
|
||||
config.onUpdate(registration);
|
||||
}
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl, {
|
||||
headers: { 'Service-Worker': 'script' }
|
||||
})
|
||||
.then(response => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (
|
||||
response.status === 404 ||
|
||||
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}
|
||||
}
|
5
react/src/setupTests.ts
Normal file
5
react/src/setupTests.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect';
|
117
react/src/ui/App.css
Normal file
117
react/src/ui/App.css
Normal file
@ -0,0 +1,117 @@
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f7f7f7;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #333;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(0, 100, 200);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: rgb(0, 80, 160);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0.4em;
|
||||
margin: 0 0 0.5em 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
color: #333;
|
||||
background-color: #f4f4f4;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.Resizer {
|
||||
background: #000;
|
||||
opacity: 0.2;
|
||||
z-index: 1;
|
||||
-moz-box-sizing: border-box;
|
||||
-webkit-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
-moz-background-clip: padding;
|
||||
-webkit-background-clip: padding;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.Resizer:hover {
|
||||
-webkit-transition: all 2s ease;
|
||||
transition: all 2s ease;
|
||||
}
|
||||
|
||||
.Resizer.horizontal {
|
||||
height: 11px;
|
||||
margin: -5px 0;
|
||||
border-top: 5px solid rgba(255, 255, 255, 0);
|
||||
border-bottom: 5px solid rgba(255, 255, 255, 0);
|
||||
cursor: row-resize;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.Resizer.horizontal:hover {
|
||||
border-top: 5px solid rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 5px solid rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.Resizer.vertical {
|
||||
width: 11px;
|
||||
margin: 0 -5px;
|
||||
border-left: 5px solid rgba(255, 255, 255, 0);
|
||||
border-right: 5px solid rgba(255, 255, 255, 0);
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.Resizer.vertical:hover {
|
||||
border-left: 5px solid rgba(0, 0, 0, 0.5);
|
||||
border-right: 5px solid rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.Resizer.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.Resizer.disabled:hover {
|
||||
border-color: transparent;
|
||||
}
|
9
react/src/ui/App.test.tsx
Normal file
9
react/src/ui/App.test.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import App from "./App";
|
||||
|
||||
test("renders link", () => {
|
||||
const { getByText } = render(<App />);
|
||||
const linkElement = getByText(/decks/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
33
react/src/ui/App.tsx
Normal file
33
react/src/ui/App.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import "./App.css";
|
||||
import React from "react";
|
||||
import { Switch, Route, Link, HashRouter } from "react-router-dom";
|
||||
import { DeckDueScreen } from "./DeckScreen";
|
||||
import { BrowseScreen } from "./BrowseScreen";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<HashRouter hashType="slash">
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/decks">Decks</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/browse">Browse</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr />
|
||||
|
||||
<Switch>
|
||||
<Route exact path="/decks">
|
||||
<DeckDueScreen />
|
||||
</Route>
|
||||
<Route path="/browse">
|
||||
<BrowseScreen />
|
||||
</Route>
|
||||
</Switch>
|
||||
</div>
|
||||
</HashRouter>
|
||||
);
|
||||
}
|
20
react/src/ui/BrowseRow.module.css
Normal file
20
react/src/ui/BrowseRow.module.css
Normal file
@ -0,0 +1,20 @@
|
||||
.row {
|
||||
/* ensures text is center-aligned */
|
||||
line-height: 2em;
|
||||
height: 2em;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
background-color: white;
|
||||
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
.rowAlt {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.rowSelected {
|
||||
background-color: #77f;
|
||||
}
|
51
react/src/ui/BrowseRow.tsx
Normal file
51
react/src/ui/BrowseRow.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useEffect, useState, CSSProperties, memo } from "react";
|
||||
import { Browser } from "anki/dist/browser";
|
||||
import React from "react";
|
||||
import styles from "./BrowseRow.module.css";
|
||||
|
||||
interface BrowseRow {
|
||||
index: number;
|
||||
style: CSSProperties;
|
||||
data: BrowserData;
|
||||
}
|
||||
|
||||
interface BrowserData {
|
||||
browser: Browser;
|
||||
}
|
||||
|
||||
export const BrowseRow = memo(
|
||||
({ index, style, data: { browser } }: BrowseRow) => {
|
||||
const [data, setData] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const idx = index;
|
||||
browser.getRowData(idx, setData);
|
||||
console.log(`effect ${index}`);
|
||||
|
||||
return function cleanup() {
|
||||
browser.cancelRequest(index);
|
||||
};
|
||||
}, [index, browser]);
|
||||
|
||||
const onClick = () => {
|
||||
browser.selectOnly(index);
|
||||
// fixme: better way to trigger refresh
|
||||
setData(data + " ");
|
||||
};
|
||||
|
||||
const classes = [styles.row];
|
||||
if (index % 2) {
|
||||
classes.push(styles.rowAlt);
|
||||
}
|
||||
if (browser.rowIsSelected(index)) {
|
||||
classes.push(styles.rowSelected);
|
||||
}
|
||||
|
||||
console.log(`render ${index}`);
|
||||
return (
|
||||
<div style={style} className={classes.join(" ")} onClick={onClick}>
|
||||
Item {index}: {data}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
37
react/src/ui/BrowseScreen.tsx
Normal file
37
react/src/ui/BrowseScreen.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React, { useState } from "react";
|
||||
import { Browser } from "anki/dist/browser";
|
||||
import { BrowseTable } from "./BrowseTable";
|
||||
import { BrowseSearchInput } from "./BrowseSearchInput";
|
||||
import SplitPane from "react-split-pane";
|
||||
|
||||
export const BrowseScreen = () => {
|
||||
const [browser, setBrowser] = useState<Browser | null>(null);
|
||||
|
||||
const onSearchChanged = (txt: string) => {
|
||||
const s = new Browser();
|
||||
setBrowser(null);
|
||||
s.search(txt)
|
||||
.then(() => setBrowser(s))
|
||||
.catch(err => {
|
||||
throw Error(err);
|
||||
});
|
||||
};
|
||||
|
||||
console.log("render browser");
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
<div>
|
||||
<BrowseSearchInput onSearchChanged={onSearchChanged} />
|
||||
</div>
|
||||
<div style={{ position: "relative", height: "100%" }}>
|
||||
<SplitPane split="horizontal" minSize={50} defaultSize={500}>
|
||||
<div style={{ flexBasis: "100%" }} tabIndex={0}>
|
||||
<BrowseTable browser={browser} />
|
||||
</div>
|
||||
<div>Editing Area</div>
|
||||
</SplitPane>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
30
react/src/ui/BrowseSearchInput.tsx
Normal file
30
react/src/ui/BrowseSearchInput.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React from "react";
|
||||
|
||||
interface BrowseSearchInputProps {
|
||||
onSearchChanged: (txt: string) => void;
|
||||
}
|
||||
|
||||
export const BrowseSearchInput = ({
|
||||
onSearchChanged
|
||||
}: BrowseSearchInputProps) => {
|
||||
let currentInput = "";
|
||||
|
||||
function onInput(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
onSearchChanged(currentInput);
|
||||
}
|
||||
|
||||
function onInputChange(e: React.FormEvent<HTMLInputElement>) {
|
||||
currentInput = (e.target as any).value;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onInput}>
|
||||
<input
|
||||
onChange={onInputChange}
|
||||
autoFocus
|
||||
placeholder="Hit enter to search"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
33
react/src/ui/BrowseTable.tsx
Normal file
33
react/src/ui/BrowseTable.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import { FixedSizeList } from "react-window";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import { Browser } from "anki/dist/browser";
|
||||
import { BrowseRow } from "./BrowseRow";
|
||||
|
||||
interface BrowseTableProps {
|
||||
browser: Browser | null;
|
||||
}
|
||||
|
||||
export const BrowseTable: React.FC<BrowseTableProps> = ({ browser }) => {
|
||||
if (!browser) {
|
||||
return <div />;
|
||||
} else {
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<div tabIndex={0}>
|
||||
<FixedSizeList
|
||||
height={height}
|
||||
itemCount={browser.rows()}
|
||||
itemSize={35}
|
||||
width={width}
|
||||
itemData={{ browser }}
|
||||
>
|
||||
{BrowseRow}
|
||||
</FixedSizeList>
|
||||
</div>
|
||||
)}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
};
|
36
react/src/ui/DeckScreen.css
Normal file
36
react/src/ui/DeckScreen.css
Normal file
@ -0,0 +1,36 @@
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.nodeOuter {
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 5px;
|
||||
transition: 1s ease-in;
|
||||
transition-property: height;
|
||||
}
|
||||
|
||||
.nodeInner {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.counts {
|
||||
float: right;
|
||||
font-size: smaller;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.new {
|
||||
color: #000099;
|
||||
}
|
||||
.due {
|
||||
color: #007700;
|
||||
}
|
||||
|
||||
.dueTable {
|
||||
max-width: 50em;
|
||||
min-width: 40vw;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
69
react/src/ui/DeckScreen.tsx
Normal file
69
react/src/ui/DeckScreen.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import "./DeckScreen.css";
|
||||
import "./App.css";
|
||||
|
||||
import { deckTree, pb as pt } from "anki/dist/backend";
|
||||
|
||||
export const DeckDueScreen: React.FC = () => {
|
||||
const [decks, setDecks] = useState<pt.IDeckTreeNode[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!decks.length) {
|
||||
getTopNode();
|
||||
}
|
||||
});
|
||||
|
||||
const getTopNode = async () => {
|
||||
setDecks(await deckTree());
|
||||
};
|
||||
|
||||
const rows = decks.map(deck => (
|
||||
<DeckRow key={deck.deckId?.toString()} deck={deck} />
|
||||
));
|
||||
|
||||
return <div className="dueTable">{rows}</div>;
|
||||
};
|
||||
|
||||
type DeckRowProps = {
|
||||
deck: pt.IDeckTreeNode;
|
||||
};
|
||||
|
||||
const DeckRow: React.FC<DeckRowProps> = ({ deck }) => {
|
||||
const name = deck.names![deck.names!.length - 1];
|
||||
const dueCount = deck.reviewCount! + deck.learnCount!;
|
||||
const haveChildren = deck.children!.length > 0;
|
||||
const indent = deck.names!.length > 1 ? 1 : 0;
|
||||
const [collapsed, setCollapsed] = useState(deck.collapsed!);
|
||||
|
||||
const onClick = () => {
|
||||
setCollapsed(!collapsed);
|
||||
console.log(`collapsed now ${collapsed}`);
|
||||
};
|
||||
|
||||
console.log(`drawing ${name}`);
|
||||
|
||||
return (
|
||||
<div className="nodeOuter" style={{ marginLeft: indent * 15 + "px" }}>
|
||||
<div className="nodeInner">
|
||||
{haveChildren ? (
|
||||
<button onClick={onClick}>{collapsed ? "+" : "-"}</button>
|
||||
) : (
|
||||
<button />
|
||||
)}
|
||||
{name}
|
||||
<div className="counts">
|
||||
<span className="due">{dueCount}</span>
|
||||
<br />
|
||||
<span className="new">{deck.newCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
{collapsed
|
||||
? ""
|
||||
: deck.children!.map(deck => (
|
||||
<DeckRow key={deck.deckId?.toString()} deck={deck} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
20
react/src/ui/index.css
Normal file
20
react/src/ui/index.css
Normal file
@ -0,0 +1,20 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
30
react/tsconfig.json
Normal file
30
react/tsconfig.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"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",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "typescript-plugin-css-modules"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue
Block a user