Implement browser extension
@ -5,9 +5,9 @@
|
||||
You need NodeJS >= 14.x.x
|
||||
|
||||
- Clone the repository.
|
||||
- Go to directory with `package.json`.
|
||||
- `npm run dev`.
|
||||
- Before publish a PR, please check that it builds without errors and works in production mode: `npm run build && npm start`
|
||||
- `cd ./hackage-ui`.
|
||||
- See `Makefile`.
|
||||
- Before publish a PR, please ensure that it builds without errors in production mode: `npm run build && npm start`.
|
||||
|
||||
If you want to implement some significant change, it's better to create a GitHub issue and discuss it first.
|
||||
|
||||
|
3
browser-extension/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/build
|
||||
/build.zip
|
||||
/node_modules
|
26
browser-extension/.swcrc
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"module": {
|
||||
"type": "commonjs"
|
||||
},
|
||||
"jsc": {
|
||||
"target": "es2015",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"throwIfNamespace": true,
|
||||
"useBuiltins": true,
|
||||
"runtime": "automatic"
|
||||
},
|
||||
"optimizer": {
|
||||
"globals": {
|
||||
"vars": {
|
||||
"__DEBUG__": "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
17
browser-extension/Makefile
Normal file
@ -0,0 +1,17 @@
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: dev
|
||||
dev: ## Start development.
|
||||
npm start
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the project.
|
||||
cd ../react-lib && make build && npm link
|
||||
npm link @hackage-ui/react-lib
|
||||
npm i
|
||||
npm run build
|
||||
# xcrun safari-web-extension-converter --project-location ./build/safari ./build
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
21
browser-extension/README.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Browser Extension
|
||||
|
||||
<https://stackoverflow.com/questions/59608956/can-cypress-io-test-a-chrome-extension>
|
||||
|
||||
## Development
|
||||
|
||||
`cd extension && npm i && npm start`
|
||||
|
||||
**Chrome:** <chrome://extensions> -> Check Developer mode -> Load unpacked
|
||||
|
||||
**Firefox:** <about:debugging> -> This Firefox -> Load Temporary Addon
|
||||
|
||||
**Safari** Build: <https://developer.apple.com/documentation/safariservices/safari_web_extensions/converting_a_web_extension_for_safari>
|
||||
|
||||
Run: <https://developer.apple.com/documentation/safariservices/safari_web_extensions/running_your_safari_web_extension>
|
||||
|
||||
Then use the reload button at your extensions list page to see changes.
|
||||
|
||||
Sometimes it stucks, so just remove and upload the extension again.
|
||||
|
||||
### Localizaiton: <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Internationalization>
|
BIN
browser-extension/assets/images/icon-128.png
Normal file
After Width: | Height: | Size: 6.8 KiB |
BIN
browser-extension/assets/images/icon-192.png
Normal file
After Width: | Height: | Size: 9.5 KiB |
32
browser-extension/assets/manifest.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Haskell Spotlight",
|
||||
"short_name": "Haskell Spotlight",
|
||||
"description": "Search on Hackage, Hoogle and more soon.",
|
||||
"homepage_url": "https://github.com/visortelle/hackage-ui",
|
||||
"version": "0.0.1",
|
||||
"icons": { "192": "images/icon-192.png" },
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["contentscript.js"],
|
||||
"run_at": "document_start",
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["background.js"]
|
||||
},
|
||||
"browser_action": {
|
||||
"default_icon": {
|
||||
"128": "images/icon-192.png"
|
||||
},
|
||||
"default_title": "Haskell title",
|
||||
"default_popup": "popup.html"
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"unlimitedStorage",
|
||||
"webRequest"
|
||||
]
|
||||
}
|
15
browser-extension/assets/popup.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html style="width:350px; height:240px;">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1 user-scalable=no">
|
||||
<title>Haskell</title>
|
||||
</head>
|
||||
|
||||
<body style="width:350px; height:240px;">
|
||||
<div id="app" style="width: 100%; height: 100%;"></div>
|
||||
<script src="./popup.js" type="text/javascript" charset="utf-8"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
80
browser-extension/content/Content.module.css
Normal file
@ -0,0 +1,80 @@
|
||||
.content {
|
||||
padding: 12px 18px;
|
||||
padding-left: 60px;
|
||||
background-color: var(--purple-color-2);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
width: 100vw;
|
||||
max-width: 600px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition: var(--transition-short);
|
||||
background-color: var(--purple-color-2);
|
||||
position: absolute;
|
||||
border-radius: 8px;
|
||||
left: 12px;
|
||||
top: 7px;
|
||||
bottom: 8px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
transition: var(--transition-short);
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--purple-color-1);
|
||||
transition: var(--transition-short);
|
||||
}
|
||||
|
||||
.logo:hover svg {
|
||||
cursor: pointer;
|
||||
fill: var(--purple-color-2);
|
||||
transition: var(--transition-short);
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
fill: var(--purple-color-1);
|
||||
transition: var(--transition-short);
|
||||
}
|
||||
|
||||
.progressIndicator {
|
||||
width: calc(100% - 4px);
|
||||
height: 4px;
|
||||
background-color: var(--purple-color-2);
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 2px;
|
||||
right: 0;
|
||||
border-radius: 8px / 4px;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.progressIndicatorRunning {
|
||||
background-color: var(--purple-color-1);
|
||||
animation: pulse 400ms infinite normal;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0;
|
||||
width: 0%;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
width: calc(100% - 4px);
|
||||
}
|
||||
}
|
114
browser-extension/content/Content.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import * as lib from '@hackage-ui/react-lib';
|
||||
import normalizeStyles from '../../styles/normalize.css';
|
||||
import globalsStyles from '../../styles/globals.css';
|
||||
import fontsStyles from '../../styles/fonts.css';
|
||||
import reactLibStyles from '@hackage-ui/react-lib/dist/react-lib.css';
|
||||
import reactToastifyStyles from 'react-toastify/dist/ReactToastify.css';
|
||||
import styles from './Content.module.css';
|
||||
import * as s from './Content.module.css';
|
||||
import haskellLogo from '!!raw-loader!./haskell-monochrome.svg'
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
|
||||
const Content = (props: { rootElement: HTMLElement }) => {
|
||||
const appContext = useContext(lib.appContext.AppContext);
|
||||
const stylesContainerRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isShow, setIsShow] = useState(false);
|
||||
const [explode, setExplode] = useState(false);
|
||||
|
||||
const toggleVisibility = useCallback((event: KeyboardEvent) => {
|
||||
if (event.ctrlKey && event.key === 'h') {
|
||||
setIsShow((isShow) => !isShow);
|
||||
}
|
||||
}, [isShow, setIsShow]);
|
||||
|
||||
// Prevent global page hotkeys when the search input is in focus.
|
||||
const handleKeyboardEvents = useCallback((event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setIsShow(false);
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleClickOutside = useCallback((event: MouseEvent) => {
|
||||
if (props.rootElement === event.target || props.rootElement.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsShow(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
contentRef.current.addEventListener('keyup', handleKeyboardEvents);
|
||||
contentRef.current.addEventListener('keydown', handleKeyboardEvents);
|
||||
contentRef.current.addEventListener('keypress', handleKeyboardEvents);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
contentRef?.current?.removeEventListener('keyup', handleKeyboardEvents)
|
||||
contentRef?.current?.removeEventListener('keydown', handleKeyboardEvents)
|
||||
contentRef?.current?.removeEventListener('keypress', handleKeyboardEvents)
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keyup', toggleVisibility);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keyup', toggleVisibility)
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let extraStyles = document.createElement('style');
|
||||
extraStyles.appendChild(document.createTextNode("")); // WebKit hack
|
||||
(stylesContainerRef.current as HTMLElement).appendChild(extraStyles);
|
||||
extraStyles.sheet.insertRule(`.${lib.searchInput.SearchResultsClassName} { top: 72px !important; max-height: calc(100vh - 82px) !important; }`);
|
||||
|
||||
normalizeStyles.use({ target: stylesContainerRef.current });
|
||||
fontsStyles.use({ target: document.head });
|
||||
globalsStyles.use({ target: stylesContainerRef.current });
|
||||
reactLibStyles.use({ target: stylesContainerRef.current });
|
||||
reactToastifyStyles.use({ target: stylesContainerRef.current });
|
||||
styles.use({ target: stylesContainerRef.current });
|
||||
|
||||
setIsReady(true);
|
||||
}, [stylesContainerRef]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={() => { return (<div>Something went wrong...</div>) }}
|
||||
onReset={() => setExplode(false)}
|
||||
resetKeys={[explode]}
|
||||
>
|
||||
<div>
|
||||
<div ref={stylesContainerRef}></div>
|
||||
{isReady && isShow && (
|
||||
<div ref={contentRef} className={s.content}>
|
||||
<div className={`${s.progressIndicator} ${Object.keys(appContext.tasks).length > 0 ? s.progressIndicatorRunning : ''}`}></div>
|
||||
<a href="https://github.com/visortelle/hackage-ui" target='__blank' className={s.logo} dangerouslySetInnerHTML={{ __html: haskellLogo }}></a>
|
||||
<div style={{ flex: 1 }}>
|
||||
<lib.searchInput.SearchInput
|
||||
asEmbeddedWidget={true}
|
||||
api={{
|
||||
hackageApiUrl: 'https://hackage-ui.vercel.app/api/hackage',
|
||||
hoogleApiUrl: 'https://hackage-ui.vercel.app/api/hoogle'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default Content;
|
12
browser-extension/content/document.css
Normal file
@ -0,0 +1,12 @@
|
||||
#haskell-extension-root {
|
||||
width: 640px;
|
||||
height: auto;
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
z-index: 2147483647;
|
||||
display: flex;
|
||||
font-size: 16px;;
|
||||
}
|
5
browser-extension/content/haskell-monochrome.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80" viewBox="0 0 120 80">
|
||||
<path d="M1.842 77.722L26.586 40.63 1.842 3.537H20.4L45.144 40.63 20.4 77.722H1.842zm0 0" />
|
||||
<path d="M26.586 77.722L51.33 40.63 26.586 3.537h18.558L94.63 77.722H76.074L60.61 54.54 45.143 77.722H26.586zm0 0" />
|
||||
<path d="M86.384 56.085L78.136 43.72h28.868v12.366h-20.62zM74.012 37.54l-8.248-12.365h41.24V37.54H74.012zm0 0" />
|
||||
</svg>
|
After Width: | Height: | Size: 424 B |
18
browser-extension/content/render.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import documentStyles from './document.css';
|
||||
import Content from './Content';
|
||||
import * as lib from '@hackage-ui/react-lib'
|
||||
|
||||
export function render({ to }: { to: HTMLElement }) {
|
||||
ReactDOM.render(
|
||||
(
|
||||
<lib.appContext.DefaultAppContextProvider useNextJSRouting={false} asWebExtension={true}>
|
||||
<Content rootElement={to} />
|
||||
</lib.appContext.DefaultAppContextProvider>
|
||||
),
|
||||
to.shadowRoot
|
||||
);
|
||||
|
||||
documentStyles.use();
|
||||
}
|
21737
browser-extension/package-lock.json
generated
Normal file
66
browser-extension/package.json
Normal file
@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@hackage-ui/browser-extension",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"main": "build/index.js",
|
||||
"files": [
|
||||
"build",
|
||||
"README.md"
|
||||
],
|
||||
"dependencies": {
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"express": "^4.17.1",
|
||||
"postcss-selector-replace": "^1.0.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-error-boundary": "^3.1.4",
|
||||
"react-toastify": "^8.1.0",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"url-parse": "^1.5.3",
|
||||
"uuid": "^8.3.2",
|
||||
"ws": "^8.2.0",
|
||||
"webextension-polyfill": "^0.8.0",
|
||||
"@hackage-ui/react-lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.2.107",
|
||||
"@types/node": "^16.9.1",
|
||||
"@types/react": "^17.0.16",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"@types/terser-webpack-plugin": "^5.0.4",
|
||||
"@types/webextension-polyfill": "^0.8.0",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"@types/webpack-dev-server": "^3.11.5",
|
||||
"@types/ws": "^7.4.7",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"css-loader": "^6.5.1",
|
||||
"glob": "^7.1.7",
|
||||
"html-webpack-plugin": "^5.3.2",
|
||||
"jest": "^27.2.0",
|
||||
"postcss": "^8.4.5",
|
||||
"postcss-loader": "^6.2.1",
|
||||
"postcss-pxtorem": "^6.0.0",
|
||||
"postcss-rem-to-pixel": "^4.1.2",
|
||||
"raw-loader": "^4.0.2",
|
||||
"style-loader": "^3.3.1",
|
||||
"swc-loader": "^0.1.15",
|
||||
"terser-webpack-plugin": "^5.1.4",
|
||||
"ts-jest": "^27.0.5",
|
||||
"ts-loader": "^9.2.6",
|
||||
"ts-node": "^10.1.0",
|
||||
"tsconfig-paths": "^3.10.1",
|
||||
"typescript": "^4.4.4",
|
||||
"webextension-polyfill": "^0.8.0",
|
||||
"webpack": "^5.63.0",
|
||||
"webpack-cli": "^4.9.1",
|
||||
"webpack-dev-server": "^4.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rm -rf build && webpack --config webpack.config.ts --env mode=production && cp -r ./assets/* ./build/",
|
||||
"build:dev": "rm -rf build && webpack --config webpack.config.ts --env mode=development && cp -r ./assets/* ./build/",
|
||||
"build:analyze": "webpack --analyze --mode=poduction",
|
||||
"dev": "chokidar \"**/*\" -i \"node_modules/**/*\" -i \"build\" -c \"npm run build:dev\" --initial"
|
||||
}
|
||||
}
|
38
browser-extension/popup/Popup.module.css
Normal file
@ -0,0 +1,38 @@
|
||||
.popup {
|
||||
background: #f6eedc;
|
||||
color: #777;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-weight: bold;
|
||||
font-size: 32px;
|
||||
margin-bottom: 18px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 18px;
|
||||
background: #5e5086;
|
||||
color: #fff;
|
||||
text-shadow: 1px 3px 2px rgb(0 0 0 / 27%);
|
||||
}
|
||||
|
||||
.kbd {
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
background: #5e5086;
|
||||
color: #fff;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
min-width: 64px;
|
||||
border-bottom: 6px solid #453a62;
|
||||
border-right: 4px solid #453a62;
|
||||
}
|
41
browser-extension/popup/Popup.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
import * as s from './Popup.module.css';
|
||||
import styles from './Popup.module.css';
|
||||
import normalizeStyles from '../../styles/normalize.css';
|
||||
import globalsStyles from '../../styles/globals.css';
|
||||
import fontsStyles from '../../styles/fonts.css';
|
||||
|
||||
export default () => {
|
||||
useEffect(() => {
|
||||
normalizeStyles.use();
|
||||
fontsStyles.use();
|
||||
globalsStyles.use({ target: document.body });
|
||||
styles.use();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={s.popup}>
|
||||
<div className={s.header}>Haskell Spotlight</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ fontSize: '18px', fontWeight: 'bold', textAlign: 'center' }}>Close this popup and press</div>
|
||||
<div style={{ display: 'flex', marginTop: '8px', justifyContent: 'center' }}>
|
||||
<code className={s.kbd}>
|
||||
Ctrl
|
||||
</code>
|
||||
<div style={{ fontSize: '32px', display: 'flex', alignItems: 'center', justifyContent: 'center', height: '64px', padding: '0 16px' }}>+</div>
|
||||
<code className={s.kbd}>
|
||||
H
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<a
|
||||
style={{ display: 'flex', marginTop: '18px', fontSize: '16px', color: '#5e5086' }}
|
||||
href="https://github.com/visortelle/hackage-ui"
|
||||
target="__blank"
|
||||
>
|
||||
github.com/visortelle/hackage-ui
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
7
browser-extension/popup/render.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Popup from './Popup';
|
||||
|
||||
export function render({ to }: { to: HTMLElement }) {
|
||||
ReactDOM.render(<Popup />, to);
|
||||
}
|
1
browser-extension/scripts/background.ts
Normal file
@ -0,0 +1 @@
|
||||
export default {};
|
19
browser-extension/scripts/contentscript.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import browser from "webextension-polyfill";
|
||||
import { render } from "../content/render";
|
||||
|
||||
(global as any).browser = browser;
|
||||
|
||||
export const shadowDomRootId = "haskell-extension-root";
|
||||
|
||||
function entrypoint(): void {
|
||||
const renderTarget = document.createElement("div");
|
||||
renderTarget.id = shadowDomRootId;
|
||||
document.body.appendChild(renderTarget);
|
||||
|
||||
renderTarget.attachShadow({ mode: "open" });
|
||||
render({ to: renderTarget });
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", entrypoint);
|
||||
|
||||
export {};
|
3
browser-extension/scripts/popup.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { render } from "../popup/render";
|
||||
|
||||
render({ to: document.getElementById("app") as HTMLElement });
|
1
browser-extension/types.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module '*.css';
|
132
browser-extension/webpack.config.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import path from "path";
|
||||
import { Configuration, ProvidePlugin } from "webpack";
|
||||
import TerserPlugin from "terser-webpack-plugin";
|
||||
import * as postcss from 'postcss';
|
||||
|
||||
export default ({
|
||||
mode,
|
||||
}: {
|
||||
mode: "production" | "development";
|
||||
}): Configuration => ({
|
||||
mode,
|
||||
entry: {
|
||||
background: path.resolve(__dirname, "./scripts/contentscript.ts"),
|
||||
contentscript: path.resolve(__dirname, "./scripts/contentscript.ts"),
|
||||
popup: path.resolve(__dirname, "./scripts/popup.ts"),
|
||||
},
|
||||
optimization: {
|
||||
minimize: mode === "production",
|
||||
minimizer:
|
||||
mode === "production"
|
||||
? [
|
||||
new TerserPlugin({
|
||||
parallel: true,
|
||||
terserOptions: {
|
||||
// https://github.com/webpack-contrib/terser-webpack-plugin#terseroptions
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
moduleIds: "deterministic",
|
||||
usedExports: false,
|
||||
},
|
||||
plugins: [
|
||||
// Fix for:
|
||||
// BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
|
||||
// This is no longer the case. Verify if you need this module and configure a polyfill for it.
|
||||
new ProvidePlugin({
|
||||
process: "process/browser.js",
|
||||
Buffer: ["buffer", "Buffer"],
|
||||
}),
|
||||
],
|
||||
devtool: false,
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: [
|
||||
{
|
||||
loader: "style-loader",
|
||||
options: {
|
||||
injectType: "lazyStyleTag",
|
||||
insert: function insertIntoTarget(
|
||||
element: any,
|
||||
options: { target: any }
|
||||
) {
|
||||
var parent = options.target || (global as any).document.head;
|
||||
parent.appendChild(element);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: "css-loader",
|
||||
options: {
|
||||
modules: {
|
||||
auto: true,
|
||||
namedExport: true,
|
||||
localIdentName: "[local]--[hash:base64:5]",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: "postcss-loader",
|
||||
options: {
|
||||
postcssOptions: {
|
||||
plugins: [
|
||||
[
|
||||
// Replace :root to :host as extension renders to shadow dom.
|
||||
"postcss-selector-replace",
|
||||
{ before: [":root"], after: [":host"] },
|
||||
],
|
||||
[
|
||||
// Replace :root to :host as extension renders to shadow dom.
|
||||
"postcss-rem-to-pixel",
|
||||
{
|
||||
rootValue: 1,
|
||||
unitPrecision: 5,
|
||||
propList: ["*", "border-radius"],
|
||||
replace: true,
|
||||
mediaQuery: true
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: /(node_modules)/,
|
||||
use: {
|
||||
loader: "swc-loader",
|
||||
options: {
|
||||
// This makes swc-loader invoke swc synchronously.
|
||||
sync: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
react: path.resolve(__dirname, ".", "node_modules", "react"),
|
||||
"react-dom": path.resolve(__dirname, ".", "node_modules", "react-dom"),
|
||||
src: path.resolve(__dirname),
|
||||
build: path.resolve(__dirname, "./build"),
|
||||
},
|
||||
extensions: [".mjs", ".js", ".wasm", ".tsx", ".d.ts", ".ts", ".json"],
|
||||
fallback: {
|
||||
// Fix for:
|
||||
// BREAKING CHANGE: webpack < 5 used to include polyfills for node.js core modules by default.
|
||||
// This is no longer the case. Verify if you need this module and configure a polyfill for it.
|
||||
crypto: require.resolve("crypto-browserify"),
|
||||
stream: require.resolve("stream-browserify"),
|
||||
buffer: "buffer",
|
||||
},
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, "./build"),
|
||||
filename: "[name].js",
|
||||
},
|
||||
});
|
3
hackage-ui/.gitignore
vendored
@ -35,3 +35,6 @@ yarn-error.log*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Global styles across other modules
|
||||
/public/styles
|
||||
|
16
hackage-ui/Makefile
Normal file
@ -0,0 +1,16 @@
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: dev
|
||||
dev: ## Start development.
|
||||
npm run dev
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the project.
|
||||
cd ../react-lib && make build && npm link
|
||||
npm link @hackage-ui/react-lib
|
||||
npm i
|
||||
npm run build
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
@ -1,17 +0,0 @@
|
||||
.toastContainer {
|
||||
max-width: calc(100vw - 24rem);
|
||||
padding: 8rem;
|
||||
}
|
||||
.toast {
|
||||
font-family: 'Fira Sans';
|
||||
border-radius: 8rem;
|
||||
box-shadow: 0rem 2rem 4rem rgb(0 0 0 / 27%);
|
||||
font-size: 14rem;
|
||||
margin-bottom: 8rem;
|
||||
--toastify-icon-color-success: var(--purple-color-2)
|
||||
}
|
||||
|
||||
.toastBody {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
@ -1,135 +0,0 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import s from './AppContext.module.css';
|
||||
import { Analytics, AnalyticsState } from './analytics';
|
||||
export type SearchHistory = string[];
|
||||
|
||||
export type AppContextValue = {
|
||||
notifySuccess: (content: ReactNode) => void,
|
||||
notifyError: (content: ReactNode) => void,
|
||||
tasks: Record<string, string | undefined>,
|
||||
startTask: (id: string, comment?: string) => void,
|
||||
finishTask: (id: string) => void,
|
||||
writeSearchHistoryEntry: (query: string) => void
|
||||
readSearchHistory: () => SearchHistory,
|
||||
removeSearchHistoryEntry: (query: string) => void,
|
||||
purgeSearchHistory: () => void,
|
||||
analytics?: AnalyticsState,
|
||||
}
|
||||
|
||||
const defaultAppContextValue: AppContextValue = {
|
||||
notifySuccess: () => { },
|
||||
notifyError: () => { },
|
||||
tasks: {},
|
||||
startTask: () => { },
|
||||
finishTask: () => { },
|
||||
writeSearchHistoryEntry: () => { },
|
||||
readSearchHistory: () => [],
|
||||
removeSearchHistoryEntry: () => [],
|
||||
purgeSearchHistory: () => { },
|
||||
analytics: undefined
|
||||
}
|
||||
|
||||
const AppContext = React.createContext<AppContextValue>(defaultAppContextValue);
|
||||
|
||||
export const DefaultAppContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [value, setValue] = useState<AppContextValue>(defaultAppContextValue);
|
||||
|
||||
const notifySuccess = useCallback((content: ReactNode) => toast.success(content), []);
|
||||
const notifyError = useCallback((content: ReactNode) => {
|
||||
value.analytics?.gtag('event', 'error', {
|
||||
category: value.analytics.categories.issues,
|
||||
label: content?.toString() || 'unknown error',
|
||||
});
|
||||
|
||||
toast.error(content);
|
||||
}, [value.analytics]);
|
||||
|
||||
const startTask = useCallback((id: string, comment?: string) => {
|
||||
setValue({
|
||||
...value,
|
||||
tasks: { ...value.tasks, [id]: comment }
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
const searchHistoryLsKey = 'searchHistory';
|
||||
const readSearchHistory = useCallback(() => {
|
||||
let searchHistory: SearchHistory = [];
|
||||
const jsonStr = localStorage.getItem(searchHistoryLsKey);
|
||||
|
||||
try {
|
||||
searchHistory = jsonStr === null ? [] : JSON.parse(jsonStr);
|
||||
|
||||
if (searchHistory && !Array.isArray(searchHistory)) {
|
||||
throw new Error('Search history is stored in wrong format. It should be an array of strings.');
|
||||
}
|
||||
} catch (_) {
|
||||
const historyBackupKey = `${searchHistoryLsKey}_backup_${new Date().toISOString()}`
|
||||
localStorage.setItem(historyBackupKey, jsonStr || '[]');
|
||||
notifyError(`Your search history is corrupted. You can get a backup by running 'localStorage.getItem(${historyBackupKey})' in browser console.`);
|
||||
} finally {
|
||||
return searchHistory;
|
||||
}
|
||||
}, [notifyError]);
|
||||
|
||||
const writeSearchHistoryEntry = useCallback((query: string) => {
|
||||
const searchHistory = readSearchHistory();
|
||||
const newSearchHistory = Array.from(new Set([query].concat(searchHistory).slice(0, 1000)));
|
||||
localStorage.setItem(searchHistoryLsKey, JSON.stringify(newSearchHistory));
|
||||
}, [readSearchHistory]);
|
||||
|
||||
const removeSearchHistoryEntry = useCallback((query: string) => {
|
||||
const searchHistory = readSearchHistory();
|
||||
const newSearchHistory = searchHistory.filter(entry => entry !== query);
|
||||
localStorage.setItem(searchHistoryLsKey, JSON.stringify(newSearchHistory));
|
||||
}, [readSearchHistory]);
|
||||
|
||||
const purgeSearchHistory = useCallback(() => {
|
||||
localStorage.removeItem(searchHistoryLsKey);
|
||||
}, []);
|
||||
|
||||
const finishTask = useCallback((id: string) => {
|
||||
let tasks = { ...value.tasks };
|
||||
delete tasks[id];
|
||||
|
||||
setValue({ ...value, tasks });
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Analytics onChange={(analytics) => setValue({ ...value, analytics })} />
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
...value,
|
||||
notifySuccess,
|
||||
notifyError,
|
||||
startTask,
|
||||
finishTask,
|
||||
readSearchHistory,
|
||||
writeSearchHistoryEntry,
|
||||
purgeSearchHistory,
|
||||
removeSearchHistoryEntry
|
||||
}}
|
||||
>
|
||||
<ToastContainer
|
||||
position="top-right"
|
||||
autoClose={3000}
|
||||
newestOnTop={true}
|
||||
hideProgressBar={true}
|
||||
closeOnClick
|
||||
pauseOnFocusLoss
|
||||
draggable={false}
|
||||
pauseOnHover
|
||||
className={s.toastContainer}
|
||||
toastClassName={s.toast}
|
||||
bodyClassName={s.toastBody}
|
||||
/>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
export default AppContext;
|
@ -1,7 +1,6 @@
|
||||
import { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode, useContext } from 'react';
|
||||
import s from './Button.module.css';
|
||||
import { ExtA } from '../layout/A';
|
||||
import AppContext from '../AppContext';
|
||||
import * as lib from '@hackage-ui/react-lib';
|
||||
|
||||
export type ButtonProps = {
|
||||
children: ReactNode,
|
||||
@ -14,10 +13,10 @@ export type ButtonProps = {
|
||||
} & { analytics: { featureName: string, eventParams: Gtag.EventParams } };
|
||||
|
||||
const Button = (props: ButtonProps) => {
|
||||
const appContext = useContext(AppContext);
|
||||
const appContext = useContext(lib.appContext.AppContext);
|
||||
|
||||
return props.href ? (
|
||||
<ExtA
|
||||
<lib.links.ExtA
|
||||
className={`${s[props.type]} ${props.kind === 'danger' ? s.danger : ''}`}
|
||||
href={props.href}
|
||||
tabIndex={props.tabIndex}
|
||||
@ -25,7 +24,7 @@ const Button = (props: ButtonProps) => {
|
||||
{...props.overrides as AnchorHTMLAttributes<HTMLAnchorElement>}
|
||||
>
|
||||
{props.children}
|
||||
</ExtA>
|
||||
</lib.links.ExtA>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
|
@ -1,8 +1,8 @@
|
||||
import AppContext from '../AppContext';
|
||||
import { useContext } from 'react';
|
||||
import contentCopyIcon from '!!raw-loader!../icons/content-copy.svg';
|
||||
import SvgIcon from '../icons/SVGIcon';
|
||||
import s from './CopyButton.module.css';
|
||||
import * as lib from '@hackage-ui/react-lib';
|
||||
|
||||
export type CopyButtonProps = {
|
||||
analyticsId: string,
|
||||
@ -11,7 +11,7 @@ export type CopyButtonProps = {
|
||||
}
|
||||
|
||||
export const CopyButton = (props: CopyButtonProps) => {
|
||||
const appContext = useContext(AppContext);
|
||||
const appContext = useContext(lib.appContext.AppContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -12,7 +12,7 @@
|
||||
border: none;
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
font-size: 14px;
|
||||
font-size: 14rem;
|
||||
color: var(--text-color);
|
||||
caret-color: var(--text-color);
|
||||
transition: var(--transition-short);
|
||||
|
5
hackage-ui/components/icons/discourse-monochrome.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg focusable="false" viewBox="0 0 64 64" tabindex="-1">
|
||||
<path d="M32.58 12.17c-6.867.004-13.225 3.62-16.738 9.52s-3.663 13.213-.395 19.252L11.92 52.286l12.663-2.86a19.49 19.49 0 1 0 8.015-37.256z" />
|
||||
<path d="M44.454 16.208c6.114 6.482 7.05 16.286 2.274 23.81s-14.047 10.846-22.514 8.07L11.92 52.292l12.663-2.867c9.02 4.074 19.66.74 24.74-7.754s2.986-19.445-4.87-25.464z" />
|
||||
<path d="M32.27 0C14.9 0 .293 14.075.293 31.442V64l31.972-.03c17.36 0 31.442-14.617 31.442-31.978S49.614 0 32.27 0z" />
|
||||
</svg>
|
After Width: | Height: | Size: 510 B |
@ -1,5 +1,8 @@
|
||||
<svg focusable="false" viewBox="0 0 64 64" tabindex="-1">
|
||||
<path d="M32.58 12.17c-6.867.004-13.225 3.62-16.738 9.52s-3.663 13.213-.395 19.252L11.92 52.286l12.663-2.86a19.49 19.49 0 1 0 8.015-37.256z" />
|
||||
<path d="M44.454 16.208c6.114 6.482 7.05 16.286 2.274 23.81s-14.047 10.846-22.514 8.07L11.92 52.292l12.663-2.867c9.02 4.074 19.66.74 24.74-7.754s2.986-19.445-4.87-25.464z" />
|
||||
<path d="M32.27 0C14.9 0 .293 14.075.293 31.442V64l31.972-.03c17.36 0 31.442-14.617 31.442-31.978S49.614 0 32.27 0z" />
|
||||
<path d="M32.27 0C14.9 0 .293 14.075.293 31.442V64l31.972-.03c17.36 0 31.442-14.617 31.442-31.978S49.614 0 32.27 0z" fill="#231f20" />
|
||||
<path d="M32.58 12.17c-6.867.004-13.225 3.62-16.738 9.52s-3.663 13.213-.395 19.252L11.92 52.286l12.663-2.86a19.49 19.49 0 1 0 8.015-37.256z" fill="#fff9ae" />
|
||||
<path d="M48.042 19.802c5.404 7.085 5.312 16.934-.224 23.917s-15.103 9.32-23.235 5.676L11.92 52.292l12.89-1.523c8.546 5.006 19.488 2.804 25.43-5.12s4.996-19.044-2.2-25.848z" fill="#00aeef" />
|
||||
<path d="M44.454 16.208c6.114 6.482 7.05 16.286 2.274 23.81s-14.047 10.846-22.514 8.07L11.92 52.292l12.663-2.867c9.02 4.074 19.66.74 24.74-7.754s2.986-19.445-4.87-25.464z" fill="#00a94f" />
|
||||
<path d="M16.612 41.374a19.49 19.49 0 0 1 31.442-21.578 19.49 19.49 0 0 0-32.607 21.146L11.92 52.286z" fill="#f15d22" />
|
||||
<path d="M15.447 40.942a19.49 19.49 0 0 1 29.007-24.734A19.49 19.49 0 0 0 14.244 40.64l-2.318 11.652z" fill="#e31b23" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 510 B After Width: | Height: | Size: 995 B |
5
hackage-ui/components/icons/haskell-monochrome.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80" viewBox="0 0 120 80">
|
||||
<path d="M1.842 77.722L26.586 40.63 1.842 3.537H20.4L45.144 40.63 20.4 77.722H1.842zm0 0" />
|
||||
<path d="M26.586 77.722L51.33 40.63 26.586 3.537h18.558L94.63 77.722H76.074L60.61 54.54 45.143 77.722H26.586zm0 0" />
|
||||
<path d="M86.384 56.085L78.136 43.72h28.868v12.366h-20.62zM74.012 37.54l-8.248-12.365h41.24V37.54H74.012zm0 0" />
|
||||
</svg>
|
After Width: | Height: | Size: 424 B |
3
hackage-ui/components/icons/reddit.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 800 800">
|
||||
<circle cx="400" cy="400" fill="#ff4500" r="400"/><path d="M666.8 400c.08 5.48-.6 10.95-2.04 16.24s-3.62 10.36-6.48 15.04c-2.85 4.68-6.35 8.94-10.39 12.65s-8.58 6.83-13.49 9.27c.11 1.46.2 2.93.25 4.4a107.268 107.268 0 0 1 0 8.8c-.05 1.47-.14 2.94-.25 4.4 0 89.6-104.4 162.4-233.2 162.4S168 560.4 168 470.8c-.11-1.46-.2-2.93-.25-4.4a107.268 107.268 0 0 1 0-8.8c.05-1.47.14-2.94.25-4.4a58.438 58.438 0 0 1-31.85-37.28 58.41 58.41 0 0 1 7.8-48.42 58.354 58.354 0 0 1 41.93-25.4 58.4 58.4 0 0 1 46.52 15.5 286.795 286.795 0 0 1 35.89-20.71c12.45-6.02 25.32-11.14 38.51-15.3s26.67-7.35 40.32-9.56 27.45-3.42 41.28-3.63L418 169.6c.33-1.61.98-3.13 1.91-4.49.92-1.35 2.11-2.51 3.48-3.4 1.38-.89 2.92-1.5 4.54-1.8 1.61-.29 3.27-.26 4.87.09l98 19.6c9.89-16.99 30.65-24.27 48.98-17.19s28.81 26.43 24.71 45.65c-4.09 19.22-21.55 32.62-41.17 31.61-19.63-1.01-35.62-16.13-37.72-35.67L440 186l-26 124.8c13.66.29 27.29 1.57 40.77 3.82a284.358 284.358 0 0 1 77.8 24.86A284.412 284.412 0 0 1 568 360a58.345 58.345 0 0 1 29.4-15.21 58.361 58.361 0 0 1 32.95 3.21 58.384 58.384 0 0 1 25.91 20.61A58.384 58.384 0 0 1 666.8 400zm-396.96 55.31c2.02 4.85 4.96 9.26 8.68 12.97 3.71 3.72 8.12 6.66 12.97 8.68A40.049 40.049 0 0 0 306.8 480c16.18 0 30.76-9.75 36.96-24.69 6.19-14.95 2.76-32.15-8.68-43.59s-28.64-14.87-43.59-8.68c-14.94 6.2-24.69 20.78-24.69 36.96 0 5.25 1.03 10.45 3.04 15.31zm229.1 96.02c2.05-2 3.22-4.73 3.26-7.59.04-2.87-1.07-5.63-3.07-7.68s-4.73-3.22-7.59-3.26c-2.87-.04-5.63 1.07-7.94 2.8a131.06 131.06 0 0 1-19.04 11.35 131.53 131.53 0 0 1-20.68 7.99c-7.1 2.07-14.37 3.54-21.72 4.39-7.36.85-14.77 1.07-22.16.67-7.38.33-14.78.03-22.11-.89a129.01 129.01 0 0 1-21.64-4.6c-7.08-2.14-13.95-4.88-20.56-8.18s-12.93-7.16-18.89-11.53c-2.07-1.7-4.7-2.57-7.38-2.44s-5.21 1.26-7.11 3.15c-1.89 1.9-3.02 4.43-3.15 7.11s.74 5.31 2.44 7.38c7.03 5.3 14.5 9.98 22.33 14s16 7.35 24.4 9.97 17.01 4.51 25.74 5.66c8.73 1.14 17.54 1.53 26.33 1.17 8.79.36 17.6-.03 26.33-1.17A153.961 153.961 0 0 0 476.87 564c7.83-4.02 15.3-8.7 22.33-14zm-7.34-68.13c5.42.06 10.8-.99 15.81-3.07 5.01-2.09 9.54-5.17 13.32-9.06s6.72-8.51 8.66-13.58A39.882 39.882 0 0 0 532 441.6c0-16.18-9.75-30.76-24.69-36.96-14.95-6.19-32.15-2.76-43.59 8.68s-14.87 28.64-8.68 43.59c6.2 14.94 20.78 24.69 36.96 24.69z" fill="#fff"/>
|
||||
</svg>
|
After Width: | Height: | Size: 2.3 KiB |
@ -2,12 +2,12 @@ import s from './Footer.module.css';
|
||||
import SvgIcon from '../icons/SVGIcon';
|
||||
import githubIcon from '!!raw-loader!../icons/github.svg';
|
||||
import twitterIcon from '!!raw-loader!../icons/twitter.svg';
|
||||
import discourseIcon from '!!raw-loader!../icons/discourse.svg';
|
||||
import { ExtA } from '../layout/A';
|
||||
import discourseIcon from '!!raw-loader!../icons/discourse-monochrome.svg';
|
||||
import * as lib from '@hackage-ui/react-lib';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
const FooterLink = (props: { href: string, children: ReactNode }) => {
|
||||
return <ExtA {...props} analytics={{ featureName: 'FooterLink', eventParams: { screen_name: 'All' } }} />
|
||||
return <lib.links.ExtA {...props} analytics={{ featureName: 'FooterLink', eventParams: { screen_name: 'All' } }} />
|
||||
}
|
||||
|
||||
const Footer = () => {
|
||||
|
@ -1,10 +1,3 @@
|
||||
.menuItem,
|
||||
.menuItems {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@ -12,6 +5,7 @@
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 16rem;
|
||||
|
||||
background-color: var(--purple-color-2);
|
||||
transition: var(--transition-short);
|
||||
@ -54,6 +48,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.menuItem,
|
||||
.menuItems {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.menuItems {
|
||||
display: flex;
|
||||
}
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { useEffect, useState, useContext } from 'react';
|
||||
import s from './GlobalMenu.module.css';
|
||||
import Logo from '../branding/Logo';
|
||||
import SearchInput from '../search/SearchInput';
|
||||
import AppContext from '../AppContext';
|
||||
import * as lib from '@hackage-ui/react-lib';
|
||||
import { SettingsButton } from '../forms/Settings';
|
||||
import A from './A';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const heightPx = 60;
|
||||
|
||||
@ -18,13 +17,14 @@ type Props = {
|
||||
|
||||
export const defaultMenuProps: Props = {
|
||||
items: [
|
||||
{ id: 'propose-an-idea', href: 'https://github.com/visortelle/hackage-ui/issues/1', title: 'Propose an idea' },
|
||||
{ id: 'propose-an-idea', href: 'https://github.com/visortelle/hackage-ui/issues/1', title: 'Propose an Idea' },
|
||||
]
|
||||
};
|
||||
|
||||
const GlobalMenu = (props: Props) => {
|
||||
const router = useRouter();
|
||||
const [atTop, setAtTop] = useState(false);
|
||||
const appContext = useContext(AppContext);
|
||||
const appContext = useContext(lib.appContext.AppContext);
|
||||
|
||||
function handleScroll(): void {
|
||||
let scrollY = window.scrollY;
|
||||
@ -57,16 +57,21 @@ const GlobalMenu = (props: Props) => {
|
||||
>
|
||||
<div className={`${s.progressIndicator} ${Object.keys(appContext.tasks).length > 0 ? s.progressIndicatorRunning : ''}`}></div>
|
||||
<div className={s.content}>
|
||||
<A href="/" className={s.logo} analytics={{ featureName: 'GlobalMenuLogo', eventParams: { screen_name: 'All' } }}>
|
||||
<lib.links.A href="/" className={s.logo} analytics={{ featureName: 'GlobalMenuLogo', eventParams: { screen_name: 'All' } }}>
|
||||
<Logo fontSize={18} />
|
||||
</A>
|
||||
</lib.links.A>
|
||||
|
||||
<div className={s.searchInput}>
|
||||
<SearchInput
|
||||
<lib.searchInput.SearchInput
|
||||
key={
|
||||
// Refresh search input state on each location change
|
||||
(global as any)?.document ? (document.location.origin + document.location.pathname) : '_'
|
||||
}
|
||||
router={router}
|
||||
api={{
|
||||
hackageApiUrl: '/api/hackage',
|
||||
hoogleApiUrl: '/api/hoogle'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -75,7 +80,7 @@ const GlobalMenu = (props: Props) => {
|
||||
{props.items.map(item => {
|
||||
return (
|
||||
<li key={item.id} className={s.menuItem}>
|
||||
<A className={s.menuItemLink} href={item.href} analytics={{ featureName: `GlobalMenuItem`, eventParams: { screen_name: 'All' } }}>{item.title}</A>
|
||||
<lib.links.A className={s.menuItemLink} href={item.href} analytics={{ featureName: `GlobalMenuItem`, eventParams: { screen_name: 'All' } }}>{item.title}</lib.links.A>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
@ -60,12 +60,12 @@
|
||||
}
|
||||
|
||||
.packageListsHeader {
|
||||
width: 100vw;
|
||||
max-width: var(--max-content-width);
|
||||
margin: auto;
|
||||
margin-bottom: 24rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.packageListsHeaderLink {
|
||||
@ -78,7 +78,8 @@
|
||||
background-color: var(--purple-color-2);
|
||||
color: #fff;
|
||||
padding: 8rem 18rem;
|
||||
margin-left: 24rem;
|
||||
margin-right: 12rem;
|
||||
margin-bottom: 4rem;
|
||||
box-shadow: 0rem 2rem 4rem rgb(0 0 0 / 27%);
|
||||
}
|
||||
|
||||
@ -90,7 +91,6 @@
|
||||
width: 18rem;
|
||||
height: 18rem;
|
||||
margin-right: 8rem;
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.packageList {
|
||||
|
@ -1,15 +1,16 @@
|
||||
import GlobalMenu, { defaultMenuProps } from "../../layout/GlobalMenu";
|
||||
import s from './HomePage.module.css';
|
||||
import SidebarButton from "../../forms/SidebarButton"; // Temporary here.
|
||||
import GitHubIcon from '!!raw-loader!../../icons/github.svg';
|
||||
import TwitterIcon from '!!raw-loader!../../icons/twitter.svg';
|
||||
import DiscourseIcon from '!!raw-loader!../../icons/discourse.svg';
|
||||
import Footer from "../../layout/Footer";
|
||||
import SvgIcon from "../../icons/SVGIcon";
|
||||
import gitHubIcon from '!!raw-loader!../../icons/github.svg';
|
||||
import twitterIcon from '!!raw-loader!../../icons/twitter.svg';
|
||||
import discourseIcon from '!!raw-loader!../../icons/discourse.svg';
|
||||
import redditIcon from '!!raw-loader!../../icons/reddit.svg';
|
||||
import haskellMonochromeIcon from '!!raw-loader!../../icons/haskell-monochrome.svg';
|
||||
import Footer from "../../layout/Footer";
|
||||
import VerticalList, { Item } from "../../widgets/VerticalList";
|
||||
import AppContext from "../../AppContext";
|
||||
import * as lib from "@hackage-ui/react-lib";
|
||||
import { useContext, useEffect } from "react";
|
||||
import { ExtA } from "../../layout/A";
|
||||
|
||||
export type HomeProps = {
|
||||
editorsPick: Item[]
|
||||
@ -29,7 +30,8 @@ export type HomeProps = {
|
||||
const screenName = 'HomePage';
|
||||
|
||||
const Home = (props: HomeProps) => {
|
||||
const appContext = useContext(AppContext);
|
||||
const appContext = useContext(lib.appContext.AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
appContext.analytics?.gtag('event', 'screen_view', { screen_name: screenName });
|
||||
}, []);
|
||||
@ -47,7 +49,7 @@ const Home = (props: HomeProps) => {
|
||||
onClick={() => { }} href="https://github.com/visortelle/hackage-ui/issues/"
|
||||
overrides={{ style: { flex: 'initial', backgroundColor: 'var(--text-color)', marginBottom: '12rem', justifyContent: 'flex-start', padding: '12rem 24rem', fontSize: '18rem', marginRight: '24rem' } }}
|
||||
>
|
||||
<SvgIcon svg={GitHubIcon} />
|
||||
<SvgIcon svg={gitHubIcon} />
|
||||
<div>Contribute on GitHub</div>
|
||||
</SidebarButton>
|
||||
|
||||
@ -55,7 +57,7 @@ const Home = (props: HomeProps) => {
|
||||
onClick={() => { }} href="https://twitter.com/HackageUI"
|
||||
overrides={{ style: { flex: 'initial', backgroundColor: '#00ACEE', marginBottom: '12rem', justifyContent: 'flex-start', padding: '12rem 24rem', fontSize: '18rem' } }}
|
||||
>
|
||||
<SvgIcon svg={TwitterIcon} />
|
||||
<SvgIcon svg={twitterIcon} />
|
||||
<div>Follow us on Twitter</div>
|
||||
</SidebarButton>
|
||||
</div>
|
||||
@ -66,11 +68,20 @@ const Home = (props: HomeProps) => {
|
||||
<div className={s.content}>
|
||||
|
||||
<h2 className={s.packageListsHeader}>
|
||||
Community
|
||||
<ExtA href="https://discourse.haskell.org/" analytics={{ featureName: 'GoToDiscourse', eventParams: {} }} className={s.packageListsHeaderLink}>
|
||||
<div className={s.packageListsHeaderIcon}><SvgIcon svg={DiscourseIcon} /></div>discourse.haskell.org
|
||||
</ExtA>
|
||||
Community
|
||||
<lib.links.ExtA href="https://haskell.foundation/" analytics={{ featureName: 'GoToHaskellFoundation', eventParams: {} }} className={s.packageListsHeaderLink}>
|
||||
<div className={s.packageListsHeaderIcon} style={{ fill: '#fff' }}><SvgIcon svg={haskellMonochromeIcon} /></div>Haskell Foundation
|
||||
</lib.links.ExtA>
|
||||
|
||||
<lib.links.ExtA href="https://discourse.haskell.org/" analytics={{ featureName: 'GoToDiscourse', eventParams: {} }} className={s.packageListsHeaderLink} style={{ background: '#fff', color: 'var(--text-color)' }}>
|
||||
<div className={s.packageListsHeaderIcon} style={{ fill: 'var(--text-color)' }}><SvgIcon svg={discourseIcon} /></div>Discourse
|
||||
</lib.links.ExtA>
|
||||
|
||||
<lib.links.ExtA href="https://www.reddit.com/r/haskell" analytics={{ featureName: 'GoToReddit', eventParams: {} }} className={s.packageListsHeaderLink} style={{ background: '#fff', color: 'var(--text-color)' }}>
|
||||
<div className={s.packageListsHeaderIcon} style={{ fill: 'var(--text-color)' }}><SvgIcon svg={redditIcon} /></div>Reddit
|
||||
</lib.links.ExtA>
|
||||
</h2>
|
||||
|
||||
<div className={s.packageLists}>
|
||||
|
||||
<div className={s.packageList}>
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useContext } from "react";
|
||||
import AppContext from "../../AppContext";
|
||||
import s from './Sidebar.module.css';
|
||||
import SvgIcon from "../../icons/SVGIcon";
|
||||
import CopyButton from "../../forms/CopyButton";
|
||||
@ -11,8 +10,8 @@ import repositoryIcon from '!!raw-loader!../../icons/github.svg';
|
||||
import bugReportIcon from '!!raw-loader!../../icons/bug-report.svg';
|
||||
import updatedAtIcon from '!!raw-loader!../../icons/updated-at.svg';
|
||||
import SidebarButton from "../../forms/SidebarButton";
|
||||
import { ExtA } from "../../layout/A";
|
||||
import { PackageProps } from './common';
|
||||
import * as lib from '@hackage-ui/react-lib';
|
||||
|
||||
const tooltipId = 'package-sidebar-tooltip';
|
||||
|
||||
@ -22,7 +21,7 @@ type SidebarProps = {
|
||||
}
|
||||
|
||||
export const Sidebar = (props: SidebarProps) => {
|
||||
const appContext = useContext(AppContext);
|
||||
const appContext = useContext(lib.appContext.AppContext);
|
||||
const repository = props.package.repositoryUrl ? parseRepositoryUrl(props.package.repositoryUrl) : null;
|
||||
const copyToInstall = `${props.package.name} >= ${props.package.versions.current}`;
|
||||
|
||||
@ -58,9 +57,9 @@ export const Sidebar = (props: SidebarProps) => {
|
||||
>
|
||||
<div className={s.sidebarEntryIcon}><SvgIcon svg={licenseIcon} /></div>
|
||||
{props.package.license?.url ? (
|
||||
<ExtA style={{ color: 'inherit' }} href={props.package.license.url} analytics={{ featureName: 'PackageLicenseLink', eventParams: { screen_name: props.analytics.screenName } }}>
|
||||
<lib.links.ExtA style={{ color: 'inherit' }} href={props.package.license.url} analytics={{ featureName: 'PackageLicenseLink', eventParams: { screen_name: props.analytics.screenName } }}>
|
||||
{props.package.license.name}
|
||||
</ExtA>
|
||||
</lib.links.ExtA>
|
||||
) : (
|
||||
<span>{props.package.license.name}</span>
|
||||
)}
|
||||
@ -84,13 +83,13 @@ export const Sidebar = (props: SidebarProps) => {
|
||||
</h3>
|
||||
<div className={s.sidebarEntry}>
|
||||
<div className={s.sidebarEntryIcon}><SvgIcon svg={homepageIcon} /></div>
|
||||
<ExtA
|
||||
<lib.links.ExtA
|
||||
className={s.sidebarEntryLink}
|
||||
href={props.package.homepage.url}
|
||||
analytics={{ featureName: 'GoToPackageHomepage', eventParams: { screen_name: props.analytics.screenName } }}
|
||||
>
|
||||
{props.package.homepage.text.replace(/^https?\:\/\//, '').replace(/\/$/, '')}
|
||||
</ExtA>
|
||||
</lib.links.ExtA>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ -110,12 +109,12 @@ export const Sidebar = (props: SidebarProps) => {
|
||||
|
||||
{repository.browserUrl && repository.kind === 'unknown' && (
|
||||
<div className={s.sidebarEntry}>
|
||||
<ExtA
|
||||
<lib.links.ExtA
|
||||
href={repository.browserUrl}
|
||||
analytics={{ featureName: 'GoToPackageRepository', eventParams: { screen_name: props.analytics.screenName } }}
|
||||
>
|
||||
{repository.displayText}
|
||||
</ExtA>
|
||||
</lib.links.ExtA>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import s from './VerticalList.module.css';
|
||||
import ArrowRightIcon from '!!raw-loader!../../components/icons/arrow-right.svg';
|
||||
import SvgIcon from '../icons/SVGIcon';
|
||||
import A, { ExtA } from '../layout/A';
|
||||
import * as lib from '@hackage-ui/react-lib';
|
||||
|
||||
export type Item = {
|
||||
title: string,
|
||||
@ -18,7 +18,7 @@ export type Props = {
|
||||
}
|
||||
|
||||
const VerticalList = (props: Props) => {
|
||||
const Link = props.linksType === 'external' ? ExtA : A;
|
||||
const Link = props.linksType === 'external' ? lib.links.ExtA : lib.links.A;
|
||||
|
||||
return (
|
||||
<div className={s.verticalList}>
|
||||
|
@ -1,3 +1,5 @@
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
async rewrites() {
|
||||
@ -13,4 +15,35 @@ module.exports = {
|
||||
];
|
||||
},
|
||||
reactStrictMode: true,
|
||||
webpack: (_config) => {
|
||||
let config = fixMultipleReactInstancesIssue(_config);
|
||||
|
||||
return {
|
||||
...config,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Fixes:
|
||||
//
|
||||
// Unhandled Runtime Error
|
||||
// Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
|
||||
// 1. You might have mismatching versions of React and the renderer (such as React DOM)
|
||||
// 2. You might be breaking the Rules of Hooks
|
||||
// 3. You might have more than one copy of React in the same app
|
||||
// See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.
|
||||
function fixMultipleReactInstancesIssue(config) {
|
||||
config.resolve.alias["react"] = path.resolve(
|
||||
__dirname,
|
||||
".",
|
||||
"node_modules",
|
||||
"react"
|
||||
);
|
||||
config.resolve.alias["react-dom"] = path.resolve(
|
||||
__dirname,
|
||||
".",
|
||||
"node_modules",
|
||||
"react-dom"
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
13650
hackage-ui/package-lock.json
generated
@ -2,10 +2,11 @@
|
||||
"name": "hackage-ui",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"dev": "npm run prepare && next dev",
|
||||
"build": "npm run prepare && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"prepare": "cp -r ../styles ./public/"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^0.24.0",
|
||||
@ -19,16 +20,19 @@
|
||||
"react-toastify": "^8.1.0",
|
||||
"react-tooltip": "^4.2.21",
|
||||
"timeago.js": "^4.0.2",
|
||||
"use-debounce": "^7.0.1"
|
||||
"use-debounce": "^7.0.1",
|
||||
"@hackage-ui/react-lib": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/gtag.js": "0.0.8",
|
||||
"@types/lodash": "^4.14.178",
|
||||
"@types/node": "17.0.4",
|
||||
"@types/react": "17.0.38",
|
||||
"@types/react": "17.0.2",
|
||||
"eslint": "8.5.0",
|
||||
"eslint-config-next": "12.0.7",
|
||||
"raw-loader": "^4.0.2",
|
||||
"typescript": "4.5.4"
|
||||
"rollup-plugin-string": "^3.0.0",
|
||||
"typescript": "4.5.4",
|
||||
"webextension-polyfill": "^0.8.0"
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
import type { AppProps } from 'next/app'
|
||||
import Head from 'next/head'
|
||||
import { DefaultAppContextProvider } from '../components/AppContext';
|
||||
import { appContext } from '@hackage-ui/react-lib';
|
||||
import '@hackage-ui/react-lib/dist/react-lib.css';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <>
|
||||
@ -8,9 +10,9 @@ function MyApp({ Component, pageProps }: AppProps) {
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"></meta>
|
||||
</Head>
|
||||
|
||||
<DefaultAppContextProvider>
|
||||
<appContext.DefaultAppContextProvider useNextJSRouting={true}>
|
||||
<Component {...pageProps} />
|
||||
</DefaultAppContextProvider>
|
||||
</appContext.DefaultAppContextProvider>
|
||||
</>
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||
import { gaTrackingId } from '../components/analytics';
|
||||
import * as lib from '@hackage-ui/react-lib';
|
||||
|
||||
class MyDocument extends Document {
|
||||
render() {
|
||||
@ -10,14 +10,14 @@ class MyDocument extends Document {
|
||||
<meta charSet="utf-8" />
|
||||
|
||||
{/* Global Site Tag (gtag.js) - Google Analytics */}
|
||||
<script async src={`https://www.googletagmanager.com/gtag/js?id=${gaTrackingId}`} />
|
||||
<script async src={`https://www.googletagmanager.com/gtag/js?id=${lib.analytics.gaTrackingId}`} />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${gaTrackingId}', { send_page_view: false, cookie_flags: 'SameSite=None;Secure' })
|
||||
gtag('config', '${lib.analytics.gaTrackingId}', { send_page_view: false, cookie_flags: 'SameSite=None;Secure' })
|
||||
gtag('consent', 'default', { ad_storage: 'denied', analytics_storage: 'granted' });
|
||||
`}}
|
||||
/>
|
||||
|
@ -56,14 +56,23 @@ export async function getStaticProps(): Promise<GetStaticPropsResult<HomeProps>>
|
||||
},
|
||||
editorsPick: [
|
||||
{
|
||||
title: 'State of the Haskell ecosystem',
|
||||
href: 'https://github.com/Gabriel439/post-rfc/blob/main/sotu.md',
|
||||
description: 'by Gabriella Gonzalez',
|
||||
title: '💪 Became a Haskell Foundation volunteer!',
|
||||
href: 'https://github.com/haskellfoundation/volunteering/issues/new?assignees=&labels=Volunteer+Available&template=volunteer-available.yml',
|
||||
description: 'Find a Haskell project opportunity for you!',
|
||||
},
|
||||
|
||||
{
|
||||
title: '🛠 Announcement for the Compiler Tooling Task Force',
|
||||
href: 'https://discourse.haskell.org/t/announcement-for-the-compiler-tooling-task-force/3893'
|
||||
},
|
||||
{
|
||||
title: '🧙♂️ Mentor for beginner Haskeller available',
|
||||
href: 'https://github.com/haskellfoundation/volunteering/issues/8',
|
||||
description: 'One one hour session per month, 30 minutes of code review per week.',
|
||||
title: '🔐 A new future for cryptography in Haskell',
|
||||
href: 'https://discourse.haskell.org/t/a-new-future-for-cryptography-in-haskell/3888/28'
|
||||
},
|
||||
{
|
||||
title: '📃 State of the Haskell ecosystem',
|
||||
href: 'https://github.com/Gabriel439/post-rfc/blob/main/sotu.md',
|
||||
description: 'by Gabriella Gonzalez',
|
||||
},
|
||||
{
|
||||
title: 'Volunteer Available. Frontend, DevOps. ',
|
||||
|
@ -59,7 +59,7 @@ export async function getStaticProps(props: GetStaticPropsContext): Promise<GetS
|
||||
export async function getStaticPaths() {
|
||||
return {
|
||||
paths: [],
|
||||
fallback: 'blocking',
|
||||
fallback: 'blocking'
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -2,8 +2,13 @@
|
||||
min-height: inherit;
|
||||
}
|
||||
|
||||
:root {
|
||||
:root,
|
||||
:host {
|
||||
font-size: 1px;
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
color: var(--text-color);
|
||||
line-height: 1.4;
|
||||
font-variant-ligatures: none;
|
||||
--background-color: #f6eedc;
|
||||
--background-color-backdrop: rgba(94, 80, 134, 0.8);
|
||||
--surface-color: #dfd8c7;
|
||||
@ -20,20 +25,15 @@
|
||||
--accent-color-blue: #5084ff;
|
||||
--accent-color-purple: #9d50ff;
|
||||
--max-content-width: 1000px;
|
||||
--toastify-icon-color-success: var(--purple-color-2) !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
font-size: 16rem;
|
||||
line-height: 1.4;
|
||||
min-height: 100vh;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
a {
|
||||
|
19
hackage-ui/vercel.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"headers": [
|
||||
{
|
||||
"source": "/api/(.*)",
|
||||
"headers": [
|
||||
{ "key": "Access-Control-Allow-Credentials", "value": "true" },
|
||||
{ "key": "Access-Control-Allow-Origin", "value": "*" },
|
||||
{
|
||||
"key": "Access-Control-Allow-Methods",
|
||||
"value": "GET,OPTIONS"
|
||||
},
|
||||
{
|
||||
"key": "Access-Control-Allow-Headers",
|
||||
"value": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
2
react-lib/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/dist
|
||||
/node_modules
|
14
react-lib/Makefile
Normal file
@ -0,0 +1,14 @@
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: dev
|
||||
dev: ## Start development.
|
||||
npm run dev
|
||||
|
||||
.PHONY: build
|
||||
build: ## Build the project.
|
||||
npm i
|
||||
npm run build
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
14871
react-lib/package-lock.json
generated
Normal file
73
react-lib/package.json
Normal file
@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "@hackage-ui/react-lib",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"main": "dist/index.js",
|
||||
"typings": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"sideEffects": true,
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/gtag.js": "0.0.8",
|
||||
"@types/lodash": "^4.14.178",
|
||||
"axios": "^0.24.0",
|
||||
"buffer": "^6.0.3",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"express": "^4.17.1",
|
||||
"fuse.js": "^6.5.3",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "^12.0.7",
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17",
|
||||
"react-toastify": "^8.1.0",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"url-parse": "^1.5.3",
|
||||
"use-debounce": "^7.0.1",
|
||||
"uuid": "^8.3.2",
|
||||
"webextension-polyfill": "^0.8.0",
|
||||
"ws": "^8.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.9.1",
|
||||
"@types/react": "17.0.2",
|
||||
"@types/react-dom": "17.0.2",
|
||||
"@types/terser-webpack-plugin": "^5.0.4",
|
||||
"@types/webextension-polyfill": "^0.8.0",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"@types/webpack-dev-server": "^3.11.5",
|
||||
"@types/ws": "^7.4.7",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"css-loader": "^6.2.0",
|
||||
"cssnano": "^5.0.15",
|
||||
"glob": "^7.1.7",
|
||||
"html-webpack-plugin": "^5.3.2",
|
||||
"jest": "^27.2.0",
|
||||
"process": "^0.11.10",
|
||||
"rollup-plugin-postcss": "^4.0.2",
|
||||
"rollup-plugin-string": "^3.0.0",
|
||||
"style-loader": "^3.2.1",
|
||||
"terser-webpack-plugin": "^5.1.4",
|
||||
"ts-jest": "^27.0.5",
|
||||
"ts-loader": "^9.2.6",
|
||||
"ts-node": "^10.1.0",
|
||||
"tsconfig-paths": "^3.10.1",
|
||||
"tsdx": "^0.14.1",
|
||||
"tslib": "^2.3.1",
|
||||
"typescript": "^4.4.4",
|
||||
"webpack": "^5.63.0",
|
||||
"webpack-cli": "^4.9.1",
|
||||
"webpack-dev-server": "^4.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rm -rf build && tsdx build",
|
||||
"dev": "tsdx watch"
|
||||
}
|
||||
}
|
@ -1,11 +1,11 @@
|
||||
// Regular html <a /> tag, but that works properly with NextJS.
|
||||
|
||||
import { LinkHTMLAttributes, forwardRef, ForwardedRef, useContext } from 'react';
|
||||
import React, { AnchorHTMLAttributes, forwardRef, ForwardedRef, useContext } from 'react';
|
||||
import Link, { LinkProps } from 'next/link';
|
||||
import AppContext from '../AppContext';
|
||||
import { AppContext } from '../AppContext/AppContext';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
type ExtAProps = LinkHTMLAttributes<HTMLAnchorElement> & { analytics: { featureName: string, eventParams: Gtag.EventParams } };
|
||||
type ExtAProps = AnchorHTMLAttributes<HTMLAnchorElement> & { analytics: { featureName: string, eventParams: Gtag.EventParams } };
|
||||
type AProps = ExtAProps & LinkProps;
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
@ -30,7 +30,7 @@ export const ExtA = forwardRef((props: ExtAProps, ref: ForwardedRef<HTMLAnchorEl
|
||||
);
|
||||
});
|
||||
|
||||
const A = (props: AProps) => {
|
||||
export const A = (props: AProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={props.href || '#'}
|
||||
@ -46,5 +46,3 @@ const A = (props: AProps) => {
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default A;
|
@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
export const gaTrackingId = 'G-3LYZX9KW55';
|
||||
|
||||
@ -17,7 +17,7 @@ export type AnalyticsState = {
|
||||
categories: typeof categories
|
||||
}
|
||||
|
||||
export const Analytics = (props: { onChange: (state: AnalyticsState) => void }) => {
|
||||
export const Analytics = (props: { useNextJSRouting: boolean, onChange: (state: AnalyticsState) => void }) => {
|
||||
useEffect(() => {
|
||||
props.onChange({
|
||||
categories,
|
||||
@ -25,9 +25,13 @@ export const Analytics = (props: { onChange: (state: AnalyticsState) => void })
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RouterEventListener />
|
||||
);
|
||||
if (props.useNextJSRouting) {
|
||||
return (
|
||||
<RouterEventListener />
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const RouterEventListener = () => {
|
18
react-lib/src/AppContext/AppContext.module.css
Normal file
@ -0,0 +1,18 @@
|
||||
.toastContainer {
|
||||
max-width: calc(100vw - 24rem) !important;
|
||||
padding: 8rem !important;
|
||||
}
|
||||
|
||||
.toast {
|
||||
font-family: "Fira Sans" !important;
|
||||
border-radius: 8rem !important;
|
||||
box-shadow: 0rem 2rem 4rem rgb(0 0 0 / 27%) !important;
|
||||
font-size: 14rem !important;
|
||||
margin-bottom: 8rem !important;
|
||||
}
|
||||
|
||||
.toastBody {
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
166
react-lib/src/AppContext/AppContext.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import React, { ReactNode, useCallback, useState } from 'react';
|
||||
import { toast, ToastContainer } from 'react-toastify';
|
||||
import s from './AppContext.module.css';
|
||||
import { Analytics, AnalyticsState } from '../Analytics/Analytics';
|
||||
import type { Browser } from 'webextension-polyfill';
|
||||
|
||||
export const toastContainerId = 'hackage-ui-toast-container';
|
||||
|
||||
export type SearchHistory = string[];
|
||||
|
||||
export type AppContextValue = {
|
||||
notifySuccess: (content: ReactNode) => void,
|
||||
notifyError: (content: ReactNode) => void,
|
||||
tasks: Record<string, string | undefined>,
|
||||
startTask: (id: string, comment?: string) => void,
|
||||
finishTask: (id: string) => void,
|
||||
writeSearchHistoryEntry: (query: string) => void
|
||||
readSearchHistory: () => Promise<SearchHistory>,
|
||||
removeSearchHistoryEntry: (query: string) => Promise<void>,
|
||||
purgeSearchHistory: () => Promise<void>,
|
||||
analytics?: AnalyticsState,
|
||||
}
|
||||
|
||||
const defaultAppContextValue: AppContextValue = {
|
||||
notifySuccess: () => { },
|
||||
notifyError: () => { },
|
||||
tasks: {},
|
||||
startTask: () => { },
|
||||
finishTask: () => { },
|
||||
writeSearchHistoryEntry: () => { },
|
||||
readSearchHistory: () => new Promise(() => []),
|
||||
removeSearchHistoryEntry: () => new Promise(() => undefined),
|
||||
purgeSearchHistory: () => new Promise(() => undefined),
|
||||
analytics: undefined,
|
||||
}
|
||||
|
||||
export const AppContext = React.createContext<AppContextValue>(defaultAppContextValue);
|
||||
|
||||
export const DefaultAppContextProvider = ({ useNextJSRouting, children, asWebExtension }: { useNextJSRouting: boolean, children: ReactNode, asWebExtension?: boolean }) => {
|
||||
// If build for web extension, then expect https://github.com/mozilla/webextension-polyfill in a global object.
|
||||
let browser: Browser;
|
||||
if (asWebExtension) {
|
||||
browser = (global as any).browser;
|
||||
}
|
||||
|
||||
const [value, setValue] = useState<AppContextValue>(defaultAppContextValue);
|
||||
|
||||
const storageSetItem = useCallback((k, v: string) => {
|
||||
if (asWebExtension) {
|
||||
// https://developer.chrome.com/docs/extensions/reference/storage/#type-StorageArea
|
||||
// Debug: chrome://sync-internals/
|
||||
browser.storage.local.set({ [k]: v });
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(k, v);
|
||||
}, [asWebExtension]);
|
||||
|
||||
const storageGetItem = useCallback(async (k) => {
|
||||
if (asWebExtension) {
|
||||
// https://developer.chrome.com/docs/extensions/reference/storage/#type-StorageArea
|
||||
// Debug: chrome://sync-internals/
|
||||
return (await browser.storage.local.get(k))[k];
|
||||
}
|
||||
return localStorage.getItem(k);
|
||||
}, [asWebExtension]);
|
||||
|
||||
const notifySuccess = useCallback((content: ReactNode) => toast.success(content, { containerId: toastContainerId }), []);
|
||||
const notifyError = useCallback((content: ReactNode) => {
|
||||
value.analytics?.gtag('event', 'error', {
|
||||
category: value.analytics.categories.issues,
|
||||
label: content?.toString() || 'unknown error',
|
||||
});
|
||||
|
||||
toast.error(content, { containerId: toastContainerId });
|
||||
}, [value.analytics]);
|
||||
|
||||
const startTask = useCallback((id: string, comment?: string) => {
|
||||
setValue({
|
||||
...value,
|
||||
tasks: { ...value.tasks, [id]: comment }
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
const searchHistoryStorageKey = 'haskellSearchHistory';
|
||||
const readSearchHistory = useCallback(async () => {
|
||||
let searchHistory: SearchHistory = [];
|
||||
const jsonStr = await storageGetItem(searchHistoryStorageKey);
|
||||
|
||||
try {
|
||||
searchHistory = jsonStr === (null || undefined) ? [] : JSON.parse(jsonStr);
|
||||
|
||||
if (searchHistory && !Array.isArray(searchHistory)) {
|
||||
throw new Error('Search history is stored in wrong format. It should be an array of strings.');
|
||||
}
|
||||
} catch (_) {
|
||||
const searchHistoryBackupKey = `${searchHistoryStorageKey}_backup_${new Date().toISOString()}`
|
||||
await storageSetItem(searchHistoryBackupKey, jsonStr || '[]');
|
||||
|
||||
searchHistory = [];
|
||||
await storageSetItem(searchHistoryStorageKey, JSON.stringify(searchHistory));
|
||||
|
||||
notifyError(`Your search history is corrupted. You can get a backup by running 'localStorage.getItem(${searchHistoryBackupKey})' in browser console.`);
|
||||
} finally {
|
||||
return searchHistory;
|
||||
}
|
||||
}, [notifyError]);
|
||||
|
||||
const writeSearchHistoryEntry = useCallback(async (query: string) => {
|
||||
const searchHistory = await readSearchHistory();
|
||||
const newSearchHistory = Array.from(new Set([query].concat(searchHistory).slice(0, 1000)));
|
||||
return storageSetItem(searchHistoryStorageKey, JSON.stringify(newSearchHistory));
|
||||
}, [readSearchHistory]);
|
||||
|
||||
const removeSearchHistoryEntry = useCallback(async (query: string) => {
|
||||
const searchHistory = await readSearchHistory();
|
||||
const newSearchHistory = searchHistory.filter(entry => entry !== query);
|
||||
return storageSetItem(searchHistoryStorageKey, JSON.stringify(newSearchHistory));
|
||||
}, [readSearchHistory]);
|
||||
|
||||
const purgeSearchHistory = useCallback(async () => {
|
||||
return await storageSetItem(searchHistoryStorageKey, JSON.stringify([]));
|
||||
}, []);
|
||||
|
||||
const finishTask = useCallback((id: string) => {
|
||||
let tasks = { ...value.tasks };
|
||||
delete tasks[id];
|
||||
|
||||
setValue({ ...value, tasks });
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Analytics useNextJSRouting={useNextJSRouting} onChange={(analytics) => setValue({ ...value, analytics })} />
|
||||
<AppContext.Provider
|
||||
value={{
|
||||
...value,
|
||||
notifySuccess,
|
||||
notifyError,
|
||||
startTask,
|
||||
finishTask,
|
||||
readSearchHistory,
|
||||
writeSearchHistoryEntry,
|
||||
purgeSearchHistory,
|
||||
removeSearchHistoryEntry
|
||||
}}
|
||||
>
|
||||
<ToastContainer
|
||||
enableMultiContainer
|
||||
containerId={toastContainerId}
|
||||
position="top-right"
|
||||
autoClose={3000}
|
||||
newestOnTop={true}
|
||||
hideProgressBar={true}
|
||||
closeOnClick
|
||||
pauseOnFocusLoss
|
||||
draggable={false}
|
||||
pauseOnHover
|
||||
className={s.toastContainer}
|
||||
toastClassName={s.toast}
|
||||
bodyClassName={s.toastBody}
|
||||
/>
|
||||
{children}
|
||||
</AppContext.Provider>
|
||||
</>
|
||||
)
|
||||
};
|
@ -1,14 +1,21 @@
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState, useContext } from "react";
|
||||
import AppContext from '../AppContext';
|
||||
import A from '../layout/A';
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import { AppContext } from '../AppContext/AppContext';
|
||||
import { A, ExtA } from '../A/A';
|
||||
import s from './HackageSearchResults.module.css';
|
||||
import Header from './Header';
|
||||
import NothingFound from './NothingFound';
|
||||
|
||||
export type HackageSearchResults = { name: string }[];
|
||||
export type HackageSearchResult = { name: string };
|
||||
export type HackageSearchResults = HackageSearchResult[];
|
||||
|
||||
const HackageSearchResults = ({ query }: { query: string }) => {
|
||||
export type HackageSearchResultsProps = {
|
||||
query: string,
|
||||
apiUrl: string,
|
||||
asEmbeddedWidget?: boolean
|
||||
};
|
||||
|
||||
const HackageSearchResults = ({ query, apiUrl, asEmbeddedWidget }: HackageSearchResultsProps) => {
|
||||
const appContext = useContext(AppContext);
|
||||
const [searchResults, setSearchResults] = useState<HackageSearchResults>([]);
|
||||
|
||||
@ -27,7 +34,7 @@ const HackageSearchResults = ({ query }: { query: string }) => {
|
||||
appContext.startTask(taskId, `search on Hackage: ${query}`);
|
||||
|
||||
resData = await (await axios.get(
|
||||
`/api/hackage/packages/search?terms=${encodeURIComponent(searchTerms)}`,
|
||||
`${apiUrl}/packages/search?terms=${encodeURIComponent(searchTerms)}`,
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)).data;
|
||||
} catch (err) {
|
||||
@ -50,6 +57,7 @@ const HackageSearchResults = ({ query }: { query: string }) => {
|
||||
// It may cause infinite recursive calls. Fix it if you know how.
|
||||
}, [query, appContext.tasks.length]);
|
||||
|
||||
const Link = asEmbeddedWidget ? ExtA : A;
|
||||
return (
|
||||
<div className={s.searchResults}>
|
||||
{searchResults.length === 0 && (
|
||||
@ -61,14 +69,15 @@ const HackageSearchResults = ({ query }: { query: string }) => {
|
||||
<div className={s.searchResultsContainer}>
|
||||
{searchResults.map(pkg => {
|
||||
return (
|
||||
<A
|
||||
<Link
|
||||
key={pkg.name}
|
||||
className={s.searchResult}
|
||||
href={`/package/${pkg.name}`}
|
||||
target={asEmbeddedWidget ? '__blank' : '_self'}
|
||||
href={asEmbeddedWidget ? `https://hackage.haskell.org/package/${pkg.name}` : `/package/${pkg.name}`}
|
||||
analytics={{ featureName: 'HackageSearchResult', eventParams: {} }}
|
||||
>
|
||||
{pkg.name}
|
||||
</A>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
@ -1,4 +1,4 @@
|
||||
import { ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import s from './Header.module.css';
|
||||
|
||||
type HeaderProps = {
|
@ -1,4 +1,4 @@
|
||||
import { MouseEventHandler } from 'react';
|
||||
import React, { MouseEventHandler } from 'react';
|
||||
import SVGIcon from '../icons/SVGIcon';
|
||||
import s from './HeaderButton.module.css';
|
||||
|
@ -1,14 +1,14 @@
|
||||
import axios from 'axios';
|
||||
import { useEffect, useState, useContext } from "react";
|
||||
import AppContext from '../AppContext';
|
||||
import React, { useEffect, useState, useContext } from "react";
|
||||
import { AppContext } from '../AppContext/AppContext';
|
||||
import s from './HoogleSearchResults.module.css';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import A from '../layout/A';
|
||||
import { A, ExtA } from '../A/A';
|
||||
import Header from './Header';
|
||||
import HeaderButton from './HeaderButton';
|
||||
import NothingFound from './NothingFound';
|
||||
import viewNormallyIcon from '!!raw-loader!../icons/plus.svg';
|
||||
import viewBrieflyIcon from '!!raw-loader!../icons/minus.svg';
|
||||
import viewNormallyIcon from '../icons/plus.svg';
|
||||
import viewBrieflyIcon from '../icons/minus.svg';
|
||||
import zipObject from 'lodash/zipObject';
|
||||
import mapValues from 'lodash/mapValues';
|
||||
|
||||
@ -34,7 +34,13 @@ export type HoogleSearchResults = Record<HoogleItemKey, HoogleItemEntry[]>;
|
||||
|
||||
export type ViewMode = 'brief' | 'normal';
|
||||
|
||||
const HoogleSearchResults = ({ query }: { query: string }) => {
|
||||
export type HoogleSearchResultsProps = {
|
||||
query: string,
|
||||
apiUrl: string,
|
||||
asEmbeddedWidget?: boolean
|
||||
};
|
||||
|
||||
const HoogleSearchResults = ({ query, apiUrl, asEmbeddedWidget }: HoogleSearchResultsProps) => {
|
||||
// Hoogle sometimes returns duplicate entries. Maybe a Hoogle bug, maybe I missed something.
|
||||
function deduplicate(arr: any[]) {
|
||||
return Array.from(new Set(arr.map(el => JSON.stringify(el)))).map(el => JSON.parse(el));
|
||||
@ -70,7 +76,7 @@ const HoogleSearchResults = ({ query }: { query: string }) => {
|
||||
appContext.startTask(taskId, `search on Hoogle: ${query}`);
|
||||
|
||||
resData = await (await axios.get(
|
||||
`/api/hoogle?mode=json&format=text&hoogle=${encodeURIComponent(query)}&start=1&count=1000`,
|
||||
`${apiUrl}?mode=json&format=text&hoogle=${encodeURIComponent(query)}&start=1&count=1000`,
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
)).data;
|
||||
} catch (err) {
|
||||
@ -96,6 +102,8 @@ const HoogleSearchResults = ({ query }: { query: string }) => {
|
||||
// It may cause infinite recursive calls. Fix it if you know how.
|
||||
}, [query]);
|
||||
|
||||
const Link = asEmbeddedWidget ? ExtA : A;
|
||||
|
||||
return (
|
||||
<div className={s.searchResults}>
|
||||
{query.length > 0 && Object.keys(searchResults).length === 0 && (
|
||||
@ -134,13 +142,14 @@ const HoogleSearchResults = ({ query }: { query: string }) => {
|
||||
svgIcon={itemViewMode === 'normal' ? viewBrieflyIcon : viewNormallyIcon}
|
||||
/>
|
||||
</div>
|
||||
<A
|
||||
<Link
|
||||
href={rewriteUrl(hoogleItem[0].url)}
|
||||
target={asEmbeddedWidget ? '__blank' : '__self'}
|
||||
className={`${s.hoogleItemLink} ${s.link} ${itemViewMode === 'brief' ? s.hoogleItemLinkBrief : ''}`}
|
||||
analytics={{ featureName: 'HoogleSearchResultItem', eventParams: {} }}
|
||||
>
|
||||
<strong className={s.hoogleItemTypeName}>{typeName}</strong>{typeDef ? <strong> :: </strong> : ''}<span>{typeDef}</span>
|
||||
</A>
|
||||
</Link>
|
||||
<div className={s.hoogleItemContent}>
|
||||
{docs && (
|
||||
<div className={`${s.hoogleItemDocs} ${itemViewMode === 'brief' ? s.hoogleItemDocsBrief : ''}`}>
|
||||
@ -153,23 +162,25 @@ const HoogleSearchResults = ({ query }: { query: string }) => {
|
||||
|
||||
return (
|
||||
<div key={packageKey} className={s.hoogleItemPackage}>
|
||||
<A
|
||||
<Link
|
||||
href={rewriteUrl(pkg[0].package.url)}
|
||||
target={asEmbeddedWidget ? '__blank' : '__self'}
|
||||
className={s.link}
|
||||
analytics={{ featureName: 'HoogleSearchResultItem', eventParams: {} }}
|
||||
>
|
||||
<small style={{ marginRight: '0.5em' }}>📦</small>{pkg[0].package.name}
|
||||
</A>
|
||||
</Link>
|
||||
<div className={s.hoogleItemModules}>
|
||||
{pkg.map(item => (
|
||||
<A
|
||||
<Link
|
||||
key={`${packageKey}@${item.module.name}`}
|
||||
href={rewriteUrl(item.module.url)}
|
||||
target={asEmbeddedWidget ? '__blank' : '__self'}
|
||||
className={s.link}
|
||||
analytics={{ featureName: 'HoogleSearchResultModule', eventParams: {} }}
|
||||
>
|
||||
{item.module.name}
|
||||
</A>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
30
react-lib/src/SearchInput/Input.module.css
Normal file
@ -0,0 +1,30 @@
|
||||
.input {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputInput {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
border-radius: 24rem;
|
||||
padding: 8rem 18rem 8rem 18rem;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
font-size: 14rem;
|
||||
color: var(--text-color);
|
||||
caret-color: var(--text-color);
|
||||
transition: var(--transition-short);
|
||||
box-shadow: 1rem 3rem 2rem rgb(0 0 0 / 0.27);
|
||||
}
|
||||
|
||||
.inputInput:focus {
|
||||
transition: var(--transition-short);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.inputInput::placeholder {
|
||||
opacity: 0.5;
|
||||
color: var(--text-color);
|
||||
}
|
44
react-lib/src/SearchInput/Input.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import s from './Input.module.css';
|
||||
import React, { RefObject, useEffect, useRef } from 'react';
|
||||
|
||||
type InputProps = {
|
||||
placeholder: string,
|
||||
onFocus?: () => void,
|
||||
onBlur?: () => void,
|
||||
onChange: (v: string) => void,
|
||||
onInputRef: (ref: RefObject<HTMLInputElement>) => void,
|
||||
value: string,
|
||||
focusOnMount?: boolean
|
||||
};
|
||||
|
||||
const Input = ({ value, placeholder, onChange, onFocus, onBlur, onInputRef, focusOnMount }: InputProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusOnMount) {
|
||||
inputRef?.current?.focus();
|
||||
}
|
||||
}, [focusOnMount]);
|
||||
|
||||
useEffect(() => {
|
||||
onInputRef(inputRef);
|
||||
}, [onInputRef, inputRef]);
|
||||
|
||||
return (
|
||||
<div className={s.input}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className={`${s.inputInput}`}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocus || (() => {})}
|
||||
onBlur={onBlur || (() => {})}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Input;
|
@ -1,6 +1,6 @@
|
||||
import { ReactNode, useState, useEffect, useContext } from 'react'
|
||||
import React, { ReactNode, useState, useEffect, useContext } from 'react'
|
||||
import s from './NothingFound.module.css'
|
||||
import AppContext from '../AppContext';
|
||||
import { AppContext } from '../AppContext/AppContext';
|
||||
|
||||
type NothingFoundProps = {
|
||||
children: ReactNode,
|
@ -1,9 +1,9 @@
|
||||
import { useContext, useState } from "react";
|
||||
import AppContext from "../AppContext";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { AppContext, SearchHistory } from "../AppContext/AppContext";
|
||||
import s from './RecentSearches.module.css';
|
||||
import Fuse from 'fuse.js'
|
||||
import SVGIcon from '../icons/SVGIcon';
|
||||
import clearIcon from '!!raw-loader!../icons/clear.svg';
|
||||
import clearIcon from '../icons/clear.svg';
|
||||
import Header from './Header';
|
||||
import HeaderButton from './HeaderButton';
|
||||
import NothingFound from './NothingFound';
|
||||
@ -15,15 +15,24 @@ type RecentSearchesProps = {
|
||||
|
||||
const RecentSearches = (props: RecentSearchesProps) => {
|
||||
const appContext = useContext(AppContext);
|
||||
const searchHistory = appContext.readSearchHistory();
|
||||
const showHistory = searchHistory.length > 0;
|
||||
const [_, forceUpdate] = useState({});
|
||||
const [searchHistory, setSearchHistory] = useState<SearchHistory>([]);
|
||||
|
||||
// Change React element key is a way to do a component force update.
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const searchHistory = await appContext.readSearchHistory();
|
||||
setSearchHistory(searchHistory);
|
||||
})()
|
||||
}, [key]);
|
||||
|
||||
const showHistory = searchHistory.length > 0;
|
||||
const fuse = new Fuse(searchHistory);
|
||||
const withFilter = props.query.length === 0 ? searchHistory : fuse.search(props.query).map(item => item.item);
|
||||
|
||||
return (
|
||||
<div className={s.searchResults}>
|
||||
<div className={s.searchResults} key={key}>
|
||||
{!showHistory && (
|
||||
<NothingFound waitBeforeShow={0}>
|
||||
Search history is empty.
|
||||
@ -37,8 +46,7 @@ const RecentSearches = (props: RecentSearchesProps) => {
|
||||
svgIcon={clearIcon}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
appContext.purgeSearchHistory();
|
||||
forceUpdate({});
|
||||
appContext.purgeSearchHistory().then(() => setKey(key + 1));
|
||||
}}
|
||||
/>
|
||||
</Header>
|
||||
@ -53,8 +61,7 @@ const RecentSearches = (props: RecentSearchesProps) => {
|
||||
title="Delete search history entry"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
appContext.removeSearchHistoryEntry(historyEntry);
|
||||
forceUpdate({});
|
||||
appContext.removeSearchHistoryEntry(historyEntry).then(() => setKey(key + 1));
|
||||
}}
|
||||
>
|
||||
<SVGIcon svg={clearIcon} />
|
@ -1,5 +1,6 @@
|
||||
.searchInput {
|
||||
position: relative;
|
||||
font-size: 16rem;
|
||||
}
|
||||
|
||||
.searchResultsContainer {
|
@ -1,16 +1,25 @@
|
||||
import { RefObject, useEffect, useState, useCallback, useContext, useRef } from "react";
|
||||
import AppContext from "../AppContext";
|
||||
import Input from "../forms/Input";
|
||||
import React, { RefObject, useEffect, useState, useCallback, useContext, useRef } from "react";
|
||||
import { AppContext } from "../AppContext/AppContext";
|
||||
import Input from "./Input";
|
||||
import s from './SearchInput.module.css';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { useRouter } from 'next/router';
|
||||
import { NextRouter } from 'next/router';
|
||||
import HackageSearchResults from './HackageSearchResults';
|
||||
import HoogleSearchResults from './HoogleSearchResults';
|
||||
import RecentSearches from './RecentSearches';
|
||||
|
||||
export const SearchResultsClassName = `Haskell-8f731b8c-7900-4d8b`; // Whatever.
|
||||
|
||||
type Api = {
|
||||
hackageApiUrl: string,
|
||||
hoogleApiUrl: string
|
||||
}
|
||||
|
||||
type SearchResultsProps = {
|
||||
query: string,
|
||||
setQuery: (query: string) => void
|
||||
setQuery: (query: string) => void,
|
||||
api: Api,
|
||||
asEmbeddedWidget?: boolean
|
||||
}
|
||||
|
||||
const SearchResults = (props: SearchResultsProps) => {
|
||||
@ -25,13 +34,8 @@ const SearchResults = (props: SearchResultsProps) => {
|
||||
appContext.analytics?.gtag('event', 'search', {
|
||||
search_term: query,
|
||||
});
|
||||
|
||||
console.log('context', appContext);
|
||||
|
||||
console.log('query changed:', query);
|
||||
}, [query]);
|
||||
|
||||
|
||||
let queryType: 'hackage' | 'hoogle' | 'recentSearches' | 'allRecentSearches' | 'showHelp' | 'unknown' = 'unknown';
|
||||
|
||||
if (query.startsWith(':')) {
|
||||
@ -53,11 +57,17 @@ const SearchResults = (props: SearchResultsProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.searchResultsContainer}>
|
||||
<div className={`${s.searchResultsContainer} ${SearchResultsClassName}`}>
|
||||
<div className={s.searchResults}>
|
||||
{queryType === 'showHelp' && (<Help />)}
|
||||
{query && queryType === 'hackage' && <HackageSearchResults query={query.trim()} />}
|
||||
{query && queryType === 'hoogle' && <HoogleSearchResults query={query.replace(/^\:t /, '').trim()} />}
|
||||
{query && queryType === 'hackage' && (
|
||||
<HackageSearchResults
|
||||
query={query.trim()}
|
||||
apiUrl={props.api.hackageApiUrl}
|
||||
asEmbeddedWidget={props.asEmbeddedWidget}
|
||||
/>
|
||||
)}
|
||||
{query && queryType === 'hoogle' && <HoogleSearchResults query={query.replace(/^\:t /, '').trim()} apiUrl={props.api.hoogleApiUrl} />}
|
||||
{query && queryType === 'recentSearches' && <RecentSearches query={query.replace(/^\:r ?/, '').trim()} onSelect={props.setQuery} />}
|
||||
{query && queryType === 'allRecentSearches' && <RecentSearches query={''} onSelect={props.setQuery} />}
|
||||
</div>
|
||||
@ -65,8 +75,10 @@ const SearchResults = (props: SearchResultsProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
const SearchInput = () => {
|
||||
const router = useRouter();
|
||||
export type SearchInputProps = { router?: NextRouter, api: Api, asEmbeddedWidget?: boolean };
|
||||
|
||||
export const SearchInput = (props: SearchInputProps) => {
|
||||
const { router } = props;
|
||||
const [query, _setQuery] = useState('');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [focusedTimes, setFocusedTimes] = useState(0);
|
||||
@ -75,16 +87,15 @@ const SearchInput = () => {
|
||||
const [inputRef, setInputRef] = useState<RefObject<HTMLInputElement>>();
|
||||
|
||||
const setQuery = useCallback((query: string) => {
|
||||
router.replace({ query: { ...router.query, search: query } }, undefined, { shallow: true });
|
||||
router?.replace({ query: { ...router.query, search: query } }, undefined, { shallow: true });
|
||||
_setQuery(query);
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDirty && router.query?.search !== query) {
|
||||
if (router && !isDirty && router.query?.search !== query) {
|
||||
_setQuery(router.query.search as string || '');
|
||||
}
|
||||
}, [query, isDirty, setQuery, router.query.search]);
|
||||
|
||||
}, [query, isDirty, setQuery, router?.query.search]);
|
||||
|
||||
const handleKeyUp = useCallback((event: KeyboardEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node) && event.key === 'Tab') {
|
||||
@ -102,6 +113,10 @@ const SearchInput = () => {
|
||||
}, [isFocused, inputRef]);
|
||||
|
||||
const handleMouseDown = useCallback((event: MouseEvent) => {
|
||||
if (props.asEmbeddedWidget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
setIsFocused(false);
|
||||
}
|
||||
@ -118,8 +133,14 @@ const SearchInput = () => {
|
||||
}, [handleKeyUp, handleMouseDown]);
|
||||
|
||||
const showSearchResults =
|
||||
(isFocused && query?.length) ||
|
||||
(isFocused && (isDirty || focusedTimes > 1)); // don't show search results after page load
|
||||
props.asEmbeddedWidget ||
|
||||
Boolean((isFocused && query?.length) ||
|
||||
(isFocused && (isDirty || focusedTimes > 1))); // don't show search results after page load
|
||||
|
||||
let placeholder = ':t a -> b';
|
||||
if (!props.asEmbeddedWidget) {
|
||||
placeholder = (isFocused && !isDirty && !showSearchResults) ? `Type ":" to show help` : `Click or press "/" to search…`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.searchInput} ref={ref}>
|
||||
@ -133,11 +154,11 @@ const SearchInput = () => {
|
||||
setIsFocused(true);
|
||||
}}
|
||||
onInputRef={setInputRef}
|
||||
placeholder={(isFocused && !isDirty && !showSearchResults) ? `Type ":" to show help` : `Click or press "/" to search…`}
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
focusOnMount
|
||||
/>
|
||||
{showSearchResults && <SearchResults query={query} setQuery={setQuery} />}
|
||||
{showSearchResults && <SearchResults query={query} setQuery={setQuery} api={props.api} asEmbeddedWidget={props.asEmbeddedWidget} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -157,5 +178,3 @@ const Help = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchInput;
|
9
react-lib/src/icons/SVGIcon.module.css
Normal file
@ -0,0 +1,9 @@
|
||||
.svgIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.svgIcon svg:focus {
|
||||
outline: none;
|
||||
}
|
12
react-lib/src/icons/SVGIcon.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import s from './SVGIcon.module.css';
|
||||
|
||||
export type SvgIconProps = {
|
||||
svg: string
|
||||
}
|
||||
|
||||
const SvgIcon = (props: SvgIconProps) => {
|
||||
return (<div className={s.svgIcon} dangerouslySetInnerHTML={{ __html: props.svg }}></div>);
|
||||
}
|
||||
|
||||
export default SvgIcon;
|
3
react-lib/src/icons/clear.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg focusable="false" viewBox="0 0 24 24" tabindex="-1">
|
||||
<path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 187 B |
3
react-lib/src/icons/minus.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg focusable="false" viewBox="0 0 24 24" tabindex="-1">
|
||||
<path d="M19 13H5v-2h14v2z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 103 B |
3
react-lib/src/icons/plus.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg focusable="false" viewBox="0 0 24 24" tabindex="-1">
|
||||
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 121 B |
10
react-lib/src/index.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import * as _a from './A/A';
|
||||
import * as _analytics from './Analytics/Analytics';
|
||||
import * as _appContext from './AppContext/AppContext';
|
||||
import * as _searchInput from './SearchInput/SearchInput';
|
||||
|
||||
// TODO - refactor exports;
|
||||
export const links = _a;
|
||||
export const analytics = _analytics;
|
||||
export const appContext = _appContext;
|
||||
export const searchInput = _searchInput;
|
3
react-lib/src/types.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
declare module '*.css';
|
||||
declare module '*.module.css';
|
||||
declare module '*.svg';
|
23
react-lib/tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"rootDir": "./src",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["node_modules", "build", "./*.js"]
|
||||
}
|
31
react-lib/tsdx.config.js
Normal file
@ -0,0 +1,31 @@
|
||||
const path = require("path");
|
||||
const postcss = require("rollup-plugin-postcss");
|
||||
const autoprefixer = require("autoprefixer");
|
||||
const cssnano = require("cssnano");
|
||||
const stringPlugin = require("rollup-plugin-string");
|
||||
|
||||
module.exports = {
|
||||
rollup(config) {
|
||||
// Fix https://github.com/jaredpalmer/tsdx/issues/954
|
||||
config.output.strict = false;
|
||||
config.plugins.push(
|
||||
stringPlugin.string({
|
||||
include: "**/*.svg"
|
||||
})
|
||||
);
|
||||
config.plugins.push(
|
||||
postcss({
|
||||
sourceMap: false,
|
||||
plugins: [
|
||||
autoprefixer(),
|
||||
cssnano({
|
||||
preset: "default"
|
||||
})
|
||||
],
|
||||
inject: false,
|
||||
extract: path.resolve("dist/react-lib.css")
|
||||
})
|
||||
);
|
||||
return config;
|
||||
}
|
||||
};
|
2
styles/fonts.css
Normal file
@ -0,0 +1,2 @@
|
||||
/* It's from haskell extension. */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@300;400&family=Fira+Sans:wght@400;700&display=swap");
|
111
styles/globals.css
Normal file
@ -0,0 +1,111 @@
|
||||
#__next {
|
||||
min-height: inherit;
|
||||
}
|
||||
|
||||
:root,
|
||||
:host {
|
||||
font-size: 1px;
|
||||
font-family: "Fira Sans", sans-serif;
|
||||
color: var(--text-color);
|
||||
line-height: 1.4;
|
||||
font-variant-ligatures: none;
|
||||
--background-color: #f6eedc;
|
||||
--background-color-backdrop: rgba(94, 80, 134, 0.8);
|
||||
--surface-color: #dfd8c7;
|
||||
--surface-color-backdrop: rgba(94, 80, 134, 0.9);
|
||||
--text-color: rgb(70, 70, 70);
|
||||
--purple-color-1: #453a62;
|
||||
--purple-color-2: #5e5086;
|
||||
--purple-color-3: #8f4e8b;
|
||||
--transition-short: all 180ms ease 0s;
|
||||
--backdrop-filter-blur: blur(12px);
|
||||
--accent-color-green: var(--purple-color-2);
|
||||
--accent-color-red: #bf4646;
|
||||
--accent-color-yellow: #ff9042;
|
||||
--accent-color-blue: #5084ff;
|
||||
--accent-color-purple: #9d50ff;
|
||||
--max-content-width: 1000px;
|
||||
--toastify-icon-color-success: var(--purple-color-2) !important;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: var(--background-color);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--purple-color-2);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 0.66;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
code,
|
||||
pre {
|
||||
font-family: "Fira Code", monospace;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
code.hljs,
|
||||
pre.hljs {
|
||||
background-color: var(
|
||||
--purple-color-1
|
||||
) !important; /* important! here is to override highlight.js theme styles */
|
||||
border-radius: 4rem;
|
||||
padding: 1rem 6rem;
|
||||
font-size: 14rem;
|
||||
}
|
||||
|
||||
code.hljs {
|
||||
padding: 0rem 4rem !important;
|
||||
}
|
||||
|
||||
pre.hljs {
|
||||
padding: 8rem 18rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
background-color: var(--surface-color);
|
||||
width: 100%;
|
||||
max-width: var(--max-content-width);
|
||||
height: 4rem;
|
||||
background-color: var(--surface-color);
|
||||
border: none;
|
||||
border-radius: 4rem;
|
||||
}
|
||||
|
||||
@keyframes loading-overlay {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.066;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 1;
|
||||
background: #000;
|
||||
animation-name: loading-overlay;
|
||||
animation-duration: 0.66s;
|
||||
animation-timing-function: ease;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: normal;
|
||||
}
|
285
styles/normalize.css
vendored
Normal file
@ -0,0 +1,285 @@
|
||||
/*! modern-normalize v1.1.0 | MIT License | https://github.com/sindresorhus/modern-normalize */
|
||||
|
||||
/*
|
||||
Document
|
||||
========
|
||||
*/
|
||||
|
||||
/**
|
||||
Use a better box model (opinionated).
|
||||
*/
|
||||
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Correct the line height in all browsers.
|
||||
2. Prevent adjustments of font size after orientation changes in iOS.
|
||||
3. Use a more readable tab size (opinionated).
|
||||
*/
|
||||
|
||||
html {
|
||||
line-height: 1.15; /* 1 */
|
||||
-webkit-text-size-adjust: 100%; /* 2 */
|
||||
-moz-tab-size: 4; /* 3 */
|
||||
tab-size: 4; /* 3 */
|
||||
}
|
||||
|
||||
/*
|
||||
Sections
|
||||
========
|
||||
*/
|
||||
|
||||
/**
|
||||
1. Remove the margin in all browsers.
|
||||
2. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||
*/
|
||||
|
||||
body {
|
||||
margin: 0; /* 1 */
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system, /* Firefox supports this but not yet `system-ui` */
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji'; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Grouping content
|
||||
================
|
||||
*/
|
||||
|
||||
/**
|
||||
1. Add the correct height in Firefox.
|
||||
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
|
||||
*/
|
||||
|
||||
hr {
|
||||
height: 0; /* 1 */
|
||||
color: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Text-level semantics
|
||||
====================
|
||||
*/
|
||||
|
||||
/**
|
||||
Add the correct text decoration in Chrome, Edge, and Safari.
|
||||
*/
|
||||
|
||||
abbr[title] {
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
/**
|
||||
Add the correct font weight in Edge and Safari.
|
||||
*/
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
|
||||
2. Correct the odd 'em' font sizing in all browsers.
|
||||
*/
|
||||
|
||||
code,
|
||||
kbd,
|
||||
samp,
|
||||
pre {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Consolas,
|
||||
'Liberation Mono',
|
||||
Menlo,
|
||||
monospace; /* 1 */
|
||||
font-size: 1em; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Add the correct font size in all browsers.
|
||||
*/
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
/**
|
||||
Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
|
||||
*/
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
/*
|
||||
Tabular data
|
||||
============
|
||||
*/
|
||||
|
||||
/**
|
||||
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
|
||||
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
|
||||
*/
|
||||
|
||||
table {
|
||||
text-indent: 0; /* 1 */
|
||||
border-color: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Forms
|
||||
=====
|
||||
*/
|
||||
|
||||
/**
|
||||
1. Change the font styles in all browsers.
|
||||
2. Remove the margin in Firefox and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit; /* 1 */
|
||||
font-size: 100%; /* 1 */
|
||||
line-height: 1.15; /* 1 */
|
||||
margin: 0; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Remove the inheritance of text transform in Edge and Firefox.
|
||||
*/
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/**
|
||||
Correct the inability to style clickable types in iOS and Safari.
|
||||
*/
|
||||
|
||||
button,
|
||||
[type='button'],
|
||||
[type='reset'],
|
||||
[type='submit'] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
/**
|
||||
Remove the inner border and padding in Firefox.
|
||||
*/
|
||||
|
||||
::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
Restore the focus styles unset by the previous rule.
|
||||
*/
|
||||
|
||||
:-moz-focusring {
|
||||
outline: 1px dotted ButtonText;
|
||||
}
|
||||
|
||||
/**
|
||||
Remove the additional ':invalid' styles in Firefox.
|
||||
See: https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737
|
||||
*/
|
||||
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/**
|
||||
Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
|
||||
*/
|
||||
|
||||
legend {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
Add the correct vertical alignment in Chrome and Firefox.
|
||||
*/
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
Correct the cursor style of increment and decrement buttons in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-inner-spin-button,
|
||||
::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Correct the odd appearance in Chrome and Safari.
|
||||
2. Correct the outline style in Safari.
|
||||
*/
|
||||
|
||||
[type='search'] {
|
||||
-webkit-appearance: textfield; /* 1 */
|
||||
outline-offset: -2px; /* 2 */
|
||||
}
|
||||
|
||||
/**
|
||||
Remove the inner padding in Chrome and Safari on macOS.
|
||||
*/
|
||||
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/**
|
||||
1. Correct the inability to style clickable types in iOS and Safari.
|
||||
2. Change font properties to 'inherit' in Safari.
|
||||
*/
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button; /* 1 */
|
||||
font: inherit; /* 2 */
|
||||
}
|
||||
|
||||
/*
|
||||
Interactive
|
||||
===========
|
||||
*/
|
||||
|
||||
/*
|
||||
Add the correct display in Chrome and Safari.
|
||||
*/
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|