diff --git a/localtypings/pxtarget.d.ts b/localtypings/pxtarget.d.ts index d4d471eeb2ab..db2c71941f7b 100644 --- a/localtypings/pxtarget.d.ts +++ b/localtypings/pxtarget.d.ts @@ -33,6 +33,7 @@ declare namespace pxt { electronManifest?: pxt.electron.ElectronManifest; profileNotification?: ProfileNotification; kiosk?: KioskConfig; + teachertool?: TeacherToolConfig; } interface PackagesConfig { @@ -88,6 +89,15 @@ declare namespace pxt { highScoreMode: string; } + interface TeacherToolConfig { + carousels?: TeacherToolCarouselConfig[]; + } + + interface TeacherToolCarouselConfig { + title: string; + cardsUrl: string; + } + interface AppTarget { id: string; // has to match ^[a-z]+$; used in URLs and domain names platformid?: string; // eg "codal"; used when search for gh packages ("for PXT/codal"); defaults to id diff --git a/teachertool/public/index.html b/teachertool/public/index.html index 45401d391bd2..e35236a327af 100644 --- a/teachertool/public/index.html +++ b/teachertool/public/index.html @@ -33,8 +33,6 @@ - -
diff --git a/teachertool/src/App.tsx b/teachertool/src/App.tsx index f9b7ca1809a1..e32570963827 100644 --- a/teachertool/src/App.tsx +++ b/teachertool/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useContext, useState } from "react"; import { AppStateContext, AppStateReady } from "./state/appStateContext"; -import { usePromise } from "./hooks"; +import { usePromise } from "./hooks/usePromise"; import { makeToast } from "./utils"; import * as Actions from "./state/actions"; import { downloadTargetConfigAsync } from "./services/backendRequests"; diff --git a/teachertool/src/components/HomeScreen.tsx b/teachertool/src/components/HomeScreen.tsx index b9dedee72399..0dd9699fcc09 100644 --- a/teachertool/src/components/HomeScreen.tsx +++ b/teachertool/src/components/HomeScreen.tsx @@ -1,17 +1,22 @@ +import "swiper/scss"; +import "swiper/scss/navigation"; +import "swiper/scss/mousewheel"; + import * as React from "react"; +import { useContext, useState } from "react"; import css from "./styling/HomeScreen.module.scss"; import { Link } from "react-common/components/controls/Link"; import { Button } from "react-common/components/controls/Button"; import { classList } from "react-common/components/util"; import { showModal } from "../transforms/showModal"; import { resetRubricAsync } from "../transforms/resetRubricAsync"; +import { loadRubricAsync } from "../transforms/loadRubricAsync"; import { Constants, Strings, Ticks } from "../constants"; import { Swiper, SwiperSlide } from "swiper/react"; import { Mousewheel, Navigation } from "swiper"; - -import "swiper/scss"; -import "swiper/scss/navigation"; -import "swiper/scss/mousewheel"; +import { AppStateContext } from "../state/appStateContext"; +import { CarouselCardSet, RequestStatus, CarouselRubricResourceCard } from "../types"; +import { useJsonDocRequest } from "../hooks/useJsonDocRequest"; const Welcome: React.FC = () => { return ( @@ -27,14 +32,14 @@ const Welcome: React.FC = () => { ); }; -interface CardProps { +interface IconCardProps { title: string; className?: string; icon?: string; onClick: () => void; } -const Card: React.FC = ({ title, className, icon, onClick }) => { +const IconCard: React.FC = ({ title, className, icon, onClick }) => { return (
+
+ ); +}; + +interface RubricResourceCardProps { + cardTitle: string; + imageUrl: string; + rubricUrl: string; +} + +const RubricResourceCard: React.FC = ({ cardTitle, imageUrl, rubricUrl }) => { + const onRubricClickedAsync = async () => { + pxt.tickEvent(Ticks.LoadRubric, { rubricUrl }); + await loadRubricAsync(rubricUrl); + }; + return ( +
+ +
+ ); +}; + interface CarouselProps extends React.PropsWithChildren<{}> {} const Carousel: React.FC = ({ children }) => { @@ -65,7 +124,9 @@ const Carousel: React.FC = ({ children }) => { allowTouchMove={true} slidesOffsetBefore={32} navigation={true} - mousewheel={true} + mousewheel={{ + forceToAxis: true, + }} modules={[Navigation, Mousewheel]} className={css.swiperCarousel} > @@ -95,13 +156,13 @@ const GetStarted: React.FC = () => {

{lf("Get Started")}

- - { ); }; +interface DataCarouselProps { + title: string; + cardsUrl: string; +} + +const CardCarousel: React.FC = ({ title, cardsUrl }) => { + const [cardSet, setCardSet] = useState(); + const [fetchStatus, setFetchStatus] = useState(); + + useJsonDocRequest(cardsUrl, setFetchStatus, setCardSet); + + return ( + <> +
+
+

{title}

+
+ {(fetchStatus === "loading" || fetchStatus === "error") && ( + + + + + )} + {fetchStatus === "success" && ( + + {cardSet?.cards.map((card, index) => { + switch (card.cardType) { + case "rubric-resource": { + const rubricCard = card as CarouselRubricResourceCard; + return ( + + ); + } + default: + return ; + } + })} + + )} +
+ + ); +}; + +const CardCarousels: React.FC = () => { + const { state } = useContext(AppStateContext); + const { targetConfig } = state; + const teachertool = targetConfig?.teachertool; + const carousels = teachertool?.carousels; + + return ( + <> + {carousels?.map((carousel, index) => ( + + ))} + + ); +}; + export const HomeScreen: React.FC = () => { return (
+
); }; diff --git a/teachertool/src/components/styling/HomeScreen.module.scss b/teachertool/src/components/styling/HomeScreen.module.scss index a22f863cf98a..f14b72564f15 100644 --- a/teachertool/src/components/styling/HomeScreen.module.scss +++ b/teachertool/src/components/styling/HomeScreen.module.scss @@ -47,6 +47,7 @@ div.page { padding: 0; min-width: 17.5rem; min-height: 12rem; + overflow: hidden; div.cardDiv { display: flex; @@ -104,6 +105,38 @@ div.page { outline-color: var(--pxt-button-primary-foreground); } } + &.loadingGradient { + background: linear-gradient(45deg, var(--pxt-content-background), var(--pxt-content-foreground)); + opacity: 0.2; + background-size: 400% 200%; + animation: loading 3s ease infinite; + + @keyframes loading { + 0% { + background-position: 0 0; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0 0; + } + } + &.loadingGradientDelay { + animation-delay: 0.3s; + } + } + &.rubricResource { + background-size: 100% 100%; + background-repeat: no-repeat; + grid-area: 1 / 1 / 2 / 5; + border: 1px solid var(--pxt-content-background); + + .rubricResourceCardTitle { + background-color: rgba(228, 231, 241, 0.9); + color: var(--pxt-page-foreground); + } + } } .swiperCarousel { @@ -146,7 +179,7 @@ div.page { transform: scale(1.1); } } - + &::after { transform: scale(0.9); color: var(--pxt-headerbar-background); diff --git a/teachertool/src/components/styling/SplitPane.module.scss b/teachertool/src/components/styling/SplitPane.module.scss index 92ac6878c384..6c0629eda7cc 100644 --- a/teachertool/src/components/styling/SplitPane.module.scss +++ b/teachertool/src/components/styling/SplitPane.module.scss @@ -31,6 +31,7 @@ width: 5px; height: 100%; cursor: ew-resize; + z-index: 1; } .splitter-vertical-inner:hover { diff --git a/teachertool/src/constants.ts b/teachertool/src/constants.ts index dd07dde8381d..b7c1bf64365d 100644 --- a/teachertool/src/constants.ts +++ b/teachertool/src/constants.ts @@ -1,5 +1,6 @@ export namespace Strings { - export const ConfirmReplaceRubric = lf("This will replace your current rubric. Continue?"); + export const ErrorLoadingRubricMsg = lf("That wasn't a valid rubric."); + export const ConfirmReplaceRubricMsg = lf("This will replace your current rubric. Continue?"); export const UntitledProject = lf("Untitled Project"); export const UntitledRubric = lf("Untitled Rubric"); export const NewRubric = lf("New Rubric"); @@ -22,6 +23,7 @@ export namespace Ticks { export const NewRubric = "teachertool.newrubric"; export const ImportRubric = "teachertool.importrubric"; export const ExportRubric = "teachertool.exportrubric"; + export const LoadRubric = "teachertool.loadrubric"; export const Evaluate = "teachertool.evaluate"; export const Autorun = "teachertool.autorun"; export const AddCriteria = "teachertool.addcriteria"; diff --git a/teachertool/src/hooks/index.ts b/teachertool/src/hooks/index.ts deleted file mode 100644 index 63adb117dfcf..000000000000 --- a/teachertool/src/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { usePromise } from "./usePromise"; diff --git a/teachertool/src/hooks/useJsonDocRequest.ts b/teachertool/src/hooks/useJsonDocRequest.ts new file mode 100644 index 000000000000..3031e6bd10ab --- /dev/null +++ b/teachertool/src/hooks/useJsonDocRequest.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +import { RequestStatus } from "../types"; +import { fetchJsonDocAsync } from "../services/backendRequests"; + +export function useJsonDocRequest( + url: string, + statusCb: (status: RequestStatus) => void, + jsonCb: (data: T) => void +) { + const [status, setStatus] = useState(); + + useEffect(() => { + if (!status) { + setStatus("loading"); + statusCb("loading"); + Promise.resolve().then(async () => { + const json = await fetchJsonDocAsync(url); + if (!json) { + setStatus("error"); + statusCb("error"); + } else { + setStatus("success"); + statusCb("success"); + jsonCb(json as T); + } + }); + } + }, []); +} diff --git a/teachertool/src/services/backendRequests.ts b/teachertool/src/services/backendRequests.ts index 8f43b05273d9..2e46251f9c03 100644 --- a/teachertool/src/services/backendRequests.ts +++ b/teachertool/src/services/backendRequests.ts @@ -2,6 +2,21 @@ import { stateAndDispatch } from "../state"; import { ErrorCode } from "../types/errorCode"; import { logError } from "./loggingService"; +export async function fetchJsonDocAsync(url: string): Promise { + try { + // TODO: Prepend CDN origin if not localhost + const response = await fetch(url); + if (!response.ok) { + throw new Error("Unable to fetch the json file from CDN"); + } else { + const json = await response.json(); + return json; + } + } catch (e) { + logError(ErrorCode.fetchJsonDocAsync, e); + } +} + export async function getProjectTextAsync(projectId: string): Promise { try { const projectTextUrl = `${pxt.Cloud.apiRoot}/${projectId}/text`; diff --git a/teachertool/src/transforms/loadRubricAsync.ts b/teachertool/src/transforms/loadRubricAsync.ts new file mode 100644 index 000000000000..bdb71ad3015a --- /dev/null +++ b/teachertool/src/transforms/loadRubricAsync.ts @@ -0,0 +1,32 @@ +import { Strings } from "../constants"; +import { fetchJsonDocAsync } from "../services/backendRequests"; +import { verifyRubricIntegrity } from "../state/helpers"; +import { Rubric } from "../types/rubric"; +import { makeToast } from "../utils"; +import { confirmAsync } from "./confirmAsync"; +import { setActiveTab } from "./setActiveTab"; +import { setRubric } from "./setRubric"; +import { showToast } from "./showToast"; + +export async function loadRubricAsync(rubricUrl: string) { + if (!(await confirmAsync(Strings.ConfirmReplaceRubricMsg))) { + return; + } + + const json = await fetchJsonDocAsync(rubricUrl); + + if (!json) { + showToast(makeToast("error", Strings.ErrorLoadingRubricMsg)); + return; + } + + const { valid } = verifyRubricIntegrity(json); + + if (!valid) { + showToast(makeToast("error", Strings.ErrorLoadingRubricMsg)); + return; + } + + setRubric(json); + setActiveTab("rubric"); +} diff --git a/teachertool/src/transforms/resetRubricAsync.ts b/teachertool/src/transforms/resetRubricAsync.ts index 9440cd16a6f6..216ff3943aa8 100644 --- a/teachertool/src/transforms/resetRubricAsync.ts +++ b/teachertool/src/transforms/resetRubricAsync.ts @@ -10,7 +10,7 @@ export async function resetRubricAsync() { const { state: teachertool, dispatch } = stateAndDispatch(); if (isRubricLoaded(teachertool)) { - if (!(await confirmAsync(Strings.ConfirmReplaceRubric))) { + if (!(await confirmAsync(Strings.ConfirmReplaceRubricMsg))) { return; } } diff --git a/teachertool/src/types/errorCode.ts b/teachertool/src/types/errorCode.ts index af9acce2631e..a38cf7d840a2 100644 --- a/teachertool/src/types/errorCode.ts +++ b/teachertool/src/types/errorCode.ts @@ -16,4 +16,6 @@ export enum ErrorCode { localStorageReadError = "localStorageReadError", localStorageWriteError = "localStorageWriteError", validatorPlansNotFound = "validatorPlansNotFound", + fetchRequestFailed = "fetchRequestFailed", + fetchJsonDocAsync = "fetchJsonDocAsync", } diff --git a/teachertool/src/types/index.ts b/teachertool/src/types/index.ts index 6398631e41a6..8d2662534b76 100644 --- a/teachertool/src/types/index.ts +++ b/teachertool/src/types/index.ts @@ -24,3 +24,21 @@ export type ToastWithId = Toast & { export type ModalType = "catalog-display" | "import-rubric"; export type TabName = "home" | "rubric" | "results"; + +// Rubric Card types that can be appear in the carousel +export type CarouselCard = { + cardType: string; +}; + +export type CarouselRubricResourceCard = CarouselCard & { + cardType: "rubric-card"; + cardTitle: string; + imageUrl: string; + rubricUrl: string; +}; + +export type CarouselCardSet = { + cards: CarouselCard[]; +}; + +export type RequestStatus = "init" | "loading" | "error" | "success"; diff --git a/teachertool/tsconfig.json b/teachertool/tsconfig.json index 41dc635c3ac0..a6492c66a961 100644 --- a/teachertool/tsconfig.json +++ b/teachertool/tsconfig.json @@ -15,7 +15,10 @@ "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "types": [ + "../localtypings/pxtarget.d.ts" + ] }, "include": ["src"] }