Refactor page components, reduce flicker, remove orphaned module
Move some business logic out of the page components. They are now responsible for just filtering out the unsuccessful render paths and selecting between an error view or a success view passing the successfully fetched data.
This commit is contained in:
parent
b43abafa1c
commit
d4b838106a
@ -1,7 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { UrlObject } from "url";
|
|
||||||
|
|
||||||
export interface NavigationItemProps {
|
export interface NavigationItemProps {
|
||||||
text: string;
|
text: string;
|
||||||
|
@ -5,7 +5,6 @@ import React from "react";
|
|||||||
import { ComponentStory, ComponentMeta } from "@storybook/react";
|
import { ComponentStory, ComponentMeta } from "@storybook/react";
|
||||||
|
|
||||||
import { Page } from "./Page";
|
import { Page } from "./Page";
|
||||||
import { Navigation, NavigationItem } from "../Navigation";
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Components/Page",
|
title: "Components/Page",
|
||||||
@ -13,7 +12,7 @@ export default {
|
|||||||
} as ComponentMeta<typeof Page>;
|
} as ComponentMeta<typeof Page>;
|
||||||
|
|
||||||
export const Example: ComponentStory<typeof Page> = (args) => (
|
export const Example: ComponentStory<typeof Page> = (args) => (
|
||||||
<Page>
|
<Page title="Some title">
|
||||||
<div style={{ display: "flex", height: "100%" }}>
|
<div style={{ display: "flex", height: "100%" }}>
|
||||||
<div style={{ margin: "auto" }}>Content goes here</div>
|
<div style={{ margin: "auto" }}>Content goes here</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,2 +1,5 @@
|
|||||||
export * from "./ExpressionCard";
|
export * from "./ExpressionCard";
|
||||||
|
export * from "./ExpressionSetCard";
|
||||||
|
export * from "./ExpressionSetInfo";
|
||||||
export * from "./Navigation";
|
export * from "./Navigation";
|
||||||
|
export * from "./Page";
|
||||||
|
6
src/hooks/index.ts
Normal file
6
src/hooks/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./useExpressionCategories";
|
||||||
|
export * from "./useExpressionFilterQueryIds";
|
||||||
|
export * from "./useExpressionSet";
|
||||||
|
export * from "./useExpressionSetQueryId";
|
||||||
|
export * from "./useExpressionSets";
|
||||||
|
export * from "./useExpressionsInSet";
|
@ -2,8 +2,7 @@ import { useLiveQuery } from "dexie-react-hooks";
|
|||||||
import { database } from "../model";
|
import { database } from "../model";
|
||||||
|
|
||||||
export function useExpressionCategories(expression_id: number) {
|
export function useExpressionCategories(expression_id: number) {
|
||||||
return (
|
return useLiveQuery(() => {
|
||||||
useLiveQuery(() => {
|
|
||||||
return database.expression_to_category
|
return database.expression_to_category
|
||||||
.where({ expression_id })
|
.where({ expression_id })
|
||||||
.toArray()
|
.toArray()
|
||||||
@ -11,6 +10,5 @@ export function useExpressionCategories(expression_id: number) {
|
|||||||
const category_ids = relationships.map((item) => item.category_id);
|
const category_ids = relationships.map((item) => item.category_id);
|
||||||
return database.categories.where("id").anyOf(category_ids).toArray();
|
return database.categories.where("id").anyOf(category_ids).toArray();
|
||||||
});
|
});
|
||||||
}, [expression_id]) || []
|
}, [expression_id]);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
import { database, IndexedExpressionSet } from "../model";
|
import { database } from "../model";
|
||||||
|
|
||||||
export function useExpressionSets() {
|
export function useExpressionSets() {
|
||||||
return useLiveQuery(() => database.expression_sets.toArray()) || [];
|
return useLiveQuery(() => database.expression_sets.toArray());
|
||||||
}
|
}
|
||||||
|
@ -2,20 +2,13 @@ import { useLiveQuery } from "dexie-react-hooks";
|
|||||||
import { database } from "../model";
|
import { database } from "../model";
|
||||||
|
|
||||||
export function useExpressionsInSet(expression_set_id: number) {
|
export function useExpressionsInSet(expression_set_id: number) {
|
||||||
return (
|
return useLiveQuery(() => {
|
||||||
useLiveQuery(() => {
|
|
||||||
return database.expression_to_expression_set
|
return database.expression_to_expression_set
|
||||||
.where({ expression_set_id })
|
.where({ expression_set_id })
|
||||||
.toArray()
|
.toArray()
|
||||||
.then((relationships) => {
|
.then((relationships) => {
|
||||||
const expression_ids = relationships.map(
|
const expression_ids = relationships.map((item) => item.expression_id);
|
||||||
(item) => item.expression_id
|
return database.expressions.where("id").anyOf(expression_ids).toArray();
|
||||||
);
|
|
||||||
return database.expressions
|
|
||||||
.where("id")
|
|
||||||
.anyOf(expression_ids)
|
|
||||||
.toArray();
|
|
||||||
});
|
});
|
||||||
}, [expression_set_id]) || []
|
}, [expression_set_id]);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
export * from "./mock-data";
|
|
@ -4,7 +4,7 @@ import {
|
|||||||
ExpressionSet,
|
ExpressionSet,
|
||||||
ExpressionToCategory,
|
ExpressionToCategory,
|
||||||
ExpressionToExpressionSet,
|
ExpressionToExpressionSet,
|
||||||
} from "../model";
|
} from "../model/types";
|
||||||
|
|
||||||
interface RawExpressionDataItem {
|
interface RawExpressionDataItem {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
|
import { MockData } from "../mock/mock-data";
|
||||||
import Dexie, { Table } from "dexie";
|
import Dexie, { Table } from "dexie";
|
||||||
import { MockData } from "../mock";
|
|
||||||
import {
|
import {
|
||||||
Category,
|
Category,
|
||||||
Expression,
|
Expression,
|
||||||
|
@ -1,2 +1,2 @@
|
|||||||
export * from "./types";
|
|
||||||
export * from "./database";
|
export * from "./database";
|
||||||
|
export * from "./types";
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
import { PageWithError } from "../views/PageWithError";
|
import { ErrorView } from "../views";
|
||||||
|
|
||||||
const PageTitle = "Flash Card App - 404";
|
const PageTitle = "Flash Card App - 404";
|
||||||
|
|
||||||
const Page404: NextPage = () => {
|
const Page404: NextPage = () => {
|
||||||
return <PageWithError title={PageTitle} message="404 - content not found" />;
|
return <ErrorView title={PageTitle} message="404 - content not found" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page404;
|
export default Page404;
|
||||||
|
@ -3,7 +3,7 @@ import "../styles/components.css";
|
|||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
import { Navigation } from "../components";
|
import { Navigation } from "../components";
|
||||||
|
|
||||||
export default function MyApp({ Component, pageProps }: AppProps) {
|
export default function FlashCardApp({ Component, pageProps }: AppProps) {
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header>
|
<header>
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ExpressionSetInfo } from "../../components/ExpressionSetInfo/ExpressionSetInfo";
|
import { ExpressionSetInfo, Page } from "../../components";
|
||||||
import { Page } from "../../components/Page";
|
import {
|
||||||
import { useExpressionSet } from "../../hooks/useExpressionSet";
|
useExpressionSet,
|
||||||
import { useExpressionSetQueryId } from "../../hooks/useExpressionSetQueryId";
|
useExpressionSetQueryId,
|
||||||
import { useExpressionsInSet } from "../../hooks/useExpressionsInSet";
|
useExpressionsInSet,
|
||||||
import { PageWithError } from "../../views/PageWithError/PageWithError";
|
} from "../../hooks";
|
||||||
|
import { ErrorView, ExpressionSetDetailsView } from "../../views";
|
||||||
|
|
||||||
function pageTitle(name?: string) {
|
function pageTitle(name?: string) {
|
||||||
return `Flash Card App - ${name || "Sets"}`;
|
return `Flash Card App - ${name || "Sets"}`;
|
||||||
@ -18,53 +19,18 @@ const ExpressionSetDetailsPage: NextPage = () => {
|
|||||||
const expressions = useExpressionsInSet(expression_set_id);
|
const expressions = useExpressionsInSet(expression_set_id);
|
||||||
|
|
||||||
// Fallback for expression set not found
|
// Fallback for expression set not found
|
||||||
|
if (!expressions) return null;
|
||||||
if (!expression_set) {
|
if (!expression_set) {
|
||||||
return (
|
return <ErrorView title={pageTitle()} message="Expression set not found" />;
|
||||||
<PageWithError title={pageTitle()} message="Expression set not found" />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback for expression set empty
|
// Fallback for expression set empty
|
||||||
if (expressions.length === 0)
|
|
||||||
return (
|
return (
|
||||||
<Page title={pageTitle(expression_set.name)}>
|
<Page title={pageTitle(expression_set.name)}>
|
||||||
<div className="page-with-padding scroll">
|
<ExpressionSetDetailsView
|
||||||
<ExpressionSetInfo
|
expression_set={expression_set}
|
||||||
id={expression_set.id!}
|
|
||||||
name={expression_set.name}
|
|
||||||
description={expression_set.description}
|
|
||||||
expression_count={expressions.length}
|
expression_count={expressions.length}
|
||||||
/>
|
/>
|
||||||
<p className="text-details">No expressions left in this set.</p>
|
|
||||||
</div>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page title={pageTitle(expression_set.name)}>
|
|
||||||
<div className="page-with-bottom-navigation">
|
|
||||||
<section className="padding-small scroll">
|
|
||||||
<ExpressionSetInfo
|
|
||||||
id={expression_set.id!}
|
|
||||||
name={expression_set.name}
|
|
||||||
description={expression_set.description}
|
|
||||||
expression_count={expressions.length}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<section className="navigation-bottom">
|
|
||||||
<Link
|
|
||||||
href={{
|
|
||||||
pathname: "/expression-sets/practice",
|
|
||||||
query: { "set-id": expression_set.id },
|
|
||||||
}}
|
|
||||||
passHref
|
|
||||||
>
|
|
||||||
<a className="navigation-item bottom text-navigation grow">
|
|
||||||
<span>Practice this set</span>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,61 +1,25 @@
|
|||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import Link from "next/link";
|
import { Page } from "../../components";
|
||||||
import { ExpressionSetCard } from "../../components/ExpressionSetCard";
|
import { useExpressionSets } from "../../hooks";
|
||||||
import { Page } from "../../components/Page";
|
import { ErrorView, ExpressionSetListView } from "../../views";
|
||||||
import { useExpressionSets } from "../../hooks/useExpressionSets";
|
|
||||||
import { useExpressionsInSet } from "../../hooks/useExpressionsInSet";
|
|
||||||
import { IndexedExpressionSet } from "../../model";
|
|
||||||
import { PageWithError } from "../../views/PageWithError";
|
|
||||||
|
|
||||||
const PageTitle = "Flash Card App - Sets";
|
const PageTitle = "Flash Card App - Sets";
|
||||||
|
|
||||||
const ExpressionSetListPage: NextPage = () => {
|
const ExpressionSetListPage: NextPage = () => {
|
||||||
const expression_sets = useExpressionSets();
|
const expression_sets = useExpressionSets();
|
||||||
|
if (!expression_sets) return null;
|
||||||
if (!expression_sets?.length) {
|
if (!expression_sets.length) {
|
||||||
return (
|
return <ErrorView title={PageTitle} message="No expression sets found" />;
|
||||||
<PageWithError title={PageTitle} message="No expression sets found" />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page title={PageTitle}>
|
<Page title={PageTitle}>
|
||||||
<div className="page-with-padding content-list scroll">
|
<ExpressionSetListView expression_sets={expression_sets} />
|
||||||
{expression_sets.map(({ id, name, description }) => (
|
|
||||||
<ExpressionSetLink
|
|
||||||
key={id}
|
|
||||||
id={id}
|
|
||||||
name={name}
|
|
||||||
description={description}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function ExpressionSetLink({ id, description, name }: IndexedExpressionSet) {
|
|
||||||
const expressions = useExpressionsInSet(id!) || [];
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={{
|
|
||||||
pathname: "/expression-sets/details",
|
|
||||||
query: { "set-id": id },
|
|
||||||
}}
|
|
||||||
passHref
|
|
||||||
>
|
|
||||||
<a>
|
|
||||||
<ExpressionSetCard
|
|
||||||
name={name}
|
|
||||||
description={description}
|
|
||||||
expression_count={expressions.length}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default dynamic(() => Promise.resolve(ExpressionSetListPage), {
|
export default dynamic(() => Promise.resolve(ExpressionSetListPage), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
@ -1,162 +1,44 @@
|
|||||||
import type { NextPage } from "next";
|
import { NextPage } from "next";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useRouter } from "next/router";
|
import { Page } from "../../components";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { ExpressionCard } from "../../components";
|
|
||||||
import { Page } from "../../components/Page";
|
|
||||||
import { useExpressionCategories } from "../../hooks/useExpressionCategories";
|
|
||||||
import { useExpressionFilterQueryIds } from "../../hooks/useExpressionFilterQueryIds";
|
|
||||||
import { useExpressionSetQueryId } from "../../hooks/useExpressionSetQueryId";
|
|
||||||
import { useExpressionsInSet } from "../../hooks/useExpressionsInSet";
|
|
||||||
import {
|
import {
|
||||||
assignExpressionToSet,
|
useExpressionFilterQueryIds,
|
||||||
IndexedCategory,
|
useExpressionSetQueryId,
|
||||||
IndexedExpression,
|
useExpressionsInSet,
|
||||||
} from "../../model";
|
} from "../../hooks";
|
||||||
import { sample } from "../../util/array-utils";
|
import { ErrorView } from "../../views";
|
||||||
import { PageWithError } from "../../views/PageWithError/PageWithError";
|
import { ExpressionPracticeView } from "../../views";
|
||||||
|
|
||||||
const PageTitle = "Flash Card App - Practice";
|
const PageTitle = "Flash Card App - Practice";
|
||||||
|
|
||||||
// Do random selection here so we don't keep flipping states with interaction
|
|
||||||
const ExpressionPracticePage: NextPage = () => {
|
const ExpressionPracticePage: NextPage = () => {
|
||||||
// Query info
|
|
||||||
const expression_set_id = useExpressionSetQueryId();
|
const expression_set_id = useExpressionSetQueryId();
|
||||||
const filter_ids = useExpressionFilterQueryIds();
|
const filter_ids = useExpressionFilterQueryIds();
|
||||||
|
const expressions = useExpressionsInSet(expression_set_id);
|
||||||
|
|
||||||
// Filter out failed expressions and select random expression
|
// Fallback rendering for content not yet fetched
|
||||||
const expressions = useExpressionsInSet(expression_set_id).filter(
|
if (!expressions) return null;
|
||||||
(expression) => !filter_ids.includes(expression.id!)
|
if (!filter_ids) return null;
|
||||||
);
|
|
||||||
const expression: IndexedExpression | undefined = useMemo(
|
|
||||||
() => (expressions ? sample(expressions) : undefined),
|
|
||||||
[expressions]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch categories that the expression relates to
|
|
||||||
const categories = useExpressionCategories(expression?.id || 0);
|
|
||||||
|
|
||||||
// Fallback views for expression set content not found
|
// Fallback views for expression set content not found
|
||||||
if (!expression_set_id) {
|
if (!expression_set_id) {
|
||||||
return (
|
return <ErrorView title={PageTitle} message="Expression set not found" />;
|
||||||
<PageWithError title={PageTitle} message="Expression set not found" />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!expression) {
|
const filtered_expressions = expressions.filter(
|
||||||
|
(expression) => !filter_ids.includes(expression.id!)
|
||||||
|
);
|
||||||
|
if (!filtered_expressions.length) {
|
||||||
return (
|
return (
|
||||||
<PageWithError
|
<ErrorView title={PageTitle} message="No expressions left in this set" />
|
||||||
title={PageTitle}
|
|
||||||
message="No expressions left in this set"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
|
||||||
<ExpressionCardPracticeView
|
|
||||||
key={expression.id}
|
|
||||||
expression={expression}
|
|
||||||
categories={categories}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ExpressionCardPracticeViewProps {
|
|
||||||
expression: IndexedExpression;
|
|
||||||
categories: IndexedCategory[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle internal state here
|
|
||||||
function ExpressionCardPracticeView({
|
|
||||||
expression,
|
|
||||||
categories,
|
|
||||||
}: ExpressionCardPracticeViewProps) {
|
|
||||||
const [revealed, setRevealed] = useState(false);
|
|
||||||
return (
|
return (
|
||||||
<Page title={PageTitle}>
|
<Page title={PageTitle}>
|
||||||
<div className="page-with-bottom-navigation">
|
<ExpressionPracticeView select_from_expressions={filtered_expressions} />
|
||||||
<section className="padding-small scroll">
|
|
||||||
<ExpressionCard
|
|
||||||
prompt={expression.prompt}
|
|
||||||
categories={categories.map((category) => category.name)}
|
|
||||||
description={expression.description}
|
|
||||||
show_description={revealed}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<section className="navigation-bottom">
|
|
||||||
{revealed ? (
|
|
||||||
<>
|
|
||||||
<DemoteExpressionButton expression_id={expression.id!} />
|
|
||||||
<PromoteExpressionButton expression_id={expression.id!} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="navigation-item bottom text-navigation grow"
|
|
||||||
onClick={() => setRevealed(true)}
|
|
||||||
>
|
|
||||||
Reveal
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
interface ExpressionIdProps {
|
|
||||||
expression_id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PromoteExpressionButton({ expression_id }: ExpressionIdProps) {
|
|
||||||
const expression_set_id = useExpressionSetQueryId();
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
assignExpressionToSet({
|
|
||||||
expression_id,
|
|
||||||
expression_set_id: expression_set_id + 1,
|
|
||||||
});
|
|
||||||
}, [expression_id, expression_set_id]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="navigation-item bottom text-navigation grow"
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<span>Right</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DemoteExpressionButton({ expression_id }: ExpressionIdProps) {
|
|
||||||
const { query, pathname, push } = useRouter();
|
|
||||||
const expression_set_id = useExpressionSetQueryId();
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
if (expression_set_id === 1) {
|
|
||||||
const filter_ids = query["filter-ids"]
|
|
||||||
? `${query["filter-ids"]} ${expression_id}`
|
|
||||||
: `${expression_id}`;
|
|
||||||
push({
|
|
||||||
pathname,
|
|
||||||
query: {
|
|
||||||
...query,
|
|
||||||
"filter-ids": filter_ids,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
assignExpressionToSet({
|
|
||||||
expression_id,
|
|
||||||
expression_set_id: Math.max(1, expression_set_id - 1),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [expression_id, expression_set_id, pathname, push, query]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className="navigation-item bottom text-navigation grow"
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<span>Wrong</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default dynamic(() => Promise.resolve(ExpressionPracticePage), {
|
export default dynamic(() => Promise.resolve(ExpressionPracticePage), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
import { Page } from "../components/Page";
|
import { Page } from "../components";
|
||||||
|
|
||||||
const PageTitle = "Flash Card App";
|
const PageTitle = "Flash Card App";
|
||||||
|
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
import {
|
|
||||||
Category,
|
|
||||||
Expression,
|
|
||||||
ExpressionToCategory,
|
|
||||||
ExpressionToExpressionSet,
|
|
||||||
} from "../model";
|
|
||||||
|
|
||||||
interface GetExpressionsInSetParams {
|
|
||||||
expression_set_id: number;
|
|
||||||
expressions: Expression[];
|
|
||||||
expression_to_expression_set: ExpressionToExpressionSet[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getExpressionsInSet({
|
|
||||||
expression_set_id,
|
|
||||||
expression_to_expression_set,
|
|
||||||
expressions,
|
|
||||||
}: GetExpressionsInSetParams): Expression[] {
|
|
||||||
const expression_ids = expression_to_expression_set
|
|
||||||
.filter((item) => item.expression_set_id === expression_set_id)
|
|
||||||
.map((item) => item.expression_id);
|
|
||||||
return expressions.filter((item) => expression_ids.includes(item.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GetCategoriesInExpressionParams {
|
|
||||||
expression_id: number;
|
|
||||||
categories: Category[];
|
|
||||||
expression_to_category: ExpressionToCategory[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCategoriesInExpression({
|
|
||||||
categories,
|
|
||||||
expression_id,
|
|
||||||
expression_to_category,
|
|
||||||
}: GetCategoriesInExpressionParams) {
|
|
||||||
const category_ids = expression_to_category
|
|
||||||
.filter((item) => item.expression_id === expression_id)
|
|
||||||
.map((item) => item.category_id);
|
|
||||||
return categories.filter((item) => category_ids.includes(item.id));
|
|
||||||
}
|
|
2
src/util/index.ts
Normal file
2
src/util/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./array-utils";
|
||||||
|
export * from "./clamp";
|
@ -1,11 +1,11 @@
|
|||||||
import { Page } from "../../components/Page";
|
import { Page } from "../../components/Page";
|
||||||
|
|
||||||
export interface PageWithErrorProps {
|
export interface ErrorViewProps {
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageWithError({ title, message }: PageWithErrorProps) {
|
export function ErrorView({ title, message }: ErrorViewProps) {
|
||||||
return (
|
return (
|
||||||
<Page title={title}>
|
<Page title={title}>
|
||||||
<div className="page-with-padding">
|
<div className="page-with-padding">
|
1
src/views/ErrorView/index.ts
Normal file
1
src/views/ErrorView/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./ErrorView";
|
45
src/views/ExpressionPracticeView/DemoteExpressionButton.tsx
Normal file
45
src/views/ExpressionPracticeView/DemoteExpressionButton.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useExpressionSetQueryId } from "../../hooks";
|
||||||
|
import { assignExpressionToSet } from "../../model";
|
||||||
|
|
||||||
|
// TODO fix promotion algorithm so it uses a destination expression_set_id
|
||||||
|
|
||||||
|
export interface DemoteExpressionButtonProps {
|
||||||
|
expression_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DemoteExpressionButton({
|
||||||
|
expression_id,
|
||||||
|
}: DemoteExpressionButtonProps) {
|
||||||
|
const { query, pathname, push } = useRouter();
|
||||||
|
const expression_set_id = useExpressionSetQueryId();
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (expression_set_id === 1) {
|
||||||
|
const filter_ids = query["filter-ids"]
|
||||||
|
? `${query["filter-ids"]} ${expression_id}`
|
||||||
|
: `${expression_id}`;
|
||||||
|
push({
|
||||||
|
pathname,
|
||||||
|
query: {
|
||||||
|
...query,
|
||||||
|
"filter-ids": filter_ids,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
assignExpressionToSet({
|
||||||
|
expression_id,
|
||||||
|
expression_set_id: Math.max(1, expression_set_id - 1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [expression_id, expression_set_id, pathname, push, query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="navigation-item bottom text-navigation grow"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<span>Wrong</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,45 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ExpressionCard } from "../../components";
|
||||||
|
import { IndexedExpression, IndexedCategory } from "../../model";
|
||||||
|
import { DemoteExpressionButton } from "./DemoteExpressionButton";
|
||||||
|
import { PromoteExpressionButton } from "./PromoteExpressionButton";
|
||||||
|
|
||||||
|
export interface ExpressionPracticeCardViewProps {
|
||||||
|
expression: IndexedExpression;
|
||||||
|
categories: IndexedCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle internal state here
|
||||||
|
export function ExpressionPracticeCardView({
|
||||||
|
expression,
|
||||||
|
categories,
|
||||||
|
}: ExpressionPracticeCardViewProps) {
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="page-with-bottom-navigation">
|
||||||
|
<section className="padding-small scroll">
|
||||||
|
<ExpressionCard
|
||||||
|
prompt={expression.prompt}
|
||||||
|
categories={categories.map((category) => category.name)}
|
||||||
|
description={expression.description}
|
||||||
|
show_description={revealed}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="navigation-bottom">
|
||||||
|
{revealed ? (
|
||||||
|
<>
|
||||||
|
<DemoteExpressionButton expression_id={expression.id!} />
|
||||||
|
<PromoteExpressionButton expression_id={expression.id!} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="navigation-item bottom text-navigation grow"
|
||||||
|
onClick={() => setRevealed(true)}
|
||||||
|
>
|
||||||
|
Reveal
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
27
src/views/ExpressionPracticeView/ExpressionPracticeView.tsx
Normal file
27
src/views/ExpressionPracticeView/ExpressionPracticeView.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useExpressionCategories } from "../../hooks";
|
||||||
|
import { IndexedExpression } from "../../model";
|
||||||
|
import { sample } from "../../util";
|
||||||
|
import { ExpressionPracticeCardView } from "./ExpressionPracticeCardView";
|
||||||
|
|
||||||
|
export interface ExpressionPracticeViewProps {
|
||||||
|
select_from_expressions: IndexedExpression[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpressionPracticeView({
|
||||||
|
select_from_expressions: expressions,
|
||||||
|
}: ExpressionPracticeViewProps) {
|
||||||
|
const expression = useMemo(() => sample(expressions), [expressions]);
|
||||||
|
const categories = useExpressionCategories(expression.id!);
|
||||||
|
if (!categories) return null;
|
||||||
|
|
||||||
|
// Delegate internal interaction state to next component so it resets
|
||||||
|
// on an expression switch
|
||||||
|
return (
|
||||||
|
<ExpressionPracticeCardView
|
||||||
|
key={expression.id}
|
||||||
|
expression={expression}
|
||||||
|
categories={categories}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
30
src/views/ExpressionPracticeView/PromoteExpressionButton.tsx
Normal file
30
src/views/ExpressionPracticeView/PromoteExpressionButton.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useExpressionSetQueryId } from "../../hooks";
|
||||||
|
import { assignExpressionToSet } from "../../model";
|
||||||
|
|
||||||
|
// TODO fix promotion algorithm so it uses a destination expression_set_id
|
||||||
|
|
||||||
|
export interface PromoteExpressionButtonProps {
|
||||||
|
expression_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PromoteExpressionButton({
|
||||||
|
expression_id,
|
||||||
|
}: PromoteExpressionButtonProps) {
|
||||||
|
const expression_set_id = useExpressionSetQueryId();
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
assignExpressionToSet({
|
||||||
|
expression_id,
|
||||||
|
expression_set_id: expression_set_id + 1,
|
||||||
|
});
|
||||||
|
}, [expression_id, expression_set_id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="navigation-item bottom text-navigation grow"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<span>Right</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
1
src/views/ExpressionPracticeView/index.ts
Normal file
1
src/views/ExpressionPracticeView/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./ExpressionPracticeView";
|
@ -0,0 +1,53 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ExpressionSetInfo } from "../../components";
|
||||||
|
import { IndexedExpressionSet } from "../../model";
|
||||||
|
|
||||||
|
export interface ExpressionSetDetailsViewProps {
|
||||||
|
expression_set: IndexedExpressionSet;
|
||||||
|
expression_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpressionSetDetailsView({
|
||||||
|
expression_set,
|
||||||
|
expression_count,
|
||||||
|
}: ExpressionSetDetailsViewProps) {
|
||||||
|
if (!expression_count) {
|
||||||
|
return (
|
||||||
|
<div className="page-with-padding scroll">
|
||||||
|
<ExpressionSetInfo
|
||||||
|
id={expression_set.id!}
|
||||||
|
name={expression_set.name}
|
||||||
|
description={expression_set.description}
|
||||||
|
expression_count={expression_count}
|
||||||
|
/>
|
||||||
|
<p className="text-details">No expressions left in this set.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-with-bottom-navigation">
|
||||||
|
<section className="padding-small scroll">
|
||||||
|
<ExpressionSetInfo
|
||||||
|
id={expression_set.id!}
|
||||||
|
name={expression_set.name}
|
||||||
|
description={expression_set.description}
|
||||||
|
expression_count={expression_count}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<section className="navigation-bottom">
|
||||||
|
<Link
|
||||||
|
href={{
|
||||||
|
pathname: "/expression-sets/practice",
|
||||||
|
query: { "set-id": expression_set.id },
|
||||||
|
}}
|
||||||
|
passHref
|
||||||
|
>
|
||||||
|
<a className="navigation-item bottom text-navigation grow">
|
||||||
|
<span>Practice this set</span>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
1
src/views/ExpressionSetDetailsView/index.ts
Normal file
1
src/views/ExpressionSetDetailsView/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./ExpressionSetDetailsView";
|
30
src/views/ExpressionSetListView/ExpressionSetLink.tsx
Normal file
30
src/views/ExpressionSetListView/ExpressionSetLink.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { ExpressionSetCard } from "../../components";
|
||||||
|
import { useExpressionsInSet } from "../../hooks";
|
||||||
|
import { IndexedExpressionSet } from "../../model";
|
||||||
|
|
||||||
|
export function ExpressionSetLink({
|
||||||
|
id,
|
||||||
|
description,
|
||||||
|
name,
|
||||||
|
}: IndexedExpressionSet) {
|
||||||
|
const expressions = useExpressionsInSet(id!) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={{
|
||||||
|
pathname: "/expression-sets/details",
|
||||||
|
query: { "set-id": id },
|
||||||
|
}}
|
||||||
|
passHref
|
||||||
|
>
|
||||||
|
<a>
|
||||||
|
<ExpressionSetCard
|
||||||
|
name={name}
|
||||||
|
description={description}
|
||||||
|
expression_count={expressions.length}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
23
src/views/ExpressionSetListView/ExpressionSetListView.tsx
Normal file
23
src/views/ExpressionSetListView/ExpressionSetListView.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { IndexedExpressionSet } from "../../model";
|
||||||
|
import { ExpressionSetLink } from "./ExpressionSetLink";
|
||||||
|
|
||||||
|
export interface ExpressionSetListViewProps {
|
||||||
|
expression_sets: IndexedExpressionSet[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpressionSetListView({
|
||||||
|
expression_sets,
|
||||||
|
}: ExpressionSetListViewProps) {
|
||||||
|
return (
|
||||||
|
<div className="page-with-padding content-list scroll">
|
||||||
|
{expression_sets.map(({ id, name, description }) => (
|
||||||
|
<ExpressionSetLink
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
description={description}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
1
src/views/ExpressionSetListView/index.ts
Normal file
1
src/views/ExpressionSetListView/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./ExpressionSetListView";
|
@ -1 +0,0 @@
|
|||||||
export * from "./PageWithError";
|
|
4
src/views/index.ts
Normal file
4
src/views/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./ErrorView";
|
||||||
|
export * from "./ExpressionPracticeView";
|
||||||
|
export * from "./ExpressionSetDetailsView";
|
||||||
|
export * from "./ExpressionSetListView";
|
Loading…
Reference in New Issue
Block a user