diff --git a/docs/coding-style.md b/docs/coding-style.md new file mode 100644 index 0000000..5ad0991 --- /dev/null +++ b/docs/coding-style.md @@ -0,0 +1,29 @@ +# TypeScript + +- avoid `any`; get rid of any existing `any` whenever you can so that we can enable `"strict": true` later on in `tsconfig.json` +- define custom types for common data structures + - don't worry about `interface` vs `type`, [both are fine](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#differences-between-type-aliases-and-interfaces) + +## Typescript and React/Next + +- use `React.FC` type for React components, e.g. `const MyComponent: React.FC = ({ ... }) => { ... };` +- use `NextPage` for typing stuff in `src/pages/` +- use generic versions of `GetServerSideProps` and `GetStaticProps` + +# React + +- create one file per one component (tiny helper components in the same file are fine) +- name file identically to the component it describes (e.g. `const DisplayForecasts: React.FC = ...` in `DisplayForecasts.ts`) +- use named export instead of default export for all React components + - it's better for refactoring + - and it plays well with `React.FC` typing + +# Styles + +- use [Tailwind](https://tailwindcss.com/) +- avoid positioning styles in components, position elements from the outside (e.g. with [space-\*](https://tailwindcss.com/docs/space) or grid/flexbox) + +# General notes + +- use `const` instead of `let` whenever possible +- set up [prettier](https://prettier.io/) to format code on save diff --git a/src/pages/_middleware.ts b/src/pages/_middleware.ts new file mode 100644 index 0000000..f9f1509 --- /dev/null +++ b/src/pages/_middleware.ts @@ -0,0 +1,30 @@ +import { NextURL } from "next/dist/server/web/next-url"; +import { NextRequest, NextResponse } from "next/server"; + +export async function middleware(req: NextRequest) { + const { pathname, searchParams } = req.nextUrl; + + console.log(pathname); + if (pathname === "/dashboards") { + const dashboardId = searchParams.get("dashboardId"); + if (dashboardId) { + return NextResponse.redirect( + new URL(`/dashboards/view/${dashboardId}`, req.url) + ); + } + } else if (pathname === "/secretDashboard") { + const dashboardId = searchParams.get("dashboardId"); + if (dashboardId) { + const url = new URL(`/dashboards/embed/${dashboardId}`, req.url); + const numCols = searchParams.get("numCols"); + if (numCols) { + url.searchParams.set("numCols", numCols); + } + return NextResponse.redirect(url); + } else { + return NextResponse.rewrite(new NextURL("/404", req.url)); + } + } + + return NextResponse.next(); +} diff --git a/src/pages/about.tsx b/src/pages/about.tsx index 4590e84..193d0fe 100644 --- a/src/pages/about.tsx +++ b/src/pages/about.tsx @@ -2,9 +2,9 @@ import React from "react"; import ReactMarkdown from "react-markdown"; import gfm from "remark-gfm"; -import Layout from "../web/display/layout"; +import { Layout } from "../web/display/Layout"; -let readmeMarkdownText = `# About +const readmeMarkdownText = `# About This webpage is a search engine for probabilities. Given a query, it searches for relevant questions in various prediction markets and forecasting platforms. For example, try searching for "China", "North Korea", "Semiconductors", "COVID", "Trump", or "X-risk". In addition to search, we also provide various [tools](http://localhost:3000/tools). diff --git a/src/pages/capture.tsx b/src/pages/capture.tsx index 6cd6905..16712bd 100644 --- a/src/pages/capture.tsx +++ b/src/pages/capture.tsx @@ -2,7 +2,7 @@ import { NextPage } from "next"; import React from "react"; import { displayForecastsWrapperForCapture } from "../web/display/displayForecastsWrappers"; -import Layout from "../web/display/layout"; +import { Layout } from "../web/display/Layout"; import { Props } from "../web/search/anySearchPage"; import CommonDisplay from "../web/search/CommonDisplay"; diff --git a/src/pages/dashboards.tsx b/src/pages/dashboards.tsx deleted file mode 100644 index 7cc6057..0000000 --- a/src/pages/dashboards.tsx +++ /dev/null @@ -1,210 +0,0 @@ -/* Imports */ -import axios from "axios"; -import { GetServerSideProps, NextPage } from "next"; -import { useRouter } from "next/router"; // https://nextjs.org/docs/api-reference/next/router -import { useState } from "react"; - -import { DashboardItem } from "../backend/dashboards"; -import { getPlatformsConfig, PlatformConfig } from "../backend/platforms"; -import { DashboardCreator } from "../web/display/dashboardCreator"; -import displayForecasts from "../web/display/displayForecasts"; -import Layout from "../web/display/layout"; -import { addLabelsToForecasts, FrontendForecast } from "../web/platforms"; -import { getDashboardForecastsByDashboardId } from "../web/worker/getDashboardForecasts"; - -interface Props { - initialDashboardForecasts: FrontendForecast[]; - initialDashboardId: string | null; - initialDashboardItem: DashboardItem | null; - platformsConfig: PlatformConfig[]; -} - -export const getServerSideProps: GetServerSideProps = async ( - context -) => { - const dashboardIdQ = context.query.dashboardId; - const dashboardId: string | undefined = - typeof dashboardIdQ === "object" ? dashboardIdQ[0] : dashboardIdQ; - - const platformsConfig = getPlatformsConfig({ withGuesstimate: false }); - - if (!dashboardId) { - return { - props: { - platformsConfig, - initialDashboardForecasts: [], - initialDashboardId: null, - initialDashboardItem: null, - }, - }; - } - - const { dashboardForecasts, dashboardItem } = - await getDashboardForecastsByDashboardId({ - dashboardId, - }); - const frontendDashboardForecasts = addLabelsToForecasts( - dashboardForecasts, - platformsConfig - ); - - return { - props: { - initialDashboardForecasts: frontendDashboardForecasts, - initialDashboardId: dashboardId, - initialDashboardItem: dashboardItem, - platformsConfig, - }, - }; -}; - -/* Body */ -const DashboardsPage: NextPage = ({ - initialDashboardForecasts, - initialDashboardItem, - platformsConfig, -}) => { - const router = useRouter(); - const [dashboardForecasts, setDashboardForecasts] = useState( - initialDashboardForecasts - ); - const [dashboardItem, setDashboardItem] = useState(initialDashboardItem); - - let handleSubmit = async (data) => { - console.log(data); - // Send to server to create - // Get back the id - let response = await axios({ - url: `/api/create-dashboard-from-ids`, - method: "POST", - headers: { "Content-Type": "application/json" }, - data: JSON.stringify(data), - }).then((res) => res.data); - let dashboardId = response.dashboardId; - if (!!dashboardId) { - console.log("response: ", response); - if (typeof window !== "undefined") { - let urlWithoutDefaultParameters = `/dashboards?dashboardId=${dashboardId}`; - if (!window.location.href.includes(urlWithoutDefaultParameters)) { - window.history.replaceState( - null, - "Metaforecast", - urlWithoutDefaultParameters - ); - } - } - // router.push(`?dashboardId=${dashboardId}`) - // display it - - let { dashboardForecasts, dashboardItem } = - await getDashboardForecastsByDashboardId({ - dashboardId, - }); - setDashboardForecasts( - addLabelsToForecasts(dashboardForecasts, platformsConfig) - ); - setDashboardItem(dashboardItem); - } - }; - - let isGraubardEasterEgg = (name) => (name == "Clay Graubard" ? true : false); - - return ( - - {/* Display forecasts */} -
-

