Switch to single-page vanilla React app, add PWA support
Story time Too much time was spent trying to get Next.js to build the app as a PWA. Maybe I've been trying to do this at an unlucky time when something somewhere in the dependency stack leading up to next-pwa is only temporarily broken. At this point enough hours have been spent thorough the project getting this or that and that to play nice with Next.js, and the benefits to this project were at best questionable. When next-pwa didn't deliver a PWA experience with the promised "zero configuration" setup, and even failed to do so with a lot of fiddling around and following several issue discussions in its repo I thought back at all the other nuisances stemming from trying to get Next.js to play nice in the app's context and decided it was time for it to go. Writing this frankenstein of a commit that forces the project from its Next.js structure into more-or-less the structure of a vanilla create-react-app project felt like a breeze compared to any more time spent reading github issues lingering unanswered or bumping into Stack Overflow threads from a time when none of the answers are right any longer. Some top answers in Stack Overflow don't even look like they were ever right. I don't like the big component switch that I wrote to replace the seemingly elegant app routing from Next.js. The new app routing I wrote is a kludge designed to minimize the amount of code fixes I'd need to do when it came to navigation, but I am glad to no longer parse numbers out of URLs, put dynamic() calls in nearly every page, use wrapper Link and Image components so Next.js can do some magic and a few other problems that right now I forget. I also appreciate the smaller bundle. This isn't some content publishing app, full of meaningful URLs to be shared over the net with other users. There's really only the skeleton of the app with a few very simple views to navigate to and then there's the local data generated by the user that the app just hosts locally.
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"extends": "next/core-web-vitals"
|
"extends": ["react-app", "react-app/jest"]
|
||||||
}
|
}
|
||||||
|
7
.gitignore
vendored
@ -8,10 +8,6 @@
|
|||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
@ -28,8 +24,5 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
@ -4,7 +4,6 @@ module.exports = {
|
|||||||
"@storybook/addon-links",
|
"@storybook/addon-links",
|
||||||
"@storybook/addon-essentials",
|
"@storybook/addon-essentials",
|
||||||
"@storybook/addon-interactions",
|
"@storybook/addon-interactions",
|
||||||
"storybook-addon-next-router",
|
|
||||||
],
|
],
|
||||||
framework: "@storybook/react",
|
framework: "@storybook/react",
|
||||||
core: {
|
core: {
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
import { RouterContext } from "next/dist/shared/lib/router-context"; // next 12
|
|
||||||
|
|
||||||
export const parameters = {
|
export const parameters = {
|
||||||
actions: { argTypesRegex: "^on[A-Z].*" },
|
actions: { argTypesRegex: "^on[A-Z].*" },
|
||||||
controls: {
|
controls: {
|
||||||
@ -8,12 +6,4 @@ export const parameters = {
|
|||||||
date: /Date$/,
|
date: /Date$/,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
nextRouter: {
|
|
||||||
Provider: RouterContext.Provider,
|
|
||||||
asPath: "/",
|
|
||||||
pathname: "/",
|
|
||||||
query: {},
|
|
||||||
route: "/",
|
|
||||||
push() {}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
5
next-env.d.ts
vendored
@ -1,5 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
@ -1,11 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
reactStrictMode: true,
|
|
||||||
experimental: {
|
|
||||||
images: {
|
|
||||||
unoptimized: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = nextConfig;
|
|
53
package.json
@ -3,11 +3,10 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky install",
|
"start": "react-scripts start",
|
||||||
"dev": "next dev",
|
"build": "react-scripts build",
|
||||||
"build": "next build",
|
"test": "react-scripts test",
|
||||||
"start": "next start",
|
"eject": "react-scripts eject",
|
||||||
"lint": "next lint",
|
|
||||||
"prettify": "prettier --write src/",
|
"prettify": "prettier --write src/",
|
||||||
"storybook": "start-storybook -p 6006",
|
"storybook": "start-storybook -p 6006",
|
||||||
"build-storybook": "build-storybook"
|
"build-storybook": "build-storybook"
|
||||||
@ -18,9 +17,19 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dexie": "^3.2.2",
|
"dexie": "^3.2.2",
|
||||||
"dexie-react-hooks": "^1.1.1",
|
"dexie-react-hooks": "^1.1.1",
|
||||||
"next": "12.2.0",
|
"react": "^18.2.0",
|
||||||
"react": "18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-dom": "18.2.0"
|
"workbox-background-sync": "^6.5.4",
|
||||||
|
"workbox-broadcast-update": "^6.5.4",
|
||||||
|
"workbox-cacheable-response": "^6.5.4",
|
||||||
|
"workbox-core": "^6.5.4",
|
||||||
|
"workbox-expiration": "^6.5.4",
|
||||||
|
"workbox-navigation-preload": "^6.5.4",
|
||||||
|
"workbox-precaching": "^6.5.4",
|
||||||
|
"workbox-range-requests": "^6.5.4",
|
||||||
|
"workbox-routing": "^6.5.4",
|
||||||
|
"workbox-strategies": "^6.5.4",
|
||||||
|
"workbox-streams": "^6.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.18.6",
|
"@babel/core": "^7.18.6",
|
||||||
@ -32,17 +41,25 @@
|
|||||||
"@storybook/manager-webpack5": "^6.5.9",
|
"@storybook/manager-webpack5": "^6.5.9",
|
||||||
"@storybook/react": "^6.5.9",
|
"@storybook/react": "^6.5.9",
|
||||||
"@storybook/testing-library": "^0.0.13",
|
"@storybook/testing-library": "^0.0.13",
|
||||||
"@types/node": "18.0.3",
|
"@testing-library/jest-dom": "^5.16.4",
|
||||||
"@types/react": "18.0.15",
|
"@testing-library/react": "^13.3.0",
|
||||||
"@types/react-dom": "18.0.6",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@types/react": "^18.0.15",
|
||||||
"babel-loader": "^8.2.5",
|
"babel-loader": "^8.2.5",
|
||||||
"eslint": "8.19.0",
|
|
||||||
"eslint-config-next": "12.2.0",
|
|
||||||
"eslint-plugin-storybook": "^0.5.13",
|
"eslint-plugin-storybook": "^0.5.13",
|
||||||
"husky": "^8.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"lint-staged": "^13.0.3",
|
"typescript": "^4.7.4"
|
||||||
"prettier": "^2.7.1",
|
},
|
||||||
"storybook-addon-next-router": "^4.0.0",
|
"browserslist": {
|
||||||
"typescript": "4.7.4"
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
public/favicon.ico
Executable file → Normal file
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 3.8 KiB |
43
public/index.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Web site created using create-react-app"
|
||||||
|
/>
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>React App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
BIN
public/logo192.png
Normal file
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"short_name": "React App",
|
||||||
|
"name": "Create React App Sample",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
3
public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
@ -1,4 +0,0 @@
|
|||||||
<svg width="283" height="64" viewBox="0 0 283 64" fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M141.04 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.46 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM248.72 16c-11.04 0-19 7.2-19 18s8.96 18 20 18c6.67 0 12.55-2.64 16.19-7.09l-7.65-4.42c-2.02 2.21-5.09 3.5-8.54 3.5-4.79 0-8.86-2.5-10.37-6.5h28.02c.22-1.12.35-2.28.35-3.5 0-10.79-7.96-17.99-19-17.99zm-9.45 14.5c1.25-3.99 4.67-6.5 9.45-6.5 4.79 0 8.21 2.51 9.45 6.5h-18.9zM200.24 34c0 6 3.92 10 10 10 4.12 0 7.21-1.87 8.8-4.92l7.68 4.43c-3.18 5.3-9.14 8.49-16.48 8.49-11.05 0-19-7.2-19-18s7.96-18 19-18c7.34 0 13.29 3.19 16.48 8.49l-7.68 4.43c-1.59-3.05-4.68-4.92-8.8-4.92-6.07 0-10 4-10 10zm82.48-29v46h-9V5h9zM36.95 0L73.9 64H0L36.95 0zm92.38 5l-27.71 48L73.91 5H84.3l17.32 30 17.32-30h10.39zm58.91 12v9.69c-1-.29-2.06-.49-3.2-.49-5.81 0-10 4-10 10V51h-9V17h9v9.2c0-5.08 5.91-9.2 13.2-9.2z" fill="#000"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.1 KiB |
9
src/App.test.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
test("renders learn react link", () => {
|
||||||
|
render(<App />);
|
||||||
|
const linkElement = screen.getByText(/learn react/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
|
});
|
22
src/App.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Navigation, Page, WithRouting } from "./components";
|
||||||
|
import "./styles/globals.css";
|
||||||
|
import "./styles/components.css";
|
||||||
|
import "./styles/wiktionary.css";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<WithRouting>
|
||||||
|
<header>
|
||||||
|
<Navigation />
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<Page />
|
||||||
|
</main>
|
||||||
|
</WithRouting>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
@ -1,7 +1,4 @@
|
|||||||
import Image from "next/image";
|
export interface ExpressionSetInfoProps {
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export interface ExpressionSetInfo {
|
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@ -13,29 +10,29 @@ export function ExpressionSetInfo({
|
|||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
expression_count,
|
expression_count,
|
||||||
}: ExpressionSetInfo) {
|
}: ExpressionSetInfoProps) {
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="content-row margin-small">
|
<h2 className="content-row margin-small">
|
||||||
<span className="text-title grow">{name}</span>
|
<span className="text-title grow">{name}</span>
|
||||||
<Link
|
{/*
|
||||||
href={{
|
<div
|
||||||
pathname: "expression-set/settings",
|
className="icon-button"
|
||||||
query: {
|
onClick={() =>
|
||||||
"set-id": id,
|
setRoute({
|
||||||
},
|
path: AppPath.ExpressionSetsSettings,
|
||||||
}}
|
options: { expression_set_id: id },
|
||||||
passHref
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<a className="icon-button">
|
<img
|
||||||
<Image
|
|
||||||
src="/icons/settings.svg"
|
src="/icons/settings.svg"
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
alt="settings"
|
alt="settings"
|
||||||
/>
|
/>
|
||||||
</a>
|
</div>
|
||||||
</Link>
|
*/}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="content-row text-meta margin-small">
|
<span className="content-row text-meta margin-small">
|
||||||
{expression_count} expression(s)
|
{expression_count} expression(s)
|
||||||
|
@ -5,6 +5,7 @@ import React from "react";
|
|||||||
import { ComponentStory, ComponentMeta } from "@storybook/react";
|
import { ComponentStory, ComponentMeta } from "@storybook/react";
|
||||||
|
|
||||||
import { Navigation } from "./Navigation";
|
import { Navigation } from "./Navigation";
|
||||||
|
import { WithRouting } from "../WithRouting";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Components/Navigation",
|
title: "Components/Navigation",
|
||||||
@ -12,5 +13,7 @@ export default {
|
|||||||
} as ComponentMeta<typeof Navigation>;
|
} as ComponentMeta<typeof Navigation>;
|
||||||
|
|
||||||
export const Example: ComponentStory<typeof Navigation> = (args) => (
|
export const Example: ComponentStory<typeof Navigation> = (args) => (
|
||||||
|
<WithRouting>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
</WithRouting>
|
||||||
);
|
);
|
||||||
|
@ -1,18 +1,23 @@
|
|||||||
|
import { AppPath } from "../../model/routing";
|
||||||
import { NavigationItem } from "./NavigationItem";
|
import { NavigationItem } from "./NavigationItem";
|
||||||
|
|
||||||
export function Navigation() {
|
export function Navigation() {
|
||||||
return (
|
return (
|
||||||
<nav className="navigation">
|
<nav className="navigation">
|
||||||
<NavigationItem text="home" iconUrl="/icons/home.svg" href="/" />
|
<NavigationItem
|
||||||
|
text="home"
|
||||||
|
iconUrl="/icons/home.svg"
|
||||||
|
page={AppPath.Home}
|
||||||
|
/>
|
||||||
<NavigationItem
|
<NavigationItem
|
||||||
text="practice"
|
text="practice"
|
||||||
iconUrl="/icons/calendar.svg"
|
iconUrl="/icons/calendar.svg"
|
||||||
href="/expression-sets"
|
page={AppPath.ExpressionSets}
|
||||||
/>
|
/>
|
||||||
<NavigationItem
|
<NavigationItem
|
||||||
text="settings"
|
text="settings"
|
||||||
iconUrl="/icons/check.svg"
|
iconUrl="/icons/check.svg"
|
||||||
href="/settings"
|
page={AppPath.Settings}
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
@ -5,6 +5,8 @@ import React from "react";
|
|||||||
import { ComponentStory, ComponentMeta } from "@storybook/react";
|
import { ComponentStory, ComponentMeta } from "@storybook/react";
|
||||||
|
|
||||||
import { NavigationItem } from "./NavigationItem";
|
import { NavigationItem } from "./NavigationItem";
|
||||||
|
import { AppPath } from "../../model/routing";
|
||||||
|
import { WithRouting } from "../WithRouting";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Components/Navigation Item",
|
title: "Components/Navigation Item",
|
||||||
@ -12,33 +14,35 @@ export default {
|
|||||||
} as ComponentMeta<typeof NavigationItem>;
|
} as ComponentMeta<typeof NavigationItem>;
|
||||||
|
|
||||||
const Template: ComponentStory<typeof NavigationItem> = (args) => (
|
const Template: ComponentStory<typeof NavigationItem> = (args) => (
|
||||||
|
<WithRouting>
|
||||||
<NavigationItem {...args} />
|
<NavigationItem {...args} />
|
||||||
|
</WithRouting>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const TextItem = Template.bind({});
|
export const TextItem = Template.bind({});
|
||||||
TextItem.story = {
|
TextItem.story = {
|
||||||
args: {
|
args: {
|
||||||
text: "Home",
|
text: "Home",
|
||||||
href: "/",
|
page: AppPath.Home,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IconItem = Template.bind({});
|
export const IconItem = Template.bind({});
|
||||||
IconItem.args = {
|
IconItem.args = {
|
||||||
iconUrl: "/icons/home.svg",
|
iconUrl: "/icons/home.svg",
|
||||||
href: "/",
|
page: AppPath.Home,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TextAndIconItem = Template.bind({});
|
export const TextAndIconItem = Template.bind({});
|
||||||
TextAndIconItem.args = {
|
TextAndIconItem.args = {
|
||||||
text: "Home",
|
text: "Home",
|
||||||
iconUrl: "/icons/home.svg",
|
iconUrl: "/icons/home.svg",
|
||||||
href: "/",
|
page: AppPath.Home,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const InactiveItem = Template.bind({});
|
export const InactiveItem = Template.bind({});
|
||||||
InactiveItem.args = {
|
InactiveItem.args = {
|
||||||
text: "Inactive",
|
text: "Inactive",
|
||||||
iconUrl: "/icons/home.svg",
|
iconUrl: "/icons/home.svg",
|
||||||
href: "current-path",
|
page: AppPath.Settings,
|
||||||
};
|
};
|
||||||
|
@ -1,28 +1,25 @@
|
|||||||
import Image from "next/image";
|
import { useContext } from "react";
|
||||||
import Link from "next/link";
|
import { AppPath, AppRouting } from "../../model/routing";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
export interface NavigationItemProps {
|
export interface NavigationItemProps {
|
||||||
text: string;
|
text: string;
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
href: string;
|
page: AppPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavigationItem({ text, iconUrl, href }: NavigationItemProps) {
|
export function NavigationItem({ page, text, iconUrl }: NavigationItemProps) {
|
||||||
const router = useRouter();
|
const { route, setRoute } = useContext(AppRouting);
|
||||||
|
|
||||||
let active = false;
|
let active = false;
|
||||||
if (href === "/" && router.pathname === "/") active = true;
|
if (page === AppPath.Home && route.path === AppPath.Home) active = true;
|
||||||
if (href !== "/" && router.pathname.startsWith(href)) active = true;
|
if (page !== AppPath.Home && route.path.startsWith(page)) active = true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="navigation-li">
|
<li className="navigation-li" onClick={() => setRoute({ path: page })}>
|
||||||
<Link href={href} passHref>
|
<div className={active ? "navigation-item active" : "navigation-item"}>
|
||||||
<a className={active ? "navigation-item active" : "navigation-item"}>
|
{iconUrl && <img src={iconUrl} width="24" height="24" alt="" />}
|
||||||
{iconUrl && <Image src={iconUrl} width="24" height="24" alt="" />}
|
|
||||||
<span className="text-navigation">{text}</span>
|
<span className="text-navigation">{text}</span>
|
||||||
</a>
|
</div>
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,23 +0,0 @@
|
|||||||
import "../../styles/globals.css";
|
|
||||||
import "../../styles/components.css";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { ComponentStory, ComponentMeta } from "@storybook/react";
|
|
||||||
|
|
||||||
import { Page } from "./Page";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: "Components/Page",
|
|
||||||
component: Page,
|
|
||||||
} as ComponentMeta<typeof Page>;
|
|
||||||
|
|
||||||
export const Example: ComponentStory<typeof Page> = (args) => (
|
|
||||||
<Page title="Some title">
|
|
||||||
<div style={{ display: "flex", height: "100%" }}>
|
|
||||||
<div style={{ margin: "auto" }}>Content goes here</div>
|
|
||||||
</div>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
Example.parameters = {
|
|
||||||
layout: "fullscreen",
|
|
||||||
};
|
|
@ -1,17 +1,27 @@
|
|||||||
import Head from "next/head";
|
import { useContext } from "react";
|
||||||
import { PropsWithChildren } from "react";
|
import { AppPath, AppRouting } from "../../model/routing";
|
||||||
|
import {
|
||||||
|
ExpressionPracticeView,
|
||||||
|
ExpressionSetDetailsView,
|
||||||
|
ExpressionSetListView,
|
||||||
|
} from "../../views";
|
||||||
|
import { AddExpressionView } from "../../views/AddExpressionView";
|
||||||
|
|
||||||
export interface PageProps {
|
export function Page() {
|
||||||
title: string;
|
const { route } = useContext(AppRouting);
|
||||||
}
|
console.log("Route:", JSON.stringify(route));
|
||||||
|
|
||||||
export function Page({ title, children }: PropsWithChildren<PageProps>) {
|
switch (route.path) {
|
||||||
return (
|
case AppPath.Home:
|
||||||
<>
|
return <p>TODO home</p>;
|
||||||
<Head>
|
case AppPath.ExpressionSets:
|
||||||
<title>{title}</title>
|
return <ExpressionSetListView />;
|
||||||
</Head>
|
case AppPath.ExpressionSetsDetails:
|
||||||
{children}
|
return <ExpressionSetDetailsView />;
|
||||||
</>
|
case AppPath.ExpressionSetsPractice:
|
||||||
);
|
return <ExpressionPracticeView />;
|
||||||
|
case AppPath.Settings:
|
||||||
|
// TODO this should split onto more views
|
||||||
|
return <AddExpressionView />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
18
src/components/WithRouting/WithRouting.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { PropsWithChildren, useState } from "react";
|
||||||
|
import { AppPath, AppRoute, AppRouting } from "../../model/routing";
|
||||||
|
|
||||||
|
interface WithRoutingProps {
|
||||||
|
path?: AppPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WithRouting({
|
||||||
|
children,
|
||||||
|
path,
|
||||||
|
}: PropsWithChildren<WithRoutingProps>) {
|
||||||
|
const [route, setRoute] = useState<AppRoute>({ path: path || AppPath.Home });
|
||||||
|
return (
|
||||||
|
<AppRouting.Provider value={{ route, setRoute }}>
|
||||||
|
{children}
|
||||||
|
</AppRouting.Provider>
|
||||||
|
);
|
||||||
|
}
|
1
src/components/WithRouting/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./WithRouting";
|
@ -3,3 +3,4 @@ export * from "./ExpressionSetCard";
|
|||||||
export * from "./ExpressionSetInfo";
|
export * from "./ExpressionSetInfo";
|
||||||
export * from "./Navigation";
|
export * from "./Navigation";
|
||||||
export * from "./Page";
|
export * from "./Page";
|
||||||
|
export * from "./WithRouting";
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useContext } from "react";
|
||||||
|
import { AppRouting } from "../model/routing";
|
||||||
|
|
||||||
export function useExpressionFilterQueryIds(): number[] {
|
export function useExpressionFilterQueryIds(): number[] {
|
||||||
const { query } = useRouter();
|
const { route } = useContext(AppRouting);
|
||||||
const filter_ids = (query["filter-ids"] || "") as string;
|
return route.options?.expression_id_filters || [];
|
||||||
return filter_ids.split(" ").map((id) => Number.parseInt(id));
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useContext } from "react";
|
||||||
|
import { AppRouting } from "../model/routing";
|
||||||
|
|
||||||
export function useExpressionSetQueryId(): number {
|
export function useExpressionSetQueryId(): number {
|
||||||
const { query } = useRouter();
|
const { route } = useContext(AppRouting);
|
||||||
return Number.parseInt(query["set-id"] as string) || 0;
|
return route.options?.expression_set_id || 0;
|
||||||
}
|
}
|
||||||
|
13
src/index.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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;
|
||||||
|
}
|
25
src/index.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import "./index.css";
|
||||||
|
import App from "./App";
|
||||||
|
import * as serviceWorkerRegistration from "./serviceWorkerRegistration";
|
||||||
|
// import reportWebVitals from './reportWebVitals';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(
|
||||||
|
document.getElementById("root") as HTMLElement
|
||||||
|
);
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
||||||
|
// If you want your app to work offline and load faster, you can change
|
||||||
|
// unregister() to register() below. Note this comes with some pitfalls.
|
||||||
|
// Learn more about service workers: https://cra.link/PWA
|
||||||
|
serviceWorkerRegistration.register();
|
||||||
|
|
||||||
|
// If you want to start measuring performance in your app, pass a function
|
||||||
|
// to log results (for example: reportWebVitals(console.log))
|
||||||
|
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||||
|
// reportWebVitals();
|
7
src/logo.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||||
|
<g fill="#61DAFB">
|
||||||
|
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||||
|
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||||
|
<path d="M520.5 78.1z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
@ -8,7 +8,6 @@ export function parseWiktionaryData(
|
|||||||
if (typeof window === "undefined") return null;
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
const parser = new DOMParser();
|
const parser = new DOMParser();
|
||||||
console.log("Parsing", data);
|
|
||||||
const document = parser.parseFromString(data, "text/html");
|
const document = parser.parseFromString(data, "text/html");
|
||||||
|
|
||||||
// TODO settings-based language selection
|
// TODO settings-based language selection
|
||||||
|
30
src/model/routing.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export enum AppPath {
|
||||||
|
Home = "/",
|
||||||
|
ExpressionSets = "expression-sets",
|
||||||
|
// ExpressionSetsSettings = "expression-sets/settings",
|
||||||
|
ExpressionSetsPractice = "expression-sets/practice",
|
||||||
|
ExpressionSetsDetails = "expression-sets/details",
|
||||||
|
Settings = "settings",
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteOptions {
|
||||||
|
expression_set_id?: number;
|
||||||
|
expression_id_filters?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppRoute {
|
||||||
|
path: AppPath;
|
||||||
|
options?: RouteOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppRoutingType {
|
||||||
|
route: AppRoute;
|
||||||
|
setRoute(value: AppRoute): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppRouting = createContext<AppRoutingType>({
|
||||||
|
route: { path: AppPath.Home },
|
||||||
|
setRoute(_value: AppRoute) {},
|
||||||
|
});
|
@ -1,10 +0,0 @@
|
|||||||
import type { NextPage } from "next";
|
|
||||||
import { ErrorView } from "../views";
|
|
||||||
|
|
||||||
const PageTitle = "Flash Card App - 404";
|
|
||||||
|
|
||||||
const Page404: NextPage = () => {
|
|
||||||
return <ErrorView title={PageTitle} message="404 - content not found" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page404;
|
|
@ -1,18 +0,0 @@
|
|||||||
import "../styles/globals.css";
|
|
||||||
import "../styles/components.css";
|
|
||||||
import "../styles/wiktionary.css";
|
|
||||||
import type { AppProps } from "next/app";
|
|
||||||
import { Navigation } from "../components";
|
|
||||||
|
|
||||||
export default function FlashCardApp({ Component, pageProps }: AppProps) {
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<header>
|
|
||||||
<Navigation />
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<Component {...pageProps} />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
|
||||||
|
|
||||||
type Data = {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function handler(
|
|
||||||
req: NextApiRequest,
|
|
||||||
res: NextApiResponse<Data>
|
|
||||||
) {
|
|
||||||
res.status(200).json({ name: "John Doe" });
|
|
||||||
}
|
|
@ -1,40 +0,0 @@
|
|||||||
import type { NextPage } from "next";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ExpressionSetInfo, Page } from "../../components";
|
|
||||||
import {
|
|
||||||
useExpressionSet,
|
|
||||||
useExpressionSetQueryId,
|
|
||||||
useExpressionsInSet,
|
|
||||||
} from "../../hooks";
|
|
||||||
import { ErrorView, ExpressionSetDetailsView } from "../../views";
|
|
||||||
|
|
||||||
function pageTitle(name?: string) {
|
|
||||||
return `Flash Card App - ${name || "Sets"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ExpressionSetDetailsPage: NextPage = () => {
|
|
||||||
const expression_set_id = useExpressionSetQueryId();
|
|
||||||
const expression_set = useExpressionSet(expression_set_id);
|
|
||||||
const expressions = useExpressionsInSet(expression_set_id);
|
|
||||||
|
|
||||||
// Fallback for expression set not found
|
|
||||||
if (!expressions) return null;
|
|
||||||
if (!expression_set) {
|
|
||||||
return <ErrorView title={pageTitle()} message="Expression set not found" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for expression set empty
|
|
||||||
return (
|
|
||||||
<Page title={pageTitle(expression_set.name)}>
|
|
||||||
<ExpressionSetDetailsView
|
|
||||||
expression_set={expression_set}
|
|
||||||
expression_count={expressions.length}
|
|
||||||
/>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default dynamic(() => Promise.resolve(ExpressionSetDetailsPage), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
@ -1,25 +0,0 @@
|
|||||||
import type { NextPage } from "next";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import { Page } from "../../components";
|
|
||||||
import { useExpressionSets } from "../../hooks";
|
|
||||||
import { ErrorView, ExpressionSetListView } from "../../views";
|
|
||||||
|
|
||||||
const PageTitle = "Flash Card App - Sets";
|
|
||||||
|
|
||||||
const ExpressionSetListPage: NextPage = () => {
|
|
||||||
const expression_sets = useExpressionSets();
|
|
||||||
if (!expression_sets) return null;
|
|
||||||
if (!expression_sets.length) {
|
|
||||||
return <ErrorView title={PageTitle} message="No expression sets found" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page title={PageTitle}>
|
|
||||||
<ExpressionSetListView expression_sets={expression_sets} />
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default dynamic(() => Promise.resolve(ExpressionSetListPage), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
@ -1,45 +0,0 @@
|
|||||||
import { NextPage } from "next";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import { Page } from "../../components";
|
|
||||||
import {
|
|
||||||
useExpressionFilterQueryIds,
|
|
||||||
useExpressionSetQueryId,
|
|
||||||
useExpressionsInSet,
|
|
||||||
} from "../../hooks";
|
|
||||||
import { ErrorView } from "../../views";
|
|
||||||
import { ExpressionPracticeView } from "../../views";
|
|
||||||
|
|
||||||
const PageTitle = "Flash Card App - Practice";
|
|
||||||
|
|
||||||
const ExpressionPracticePage: NextPage = () => {
|
|
||||||
const expression_set_id = useExpressionSetQueryId();
|
|
||||||
const filter_ids = useExpressionFilterQueryIds();
|
|
||||||
const expressions = useExpressionsInSet(expression_set_id);
|
|
||||||
|
|
||||||
// Fallback rendering for content not yet fetched
|
|
||||||
if (!expressions) return null;
|
|
||||||
if (!filter_ids) return null;
|
|
||||||
|
|
||||||
// Fallback views for expression set content not found
|
|
||||||
if (!expression_set_id) {
|
|
||||||
return <ErrorView title={PageTitle} message="Expression set not found" />;
|
|
||||||
}
|
|
||||||
const filtered_expressions = expressions.filter(
|
|
||||||
(expression) => !filter_ids.includes(expression.id!)
|
|
||||||
);
|
|
||||||
if (!filtered_expressions.length) {
|
|
||||||
return (
|
|
||||||
<ErrorView title={PageTitle} message="No expressions left in this set" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page title={PageTitle}>
|
|
||||||
<ExpressionPracticeView select_from_expressions={filtered_expressions} />
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default dynamic(() => Promise.resolve(ExpressionPracticePage), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
@ -1,10 +0,0 @@
|
|||||||
import type { NextPage } from "next";
|
|
||||||
import { Page } from "../components";
|
|
||||||
|
|
||||||
const PageTitle = "Flash Card App";
|
|
||||||
|
|
||||||
const Home: NextPage = () => {
|
|
||||||
return <Page title={PageTitle} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Home;
|
|
@ -1,18 +0,0 @@
|
|||||||
import type { NextPage } from "next";
|
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import { Page } from "../../components";
|
|
||||||
import { AddExpressionView } from "../../views/AddExpressionView";
|
|
||||||
|
|
||||||
const PageTitle = "Settings - Add expression";
|
|
||||||
|
|
||||||
const AddExpressionPage: NextPage = () => {
|
|
||||||
return (
|
|
||||||
<Page title={PageTitle}>
|
|
||||||
<AddExpressionView />
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default dynamic(() => Promise.resolve(AddExpressionPage), {
|
|
||||||
ssr: false,
|
|
||||||
});
|
|
@ -1,3 +0,0 @@
|
|||||||
import AddExpressionPage from "./add-expression";
|
|
||||||
|
|
||||||
export default AddExpressionPage;
|
|
1
src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="react-scripts" />
|
89
src/service-worker.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
/* eslint-disable no-restricted-globals */
|
||||||
|
|
||||||
|
// This service worker can be customized!
|
||||||
|
// See https://developers.google.com/web/tools/workbox/modules
|
||||||
|
// for the list of available Workbox modules, or add any other
|
||||||
|
// code you'd like.
|
||||||
|
// You can also remove this file if you'd prefer not to use a
|
||||||
|
// service worker, and the Workbox build step will be skipped.
|
||||||
|
|
||||||
|
import { clientsClaim } from "workbox-core";
|
||||||
|
import { ExpirationPlugin } from "workbox-expiration";
|
||||||
|
import { precacheAndRoute, createHandlerBoundToURL } from "workbox-precaching";
|
||||||
|
import { registerRoute } from "workbox-routing";
|
||||||
|
import { StaleWhileRevalidate } from "workbox-strategies";
|
||||||
|
|
||||||
|
declare const self: ServiceWorkerGlobalScope;
|
||||||
|
|
||||||
|
clientsClaim();
|
||||||
|
|
||||||
|
// Precache all of the assets generated by your build process.
|
||||||
|
// Their URLs are injected into the manifest variable below.
|
||||||
|
// This variable must be present somewhere in your service worker file,
|
||||||
|
// even if you decide not to use precaching. See https://cra.link/PWA
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST);
|
||||||
|
|
||||||
|
// Set up App Shell-style routing, so that all navigation requests
|
||||||
|
// are fulfilled with your index.html shell. Learn more at
|
||||||
|
// https://developers.google.com/web/fundamentals/architecture/app-shell
|
||||||
|
const fileExtensionRegexp = new RegExp("/[^/?]+\\.[^/]+$");
|
||||||
|
registerRoute(
|
||||||
|
// Return false to exempt requests from being fulfilled by index.html.
|
||||||
|
({ request, url }: { request: Request; url: URL }) => {
|
||||||
|
// If this isn't a navigation, skip.
|
||||||
|
if (request.mode !== "navigate") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is a URL that starts with /_, skip.
|
||||||
|
if (url.pathname.startsWith("/_")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this looks like a URL for a resource, because it contains
|
||||||
|
// a file extension, skip.
|
||||||
|
if (url.pathname.match(fileExtensionRegexp)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return true to signal that we want to use the handler.
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
createHandlerBoundToURL(process.env.PUBLIC_URL + "/index.html")
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageExtensions = [".png", ".svg"];
|
||||||
|
|
||||||
|
function urlIsImage(url: URL) {
|
||||||
|
for (const extension of imageExtensions) {
|
||||||
|
if (url.pathname.endsWith(extension)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An example runtime caching route for requests that aren't handled by the
|
||||||
|
// precache, in this case same-origin .png requests like those from in public/
|
||||||
|
registerRoute(
|
||||||
|
// Add in any other file extensions or routing criteria as needed.
|
||||||
|
({ url }) => url.origin === self.location.origin && urlIsImage(url),
|
||||||
|
// Customize this strategy as needed, e.g., by changing to CacheFirst.
|
||||||
|
new StaleWhileRevalidate({
|
||||||
|
cacheName: "images",
|
||||||
|
plugins: [
|
||||||
|
// Ensure that once this runtime cache reaches a maximum size the
|
||||||
|
// least-recently used images are removed.
|
||||||
|
new ExpirationPlugin({ maxEntries: 50 }),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// This allows the web app to trigger skipWaiting via
|
||||||
|
// registration.waiting.postMessage({type: 'SKIP_WAITING'})
|
||||||
|
self.addEventListener("message", (event) => {
|
||||||
|
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any other custom service worker logic can go here.
|
147
src/serviceWorkerRegistration.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
// 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://cra.link/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}$/)
|
||||||
|
*/
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
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://cra.link/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://cra.link/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();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
5
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";
|
@ -1,41 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
|
||||||
|
|
||||||
import { Button } from './Button';
|
|
||||||
|
|
||||||
// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
|
|
||||||
export default {
|
|
||||||
title: 'Example/Button',
|
|
||||||
component: Button,
|
|
||||||
// More on argTypes: https://storybook.js.org/docs/react/api/argtypes
|
|
||||||
argTypes: {
|
|
||||||
backgroundColor: { control: 'color' },
|
|
||||||
},
|
|
||||||
} as ComponentMeta<typeof Button>;
|
|
||||||
|
|
||||||
// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
|
|
||||||
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;
|
|
||||||
|
|
||||||
export const Primary = Template.bind({});
|
|
||||||
// More on args: https://storybook.js.org/docs/react/writing-stories/args
|
|
||||||
Primary.args = {
|
|
||||||
primary: true,
|
|
||||||
label: 'Button',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Secondary = Template.bind({});
|
|
||||||
Secondary.args = {
|
|
||||||
label: 'Button',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Large = Template.bind({});
|
|
||||||
Large.args = {
|
|
||||||
size: 'large',
|
|
||||||
label: 'Button',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Small = Template.bind({});
|
|
||||||
Small.args = {
|
|
||||||
size: 'small',
|
|
||||||
label: 'Button',
|
|
||||||
};
|
|
@ -1,48 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import './button.css';
|
|
||||||
|
|
||||||
interface ButtonProps {
|
|
||||||
/**
|
|
||||||
* Is this the principal call to action on the page?
|
|
||||||
*/
|
|
||||||
primary?: boolean;
|
|
||||||
/**
|
|
||||||
* What background color to use
|
|
||||||
*/
|
|
||||||
backgroundColor?: string;
|
|
||||||
/**
|
|
||||||
* How large should the button be?
|
|
||||||
*/
|
|
||||||
size?: 'small' | 'medium' | 'large';
|
|
||||||
/**
|
|
||||||
* Button contents
|
|
||||||
*/
|
|
||||||
label: string;
|
|
||||||
/**
|
|
||||||
* Optional click handler
|
|
||||||
*/
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Primary UI component for user interaction
|
|
||||||
*/
|
|
||||||
export const Button = ({
|
|
||||||
primary = false,
|
|
||||||
size = 'medium',
|
|
||||||
backgroundColor,
|
|
||||||
label,
|
|
||||||
...props
|
|
||||||
}: ButtonProps) => {
|
|
||||||
const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
|
|
||||||
style={{ backgroundColor }}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,25 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
|
||||||
|
|
||||||
import { Header } from './Header';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: 'Example/Header',
|
|
||||||
component: Header,
|
|
||||||
parameters: {
|
|
||||||
// More on Story layout: https://storybook.js.org/docs/react/configure/story-layout
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
} as ComponentMeta<typeof Header>;
|
|
||||||
|
|
||||||
const Template: ComponentStory<typeof Header> = (args) => <Header {...args} />;
|
|
||||||
|
|
||||||
export const LoggedIn = Template.bind({});
|
|
||||||
LoggedIn.args = {
|
|
||||||
user: {
|
|
||||||
name: 'Jane Doe',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LoggedOut = Template.bind({});
|
|
||||||
LoggedOut.args = {};
|
|
@ -1,56 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Button } from './Button';
|
|
||||||
import './header.css';
|
|
||||||
|
|
||||||
type User = {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface HeaderProps {
|
|
||||||
user?: User;
|
|
||||||
onLogin: () => void;
|
|
||||||
onLogout: () => void;
|
|
||||||
onCreateAccount: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => (
|
|
||||||
<header>
|
|
||||||
<div className="wrapper">
|
|
||||||
<div>
|
|
||||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g fill="none" fillRule="evenodd">
|
|
||||||
<path
|
|
||||||
d="M10 0h12a10 10 0 0110 10v12a10 10 0 01-10 10H10A10 10 0 010 22V10A10 10 0 0110 0z"
|
|
||||||
fill="#FFF"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M5.3 10.6l10.4 6v11.1l-10.4-6v-11zm11.4-6.2l9.7 5.5-9.7 5.6V4.4z"
|
|
||||||
fill="#555AB9"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M27.2 10.6v11.2l-10.5 6V16.5l10.5-6zM15.7 4.4v11L6 10l9.7-5.5z"
|
|
||||||
fill="#91BAF8"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
<h1>Acme</h1>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{user ? (
|
|
||||||
<>
|
|
||||||
<span className="welcome">
|
|
||||||
Welcome, <b>{user.name}</b>!
|
|
||||||
</span>
|
|
||||||
<Button size="small" onClick={onLogout} label="Log out" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button size="small" onClick={onLogin} label="Log in" />
|
|
||||||
<Button primary size="small" onClick={onCreateAccount} label="Sign up" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
@ -1,211 +0,0 @@
|
|||||||
import { Meta } from '@storybook/addon-docs';
|
|
||||||
import Code from './assets/code-brackets.svg';
|
|
||||||
import Colors from './assets/colors.svg';
|
|
||||||
import Comments from './assets/comments.svg';
|
|
||||||
import Direction from './assets/direction.svg';
|
|
||||||
import Flow from './assets/flow.svg';
|
|
||||||
import Plugin from './assets/plugin.svg';
|
|
||||||
import Repo from './assets/repo.svg';
|
|
||||||
import StackAlt from './assets/stackalt.svg';
|
|
||||||
|
|
||||||
<Meta title="Example/Introduction" />
|
|
||||||
|
|
||||||
<style>
|
|
||||||
{`
|
|
||||||
.subheading {
|
|
||||||
--mediumdark: '#999999';
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #999;
|
|
||||||
letter-spacing: 6px;
|
|
||||||
line-height: 24px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: 1fr 1fr;
|
|
||||||
row-gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 620px) {
|
|
||||||
.link-list {
|
|
||||||
row-gap: 20px;
|
|
||||||
column-gap: 20px;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media all and (-ms-high-contrast:none) {
|
|
||||||
.link-list {
|
|
||||||
display: -ms-grid;
|
|
||||||
-ms-grid-columns: 1fr 1fr;
|
|
||||||
-ms-grid-rows: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item {
|
|
||||||
display: block;
|
|
||||||
padding: 20px 30px 20px 15px;
|
|
||||||
border: 1px solid #00000010;
|
|
||||||
border-radius: 5px;
|
|
||||||
transition: background 150ms ease-out, border 150ms ease-out, transform 150ms ease-out;
|
|
||||||
color: #333333;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item:hover {
|
|
||||||
border-color: #1EA7FD50;
|
|
||||||
transform: translate3d(0, -3px, 0);
|
|
||||||
box-shadow: rgba(0, 0, 0, 0.08) 0 3px 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item:active {
|
|
||||||
border-color: #1EA7FD;
|
|
||||||
transform: translate3d(0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item strong {
|
|
||||||
font-weight: 700;
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item img {
|
|
||||||
height: 40px;
|
|
||||||
width: 40px;
|
|
||||||
margin-right: 15px;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-item span {
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip {
|
|
||||||
display: inline-block;
|
|
||||||
border-radius: 1em;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: #E7FDD8;
|
|
||||||
color: #66BF3C;
|
|
||||||
padding: 4px 12px;
|
|
||||||
margin-right: 10px;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-wrapper {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 20px;
|
|
||||||
margin-top: 40px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-wrapper code {
|
|
||||||
font-size: 12px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
# Welcome to Storybook
|
|
||||||
|
|
||||||
Storybook helps you build UI components in isolation from your app's business logic, data, and context.
|
|
||||||
That makes it easy to develop hard-to-reach states. Save these UI states as **stories** to revisit during development, testing, or QA.
|
|
||||||
|
|
||||||
Browse example stories now by navigating to them in the sidebar.
|
|
||||||
View their code in the `stories` directory to learn how they work.
|
|
||||||
We recommend building UIs with a [**component-driven**](https://componentdriven.org) process starting with atomic components and ending with pages.
|
|
||||||
|
|
||||||
<div className="subheading">Configure</div>
|
|
||||||
|
|
||||||
<div className="link-list">
|
|
||||||
<a
|
|
||||||
className="link-item"
|
|
||||||
href="https://storybook.js.org/docs/react/addons/addon-types"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<img src={Plugin} alt="plugin" />
|
|
||||||
<span>
|
|
||||||
<strong>Presets for popular tools</strong>
|
|
||||||
Easy setup for TypeScript, SCSS and more.
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="link-item"
|
|
||||||
href="https://storybook.js.org/docs/react/configure/webpack"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<img src={StackAlt} alt="Build" />
|
|
||||||
<span>
|
|
||||||
<strong>Build configuration</strong>
|
|
||||||
How to customize webpack and Babel
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="link-item"
|
|
||||||
href="https://storybook.js.org/docs/react/configure/styling-and-css"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<img src={Colors} alt="colors" />
|
|
||||||
<span>
|
|
||||||
<strong>Styling</strong>
|
|
||||||
How to load and configure CSS libraries
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="link-item"
|
|
||||||
href="https://storybook.js.org/docs/react/get-started/setup#configure-storybook-for-your-stack"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<img src={Flow} alt="flow" />
|
|
||||||
<span>
|
|
||||||
<strong>Data</strong>
|
|
||||||
Providers and mocking for data libraries
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="subheading">Learn</div>
|
|
||||||
|
|
||||||
<div className="link-list">
|
|
||||||
<a className="link-item" href="https://storybook.js.org/docs" target="_blank">
|
|
||||||
<img src={Repo} alt="repo" />
|
|
||||||
<span>
|
|
||||||
<strong>Storybook documentation</strong>
|
|
||||||
Configure, customize, and extend
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a className="link-item" href="https://storybook.js.org/tutorials/" target="_blank">
|
|
||||||
<img src={Direction} alt="direction" />
|
|
||||||
<span>
|
|
||||||
<strong>In-depth guides</strong>
|
|
||||||
Best practices from leading teams
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a className="link-item" href="https://github.com/storybookjs/storybook" target="_blank">
|
|
||||||
<img src={Code} alt="code" />
|
|
||||||
<span>
|
|
||||||
<strong>GitHub project</strong>
|
|
||||||
View the source and add issues
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<a className="link-item" href="https://discord.gg/storybook" target="_blank">
|
|
||||||
<img src={Comments} alt="comments" />
|
|
||||||
<span>
|
|
||||||
<strong>Discord chat</strong>
|
|
||||||
Chat with maintainers and the community
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tip-wrapper">
|
|
||||||
<span className="tip">Tip</span>Edit the Markdown in{' '}
|
|
||||||
<code>stories/Introduction.stories.mdx</code>
|
|
||||||
</div>
|
|
@ -1,26 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
|
||||||
import { within, userEvent } from '@storybook/testing-library';
|
|
||||||
import { Page } from './Page';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
title: 'Example/Page',
|
|
||||||
component: Page,
|
|
||||||
parameters: {
|
|
||||||
// More on Story layout: https://storybook.js.org/docs/react/configure/story-layout
|
|
||||||
layout: 'fullscreen',
|
|
||||||
},
|
|
||||||
} as ComponentMeta<typeof Page>;
|
|
||||||
|
|
||||||
const Template: ComponentStory<typeof Page> = (args) => <Page {...args} />;
|
|
||||||
|
|
||||||
export const LoggedOut = Template.bind({});
|
|
||||||
|
|
||||||
export const LoggedIn = Template.bind({});
|
|
||||||
|
|
||||||
// More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing
|
|
||||||
LoggedIn.play = async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
const loginButton = await canvas.getByRole('button', { name: /Log in/i });
|
|
||||||
await userEvent.click(loginButton);
|
|
||||||
};
|
|
@ -1,73 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Header } from './Header';
|
|
||||||
import './page.css';
|
|
||||||
|
|
||||||
type User = {
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Page: React.VFC = () => {
|
|
||||||
const [user, setUser] = React.useState<User>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article>
|
|
||||||
<Header
|
|
||||||
user={user}
|
|
||||||
onLogin={() => setUser({ name: 'Jane Doe' })}
|
|
||||||
onLogout={() => setUser(undefined)}
|
|
||||||
onCreateAccount={() => setUser({ name: 'Jane Doe' })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Pages in Storybook</h2>
|
|
||||||
<p>
|
|
||||||
We recommend building UIs with a{' '}
|
|
||||||
<a href="https://componentdriven.org" target="_blank" rel="noopener noreferrer">
|
|
||||||
<strong>component-driven</strong>
|
|
||||||
</a>{' '}
|
|
||||||
process starting with atomic components and ending with pages.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Render pages with mock data. This makes it easy to build and review page states without
|
|
||||||
needing to navigate to them in your app. Here are some handy patterns for managing page
|
|
||||||
data in Storybook:
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
Use a higher-level connected component. Storybook helps you compose such data from the
|
|
||||||
"args" of child component stories
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Assemble data in the page component from your services. You can mock these services out
|
|
||||||
using Storybook.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p>
|
|
||||||
Get a guided tutorial on component-driven development at{' '}
|
|
||||||
<a href="https://storybook.js.org/tutorials/" target="_blank" rel="noopener noreferrer">
|
|
||||||
Storybook tutorials
|
|
||||||
</a>
|
|
||||||
. Read more in the{' '}
|
|
||||||
<a href="https://storybook.js.org/docs" target="_blank" rel="noopener noreferrer">
|
|
||||||
docs
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
<div className="tip-wrapper">
|
|
||||||
<span className="tip">Tip</span> Adjust the width of the canvas with the{' '}
|
|
||||||
<svg width="10" height="10" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<g fill="none" fillRule="evenodd">
|
|
||||||
<path
|
|
||||||
d="M1.5 5.2h4.8c.3 0 .5.2.5.4v5.1c-.1.2-.3.3-.4.3H1.4a.5.5 0 01-.5-.4V5.7c0-.3.2-.5.5-.5zm0-2.1h6.9c.3 0 .5.2.5.4v7a.5.5 0 01-1 0V4H1.5a.5.5 0 010-1zm0-2.1h9c.3 0 .5.2.5.4v9.1a.5.5 0 01-1 0V2H1.5a.5.5 0 010-1zm4.3 5.2H2V10h3.8V6.2z"
|
|
||||||
id="a"
|
|
||||||
fill="#999"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
Viewports addon in the toolbar
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/code-brackets</title><g id="illustration/code-brackets" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Combined-Shape" fill="#87E6E5" d="M11.4139325,12 C11.7605938,12 12,12.5059743 12,13.3779712 L12,17.4951758 L6.43502246,23.3839989 C5.85499251,23.9978337 5.85499251,25.0021663 6.43502246,25.6160011 L12,31.5048242 L12,35.6220288 C12,36.4939606 11.7605228,37 11.4139325,37 C11.2725831,37 11.1134406,36.9158987 10.9453839,36.7379973 L0.435022463,25.6160011 C-0.145007488,25.0021663 -0.145007488,23.9978337 0.435022463,23.3839989 L10.9453839,12.2620027 C11.1134051,12.0841663 11.2725831,12 11.4139325,12 Z M36.5860675,12 C36.7274169,12 36.8865594,12.0841013 37.0546161,12.2620027 L47.5649775,23.3839989 C48.1450075,23.9978337 48.1450075,25.0021663 47.5649775,25.6160011 L37.0546161,36.7379973 C36.8865949,36.9158337 36.7274169,37 36.5860675,37 C36.2394062,37 36,36.4940257 36,35.6220288 L36,31.5048242 L41.5649775,25.6160011 C42.1450075,25.0021663 42.1450075,23.9978337 41.5649775,23.3839989 L36,17.4951758 L36,13.3779712 C36,12.5060394 36.2394772,12 36.5860675,12 Z"/><rect id="Rectangle-7-Copy-5" width="35.57" height="4" x="5.009" y="22.662" fill="#A0DB77" rx="2" transform="translate(22.793959, 24.662305) rotate(-75.000000) translate(-22.793959, -24.662305)"/></g></svg>
|
|
Before Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 8.3 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/comments</title><g id="illustration/comments" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Path" fill="#96D07C" d="M2.52730803,17.9196415 C2.44329744,17.9745167 2.36370847,18.000488 2.29303375,18.000488 C2.1197031,18.000488 2,17.8443588 2,17.5752855 L2,4 C2,1.790861 3.790861,3.23296945e-13 6,3.23296945e-13 L33.9995117,3.23296945e-13 C36.2086507,3.23296945e-13 37.9995117,1.790861 37.9995117,4 L37.9995117,9.999512 C37.9995117,12.208651 36.2086507,13.999512 33.9995117,13.999512 L8,13.999512 C7.83499225,13.999512 7.6723181,13.9895206 7.51254954,13.9701099 L2.52730803,17.9196415 Z"/><path id="Path" fill="#73E1E0" d="M7.51066,44.9703679 L2.52730803,47.9186655 C2.44329744,47.9735407 2.36370847,47.999512 2.29303375,47.999512 C2.1197031,47.999512 2,47.8433828 2,47.5743095 L2,35 C2,32.790861 3.790861,31 6,31 L26,31 C28.209139,31 30,32.790861 30,35 L30,41 C30,43.209139 28.209139,45 26,45 L8,45 C7.8343417,45 7.67103544,44.9899297 7.51066,44.9703679 Z"/><path id="Path" fill="#FFD476" d="M46,19.5 L46,33.0747975 C46,33.3438708 45.8802969,33.5 45.7069663,33.5 C45.6362915,33.5 45.5567026,33.4740287 45.472692,33.4191535 L40.4887103,29.4704446 C40.3285371,29.489956 40.1654415,29.5 40,29.5 L18,29.5 C15.790861,29.5 14,27.709139 14,25.5 L14,19.5 C14,17.290861 15.790861,15.5 18,15.5 L42,15.5 C44.209139,15.5 46,17.290861 46,19.5 Z"/></g></svg>
|
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/direction</title><g id="illustration/direction" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Combined-Shape" fill="#FFD476" d="M23.4917015,33.6030641 L2.93840258,31.4321033 C2.38917316,31.3740904 1.99096346,30.8818233 2.04897631,30.3325939 C2.0747515,30.0885705 2.18934861,29.8625419 2.37095722,29.6975265 L34.2609105,0.721285325 C34.6696614,0.349881049 35.3021022,0.38015648 35.6735064,0.788907393 C35.9232621,1.06377731 36.0001133,1.45442096 35.8730901,1.80341447 L24.5364357,32.9506164 C24.3793473,33.3822133 23.9484565,33.6513092 23.4917015,33.6030641 L23.4917015,33.6030641 Z"/><path id="Combined-Shape-Copy" fill="#FFC445" d="M24.3163597,33.2881029 C24.0306575,33.0138462 23.9337246,32.5968232 24.069176,32.2246735 L35.091923,1.9399251 C35.2266075,1.56988243 35.5659249,1.31333613 35.9586669,1.28460955 C36.5094802,1.24432106 36.9886628,1.65818318 37.0289513,2.20899647 L40.2437557,46.1609256 C40.2644355,46.4436546 40.1641446,46.7218752 39.9678293,46.9263833 C39.5853672,47.3248067 38.9523344,47.3377458 38.5539111,46.9552837 L24.3163597,33.2881029 L24.3163597,33.2881029 Z"/></g></svg>
|
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/flow</title><g id="illustration/flow" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Combined-Shape" fill="#79C9FC" fill-rule="nonzero" d="M30,29 C32.7614237,29 35,26.7614237 35,24 C35,14.6111593 27.3888407,7 18,7 C8.61115925,7 1,14.6111593 1,24 C1,33.3888407 8.61115925,41 18,41 C19.3333404,41 20.6447683,40.8466238 21.9154603,40.5471706 C19.5096374,39.3319645 17.5510566,37.8612875 16.0456579,36.1314815 C14.1063138,33.9030427 12.769443,31.0725999 12.0293806,27.6556449 C11.360469,26.565281 11,25.3082308 11,24 C11,20.1340068 14.1340068,17 18,17 C21.8659932,17 25,20.1340068 25,24 C25,26.125 27.7040312,29 30,29 Z"/><path id="Combined-Shape-Copy" fill="#FFC445" fill-rule="nonzero" d="M42,29 C44.7614237,29 47,26.7614237 47,24 C47,14.6111593 39.3888407,7 30,7 C20.6111593,7 13,14.6111593 13,24 C13,33.3888407 20.6111593,41 30,41 C31.3333404,41 32.6447683,40.8466238 33.9154603,40.5471706 C31.5096374,39.3319645 29.4051056,37.9781963 28.0456579,36.1314815 C26.0625,33.4375 23,27.1875 23,24 C23,20.1340068 26.1340068,17 30,17 C33.8659932,17 37,20.1340068 37,24 C37.02301,26.3435241 39.7040312,29 42,29 Z" transform="translate(30.000000, 24.000000) scale(-1, -1) translate(-30.000000, -24.000000)"/></g></svg>
|
|
Before Width: | Height: | Size: 1.4 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/plugin</title><g id="illustration/plugin" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Combined-Shape" fill="#79C9FC" d="M26,15.3994248 C26,15.4091303 26,15.4188459 26,15.4285714 L26,21.4694881 C25.8463595,21.4969567 25.6941676,21.51275 25.5873784,21.51275 C25.4974117,21.51275 25.4230979,21.4768034 25.377756,21.4206259 L25.2660784,21.2822603 L25.1317423,21.1657666 C24.2436317,20.3956144 23.100098,19.9633214 21.895551,19.9633214 C19.2039137,19.9633214 17,22.1075558 17,24.7804643 C17,27.4533728 19.2039137,29.5976071 21.895551,29.5976071 C23.1972122,29.5976071 24.3149423,29.2878193 25.1231445,28.3613697 C25.4542273,27.9818463 25.568273,27.9073214 25.5873784,27.9073214 C25.681532,27.9073214 25.8352452,27.9239643 26,27.9524591 L26,32.5714286 C26,32.5811541 26,32.5908697 26,32.6005752 L26,33 C26,35.209139 24.209139,37 22,37 L4,37 C1.790861,37 0,35.209139 0,33 L0,15 C0,12.790861 1.790861,11 4,11 L22,11 C24.209139,11 26,12.790861 26,15 L26,15.3994248 Z"/><path id="Path" fill="#87E6E5" d="M27.9998779,32.5714286 C27.9998779,33.3604068 28.6572726,34 29.4682101,34 L46.5315458,34 C47.3424832,34 47.9998779,33.3604068 47.9998779,32.5714286 L47.9998779,15.4285714 C47.9998779,14.6395932 47.3424832,14 46.5315458,14 L29.4682101,14 C28.6572726,14 27.9998779,14.6395932 27.9998779,15.4285714 L27.9998779,21.8355216 C27.9334367,22.2650514 27.8567585,22.6454496 27.746391,22.8084643 C27.4245309,23.2838571 26.2402709,23.51275 25.5873784,23.51275 C24.8705773,23.51275 24.2322714,23.1857725 23.8214379,22.6767605 C23.3096996,22.2329909 22.6349941,21.9633214 21.895551,21.9633214 C20.2963823,21.9633214 19,23.2245992 19,24.7804643 C19,26.3363293 20.2963823,27.5976071 21.895551,27.5976071 C22.5398535,27.5976071 23.2399343,27.477727 23.6160247,27.0466112 C24.1396029,26.4464286 24.7367044,25.9073214 25.5873784,25.9073214 C26.2402709,25.9073214 27.5912951,26.1766031 27.8226692,26.6116071 C27.8819199,26.7230038 27.9403239,26.921677 27.9998779,27.1556219 L27.9998779,32.5714286 Z"/></g></svg>
|
|
Before Width: | Height: | Size: 2.1 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/repo</title><g id="illustration/repo" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Rectangle-62-Copy" fill="#B7F0EF" d="M27.2217723,9.04506931 L41.2217723,6.2682098 C43.3886973,5.83840648 45.4937616,7.2466219 45.9235649,9.41354696 C45.9743993,9.66983721 46,9.93049166 46,10.1917747 L46,32.581381 C46,34.4904961 44.650862,36.1335143 42.7782277,36.5049459 L28.7782277,39.2818054 C26.6113027,39.7116087 24.5062384,38.3033933 24.0764351,36.1364682 C24.0256007,35.880178 24,35.6195235 24,35.3582405 L24,12.9686342 C24,11.0595191 25.349138,9.4165009 27.2217723,9.04506931 Z" opacity=".7"/><path id="Combined-Shape" fill="#87E6E5" d="M6.77822775,6.2682098 L20.7782277,9.04506931 C22.650862,9.4165009 24,11.0595191 24,12.9686342 L24,35.3582405 C24,37.5673795 22.209139,39.3582405 20,39.3582405 C19.738717,39.3582405 19.4780625,39.3326398 19.2217723,39.2818054 L5.22177225,36.5049459 C3.34913798,36.1335143 2,34.4904961 2,32.581381 L2,10.1917747 C2,7.98263571 3.790861,6.19177471 6,6.19177471 C6.26128305,6.19177471 6.5219375,6.21737537 6.77822775,6.2682098 Z"/><path id="Rectangle-63-Copy-2" fill="#61C1FD" d="M22,10 C23.1666667,10.2291667 24.0179036,10.625 24.5537109,11.1875 C25.0895182,11.75 25.5716146,12.875 26,14.5625 C26,29.3020833 26,37.5208333 26,39.21875 C26,40.9166667 26.4241536,42.9583333 27.2724609,45.34375 L24.5537109,41.875 L22.9824219,45.34375 C22.327474,43.1979167 22,41.2291667 22,39.4375 C22,37.6458333 22,27.8333333 22,10 Z"/></g></svg>
|
|
Before Width: | Height: | Size: 1.6 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="48" height="48" version="1.1" viewBox="0 0 48 48"><title>illustration/stackalt</title><g id="illustration/stackalt" fill="none" fill-rule="evenodd" stroke="none" stroke-width="1"><path id="Combined-Shape" fill="#FFAE00" d="M23.8628277,0 L23.8628277,48 L3.32291648,36.2491883 L3.32155653,11.9499781 L23.8628277,0 Z M23.8670509,0 L44.408322,11.9499781 L44.4069621,36.2491883 L23.8670509,48 L23.8670509,0 Z" opacity=".196"/><path id="Rectangle-46-Copy-3" fill="#66BF3C" d="M15.8232279,19.1155258 L24.7368455,21.4714881 C29.6053842,22.7582937 33.4077423,26.5606518 34.694548,31.4291905 L37.0505103,40.3428082 C37.6150232,42.4786032 36.3412474,44.6676353 34.2054524,45.2321482 C33.5569474,45.4035549 32.87575,45.4091235 32.2245294,45.2483418 L23.3459013,43.0562718 C18.2976962,41.809906 14.3561301,37.8683399 13.1097642,32.8201348 L10.9176943,23.9415066 C10.3881737,21.7967682 11.6975664,19.6288529 13.8423049,19.0993322 C14.4935255,18.9385505 15.1747229,18.9441191 15.8232279,19.1155258 Z" opacity=".5" transform="translate(23.999997, 32.166058) rotate(-45.000000) translate(-23.999997, -32.166058)"/><path id="Rectangle-46-Copy-2" fill="#FFAE00" d="M15.8232279,11.2216893 L24.7368455,13.5776516 C29.6053842,14.8644572 33.4077423,18.6668153 34.694548,23.5353541 L37.0505103,32.4489717 C37.6150232,34.5847667 36.3412474,36.7737988 34.2054524,37.3383117 C33.5569474,37.5097184 32.87575,37.515287 32.2245294,37.3545053 L23.3459013,35.1624353 C18.2976962,33.9160695 14.3561301,29.9745034 13.1097642,24.9262983 L10.9176943,16.0476701 C10.3881737,13.9029317 11.6975664,11.7350164 13.8423049,11.2054957 C14.4935255,11.044714 15.1747229,11.0502826 15.8232279,11.2216893 Z" opacity=".5" transform="translate(23.999997, 24.272222) rotate(-45.000000) translate(-23.999997, -24.272222)"/><path id="Rectangle-46-Copy" fill="#FC521F" d="M15.8232279,3.32785281 L24.7368455,5.68381509 C29.6053842,6.97062075 33.4077423,10.7729788 34.694548,15.6415176 L37.0505103,24.5551352 C37.6150232,26.6909302 36.3412474,28.8799623 34.2054524,29.4444752 C33.5569474,29.6158819 32.87575,29.6214505 32.2245294,29.4606688 L23.3459013,27.2685988 C18.2976962,26.022233 14.3561301,22.0806669 13.1097642,17.0324618 L10.9176943,8.15383364 C10.3881737,6.00909519 11.6975664,3.84117987 13.8423049,3.31165925 C14.4935255,3.15087753 15.1747229,3.15644615 15.8232279,3.32785281 Z" opacity=".5" transform="translate(23.999997, 16.378385) rotate(-45.000000) translate(-23.999997, -16.378385)"/></g></svg>
|
|
Before Width: | Height: | Size: 2.5 KiB |
@ -1,30 +0,0 @@
|
|||||||
.storybook-button {
|
|
||||||
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
font-weight: 700;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 3em;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-block;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.storybook-button--primary {
|
|
||||||
color: white;
|
|
||||||
background-color: #1ea7fd;
|
|
||||||
}
|
|
||||||
.storybook-button--secondary {
|
|
||||||
color: #333;
|
|
||||||
background-color: transparent;
|
|
||||||
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset;
|
|
||||||
}
|
|
||||||
.storybook-button--small {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
}
|
|
||||||
.storybook-button--medium {
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 11px 20px;
|
|
||||||
}
|
|
||||||
.storybook-button--large {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 12px 24px;
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
.wrapper {
|
|
||||||
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
padding: 15px 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 1;
|
|
||||||
margin: 6px 0 6px 10px;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
button + button {
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome {
|
|
||||||
color: #333;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
@ -1,69 +0,0 @@
|
|||||||
section {
|
|
||||||
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 24px;
|
|
||||||
padding: 48px 20px;
|
|
||||||
margin: 0 auto;
|
|
||||||
max-width: 600px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
section h2 {
|
|
||||||
font-weight: 900;
|
|
||||||
font-size: 32px;
|
|
||||||
line-height: 1;
|
|
||||||
margin: 0 0 4px;
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
section p {
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
section a {
|
|
||||||
text-decoration: none;
|
|
||||||
color: #1ea7fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
section ul {
|
|
||||||
padding-left: 30px;
|
|
||||||
margin: 1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
section li {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
section .tip {
|
|
||||||
display: inline-block;
|
|
||||||
border-radius: 1em;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: #e7fdd8;
|
|
||||||
color: #66bf3c;
|
|
||||||
padding: 4px 12px;
|
|
||||||
margin-right: 10px;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
section .tip-wrapper {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 20px;
|
|
||||||
margin-top: 40px;
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
section .tip-wrapper svg {
|
|
||||||
display: inline-block;
|
|
||||||
height: 12px;
|
|
||||||
width: 12px;
|
|
||||||
margin-right: 4px;
|
|
||||||
vertical-align: top;
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
section .tip-wrapper svg path {
|
|
||||||
fill: #1ea7fd;
|
|
||||||
}
|
|
@ -1,8 +1,3 @@
|
|||||||
/* This wraps the whole application, rather than just the "page" area */
|
|
||||||
#__next {
|
|
||||||
background-color: darkslategray;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Generics */
|
/* Generics */
|
||||||
|
|
||||||
.grow {
|
.grow {
|
||||||
|
@ -2,19 +2,14 @@ import { useState } from "react";
|
|||||||
import URL from "url";
|
import URL from "url";
|
||||||
import { ExpressionCard } from "../../components";
|
import { ExpressionCard } from "../../components";
|
||||||
import { ExpressionDescription } from "../../components/ExpressionDescription";
|
import { ExpressionDescription } from "../../components/ExpressionDescription";
|
||||||
import {
|
import { addExpressionWithRelationships, Expression } from "../../model";
|
||||||
addExpression,
|
|
||||||
addExpressionWithRelationships,
|
|
||||||
Expression,
|
|
||||||
} from "../../model";
|
|
||||||
import { parseWiktionaryData } from "../../model/parseWiktionaryData";
|
import { parseWiktionaryData } from "../../model/parseWiktionaryData";
|
||||||
|
|
||||||
export function AddExpressionView() {
|
export function AddExpressionView() {
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [expression, setExpression] = useState<Expression | null>(null);
|
const [expression, setExpression] = useState<Expression | null>(null);
|
||||||
const [submitStatus, setSubmitStatus] = useState<string | undefined>(
|
const [submitStatus, setSubmitStatus] =
|
||||||
undefined
|
useState<string | undefined>(undefined);
|
||||||
);
|
|
||||||
const [error, setError] = useState<any>(undefined);
|
const [error, setError] = useState<any>(undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -53,6 +48,7 @@ export function AddExpressionView() {
|
|||||||
disabled={prompt === ""}
|
disabled={prompt === ""}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
|
// `https://en.wiktionary.org/w/api.php?action=parse&format=json&prop=text&page=${query}`;
|
||||||
const url = URL.format({
|
const url = URL.format({
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "en.wiktionary.org",
|
hostname: "en.wiktionary.org",
|
||||||
@ -121,7 +117,3 @@ export function AddExpressionView() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(query: string) {
|
|
||||||
return `https://en.wiktionary.org/w/api.php?action=parse&format=json&prop=text&page=${query}`;
|
|
||||||
}
|
|
||||||
|
@ -1,16 +1,11 @@
|
|||||||
import { Page } from "../../components/Page";
|
|
||||||
|
|
||||||
export interface ErrorViewProps {
|
export interface ErrorViewProps {
|
||||||
title: string;
|
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorView({ title, message }: ErrorViewProps) {
|
export function ErrorView({ message }: ErrorViewProps) {
|
||||||
return (
|
return (
|
||||||
<Page title={title}>
|
|
||||||
<div className="page-with-padding">
|
<div className="page-with-padding">
|
||||||
<p className="text-meta">{message}</p>
|
<p className="text-meta">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
</Page>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useCallback, useContext } from "react";
|
||||||
import { useCallback } from "react";
|
import {
|
||||||
import { useExpressionSetQueryId } from "../../hooks";
|
useExpressionFilterQueryIds,
|
||||||
|
useExpressionSetQueryId,
|
||||||
|
} from "../../hooks";
|
||||||
import { assignExpressionToSet } from "../../model";
|
import { assignExpressionToSet } from "../../model";
|
||||||
|
import { AppRouting } from "../../model/routing";
|
||||||
|
|
||||||
// TODO fix promotion algorithm so it uses a destination expression_set_id
|
// TODO fix promotion algorithm so it uses a destination expression_set_id
|
||||||
|
|
||||||
@ -12,18 +15,16 @@ export interface DemoteExpressionButtonProps {
|
|||||||
export function DemoteExpressionButton({
|
export function DemoteExpressionButton({
|
||||||
expression_id,
|
expression_id,
|
||||||
}: DemoteExpressionButtonProps) {
|
}: DemoteExpressionButtonProps) {
|
||||||
const { query, pathname, push } = useRouter();
|
const { route, setRoute } = useContext(AppRouting);
|
||||||
const expression_set_id = useExpressionSetQueryId();
|
const expression_set_id = useExpressionSetQueryId();
|
||||||
|
const expression_id_filters = useExpressionFilterQueryIds();
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
if (expression_set_id === 1) {
|
if (expression_set_id === 1) {
|
||||||
const filter_ids = query["filter-ids"]
|
setRoute({
|
||||||
? `${query["filter-ids"]} ${expression_id}`
|
path: route.path,
|
||||||
: `${expression_id}`;
|
options: {
|
||||||
push({
|
...route.options,
|
||||||
pathname,
|
expression_id_filters: expression_id_filters.concat(expression_id),
|
||||||
query: {
|
|
||||||
...query,
|
|
||||||
"filter-ids": filter_ids,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -32,8 +33,13 @@ export function DemoteExpressionButton({
|
|||||||
expression_set_id: Math.max(1, expression_set_id - 1),
|
expression_set_id: Math.max(1, expression_set_id - 1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [expression_id, expression_set_id, pathname, push, query]);
|
}, [
|
||||||
|
expression_id,
|
||||||
|
expression_id_filters,
|
||||||
|
expression_set_id,
|
||||||
|
route,
|
||||||
|
setRoute,
|
||||||
|
]);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className="navigation-item bottom text-navigation grow"
|
className="navigation-item bottom text-navigation grow"
|
||||||
|
@ -1,19 +1,52 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useExpressionCategories } from "../../hooks";
|
import {
|
||||||
|
useExpressionCategories,
|
||||||
|
useExpressionFilterQueryIds,
|
||||||
|
useExpressionSetQueryId,
|
||||||
|
useExpressionsInSet,
|
||||||
|
} from "../../hooks";
|
||||||
import { IndexedExpression } from "../../model";
|
import { IndexedExpression } from "../../model";
|
||||||
import { sample } from "../../util";
|
import { sample } from "../../util";
|
||||||
|
import { ErrorView } from "../ErrorView";
|
||||||
import { ExpressionPracticeCardView } from "./ExpressionPracticeCardView";
|
import { ExpressionPracticeCardView } from "./ExpressionPracticeCardView";
|
||||||
|
|
||||||
export interface ExpressionPracticeViewProps {
|
export function ExpressionPracticeView() {
|
||||||
|
const expression_set_id = useExpressionSetQueryId();
|
||||||
|
const filter_ids = useExpressionFilterQueryIds();
|
||||||
|
const expressions = useExpressionsInSet(expression_set_id);
|
||||||
|
|
||||||
|
// Fallback rendering for content not yet fetched
|
||||||
|
if (!expressions) return null;
|
||||||
|
if (!filter_ids) return null;
|
||||||
|
|
||||||
|
// Fallback views for expression set content not found
|
||||||
|
if (!expression_set_id) {
|
||||||
|
return <ErrorView message="Expression set not found" />;
|
||||||
|
}
|
||||||
|
const filtered_expressions = expressions.filter(
|
||||||
|
(expression) => !filter_ids.includes(expression.id!)
|
||||||
|
);
|
||||||
|
if (!filtered_expressions.length) {
|
||||||
|
return <ErrorView message="No expressions left in this set" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExpressionPracticeViewImpl
|
||||||
|
select_from_expressions={filtered_expressions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpressionPracticeViewImplProps {
|
||||||
select_from_expressions: IndexedExpression[];
|
select_from_expressions: IndexedExpression[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpressionPracticeView({
|
function ExpressionPracticeViewImpl({
|
||||||
select_from_expressions: expressions,
|
select_from_expressions: expressions,
|
||||||
}: ExpressionPracticeViewProps) {
|
}: ExpressionPracticeViewImplProps) {
|
||||||
const expression = useMemo(() => sample(expressions), [expressions]);
|
const expression = useMemo(() => sample(expressions), [expressions]);
|
||||||
const categories = useExpressionCategories(expression.id!);
|
const categories = useExpressionCategories(expression.id!);
|
||||||
if (!categories) return null;
|
if (!categories) return null; // Loading
|
||||||
|
|
||||||
// Delegate internal interaction state to next component so it resets
|
// Delegate internal interaction state to next component so it resets
|
||||||
// on an expression switch
|
// on an expression switch
|
||||||
|
@ -1,16 +1,44 @@
|
|||||||
import Link from "next/link";
|
import { useContext } from "react";
|
||||||
import { ExpressionSetInfo } from "../../components";
|
import { ExpressionSetInfo } from "../../components";
|
||||||
|
import {
|
||||||
|
useExpressionSet,
|
||||||
|
useExpressionSetQueryId,
|
||||||
|
useExpressionsInSet,
|
||||||
|
} from "../../hooks";
|
||||||
import { IndexedExpressionSet } from "../../model";
|
import { IndexedExpressionSet } from "../../model";
|
||||||
|
import { AppPath, AppRouting } from "../../model/routing";
|
||||||
|
import { ErrorView } from "../ErrorView";
|
||||||
|
|
||||||
export interface ExpressionSetDetailsViewProps {
|
export function ExpressionSetDetailsView() {
|
||||||
|
const expression_set_id = useExpressionSetQueryId();
|
||||||
|
const expression_set = useExpressionSet(expression_set_id);
|
||||||
|
const expressions = useExpressionsInSet(expression_set_id);
|
||||||
|
|
||||||
|
// Fallback for expression set not found
|
||||||
|
if (!expressions) return null;
|
||||||
|
if (!expression_set) {
|
||||||
|
return <ErrorView message="Expression set not found" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for expression set empty
|
||||||
|
return (
|
||||||
|
<ExpressionSetDetailsViewImpl
|
||||||
|
expression_set={expression_set}
|
||||||
|
expression_count={expressions.length}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpressionSetDetailsViewImplProps {
|
||||||
expression_set: IndexedExpressionSet;
|
expression_set: IndexedExpressionSet;
|
||||||
expression_count: number;
|
expression_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpressionSetDetailsView({
|
function ExpressionSetDetailsViewImpl({
|
||||||
expression_set,
|
expression_set,
|
||||||
expression_count,
|
expression_count,
|
||||||
}: ExpressionSetDetailsViewProps) {
|
}: ExpressionSetDetailsViewImplProps) {
|
||||||
|
const { setRoute } = useContext(AppRouting);
|
||||||
if (!expression_count) {
|
if (!expression_count) {
|
||||||
return (
|
return (
|
||||||
<div className="page-with-padding scroll">
|
<div className="page-with-padding scroll">
|
||||||
@ -36,17 +64,17 @@ export function ExpressionSetDetailsView({
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section className="navigation-bottom">
|
<section className="navigation-bottom">
|
||||||
<Link
|
<div
|
||||||
href={{
|
className="navigation-item bottom text-navigation grow"
|
||||||
pathname: "/expression-sets/practice",
|
onClick={() =>
|
||||||
query: { "set-id": expression_set.id },
|
setRoute({
|
||||||
}}
|
path: AppPath.ExpressionSetsPractice,
|
||||||
passHref
|
options: { expression_set_id: expression_set.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<a className="navigation-item bottom text-navigation grow">
|
|
||||||
<span>Practice this set</span>
|
<span>Practice this set</span>
|
||||||
</a>
|
</div>
|
||||||
</Link>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,30 +1,31 @@
|
|||||||
import Link from "next/link";
|
import { useContext } from "react";
|
||||||
import { ExpressionSetCard } from "../../components";
|
import { ExpressionSetCard } from "../../components";
|
||||||
import { useExpressionsInSet } from "../../hooks";
|
import { useExpressionsInSet } from "../../hooks";
|
||||||
import { IndexedExpressionSet } from "../../model";
|
import { IndexedExpressionSet } from "../../model";
|
||||||
|
import { AppPath, AppRouting } from "../../model/routing";
|
||||||
|
|
||||||
export function ExpressionSetLink({
|
export function ExpressionSetLink({
|
||||||
id,
|
id,
|
||||||
description,
|
description,
|
||||||
name,
|
name,
|
||||||
}: IndexedExpressionSet) {
|
}: IndexedExpressionSet) {
|
||||||
|
const { setRoute } = useContext(AppRouting);
|
||||||
const expressions = useExpressionsInSet(id!) || [];
|
const expressions = useExpressionsInSet(id!) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<div
|
||||||
href={{
|
onClick={() =>
|
||||||
pathname: "/expression-sets/details",
|
setRoute({
|
||||||
query: { "set-id": id },
|
path: AppPath.ExpressionSetsDetails,
|
||||||
}}
|
options: { expression_set_id: id },
|
||||||
passHref
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<a>
|
|
||||||
<ExpressionSetCard
|
<ExpressionSetCard
|
||||||
name={name}
|
name={name}
|
||||||
description={description}
|
description={description}
|
||||||
expression_count={expressions.length}
|
expression_count={expressions.length}
|
||||||
/>
|
/>
|
||||||
</a>
|
</div>
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import { IndexedExpressionSet } from "../../model";
|
import { useExpressionSets } from "../../hooks";
|
||||||
|
import { ErrorView } from "../ErrorView";
|
||||||
import { ExpressionSetLink } from "./ExpressionSetLink";
|
import { ExpressionSetLink } from "./ExpressionSetLink";
|
||||||
|
|
||||||
export interface ExpressionSetListViewProps {
|
export function ExpressionSetListView() {
|
||||||
expression_sets: IndexedExpressionSet[];
|
const expression_sets = useExpressionSets();
|
||||||
}
|
if (!expression_sets) return null; // LOADING
|
||||||
|
if (!expression_sets.length) {
|
||||||
|
return <ErrorView message="No expression sets found" />;
|
||||||
|
}
|
||||||
|
|
||||||
export function ExpressionSetListView({
|
|
||||||
expression_sets,
|
|
||||||
}: ExpressionSetListViewProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="page-with-padding content-list scroll">
|
<div className="page-with-padding content-list scroll">
|
||||||
{expression_sets.map(({ id, name, description }) => (
|
{expression_sets.map(({ id, name, description }) => (
|
||||||
|