Merge pull request #74 from quantified-uncertainty/questions-page-2
Question pages and charts
This commit is contained in:
commit
44fbd0cd2b
6
.eslintrc
Normal file
6
.eslintrc
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends": ["next", "prettier"],
|
||||||
|
"rules": {
|
||||||
|
"next/no-document-import-in-page": "off"
|
||||||
|
}
|
||||||
|
}
|
1131
package-lock.json
generated
1131
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
@ -27,6 +27,7 @@
|
||||||
"dbshell": ". .env && psql $DIGITALOCEAN_POSTGRES"
|
"dbshell": ". .env && psql $DIGITALOCEAN_POSTGRES"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^0.7.0",
|
||||||
"@graphql-yoga/node": "^2.1.0",
|
"@graphql-yoga/node": "^2.1.0",
|
||||||
"@pothos/core": "^3.5.1",
|
"@pothos/core": "^3.5.1",
|
||||||
"@pothos/plugin-prisma": "^3.4.0",
|
"@pothos/plugin-prisma": "^3.4.0",
|
||||||
|
@ -34,15 +35,18 @@
|
||||||
"@prisma/client": "^3.11.1",
|
"@prisma/client": "^3.11.1",
|
||||||
"@tailwindcss/forms": "^0.4.0",
|
"@tailwindcss/forms": "^0.4.0",
|
||||||
"@tailwindcss/typography": "^0.5.1",
|
"@tailwindcss/typography": "^0.5.1",
|
||||||
|
"@types/dom-to-image": "^2.6.4",
|
||||||
"@types/jsdom": "^16.2.14",
|
"@types/jsdom": "^16.2.14",
|
||||||
"@types/nprogress": "^0.2.0",
|
"@types/nprogress": "^0.2.0",
|
||||||
"@types/react": "^17.0.39",
|
"@types/react": "^17.0.39",
|
||||||
|
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||||
"airtable": "^0.11.1",
|
"airtable": "^0.11.1",
|
||||||
"algoliasearch": "^4.10.3",
|
"algoliasearch": "^4.10.3",
|
||||||
"autoprefixer": "^10.1.0",
|
"autoprefixer": "^10.1.0",
|
||||||
"axios": "^0.25.0",
|
"axios": "^0.25.0",
|
||||||
"chroma-js": "^2.4.2",
|
"chroma-js": "^2.4.2",
|
||||||
"critters": "^0.0.16",
|
"critters": "^0.0.16",
|
||||||
|
"date-fns": "^2.28.0",
|
||||||
"dom-to-image": "^2.6.0",
|
"dom-to-image": "^2.6.0",
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"fetch": "^1.1.0",
|
"fetch": "^1.1.0",
|
||||||
|
@ -89,7 +93,8 @@
|
||||||
"ts-node": "^10.7.0",
|
"ts-node": "^10.7.0",
|
||||||
"tunnel": "^0.0.6",
|
"tunnel": "^0.0.6",
|
||||||
"urql": "^2.2.0",
|
"urql": "^2.2.0",
|
||||||
"urql-custom-scalars-exchange": "^0.1.5"
|
"urql-custom-scalars-exchange": "^0.1.5",
|
||||||
|
"victory": "^36.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@graphql-codegen/cli": "^2.6.2",
|
"@graphql-codegen/cli": "^2.6.2",
|
||||||
|
|
BIN
public/screenshots/capture.png
Normal file
BIN
public/screenshots/capture.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.0 MiB |
BIN
public/screenshots/dashboard.png
Normal file
BIN
public/screenshots/dashboard.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 426 KiB |
BIN
public/screenshots/frontpage.png
Normal file
BIN
public/screenshots/frontpage.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 452 KiB |
BIN
public/screenshots/twitter.png
Normal file
BIN
public/screenshots/twitter.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 594 KiB |
|
@ -19,11 +19,14 @@ export async function getFrontpage(): Promise<Question[]> {
|
||||||
export async function rebuildFrontpage() {
|
export async function rebuildFrontpage() {
|
||||||
await measureTime(async () => {
|
await measureTime(async () => {
|
||||||
const rows = await prisma.$queryRaw<{ id: string }[]>`
|
const rows = await prisma.$queryRaw<{ id: string }[]>`
|
||||||
SELECT id FROM questions
|
SELECT questions.id FROM questions, history
|
||||||
WHERE
|
WHERE
|
||||||
(qualityindicators->>'stars')::int >= 3
|
questions.id = history.id
|
||||||
AND description != ''
|
AND (questions.qualityindicators->>'stars')::int >= 3
|
||||||
AND JSONB_ARRAY_LENGTH(options) > 0
|
AND questions.description != ''
|
||||||
|
AND JSONB_ARRAY_LENGTH(questions.options) > 0
|
||||||
|
GROUP BY questions.id
|
||||||
|
HAVING COUNT(DISTINCT history.timestamp) >= 7
|
||||||
ORDER BY RANDOM() LIMIT 50
|
ORDER BY RANDOM() LIMIT 50
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
103
src/backend/platforms/guesstimate.ts
Normal file
103
src/backend/platforms/guesstimate.ts
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import axios from "axios";
|
||||||
|
import { parseISO } from "date-fns";
|
||||||
|
|
||||||
|
/* Imports */
|
||||||
|
import { Question } from "@prisma/client";
|
||||||
|
|
||||||
|
import { AlgoliaQuestion } from "../../backend/utils/algolia";
|
||||||
|
import { prisma } from "../database/prisma";
|
||||||
|
import { Platform } from "./";
|
||||||
|
|
||||||
|
/* Definitions */
|
||||||
|
const searchEndpoint =
|
||||||
|
"https://m629r9ugsg-dsn.algolia.net/1/indexes/Space_production/query?x-algolia-agent=Algolia%20for%20vanilla%20JavaScript%203.32.1&x-algolia-application-id=M629R9UGSG&x-algolia-api-key=4e893740a2bd467a96c8bfcf95b2809c";
|
||||||
|
|
||||||
|
const apiEndpoint = "https://guesstimate.herokuapp.com";
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
|
||||||
|
const modelToQuestion = (model: any): Question => {
|
||||||
|
const { description } = model;
|
||||||
|
// const description = model.description
|
||||||
|
// ? model.description.replace(/\n/g, " ").replace(/ /g, " ")
|
||||||
|
// : "";
|
||||||
|
const stars = description.length > 250 ? 2 : 1;
|
||||||
|
const timestamp = parseISO(model.created_at);
|
||||||
|
const q: Question = {
|
||||||
|
id: `guesstimate-${model.id}`,
|
||||||
|
title: model.name,
|
||||||
|
url: `https://www.getguesstimate.com/models/${model.id}`,
|
||||||
|
timestamp,
|
||||||
|
platform: "guesstimate",
|
||||||
|
description,
|
||||||
|
options: [],
|
||||||
|
qualityindicators: {
|
||||||
|
stars,
|
||||||
|
numforecasts: 1,
|
||||||
|
numforecasters: 1,
|
||||||
|
},
|
||||||
|
extra: {
|
||||||
|
visualization: model.big_screenshot,
|
||||||
|
},
|
||||||
|
// ranking: 10 * (index + 1) - 0.5, //(model._rankingInfo - 1*index)// hack
|
||||||
|
};
|
||||||
|
return q;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function search(query: string): Promise<AlgoliaQuestion[]> {
|
||||||
|
const response = await axios({
|
||||||
|
url: searchEndpoint,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
data: `{\"params\":\"query=${query.replace(
|
||||||
|
/ /g,
|
||||||
|
"%20"
|
||||||
|
)}&hitsPerPage=20&page=0&getRankingInfo=true\"}`,
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
const models: any[] = response.data.hits;
|
||||||
|
const mappedModels: AlgoliaQuestion[] = models.map((model) => {
|
||||||
|
const q = modelToQuestion(model);
|
||||||
|
return {
|
||||||
|
...q,
|
||||||
|
timestamp: String(q.timestamp),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// filter for duplicates. Surprisingly common.
|
||||||
|
let uniqueTitles = [];
|
||||||
|
let uniqueModels: AlgoliaQuestion[] = [];
|
||||||
|
for (let model of mappedModels) {
|
||||||
|
if (!uniqueTitles.includes(model.title) && !model.title.includes("copy")) {
|
||||||
|
uniqueModels.push(model);
|
||||||
|
uniqueTitles.push(model.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchQuestion = async (id: number): Promise<Question> => {
|
||||||
|
const response = await axios({ url: `${apiEndpoint}/spaces/${id}` });
|
||||||
|
let q = modelToQuestion(response.data);
|
||||||
|
q = await prisma.question.upsert({
|
||||||
|
where: { id: q.id },
|
||||||
|
create: q,
|
||||||
|
update: q,
|
||||||
|
});
|
||||||
|
return q;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guesstimate: Platform & {
|
||||||
|
search: typeof search;
|
||||||
|
fetchQuestion: typeof fetchQuestion;
|
||||||
|
} = {
|
||||||
|
name: "guesstimate",
|
||||||
|
label: "Guesstimate",
|
||||||
|
color: "#223900",
|
||||||
|
search,
|
||||||
|
fetchQuestion,
|
||||||
|
};
|
|
@ -7,6 +7,7 @@ import { foretold } from "./foretold";
|
||||||
import { givewellopenphil } from "./givewellopenphil";
|
import { givewellopenphil } from "./givewellopenphil";
|
||||||
import { goodjudgment } from "./goodjudgment";
|
import { goodjudgment } from "./goodjudgment";
|
||||||
import { goodjudgmentopen } from "./goodjudgmentopen";
|
import { goodjudgmentopen } from "./goodjudgmentopen";
|
||||||
|
import { guesstimate } from "./guesstimate";
|
||||||
import { infer } from "./infer";
|
import { infer } from "./infer";
|
||||||
import { kalshi } from "./kalshi";
|
import { kalshi } from "./kalshi";
|
||||||
import { manifold } from "./manifold";
|
import { manifold } from "./manifold";
|
||||||
|
@ -72,6 +73,7 @@ export const platforms: Platform[] = [
|
||||||
givewellopenphil,
|
givewellopenphil,
|
||||||
goodjudgment,
|
goodjudgment,
|
||||||
goodjudgmentopen,
|
goodjudgmentopen,
|
||||||
|
guesstimate,
|
||||||
infer,
|
infer,
|
||||||
kalshi,
|
kalshi,
|
||||||
manifold,
|
manifold,
|
||||||
|
@ -161,21 +163,12 @@ export interface PlatformConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
// get frontend-safe version of platforms data
|
// get frontend-safe version of platforms data
|
||||||
export const getPlatformsConfig = (options: {
|
export const getPlatformsConfig = (): PlatformConfig[] => {
|
||||||
withGuesstimate: boolean;
|
|
||||||
}): PlatformConfig[] => {
|
|
||||||
const platformsConfig = platforms.map((platform) => ({
|
const platformsConfig = platforms.map((platform) => ({
|
||||||
name: platform.name,
|
name: platform.name,
|
||||||
label: platform.label,
|
label: platform.label,
|
||||||
color: platform.color,
|
color: platform.color,
|
||||||
}));
|
}));
|
||||||
if (options.withGuesstimate) {
|
|
||||||
platformsConfig.push({
|
|
||||||
name: "guesstimate",
|
|
||||||
label: "Guesstimate",
|
|
||||||
color: "223900",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return platformsConfig;
|
return platformsConfig;
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { History, Question } from "@prisma/client";
|
||||||
|
|
||||||
import { prisma } from "../../backend/database/prisma";
|
import { prisma } from "../../backend/database/prisma";
|
||||||
import { QualityIndicators } from "../../backend/platforms";
|
import { QualityIndicators } from "../../backend/platforms";
|
||||||
|
import { guesstimate } from "../../backend/platforms/guesstimate";
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { PlatformObj } from "./platforms";
|
import { PlatformObj } from "./platforms";
|
||||||
|
|
||||||
|
@ -113,7 +114,13 @@ export const QuestionObj = builder.prismaObject("Question", {
|
||||||
resolve: (parent) => (parent.extra as any)?.visualization, // used for guesstimate only, see searchGuesstimate.ts
|
resolve: (parent) => (parent.extra as any)?.visualization, // used for guesstimate only, see searchGuesstimate.ts
|
||||||
nullable: true,
|
nullable: true,
|
||||||
}),
|
}),
|
||||||
history: t.relation("history", {}),
|
history: t.relation("history", {
|
||||||
|
query: () => ({
|
||||||
|
orderBy: {
|
||||||
|
timestamp: "asc",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -138,6 +145,13 @@ builder.queryField("question", (t) =>
|
||||||
id: t.arg({ type: "ID", required: true }),
|
id: t.arg({ type: "ID", required: true }),
|
||||||
},
|
},
|
||||||
resolve: async (parent, args) => {
|
resolve: async (parent, args) => {
|
||||||
|
const parts = String(args.id).split("-");
|
||||||
|
const [platform, id] = [parts[0], parts.slice(1).join("-")];
|
||||||
|
if (platform === "guesstimate") {
|
||||||
|
const q = await guesstimate.fetchQuestion(Number(id));
|
||||||
|
console.log(q);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
return await prisma.question.findUnique({
|
return await prisma.question.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id: String(args.id),
|
id: String(args.id),
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { guesstimate } from "../../backend/platforms/guesstimate";
|
||||||
import { AlgoliaQuestion } from "../../backend/utils/algolia";
|
import { AlgoliaQuestion } from "../../backend/utils/algolia";
|
||||||
import searchGuesstimate from "../../web/worker/searchGuesstimate";
|
|
||||||
import searchWithAlgolia from "../../web/worker/searchWithAlgolia";
|
import searchWithAlgolia from "../../web/worker/searchWithAlgolia";
|
||||||
import { builder } from "../builder";
|
import { builder } from "../builder";
|
||||||
import { QuestionObj } from "./questions";
|
import { QuestionObj } from "./questions";
|
||||||
|
@ -54,7 +54,7 @@ builder.queryField("searchQuestions", (t) =>
|
||||||
const [responsesNotGuesstimate, responsesGuesstimate] =
|
const [responsesNotGuesstimate, responsesGuesstimate] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
unawaitedAlgoliaResponse,
|
unawaitedAlgoliaResponse,
|
||||||
searchGuesstimate(query),
|
guesstimate.search(query),
|
||||||
]); // faster than two separate requests
|
]); // faster than two separate requests
|
||||||
results = [...responsesNotGuesstimate, ...responsesGuesstimate];
|
results = [...responsesNotGuesstimate, ...responsesGuesstimate];
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const { pathname, searchParams } = req.nextUrl;
|
const { pathname, searchParams } = req.nextUrl;
|
||||||
|
|
||||||
console.log(pathname);
|
|
||||||
if (pathname === "/dashboards") {
|
if (pathname === "/dashboards") {
|
||||||
const dashboardId = searchParams.get("dashboardId");
|
const dashboardId = searchParams.get("dashboardId");
|
||||||
if (dashboardId) {
|
if (dashboardId) {
|
||||||
|
@ -12,6 +11,8 @@ export async function middleware(req: NextRequest) {
|
||||||
new URL(`/dashboards/view/${dashboardId}`, req.url)
|
new URL(`/dashboards/view/${dashboardId}`, req.url)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (pathname === "/capture") {
|
||||||
|
return NextResponse.redirect(new URL("/", req.url));
|
||||||
} else if (pathname === "/secretDashboard") {
|
} else if (pathname === "/secretDashboard") {
|
||||||
const dashboardId = searchParams.get("dashboardId");
|
const dashboardId = searchParams.get("dashboardId");
|
||||||
if (dashboardId) {
|
if (dashboardId) {
|
||||||
|
|
|
@ -3,8 +3,8 @@ import React from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import gfm from "remark-gfm";
|
import gfm from "remark-gfm";
|
||||||
|
|
||||||
import { Card } from "../web/display/Card";
|
import { Card } from "../web/common/Card";
|
||||||
import { Layout } from "../web/display/Layout";
|
import { Layout } from "../web/common/Layout";
|
||||||
|
|
||||||
const readmeMarkdownText = `# About
|
const readmeMarkdownText = `# About
|
||||||
|
|
||||||
|
@ -31,10 +31,12 @@ Also note that, whatever other redeeming features they might have, prediction ma
|
||||||
const AboutPage: NextPage = () => {
|
const AboutPage: NextPage = () => {
|
||||||
return (
|
return (
|
||||||
<Layout page="about">
|
<Layout page="about">
|
||||||
<Card highlightOnHover={false}>
|
<Card highlightOnHover={false} large={true}>
|
||||||
<div className="p-4">
|
<ReactMarkdown
|
||||||
<ReactMarkdown remarkPlugins={[gfm]} children={readmeMarkdownText} />
|
remarkPlugins={[gfm]}
|
||||||
</div>
|
children={readmeMarkdownText}
|
||||||
|
className="max-w-prose mx-auto"
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,27 +0,0 @@
|
||||||
import { NextPage } from "next";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
import { displayQuestionsWrapperForCapture } from "../web/display/displayQuestionsWrappers";
|
|
||||||
import { Layout } from "../web/display/Layout";
|
|
||||||
import { Props } from "../web/search/anySearchPage";
|
|
||||||
import CommonDisplay from "../web/search/CommonDisplay";
|
|
||||||
|
|
||||||
export { getServerSideProps } from "../web/search/anySearchPage";
|
|
||||||
|
|
||||||
const CapturePage: NextPage<Props> = (props) => {
|
|
||||||
return (
|
|
||||||
<Layout page="capture">
|
|
||||||
<CommonDisplay
|
|
||||||
{...props}
|
|
||||||
hasSearchbar={true}
|
|
||||||
hasCapture={true}
|
|
||||||
hasAdvancedOptions={false}
|
|
||||||
placeholder="Get best title match..."
|
|
||||||
displaySeeMoreHint={false}
|
|
||||||
displayQuestionsWrapper={displayQuestionsWrapperForCapture}
|
|
||||||
/>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CapturePage;
|
|
|
@ -4,7 +4,7 @@ import Error from "next/error";
|
||||||
import {
|
import {
|
||||||
DashboardByIdDocument, DashboardFragment
|
DashboardByIdDocument, DashboardFragment
|
||||||
} from "../../../web/dashboards/queries.generated";
|
} from "../../../web/dashboards/queries.generated";
|
||||||
import { DisplayQuestions } from "../../../web/display/DisplayQuestions";
|
import { QuestionCardsList } from "../../../web/questions/components/QuestionCardsList";
|
||||||
import { ssrUrql } from "../../../web/urql";
|
import { ssrUrql } from "../../../web/urql";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -52,7 +52,7 @@ const EmbedDashboardPage: NextPage<Props> = ({ dashboard, numCols }) => {
|
||||||
numCols || 3
|
numCols || 3
|
||||||
} gap-4 mb-6`}
|
} gap-4 mb-6`}
|
||||||
>
|
>
|
||||||
<DisplayQuestions
|
<QuestionCardsList
|
||||||
results={dashboard.questions}
|
results={dashboard.questions}
|
||||||
numDisplay={dashboard.questions.length}
|
numDisplay={dashboard.questions.length}
|
||||||
showIdToggle={false}
|
showIdToggle={false}
|
||||||
|
|
|
@ -2,10 +2,10 @@ import { NextPage } from "next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useMutation } from "urql";
|
import { useMutation } from "urql";
|
||||||
|
|
||||||
|
import { Layout } from "../../web/common/Layout";
|
||||||
|
import { LineHeader } from "../../web/common/LineHeader";
|
||||||
import { CreateDashboardDocument } from "../../web/dashboards/queries.generated";
|
import { CreateDashboardDocument } from "../../web/dashboards/queries.generated";
|
||||||
import { DashboardCreator } from "../../web/display/DashboardCreator";
|
import { DashboardCreator } from "../../web/display/DashboardCreator";
|
||||||
import { Layout } from "../../web/display/Layout";
|
|
||||||
import { LineHeader } from "../../web/display/LineHeader";
|
|
||||||
|
|
||||||
const DashboardsPage: NextPage = () => {
|
const DashboardsPage: NextPage = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
|
@ -2,13 +2,13 @@ import { GetServerSideProps, NextPage } from "next";
|
||||||
import Error from "next/error";
|
import Error from "next/error";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { InfoBox } from "../../../web/common/InfoBox";
|
||||||
|
import { Layout } from "../../../web/common/Layout";
|
||||||
|
import { LineHeader } from "../../../web/common/LineHeader";
|
||||||
import {
|
import {
|
||||||
DashboardByIdDocument, DashboardFragment
|
DashboardByIdDocument, DashboardFragment
|
||||||
} from "../../../web/dashboards/queries.generated";
|
} from "../../../web/dashboards/queries.generated";
|
||||||
import { DisplayQuestions } from "../../../web/display/DisplayQuestions";
|
import { QuestionCardsList } from "../../../web/questions/components/QuestionCardsList";
|
||||||
import { InfoBox } from "../../../web/display/InfoBox";
|
|
||||||
import { Layout } from "../../../web/display/Layout";
|
|
||||||
import { LineHeader } from "../../../web/display/LineHeader";
|
|
||||||
import { ssrUrql } from "../../../web/urql";
|
import { ssrUrql } from "../../../web/urql";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -84,7 +84,7 @@ const ViewDashboardPage: NextPage<Props> = ({ dashboard }) => {
|
||||||
<>
|
<>
|
||||||
<DashboardMetadata dashboard={dashboard} />
|
<DashboardMetadata dashboard={dashboard} />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<DisplayQuestions
|
<QuestionCardsList
|
||||||
results={dashboard.questions}
|
results={dashboard.questions}
|
||||||
numDisplay={dashboard.questions.length}
|
numDisplay={dashboard.questions.length}
|
||||||
showIdToggle={false}
|
showIdToggle={false}
|
||||||
|
|
|
@ -1,25 +1,86 @@
|
||||||
import { NextPage } from "next";
|
import { GetServerSideProps, NextPage } from "next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { displayQuestionsWrapperForSearch } from "../web/display/displayQuestionsWrappers";
|
import { getPlatformsConfig, platforms } from "../backend/platforms";
|
||||||
import { Layout } from "../web/display/Layout";
|
import { Layout } from "../web/common/Layout";
|
||||||
import { Props } from "../web/search/anySearchPage";
|
import { Props, QueryParameters, SearchScreen } from "../web/search/components/SearchScreen";
|
||||||
import CommonDisplay from "../web/search/CommonDisplay";
|
import { FrontpageDocument, SearchDocument } from "../web/search/queries.generated";
|
||||||
|
import { ssrUrql } from "../web/urql";
|
||||||
|
|
||||||
export { getServerSideProps } from "../web/search/anySearchPage";
|
export const getServerSideProps: GetServerSideProps<Props> = async (
|
||||||
|
context
|
||||||
|
) => {
|
||||||
|
const [ssrCache, client] = ssrUrql();
|
||||||
|
const urlQuery = context.query;
|
||||||
|
|
||||||
|
const platformsConfig = getPlatformsConfig();
|
||||||
|
|
||||||
|
const defaultQueryParameters: QueryParameters = {
|
||||||
|
query: "",
|
||||||
|
starsThreshold: 2,
|
||||||
|
forecastsThreshold: 0,
|
||||||
|
forecastingPlatforms: platforms.map((platform) => platform.name),
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialQueryParameters: QueryParameters = {
|
||||||
|
...defaultQueryParameters,
|
||||||
|
};
|
||||||
|
if (urlQuery.query) {
|
||||||
|
initialQueryParameters.query = String(urlQuery.query);
|
||||||
|
}
|
||||||
|
if (urlQuery.starsThreshold) {
|
||||||
|
initialQueryParameters.starsThreshold = Number(urlQuery.starsThreshold);
|
||||||
|
}
|
||||||
|
if (urlQuery.forecastsThreshold !== undefined) {
|
||||||
|
initialQueryParameters.forecastsThreshold = Number(
|
||||||
|
urlQuery.forecastsThreshold
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (urlQuery.forecastingPlatforms !== undefined) {
|
||||||
|
initialQueryParameters.forecastingPlatforms = String(
|
||||||
|
urlQuery.forecastingPlatforms
|
||||||
|
).split("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultNumDisplay = 21;
|
||||||
|
const initialNumDisplay = Number(urlQuery.numDisplay) || defaultNumDisplay;
|
||||||
|
|
||||||
|
const defaultResults = (await client.query(FrontpageDocument).toPromise())
|
||||||
|
.data.result;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!!initialQueryParameters &&
|
||||||
|
initialQueryParameters.query != "" &&
|
||||||
|
initialQueryParameters.query != undefined
|
||||||
|
) {
|
||||||
|
// must match the query from CommonDisplay
|
||||||
|
await client
|
||||||
|
.query(SearchDocument, {
|
||||||
|
input: {
|
||||||
|
...initialQueryParameters,
|
||||||
|
limit: initialNumDisplay,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.toPromise();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
urqlState: ssrCache.extractData(),
|
||||||
|
initialQueryParameters,
|
||||||
|
defaultQueryParameters,
|
||||||
|
initialNumDisplay,
|
||||||
|
defaultNumDisplay,
|
||||||
|
defaultResults,
|
||||||
|
platformsConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const IndexPage: NextPage<Props> = (props) => {
|
const IndexPage: NextPage<Props> = (props) => {
|
||||||
return (
|
return (
|
||||||
<Layout page="search">
|
<Layout page="search">
|
||||||
<CommonDisplay
|
<SearchScreen {...props} />
|
||||||
{...props}
|
|
||||||
hasSearchbar={true}
|
|
||||||
hasCapture={false}
|
|
||||||
hasAdvancedOptions={true}
|
|
||||||
placeholder={"Find forecasts about..."}
|
|
||||||
displaySeeMoreHint={true}
|
|
||||||
displayQuestionsWrapper={displayQuestionsWrapperForSearch}
|
|
||||||
/>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,8 +4,9 @@ import { GetServerSideProps, NextPage } from "next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { platforms } from "../backend/platforms";
|
import { platforms } from "../backend/platforms";
|
||||||
import { DisplayQuestion } from "../web/display/DisplayQuestion";
|
import { QuestionFragment } from "../web/fragments.generated";
|
||||||
import { QuestionFragment, SearchDocument } from "../web/search/queries.generated";
|
import { QuestionCard } from "../web/questions/components/QuestionCard";
|
||||||
|
import { SearchDocument } from "../web/search/queries.generated";
|
||||||
import { ssrUrql } from "../web/urql";
|
import { ssrUrql } from "../web/urql";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -57,7 +58,7 @@ const SecretEmbedPage: NextPage<Props> = ({ results }) => {
|
||||||
<div>
|
<div>
|
||||||
<div id="secretEmbed">
|
<div id="secretEmbed">
|
||||||
{result ? (
|
{result ? (
|
||||||
<DisplayQuestion
|
<QuestionCard
|
||||||
question={result}
|
question={result}
|
||||||
showTimeStamp={true}
|
showTimeStamp={true}
|
||||||
expandFooterToFullWidth={true}
|
expandFooterToFullWidth={true}
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Card } from "../web/display/Card";
|
import captureImg from "../../public/screenshots/capture.png";
|
||||||
import { Layout } from "../web/display/Layout";
|
import dashboardImg from "../../public/screenshots/dashboard.png";
|
||||||
|
import frontpageImg from "../../public/screenshots/frontpage.png";
|
||||||
|
import twitterImg from "../../public/screenshots/twitter.png";
|
||||||
|
import { Card } from "../web/common/Card";
|
||||||
|
import { Layout } from "../web/common/Layout";
|
||||||
|
|
||||||
type AnyTool = {
|
type AnyTool = {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
img?: string;
|
img?: StaticImageData;
|
||||||
};
|
};
|
||||||
|
|
||||||
type InnerTool = AnyTool & { innerLink: string };
|
type InnerTool = AnyTool & { innerLink: string };
|
||||||
|
@ -24,7 +29,7 @@ const ToolCard: React.FC<Tool> = (tool) => {
|
||||||
<div className="grid content-start gap-3">
|
<div className="grid content-start gap-3">
|
||||||
<div className="text-gray-800 text-lg font-medium">{tool.title}</div>
|
<div className="text-gray-800 text-lg font-medium">{tool.title}</div>
|
||||||
<div className="text-gray-500">{tool.description}</div>
|
<div className="text-gray-500">{tool.description}</div>
|
||||||
{tool.img && <img src={tool.img} className="text-gray-500" />}
|
{tool.img && <Image src={tool.img} className="text-gray-500" />}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
@ -52,32 +57,33 @@ const ToolsPage: NextPage = () => {
|
||||||
title: "Search",
|
title: "Search",
|
||||||
description: "Find forecasting questions on many platforms.",
|
description: "Find forecasting questions on many platforms.",
|
||||||
innerLink: "/",
|
innerLink: "/",
|
||||||
img: "https://i.imgur.com/Q94gVqG.png",
|
img: frontpageImg,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "[Beta] Present",
|
title: "[Beta] Present",
|
||||||
description: "Present forecasts in dashboards.",
|
description: "Present forecasts in dashboards.",
|
||||||
innerLink: "/dashboards",
|
innerLink: "/dashboards",
|
||||||
img: "https://i.imgur.com/x8qkuHQ.png",
|
img: dashboardImg,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Capture",
|
title: "Capture",
|
||||||
description:
|
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.",
|
"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. Capture button can be found on individual questions pages.",
|
||||||
innerLink: "/capture",
|
innerLink: "/",
|
||||||
img: "https://i.imgur.com/EXkFBzz.png",
|
img: captureImg,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Summon",
|
title: "Summon",
|
||||||
description:
|
description:
|
||||||
"Summon metaforecast on Twitter by mentioning @metaforecast, or on Discord by using Fletcher and !metaforecast, followed by search terms.",
|
"Summon metaforecast on Twitter by mentioning @metaforecast, or on Discord by using Fletcher and !metaforecast, followed by search terms.",
|
||||||
externalLink: "https://twitter.com/metaforecast",
|
externalLink: "https://twitter.com/metaforecast",
|
||||||
img: "https://i.imgur.com/BQ4Zzjw.png",
|
img: twitterImg,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "[Upcoming] Request",
|
title: "[Beta] Request",
|
||||||
description:
|
description:
|
||||||
"Interact with metaforecast's API and fetch forecasts for your application. Currently possible but documentation is poor, get in touch.",
|
"Interact with metaforecast's GraphQL API and fetch forecasts for your application. Currently possible but documentation is poor, get in touch.",
|
||||||
|
externalLink: "/api/graphql",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "[Upcoming] Record",
|
title: "[Upcoming] Record",
|
||||||
|
|
19
src/web/common/Button.tsx
Normal file
19
src/web/common/Button.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
size?: "small" | "normal";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<Props> = ({
|
||||||
|
children,
|
||||||
|
size = "normal",
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
const padding = size === "normal" ? "px-5 py-4" : "px-3 py-2";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...rest}
|
||||||
|
className={`bg-blue-500 cursor-pointer rounded-md shadow text-white hover:bg-blue-600 active:bg-gray-700 ${padding}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
|
@ -4,17 +4,22 @@ const CardTitle: React.FC = ({ children }) => (
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
highlightOnHover?: boolean;
|
highlightOnHover?: boolean;
|
||||||
|
large?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardType = React.FC<Props> & {
|
type CardType = React.FC<Props> & {
|
||||||
Title: typeof CardTitle;
|
Title: typeof CardTitle;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Card: CardType = ({ children, highlightOnHover = true }) => (
|
export const Card: CardType = ({
|
||||||
|
children,
|
||||||
|
large = false,
|
||||||
|
highlightOnHover = true,
|
||||||
|
}) => (
|
||||||
<div
|
<div
|
||||||
className={`h-full px-4 py-3 bg-white rounded-md shadow ${
|
className={`h-full bg-white rounded-md shadow ${
|
||||||
highlightOnHover ? "hover:bg-gray-100" : ""
|
highlightOnHover ? "hover:bg-gray-100" : ""
|
||||||
}`}
|
} ${large ? "p-5 sm:p-10" : "px-4 py-3"}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
35
src/web/common/CopyParagraph.tsx
Normal file
35
src/web/common/CopyParagraph.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||||
|
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/39501289/in-reactjs-how-to-copy-text-to-clipboard
|
||||||
|
|
||||||
|
export const CopyParagraph: React.FC<{ text: string; buttonText: string }> = ({
|
||||||
|
text,
|
||||||
|
buttonText: initialButtonText,
|
||||||
|
}) => {
|
||||||
|
const [buttonText, setButtonText] = useState(initialButtonText);
|
||||||
|
const handleButton = () => {
|
||||||
|
setButtonText("Copied");
|
||||||
|
setTimeout(async () => {
|
||||||
|
setButtonText(initialButtonText);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-stretch">
|
||||||
|
<p
|
||||||
|
className="bg-gray-100 cursor-pointer px-3 py-2 rounded-md shadow text-gray-700 font-mono text-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
<CopyToClipboard text={text} onCopy={handleButton}>
|
||||||
|
<Button size="small">{buttonText}</Button>
|
||||||
|
</CopyToClipboard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -5,6 +5,8 @@ interface Props {
|
||||||
displayText: string;
|
displayText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/39501289/in-reactjs-how-to-copy-text-to-clipboard
|
||||||
|
|
||||||
export const CopyText: React.FC<Props> = ({ text, displayText }) => (
|
export const CopyText: React.FC<Props> = ({ text, displayText }) => (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center p-4 space-x-3 border rounded border-blue-400 hover:border-transparent bg-transparent hover:bg-blue-300 text-sm font-medium text-blue-400 hover:text-white cursor-pointer"
|
className="flex items-center justify-center p-4 space-x-3 border rounded border-blue-400 hover:border-transparent bg-transparent hover:bg-blue-300 text-sm font-medium text-blue-400 hover:text-white cursor-pointer"
|
||||||
|
|
|
@ -151,7 +151,7 @@ export const Layout: React.FC<Props> = ({ page, children }) => {
|
||||||
</nav>
|
</nav>
|
||||||
<main>
|
<main>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<div className="container max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-5">
|
<div className="container max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-5 mb-10">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
|
@ -1,10 +1,16 @@
|
||||||
import chroma from "chroma-js";
|
import chroma from "chroma-js";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import Select from "react-select";
|
import Select, { StylesConfig } from "react-select";
|
||||||
|
|
||||||
import { PlatformConfig } from "../../backend/platforms";
|
import { PlatformConfig } from "../../backend/platforms";
|
||||||
|
|
||||||
const colourStyles = {
|
type Option = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const colourStyles: StylesConfig<Option> = {
|
||||||
control: (styles) => ({ ...styles, backgroundColor: "white" }),
|
control: (styles) => ({ ...styles, backgroundColor: "white" }),
|
||||||
option: (styles, { data, isDisabled, isFocused, isSelected }) => {
|
option: (styles, { data, isDisabled, isFocused, isSelected }) => {
|
||||||
const color = chroma(data.color);
|
const color = chroma(data.color);
|
||||||
|
@ -70,12 +76,6 @@ export const MultiSelectPlatform: React.FC<Props> = ({
|
||||||
value,
|
value,
|
||||||
platformsConfig,
|
platformsConfig,
|
||||||
}) => {
|
}) => {
|
||||||
type Option = {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const options: Option[] = platformsConfig.map((platform) => ({
|
const options: Option[] = platformsConfig.map((platform) => ({
|
||||||
value: platform.name,
|
value: platform.name,
|
||||||
label: platform.label,
|
label: platform.label,
|
|
@ -1,7 +1,7 @@
|
||||||
import * as Types from '../../graphql/types.generated';
|
import * as Types from '../../graphql/types.generated';
|
||||||
|
|
||||||
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||||
import { QuestionFragmentDoc } from '../search/queries.generated';
|
import { QuestionFragmentDoc } from '../fragments.generated';
|
||||||
export type DashboardFragment = { __typename?: 'Dashboard', id: string, title: string, description: string, creator: string, questions: Array<{ __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } }> };
|
export type DashboardFragment = { __typename?: 'Dashboard', id: string, title: string, description: string, creator: string, questions: Array<{ __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } }> };
|
||||||
|
|
||||||
export type DashboardByIdQueryVariables = Types.Exact<{
|
export type DashboardByIdQueryVariables = Types.Exact<{
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
|
||||||
|
|
||||||
export const Button: React.FC<Props> = ({ children, ...rest }) => (
|
|
||||||
<button
|
|
||||||
{...rest}
|
|
||||||
className="bg-transparent hover:bg-blue-500 text-blue-700 font-semibold hover:text-white py-2 px-4 border border-blue-500 hover:border-transparent rounded text-center"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { EventHandler, SyntheticEvent, useState } from "react";
|
import React, { EventHandler, SyntheticEvent, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "../common/Button";
|
||||||
import { InfoBox } from "./InfoBox";
|
import { InfoBox } from "../common/InfoBox";
|
||||||
|
|
||||||
const exampleInput = `{
|
const exampleInput = `{
|
||||||
"title": "Random example",
|
"title": "Random example",
|
||||||
|
|
|
@ -1,253 +0,0 @@
|
||||||
import domtoimage from "dom-to-image"; // https://github.com/tsayen/dom-to-image
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
|
||||||
|
|
||||||
import { QuestionFragment } from "../search/queries.generated";
|
|
||||||
import { uploadToImgur } from "../worker/uploadToImgur";
|
|
||||||
import { DisplayQuestion } from "./DisplayQuestion";
|
|
||||||
|
|
||||||
function displayOneQuestionInner(result: QuestionFragment, containerRef) {
|
|
||||||
return (
|
|
||||||
<div ref={containerRef}>
|
|
||||||
{result ? (
|
|
||||||
<DisplayQuestion
|
|
||||||
question={result}
|
|
||||||
showTimeStamp={true}
|
|
||||||
expandFooterToFullWidth={true}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let domToImageWrapper = (reactRef) => {
|
|
||||||
let node = reactRef.current;
|
|
||||||
const scale = 3; // Increase for better quality
|
|
||||||
const style = {
|
|
||||||
transform: "scale(" + scale + ")",
|
|
||||||
transformOrigin: "top left",
|
|
||||||
width: node.offsetWidth + "px",
|
|
||||||
height: node.offsetHeight + "px",
|
|
||||||
};
|
|
||||||
const param = {
|
|
||||||
height: node.offsetHeight * scale,
|
|
||||||
width: node.offsetWidth * scale,
|
|
||||||
quality: 1,
|
|
||||||
style,
|
|
||||||
};
|
|
||||||
let image = domtoimage.toPng(node, param);
|
|
||||||
return image;
|
|
||||||
};
|
|
||||||
|
|
||||||
let generateHtml = (result, imgSrc) => {
|
|
||||||
let html = `<a href="${result.url} target="_blank""><img src="${imgSrc}" alt="Metaforecast.org snapshot of ''${result.title}'', from ${result.platform}"></a>`;
|
|
||||||
return html;
|
|
||||||
};
|
|
||||||
|
|
||||||
let generateMarkdown = (result, imgSrc) => {
|
|
||||||
let markdown = `[![](${imgSrc})](${result.url})`;
|
|
||||||
return markdown;
|
|
||||||
};
|
|
||||||
|
|
||||||
let generateSource = (result, imgSrc, hasDisplayBeenCaptured) => {
|
|
||||||
const [htmlButtonStatus, setHtmlButtonStatus] = useState("Copy HTML");
|
|
||||||
const [markdownButtonStatus, setMarkdownButtonStatus] =
|
|
||||||
useState("Copy markdown");
|
|
||||||
let handleHtmlButton = () => {
|
|
||||||
setHtmlButtonStatus("Copied");
|
|
||||||
let newtimeoutId = setTimeout(async () => {
|
|
||||||
setHtmlButtonStatus("Copy HTML");
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
let handleMarkdownButton = () => {
|
|
||||||
setMarkdownButtonStatus("Copied");
|
|
||||||
let newtimeoutId = setTimeout(async () => {
|
|
||||||
setMarkdownButtonStatus("Copy markdown");
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (result && imgSrc && hasDisplayBeenCaptured) {
|
|
||||||
return (
|
|
||||||
<div className="grid">
|
|
||||||
<p className="bg-gray-100 cursor-pointer px-3 py-2 rounded-md shadow text-grey-7000 font-mono text-sm">
|
|
||||||
{generateMarkdown(result, imgSrc)}
|
|
||||||
</p>
|
|
||||||
<CopyToClipboard
|
|
||||||
text={generateMarkdown(result, imgSrc)}
|
|
||||||
onCopy={() => handleMarkdownButton()}
|
|
||||||
>
|
|
||||||
<button className="bg-blue-500 cursor-pointer px-3 py-2 rounded-md shadow text-white hover:bg-blue-600 active:scale-120">
|
|
||||||
{markdownButtonStatus}
|
|
||||||
</button>
|
|
||||||
</CopyToClipboard>
|
|
||||||
<p className="bg-gray-100 cursor-pointer px-3 py-2 rounded-md shadow text-grey-7000 font-mono text-sm">
|
|
||||||
{generateHtml(result, imgSrc)}
|
|
||||||
</p>
|
|
||||||
<CopyToClipboard
|
|
||||||
text={generateHtml(result, imgSrc)}
|
|
||||||
onCopy={() => handleHtmlButton()}
|
|
||||||
>
|
|
||||||
<button className="bg-blue-500 cursor-pointer px-3 py-2 rounded-md shadow text-white mb-4 hover:bg-blue-600">
|
|
||||||
{htmlButtonStatus}
|
|
||||||
</button>
|
|
||||||
</CopyToClipboard>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let generateIframeURL = (result) => {
|
|
||||||
let iframeURL = "";
|
|
||||||
if (result) {
|
|
||||||
// if check not strictly necessary today
|
|
||||||
let parts = result.url.replace("questions", "questions/embed").split("/");
|
|
||||||
parts.pop();
|
|
||||||
parts.pop();
|
|
||||||
iframeURL = parts.join("/");
|
|
||||||
}
|
|
||||||
return iframeURL;
|
|
||||||
};
|
|
||||||
|
|
||||||
let metaculusEmbed = (result) => {
|
|
||||||
let platform = "";
|
|
||||||
let iframeURL = "";
|
|
||||||
if (result) {
|
|
||||||
iframeURL = generateIframeURL(result);
|
|
||||||
platform = result.platform;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<iframe
|
|
||||||
className={`${
|
|
||||||
platform == "Metaculus" ? "" : "hidden"
|
|
||||||
} flex h-80 w-full justify-self-center self-center`}
|
|
||||||
src={iframeURL}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
let generateMetaculusIframeHTML = (result) => {
|
|
||||||
if (result) {
|
|
||||||
let iframeURL = generateIframeURL(result);
|
|
||||||
return `<iframe src="${iframeURL}" height="400" width="600"/>`;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let generateMetaculusSource = (result, hasDisplayBeenCaptured) => {
|
|
||||||
const [htmlButtonStatus, setHtmlButtonStatus] = useState("Copy HTML");
|
|
||||||
let handleHtmlButton = () => {
|
|
||||||
setHtmlButtonStatus("Copied");
|
|
||||||
let newtimeoutId = setTimeout(async () => {
|
|
||||||
setHtmlButtonStatus("Copy HTML");
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (result && hasDisplayBeenCaptured && result.platform == "Metaculus") {
|
|
||||||
return (
|
|
||||||
<div className="grid">
|
|
||||||
<p className="bg-gray-100 cursor-pointer px-3 py-2 rounded-md shadow text-grey-7000 font-mono text-sm">
|
|
||||||
{generateMetaculusIframeHTML(result)}
|
|
||||||
</p>
|
|
||||||
<CopyToClipboard
|
|
||||||
text={generateMetaculusIframeHTML(result)}
|
|
||||||
onCopy={() => handleHtmlButton()}
|
|
||||||
>
|
|
||||||
<button className="bg-blue-500 cursor-pointer px-3 py-2 rounded-md shadow text-white mb-4 hover:bg-blue-600">
|
|
||||||
{htmlButtonStatus}
|
|
||||||
</button>
|
|
||||||
</CopyToClipboard>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
result: QuestionFragment;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DisplayOneQuestionForCapture: React.FC<Props> = ({ result }) => {
|
|
||||||
const [hasDisplayBeenCaptured, setHasDisplayBeenCaptured] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setHasDisplayBeenCaptured(false);
|
|
||||||
}, [result]);
|
|
||||||
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const [imgSrc, setImgSrc] = useState("");
|
|
||||||
const [mainButtonStatus, setMainButtonStatus] = useState(
|
|
||||||
"Capture image and generate code"
|
|
||||||
);
|
|
||||||
|
|
||||||
let exportAsPictureAndCode = () => {
|
|
||||||
let handleGettingImgurlImage = (imgurUrl) => {
|
|
||||||
setImgSrc(imgurUrl);
|
|
||||||
setMainButtonStatus("Done!");
|
|
||||||
let newtimeoutId = setTimeout(async () => {
|
|
||||||
setMainButtonStatus("Capture image and generate code");
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
domToImageWrapper(containerRef)
|
|
||||||
.then(async function (dataUrl) {
|
|
||||||
if (dataUrl) {
|
|
||||||
uploadToImgur(dataUrl, handleGettingImgurlImage);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function (error) {
|
|
||||||
console.error("oops, something went wrong!", error);
|
|
||||||
});
|
|
||||||
}; //
|
|
||||||
|
|
||||||
let onCaptureButtonClick = () => {
|
|
||||||
exportAsPictureAndCode();
|
|
||||||
setMainButtonStatus("Processing...");
|
|
||||||
setHasDisplayBeenCaptured(true);
|
|
||||||
setImgSrc("");
|
|
||||||
};
|
|
||||||
|
|
||||||
function generateCaptureButton(result, onCaptureButtonClick) {
|
|
||||||
if (result) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => onCaptureButtonClick()}
|
|
||||||
className="bg-blue-500 cursor-pointer px-5 py-4 rounded-md shadow text-white hover:bg-blue-600 active:bg-gray-700"
|
|
||||||
>
|
|
||||||
{mainButtonStatus}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full justify-center">
|
|
||||||
<div className="flex col-span-1 items-center justify-center">
|
|
||||||
{displayOneQuestionInner(result, containerRef)}
|
|
||||||
</div>
|
|
||||||
<div className="flex col-span-1 items-center justify-center">
|
|
||||||
{generateCaptureButton(result, onCaptureButtonClick)}
|
|
||||||
</div>
|
|
||||||
<div className="flex col-span-1 items-center justify-center">
|
|
||||||
<img src={imgSrc} className={hasDisplayBeenCaptured ? "" : "hidden"} />
|
|
||||||
</div>
|
|
||||||
<div className="flex col-span-1 items-center justify-center">
|
|
||||||
<div>{generateSource(result, imgSrc, hasDisplayBeenCaptured)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex col-span-1 items-center justify-center mb-8">
|
|
||||||
{metaculusEmbed(result)}
|
|
||||||
</div>
|
|
||||||
<div className="flex col-span-1 items-center justify-center">
|
|
||||||
<div>{generateMetaculusSource(result, hasDisplayBeenCaptured)}</div>
|
|
||||||
</div>
|
|
||||||
<br></br>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// https://stackoverflow.com/questions/39501289/in-reactjs-how-to-copy-text-to-clipboard
|
|
||||||
// Note: https://stackoverflow.com/questions/66016033/can-no-longer-upload-images-to-imgur-from-localhost
|
|
||||||
// Use: http://imgurtester:3000/embed for testing.
|
|
|
@ -1,31 +0,0 @@
|
||||||
import { DisplayOneQuestionForCapture } from "./DisplayOneQuestionForCapture";
|
|
||||||
import { DisplayQuestions } from "./DisplayQuestions";
|
|
||||||
|
|
||||||
export function displayQuestionsWrapperForSearch({
|
|
||||||
results,
|
|
||||||
numDisplay,
|
|
||||||
showIdToggle,
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
<DisplayQuestions
|
|
||||||
results={results || []}
|
|
||||||
numDisplay={numDisplay}
|
|
||||||
showIdToggle={showIdToggle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function displayQuestionsWrapperForCapture({
|
|
||||||
results,
|
|
||||||
whichResultToDisplayAndCapture,
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-1 w-full justify-center">
|
|
||||||
<DisplayOneQuestionForCapture
|
|
||||||
result={results[whichResultToDisplayAndCapture]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
9
src/web/fragments.generated.tsx
Normal file
9
src/web/fragments.generated.tsx
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import * as Types from '../graphql/types.generated';
|
||||||
|
|
||||||
|
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||||
|
export type QuestionFragment = { __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } };
|
||||||
|
|
||||||
|
export type QuestionWithHistoryFragment = { __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, history: Array<{ __typename?: 'History', timestamp: number, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }> }>, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } };
|
||||||
|
|
||||||
|
export const QuestionFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Question"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Question"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"probability"}}]}},{"kind":"Field","name":{"kind":"Name","value":"platform"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}},{"kind":"Field","name":{"kind":"Name","value":"qualityIndicators"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stars"}},{"kind":"Field","name":{"kind":"Name","value":"numForecasts"}},{"kind":"Field","name":{"kind":"Name","value":"numForecasters"}},{"kind":"Field","name":{"kind":"Name","value":"volume"}},{"kind":"Field","name":{"kind":"Name","value":"spread"}},{"kind":"Field","name":{"kind":"Name","value":"sharesVolume"}},{"kind":"Field","name":{"kind":"Name","value":"openInterest"}},{"kind":"Field","name":{"kind":"Name","value":"liquidity"}},{"kind":"Field","name":{"kind":"Name","value":"tradeVolume"}}]}},{"kind":"Field","name":{"kind":"Name","value":"visualization"}}]}}]} as unknown as DocumentNode<QuestionFragment, unknown>;
|
||||||
|
export const QuestionWithHistoryFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"QuestionWithHistory"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Question"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Question"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"probability"}}]}}]}}]}},...QuestionFragmentDoc.definitions]} as unknown as DocumentNode<QuestionWithHistoryFragment, unknown>;
|
38
src/web/fragments.graphql
Normal file
38
src/web/fragments.graphql
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
fragment Question on Question {
|
||||||
|
id
|
||||||
|
url
|
||||||
|
title
|
||||||
|
description
|
||||||
|
timestamp
|
||||||
|
options {
|
||||||
|
name
|
||||||
|
probability
|
||||||
|
}
|
||||||
|
platform {
|
||||||
|
id
|
||||||
|
label
|
||||||
|
}
|
||||||
|
qualityIndicators {
|
||||||
|
stars
|
||||||
|
numForecasts
|
||||||
|
numForecasters
|
||||||
|
volume
|
||||||
|
spread
|
||||||
|
sharesVolume
|
||||||
|
openInterest
|
||||||
|
liquidity
|
||||||
|
tradeVolume
|
||||||
|
}
|
||||||
|
visualization
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment QuestionWithHistory on Question {
|
||||||
|
...Question
|
||||||
|
history {
|
||||||
|
timestamp
|
||||||
|
options {
|
||||||
|
name
|
||||||
|
probability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
155
src/web/questions/components/CaptureQuestion.tsx
Normal file
155
src/web/questions/components/CaptureQuestion.tsx
Normal file
|
@ -0,0 +1,155 @@
|
||||||
|
import domtoimage from "dom-to-image"; // https://github.com/tsayen/dom-to-image
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "../../common/Button";
|
||||||
|
import { CopyParagraph } from "../../common/CopyParagraph";
|
||||||
|
import { QuestionFragment } from "../../fragments.generated";
|
||||||
|
import { uploadToImgur } from "../../worker/uploadToImgur";
|
||||||
|
import { QuestionCard } from "./QuestionCard";
|
||||||
|
|
||||||
|
const domToImageWrapper = async (node: HTMLDivElement) => {
|
||||||
|
const scale = 3; // Increase for better quality
|
||||||
|
const style = {
|
||||||
|
transform: "scale(" + scale + ")",
|
||||||
|
transformOrigin: "top left",
|
||||||
|
width: node.offsetWidth + "px",
|
||||||
|
height: node.offsetHeight + "px",
|
||||||
|
};
|
||||||
|
const param = {
|
||||||
|
height: node.offsetHeight * scale,
|
||||||
|
width: node.offsetWidth * scale,
|
||||||
|
quality: 1,
|
||||||
|
style,
|
||||||
|
};
|
||||||
|
const image = await domtoimage.toPng(node, param);
|
||||||
|
return image;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImageSource: React.FC<{ question: QuestionFragment; imgSrc: string }> = ({
|
||||||
|
question,
|
||||||
|
imgSrc,
|
||||||
|
}) => {
|
||||||
|
if (!imgSrc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `<a href="${question.url}" target="_blank"><img src="${imgSrc}" alt="Metaforecast.org snapshot of ''${question.title}'', from ${question.platform.label}"></a>`;
|
||||||
|
const markdown = `[![](${imgSrc})](${question.url})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CopyParagraph text={markdown} buttonText="Copy markdown" />
|
||||||
|
<CopyParagraph text={html} buttonText="Copy HTML" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMetaculusIframeURL = (question: QuestionFragment) => {
|
||||||
|
let parts = question.url.replace("questions", "questions/embed").split("/");
|
||||||
|
parts.pop();
|
||||||
|
parts.pop();
|
||||||
|
const iframeURL = parts.join("/");
|
||||||
|
return iframeURL;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMetaculusIframeHTML = (question: QuestionFragment) => {
|
||||||
|
const iframeURL = generateMetaculusIframeURL(question);
|
||||||
|
return `<iframe src="${iframeURL}" height="400" width="600"/>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetaculusEmbed: React.FC<{ question: QuestionFragment }> = ({
|
||||||
|
question,
|
||||||
|
}) => {
|
||||||
|
if (question.platform.id !== "metaculus") return null;
|
||||||
|
|
||||||
|
const iframeURL = generateMetaculusIframeURL(question);
|
||||||
|
return <iframe className="w-full h-80" src={iframeURL} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetaculusSource: React.FC<{
|
||||||
|
question: QuestionFragment;
|
||||||
|
}> = ({ question }) => {
|
||||||
|
if (question.platform.id !== "metaculus") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CopyParagraph
|
||||||
|
text={generateMetaculusIframeHTML(question)}
|
||||||
|
buttonText="Copy HTML"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
question: QuestionFragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CaptureQuestion: React.FC<Props> = ({ question }) => {
|
||||||
|
const [imgSrc, setImgSrc] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImgSrc(null);
|
||||||
|
}, [question]);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const initialMainButtonText = "Capture image and generate code";
|
||||||
|
const [mainButtonText, setMainButtonText] = useState(initialMainButtonText);
|
||||||
|
|
||||||
|
const exportAsPictureAndCode = async () => {
|
||||||
|
if (!containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const dataUrl = await domToImageWrapper(containerRef.current);
|
||||||
|
const imgurUrl = await uploadToImgur(dataUrl);
|
||||||
|
setImgSrc(imgurUrl);
|
||||||
|
setMainButtonText("Done!");
|
||||||
|
setTimeout(async () => {
|
||||||
|
setMainButtonText(initialMainButtonText);
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("oops, something went wrong!", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCaptureButtonClick = async () => {
|
||||||
|
setMainButtonText("Processing...");
|
||||||
|
setImgSrc(null);
|
||||||
|
await exportAsPictureAndCode();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 place-items-center">
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<QuestionCard
|
||||||
|
question={question}
|
||||||
|
showTimeStamp={true}
|
||||||
|
showExpandButton={false}
|
||||||
|
expandFooterToFullWidth={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button onClick={onCaptureButtonClick}>{mainButtonText}</Button>
|
||||||
|
</div>
|
||||||
|
{imgSrc ? (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<img src={imgSrc} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ImageSource question={question} imgSrc={imgSrc} />
|
||||||
|
</div>
|
||||||
|
<div className="justify-self-stretch">
|
||||||
|
<MetaculusEmbed question={question} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<MetaculusSource question={question} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: https://stackoverflow.com/questions/66016033/can-no-longer-upload-images-to-imgur-from-localhost
|
||||||
|
// Use: http://imgurtester:3000/embed for testing.
|
167
src/web/questions/components/HistoryChart/InnerChart.tsx
Normal file
167
src/web/questions/components/HistoryChart/InnerChart.tsx
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
import { differenceInDays, format } from "date-fns";
|
||||||
|
import {
|
||||||
|
VictoryAxis, VictoryChart, VictoryGroup, VictoryLabel, VictoryLine, VictoryScatter,
|
||||||
|
VictoryTheme, VictoryTooltip, VictoryVoronoiContainer
|
||||||
|
} from "victory";
|
||||||
|
|
||||||
|
import { chartColors, ChartData, ChartSeries, height, width } from "./utils";
|
||||||
|
|
||||||
|
// can't be replaced with React component, VictoryChart requires VictoryGroup elements to be immediate children
|
||||||
|
const getVictoryGroup = ({
|
||||||
|
data,
|
||||||
|
i,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
data: ChartSeries;
|
||||||
|
i: number;
|
||||||
|
highlight?: boolean;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<VictoryGroup color={chartColors[i] || "darkgray"} data={data} key={i}>
|
||||||
|
<VictoryLine
|
||||||
|
name={`line-${i}`}
|
||||||
|
style={{
|
||||||
|
data: {
|
||||||
|
strokeOpacity: highlight ? 1 : 0.5,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<VictoryScatter
|
||||||
|
name={`scatter-${i}`}
|
||||||
|
size={({ active }) => (active || highlight ? 3.75 : 3)}
|
||||||
|
/>
|
||||||
|
</VictoryGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InnerChart: React.FC<{
|
||||||
|
data: ChartData;
|
||||||
|
highlight: number | undefined;
|
||||||
|
}> = ({
|
||||||
|
data: { maxProbability, seriesList, minDate, maxDate },
|
||||||
|
highlight,
|
||||||
|
}) => {
|
||||||
|
const domainMax =
|
||||||
|
maxProbability < 0.5 ? Math.round(10 * (maxProbability + 0.05)) / 10 : 1;
|
||||||
|
const padding = {
|
||||||
|
top: 20,
|
||||||
|
bottom: 65,
|
||||||
|
left: 60,
|
||||||
|
right: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VictoryChart
|
||||||
|
domainPadding={20}
|
||||||
|
padding={padding}
|
||||||
|
theme={VictoryTheme.material}
|
||||||
|
height={height}
|
||||||
|
width={width}
|
||||||
|
containerComponent={
|
||||||
|
<VictoryVoronoiContainer
|
||||||
|
labels={() => "Not shown"}
|
||||||
|
labelComponent={
|
||||||
|
<VictoryTooltip
|
||||||
|
constrainToVisibleArea
|
||||||
|
pointerLength={0}
|
||||||
|
dy={-12}
|
||||||
|
labelComponent={
|
||||||
|
<VictoryLabel
|
||||||
|
style={[
|
||||||
|
{
|
||||||
|
fontSize: 18,
|
||||||
|
fill: "black",
|
||||||
|
strokeWidth: 0.05,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fontSize: 18,
|
||||||
|
fill: "#777",
|
||||||
|
strokeWidth: 0.05,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
text={({ datum }) =>
|
||||||
|
`${datum.name}: ${Math.round(datum.y * 100)}%\n${format(
|
||||||
|
datum.x,
|
||||||
|
"yyyy-MM-dd"
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
fontSize: 18, // needs to be set here and not just in labelComponent for text size calculations
|
||||||
|
fontFamily:
|
||||||
|
'"Gill Sans", "Gill Sans MT", "Seravek", "Trebuchet MS", sans-serif',
|
||||||
|
// default font family from Victory, need to be specified explicitly for some reason, otherwise text size gets miscalculated
|
||||||
|
}}
|
||||||
|
flyoutStyle={{
|
||||||
|
stroke: "#999",
|
||||||
|
fill: "white",
|
||||||
|
}}
|
||||||
|
cornerRadius={4}
|
||||||
|
flyoutPadding={{ top: 4, bottom: 4, left: 12, right: 12 }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
radius={50}
|
||||||
|
voronoiBlacklist={
|
||||||
|
[...Array(seriesList.length).keys()].map((i) => `line-${i}`)
|
||||||
|
// see: https://github.com/FormidableLabs/victory/issues/545
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
scale={{
|
||||||
|
x: "time",
|
||||||
|
y: "linear",
|
||||||
|
}}
|
||||||
|
domain={{
|
||||||
|
x: [minDate, maxDate],
|
||||||
|
y: [0, domainMax],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VictoryAxis
|
||||||
|
tickCount={Math.min(7, differenceInDays(maxDate, minDate) + 1)}
|
||||||
|
style={{
|
||||||
|
grid: { stroke: null, strokeWidth: 0.5 },
|
||||||
|
}}
|
||||||
|
tickLabelComponent={
|
||||||
|
<VictoryLabel
|
||||||
|
dx={-38}
|
||||||
|
dy={-5}
|
||||||
|
angle={-30}
|
||||||
|
style={{ fontSize: 18, fill: "#777" }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
scale={{ x: "time" }}
|
||||||
|
tickFormat={(t) => format(t, "yyyy-MM-dd")}
|
||||||
|
/>
|
||||||
|
<VictoryAxis
|
||||||
|
dependentAxis
|
||||||
|
style={{
|
||||||
|
grid: { stroke: "#D3D3D3", strokeWidth: 0.5 },
|
||||||
|
}}
|
||||||
|
tickLabelComponent={
|
||||||
|
<VictoryLabel dy={0} style={{ fontSize: 18, fill: "#777" }} />
|
||||||
|
}
|
||||||
|
// tickFormat specifies how ticks should be displayed
|
||||||
|
tickFormat={(x) => `${x * 100}%`}
|
||||||
|
/>
|
||||||
|
{[...Array(seriesList.length).keys()]
|
||||||
|
.reverse() // affects svg render order, we want to render largest datasets on top of others
|
||||||
|
.filter((i) => i !== highlight)
|
||||||
|
.map((i) =>
|
||||||
|
getVictoryGroup({
|
||||||
|
data: seriesList[i],
|
||||||
|
i,
|
||||||
|
highlight: false,
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
{highlight === undefined
|
||||||
|
? null
|
||||||
|
: // render highlighted series on top of everything else
|
||||||
|
getVictoryGroup({
|
||||||
|
data: seriesList[highlight],
|
||||||
|
i: highlight,
|
||||||
|
highlight: true,
|
||||||
|
})}
|
||||||
|
</VictoryChart>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { height, width } from "./utils";
|
||||||
|
|
||||||
|
export const InnerChartPlaceholder: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
85
src/web/questions/components/HistoryChart/Legend.tsx
Normal file
85
src/web/questions/components/HistoryChart/Legend.tsx
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { shift, useFloating } from "@floating-ui/react-dom";
|
||||||
|
|
||||||
|
type Item = {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LegendItem: React.FC<{ item: Item; onHighlight: () => void }> = ({
|
||||||
|
item,
|
||||||
|
onHighlight,
|
||||||
|
}) => {
|
||||||
|
const { x, y, reference, floating, strategy } = useFloating({
|
||||||
|
// placement: "right",
|
||||||
|
middleware: [shift()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
const textRef = useRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
const onHover = () => {
|
||||||
|
if (textRef.current.scrollWidth > textRef.current.clientWidth) {
|
||||||
|
setShowTooltip(true);
|
||||||
|
}
|
||||||
|
onHighlight();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex items-center cursor-pointer"
|
||||||
|
onMouseOver={onHover}
|
||||||
|
onMouseLeave={() => setShowTooltip(false)}
|
||||||
|
ref={reference}
|
||||||
|
>
|
||||||
|
<svg className="mt-1 shrink-0" height="10" width="16">
|
||||||
|
<circle cx="4" cy="4" r="4" fill={item.color} />
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
className="text-xs sm:text-sm sm:whitespace-nowrap sm:text-ellipsis sm:overflow-hidden sm:max-w-160"
|
||||||
|
ref={textRef}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showTooltip
|
||||||
|
? (() => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`absolute text-xs p-2 border border-gray-300 rounded bg-white ${
|
||||||
|
showTooltip ? "" : "hidden"
|
||||||
|
}`}
|
||||||
|
ref={floating}
|
||||||
|
style={{
|
||||||
|
position: strategy,
|
||||||
|
top: y ?? "",
|
||||||
|
left: x ?? "",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
: null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Legend: React.FC<{
|
||||||
|
items: { name: string; color: string }[];
|
||||||
|
setHighlight: (i: number | undefined) => void;
|
||||||
|
}> = ({ items, setHighlight }) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" onMouseLeave={() => setHighlight(undefined)}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<LegendItem
|
||||||
|
key={item.name}
|
||||||
|
item={item}
|
||||||
|
onHighlight={() => setHighlight(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
36
src/web/questions/components/HistoryChart/index.tsx
Normal file
36
src/web/questions/components/HistoryChart/index.tsx
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { QuestionWithHistoryFragment } from "../../../fragments.generated";
|
||||||
|
import { InnerChartPlaceholder } from "./InnerChartPlaceholder";
|
||||||
|
import { Legend } from "./Legend";
|
||||||
|
import { buildChartData, chartColors } from "./utils";
|
||||||
|
|
||||||
|
const InnerChart = dynamic(
|
||||||
|
() => import("./InnerChart").then((mod) => mod.InnerChart),
|
||||||
|
{ ssr: false, loading: () => <InnerChartPlaceholder /> }
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
question: QuestionWithHistoryFragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HistoryChart: React.FC<Props> = ({ question }) => {
|
||||||
|
// maybe use context instead?
|
||||||
|
const [highlight, setHighlight] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const data = useMemo(() => buildChartData(question), [question]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center flex-col space-y-4 sm:flex-row sm:space-y-0">
|
||||||
|
<InnerChart data={data} highlight={highlight} />
|
||||||
|
<Legend
|
||||||
|
items={data.seriesNames.map((name, i) => ({
|
||||||
|
name,
|
||||||
|
color: chartColors[i],
|
||||||
|
}))}
|
||||||
|
setHighlight={setHighlight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
113
src/web/questions/components/HistoryChart/utils.ts
Normal file
113
src/web/questions/components/HistoryChart/utils.ts
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import { addDays, startOfDay, startOfToday, startOfTomorrow } from "date-fns";
|
||||||
|
|
||||||
|
import { QuestionWithHistoryFragment } from "../../../fragments.generated";
|
||||||
|
|
||||||
|
export type ChartSeries = { x: Date; y: number; name: string }[];
|
||||||
|
|
||||||
|
export const MAX_LINES = 5;
|
||||||
|
|
||||||
|
// number of colors should match MAX_LINES
|
||||||
|
// colors are taken from tailwind, https://tailwindcss.com/docs/customizing-colors
|
||||||
|
export const chartColors = [
|
||||||
|
"#0284C7", // sky-600
|
||||||
|
"#DC2626", // red-600
|
||||||
|
"#15803D", // green-700
|
||||||
|
"#7E22CE", // purple-700
|
||||||
|
"#F59E0B", // amber-500
|
||||||
|
];
|
||||||
|
|
||||||
|
const goldenRatio = (1 + Math.sqrt(5)) / 2;
|
||||||
|
// used both for chart and for ssr placeholder
|
||||||
|
export const width = 750;
|
||||||
|
export const height = width / goldenRatio;
|
||||||
|
|
||||||
|
export type ChartData = {
|
||||||
|
seriesList: ChartSeries[];
|
||||||
|
seriesNames: string[];
|
||||||
|
maxProbability: number;
|
||||||
|
minDate: Date;
|
||||||
|
maxDate: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildChartData = (
|
||||||
|
question: QuestionWithHistoryFragment
|
||||||
|
): ChartData => {
|
||||||
|
let seriesNames = question.options
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.probability > b.probability) {
|
||||||
|
return -1;
|
||||||
|
} else if (a.probability < b.probability) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return a.name < b.name ? -1 : 1; // needed for stable sorting - otherwise it's possible to get order mismatch in SSR vs client-side
|
||||||
|
})
|
||||||
|
.map((o) => o.name)
|
||||||
|
.slice(0, MAX_LINES);
|
||||||
|
|
||||||
|
const isBinary =
|
||||||
|
(seriesNames[0] === "Yes" && seriesNames[1] === "No") ||
|
||||||
|
(seriesNames[0] === "No" && seriesNames[1] === "Yes");
|
||||||
|
if (isBinary) {
|
||||||
|
seriesNames = ["Yes"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameToIndex = Object.fromEntries(
|
||||||
|
seriesNames.map((name, i) => [name, i])
|
||||||
|
);
|
||||||
|
let seriesList: ChartSeries[] = [...Array(seriesNames.length)].map((x) => []);
|
||||||
|
|
||||||
|
const sortedHistory = question.history.sort((a, b) =>
|
||||||
|
a.timestamp < b.timestamp ? -1 : 1
|
||||||
|
);
|
||||||
|
|
||||||
|
{
|
||||||
|
let previousDate = -Infinity;
|
||||||
|
for (const item of sortedHistory) {
|
||||||
|
if (item.timestamp - previousDate < 12 * 60 * 60) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const date = new Date(item.timestamp * 1000);
|
||||||
|
|
||||||
|
for (const option of item.options) {
|
||||||
|
const idx = nameToIndex[option.name];
|
||||||
|
if (idx === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = {
|
||||||
|
x: date,
|
||||||
|
y: option.probability,
|
||||||
|
name: option.name,
|
||||||
|
};
|
||||||
|
seriesList[idx].push(result);
|
||||||
|
}
|
||||||
|
previousDate = item.timestamp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxProbability = 0;
|
||||||
|
for (const dataSet of seriesList) {
|
||||||
|
for (const item of dataSet) {
|
||||||
|
maxProbability = Math.max(maxProbability, item.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minDate = sortedHistory.length
|
||||||
|
? startOfDay(new Date(sortedHistory[0].timestamp * 1000))
|
||||||
|
: startOfToday();
|
||||||
|
const maxDate = sortedHistory.length
|
||||||
|
? addDays(
|
||||||
|
startOfDay(
|
||||||
|
new Date(sortedHistory[sortedHistory.length - 1].timestamp * 1000)
|
||||||
|
),
|
||||||
|
1
|
||||||
|
)
|
||||||
|
: startOfTomorrow();
|
||||||
|
|
||||||
|
return {
|
||||||
|
seriesList,
|
||||||
|
seriesNames,
|
||||||
|
maxProbability,
|
||||||
|
minDate,
|
||||||
|
maxDate,
|
||||||
|
};
|
||||||
|
};
|
69
src/web/questions/components/IndicatorsTable.tsx
Normal file
69
src/web/questions/components/IndicatorsTable.tsx
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import { QuestionFragment } from "../../fragments.generated";
|
||||||
|
import {
|
||||||
|
formatIndicatorValue, qualityIndicatorLabels, UsedIndicatorName
|
||||||
|
} from "./QuestionCard/QuestionFooter";
|
||||||
|
import { Stars } from "./Stars";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
question: QuestionFragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableRow: React.FC<{ title: string }> = ({ title, children }) => (
|
||||||
|
<tr className="border-b">
|
||||||
|
<th
|
||||||
|
scope="row"
|
||||||
|
className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</th>
|
||||||
|
<td className="px-6 py-4">{children}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IndicatorsTable: React.FC<Props> = ({ question }) => (
|
||||||
|
<div className="relative overflow-x-auto shadow-md sm:rounded-lg">
|
||||||
|
<table className="w-full text-sm text-left text-gray-500">
|
||||||
|
<thead className="text-xs text-gray-700 uppercase bg-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3">
|
||||||
|
Indicator
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3">
|
||||||
|
Value
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<TableRow title="Stars">
|
||||||
|
<Stars num={question.qualityIndicators.stars} />
|
||||||
|
</TableRow>
|
||||||
|
<TableRow title="Platform">{question.platform.label}</TableRow>
|
||||||
|
{question.qualityIndicators.numForecasts ? (
|
||||||
|
<TableRow title="Number of forecasts">
|
||||||
|
{question.qualityIndicators.numForecasts}
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
|
{Object.keys(question.qualityIndicators)
|
||||||
|
.filter(
|
||||||
|
(indicator) =>
|
||||||
|
question.qualityIndicators[indicator] != null &&
|
||||||
|
!!qualityIndicatorLabels[indicator]
|
||||||
|
)
|
||||||
|
.map((indicator: UsedIndicatorName) => {
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
title={qualityIndicatorLabels[indicator]}
|
||||||
|
key={indicator}
|
||||||
|
>
|
||||||
|
{formatIndicatorValue(
|
||||||
|
question.qualityIndicators[indicator],
|
||||||
|
indicator,
|
||||||
|
question.platform.id
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
177
src/web/questions/components/QuestionCard/QuestionFooter.tsx
Normal file
177
src/web/questions/components/QuestionCard/QuestionFooter.tsx
Normal file
|
@ -0,0 +1,177 @@
|
||||||
|
import { QuestionFragment } from "../../../fragments.generated";
|
||||||
|
import { Stars } from "../Stars";
|
||||||
|
|
||||||
|
type QualityIndicator = QuestionFragment["qualityIndicators"];
|
||||||
|
type IndicatorName = keyof QualityIndicator;
|
||||||
|
|
||||||
|
// this duplication can probably be simplified with typescript magic, but this is good enough for now
|
||||||
|
export type UsedIndicatorName =
|
||||||
|
// | "numForecasts"
|
||||||
|
// | "stars"
|
||||||
|
| "volume"
|
||||||
|
| "numForecasters"
|
||||||
|
| "spread"
|
||||||
|
| "sharesVolume"
|
||||||
|
| "liquidity"
|
||||||
|
| "tradeVolume"
|
||||||
|
| "openInterest";
|
||||||
|
|
||||||
|
export const qualityIndicatorLabels: { [k in UsedIndicatorName]: string } = {
|
||||||
|
// numForecasts: "Number of forecasts",
|
||||||
|
// stars: "Stars",
|
||||||
|
// yesBid: "Yes bid",
|
||||||
|
// yesAsk: "Yes ask",
|
||||||
|
volume: "Volume",
|
||||||
|
numForecasters: "Forecasters",
|
||||||
|
spread: "Spread",
|
||||||
|
sharesVolume: "Shares vol.",
|
||||||
|
liquidity: "Liquidity",
|
||||||
|
tradeVolume: "Volume",
|
||||||
|
openInterest: "Interest",
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (num) => {
|
||||||
|
if (Number(num) < 1000) {
|
||||||
|
return Number(num).toFixed(0);
|
||||||
|
} else if (num < 10000) {
|
||||||
|
return (Number(num) / 1000).toFixed(1) + "k";
|
||||||
|
} else {
|
||||||
|
return (Number(num) / 1000).toFixed(0) + "k";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Display functions*/
|
||||||
|
|
||||||
|
const getPercentageSymbolIfNeeded = ({
|
||||||
|
indicator,
|
||||||
|
platform,
|
||||||
|
}: {
|
||||||
|
indicator: UsedIndicatorName;
|
||||||
|
platform: string;
|
||||||
|
}) => {
|
||||||
|
let indicatorsWhichNeedPercentageSymbol: IndicatorName[] = ["spread"];
|
||||||
|
if (indicatorsWhichNeedPercentageSymbol.includes(indicator)) {
|
||||||
|
return "%";
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrencySymbolIfNeeded = ({
|
||||||
|
indicator,
|
||||||
|
platform,
|
||||||
|
}: {
|
||||||
|
indicator: UsedIndicatorName;
|
||||||
|
platform: string;
|
||||||
|
}) => {
|
||||||
|
const indicatorsWhichNeedCurrencySymbol: IndicatorName[] = [
|
||||||
|
"volume",
|
||||||
|
"tradeVolume",
|
||||||
|
"openInterest",
|
||||||
|
"liquidity",
|
||||||
|
];
|
||||||
|
let dollarPlatforms = ["predictit", "kalshi", "polymarket"];
|
||||||
|
if (indicatorsWhichNeedCurrencySymbol.includes(indicator)) {
|
||||||
|
if (dollarPlatforms.includes(platform)) {
|
||||||
|
return "$";
|
||||||
|
} else {
|
||||||
|
return "£";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const FirstQualityIndicator: React.FC<{
|
||||||
|
question: QuestionFragment;
|
||||||
|
}> = ({ question }) => {
|
||||||
|
if (question.qualityIndicators.numForecasts) {
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
<span>Forecasts:</span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{Number(question.qualityIndicators.numForecasts).toFixed(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatIndicatorValue = (
|
||||||
|
value: any,
|
||||||
|
indicator: UsedIndicatorName,
|
||||||
|
platform: string
|
||||||
|
): string => {
|
||||||
|
return `${getCurrencySymbolIfNeeded({
|
||||||
|
indicator,
|
||||||
|
platform: platform,
|
||||||
|
})}${formatNumber(value)}${getPercentageSymbolIfNeeded({
|
||||||
|
indicator,
|
||||||
|
platform: platform,
|
||||||
|
})}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const QualityIndicatorsList: React.FC<{
|
||||||
|
question: QuestionFragment;
|
||||||
|
}> = ({ question }) => {
|
||||||
|
return (
|
||||||
|
<div className="text-sm">
|
||||||
|
<FirstQualityIndicator question={question} />
|
||||||
|
{Object.entries(question.qualityIndicators).map((entry, i) => {
|
||||||
|
const indicatorLabel = qualityIndicatorLabels[entry[0]];
|
||||||
|
if (!indicatorLabel || entry[1] === null) return;
|
||||||
|
const indicator = entry[0] as UsedIndicatorName; // guaranteed by the previous line
|
||||||
|
const value = entry[1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={indicator}>
|
||||||
|
<span>{indicatorLabel}:</span>
|
||||||
|
<span className="font-bold">
|
||||||
|
{formatIndicatorValue(value, indicator, question.platform.id)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
question: QuestionFragment;
|
||||||
|
expandFooterToFullWidth: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QuestionFooter: React.FC<Props> = ({
|
||||||
|
question,
|
||||||
|
expandFooterToFullWidth,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`grid grid-cols-3 ${
|
||||||
|
expandFooterToFullWidth ? "justify-between" : ""
|
||||||
|
} text-gray-500 mb-2 mt-1`}
|
||||||
|
>
|
||||||
|
<Stars num={question.qualityIndicators.stars} />
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
expandFooterToFullWidth ? "place-self-center" : "self-center"
|
||||||
|
} col-span-1 font-bold`}
|
||||||
|
>
|
||||||
|
{question.platform.label
|
||||||
|
.replace("Good Judgment Open", "GJOpen")
|
||||||
|
.replace(/ /g, "\u00a0")}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
expandFooterToFullWidth
|
||||||
|
? "justify-self-end mr-4"
|
||||||
|
: "justify-self-center"
|
||||||
|
} col-span-1`}
|
||||||
|
>
|
||||||
|
<QualityIndicatorsList question={question} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -2,10 +2,11 @@ import Link from "next/link";
|
||||||
import { FaExpand } from "react-icons/fa";
|
import { FaExpand } from "react-icons/fa";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
import { CopyText } from "../../common/CopyText";
|
import { Card } from "../../../common/Card";
|
||||||
import { QuestionOptions } from "../../questions/components/QuestionOptions";
|
import { CopyText } from "../../../common/CopyText";
|
||||||
import { QuestionFragment } from "../../search/queries.generated";
|
import { QuestionFragment } from "../../../fragments.generated";
|
||||||
import { Card } from "../Card";
|
import { cleanText } from "../../../utils";
|
||||||
|
import { QuestionOptions } from "../QuestionOptions";
|
||||||
import { QuestionFooter } from "./QuestionFooter";
|
import { QuestionFooter } from "./QuestionFooter";
|
||||||
|
|
||||||
const truncateText = (length: number, text: string): string => {
|
const truncateText = (length: number, text: string): string => {
|
||||||
|
@ -68,24 +69,6 @@ if (!String.prototype.replaceAll) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanText = (text: string): string => {
|
|
||||||
// Note: should no longer be necessary
|
|
||||||
let textString = !!text ? text : "";
|
|
||||||
textString = textString
|
|
||||||
.replaceAll("] (", "](")
|
|
||||||
.replaceAll(") )", "))")
|
|
||||||
.replaceAll("( [", "([")
|
|
||||||
.replaceAll(") ,", "),")
|
|
||||||
.replaceAll("==", "") // Denotes a title in markdown
|
|
||||||
.replaceAll("Background\n", "")
|
|
||||||
.replaceAll("Context\n", "")
|
|
||||||
.replaceAll("--- \n", "- ")
|
|
||||||
.replaceAll(/\[(.*?)\]\(.*?\)/g, "$1");
|
|
||||||
textString = textString.slice(0, 1) == "=" ? textString.slice(1) : textString;
|
|
||||||
//console.log(textString)
|
|
||||||
return textString;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auxiliary components
|
// Auxiliary components
|
||||||
|
|
||||||
const DisplayMarkdown: React.FC<{ description: string }> = ({
|
const DisplayMarkdown: React.FC<{ description: string }> = ({
|
||||||
|
@ -121,13 +104,15 @@ interface Props {
|
||||||
showTimeStamp: boolean;
|
showTimeStamp: boolean;
|
||||||
expandFooterToFullWidth: boolean;
|
expandFooterToFullWidth: boolean;
|
||||||
showIdToggle?: boolean;
|
showIdToggle?: boolean;
|
||||||
|
showExpandButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DisplayQuestion: React.FC<Props> = ({
|
export const QuestionCard: React.FC<Props> = ({
|
||||||
question,
|
question,
|
||||||
showTimeStamp,
|
showTimeStamp,
|
||||||
expandFooterToFullWidth,
|
expandFooterToFullWidth,
|
||||||
showIdToggle,
|
showIdToggle,
|
||||||
|
showExpandButton = true,
|
||||||
}) => {
|
}) => {
|
||||||
const { options } = question;
|
const { options } = question;
|
||||||
const lastUpdated = new Date(question.timestamp * 1000);
|
const lastUpdated = new Date(question.timestamp * 1000);
|
||||||
|
@ -146,7 +131,7 @@ export const DisplayQuestion: React.FC<Props> = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div>
|
<div>
|
||||||
{process.env.NEXT_PUBLIC_ENABLE_QUESTION_PAGES ? (
|
{showExpandButton ? (
|
||||||
<Link href={`/questions/${question.id}`} passHref>
|
<Link href={`/questions/${question.id}`} passHref>
|
||||||
<a className="float-right block ml-2 mt-1.5">
|
<a className="float-right block ml-2 mt-1.5">
|
||||||
<FaExpand
|
<FaExpand
|
|
@ -1,7 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { QuestionFragment } from "../search/queries.generated";
|
import { QuestionFragment } from "../../fragments.generated";
|
||||||
import { DisplayQuestion } from "./DisplayQuestion";
|
import { QuestionCard } from "./QuestionCard";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
results: QuestionFragment[];
|
results: QuestionFragment[];
|
||||||
|
@ -9,18 +9,18 @@ interface Props {
|
||||||
showIdToggle: boolean;
|
showIdToggle: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DisplayQuestions: React.FC<Props> = ({
|
export const QuestionCardsList: React.FC<Props> = ({
|
||||||
results,
|
results,
|
||||||
numDisplay,
|
numDisplay,
|
||||||
showIdToggle,
|
showIdToggle,
|
||||||
}) => {
|
}) => {
|
||||||
if (!results) {
|
if (!results) {
|
||||||
return <></>;
|
return null;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{results.slice(0, numDisplay).map((result) => (
|
{results.slice(0, numDisplay).map((result) => (
|
||||||
<DisplayQuestion
|
<QuestionCard
|
||||||
key={result.id}
|
key={result.id}
|
||||||
question={result}
|
question={result}
|
||||||
showTimeStamp={false}
|
showTimeStamp={false}
|
|
@ -1,4 +1,4 @@
|
||||||
import { QuestionFragment } from "../../search/queries.generated";
|
import { QuestionFragment } from "../../fragments.generated";
|
||||||
|
|
||||||
type QualityIndicator = QuestionFragment["qualityIndicators"];
|
type QualityIndicator = QuestionFragment["qualityIndicators"];
|
||||||
type IndicatorName = keyof QualityIndicator;
|
type IndicatorName = keyof QualityIndicator;
|
|
@ -1,4 +1,4 @@
|
||||||
import { QuestionFragment } from "../../search/queries.generated";
|
import { QuestionFragment } from "../../fragments.generated";
|
||||||
import { formatProbability } from "../utils";
|
import { formatProbability } from "../utils";
|
||||||
|
|
||||||
type Option = QuestionFragment["options"][0];
|
type Option = QuestionFragment["options"][0];
|
||||||
|
|
62
src/web/questions/components/Stars.tsx
Normal file
62
src/web/questions/components/Stars.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
// Database-like functions
|
||||||
|
export function getstars(numstars: number) {
|
||||||
|
let stars = "★★☆☆☆";
|
||||||
|
switch (numstars) {
|
||||||
|
case 0:
|
||||||
|
stars = "☆☆☆☆☆";
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
stars = "★☆☆☆☆";
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
stars = "★★☆☆☆";
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
stars = "★★★☆☆";
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
stars = "★★★★☆";
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
stars = "★★★★★";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
stars = "★★☆☆☆";
|
||||||
|
}
|
||||||
|
return stars;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStarsColor(numstars: number) {
|
||||||
|
let color = "text-yellow-400";
|
||||||
|
switch (numstars) {
|
||||||
|
case 0:
|
||||||
|
color = "text-red-400";
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
color = "text-red-400";
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
color = "text-orange-400";
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
color = "text-yellow-400";
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
color = "text-green-400";
|
||||||
|
break;
|
||||||
|
case 5:
|
||||||
|
color = "text-blue-400";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
color = "text-yellow-400";
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Stars: React.FC<{ num: number }> = ({ num }) => {
|
||||||
|
return (
|
||||||
|
<div className={`self-center col-span-1 ${getStarsColor(num)}`}>
|
||||||
|
{getstars(num)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,14 +1,18 @@
|
||||||
import { GetServerSideProps, NextPage } from "next";
|
import { GetServerSideProps, NextPage } from "next";
|
||||||
|
import { FaExternalLinkAlt } from "react-icons/fa";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
|
import { Card } from "../../common/Card";
|
||||||
|
import { Layout } from "../../common/Layout";
|
||||||
|
import { LineHeader } from "../../common/LineHeader";
|
||||||
import { Query } from "../../common/Query";
|
import { Query } from "../../common/Query";
|
||||||
import { Card } from "../../display/Card";
|
import { QuestionWithHistoryFragment } from "../../fragments.generated";
|
||||||
import { QuestionFooter } from "../../display/DisplayQuestion/QuestionFooter";
|
|
||||||
import { Layout } from "../../display/Layout";
|
|
||||||
import { QuestionFragment } from "../../search/queries.generated";
|
|
||||||
import { ssrUrql } from "../../urql";
|
import { ssrUrql } from "../../urql";
|
||||||
import { QuestionOptions } from "../components/QuestionOptions";
|
import { CaptureQuestion } from "../components/CaptureQuestion";
|
||||||
import { QuestionByIdDocument } from "../queries.generated";
|
import { HistoryChart } from "../components/HistoryChart";
|
||||||
|
import { IndicatorsTable } from "../components/IndicatorsTable";
|
||||||
|
import { Stars } from "../components/Stars";
|
||||||
|
import { QuestionPageDocument } from "../queries.generated";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -21,7 +25,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
|
||||||
const id = context.query.id as string;
|
const id = context.query.id as string;
|
||||||
|
|
||||||
const question =
|
const question =
|
||||||
(await client.query(QuestionByIdDocument, { id }).toPromise()).data
|
(await client.query(QuestionPageDocument, { id }).toPromise()).data
|
||||||
?.result || null;
|
?.result || null;
|
||||||
|
|
||||||
if (!question) {
|
if (!question) {
|
||||||
|
@ -36,37 +40,87 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const QuestionCardContents: React.FC<{ question: QuestionFragment }> = ({
|
const Section: React.FC<{ title: string }> = ({ title, children }) => (
|
||||||
question,
|
<div className="space-y-2 flex flex-col items-start">
|
||||||
}) => (
|
<h2 className="text-xl text-gray-900">{title}</h2>
|
||||||
<div className="space-y-4">
|
<div>{children}</div>
|
||||||
<h1>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const LargeQuestionCard: React.FC<{
|
||||||
|
question: QuestionWithHistoryFragment;
|
||||||
|
}> = ({ question }) => (
|
||||||
|
<Card highlightOnHover={false} large={true}>
|
||||||
|
<h1 className="sm:text-3xl text-xl">
|
||||||
<a
|
<a
|
||||||
className="text-black no-underline"
|
className="text-black no-underline hover:text-gray-700"
|
||||||
href={question.url}
|
href={question.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{question.title}
|
{question.title}{" "}
|
||||||
|
<FaExternalLinkAlt className="text-gray-400 inline sm:text-3xl text-xl mb-1" />
|
||||||
</a>
|
</a>
|
||||||
</h1>
|
</h1>
|
||||||
<QuestionFooter question={question} expandFooterToFullWidth={true} />
|
|
||||||
<QuestionOptions options={question.options} />
|
|
||||||
|
|
||||||
<ReactMarkdown linkTarget="_blank" className="font-normal">
|
<div className="flex gap-2 mb-2">
|
||||||
{question.description}
|
<a
|
||||||
|
className="text-black no-underline bg-red-300 rounded p-1 px-2 text-xs hover:text-gray-600"
|
||||||
|
href={question.url}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{question.platform.label}
|
||||||
|
</a>
|
||||||
|
<Stars num={question.qualityIndicators.stars} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
{question.platform.id === "guesstimate" ? (
|
||||||
|
<a className="no-underline" href={question.url} target="_blank">
|
||||||
|
<img
|
||||||
|
className="rounded-sm"
|
||||||
|
src={question.visualization}
|
||||||
|
alt="Guesstimate Screenshot"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<HistoryChart question={question} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReactMarkdown
|
||||||
|
linkTarget="_blank"
|
||||||
|
className="font-normal text-gray-900 max-w-prose"
|
||||||
|
>
|
||||||
|
{question.description.replaceAll("---", "")}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|
||||||
|
<Section title="Indicators">
|
||||||
|
<IndicatorsTable question={question} />
|
||||||
|
</Section>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
const QuestionScreen: React.FC<{ question: QuestionWithHistoryFragment }> = ({
|
||||||
|
question,
|
||||||
|
}) => (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<LargeQuestionCard question={question} />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<LineHeader>
|
||||||
|
<h1>Capture</h1>
|
||||||
|
</LineHeader>
|
||||||
|
<CaptureQuestion question={question} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const QuestionPage: NextPage<Props> = ({ id }) => {
|
const QuestionPage: NextPage<Props> = ({ id }) => {
|
||||||
return (
|
return (
|
||||||
<Layout page="question">
|
<Layout page="question">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<Card highlightOnHover={false}>
|
<Query document={QuestionPageDocument} variables={{ id }}>
|
||||||
<Query document={QuestionByIdDocument} variables={{ id }}>
|
{({ data }) => <QuestionScreen question={data.result} />}
|
||||||
{({ data }) => <QuestionCardContents question={data.result} />}
|
|
||||||
</Query>
|
</Query>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import * as Types from '../../graphql/types.generated';
|
import * as Types from '../../graphql/types.generated';
|
||||||
|
|
||||||
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||||
import { QuestionFragmentDoc } from '../search/queries.generated';
|
import { QuestionWithHistoryFragmentDoc } from '../fragments.generated';
|
||||||
export type QuestionByIdQueryVariables = Types.Exact<{
|
export type QuestionPageQueryVariables = Types.Exact<{
|
||||||
id: Types.Scalars['ID'];
|
id: Types.Scalars['ID'];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|
||||||
export type QuestionByIdQuery = { __typename?: 'Query', result: { __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } } };
|
export type QuestionPageQuery = { __typename?: 'Query', result: { __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, history: Array<{ __typename?: 'History', timestamp: number, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }> }>, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } } };
|
||||||
|
|
||||||
|
|
||||||
export const QuestionByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"QuestionById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"result"},"name":{"kind":"Name","value":"question"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Question"}}]}}]}},...QuestionFragmentDoc.definitions]} as unknown as DocumentNode<QuestionByIdQuery, QuestionByIdQueryVariables>;
|
export const QuestionPageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"QuestionPage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"result"},"name":{"kind":"Name","value":"question"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"QuestionWithHistory"}}]}}]}},...QuestionWithHistoryFragmentDoc.definitions]} as unknown as DocumentNode<QuestionPageQuery, QuestionPageQueryVariables>;
|
|
@ -1,5 +1,5 @@
|
||||||
query QuestionById($id: ID!) {
|
query QuestionPage($id: ID!) {
|
||||||
result: question(id: $id) {
|
result: question(id: $id) {
|
||||||
...Question
|
...QuestionWithHistory
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,93 +0,0 @@
|
||||||
import { GetServerSideProps } from "next";
|
|
||||||
|
|
||||||
import { getPlatformsConfig, PlatformConfig, platforms } from "../../backend/platforms";
|
|
||||||
import { ssrUrql } from "../urql";
|
|
||||||
import { FrontpageDocument, QuestionFragment, SearchDocument } from "./queries.generated";
|
|
||||||
|
|
||||||
/* Common code for / and /capture */
|
|
||||||
|
|
||||||
export interface QueryParameters {
|
|
||||||
query: string;
|
|
||||||
starsThreshold: number;
|
|
||||||
forecastsThreshold: number;
|
|
||||||
forecastingPlatforms: string[]; // platform names
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
defaultResults: QuestionFragment[];
|
|
||||||
initialQueryParameters: QueryParameters;
|
|
||||||
defaultQueryParameters: QueryParameters;
|
|
||||||
initialNumDisplay: number;
|
|
||||||
defaultNumDisplay: number;
|
|
||||||
platformsConfig: PlatformConfig[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps<Props> = async (
|
|
||||||
context
|
|
||||||
) => {
|
|
||||||
const [ssrCache, client] = ssrUrql();
|
|
||||||
const urlQuery = context.query;
|
|
||||||
|
|
||||||
const platformsConfig = getPlatformsConfig({ withGuesstimate: true });
|
|
||||||
|
|
||||||
const defaultQueryParameters: QueryParameters = {
|
|
||||||
query: "",
|
|
||||||
starsThreshold: 2,
|
|
||||||
forecastsThreshold: 0,
|
|
||||||
forecastingPlatforms: platforms.map((platform) => platform.name),
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialQueryParameters: QueryParameters = {
|
|
||||||
...defaultQueryParameters,
|
|
||||||
};
|
|
||||||
if (urlQuery.query) {
|
|
||||||
initialQueryParameters.query = String(urlQuery.query);
|
|
||||||
}
|
|
||||||
if (urlQuery.starsThreshold) {
|
|
||||||
initialQueryParameters.starsThreshold = Number(urlQuery.starsThreshold);
|
|
||||||
}
|
|
||||||
if (urlQuery.forecastsThreshold !== undefined) {
|
|
||||||
initialQueryParameters.forecastsThreshold = Number(
|
|
||||||
urlQuery.forecastsThreshold
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (urlQuery.forecastingPlatforms !== undefined) {
|
|
||||||
initialQueryParameters.forecastingPlatforms = String(
|
|
||||||
urlQuery.forecastingPlatforms
|
|
||||||
).split("|");
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultNumDisplay = 21;
|
|
||||||
const initialNumDisplay = Number(urlQuery.numDisplay) || defaultNumDisplay;
|
|
||||||
|
|
||||||
const defaultResults = (await client.query(FrontpageDocument).toPromise())
|
|
||||||
.data.result;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!!initialQueryParameters &&
|
|
||||||
initialQueryParameters.query != "" &&
|
|
||||||
initialQueryParameters.query != undefined
|
|
||||||
) {
|
|
||||||
// must match the query from CommonDisplay
|
|
||||||
await client
|
|
||||||
.query(SearchDocument, {
|
|
||||||
input: {
|
|
||||||
...initialQueryParameters,
|
|
||||||
limit: initialNumDisplay,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.toPromise();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
urqlState: ssrCache.extractData(),
|
|
||||||
initialQueryParameters,
|
|
||||||
defaultQueryParameters,
|
|
||||||
initialNumDisplay,
|
|
||||||
defaultNumDisplay,
|
|
||||||
defaultResults,
|
|
||||||
platformsConfig,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,5 +1,3 @@
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
|
@ -2,42 +2,40 @@ import { useRouter } from "next/router";
|
||||||
import React, { Fragment, useMemo, useState } from "react";
|
import React, { Fragment, useMemo, useState } from "react";
|
||||||
import { useQuery } from "urql";
|
import { useQuery } from "urql";
|
||||||
|
|
||||||
import { ButtonsForStars } from "../display/ButtonsForStars";
|
import { PlatformConfig } from "../../../backend/platforms";
|
||||||
import { MultiSelectPlatform } from "../display/MultiSelectPlatform";
|
import { MultiSelectPlatform } from "../../common/MultiSelectPlatform";
|
||||||
import { QueryForm } from "../display/QueryForm";
|
import { ButtonsForStars } from "../../display/ButtonsForStars";
|
||||||
import { SliderElement } from "../display/SliderElement";
|
import { SliderElement } from "../../display/SliderElement";
|
||||||
import { useIsFirstRender, useNoInitialEffect } from "../hooks";
|
import { QuestionFragment } from "../../fragments.generated";
|
||||||
import { Props as AnySearchPageProps, QueryParameters } from "./anySearchPage";
|
import { useIsFirstRender, useNoInitialEffect } from "../../hooks";
|
||||||
import { QuestionFragment, SearchDocument } from "./queries.generated";
|
import { QuestionCardsList } from "../../questions/components/QuestionCardsList";
|
||||||
|
import { SearchDocument } from "../queries.generated";
|
||||||
|
import { QueryForm } from "./QueryForm";
|
||||||
|
|
||||||
interface Props extends AnySearchPageProps {
|
export interface QueryParameters {
|
||||||
hasSearchbar: boolean;
|
query: string;
|
||||||
hasCapture: boolean;
|
starsThreshold: number;
|
||||||
hasAdvancedOptions: boolean;
|
forecastsThreshold: number;
|
||||||
placeholder: string;
|
forecastingPlatforms: string[]; // platform names
|
||||||
displaySeeMoreHint: boolean;
|
}
|
||||||
displayQuestionsWrapper: (opts: {
|
|
||||||
results: QuestionFragment[];
|
export interface Props {
|
||||||
numDisplay: number;
|
defaultResults: QuestionFragment[];
|
||||||
whichResultToDisplayAndCapture: number;
|
initialQueryParameters: QueryParameters;
|
||||||
showIdToggle: boolean;
|
defaultQueryParameters: QueryParameters;
|
||||||
}) => React.ReactNode;
|
initialNumDisplay: number;
|
||||||
|
defaultNumDisplay: number;
|
||||||
|
platformsConfig: PlatformConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Body */
|
/* Body */
|
||||||
const CommonDisplay: React.FC<Props> = ({
|
export const SearchScreen: React.FC<Props> = ({
|
||||||
defaultResults,
|
defaultResults,
|
||||||
initialQueryParameters,
|
initialQueryParameters,
|
||||||
defaultQueryParameters,
|
defaultQueryParameters,
|
||||||
initialNumDisplay,
|
initialNumDisplay,
|
||||||
defaultNumDisplay,
|
defaultNumDisplay,
|
||||||
platformsConfig,
|
platformsConfig,
|
||||||
hasSearchbar,
|
|
||||||
hasCapture,
|
|
||||||
hasAdvancedOptions,
|
|
||||||
placeholder,
|
|
||||||
displaySeeMoreHint,
|
|
||||||
displayQuestionsWrapper,
|
|
||||||
}) => {
|
}) => {
|
||||||
/* States */
|
/* States */
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -53,8 +51,6 @@ const CommonDisplay: React.FC<Props> = ({
|
||||||
const [forceSearch, setForceSearch] = useState(0);
|
const [forceSearch, setForceSearch] = useState(0);
|
||||||
|
|
||||||
const [advancedOptions, showAdvancedOptions] = useState(false);
|
const [advancedOptions, showAdvancedOptions] = useState(false);
|
||||||
const [whichResultToDisplayAndCapture, setWhichResultToDisplayAndCapture] =
|
|
||||||
useState(0);
|
|
||||||
const [showIdToggle, setShowIdToggle] = useState(false);
|
const [showIdToggle, setShowIdToggle] = useState(false);
|
||||||
|
|
||||||
const [typing, setTyping] = useState(false);
|
const [typing, setTyping] = useState(false);
|
||||||
|
@ -118,12 +114,15 @@ const CommonDisplay: React.FC<Props> = ({
|
||||||
numDisplay % 3 != 0
|
numDisplay % 3 != 0
|
||||||
? numDisplay + (3 - (Math.round(numDisplay) % 3))
|
? numDisplay + (3 - (Math.round(numDisplay) % 3))
|
||||||
: numDisplay;
|
: numDisplay;
|
||||||
return displayQuestionsWrapper({
|
return (
|
||||||
results,
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
numDisplay: numDisplayRounded,
|
<QuestionCardsList
|
||||||
whichResultToDisplayAndCapture,
|
results={results}
|
||||||
showIdToggle,
|
numDisplay={numDisplayRounded}
|
||||||
});
|
showIdToggle={showIdToggle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateRoute = () => {
|
const updateRoute = () => {
|
||||||
|
@ -217,7 +216,7 @@ const CommonDisplay: React.FC<Props> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Change selected platforms */
|
/* Change selected platforms */
|
||||||
const onChangeSelectedPlatforms = (value) => {
|
const onChangeSelectedPlatforms = (value: string[]) => {
|
||||||
setQueryParameters({
|
setQueryParameters({
|
||||||
...queryParameters,
|
...queryParameters,
|
||||||
forecastingPlatforms: value,
|
forecastingPlatforms: value,
|
||||||
|
@ -229,32 +228,18 @@ const CommonDisplay: React.FC<Props> = ({
|
||||||
setShowIdToggle(!showIdToggle);
|
setShowIdToggle(!showIdToggle);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capture functionality
|
|
||||||
const onClickBack = () => {
|
|
||||||
const decreaseUntil0 = (num: number) => (num - 1 > 0 ? num - 1 : 0);
|
|
||||||
setWhichResultToDisplayAndCapture(
|
|
||||||
decreaseUntil0(whichResultToDisplayAndCapture)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const onClickForward = (whichResultToDisplayAndCapture: number) => {
|
|
||||||
setWhichResultToDisplayAndCapture(whichResultToDisplayAndCapture + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* Final return */
|
/* Final return */
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<label className="mb-4 mt-4 flex flex-row justify-center items-center">
|
<label className="mb-4 mt-4 flex flex-row justify-center items-center">
|
||||||
{hasSearchbar ? (
|
|
||||||
<div className="w-10/12 mb-2">
|
<div className="w-10/12 mb-2">
|
||||||
<QueryForm
|
<QueryForm
|
||||||
value={queryParameters.query}
|
value={queryParameters.query}
|
||||||
onChange={onChangeSearchBar}
|
onChange={onChangeSearchBar}
|
||||||
placeholder={placeholder}
|
placeholder="Find forecasts about..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{hasAdvancedOptions ? (
|
|
||||||
<div className="w-2/12 flex justify-center ml-4 md:ml-2 lg:ml-0">
|
<div className="w-2/12 flex justify-center ml-4 md:ml-2 lg:ml-0">
|
||||||
<button
|
<button
|
||||||
className="text-gray-500 text-sm mb-2"
|
className="text-gray-500 text-sm mb-2"
|
||||||
|
@ -263,27 +248,9 @@ const CommonDisplay: React.FC<Props> = ({
|
||||||
Advanced options ▼
|
Advanced options ▼
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{hasCapture ? (
|
|
||||||
<div className="w-2/12 flex justify-center ml-4 md:ml-2 gap-1 lg:ml-0">
|
|
||||||
<button
|
|
||||||
className="text-blue-500 cursor-pointer text-xl mb-3 pr-3 hover:text-blue-600"
|
|
||||||
onClick={() => onClickBack()}
|
|
||||||
>
|
|
||||||
◀
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="text-blue-500 cursor-pointer text-xl mb-3 pl-3 hover:text-blue-600"
|
|
||||||
onClick={() => onClickForward(whichResultToDisplayAndCapture)}
|
|
||||||
>
|
|
||||||
▶
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{hasAdvancedOptions && advancedOptions ? (
|
{advancedOptions ? (
|
||||||
<div className="flex-1 flex-col mx-auto justify-center items-center w-full">
|
<div className="flex-1 flex-col mx-auto justify-center items-center w-full">
|
||||||
<div className="grid sm:grid-rows-4 sm:grid-cols-1 md:grid-rows-2 lg:grid-rows-2 grid-cols-1 md:grid-cols-3 lg:grid-cols-3 items-center content-center bg-gray-50 rounded-md px-8 pt-4 pb-1 shadow mb-4">
|
<div className="grid sm:grid-rows-4 sm:grid-cols-1 md:grid-rows-2 lg:grid-rows-2 grid-cols-1 md:grid-cols-3 lg:grid-cols-3 items-center content-center bg-gray-50 rounded-md px-8 pt-4 pb-1 shadow mb-4">
|
||||||
<div className="flex row-start-1 row-end-1 col-start-1 col-end-4 md:row-span-1 md:col-start-1 md:col-end-1 md:row-start-1 md:row-end-1 lg:row-span-1 lg:col-start-1 lg:col-end-1 lg:row-start-1 lg:row-end-1 items-center justify-center mb-4">
|
<div className="flex row-start-1 row-end-1 col-start-1 col-end-4 md:row-span-1 md:col-start-1 md:col-end-1 md:row-start-1 md:row-end-1 lg:row-span-1 lg:col-start-1 lg:col-end-1 lg:row-start-1 lg:row-end-1 items-center justify-center mb-4">
|
||||||
|
@ -325,8 +292,7 @@ const CommonDisplay: React.FC<Props> = ({
|
||||||
|
|
||||||
<div>{getInfoToDisplayQuestionsFunction()}</div>
|
<div>{getInfoToDisplayQuestionsFunction()}</div>
|
||||||
|
|
||||||
{displaySeeMoreHint &&
|
{!results || (results.length !== 0 && numDisplay < results.length) ? (
|
||||||
(!results || (results.length !== 0 && numDisplay < results.length)) ? (
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mt-4 mb-4">
|
<p className="mt-4 mb-4">
|
||||||
{"Can't find what you were looking for?"}
|
{"Can't find what you were looking for?"}
|
||||||
|
@ -351,10 +317,6 @@ const CommonDisplay: React.FC<Props> = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<br />
|
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CommonDisplay;
|
|
|
@ -1,8 +1,7 @@
|
||||||
import * as Types from '../../graphql/types.generated';
|
import * as Types from '../../graphql/types.generated';
|
||||||
|
|
||||||
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||||
export type QuestionFragment = { __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } };
|
import { QuestionFragmentDoc } from '../fragments.generated';
|
||||||
|
|
||||||
export type FrontpageQueryVariables = Types.Exact<{ [key: string]: never; }>;
|
export type FrontpageQueryVariables = Types.Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,6 +14,6 @@ export type SearchQueryVariables = Types.Exact<{
|
||||||
|
|
||||||
export type SearchQuery = { __typename?: 'Query', result: Array<{ __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } }> };
|
export type SearchQuery = { __typename?: 'Query', result: Array<{ __typename?: 'Question', id: string, url: string, title: string, description: string, timestamp: number, visualization?: string | null, options: Array<{ __typename?: 'ProbabilityOption', name?: string | null, probability?: number | null }>, platform: { __typename?: 'Platform', id: string, label: string }, qualityIndicators: { __typename?: 'QualityIndicators', stars: number, numForecasts?: number | null, numForecasters?: number | null, volume?: number | null, spread?: number | null, sharesVolume?: number | null, openInterest?: number | null, liquidity?: number | null, tradeVolume?: number | null } }> };
|
||||||
|
|
||||||
export const QuestionFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Question"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Question"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"options"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"probability"}}]}},{"kind":"Field","name":{"kind":"Name","value":"platform"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}},{"kind":"Field","name":{"kind":"Name","value":"qualityIndicators"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"stars"}},{"kind":"Field","name":{"kind":"Name","value":"numForecasts"}},{"kind":"Field","name":{"kind":"Name","value":"numForecasters"}},{"kind":"Field","name":{"kind":"Name","value":"volume"}},{"kind":"Field","name":{"kind":"Name","value":"spread"}},{"kind":"Field","name":{"kind":"Name","value":"sharesVolume"}},{"kind":"Field","name":{"kind":"Name","value":"openInterest"}},{"kind":"Field","name":{"kind":"Name","value":"liquidity"}},{"kind":"Field","name":{"kind":"Name","value":"tradeVolume"}}]}},{"kind":"Field","name":{"kind":"Name","value":"visualization"}}]}}]} as unknown as DocumentNode<QuestionFragment, unknown>;
|
|
||||||
export const FrontpageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Frontpage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"result"},"name":{"kind":"Name","value":"frontpage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Question"}}]}}]}},...QuestionFragmentDoc.definitions]} as unknown as DocumentNode<FrontpageQuery, FrontpageQueryVariables>;
|
export const FrontpageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Frontpage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"result"},"name":{"kind":"Name","value":"frontpage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Question"}}]}}]}},...QuestionFragmentDoc.definitions]} as unknown as DocumentNode<FrontpageQuery, FrontpageQueryVariables>;
|
||||||
export const SearchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Search"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SearchInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"result"},"name":{"kind":"Name","value":"searchQuestions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Question"}}]}}]}},...QuestionFragmentDoc.definitions]} as unknown as DocumentNode<SearchQuery, SearchQueryVariables>;
|
export const SearchDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Search"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SearchInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"result"},"name":{"kind":"Name","value":"searchQuestions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Question"}}]}}]}},...QuestionFragmentDoc.definitions]} as unknown as DocumentNode<SearchQuery, SearchQueryVariables>;
|
|
@ -1,31 +1,3 @@
|
||||||
fragment Question on Question {
|
|
||||||
id
|
|
||||||
url
|
|
||||||
title
|
|
||||||
description
|
|
||||||
timestamp
|
|
||||||
options {
|
|
||||||
name
|
|
||||||
probability
|
|
||||||
}
|
|
||||||
platform {
|
|
||||||
id
|
|
||||||
label
|
|
||||||
}
|
|
||||||
qualityIndicators {
|
|
||||||
stars
|
|
||||||
numForecasts
|
|
||||||
numForecasters
|
|
||||||
volume
|
|
||||||
spread
|
|
||||||
sharesVolume
|
|
||||||
openInterest
|
|
||||||
liquidity
|
|
||||||
tradeVolume
|
|
||||||
}
|
|
||||||
visualization
|
|
||||||
}
|
|
||||||
|
|
||||||
query Frontpage {
|
query Frontpage {
|
||||||
result: frontpage {
|
result: frontpage {
|
||||||
...Question
|
...Question
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
|
|
||||||
|
import { Layout } from "../../common/Layout";
|
||||||
import { Query } from "../../common/Query";
|
import { Query } from "../../common/Query";
|
||||||
import { Layout } from "../../display/Layout";
|
|
||||||
import { PlatformsStatusDocument } from "../queries.generated";
|
import { PlatformsStatusDocument } from "../queries.generated";
|
||||||
|
|
||||||
const StatusPage: NextPage = () => {
|
const StatusPage: NextPage = () => {
|
||||||
|
|
|
@ -10,3 +10,22 @@ export const getBasePath = () => {
|
||||||
|
|
||||||
return "http://localhost:3000";
|
return "http://localhost:3000";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const cleanText = (text: string): string => {
|
||||||
|
// Note: should no longer be necessary?
|
||||||
|
// Still needed for e.g. /questions/rootclaim-what-caused-the-disappearance-of-malaysia-airlines-flight-370
|
||||||
|
let textString = !!text ? text : "";
|
||||||
|
textString = textString
|
||||||
|
.replaceAll("] (", "](")
|
||||||
|
.replaceAll(") )", "))")
|
||||||
|
.replaceAll("( [", "([")
|
||||||
|
.replaceAll(") ,", "),")
|
||||||
|
.replaceAll("==", "") // Denotes a title in markdown
|
||||||
|
.replaceAll("Background\n", "")
|
||||||
|
.replaceAll("Context\n", "")
|
||||||
|
.replaceAll("--- \n", "- ")
|
||||||
|
.replaceAll(/\[(.*?)\]\(.*?\)/g, "$1");
|
||||||
|
textString = textString.slice(0, 1) == "=" ? textString.slice(1) : textString;
|
||||||
|
//console.log(textString)
|
||||||
|
return textString;
|
||||||
|
};
|
||||||
|
|
|
@ -1,72 +0,0 @@
|
||||||
/* Imports */
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
import { AlgoliaQuestion } from "../../backend/utils/algolia";
|
|
||||||
|
|
||||||
/* Definitions */
|
|
||||||
const urlEndPoint =
|
|
||||||
"https://m629r9ugsg-dsn.algolia.net/1/indexes/Space_production/query?x-algolia-agent=Algolia%20for%20vanilla%20JavaScript%203.32.1&x-algolia-application-id=M629R9UGSG&x-algolia-api-key=4e893740a2bd467a96c8bfcf95b2809c";
|
|
||||||
|
|
||||||
/* Body */
|
|
||||||
|
|
||||||
export default async function searchGuesstimate(
|
|
||||||
query
|
|
||||||
): Promise<AlgoliaQuestion[]> {
|
|
||||||
const response = await axios({
|
|
||||||
url: urlEndPoint,
|
|
||||||
// credentials: "omit",
|
|
||||||
headers: {
|
|
||||||
// "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:85.0) Gecko/20100101 Firefox/85.0",
|
|
||||||
Accept: "application/json",
|
|
||||||
"Accept-Language": "en-US,en;q=0.5",
|
|
||||||
"content-type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
data: `{\"params\":\"query=${query.replace(
|
|
||||||
/ /g,
|
|
||||||
"%20"
|
|
||||||
)}&hitsPerPage=20&page=0&getRankingInfo=true\"}`,
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
|
|
||||||
const models: any[] = response.data.hits;
|
|
||||||
const mappedModels: AlgoliaQuestion[] = models.map((model, index) => {
|
|
||||||
const description = model.description
|
|
||||||
? model.description.replace(/\n/g, " ").replace(/ /g, " ")
|
|
||||||
: "";
|
|
||||||
const stars = description.length > 250 ? 2 : 1;
|
|
||||||
const q: AlgoliaQuestion = {
|
|
||||||
id: `guesstimate-${model.id}`,
|
|
||||||
title: model.name,
|
|
||||||
url: `https://www.getguesstimate.com/models/${model.id}`,
|
|
||||||
timestamp: model.created_at, // TODO - check that format matches
|
|
||||||
platform: "guesstimate",
|
|
||||||
description,
|
|
||||||
options: [],
|
|
||||||
qualityindicators: {
|
|
||||||
stars,
|
|
||||||
numforecasts: 1,
|
|
||||||
numforecasters: 1,
|
|
||||||
},
|
|
||||||
extra: {
|
|
||||||
visualization: model.big_screenshot,
|
|
||||||
},
|
|
||||||
// ranking: 10 * (index + 1) - 0.5, //(model._rankingInfo - 1*index)// hack
|
|
||||||
};
|
|
||||||
return q;
|
|
||||||
});
|
|
||||||
|
|
||||||
// filter for duplicates. Surprisingly common.
|
|
||||||
let uniqueTitles = [];
|
|
||||||
let uniqueModels: AlgoliaQuestion[] = [];
|
|
||||||
for (let model of mappedModels) {
|
|
||||||
if (!uniqueTitles.includes(model.title) && !model.title.includes("copy")) {
|
|
||||||
uniqueModels.push(model);
|
|
||||||
uniqueTitles.push(model.title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(uniqueModels);
|
|
||||||
return uniqueModels;
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchGuesstimate("COVID-19").then(guesstimateModels => console.log(guesstimateModels))
|
|
|
@ -1,8 +1,7 @@
|
||||||
// import fetch from "fetch"
|
|
||||||
import axios, { AxiosRequestConfig } from "axios";
|
import axios, { AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
export async function uploadToImgur(dataURL, handleGettingImgurlImage) {
|
export async function uploadToImgur(dataURL: string): Promise<string> {
|
||||||
let request: AxiosRequestConfig = {
|
const request: AxiosRequestConfig = {
|
||||||
method: "post",
|
method: "post",
|
||||||
url: "https://api.imgur.com/3/image",
|
url: "https://api.imgur.com/3/image",
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -12,18 +11,15 @@ export async function uploadToImgur(dataURL, handleGettingImgurlImage) {
|
||||||
type: "base64",
|
type: "base64",
|
||||||
image: dataURL.split(",")[1],
|
image: dataURL.split(",")[1],
|
||||||
},
|
},
|
||||||
// redirect: "follow",
|
|
||||||
};
|
};
|
||||||
let url;
|
|
||||||
|
let url = "https://i.imgur.com/qcThRRz.gif"; // Error image
|
||||||
try {
|
try {
|
||||||
let response = await axios(request).then((response) => response.data);
|
const response = await axios(request).then((response) => response.data);
|
||||||
// console.log(dataURL)
|
|
||||||
// console.log(response)
|
|
||||||
url = `https://i.imgur.com/${response.data.id}.png`;
|
url = `https://i.imgur.com/${response.data.id}.png`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("error", error);
|
console.log("error", error);
|
||||||
}
|
}
|
||||||
let errorImageURL = "https://i.imgur.com/qcThRRz.gif"; // Error image
|
|
||||||
url = url || errorImageURL;
|
return url;
|
||||||
handleGettingImgurlImage(url);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,9 @@ module.exports = {
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
quri: "url('/icons/logo.svg')",
|
quri: "url('/icons/logo.svg')",
|
||||||
},
|
},
|
||||||
|
maxWidth: {
|
||||||
|
160: "160px",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
|
|
Loading…
Reference in New Issue
Block a user