Implement browser extension

This commit is contained in:
visortelle 2022-01-10 13:45:10 +01:00
parent 66ddd8f9b4
commit df71e03b6f
87 changed files with 51994 additions and 392 deletions

View File

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

@ -0,0 +1,3 @@
/build
/build.zip
/node_modules

26
browser-extension/.swcrc Normal file
View 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"
}
}
}
}
}
}

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

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

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

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

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

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

View 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

View 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

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

@ -0,0 +1 @@
export default {};

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

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

@ -0,0 +1 @@
declare module '*.css';

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

View File

@ -35,3 +35,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
# Global styles across other modules
/public/styles

16
hackage-ui/Makefile Normal file
View 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}'

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View 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

View 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

View File

@ -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 = () => {

View File

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

View File

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

View File

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

View File

@ -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&nbsp;&nbsp;
<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}>

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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' });
`}}
/>

View File

@ -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. ',

View File

@ -59,7 +59,7 @@ export async function getStaticProps(props: GetStaticPropsContext): Promise<GetS
export async function getStaticPaths() {
return {
paths: [],
fallback: 'blocking',
fallback: 'blocking'
};
}

View File

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

@ -0,0 +1,2 @@
/dist
/node_modules

14
react-lib/Makefile Normal file
View 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

File diff suppressed because it is too large Load Diff

73
react-lib/package.json Normal file
View 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"
}
}

View File

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

View File

@ -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 = () => {

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

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

View File

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

View File

@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import React, { ReactNode } from 'react';
import s from './Header.module.css';
type HeaderProps = {

View File

@ -1,4 +1,4 @@
import { MouseEventHandler } from 'react';
import React, { MouseEventHandler } from 'react';
import SVGIcon from '../icons/SVGIcon';
import s from './HeaderButton.module.css';

View File

@ -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>&nbsp;::&nbsp;</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>

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

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
.searchInput {
position: relative;
font-size: 16rem;
}
.searchResultsContainer {

View File

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

View File

@ -0,0 +1,9 @@
.svgIcon {
display: flex;
align-items: center;
justify-content: center;
}
.svgIcon svg:focus {
outline: none;
}

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

View 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

View 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

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

@ -0,0 +1,3 @@
declare module '*.css';
declare module '*.module.css';
declare module '*.svg';

23
react-lib/tsconfig.json Normal file
View 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
View 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
View 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
View 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
View 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;
}