Skip to content

Commit 3828b3b

Browse files
authored
Merge pull request #1332 from isaacphysics/redesign/subject-landing-pages
Implement subject landing pages
2 parents 99f43fd + 192b126 commit 3828b3b

32 files changed

+652
-109
lines changed
Lines changed: 6 additions & 0 deletions
Loading
Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 5 additions & 0 deletions
Loading
Lines changed: 5 additions & 0 deletions
Loading
Lines changed: 11 additions & 0 deletions
Loading

src/app/components/elements/PageTitle.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export const PageTitle = ({currentPageTitle, subTitle, description, disallowLaTe
120120
}));
121121
}
122122

123-
return <h1 id="main-heading" tabIndex={-1} ref={headerRef} className={classNames("h-title h-secondary d-sm-flex", {"align-items-center py-4": isPhy}, className)}>
123+
return <h1 id="main-heading" tabIndex={-1} ref={headerRef} className={classNames("h-title h-secondary d-sm-flex", {"align-items-center py-4 mb-0": isPhy}, className)}>
124124
<div className="me-auto">
125125
<div className="d-sm-flex align-items-center">
126126
{icon && (

src/app/components/elements/cards/EventCard.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@ import {Link} from "react-router-dom";
44
import {AugmentedEvent} from "../../../../IsaacAppTypes";
55
import {DateString} from "../DateString";
66
import {formatEventCardDate, siteSpecific} from "../../../services";
7-
import { Card, CardImg, CardBody, CardTitle, Badge, CardText } from "reactstrap";
7+
import { Card, CardImg, CardBody, CardTitle, Badge, CardText, CardProps } from "reactstrap";
88
import { Spacer } from "../Spacer";
9+
import classNames from "classnames";
910

10-
export const PhysicsEventCard = ({event}: {event: AugmentedEvent}) => {
11+
export const PhysicsEventCard = ({event, ...rest}: {event: AugmentedEvent} & CardProps) => {
1112
const {id, title, subtitle, eventThumbnail, location, date} = event;
1213

1314
const isVirtualEvent = event.tags?.includes("virtual");
1415
const isTeacherEvent = event.tags?.includes("teacher") && !event.tags?.includes("student");
1516
const isStudentEvent = event.tags?.includes("student") && !event.tags?.includes("teacher");
1617

17-
return <Card className="pod">
18+
return <Card {...rest} className={classNames("pod", rest.className)}>
1819
{eventThumbnail &&
1920
<a className={"pod-img event-pod-img"} href={`/events/${id}`}>
2021
<CardImg aria-hidden={true} top src={eventThumbnail.src} alt={"" /* Decorative image, should be hidden from screenreaders */} />

src/app/components/elements/list-groups/AbstractListViewItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export const AbstractListViewItem = ({icon, title, subject, subtitle, breadcrumb
146146

147147
return <ListGroupItem {...rest} className={classNames("content-summary-item", rest.className)} data-bs-theme={subject}>
148148
{url ?
149-
<Link to={{pathname: url}}> {cardBody} </Link> :
149+
<Link to={{pathname: url}} className="w-100"> {cardBody} </Link> :
150150
<div> {cardBody} </div>}
151151
</ListGroupItem>;
152152
};

src/app/components/elements/list-groups/ListView.tsx

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import React from "react";
2-
import { AbstractListViewItem, ListViewTagProps } from "./AbstractListViewItem";
2+
import { AbstractListViewItem, AbstractListViewItemProps, ListViewTagProps } from "./AbstractListViewItem";
33
import { ShortcutResponse, ViewingContext } from "../../../../IsaacAppTypes";
44
import { determineAudienceViews } from "../../../services/userViewingContext";
55
import { DOCUMENT_TYPE, documentTypePathPrefix, SEARCH_RESULT_TYPE, Subject, TAG_ID, TAG_LEVEL, tags } from "../../../services";
6-
import { ListGroup, ListGroupItemProps } from "reactstrap";
6+
import { ListGroup, ListGroupItem, ListGroupItemProps, ListGroupProps } from "reactstrap";
77
import { TitleIconProps } from "../PageTitle";
88
import { AffixButton } from "../AffixButton";
99
import { QuizSummaryDTO } from "../../../../IsaacApiTypes";
1010
import { Link } from "react-router-dom";
1111
import { showQuizSettingModal, useAppDispatch } from "../../../state";
12+
import classNames from "classnames";
1213

1314
export interface ListViewCardProps extends ListGroupItemProps {
1415
item: ShortcutResponse;
@@ -30,13 +31,17 @@ export const ListViewCard = ({item, icon, subject, linkTags, ...rest}: ListViewC
3031
/>;
3132
};
3233

33-
export const QuestionListViewItem = ({item, ...rest} : {item: ShortcutResponse}) => {
34+
type QuestionListViewItemProps = {item: ShortcutResponse} & Omit<AbstractListViewItemProps, "icon" | "title" | "subject" | "tags" | "supersededBy" | "subtitle" | "breadcrumb" | "status" | "url" | "audienceViews">;
35+
36+
export const QuestionListViewItem = (props : QuestionListViewItemProps) => {
37+
const { item, ...rest } = props;
3438
const breadcrumb = tags.getByIdsAsHierarchy((item.tags || []) as TAG_ID[]).map(tag => tag.title);
3539
const audienceViews: ViewingContext[] = determineAudienceViews(item.audience);
3640
const itemSubject = tags.getSpecifiedTag(TAG_LEVEL.subject, item.tags as TAG_ID[])?.id as Subject;
3741
const url = `/${documentTypePathPrefix[DOCUMENT_TYPE.QUESTION]}/${item.id}`;
3842

3943
return <AbstractListViewItem
44+
{...rest}
4045
icon={{type: "hex", icon: "list-icon-question", size: "sm"}}
4146
title={item.title ?? ""}
4247
subject={itemSubject}
@@ -47,7 +52,6 @@ export const QuestionListViewItem = ({item, ...rest} : {item: ShortcutResponse})
4752
status={item.state}
4853
url={url}
4954
audienceViews={audienceViews}
50-
{...rest}
5155
/>;
5256
};
5357

@@ -157,9 +161,10 @@ export const GenericListViewItem = ({item, ...rest}: {item: ShortcutResponse}) =
157161
/>;
158162
};
159163

160-
export const ListViewCards = ({cards}: {cards: ListViewCardProps[]}) => {
161-
return <ListGroup className="list-view-card-container link-list list-group-links p-0 m-0 flex-row row-cols-1 row-cols-lg-2 row">
162-
{cards.map((card, index) => <ListViewCard key={index} {...card}/>)}
164+
export const ListViewCards = (props: {cards: (ListViewCardProps | null)[]} & {showBlanks?: boolean} & ListGroupProps) => {
165+
const { cards, showBlanks, ...rest } = props;
166+
return <ListGroup {...rest} className={classNames("list-view-card-container link-list list-group-links p-0 m-0 flex-row row-cols-1 row-cols-lg-2 row", rest.className)}>
167+
{cards.map((card, index) => card ? <ListViewCard key={index} {...card}/> : (showBlanks ? <ListGroupItem key={index}/> : null))}
163168
</ListGroup>;
164169
};
165170

src/app/components/elements/modals/ActiveModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const ActiveModal = ({activeModal}: ActiveModalProps) => {
2424
};
2525
});
2626

27-
return <Modal data-testid={"active-modal"} toggle={toggle} isOpen={true} size={(activeModal && activeModal.size) || "lg"} centered={activeModal?.centered}>
27+
return <Modal data-testid={"active-modal"} toggle={toggle} isOpen={true} size={(activeModal && activeModal.size) || "lg"} centered={activeModal?.centered} data-bs-theme="neutral">
2828
{activeModal && <React.Fragment>
2929
{<ModalHeader
3030
data-testid={"modal-header"}

src/app/components/elements/modals/QuestionSearchModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export const QuestionSearchModal = (
169169
type="button"
170170
value={siteSpecific("Add Selections to Gameboard", "Add selections to quiz")}
171171
disabled={isEqual(new Set(modalQuestions.selectedQuestions.keys()), new Set(currentQuestions.selectedQuestions.keys()))}
172-
className={classNames("btn w-100 border-0", siteSpecific("btn-keyline", "btn-secondary"))}
172+
className={classNames("btn w-100", siteSpecific("btn-keyline", "btn-secondary border-0"))}
173173
onClick={() => {
174174
undoStack.push({questionOrder: currentQuestions.questionOrder, selectedQuestions: currentQuestions.selectedQuestions});
175175
currentQuestions.setSelectedQuestions(modalQuestions.selectedQuestions);
Lines changed: 184 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,123 @@
1-
import React from "react";
1+
import React, { useCallback, useEffect } from "react";
22
import { RouteComponentProps, withRouter } from "react-router";
3-
import { Container } from "reactstrap";
3+
import { Card, Col, Container, Row } from "reactstrap";
44
import { TitleAndBreadcrumb } from "../elements/TitleAndBreadcrumb";
5-
import { getHumanContext, useUrlPageTheme } from "../../services/pageContext";
5+
import { getHumanContext, isDefinedContext, isSingleStageContext, useUrlPageTheme } from "../../services/pageContext";
6+
import { ListView, ListViewCards } from "../elements/list-groups/ListView";
7+
import { getBooksForContext, getLandingPageCardsForContext } from "./subjectLandingPageComponents";
8+
import { above, below, DOCUMENT_TYPE, EventStatusFilter, EventTypeFilter, STAGE, useDeviceSize } from "../../services";
9+
import { PageContextState } from "../../../IsaacAppTypes";
10+
import { PhyHexIcon } from "../elements/svg/PhyHexIcon";
11+
import { Link } from "react-router-dom";
12+
import { ShowLoadingQuery } from "../handlers/ShowLoadingQuery";
13+
import { searchQuestions, useAppDispatch, useAppSelector, useGetNewsPodListQuery, useLazyGetEventsQuery } from "../../state";
14+
import { EventCard } from "../elements/cards/EventCard";
15+
import { debounce } from "lodash";
16+
import { Loading } from "../handlers/IsaacSpinner";
17+
import classNames from "classnames";
18+
import { NewsCard } from "../elements/cards/NewsCard";
19+
20+
const handleGetDifferentQuestion = () => {
21+
// TODO
22+
};
23+
24+
const RandomQuestionBanner = ({context}: {context?: PageContextState}) => {
25+
const deviceSize = useDeviceSize();
26+
const dispatch = useAppDispatch();
27+
28+
const searchDebounce = useCallback(debounce(() => {
29+
dispatch(searchQuestions({
30+
searchString: "",
31+
tags: "",
32+
fields: undefined,
33+
subjects: context?.subject,
34+
topics: undefined,
35+
books: undefined,
36+
stages: context?.stage?.map(s => s === "11_14" ? "year_7_and_8,year_9" : s).join(','),
37+
difficulties: undefined,
38+
examBoards: undefined,
39+
questionCategories: "problem_solving,book",
40+
statuses: undefined,
41+
fasttrack: false,
42+
startIndex: undefined,
43+
limit: 1
44+
}));
45+
}), [dispatch, context]);
46+
47+
const {results: questions} = useAppSelector((state) => state && state.questionSearchResult) || {};
48+
49+
useEffect(() => {
50+
searchDebounce();
51+
}, [searchDebounce]);
52+
53+
if (!context || !isDefinedContext(context) || !isSingleStageContext(context)) {
54+
return null;
55+
}
56+
57+
const question = questions?.[0];
58+
59+
return <div className="py-4 container-override random-question-panel">
60+
<Row className="my-3">
61+
<Col lg={7}>
62+
<div className="d-flex justify-content-between align-items-center">
63+
<h4 className="m-0">Try a random question!</h4>
64+
<button className="btn btn-link invert-underline d-flex align-items-center gap-2" onClick={handleGetDifferentQuestion}>
65+
Get a different question
66+
<i className="icon icon-refresh icon-color-black"/>
67+
</button>
68+
</div>
69+
</Col>
70+
</Row>
71+
<Row>
72+
<Col lg={7}>
73+
<Card>
74+
{question
75+
? <ListView items={[{
76+
type: DOCUMENT_TYPE.QUESTION,
77+
title: question.title,
78+
tags: question.tags,
79+
id: question.id,
80+
audience: question.audience,
81+
}]}/>
82+
: <Loading />}
83+
</Card>
84+
</Col>
85+
<Col lg={5} className="ps-lg-5 m-3 m-lg-0">
86+
<div className="d-flex align-items-center">
87+
{above['lg'](deviceSize) && <PhyHexIcon className="w-min-content" icon={"page-icon-concept"} />}
88+
<h5 className="m-0">Explore related concepts:</h5>
89+
</div>
90+
<div className="d-flex flex-wrap gap-2 mt-3">
91+
{/* TODO: replace this with "recommended content" or similar */}
92+
{/* {question?.relatedContent.filter(rc => rc.type === "isaacConceptPage").slice(0, 5).map((rc, i) => (
93+
<Link to={`/concepts/${rc.id}`} key={i}>
94+
<AffixButton key={i} color="keyline" className="px-3 py-2" affix={{
95+
affix: "icon-lightbulb",
96+
position: "prefix",
97+
type: "icon"
98+
}}>
99+
{rc.title}
100+
</AffixButton>
101+
</Link>
102+
))} */}
103+
</div>
104+
</Col>
105+
</Row>
106+
</div>;
107+
};
6108

7109
export const SubjectLandingPage = withRouter((props: RouteComponentProps) => {
8110
const pageContext = useUrlPageTheme();
111+
const deviceSize = useDeviceSize();
112+
113+
const [getEventsList, eventsQuery] = useLazyGetEventsQuery();
114+
useEffect(() => {
115+
getEventsList({startIndex: 0, limit: 10, typeFilter: EventTypeFilter["All events"], statusFilter: EventStatusFilter["Upcoming events"], stageFilter: [STAGE.ALL]});
116+
}, []);
117+
118+
const books = getBooksForContext(pageContext);
119+
// TODO: are we going to make subject-specific news?
120+
const {data: news} = useGetNewsPodListQuery({subject: "physics"});
9121

10122
return <Container data-bs-theme={pageContext?.subject}>
11123
<TitleAndBreadcrumb
@@ -16,6 +128,74 @@ export const SubjectLandingPage = withRouter((props: RouteComponentProps) => {
16128
icon: `/assets/phy/icons/redesign/subject-${pageContext.subject}.svg`
17129
} : undefined}
18130
/>
19-
<div className="mt-5">This is a subject landing page for {getHumanContext(pageContext)}!</div>
131+
132+
<RandomQuestionBanner context={pageContext} />
133+
134+
<ListViewCards cards={getLandingPageCardsForContext(pageContext, below['md'](deviceSize))} showBlanks={!below['md'](deviceSize)} className="my-5" />
135+
136+
<Row className={classNames("mt-5 py-4 row-cols-1 row-cols-md-2")}>
137+
<div className="d-flex flex-column mt-3">
138+
{/* if there are books, display books. otherwise, display news */}
139+
{books.length > 0
140+
? <>
141+
<div className="d-flex mb-3 align-items-center gap-4 white-space-pre">
142+
<h4 className="m-0">{getHumanContext(pageContext)} books</h4>
143+
<div className="section-divider-bold"/>
144+
</div>
145+
<Col className="d-flex flex-column">
146+
{books.slice(0, 4).map((book, index) => <Link key={index} to={book.path} className="book-container d-flex p-2 gap-3">
147+
<div className="book-image-container">
148+
<img src={book.image} alt={book.title} className="h-100"/>
149+
</div>
150+
<div className="d-flex flex-column">
151+
<h5 className="pt-2 pt-2 pb-1 m-0">{book.title}</h5>
152+
<div className="section-divider"/>
153+
<span className="text-decoration-none">
154+
This is some explanatory text about the book. It could be a brief description of the book, or a list of topics covered.
155+
</span>
156+
</div>
157+
</Link>)}
158+
</Col>
159+
</>
160+
: <>
161+
<div className="d-flex flex-column">
162+
<div className="d-flex mb-3 align-items-center gap-4 white-space-pre">
163+
<h4>News & Features</h4>
164+
<div className="section-divider-bold"/>
165+
</div>
166+
{news && <Row className="h-100">
167+
{news.slice(0, 2).map(newsItem => <Col xs={12} key={newsItem.id}>
168+
<NewsCard newsItem={newsItem} className="force-horizontal p-2" />
169+
</Col>)}
170+
</Row>}
171+
</div>
172+
</>
173+
}
174+
</div>
175+
<div className="d-flex flex-column mt-3">
176+
<div className="d-flex mb-3 align-items-center gap-4 white-space-pre">
177+
<h4 className="m-0">Events</h4>
178+
<div className="section-divider-bold"/>
179+
</div>
180+
<ShowLoadingQuery
181+
query={eventsQuery}
182+
defaultErrorTitle={"Error loading events list"}
183+
thenRender={({events}) => {
184+
// TODO: filter by audience, once that data is available
185+
const relevantEvents = events.filter(event => pageContext?.subject && event.tags?.includes(pageContext.subject)).slice(0, 2);
186+
return <Row className="h-100">
187+
{relevantEvents.length
188+
? relevantEvents.map((event, i) =>
189+
<Col key={i}>
190+
{event && <EventCard event={event} className="force-horizontal p-2" />}
191+
</Col>
192+
)
193+
: <Col className="pt-3 pb-5">No events found for {getHumanContext(pageContext)}. Check back soon!</Col>
194+
}
195+
</Row>;
196+
}}
197+
/>
198+
</div>
199+
</Row>
20200
</Container>;
21201
});

0 commit comments

Comments
 (0)