- {!!dashboardItem ? dashboardItem.title : ""} -

-

- {!!dashboardItem ? `Created by: ${dashboardItem.creator}` : ""} -

-

- {!!dashboardItem ? `Created by: @` : ""} - - Clay Graubard - -

-

- {!!dashboardItem ? `${dashboardItem.description}` : ""} -

-
- -
- {displayForecasts({ - results: dashboardForecasts, - numDisplay: dashboardForecasts.length, - showIdToggle: false, - })} -
- {/* */} -

- - 0 - ? `mx-3 text-md font-medium text-center` - : "hidden" - } - > - Or create your own - - - Create a dashboard! - - -

- -
-
- -
-
-
- ); -}; - -export default DashboardsPage; diff --git a/src/pages/dashboards/embed/[id].tsx b/src/pages/dashboards/embed/[id].tsx new file mode 100644 index 0000000..f4b8dd7 --- /dev/null +++ b/src/pages/dashboards/embed/[id].tsx @@ -0,0 +1,69 @@ +import { GetServerSideProps, NextPage } from "next"; +import Error from "next/error"; + +import { DashboardItem } from "../../../backend/dashboards"; +import { DisplayForecasts } from "../../../web/display/DisplayForecasts"; +import { FrontendForecast } from "../../../web/platforms"; +import { getDashboardForecastsByDashboardId } from "../../../web/worker/getDashboardForecasts"; + +interface Props { + dashboardForecasts: FrontendForecast[]; + dashboardItem: DashboardItem; + numCols?: number; +} + +export const getServerSideProps: GetServerSideProps = async ( + context +) => { + const dashboardId = context.query.id as string; + const numCols = Number(context.query.numCols); + + const { dashboardItem, dashboardForecasts } = + await getDashboardForecastsByDashboardId({ + dashboardId, + }); + + if (!dashboardItem) { + context.res.statusCode = 404; + } + + return { + props: { + dashboardForecasts, + dashboardItem, + numCols: !numCols ? null : numCols < 5 ? numCols : 4, + }, + }; +}; + +const EmbedDashboardPage: NextPage = ({ + dashboardForecasts, + dashboardItem, + numCols, +}) => { + if (!dashboardItem) { + return ; + } + + return ( +
+
+
+ +
+
+
+ ); +}; + +export default EmbedDashboardPage; diff --git a/src/pages/dashboards/index.tsx b/src/pages/dashboards/index.tsx new file mode 100644 index 0000000..f1ba14a --- /dev/null +++ b/src/pages/dashboards/index.tsx @@ -0,0 +1,37 @@ +import axios from "axios"; +import { NextPage } from "next"; +import { useRouter } from "next/router"; + +import { DashboardCreator } from "../../web/display/DashboardCreator"; +import { Layout } from "../../web/display/Layout"; +import { LineHeader } from "../../web/display/LineHeader"; + +const DashboardsPage: NextPage = () => { + const router = useRouter(); + + const handleSubmit = async (data) => { + // Send to server to create + // Get back the id + let response = await axios({ + url: "/api/create-dashboard-from-ids", + method: "POST", + headers: { "Content-Type": "application/json" }, + data: JSON.stringify(data), + }).then((res) => res.data); + await router.push(`/dashboards/view/${response.dashboardId}`); + }; + + return ( + +
+ Create a dashboard! + +
+ +
+
+
+ ); +}; + +export default DashboardsPage; diff --git a/src/pages/dashboards/view/[id].tsx b/src/pages/dashboards/view/[id].tsx new file mode 100644 index 0000000..8c60b72 --- /dev/null +++ b/src/pages/dashboards/view/[id].tsx @@ -0,0 +1,115 @@ +import { GetServerSideProps, NextPage } from "next"; +import Error from "next/error"; +import Link from "next/link"; + +import { DashboardItem } from "../../../backend/dashboards"; +import { DisplayForecasts } from "../../../web/display/DisplayForecasts"; +import { InfoBox } from "../../../web/display/InfoBox"; +import { Layout } from "../../../web/display/Layout"; +import { LineHeader } from "../../../web/display/LineHeader"; +import { FrontendForecast } from "../../../web/platforms"; +import { getDashboardForecastsByDashboardId } from "../../../web/worker/getDashboardForecasts"; + +interface Props { + dashboardForecasts: FrontendForecast[]; + dashboardItem: DashboardItem; +} + +export const getServerSideProps: GetServerSideProps = async ( + context +) => { + const dashboardId = context.query.id as string; + + const { dashboardForecasts, dashboardItem } = + await getDashboardForecastsByDashboardId({ + dashboardId, + }); + + if (!dashboardItem) { + context.res.statusCode = 404; + } + + return { + props: { + dashboardForecasts, + dashboardItem, + }, + }; +}; + +const DashboardMetadata: React.FC<{ dashboardItem: DashboardItem }> = ({ + dashboardItem, +}) => ( +
+ {dashboardItem?.title ? ( +

+ {dashboardItem.title} +

+ ) : null} + + {dashboardItem && dashboardItem.creator ? ( +

+ Created by:{" "} + {dashboardItem.creator === "Clay Graubard" ? ( + <> + @ + + Clay Graubard + + + ) : ( + dashboardItem.creator + )} +

+ ) : null} + + {dashboardItem?.description ? ( +

+ {dashboardItem.description} +

+ ) : null} +
+); + +/* Body */ +const ViewDashboardPage: NextPage = ({ + dashboardForecasts, + dashboardItem, +}) => { + return ( + +
+ {dashboardItem ? ( + + ) : ( + + )} + +
+ +
+ +
+ + Dashboards cannot be changed after they are created. + +
+ + + + Create your own dashboard + + +
+
+ ); +}; + +export default ViewDashboardPage; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index f1fa119..786e7a9 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -2,7 +2,7 @@ import { NextPage } from "next"; import React from "react"; import { displayForecastsWrapperForSearch } from "../web/display/displayForecastsWrappers"; -import Layout from "../web/display/layout"; +import { Layout } from "../web/display/Layout"; import { Props } from "../web/search/anySearchPage"; import CommonDisplay from "../web/search/CommonDisplay"; diff --git a/src/pages/recursion.tsx b/src/pages/recursion.tsx index 91aac2a..b9bcb37 100644 --- a/src/pages/recursion.tsx +++ b/src/pages/recursion.tsx @@ -1,6 +1,7 @@ +import { NextPage } from "next"; import React, { useEffect } from "react"; -function Recursion() { +const Recursion: NextPage = () => { useEffect(() => { if (typeof window !== "undefined") { window.location.href = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; @@ -12,6 +13,6 @@ function Recursion() {

You have now reached the fourth level of recursion!!

); -} +}; export default Recursion; diff --git a/src/pages/secretDashboard.tsx b/src/pages/secretDashboard.tsx deleted file mode 100644 index 21e47f4..0000000 --- a/src/pages/secretDashboard.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* Imports */ - -// React -import { useRouter } from "next/router"; // https://nextjs.org/docs/api-reference/next/router -import { useState } from "react"; - -import { getPlatformsConfig } from "../backend/platforms"; -import displayForecasts from "../web/display/displayForecasts"; -import { addLabelsToForecasts } from "../web/platforms"; -import { getDashboardForecastsByDashboardId } from "../web/worker/getDashboardForecasts"; - -/* get Props */ - -export async function getServerSideProps(context) { - console.log("getServerSideProps: "); - let urlQuery = context.query; - console.log(urlQuery); - - let dashboardId = urlQuery.dashboardId; - let numCols = urlQuery.numCols; - let props; - if (!!dashboardId) { - console.log(dashboardId); - let { dashboardForecasts, dashboardItem } = - await getDashboardForecastsByDashboardId({ - dashboardId, - }); - dashboardForecasts = addLabelsToForecasts( - dashboardForecasts, - getPlatformsConfig({ withGuesstimate: false }) - ); - props = { - initialDashboardForecasts: dashboardForecasts, - initialDashboardId: urlQuery.dashboardId, - initialDashboardItem: dashboardItem, - initialNumCols: !numCols ? null : numCols < 5 ? numCols : 4, - }; - } else { - console.log(); - props = { - initialDashboardForecasts: [], - initialDashboardId: urlQuery.dashboardId || null, - initialDashboardItem: null, - initialNumCols: !numCols ? null : numCols < 5 ? numCols : 4, - }; - } - return { - props: props, - }; - /* - let dashboardforecasts = await getdashboardforecasts({ - ids: ["metaculus-6526", "smarkets-20206424"], - }); - let props = { - dashboardforecasts: dashboardforecasts, - }; - - return { - props: props, - }; - */ -} - -/* Body */ -export default function Home({ - initialDashboardForecasts, - initialDashboardItem, - initialNumCols, -}) { - const router = useRouter(); - const [dashboardForecasts, setDashboardForecasts] = useState( - initialDashboardForecasts - ); - const [dashboardItem, setDashboardItem] = useState(initialDashboardItem); - const [numCols, setNumCols] = useState(initialNumCols); - console.log("initialNumCols", initialNumCols); - - //
- //
- - return ( -
-
-
- {displayForecasts({ - results: dashboardForecasts, - numDisplay: dashboardForecasts.length, - showIdToggle: false, - })} -
-
-
- ); -} diff --git a/src/pages/secretEmbed.tsx b/src/pages/secretEmbed.tsx index 6ede915..fa99277 100644 --- a/src/pages/secretEmbed.tsx +++ b/src/pages/secretEmbed.tsx @@ -4,7 +4,7 @@ import { GetServerSideProps, NextPage } from "next"; import React from "react"; import { platforms } from "../backend/platforms"; -import { DisplayForecast } from "../web/display/displayForecasts"; +import { DisplayForecast } from "../web/display/DisplayForecast"; import { FrontendForecast } from "../web/platforms"; import searchAccordingToQueryData from "../web/worker/searchAccordingToQueryData"; diff --git a/src/pages/tools.tsx b/src/pages/tools.tsx index d7e81fa..eef031b 100644 --- a/src/pages/tools.tsx +++ b/src/pages/tools.tsx @@ -1,85 +1,76 @@ import Link from "next/link"; import React from "react"; -import Layout from "../web/display/layout"; +import { Card } from "../web/display/Card"; +import { Layout } from "../web/display/Layout"; + +type AnyTool = { + title: string; + description: string; + img?: string; +}; + +type InnerTool = AnyTool & { innerLink: string }; +type ExternalTool = AnyTool & { externalLink: string }; +type UpcomingTool = AnyTool; + +type Tool = InnerTool | ExternalTool | UpcomingTool; /* Display one tool */ -function displayTool({ - sameWebpage, - title, - description, - link, - url, - img, - i, -}: any) { - switch (sameWebpage) { - case true: - return ( - -
-
-
- {title} -
-
{description}
- {} - -
-
- - ); - break; - default: - return ( - -
-
- {title} -
-
{description}
- {} - -
-
- ); - break; +const ToolCard: React.FC = (tool) => { + const inner = ( + +
+
{tool.title}
+
{tool.description}
+ {tool.img && } +
+
+ ); + + if ("innerLink" in tool) { + return ( + + {inner} + + ); + } else if ("externalLink" in tool) { + return ( + + {inner} + + ); + } else { + return inner; } -} +}; export default function Tools({ lastUpdated }) { - let tools = [ + let tools: Tool[] = [ { title: "Search", - description: "Find forecasting questions on many platforms", - link: "/", - sameWebpage: true, + description: "Find forecasting questions on many platforms.", + innerLink: "/", img: "https://i.imgur.com/Q94gVqG.png", }, { title: "[Beta] Present", description: "Present forecasts in dashboards.", - sameWebpage: true, - link: "/dashboards", + innerLink: "/dashboards", img: "https://i.imgur.com/x8qkuHQ.png", }, { title: "Capture", description: "Capture forecasts save them to Imgur. Useful for posting them somewhere else as images. Currently rate limited by Imgur, so if you get a .gif of a fox falling flat on his face, that's why.", - link: "/capture", - sameWebpage: true, + innerLink: "/capture", img: "https://i.imgur.com/EXkFBzz.png", }, { title: "Summon", description: - "Summon metaforecast on Twitter by mentioning @metaforecast, or on Discord by using Fletcher and !metaforecast, followed by search terms", - url: "https://twitter.com/metaforecast", + "Summon metaforecast on Twitter by mentioning @metaforecast, or on Discord by using Fletcher and !metaforecast, followed by search terms.", + externalLink: "https://twitter.com/metaforecast", img: "https://i.imgur.com/BQ4Zzjw.png", }, { @@ -87,7 +78,6 @@ export default function Tools({ lastUpdated }) { description: "Interact with metaforecast's API and fetch forecasts for your application. Currently possible but documentation is poor, get in touch.", }, - { title: "[Upcoming] Record", description: "Save your forecasts or bets.", @@ -95,8 +85,10 @@ export default function Tools({ lastUpdated }) { ]; return ( -
- {tools.map((tool, i) => displayTool({ ...tool, i }))} +
+ {tools.map((tool, i) => ( + + ))}
); diff --git a/src/web/display/Button.tsx b/src/web/display/Button.tsx new file mode 100644 index 0000000..6508d24 --- /dev/null +++ b/src/web/display/Button.tsx @@ -0,0 +1,10 @@ +interface Props extends React.ButtonHTMLAttributes {} + +export const Button: React.FC = ({ children, ...rest }) => ( + +); diff --git a/src/web/display/buttonsForStars.tsx b/src/web/display/ButtonsForStars.tsx similarity index 88% rename from src/web/display/buttonsForStars.tsx rename to src/web/display/ButtonsForStars.tsx index 471bea1..b76c9d6 100644 --- a/src/web/display/buttonsForStars.tsx +++ b/src/web/display/ButtonsForStars.tsx @@ -5,11 +5,11 @@ interface Props { value: number; } -const ButtonsForStars: React.FC = ({ onChange, value }) => { +export const ButtonsForStars: React.FC = ({ onChange, value }) => { const onChangeInner = (buttonPressed: number) => { onChange(buttonPressed); }; - let setStyle = (buttonNumber: number) => + const setStyle = (buttonNumber: number) => `flex row-span-1 col-start-${buttonNumber + 1} col-end-${ buttonNumber + 2 } items-center justify-center text-center${ @@ -37,5 +37,3 @@ const ButtonsForStars: React.FC = ({ onChange, value }) => {
); }; - -export default ButtonsForStars; diff --git a/src/web/display/Card.tsx b/src/web/display/Card.tsx new file mode 100644 index 0000000..a77614e --- /dev/null +++ b/src/web/display/Card.tsx @@ -0,0 +1,15 @@ +const CardTitle: React.FC = ({ children }) => ( +
{children}
+); + +type CardType = React.FC & { + Title: typeof CardTitle; +}; + +export const Card: CardType = ({ children }) => ( +
+ {children} +
+); + +Card.Title = CardTitle; diff --git a/src/web/display/DashboardCreator.tsx b/src/web/display/DashboardCreator.tsx new file mode 100644 index 0000000..6f49191 --- /dev/null +++ b/src/web/display/DashboardCreator.tsx @@ -0,0 +1,69 @@ +import React, { EventHandler, SyntheticEvent, useState } from "react"; + +import { Button } from "./Button"; +import { InfoBox } from "./InfoBox"; + +const exampleInput = `{ + "title": "Random example", + "description": "Just a random description of a random example", + "ids": [ "metaculus-372", "goodjudgmentopen-2244", "metaculus-7550", "kalshi-09d060ee-b184-4167-b86b-d773e56b4162", "wildeford-5d1a04e1a8", "metaculus-2817" ], + "creator": "Peter Parker" +}`; + +interface Props { + handleSubmit: (data: any) => Promise; +} + +export const DashboardCreator: React.FC = ({ handleSubmit }) => { + const [value, setValue] = useState(exampleInput); + const [acting, setActing] = useState(false); + + const handleChange = (event) => { + setValue(event.target.value); + }; + + const handleSubmitInner: EventHandler = async (event) => { + event.preventDefault(); + + try { + const newData = JSON.parse(value); + + if (!newData || !newData.ids || newData.ids.length == 0) { + throw Error("Not enough objects"); + } else { + setActing(true); + await handleSubmit(newData); + setActing(false); + } + } catch (error) { + setActing(false); + const substituteText = `Error: ${error.message} + +Try something like: +${exampleInput} + +Your old input was: ${value}`; + setValue(substituteText); + } + }; + + return ( +
+
+